feat: apps view kanban + drag-and-drop across all columns; done cards no strikethrough

This commit is contained in:
posimai 2026-03-22 22:47:23 +09:00
parent 022a934785
commit 597d3dc997
1 changed files with 81 additions and 73 deletions

View File

@ -268,12 +268,16 @@
.task-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .task-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.task-done { opacity: 0.45; } .task-done { opacity: 0.45; }
.task-done:hover { opacity: 0.7; } .task-done:hover { opacity: 0.7; }
.task-card[draggable] { cursor: grab; }
.task-card[draggable]:active { cursor: grabbing; }
.task-card.dragging { opacity: 0.3; transform: rotate(1.5deg); box-shadow: 0 8px 24px rgba(0,0,0,0.35); pointer-events: none; }
.col.drag-over { outline: 2px dashed color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: -4px; background: color-mix(in srgb, var(--accent) 6%, transparent); border-radius: 10px; }
.task-app-chip { .task-app-chip {
font-size: 10px; font-weight: 700; font-size: 10px; font-weight: 700;
color: var(--text3); text-transform: uppercase; letter-spacing: 0.07em; color: var(--text3); text-transform: uppercase; letter-spacing: 0.07em;
} }
.task-title { font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.45; } .task-title { font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.45; }
.task-done .task-title { text-decoration: line-through; } .task-done .task-title { color: var(--text3); }
.task-hold-note { .task-hold-note {
font-size: 11px; color: var(--text3); line-height: 1.4; font-size: 11px; color: var(--text3); line-height: 1.4;
background: var(--surface2); border-radius: 6px; padding: 5px 8px; background: var(--surface2); border-radius: 6px; padding: 5px 8px;
@ -298,15 +302,15 @@
.empty-col { padding: 14px 8px; text-align: center; color: var(--text3); font-size: 12px; opacity: 0.7; } .empty-col { padding: 14px 8px; text-align: center; color: var(--text3); font-size: 12px; opacity: 0.7; }
/* ── App task view ── */ /* ── App task view ── */
#view-app { max-width: 720px; margin: 0 auto; padding: 16px; } #view-app.active { display: flex; flex-direction: column; }
.app-view-header { .app-view-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding-bottom: 14px; padding: 16px 16px 14px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
margin-bottom: 16px; flex-shrink: 0;
} }
.app-view-icon { .app-view-icon {
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
@ -1004,13 +1008,13 @@ function renderBoard() {
? `<button class="add-col-btn" data-add-status="${st.id}"> ? `<button class="add-col-btn" data-add-status="${st.id}">
<i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>追加 <i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>追加
</button>` : ''; </button>` : '';
html += `<div class="col col-${st.id}"> html += `<div class="col col-${st.id}" data-status="${st.id}">
<div class="col-header"> <div class="col-header">
<i data-lucide="${st.icon}" style="width:13px;height:13px;stroke-width:2;opacity:.55"></i> <i data-lucide="${st.icon}" style="width:13px;height:13px;stroke-width:2;opacity:.55"></i>
<span class="col-label">${st.label}</span> <span class="col-label">${st.label}</span>
<span class="col-count">${col.length}</span> <span class="col-count">${col.length}</span>
</div> </div>
${col.length ? col.map(boardCard).join('') : '<div class="empty-col">なし</div>'} ${col.length ? col.map(t => boardCard(t, true)).join('') : '<div class="empty-col">なし</div>'}
${addBtn} ${addBtn}
</div>`; </div>`;
} }
@ -1018,15 +1022,16 @@ function renderBoard() {
document.getElementById('view-board').innerHTML = html; document.getElementById('view-board').innerHTML = html;
} }
function boardCard(t) { function boardCard(t, showApp = true) {
const done = t.status === 'done'; const done = t.status === 'done';
const note = (t.status === 'hold' && t.note) const note = (t.status === 'hold' && t.note)
? `<div class="task-hold-note">${esc(t.note)}</div>` : ''; ? `<div class="task-hold-note">${esc(t.note)}</div>` : '';
const commit = t.commit const commit = t.commit
? `<a class="task-commit" href="https://github.com/posimai/${t.appId}/commit/${t.commit}" target="_blank" rel="noopener">${t.commit.slice(0,7)}</a>` : ''; ? `<a class="task-commit" href="https://github.com/posimai/${t.appId}/commit/${t.commit}" target="_blank" rel="noopener">${t.commit.slice(0,7)}</a>` : '';
const date = (done && t.done_at) ? `<span class="task-date">${t.done_at}</span>` : ''; const date = (done && t.done_at) ? `<span class="task-date">${t.done_at}</span>` : '';
return `<div class="task-card${done?' task-done':''}" data-id="${t.id}" data-app="${t.appId}" role="button" tabindex="0"> const appChip = showApp ? `<div class="task-app-chip">${shortName(t.appId)}</div>` : '';
<div class="task-app-chip">${shortName(t.appId)}</div> return `<div class="task-card${done?' task-done':''}" data-id="${t.id}" data-app="${t.appId}" draggable="true" role="button" tabindex="0">
${appChip}
<div class="task-title">${esc(t.title)}</div> <div class="task-title">${esc(t.title)}</div>
${note} ${note}
<div class="task-meta"> <div class="task-meta">
@ -1043,8 +1048,7 @@ function renderAppView(appId) {
: data.apps.find(a => a.id === appId); : data.apps.find(a => a.id === appId);
if (!appEntry) { document.getElementById('view-app').innerHTML = ''; return; } if (!appEntry) { document.getElementById('view-app').innerHTML = ''; return; }
const statusOrder = { next: 0, backlog: 1, hold: 2, done: 3 }; const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 30);
const sorted = [...appEntry.tasks].sort((a, b) => (statusOrder[a.status]??9) - (statusOrder[b.status]??9));
let html = `<div class="app-view-header"> let html = `<div class="app-view-header">
<div class="app-view-icon"> <div class="app-view-icon">
@ -1056,60 +1060,27 @@ function renderAppView(appId) {
} }
html += '</div>'; html += '</div>';
let anyTask = false; html += '<div class="board">';
for (const st of STATUSES) { for (const st of STATUSES) {
const tasks = sorted.filter(t => t.status === st.id); let col = appEntry.tasks.filter(t => t.status === st.id);
if (st.id !== 'done' && tasks.length === 0) continue; if (st.id === 'done') {
anyTask = true; col = col.filter(t => !t.done_at || new Date(t.done_at) >= cutoff).slice(0, 12);
const collapsed = st.id === 'done' && tasks.length > 0 ? ' collapsed' : '';
html += `<div class="status-group sg-${st.id}${collapsed}" data-sg="${st.id}">
<div class="status-group-header">
<i data-lucide="${st.icon}" style="width:13px;height:13px;stroke-width:2;opacity:.6"></i>
<span class="status-group-label">${st.label}</span>
<span class="status-group-count">${tasks.length}</span>
<i data-lucide="chevron-down" style="width:13px;height:13px;stroke-width:2;opacity:.4" class="sg-chevron"></i>
</div>
<div class="status-group-body">`;
if (!tasks.length) {
html += `<div style="padding:6px 0;font-size:12px;color:var(--text3)">なし</div>`;
} }
for (const t of tasks) { const addBtn = st.id !== 'done'
const done = t.status === 'done'; ? `<button class="add-col-btn" data-add-status="${st.id}" data-add-app="${appId}">
const note = t.note ? `<div class="app-task-note">${esc(t.note)}</div>` : ''; <i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>追加
const commit = t.commit </button>` : '';
? `<a class="task-commit" href="https://github.com/posimai/${appId}/commit/${t.commit}" target="_blank" rel="noopener">${t.commit.slice(0,7)}</a>` : ''; html += `<div class="col col-${st.id}" data-status="${st.id}">
html += `<div class="app-task-row${done?' app-task-done':''}" data-id="${t.id}" data-app="${appId}" role="button" tabindex="0"> <div class="col-header">
<div class="app-task-content"> <i data-lucide="${st.icon}" style="width:13px;height:13px;stroke-width:2;opacity:.55"></i>
<div class="app-task-title">${esc(t.title)}</div> <span class="col-label">${st.label}</span>
${note} <span class="col-count">${col.length}</span>
<div class="task-meta" style="margin-top:3px">
<span class="task-by">${esc(t.by||'mai')}</span>
${t.done_at?`<span class="task-date">${t.done_at}</span>`:''}
${commit}
</div>
</div> </div>
${col.length ? col.map(t => boardCard(t, false)).join('') : '<div class="empty-col">なし</div>'}
${addBtn}
</div>`; </div>`;
} }
html += '</div>';
if (st.id !== 'done') {
html += `<button class="add-task-row-btn" data-add-status="${st.id}" data-add-app="${appId}">
<i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>タスクを追加
</button>`;
}
html += '</div></div>';
}
if (!anyTask) {
html += `<div class="app-view-empty">
<i data-lucide="inbox" style="width:32px;height:32px;stroke-width:1.25"></i>
<div style="font-size:13px;color:var(--text2)">タスクなし</div>
<button class="add-task-row-btn" data-add-status="next" data-add-app="${appId}" style="max-width:200px">
<i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>最初のタスクを追加
</button>
</div>`;
}
document.getElementById('view-app').innerHTML = html; document.getElementById('view-app').innerHTML = html;
} }
@ -1363,6 +1334,52 @@ function closeSettings() {
document.getElementById('settingsOpenBtn').setAttribute('aria-expanded', 'false'); document.getElementById('settingsOpenBtn').setAttribute('aria-expanded', 'false');
} }
// ── Drag & Drop ────────────────────────────────────────────────────────────────
let dndId = null;
let dndApp = null;
function attachDnd() {
const main = document.getElementById('main');
main.addEventListener('dragstart', e => {
const card = e.target.closest('.task-card[draggable]');
if (!card) return;
dndId = card.dataset.id;
dndApp = card.dataset.app;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => card.classList.add('dragging'), 0);
});
main.addEventListener('dragend', () => {
document.querySelectorAll('.task-card.dragging').forEach(el => el.classList.remove('dragging'));
document.querySelectorAll('.col.drag-over').forEach(el => el.classList.remove('drag-over'));
});
main.addEventListener('dragover', e => {
const col = e.target.closest('.col[data-status]');
if (!col || !dndId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
document.querySelectorAll('.col.drag-over').forEach(el => el !== col && el.classList.remove('drag-over'));
col.classList.add('drag-over');
});
main.addEventListener('dragleave', e => {
const col = e.target.closest('.col[data-status]');
if (col && !col.contains(e.relatedTarget)) col.classList.remove('drag-over');
});
main.addEventListener('drop', e => {
const col = e.target.closest('.col[data-status]');
if (!col || !dndId || !dndApp) return;
e.preventDefault();
col.classList.remove('drag-over');
const newStatus = col.dataset.status;
const t = findTask(dndId, dndApp);
if (!t || t.status === newStatus) { dndId = dndApp = null; return; }
t.status = newStatus;
if (newStatus === 'done' && !t.done_at) t.done_at = new Date().toISOString().slice(0, 10);
persist(); renderAll();
showToast(newStatus === 'done' ? '完了にしました' : `→ ${newStatus}`);
dndId = dndApp = null;
});
}
// ── Event delegation ─────────────────────────────────────────────────────────── // ── Event delegation ───────────────────────────────────────────────────────────
document.addEventListener('click', e => { document.addEventListener('click', e => {
// Hamburger // Hamburger
@ -1399,17 +1416,7 @@ document.addEventListener('click', e => {
openTaskModal({ taskId: card.dataset.id, appId: card.dataset.app }); return; openTaskModal({ taskId: card.dataset.id, appId: card.dataset.app }); return;
} }
// App view task row // Add task buttons (board col + app view)
const row = e.target.closest('.app-task-row[data-id]');
if (row && !e.target.closest('a')) {
openTaskModal({ taskId: row.dataset.id, appId: row.dataset.app }); return;
}
// Status group header toggle
const sgHeader = e.target.closest('.status-group-header');
if (sgHeader) { sgHeader.closest('.status-group')?.classList.toggle('collapsed'); return; }
// Add task buttons (board col + app view row)
const addBtn = e.target.closest('[data-add-status]'); const addBtn = e.target.closest('[data-add-status]');
if (addBtn) { if (addBtn) {
openTaskModal({ defaultStatus: addBtn.dataset.addStatus, defaultApp: addBtn.dataset.addApp || null }); return; openTaskModal({ defaultStatus: addBtn.dataset.addStatus, defaultApp: addBtn.dataset.addApp || null }); return;
@ -1477,7 +1484,7 @@ document.addEventListener('click', e => {
document.addEventListener('keydown', e => { document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeTaskModal(); closeIdeaModal(); closeSettings(); closeSidebar(); } if (e.key === 'Escape') { closeTaskModal(); closeIdeaModal(); closeSettings(); closeSidebar(); }
if (e.key === 'Enter' && (e.target.matches('.task-card') || e.target.matches('.idea-card') || e.target.matches('.app-task-row'))) { if (e.key === 'Enter' && (e.target.matches('.task-card') || e.target.matches('.idea-card'))) {
e.target.click(); e.target.click();
} }
}); });
@ -1493,6 +1500,7 @@ document.getElementById('appSearch').addEventListener('input', e => {
loadData().then(() => { loadData().then(() => {
initTheme(); initTheme();
renderAll(); renderAll();
attachDnd();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js'); if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
}); });
</script> </script>