posimai-roadmap/index.html

1545 lines
71 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ja" data-app-id="posimai-roadmap">
<head>
<meta charset="UTF-8">
<meta name="robots" content="noindex, nofollow">
<script>
(function () {
var t = localStorage.getItem('posimai-roadmap-theme') || 'system';
var dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', t);
})();
</script>
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="description" content="Posimai プロジェクト課題・ロードマップ管理">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0D0D0D" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#F9FAFB" media="(prefers-color-scheme: light)">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Roadmap">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="/logo.png">
<link rel="apple-touch-icon" href="/logo.png">
<title>Posimai Roadmap</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="preconnect" href="https://posimai-ui.vercel.app">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js" integrity="sha384-tTkFttkBclaU1cloKwOi9xk3pbao3VZxTjLNBt8iFABWDBQibbAbWpVmO28zMuxq" crossorigin="anonymous"></script>
<style>
/* ── Layout ── */
:root {
--sidebar-w: 240px;
}
.app { display: flex; min-height: 100dvh; }
/* ── Sidebar ── */
.sidebar {
position: fixed;
top: 0; left: 0; bottom: 0;
width: var(--sidebar-w);
background: var(--sidebar-bg, rgba(13,13,13,0.92));
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
z-index: 200;
transform: translateX(-100%);
transition: transform .25s cubic-bezier(.4,0,.2,1);
}
.sidebar.open {
transform: translateX(0);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
@media (min-width: 768px) {
.sidebar { transform: translateX(0) !important; box-shadow: none !important; }
}
.sidebar-header {
height: 52px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px 0 14px;
flex-shrink: 0;
}
.brand { display: flex; align-items: center; gap: 9px; }
.brand-logo {
width: 26px; height: 26px; border-radius: 7px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #6EE7B7, #047857);
}
[data-theme="light"] .brand-logo { background: linear-gradient(135deg, #34D399, #059669); }
.brand-text { display: flex; flex-direction: column; }
.brand-name { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; line-height: 1.3; }
.brand-org { font-size: 10px; color: var(--text3); text-transform: uppercase; letter-spacing: 0.1em; }
#closeSidebarBtn { display: flex; }
@media (min-width: 768px) { #closeSidebarBtn { display: none; } }
/* ── Sidebar nav ── */
.sidebar-nav { flex: 1; overflow-y: auto; padding: 2px 8px 0; }
.sidebar-nav::-webkit-scrollbar { width: 0; }
.nav-section-label {
font-size: 10px; font-weight: 600; color: var(--text3);
text-transform: uppercase; letter-spacing: 0.1em;
padding: 10px 8px 4px;
display: flex; align-items: center; justify-content: space-between;
}
.nav-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; min-height: 38px;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer; color: var(--text2); font-size: 13px; font-weight: 500;
transition: background .15s, color .15s, border-color .15s;
text-decoration: none; user-select: none;
}
.nav-item:hover { background: var(--surface2); color: var(--text); }
.nav-item.active {
background: color-mix(in srgb, var(--accent) 10%, transparent);
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
}
.nav-item svg { flex-shrink: 0; opacity: .75; }
.nav-item.active svg { opacity: 1; }
.nav-item-label { flex: 1; }
.nav-chevron { display: none; flex-shrink: 0; opacity: 0.5; }
.nav-item.active .nav-chevron { display: flex; }
.nav-count {
font-size: 11px; font-weight: 600; color: var(--text3);
background: var(--surface2); border-radius: 999px;
padding: 1px 7px; text-align: center; line-height: 1.6;
}
.nav-count.has-next {
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--accent);
}
.nav-item.active .nav-count { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
/* ── App search ── */
.app-search-wrap {
position: relative;
padding: 4px 8px 6px;
}
.app-search-icon {
position: absolute;
left: 18px; top: 50%; transform: translateY(-50%);
width: 12px; height: 12px;
color: var(--text3);
pointer-events: none;
}
.app-search {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 7px;
color: var(--text);
font-size: 12px;
font-family: Inter, sans-serif;
padding: 6px 10px 6px 28px;
outline: none;
transition: border-color .12s;
}
.app-search::placeholder { color: var(--text3); }
.app-search:focus { border-color: var(--accent); }
/* ── Sidebar footer ── */
.sidebar-footer {
flex-shrink: 0;
padding: 8px 10px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.sidebar-footer-label {
font-size: 11px; color: var(--text3);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── Sidebar overlay (mobile) ── */
#sidebarOverlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.55);
z-index: 150;
display: none;
}
#sidebarOverlay.open { display: block; }
@media (min-width: 768px) { #sidebarOverlay { display: none !important; } }
/* ── Main content ── */
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.main { margin-left: var(--sidebar-w); }
#menuBtn { display: none; }
}
/* ── Views ── */
.view { display: none; flex: 1; }
.view.active { display: block; }
/* ── Board ── */
.milestones-bar {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 16px 0;
}
.milestone-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 11px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 99px;
font-size: 12px;
font-weight: 500;
color: var(--text2);
}
.milestone-target { font-size: 10px; color: var(--text3); margin-left: 2px; }
.board {
display: flex;
gap: 12px;
padding: 16px;
min-height: calc(100dvh - 52px);
overflow-x: auto;
align-items: flex-start;
}
.col {
flex: 0 0 260px;
display: flex;
flex-direction: column;
gap: 8px;
}
@media (min-width: 1200px) { .col { flex: 1 1 0; min-width: 200px; } }
.col-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 2px 8px;
border-bottom: 1px solid var(--border);
margin-bottom: 2px;
}
.col-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text3);
flex: 1;
}
.col-count {
font-size: 11px; font-weight: 600; color: var(--text3);
background: var(--surface2); border-radius: 99px; padding: 1px 7px;
}
.col-next .col-label { color: var(--accent); }
.col-hold .col-label { color: #F59E0B; }
/* ── Task cards (board) ── */
.task-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 11px;
cursor: pointer;
transition: border-color .12s, transform .1s;
display: flex;
flex-direction: column;
gap: 5px;
}
.task-card:hover { border-color: var(--accent); transform: translateY(-1px); }
.task-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.task-done { opacity: 0.45; }
.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 {
font-size: 10px; font-weight: 700;
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-done .task-title { color: var(--text3); }
.task-hold-note {
font-size: 11px; color: var(--text3); line-height: 1.4;
background: var(--surface2); border-radius: 6px; padding: 5px 8px;
border-left: 2px solid #F59E0B;
}
.task-meta { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.task-by {
font-size: 10px; font-weight: 600; color: var(--text3);
background: var(--surface2); border-radius: 99px; padding: 1px 7px;
}
.task-date { font-size: 10px; color: var(--text3); margin-left: auto; }
.task-commit { font-size: 10px; color: var(--accent); font-family: monospace; text-decoration: none; }
.task-commit:hover { text-decoration: underline; }
.add-col-btn {
display: flex; align-items: center; justify-content: center; gap: 5px;
width: 100%; padding: 7px 12px;
background: none; border: 1px dashed var(--border); border-radius: 10px;
color: var(--text3); font-size: 12px; font-weight: 500;
cursor: pointer; transition: border-color .12s, color .12s;
}
.add-col-btn:hover { border-color: var(--accent); color: var(--accent); }
.empty-col { padding: 14px 8px; text-align: center; color: var(--text3); font-size: 12px; opacity: 0.7; }
/* ── App task view ── */
#view-app.active { display: block; }
.app-view-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 16px 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.app-view-icon {
width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: color-mix(in srgb, var(--accent) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
}
.app-view-name {
font-size: 15px; font-weight: 600; color: var(--text); flex: 1;
}
.app-view-link {
font-size: 11px; color: var(--text3); text-decoration: none;
}
.app-view-link:hover { color: var(--accent); text-decoration: underline; }
.status-group { margin-bottom: 4px; }
.status-group-header {
display: flex;
align-items: center;
gap: 7px;
padding: 10px 0 8px;
cursor: pointer;
user-select: none;
}
.status-group-label {
font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.09em;
color: var(--text3); flex: 1;
}
.sg-next .status-group-label { color: var(--accent); }
.sg-hold .status-group-label { color: #F59E0B; }
.status-group-count {
font-size: 11px; font-weight: 600;
color: var(--text3); background: var(--surface2);
border-radius: 99px; padding: 1px 7px;
}
.sg-next .status-group-count { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.sg-hold .status-group-count { background: color-mix(in srgb, #F59E0B 12%, transparent); color: #F59E0B; }
.status-group-body { display: flex; flex-direction: column; gap: 4px; }
.status-group.collapsed .status-group-body { display: none; }
.app-task-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: border-color .12s;
}
.app-task-row:hover { border-color: var(--accent); }
.app-task-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.app-task-done { opacity: 0.45; }
.app-task-done:hover { opacity: 0.7; }
.app-task-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.app-task-title { font-size: 13px; font-weight: 500; color: var(--text); line-height: 1.4; }
.app-task-done .app-task-title { text-decoration: line-through; }
.app-task-note {
font-size: 11px; color: var(--text3); line-height: 1.4;
background: var(--surface2); border-radius: 6px; padding: 4px 8px;
border-left: 2px solid #F59E0B; margin-top: 2px;
}
.add-task-row-btn {
display: flex; align-items: center; gap: 5px;
width: 100%; padding: 7px 10px;
background: none; border: 1px dashed var(--border); border-radius: 10px;
color: var(--text3); font-size: 12px; cursor: pointer;
transition: border-color .12s, color .12s; margin-top: 4px;
font-family: Inter, sans-serif;
}
.add-task-row-btn:hover { border-color: var(--accent); color: var(--accent); }
.app-view-empty {
display: flex; flex-direction: column; align-items: center;
gap: 10px; padding: 48px 16px; color: var(--text3); text-align: center;
}
.app-view-empty svg { opacity: 0.3; }
/* ── Ideas view ── */
#view-ideas { padding: 16px; }
.ideas-top {
display: flex; align-items: center; gap: 8px;
margin-bottom: 16px; flex-wrap: wrap;
}
.idea-filter-btn {
padding: 5px 13px; border-radius: 99px; font-size: 12px; font-weight: 500;
background: var(--surface); border: 1px solid var(--border); color: var(--text2);
cursor: pointer; transition: all .12s;
}
.idea-filter-btn.active, .idea-filter-btn:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: var(--accent); color: var(--accent);
}
.ideas-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; }
.idea-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: 10px; padding: 13px; cursor: pointer;
transition: border-color .12s; display: flex; flex-direction: column; gap: 5px;
}
.idea-card:hover { border-color: var(--accent); }
.idea-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.idea-status-chip {
font-size: 10px; font-weight: 700; letter-spacing: 0.07em;
padding: 2px 8px; border-radius: 99px; align-self: flex-start;
text-transform: uppercase;
}
.i-exploring { background: color-mix(in srgb, #8B5CF6 14%, transparent); color: #8B5CF6; }
.i-candidate { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.i-approved { background: color-mix(in srgb, #3B82F6 14%, transparent); color: #3B82F6; }
.i-archived { background: var(--surface2); color: var(--text3); }
.idea-title { font-size: 14px; font-weight: 600; color: var(--text); }
.idea-desc { font-size: 12px; color: var(--text2); line-height: 1.4; }
.idea-note { font-size: 11px; color: var(--text3); }
.idea-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.idea-by { font-size: 10px; color: var(--text3); }
.idea-date { font-size: 10px; color: var(--text3); margin-left: auto; }
.ideas-empty {
grid-column: 1/-1; text-align: center;
padding: 56px 24px; color: var(--text3);
}
.ideas-empty svg { opacity: 0.3; display: block; margin: 0 auto 10px; }
/* ── FAB ── */
.fab {
position: fixed;
bottom: max(24px, env(safe-area-inset-bottom, 24px));
right: 20px;
width: 48px; height: 48px;
border-radius: 50%;
background: var(--accent); color: var(--bg);
border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 16px color-mix(in srgb, var(--accent) 40%, transparent);
transition: transform .12s, box-shadow .12s;
z-index: 100;
}
.fab:hover { transform: scale(1.08); box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 50%, transparent); }
.fab:active { transform: scale(0.96); }
/* ── Modals ── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.65);
z-index: 400;
display: none; align-items: center; justify-content: center;
padding: 20px;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 16px; width: 100%; max-width: 440px;
max-height: 90dvh; overflow-y: auto;
display: flex; flex-direction: column;
}
.modal-header {
display: flex; align-items: center;
padding: 15px 18px; border-bottom: 1px solid var(--border); gap: 8px;
position: sticky; top: 0; background: var(--surface);
}
.modal-title { font-size: 14px; font-weight: 600; flex: 1; }
.modal-body { padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.field-label {
font-size: 10px; font-weight: 700; color: var(--text3);
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 5px;
}
.modal-body input[type="text"],
.modal-body textarea,
.modal-body select {
width: 100%; background: var(--surface2); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: 13px;
font-family: Inter, sans-serif; padding: 9px 12px;
box-sizing: border-box; outline: none; transition: border-color .12s;
}
.modal-body input:focus, .modal-body textarea:focus, .modal-body select:focus { border-color: var(--accent); }
.modal-body textarea { min-height: 72px; resize: vertical; }
.modal-body select option { background: var(--surface); }
.status-picker { display: flex; gap: 6px; flex-wrap: wrap; }
.spick {
padding: 5px 13px; border-radius: 99px; font-size: 12px; font-weight: 600;
cursor: pointer; border: 1.5px solid var(--border);
background: none; color: var(--text2); transition: all .1s;
}
.spick[data-s="next"].sel { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.spick[data-s="backlog"].sel { border-color: var(--text3); color: var(--text); background: var(--surface2); }
.spick[data-s="hold"].sel { border-color: #F59E0B; color: #F59E0B; background: color-mix(in srgb, #F59E0B 12%, transparent); }
.spick[data-s="done"].sel { border-color: var(--text3); color: var(--text3); background: var(--surface2); }
.spick[data-s="exploring"].sel { border-color: #8B5CF6; color: #8B5CF6; background: color-mix(in srgb, #8B5CF6 12%, transparent); }
.spick[data-s="candidate"].sel { border-color: var(--accent); color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
.spick[data-s="approved"].sel { border-color: #3B82F6; color: #3B82F6; background: color-mix(in srgb, #3B82F6 12%, transparent); }
.spick[data-s="archived"].sel { border-color: var(--text3); color: var(--text3); background: var(--surface2); }
.modal-footer {
display: flex; gap: 8px;
padding: 12px 18px 18px;
justify-content: flex-end;
}
.btn-danger {
margin-right: auto; background: none; border: 1px solid var(--border);
color: #EF4444; font-size: 13px; font-weight: 500;
padding: 8px 14px; border-radius: 8px; cursor: pointer;
}
.btn-danger:hover { border-color: #EF4444; background: color-mix(in srgb, #EF4444 10%, transparent); }
.btn-primary {
background: var(--accent); color: var(--bg); font-size: 13px; font-weight: 600;
padding: 8px 18px; border-radius: 8px; border: none; cursor: pointer;
transition: opacity .12s;
}
.btn-primary:hover { opacity: 0.88; }
.btn-secondary {
background: var(--surface2); color: var(--text); font-size: 13px; font-weight: 500;
padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border); cursor: pointer;
}
/* ── Settings panel ── */
.settings-panel {
position: fixed; top: 0; right: 0; bottom: 0;
width: 280px; background: var(--surface);
border-left: 1px solid var(--border);
display: flex; flex-direction: column;
z-index: 300; transform: translateX(100%);
transition: transform .25s cubic-bezier(.4,0,.2,1);
}
.settings-panel.open { transform: translateX(0); box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
.settings-panel-header {
height: 52px; display: flex; align-items: center; justify-content: space-between;
padding: 0 12px 0 16px; flex-shrink: 0; border-bottom: 1px solid var(--border);
}
.settings-panel-title { font-size: 14px; font-weight: 600; }
.settings-panel-body {
flex: 1; overflow-y: auto; padding: 16px;
display: flex; flex-direction: column; gap: 20px;
}
.settings-group-label {
font-size: 10px; font-weight: 600; color: var(--text3);
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px;
}
.settings-item {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border);
}
.settings-item:last-child { border-bottom: none; }
.settings-item-label { font-size: 13px; color: var(--text); }
.settings-action-btn {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 9px 12px;
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-size: 13px; cursor: pointer;
margin-bottom: 6px; font-family: Inter, sans-serif; text-align: left;
transition: border-color .12s, color .12s;
}
.settings-action-btn:hover { border-color: var(--accent); color: var(--accent); }
.theme-selector { display: flex; gap: 4px; }
.theme-btn {
display: flex; align-items: center; gap: 4px;
padding: 5px 10px; border-radius: 7px; font-size: 12px; font-weight: 500;
background: var(--surface2); border: 1px solid var(--border); color: var(--text2);
cursor: pointer; font-family: Inter, sans-serif; transition: all .12s;
}
.theme-btn.active { background: color-mix(in srgb, var(--accent) 12%, transparent); border-color: var(--accent); color: var(--accent); }
/* ── Overlay (settings panel) ── */
.overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.4);
z-index: 250; display: none;
}
.overlay.open { display: block; }
/* ── Misc ── */
.skip-link {
position: absolute; top: -100%; left: 8px;
background: var(--accent); color: var(--bg);
padding: 8px 16px; border-radius: 8px; font-weight: 600; font-size: 13px;
z-index: 10000; text-decoration: none;
}
.skip-link:focus { top: 8px; }
@media (prefers-reduced-motion: reduce) {
.sidebar, .settings-panel { transition: none; }
* { transition-duration: .01ms !important; }
}
</style>
</head>
<body>
<a href="#main-content" class="skip-link" tabindex="0">コンテンツへスキップ</a>
<div class="app">
<!-- ══ Sidebar ══════════════════════════════════════════════════════ -->
<aside class="sidebar" id="sidebar" aria-label="サイドバー">
<div class="sidebar-header">
<div class="brand">
<div class="brand-logo" aria-hidden="true">
<i data-lucide="map" style="width:13px;height:13px;stroke-width:2;color:var(--bg)"></i>
</div>
<div class="brand-text">
<span class="brand-name">Roadmap</span>
<span class="brand-org">Posimai</span>
</div>
</div>
<button class="icon-btn" id="closeSidebarBtn" aria-label="閉じる">
<i data-lucide="x" style="width:15px;height:15px;stroke-width:2"></i>
</button>
</div>
<nav class="sidebar-nav" aria-label="メインナビゲーション">
<div class="nav-section-label">ビュー</div>
<a class="nav-item active" data-nav="board" href="#" role="button">
<i data-lucide="layout-dashboard" style="width:15px;height:15px;stroke-width:1.75"></i>
<span class="nav-item-label">Board</span>
<i data-lucide="chevron-right" style="width:11px;height:11px;stroke-width:2.5" class="nav-chevron"></i>
</a>
<a class="nav-item" data-nav="ideas" href="#" role="button">
<i data-lucide="lightbulb" style="width:15px;height:15px;stroke-width:1.75"></i>
<span class="nav-item-label">Ideas</span>
<span class="nav-count" id="navCountIdeas">0</span>
<i data-lucide="chevron-right" style="width:11px;height:11px;stroke-width:2.5" class="nav-chevron"></i>
</a>
<div class="nav-section-label" style="margin-top:6px">
<span>アプリ</span>
<span class="nav-count" id="navCountOpen" style="font-size:10px"></span>
</div>
<div class="app-search-wrap">
<i data-lucide="search" class="app-search-icon"></i>
<input type="search" id="appSearch" class="app-search" placeholder="絞り込み..." aria-label="アプリを絞り込む">
</div>
<div id="appNavList" role="list"></div>
</nav>
<div class="sidebar-footer">
<span class="sidebar-footer-label" id="sidebarUpdatedLabel"></span>
<button class="icon-btn" id="settingsOpenBtn" aria-label="設定を開く" aria-expanded="false">
<i data-lucide="settings" style="width:15px;height:15px;stroke-width:1.75"></i>
</button>
</div>
</aside>
<!-- Sidebar overlay (mobile) -->
<div id="sidebarOverlay" aria-hidden="true"></div>
<!-- ══ Settings panel ════════════════════════════════════════════════ -->
<aside class="settings-panel" id="settingsPanel" role="dialog" aria-modal="true" aria-labelledby="settingsPanelTitle">
<div class="settings-panel-header">
<span class="settings-panel-title" id="settingsPanelTitle">設定</span>
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
<i data-lucide="x" style="width:15px;height:15px;stroke-width:2"></i>
</button>
</div>
<div class="settings-panel-body">
<div>
<div class="settings-group-label">外観</div>
<div class="settings-item">
<span class="settings-item-label">テーマ</span>
<div class="theme-selector">
<button class="theme-btn" data-theme-val="dark">
<i data-lucide="moon" style="width:11px;height:11px;stroke-width:1.75"></i>ダーク
</button>
<button class="theme-btn" data-theme-val="light">
<i data-lucide="sun" style="width:11px;height:11px;stroke-width:1.75"></i>ライト
</button>
<button class="theme-btn" data-theme-val="system">
<i data-lucide="monitor" style="width:11px;height:11px;stroke-width:1.75"></i>自動
</button>
</div>
</div>
</div>
<div>
<div class="settings-group-label">データ</div>
<button class="settings-action-btn" id="exportJsonBtn">
<i data-lucide="copy" style="width:13px;height:13px;stroke-width:1.75"></i>
JSON をコピーClaude 連携用)
</button>
<button class="settings-action-btn" id="reloadJsonBtn">
<i data-lucide="refresh-cw" style="width:13px;height:13px;stroke-width:1.75"></i>
ファイルから再読み込み
</button>
</div>
</div>
</aside>
<div class="overlay" id="overlay" aria-hidden="true"></div>
<!-- ══ Main content ══════════════════════════════════════════════════ -->
<div class="main" id="main">
<header class="header">
<div class="header-brand">
<button class="icon-btn" id="menuBtn" aria-label="メニューを開く" aria-expanded="false" aria-controls="sidebar">
<i data-lucide="menu" style="width:18px;height:18px;stroke-width:1.75"></i>
</button>
<div class="header-dot" aria-hidden="true"></div>
<span class="header-title" id="headerTitle">Board</span>
<a id="headerAppLink" href="#" target="_blank" rel="noopener noreferrer" style="display:none;font-size:11px;color:var(--text3);text-decoration:none;margin-left:6px;" onmouseover="this.style.color='var(--accent)'" onmouseout="this.style.color='var(--text3)'"></a>
</div>
<button class="icon-btn" id="addHeaderBtn" aria-label="タスクを追加">
<i data-lucide="plus" style="width:18px;height:18px;stroke-width:2"></i>
</button>
</header>
<div id="view-board" class="view active" id="main-content" role="main" aria-label="Board"></div>
<div id="view-ideas" class="view" role="main" aria-label="Ideas"></div>
<div id="view-app" class="view" role="main" aria-label="アプリタスク"></div>
</div>
<!-- FAB -->
<button class="fab" id="fabBtn" aria-label="タスクを追加">
<i data-lucide="plus" style="width:22px;height:22px;stroke-width:2.5"></i>
</button>
<!-- ══ Task modal ════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="taskModal" role="dialog" aria-modal="true" aria-labelledby="taskModalTitle">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="taskModalTitle">タスクを追加</span>
<button class="icon-btn" id="taskModalClose" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<div>
<div class="field-label">タイトル</div>
<input type="text" id="fTitle" placeholder="タスクのタイトル...">
</div>
<div>
<div class="field-label">アプリ</div>
<select id="fApp"></select>
</div>
<div>
<div class="field-label">ステータス</div>
<div class="status-picker" id="taskStatusPicker">
<button class="spick" data-s="next">Next</button>
<button class="spick" data-s="backlog">Backlog</button>
<button class="spick" data-s="hold">Hold</button>
<button class="spick" data-s="done">Done</button>
</div>
</div>
<div>
<div class="field-label">メモ / 保留の理由</div>
<textarea id="fNote" placeholder="補足情報や保留の理由..."></textarea>
</div>
<div>
<div class="field-label">担当</div>
<select id="fBy"></select>
</div>
<div id="fCommitRow" style="display:none">
<div class="field-label">Commit hash任意</div>
<input type="text" id="fCommit" placeholder="例: e7ccd82">
</div>
</div>
<div class="modal-footer">
<button class="btn-danger" id="deleteTaskBtn" style="display:none">削除</button>
<button class="btn-secondary" id="taskModalCancel">キャンセル</button>
<button class="btn-primary" id="saveTaskBtn">保存</button>
</div>
</div>
</div>
<!-- ══ Idea modal ════════════════════════════════════════════════════ -->
<div class="modal-overlay" id="ideaModal" role="dialog" aria-modal="true" aria-labelledby="ideaModalTitle">
<div class="modal">
<div class="modal-header">
<span class="modal-title" id="ideaModalTitle">アイデアを追加</span>
<button class="icon-btn" id="ideaModalClose" aria-label="閉じる">
<i data-lucide="x" style="width:16px;height:16px;stroke-width:1.75"></i>
</button>
</div>
<div class="modal-body">
<div>
<div class="field-label">タイトル</div>
<input type="text" id="iTitle" placeholder="例: posimai-finance">
</div>
<div>
<div class="field-label">説明</div>
<input type="text" id="iDesc" placeholder="一行で説明">
</div>
<div>
<div class="field-label">ステータス</div>
<div class="status-picker" id="ideaStatusPicker">
<button class="spick" data-s="exploring">Exploring</button>
<button class="spick" data-s="candidate">Candidate</button>
<button class="spick" data-s="approved">Approved</button>
<button class="spick" data-s="archived">Archived</button>
</div>
</div>
<div>
<div class="field-label">メモ</div>
<textarea id="iNote" placeholder="補足情報..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-danger" id="deleteIdeaBtn" style="display:none">削除</button>
<button class="btn-secondary" id="ideaModalCancel">キャンセル</button>
<button class="btn-primary" id="saveIdeaBtn">保存</button>
</div>
</div>
</div>
</div><!-- .app -->
<div id="toast" role="status" aria-live="polite"></div>
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
<script defer>
// ── Constants ──────────────────────────────────────────────────────────────────
const STORAGE_KEY = 'posimai-roadmap-data';
const STATUSES = [
{ id: 'next', label: 'Next', icon: 'circle-arrow-right' },
{ id: 'backlog', label: 'Backlog', icon: 'inbox' },
{ id: 'hold', label: 'Hold', icon: 'circle-pause' },
{ id: 'done', label: 'Done', icon: 'circle-check' },
];
const IDEA_STATUSES = ['exploring', 'candidate', 'approved', 'archived'];
const IDEA_LABELS = { exploring: 'Exploring', candidate: 'Candidate', approved: 'Approved', archived: 'Archived' };
// ── State ──────────────────────────────────────────────────────────────────────
let data = null;
let currentView = 'board'; // 'board' | 'ideas' | 'app'
let currentApp = null; // app id when view === 'app'
let editTaskId = null;
let editTaskApp = null;
let editIdeaId = null;
let taskStatus = 'next';
let ideaStatus = 'exploring';
let ideaFilter = 'all';
let appSearch = '';
// ── Data helpers ───────────────────────────────────────────────────────────────
async function loadData(forceFile = false) {
if (!forceFile) {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
try {
data = JSON.parse(raw);
// Merge any new apps added to roadmap.json that aren't in localStorage yet
const res = await fetch('/roadmap.json');
const fresh = await res.json();
const existingIds = new Set(data.apps.map(a => a.id));
let changed = false;
for (const app of fresh.apps) {
if (!existingIds.has(app.id)) {
data.apps.push(app);
changed = true;
}
}
if (changed) persist();
return;
} catch (_) {}
}
}
const res = await fetch('/roadmap.json');
data = await res.json();
persist();
}
function persist() {
data.updated = new Date().toISOString().slice(0, 10);
localStorage.setItem(STORAGE_KEY, JSON.stringify(data, null, 2));
}
function allTasks() {
const out = [];
for (const app of data.apps) {
for (const t of app.tasks) out.push({ ...t, appId: app.id });
}
for (const t of (data.global || [])) out.push({ ...t, appId: 'global' });
return out;
}
function findTask(id, appId) {
if (appId === 'global') return (data.global || []).find(t => t.id === id);
const app = data.apps.find(a => a.id === appId);
return app ? app.tasks.find(t => t.id === id) : null;
}
function removeTask(id, appId) {
if (appId === 'global') {
data.global = (data.global || []).filter(t => t.id !== id);
} else {
const app = data.apps.find(a => a.id === appId);
if (app) app.tasks = app.tasks.filter(t => t.id !== id);
}
}
function pushTask(task, appId) {
if (appId === 'global') {
if (!data.global) data.global = [];
data.global.push(task);
} else {
const app = data.apps.find(a => a.id === appId);
if (app) app.tasks.push(task);
}
}
function genId(prefix) { return `${prefix}-${Date.now().toString(36)}`; }
function esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function shortName(appId) { return appId.replace('posimai-', ''); }
function openCount(tasks) { return tasks.filter(t => t.status !== 'done').length; }
function hasNext(tasks) { return tasks.some(t => t.status === 'next'); }
// ── Sidebar nav ────────────────────────────────────────────────────────────────
function renderAppNav() {
const q = appSearch.toLowerCase();
const globalEntry = { id: 'global', tasks: data.global || [] };
const allApps = [globalEntry, ...data.apps];
const filtered = q ? allApps.filter(a => a.id.toLowerCase().includes(q)) : allApps;
// Update total open count
const totalOpen = allApps.reduce((n, a) => n + openCount(a.tasks), 0);
document.getElementById('navCountOpen').textContent = totalOpen ? `${totalOpen} open` : '';
// Update ideas count
document.getElementById('navCountIdeas').textContent = (data.ideas || []).length;
// Updated label
const upd = document.getElementById('sidebarUpdatedLabel');
if (upd && data.updated) upd.textContent = `更新: ${data.updated}`;
// Sort: global first, then active apps (next > any open > done-only), then inactive apps (alpha)
const globalList = filtered.filter(a => a.id === 'global');
const active = filtered.filter(a => a.id !== 'global' && openCount(a.tasks) > 0)
.sort((a, b) => (hasNext(b.tasks) ? 1 : 0) - (hasNext(a.tasks) ? 1 : 0));
const inactive = filtered.filter(a => a.id !== 'global' && openCount(a.tasks) === 0)
.sort((a, b) => a.id.localeCompare(b.id));
function navItem(app) {
const open = openCount(app.tasks);
const hn = hasNext(app.tasks);
const isActive = currentView === 'app' && currentApp === app.id;
const countHtml = open > 0
? `<span class="nav-count${hn ? ' has-next' : ''}">${open}</span>`
: '';
return `<a class="nav-item${isActive ? ' active' : ''}" data-nav="app" data-app-id="${app.id}" href="#" role="listitem">
<i data-lucide="${app.id === 'global' ? 'globe' : 'package'}" style="width:14px;height:14px;stroke-width:1.75"></i>
<span class="nav-item-label">${shortName(app.id)}</span>
${countHtml}
<i data-lucide="chevron-right" style="width:11px;height:11px;stroke-width:2.5" class="nav-chevron"></i>
</a>`;
}
let html = '';
if (!filtered.length) {
html = '<div style="padding:8px 10px;font-size:12px;color:var(--text3);">該当なし</div>';
} else {
globalList.forEach(a => { html += navItem(a); });
active.forEach(a => { html += navItem(a); });
if (inactive.length) {
if (active.length || globalList.length) {
html += `<div style="font-size:10px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:0.1em;padding:10px 8px 4px;opacity:0.7;">全アプリ</div>`;
}
inactive.forEach(a => { html += navItem(a); });
}
}
document.getElementById('appNavList').innerHTML = html;
// Update Board/Ideas nav active state
document.querySelectorAll('.nav-item[data-nav]').forEach(el => {
const nav = el.dataset.nav;
if (nav === 'board') el.classList.toggle('active', currentView === 'board');
if (nav === 'ideas') el.classList.toggle('active', currentView === 'ideas');
});
lucide.createIcons({ nodes: [document.getElementById('appNavList'), document.querySelector('.sidebar-nav')] });
}
// ── Switch view ────────────────────────────────────────────────────────────────
function switchView(view, appId) {
currentView = view;
currentApp = appId || null;
// Header title + app link
const titles = { board: 'Board', ideas: 'Ideas' };
const title = view === 'app' ? shortName(appId) : (titles[view] || view);
document.getElementById('headerTitle').textContent = title;
const appLink = document.getElementById('headerAppLink');
if (view === 'app' && appId !== 'global') {
appLink.href = `https://${appId}.vercel.app`;
appLink.textContent = `${appId}.vercel.app`;
appLink.style.display = '';
} else {
appLink.style.display = 'none';
}
// FAB visibility
document.getElementById('fabBtn').style.display = view === 'ideas' ? 'none' : 'flex';
// Add header button aria-label
const addBtn = document.getElementById('addHeaderBtn');
addBtn.setAttribute('aria-label', view === 'ideas' ? 'アイデアを追加' : 'タスクを追加');
// Show correct view
document.getElementById('view-board').classList.toggle('active', view === 'board');
document.getElementById('view-ideas').classList.toggle('active', view === 'ideas');
document.getElementById('view-app').classList.toggle('active', view === 'app');
renderAppNav();
if (view === 'app') renderAppView(appId);
// On mobile, close sidebar after navigation
closeSidebar();
}
// ── Board ──────────────────────────────────────────────────────────────────────
function renderBoard() {
const tasks = allTasks();
const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 30);
let html = '';
if (data.milestones?.length) {
html += '<div class="milestones-bar">';
for (const ms of data.milestones) {
html += `<span class="milestone-chip">
<i data-lucide="flag" style="width:11px;height:11px;stroke-width:2;opacity:.6"></i>
${esc(ms.title)}<span class="milestone-target">${ms.target}</span>
</span>`;
}
html += '</div>';
}
html += '<div class="board">';
for (const st of STATUSES) {
let col = tasks.filter(t => t.status === st.id);
if (st.id === 'done') {
col = col.filter(t => !t.done_at || new Date(t.done_at) >= cutoff).slice(0, 12);
}
const addBtn = st.id !== 'done'
? `<button class="add-col-btn" data-add-status="${st.id}">
<i data-lucide="plus" style="width:12px;height:12px;stroke-width:2.5"></i>追加
</button>` : '';
html += `<div class="col col-${st.id}" data-status="${st.id}">
<div class="col-header">
<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-count">${col.length}</span>
</div>
${col.length ? col.map(t => boardCard(t, true)).join('') : '<div class="empty-col">なし</div>'}
${addBtn}
</div>`;
}
html += '</div>';
document.getElementById('view-board').innerHTML = html;
}
function boardCard(t, showApp = true) {
const done = t.status === 'done';
const note = (t.status === 'hold' && t.note)
? `<div class="task-hold-note">${esc(t.note)}</div>` : '';
const commit = t.commit
? `<a class="task-commit" href="https://github.com/posimai/${t.appId}/commit/${t.commit}" target="_blank" rel="noopener noreferrer">${t.commit.slice(0,7)}</a>` : '';
const date = (done && t.done_at) ? `<span class="task-date">${t.done_at}</span>` : '';
const appChip = showApp ? `<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>
${note}
<div class="task-meta">
<span class="task-by">${esc(t.by||'mai')}</span>
${date}${commit}
</div>
</div>`;
}
// ── App task view ──────────────────────────────────────────────────────────────
function renderAppView(appId) {
const appEntry = appId === 'global'
? { id: 'global', tasks: data.global || [] }
: data.apps.find(a => a.id === appId);
if (!appEntry) { document.getElementById('view-app').innerHTML = ''; return; }
const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 30);
let html = '<div class="board">';
for (const st of STATUSES) {
let col = appEntry.tasks.filter(t => t.status === st.id);
if (st.id === 'done') {
col = col.filter(t => !t.done_at || new Date(t.done_at) >= cutoff).slice(0, 12);
}
const addBtn = st.id !== 'done'
? `<button class="add-col-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 class="col col-${st.id}" data-status="${st.id}">
<div class="col-header">
<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-count">${col.length}</span>
</div>
${col.length ? col.map(t => boardCard({...t, appId}, false)).join('') : '<div class="empty-col">なし</div>'}
${addBtn}
</div>`;
}
html += '</div>';
document.getElementById('view-app').innerHTML = html;
}
// ── Ideas ──────────────────────────────────────────────────────────────────────
function renderIdeas() {
const ideas = data.ideas || [];
const counts = {};
for (const s of IDEA_STATUSES) counts[s] = ideas.filter(i => i.status === s).length;
let html = '<div class="ideas-top">';
html += `<button class="idea-filter-btn${ideaFilter==='all'?' active':''}" data-ifilter="all">All ${ideas.length}</button>`;
for (const s of IDEA_STATUSES) {
html += `<button class="idea-filter-btn${ideaFilter===s?' active':''}" data-ifilter="${s}">${IDEA_LABELS[s]} ${counts[s]}</button>`;
}
html += `<button class="idea-filter-btn" id="addIdeaBtn" style="margin-left:auto;border-style:dashed">
<i data-lucide="plus" style="width:11px;height:11px;stroke-width:2.5;vertical-align:-1px"></i> 追加
</button></div>`;
const filtered = ideaFilter === 'all' ? ideas : ideas.filter(i => i.status === ideaFilter);
html += '<div class="ideas-grid">';
if (!filtered.length) {
html += `<div class="ideas-empty">
<i data-lucide="lightbulb" style="width:28px;height:28px;stroke-width:1.25"></i>
<div style="font-size:13px">アイデアなし</div>
</div>`;
}
for (const idea of filtered) {
html += `<div class="idea-card" data-idea-id="${idea.id}" role="button" tabindex="0">
<span class="idea-status-chip i-${idea.status}">${IDEA_LABELS[idea.status]}</span>
<div class="idea-title">${esc(idea.title)}</div>
${idea.description ? `<div class="idea-desc">${esc(idea.description)}</div>` : ''}
${idea.note ? `<div class="idea-note">${esc(idea.note)}</div>` : ''}
<div class="idea-meta">
<span class="idea-by">${esc(idea.by||'mai')}</span>
<span class="idea-date">${idea.created}</span>
</div>
</div>`;
}
html += '</div>';
document.getElementById('view-ideas').innerHTML = html;
}
function renderAll() {
renderBoard();
renderIdeas();
renderAppNav();
if (currentView === 'app' && currentApp) renderAppView(currentApp);
lucide.createIcons();
}
// ── Status picker ──────────────────────────────────────────────────────────────
function pickStatus(s, pickerId) {
if (pickerId === 'taskStatusPicker') taskStatus = s;
else ideaStatus = s;
document.querySelectorAll(`#${pickerId} .spick`).forEach(b => {
b.classList.toggle('sel', b.dataset.s === s);
});
if (pickerId === 'taskStatusPicker') {
document.getElementById('fCommitRow').style.display = s === 'done' ? '' : 'none';
}
}
// ── Task modal ─────────────────────────────────────────────────────────────────
function openTaskModal({ taskId, appId, defaultStatus, defaultApp } = {}) {
editTaskId = taskId || null;
editTaskApp = appId || null;
const fApp = document.getElementById('fApp');
fApp.innerHTML = '<option value="global">global</option>';
for (const app of data.apps) {
fApp.innerHTML += `<option value="${app.id}">${shortName(app.id)}</option>`;
}
const fBy = document.getElementById('fBy');
fBy.innerHTML = (data.members || ['mai']).map(m => `<option value="${m}">${m}</option>`).join('');
if (taskId) {
const t = findTask(taskId, appId);
if (!t) return;
document.getElementById('taskModalTitle').textContent = 'タスクを編集';
document.getElementById('fTitle').value = t.title;
document.getElementById('fNote').value = t.note || '';
document.getElementById('fCommit').value = t.commit || '';
fApp.value = appId;
fBy.value = t.by || 'mai';
pickStatus(t.status, 'taskStatusPicker');
document.getElementById('deleteTaskBtn').style.display = '';
} else {
document.getElementById('taskModalTitle').textContent = 'タスクを追加';
document.getElementById('fTitle').value = '';
document.getElementById('fNote').value = '';
document.getElementById('fCommit').value = '';
fApp.value = defaultApp || (currentApp && currentApp !== 'global' ? currentApp : 'global');
pickStatus(defaultStatus || 'next', 'taskStatusPicker');
document.getElementById('deleteTaskBtn').style.display = 'none';
}
document.getElementById('taskModal').classList.add('open');
document.getElementById('fTitle').focus();
}
function closeTaskModal() {
document.getElementById('taskModal').classList.remove('open');
editTaskId = editTaskApp = null;
}
function saveTask() {
const title = document.getElementById('fTitle').value.trim();
if (!title) { showToast('タイトルを入力してください'); return; }
const appId = document.getElementById('fApp').value;
const by = document.getElementById('fBy').value;
const note = document.getElementById('fNote').value.trim() || null;
const commit = document.getElementById('fCommit').value.trim() || null;
const today = new Date().toISOString().slice(0, 10);
if (editTaskId) {
const t = findTask(editTaskId, editTaskApp);
if (!t) return;
t.title = title; t.note = note; t.by = by; t.commit = commit;
if (t.status !== taskStatus) {
t.status = taskStatus;
if (taskStatus === 'done' && !t.done_at) t.done_at = today;
}
if (editTaskApp !== appId) { removeTask(editTaskId, editTaskApp); pushTask({ ...t }, appId); }
} else {
pushTask({
id: genId(shortName(appId)), title, status: taskStatus, note, by,
created: today, commit, done_at: taskStatus === 'done' ? today : null,
}, appId);
}
persist(); closeTaskModal(); renderAll();
showToast(editTaskId ? '更新しました' : '追加しました');
}
function deleteTask() {
if (!editTaskId || !confirm('このタスクを削除しますか?')) return;
removeTask(editTaskId, editTaskApp);
persist(); closeTaskModal(); renderAll();
showToast('削除しました');
}
// ── Idea modal ─────────────────────────────────────────────────────────────────
function openIdeaModal(ideaId) {
editIdeaId = ideaId || null;
if (ideaId) {
const idea = (data.ideas || []).find(i => i.id === ideaId);
if (!idea) return;
document.getElementById('ideaModalTitle').textContent = 'アイデアを編集';
document.getElementById('iTitle').value = idea.title;
document.getElementById('iDesc').value = idea.description || '';
document.getElementById('iNote').value = idea.note || '';
pickStatus(idea.status, 'ideaStatusPicker');
document.getElementById('deleteIdeaBtn').style.display = '';
} else {
document.getElementById('ideaModalTitle').textContent = 'アイデアを追加';
document.getElementById('iTitle').value = '';
document.getElementById('iDesc').value = '';
document.getElementById('iNote').value = '';
pickStatus('exploring', 'ideaStatusPicker');
document.getElementById('deleteIdeaBtn').style.display = 'none';
}
document.getElementById('ideaModal').classList.add('open');
document.getElementById('iTitle').focus();
}
function closeIdeaModal() {
document.getElementById('ideaModal').classList.remove('open');
editIdeaId = null;
}
function saveIdea() {
const title = document.getElementById('iTitle').value.trim();
if (!title) { showToast('タイトルを入力してください'); return; }
const desc = document.getElementById('iDesc').value.trim() || null;
const note = document.getElementById('iNote').value.trim() || null;
if (!data.ideas) data.ideas = [];
if (editIdeaId) {
const idea = data.ideas.find(i => i.id === editIdeaId);
if (!idea) return;
idea.title = title; idea.description = desc; idea.note = note; idea.status = ideaStatus;
} else {
data.ideas.push({
id: genId('idea'), title, description: desc, status: ideaStatus, note,
by: (data.members || ['mai'])[0],
created: new Date().toISOString().slice(0, 10),
});
}
persist(); closeIdeaModal(); renderIdeas();
renderAppNav(); lucide.createIcons();
showToast(editIdeaId ? '更新しました' : 'アイデアを追加しました');
}
function deleteIdea() {
if (!editIdeaId || !confirm('このアイデアを削除しますか?')) return;
data.ideas = data.ideas.filter(i => i.id !== editIdeaId);
persist(); closeIdeaModal(); renderIdeas();
renderAppNav(); lucide.createIcons();
showToast('削除しました');
}
// ── Sidebar ────────────────────────────────────────────────────────────────────
function openSidebar() {
document.getElementById('sidebar').classList.add('open');
document.getElementById('sidebarOverlay').classList.add('open');
document.getElementById('menuBtn').setAttribute('aria-expanded', 'true');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
document.getElementById('sidebarOverlay').classList.remove('open');
document.getElementById('menuBtn').setAttribute('aria-expanded', 'false');
}
// ── Theme ──────────────────────────────────────────────────────────────────────
function applyTheme(t) {
const dark = t === 'dark' || (t === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme-pref', t);
localStorage.setItem('posimai-roadmap-theme', t);
document.querySelectorAll('.theme-btn').forEach(b => {
b.classList.toggle('active', b.dataset.themeVal === t);
});
}
function initTheme() {
const t = localStorage.getItem('posimai-roadmap-theme') || 'system';
applyTheme(t);
}
// ── Toast (fallback if base.js doesn't provide) ────────────────────────────────
function showToast(msg) {
if (typeof window.showToast === 'function' && window.showToast !== showToast) {
window.showToast(msg); return;
}
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2800);
}
// ── Settings panel ─────────────────────────────────────────────────────────────
function openSettings() {
document.getElementById('settingsPanel').classList.add('open');
document.getElementById('overlay').classList.add('open');
document.getElementById('settingsOpenBtn').setAttribute('aria-expanded', 'true');
}
function closeSettings() {
document.getElementById('settingsPanel').classList.remove('open');
document.getElementById('overlay').classList.remove('open');
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 ───────────────────────────────────────────────────────────
document.addEventListener('click', e => {
// Hamburger
if (e.target.closest('#menuBtn')) { openSidebar(); return; }
if (e.target.closest('#closeSidebarBtn')) { closeSidebar(); return; }
if (e.target.id === 'sidebarOverlay') { closeSidebar(); return; }
// Settings
if (e.target.closest('#settingsOpenBtn')) { openSettings(); return; }
if (e.target.closest('#settingsCloseBtn')) { closeSettings(); return; }
if (e.target.id === 'overlay') { closeSettings(); return; }
// Theme
const tb = e.target.closest('.theme-btn');
if (tb) { applyTheme(tb.dataset.themeVal); return; }
// Sidebar nav
const navItem = e.target.closest('.nav-item[data-nav]');
if (navItem) {
e.preventDefault();
const nav = navItem.dataset.nav;
if (nav === 'board') { switchView('board'); }
else if (nav === 'ideas') { switchView('ideas'); }
else if (nav === 'app') { switchView('app', navItem.dataset.appId); }
return;
}
// App search clear on X
if (e.target.closest('#appSearch')) return; // let input handle
// Board task card
const card = e.target.closest('.task-card');
if (card && !e.target.closest('a')) {
openTaskModal({ taskId: card.dataset.id, appId: card.dataset.app }); return;
}
// Add task buttons (board col + app view)
const addBtn = e.target.closest('[data-add-status]');
if (addBtn) {
openTaskModal({ defaultStatus: addBtn.dataset.addStatus, defaultApp: addBtn.dataset.addApp || null }); return;
}
// Idea card
const icard = e.target.closest('.idea-card[data-idea-id]');
if (icard) { openIdeaModal(icard.dataset.ideaId); return; }
// Idea filter
const ifilt = e.target.closest('[data-ifilter]');
if (ifilt) {
ideaFilter = ifilt.dataset.ifilter;
renderIdeas(); lucide.createIcons(); return;
}
// Add idea
if (e.target.closest('#addIdeaBtn')) { openIdeaModal(null); return; }
// Header add button
if (e.target.closest('#addHeaderBtn')) {
if (currentView === 'ideas') openIdeaModal(null);
else openTaskModal({ defaultStatus: 'next', defaultApp: currentApp || null });
return;
}
// FAB
if (e.target.closest('#fabBtn')) { openTaskModal({ defaultStatus: 'next', defaultApp: currentApp || null }); return; }
// Status pickers
const spick = e.target.closest('.spick');
if (spick) {
const picker = spick.closest('.status-picker');
if (picker) pickStatus(spick.dataset.s, picker.id);
return;
}
// Task modal controls
if (e.target.closest('#taskModalClose') || e.target.closest('#taskModalCancel')) { closeTaskModal(); return; }
if (e.target.id === 'taskModal') { closeTaskModal(); return; }
if (e.target.closest('#saveTaskBtn')) { saveTask(); return; }
if (e.target.closest('#deleteTaskBtn')) { deleteTask(); return; }
// Idea modal controls
if (e.target.closest('#ideaModalClose') || e.target.closest('#ideaModalCancel')) { closeIdeaModal(); return; }
if (e.target.id === 'ideaModal') { closeIdeaModal(); return; }
if (e.target.closest('#saveIdeaBtn')) { saveIdea(); return; }
if (e.target.closest('#deleteIdeaBtn')) { deleteIdea(); return; }
// Settings actions
if (e.target.closest('#exportJsonBtn')) {
navigator.clipboard.writeText(JSON.stringify(data, null, 2))
.then(() => showToast('JSON をクリップボードにコピーしました'))
.catch(() => showToast('コピーに失敗しました'));
return;
}
if (e.target.closest('#reloadJsonBtn')) {
if (confirm('ファイルから再読み込みします。ローカルの変更は失われます。')) {
localStorage.removeItem(STORAGE_KEY);
loadData(true).then(() => { renderAll(); showToast('再読み込みしました'); });
}
return;
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeTaskModal(); closeIdeaModal(); closeSettings(); closeSidebar(); }
if (e.key === 'Enter' && (e.target.matches('.task-card') || e.target.matches('.idea-card'))) {
e.target.click();
}
});
// App search
document.getElementById('appSearch').addEventListener('input', e => {
appSearch = e.target.value;
renderAppNav();
lucide.createIcons({ nodes: [document.getElementById('appNavList')] });
});
// ── Boot ───────────────────────────────────────────────────────────────────────
loadData().then(() => {
initTheme();
renderAll();
attachDnd();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
});
</script>
</body>
</html>