@dtoolkit/dwork 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/dist/cli/configure.d.ts +2 -0
  4. package/dist/cli/configure.d.ts.map +1 -0
  5. package/dist/cli/configure.js +83 -0
  6. package/dist/cli/configure.js.map +1 -0
  7. package/dist/cli/index.d.ts +3 -0
  8. package/dist/cli/index.d.ts.map +1 -0
  9. package/dist/cli/index.js +76 -0
  10. package/dist/cli/index.js.map +1 -0
  11. package/dist/cli/init.d.ts +4 -0
  12. package/dist/cli/init.d.ts.map +1 -0
  13. package/dist/cli/init.js +143 -0
  14. package/dist/cli/init.js.map +1 -0
  15. package/dist/cli/keys.d.ts +11 -0
  16. package/dist/cli/keys.d.ts.map +1 -0
  17. package/dist/cli/keys.js +85 -0
  18. package/dist/cli/keys.js.map +1 -0
  19. package/dist/cli/start.d.ts +2 -0
  20. package/dist/cli/start.d.ts.map +1 -0
  21. package/dist/cli/start.js +53 -0
  22. package/dist/cli/start.js.map +1 -0
  23. package/dist/cli/status.d.ts +2 -0
  24. package/dist/cli/status.d.ts.map +1 -0
  25. package/dist/cli/status.js +36 -0
  26. package/dist/cli/status.js.map +1 -0
  27. package/dist/cli/sync.d.ts +4 -0
  28. package/dist/cli/sync.d.ts.map +1 -0
  29. package/dist/cli/sync.js +38 -0
  30. package/dist/cli/sync.js.map +1 -0
  31. package/dist/core/config.d.ts +21 -0
  32. package/dist/core/config.d.ts.map +1 -0
  33. package/dist/core/config.js +31 -0
  34. package/dist/core/config.js.map +1 -0
  35. package/dist/core/db.d.ts +4 -0
  36. package/dist/core/db.d.ts.map +1 -0
  37. package/dist/core/db.js +90 -0
  38. package/dist/core/db.js.map +1 -0
  39. package/dist/core/indexer.d.ts +9 -0
  40. package/dist/core/indexer.d.ts.map +1 -0
  41. package/dist/core/indexer.js +145 -0
  42. package/dist/core/indexer.js.map +1 -0
  43. package/dist/core/models.d.ts +113 -0
  44. package/dist/core/models.d.ts.map +1 -0
  45. package/dist/core/models.js +53 -0
  46. package/dist/core/models.js.map +1 -0
  47. package/dist/core/parser.d.ts +20 -0
  48. package/dist/core/parser.d.ts.map +1 -0
  49. package/dist/core/parser.js +126 -0
  50. package/dist/core/parser.js.map +1 -0
  51. package/dist/core/templates.d.ts +5 -0
  52. package/dist/core/templates.d.ts.map +1 -0
  53. package/dist/core/templates.js +84 -0
  54. package/dist/core/templates.js.map +1 -0
  55. package/dist/dashboard/index.html +1127 -0
  56. package/dist/dashboard/logo-dwork-complete.png +0 -0
  57. package/dist/dashboard/logo-dwork.png +0 -0
  58. package/dist/dashboard/server.d.ts +2 -0
  59. package/dist/dashboard/server.d.ts.map +1 -0
  60. package/dist/dashboard/server.js +27 -0
  61. package/dist/dashboard/server.js.map +1 -0
  62. package/dist/mcp/server.d.ts +5 -0
  63. package/dist/mcp/server.d.ts.map +1 -0
  64. package/dist/mcp/server.js +242 -0
  65. package/dist/mcp/server.js.map +1 -0
  66. package/dist/server/index.d.ts +7 -0
  67. package/dist/server/index.d.ts.map +1 -0
  68. package/dist/server/index.js +63 -0
  69. package/dist/server/index.js.map +1 -0
  70. package/dist/server/routes/docs.d.ts +3 -0
  71. package/dist/server/routes/docs.d.ts.map +1 -0
  72. package/dist/server/routes/docs.js +53 -0
  73. package/dist/server/routes/docs.js.map +1 -0
  74. package/dist/server/routes/health.d.ts +3 -0
  75. package/dist/server/routes/health.d.ts.map +1 -0
  76. package/dist/server/routes/health.js +27 -0
  77. package/dist/server/routes/health.js.map +1 -0
  78. package/dist/server/routes/keys.d.ts +3 -0
  79. package/dist/server/routes/keys.d.ts.map +1 -0
  80. package/dist/server/routes/keys.js +58 -0
  81. package/dist/server/routes/keys.js.map +1 -0
  82. package/dist/server/routes/overview.d.ts +3 -0
  83. package/dist/server/routes/overview.d.ts.map +1 -0
  84. package/dist/server/routes/overview.js +12 -0
  85. package/dist/server/routes/overview.js.map +1 -0
  86. package/dist/server/routes/permissions.d.ts +3 -0
  87. package/dist/server/routes/permissions.d.ts.map +1 -0
  88. package/dist/server/routes/permissions.js +9 -0
  89. package/dist/server/routes/permissions.js.map +1 -0
  90. package/dist/server/routes/projects.d.ts +3 -0
  91. package/dist/server/routes/projects.d.ts.map +1 -0
  92. package/dist/server/routes/projects.js +50 -0
  93. package/dist/server/routes/projects.js.map +1 -0
  94. package/dist/server/routes/search.d.ts +3 -0
  95. package/dist/server/routes/search.d.ts.map +1 -0
  96. package/dist/server/routes/search.js +10 -0
  97. package/dist/server/routes/search.js.map +1 -0
  98. package/dist/server/routes/sync.d.ts +3 -0
  99. package/dist/server/routes/sync.d.ts.map +1 -0
  100. package/dist/server/routes/sync.js +11 -0
  101. package/dist/server/routes/sync.js.map +1 -0
  102. package/dist/server/routes/tasks.d.ts +3 -0
  103. package/dist/server/routes/tasks.d.ts.map +1 -0
  104. package/dist/server/routes/tasks.js +45 -0
  105. package/dist/server/routes/tasks.js.map +1 -0
  106. package/dist/service/docs.d.ts +26 -0
  107. package/dist/service/docs.d.ts.map +1 -0
  108. package/dist/service/docs.js +137 -0
  109. package/dist/service/docs.js.map +1 -0
  110. package/dist/service/overview.d.ts +18 -0
  111. package/dist/service/overview.d.ts.map +1 -0
  112. package/dist/service/overview.js +46 -0
  113. package/dist/service/overview.js.map +1 -0
  114. package/dist/service/projects.d.ts +34 -0
  115. package/dist/service/projects.d.ts.map +1 -0
  116. package/dist/service/projects.js +99 -0
  117. package/dist/service/projects.js.map +1 -0
  118. package/dist/service/search.d.ts +13 -0
  119. package/dist/service/search.d.ts.map +1 -0
  120. package/dist/service/search.js +59 -0
  121. package/dist/service/search.js.map +1 -0
  122. package/dist/service/sync.d.ts +9 -0
  123. package/dist/service/sync.d.ts.map +1 -0
  124. package/dist/service/sync.js +95 -0
  125. package/dist/service/sync.js.map +1 -0
  126. package/dist/service/tasks.d.ts +40 -0
  127. package/dist/service/tasks.d.ts.map +1 -0
  128. package/dist/service/tasks.js +156 -0
  129. package/dist/service/tasks.js.map +1 -0
  130. package/dist/service/utils.d.ts +2 -0
  131. package/dist/service/utils.d.ts.map +1 -0
  132. package/dist/service/utils.js +5 -0
  133. package/dist/service/utils.js.map +1 -0
  134. package/package.json +62 -0
@@ -0,0 +1,1127 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>dwork — Project Manager</title>
6
+ <link rel="icon" type="image/png" href="/logo-dwork.png">
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
10
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
11
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
12
+ <style>
13
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
14
+ :root{
15
+ --bg:oklch(0.965 0.008 248);
16
+ --sidebar-bg:oklch(0.99 0.003 250);
17
+ --surface:oklch(1 0 0);
18
+ --surface-2:oklch(0.95 0.01 248);
19
+ --border:oklch(0.87 0.018 248);
20
+ --border-subtle:oklch(0.92 0.01 248);
21
+ --text:oklch(0.20 0.06 252);
22
+ --text-2:oklch(0.42 0.05 250);
23
+ --text-3:oklch(0.60 0.04 248);
24
+ --accent:oklch(0.50 0.22 252);
25
+ --accent-light:oklch(0.94 0.06 252);
26
+ --green:oklch(0.55 0.18 148);
27
+ --green-bg:oklch(0.92 0.06 148);
28
+ --yellow:oklch(0.60 0.18 80);
29
+ --yellow-bg:oklch(0.93 0.06 80);
30
+ --red:oklch(0.55 0.18 25);
31
+ --red-bg:oklch(0.93 0.06 25);
32
+ --blue:oklch(0.50 0.18 252);
33
+ --blue-bg:oklch(0.92 0.06 252);
34
+ --orange:oklch(0.58 0.16 55);
35
+ --orange-bg:oklch(0.93 0.06 55);
36
+ --font:'DM Sans',system-ui,sans-serif;
37
+ --mono:'JetBrains Mono',monospace;
38
+ --sb:220px;
39
+ --r:8px;
40
+ }
41
+ html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
42
+
43
+ /* ── LAYOUT ─────────────────────────────── */
44
+ .shell{display:flex;min-height:100vh}
45
+ .sidebar{
46
+ width:var(--sb);flex-shrink:0;position:fixed;top:0;left:0;bottom:0;
47
+ background:var(--sidebar-bg);border-right:1px solid var(--border-subtle);
48
+ display:flex;flex-direction:column;overflow:hidden;z-index:50;
49
+ }
50
+ .sb-logo{padding:24px 20px 8px;display:flex;flex-direction:column;align-items:center;gap:8px}
51
+ .sb-logo img{width:100px;height:auto;display:block}
52
+ .sb-logo-info{display:flex;flex-direction:column;align-items:center;gap:4px}
53
+ .sb-logo-text{font-size:18px;font-weight:700;letter-spacing:-.03em;color:var(--text)}
54
+ .sb-version{
55
+ font-size:10px;font-family:var(--mono);font-weight:500;
56
+ color:var(--text-3);background:var(--surface-2);
57
+ border:1px solid var(--border-subtle);
58
+ border-radius:4px;padding:2px 7px;display:inline-block;
59
+ }
60
+ .sb-nav{flex:1;padding:16px 10px;display:flex;flex-direction:column;gap:2px}
61
+ .sb-link{
62
+ display:flex;align-items:center;gap:10px;padding:9px 12px;
63
+ border-radius:var(--r);cursor:pointer;border:none;background:none;
64
+ font-family:var(--font);font-size:13.5px;font-weight:500;
65
+ color:var(--text-2);width:100%;text-align:left;
66
+ transition:background .12s,color .12s;
67
+ }
68
+ .sb-link:hover{background:var(--surface-2);color:var(--text)}
69
+ .sb-link.active{background:var(--accent-light);color:var(--accent);font-weight:600}
70
+ .sb-link svg{flex-shrink:0;opacity:.7}
71
+ .sb-link.active svg{opacity:1}
72
+ .sb-divider{height:1px;background:var(--border-subtle);margin:10px 12px}
73
+ .sb-footer{padding:16px;display:flex;flex-direction:column;gap:10px}
74
+ .palette-toggle{
75
+ display:flex;align-items:center;justify-content:space-between;
76
+ background:var(--surface-2);border:1px solid var(--border-subtle);
77
+ border-radius:var(--r);padding:8px 12px;
78
+ }
79
+ .palette-toggle-label{font-size:12px;font-weight:500;color:var(--text-2)}
80
+ .palette-toggle-switch{
81
+ display:flex;gap:2px;background:var(--bg);
82
+ border:1px solid var(--border-subtle);border-radius:6px;padding:2px;
83
+ }
84
+ .palette-toggle-opt{
85
+ padding:3px 9px;border-radius:4px;font-size:11px;font-weight:600;
86
+ border:none;background:none;font-family:var(--font);cursor:pointer;
87
+ color:var(--text-3);transition:all .12s;
88
+ }
89
+ .palette-toggle-opt.active{background:var(--surface);color:var(--text);box-shadow:0 1px 3px oklch(0 0 0/.1)}
90
+ .sb-user{display:flex;align-items:center;gap:8px;padding:8px 12px;margin-bottom:8px}
91
+ .sb-user-info{flex:1;min-width:0}
92
+ .sb-user-name{font-size:12px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
93
+ .sb-user-role{font-size:10px;color:var(--text-3);font-family:var(--mono)}
94
+ .sb-logout{padding:3px 8px;border:1px solid var(--border);border-radius:5px;background:none;color:var(--text-3);font-size:10px;cursor:pointer;flex-shrink:0;transition:all .15s}
95
+ .sb-logout:hover{border-color:#ef4444;color:#ef4444;background:oklch(0.95 0.05 25)}
96
+
97
+ .main{margin-left:var(--sb);flex:1;min-width:0}
98
+ .topbar{
99
+ position:sticky;top:0;z-index:40;
100
+ background:oklch(from var(--bg) l c h / .92);backdrop-filter:blur(12px);
101
+ border-bottom:1px solid var(--border-subtle);
102
+ display:flex;align-items:center;gap:12px;padding:0 32px;height:52px;
103
+ }
104
+ .topbar-title{font-size:14px;font-weight:600;color:var(--text);flex:none}
105
+ .page{padding:28px 32px 80px}
106
+ .page-title{font-size:24px;font-weight:700;letter-spacing:-.03em;margin-bottom:20px}
107
+ .page-subtitle{font-size:13px;color:var(--text-2);margin-bottom:24px}
108
+
109
+ /* ── LOGIN ──────────────────────────────── */
110
+ .login-bg{
111
+ min-height:100vh;display:flex;align-items:center;justify-content:center;
112
+ background:radial-gradient(ellipse 90% 70% at 50% -10%,oklch(.42 .15 252/.18) 0%,var(--bg) 60%);
113
+ position:relative;
114
+ }
115
+ .login-bg::before{
116
+ content:'';position:absolute;inset:0;
117
+ background-image:linear-gradient(var(--border-subtle) 1px,transparent 1px),linear-gradient(90deg,var(--border-subtle) 1px,transparent 1px);
118
+ background-size:36px 36px;opacity:.5;
119
+ }
120
+ .login-card{
121
+ position:relative;z-index:1;background:var(--surface);
122
+ border:1px solid var(--border);border-radius:16px;
123
+ padding:40px 44px;width:400px;max-width:calc(100vw - 32px);text-align:center;
124
+ box-shadow:0 20px 60px oklch(0 0 0/.12),0 0 0 1px oklch(from var(--accent) l c h /.08);
125
+ }
126
+ .login-logo{width:280px;max-width:100%;height:auto;margin:0 auto 28px;display:block}
127
+ .login-token-label{font-size:13px;color:var(--text-2);margin-bottom:10px;text-align:left}
128
+ .login-input{
129
+ width:100%;padding:11px 14px;background:var(--surface-2);
130
+ border:1.5px solid var(--border);border-radius:var(--r);
131
+ font-family:var(--mono);font-size:13.5px;color:var(--text);outline:none;
132
+ transition:border-color .15s;margin-bottom:10px;
133
+ }
134
+ .login-input:focus{border-color:var(--accent)}
135
+ .login-input.err{border-color:#e53e3e}
136
+ .login-hint{font-size:12px;color:var(--text-3);margin-bottom:14px}
137
+ .login-err{font-size:12px;color:#e53e3e;margin-bottom:14px}
138
+ .login-btn{
139
+ width:100%;padding:11px;background:var(--accent);color:#fff;border:none;
140
+ border-radius:var(--r);font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;
141
+ transition:opacity .12s;
142
+ }
143
+ .login-btn:hover{opacity:.9}
144
+
145
+ /* ── STATS ──────────────────────────────── */
146
+ .stats-row{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:28px}
147
+ .stat-card{
148
+ background:var(--surface);border:1px solid var(--border-subtle);
149
+ border-radius:var(--r);padding:18px 16px 14px;
150
+ transition:border-color .15s,box-shadow .15s;
151
+ }
152
+ .stat-card:hover{border-color:var(--border);box-shadow:0 2px 12px oklch(0 0 0/.06)}
153
+ .stat-num{font-size:28px;font-weight:700;letter-spacing:-.04em;line-height:1;margin-bottom:5px;color:var(--accent);font-family:var(--mono)}
154
+ .stat-lbl{font-size:10px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--text-3)}
155
+
156
+ /* ── CARDS ──────────────────────────────── */
157
+ .card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
158
+ .card{
159
+ background:var(--surface);border:1px solid var(--border-subtle);
160
+ border-radius:var(--r);padding:18px;cursor:pointer;
161
+ transition:transform .12s,box-shadow .15s,border-color .15s;
162
+ position:relative;overflow:hidden;
163
+ }
164
+ .card::before{content:'';position:absolute;top:0;left:0;right:0;height:3px;background:var(--accent)}
165
+ .card:hover{transform:translateY(-2px);box-shadow:0 8px 24px oklch(0 0 0/.08);border-color:var(--border)}
166
+ .card-name{font-size:15px;font-weight:600;letter-spacing:-.02em;margin-bottom:4px}
167
+ .card-desc{font-size:12px;color:var(--text-2);margin-bottom:10px}
168
+ .card-stats{display:flex;gap:6px;flex-wrap:wrap}
169
+ .stat-badge{
170
+ display:inline-flex;align-items:center;
171
+ font-size:10.5px;font-weight:700;padding:2px 7px;border-radius:4px;
172
+ letter-spacing:.04em;text-transform:uppercase;font-family:var(--mono);
173
+ }
174
+
175
+ /* ── KANBAN ─────────────────────────────── */
176
+ .kanban{display:flex;gap:12px;overflow-x:auto;padding-bottom:16px;min-height:400px}
177
+ .kanban-col{flex:1 1 0;min-width:180px;background:var(--surface-2);border-radius:var(--r);padding:12px;display:flex;flex-direction:column}
178
+ .kanban-col-header{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:10px;display:flex;align-items:center;justify-content:space-between}
179
+ .kanban-col-count{font-size:11px;font-weight:500;color:var(--text-3);font-family:var(--mono)}
180
+ .kanban-cards{flex:1;display:flex;flex-direction:column;gap:8px;min-height:60px}
181
+ .kanban-cards.drag-over{background:var(--accent-light);border-radius:6px}
182
+ .kanban-card{
183
+ background:var(--surface);border:1px solid var(--border-subtle);border-radius:6px;padding:10px 12px;
184
+ cursor:grab;transition:box-shadow .12s,opacity .12s,transform .12s;font-size:13px;
185
+ touch-action:none;
186
+ }
187
+ .kanban-card:active{cursor:grabbing}
188
+ .kanban-card.dragging{opacity:.4}
189
+ .kanban-card:hover{box-shadow:0 2px 8px oklch(0 0 0/.06);transform:translateY(-1px)}
190
+ .kanban-card-title{font-weight:500;margin-bottom:6px;line-height:1.3}
191
+ .kanban-card-meta{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
192
+ .kanban-card-meta span{font-size:10px;font-weight:600;padding:2px 6px;border-radius:3px;font-family:var(--mono)}
193
+
194
+ /* Priority badges */
195
+ .p0{background:var(--red-bg);color:var(--red)}
196
+ .p1{background:var(--orange-bg);color:var(--orange)}
197
+ .p2{background:var(--blue-bg);color:var(--blue)}
198
+ .p3{background:var(--surface-2);color:var(--text-3)}
199
+
200
+ /* Status badges */
201
+ .s-todo{background:var(--surface-2);color:var(--text-3)}
202
+ .s-refinement{background:var(--yellow-bg);color:var(--yellow)}
203
+ .s-doing{background:var(--blue-bg);color:var(--blue)}
204
+ .s-blocked{background:var(--red-bg);color:var(--red)}
205
+ .s-done{background:var(--green-bg);color:var(--green)}
206
+
207
+ /* ── TABS ──────────────────────────────── */
208
+ .tabs{display:flex;gap:2px;margin-bottom:20px;border-bottom:1px solid var(--border)}
209
+ .tab{padding:10px 16px;font-size:13px;font-weight:500;color:var(--text-2);cursor:pointer;border:none;background:none;font-family:var(--font);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .12s}
210
+ .tab:hover{color:var(--text)}
211
+ .tab.active{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
212
+
213
+ /* ── TABLE ─────────────────────────────── */
214
+ .task-table{width:100%;border-collapse:collapse;font-size:13px}
215
+ .task-table th{text-align:left;padding:10px 12px;color:var(--text-3);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;border-bottom:2px solid var(--border)}
216
+ .task-table td{padding:10px 12px;border-bottom:1px solid var(--border-subtle)}
217
+ .task-table tr:hover td{background:var(--surface-2)}
218
+
219
+ /* ── SEARCH ────────────────────────────── */
220
+ .search-bar{display:flex;gap:8px;margin-bottom:20px}
221
+ .search-input{
222
+ flex:1;padding:10px 14px;background:var(--surface);
223
+ border:1px solid var(--border-subtle);border-radius:20px;
224
+ font-family:var(--font);font-size:14px;outline:none;
225
+ transition:border-color .15s;
226
+ }
227
+ .search-input:focus{border-color:var(--accent)}
228
+ .search-btn{padding:10px 20px;background:var(--accent);color:#fff;border:none;border-radius:20px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer}
229
+ .search-result{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--r);padding:14px 16px;margin-bottom:8px;transition:border-color .12s}
230
+ .search-result:hover{border-color:var(--border)}
231
+ .search-result-type{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
232
+ .search-result-title{font-size:14px;font-weight:500}
233
+ .search-result-meta{font-size:11px;color:var(--text-3);margin-top:4px;font-family:var(--mono)}
234
+
235
+ /* ── FORMS ─────────────────────────────── */
236
+ .form-row{display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;margin-bottom:12px}
237
+ .form-input{flex:1;padding:8px 12px;border:1px solid var(--border);border-radius:6px;background:var(--surface);font-family:var(--font);font-size:13px;color:var(--text);outline:none}
238
+ .form-input:focus{border-color:var(--accent)}
239
+ .form-btn{padding:8px 16px;border:none;border-radius:6px;background:var(--accent);color:white;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer}
240
+ .form-btn:hover{opacity:.85}
241
+
242
+ /* ── MODAL ─────────────────────────────── */
243
+ .modal-overlay{position:fixed;inset:0;z-index:100;background:oklch(0 0 0/.4);display:flex;align-items:center;justify-content:center;padding:16px}
244
+ .modal{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:28px 32px;width:420px;max-width:100%;box-shadow:0 20px 60px oklch(0 0 0/.15)}
245
+ .modal-title{font-size:18px;font-weight:700;letter-spacing:-.02em;margin-bottom:20px}
246
+ .modal-field{display:flex;flex-direction:column;gap:5px;margin-bottom:14px}
247
+ .modal-field label{font-size:11px;font-weight:600;color:var(--text-3);text-transform:uppercase;letter-spacing:.3px}
248
+ .modal-field input,.modal-field select{padding:10px 12px;border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface-2);font-family:var(--font);font-size:13px;color:var(--text);outline:none;transition:border-color .15s}
249
+ .modal-field input:focus,.modal-field select:focus{border-color:var(--accent)}
250
+ .modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:20px}
251
+ .modal-actions .btn-cancel{padding:9px 18px;border:1px solid var(--border);border-radius:var(--r);background:none;font-family:var(--font);font-size:13px;font-weight:500;color:var(--text-2);cursor:pointer;transition:all .12s}
252
+ .modal-actions .btn-cancel:hover{border-color:var(--text-3);color:var(--text)}
253
+ .modal-actions .btn-primary{padding:9px 18px;border:none;border-radius:var(--r);background:var(--accent);color:#fff;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;transition:opacity .12s}
254
+ .modal-actions .btn-primary:hover{opacity:.9}
255
+ .modal-wide{width:640px}
256
+ .task-detail-header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:16px}
257
+ .task-detail-header .badge{font-size:10px;padding:3px 8px;border-radius:4px;font-weight:600;font-family:var(--mono)}
258
+ .task-detail-header .badge-status{background:var(--accent-light);color:var(--accent)}
259
+ .task-detail-header .badge-priority{background:var(--surface-2);color:var(--text-2)}
260
+ .task-detail-header .badge-project{background:var(--accent-light);color:var(--accent)}
261
+ .task-detail-editor{width:100%;min-height:260px;padding:12px;border:1.5px solid var(--border);border-radius:var(--r);background:var(--surface-2);font-family:var(--mono);font-size:13px;line-height:1.6;color:var(--text);resize:vertical;outline:none;transition:border-color .15s}
262
+ .task-detail-editor:focus{border-color:var(--accent)}
263
+ .task-detail-empty{text-align:center;padding:40px 0;color:var(--text-3);font-size:13px}
264
+ .task-detail-saved{color:var(--accent);font-size:12px;font-weight:500;opacity:0;transition:opacity .2s}
265
+ .task-detail-saved.show{opacity:1}
266
+
267
+ /* ── MISC ──────────────────────────────── */
268
+ .back-btn{
269
+ display:inline-flex;align-items:center;gap:6px;
270
+ background:none;border:none;color:var(--text-2);
271
+ font-family:var(--font);font-size:13px;font-weight:500;padding:4px 0;
272
+ cursor:pointer;transition:color .12s;margin-bottom:16px;
273
+ }
274
+ .back-btn:hover{color:var(--text)}
275
+
276
+ .del-btn{
277
+ background:none;border:none;cursor:pointer;color:var(--text-3);
278
+ font-size:16px;line-height:1;padding:2px 4px;border-radius:3px;
279
+ transition:color .12s,background .12s;flex-shrink:0;
280
+ }
281
+ .del-btn:hover{color:var(--red);background:var(--red-bg)}
282
+
283
+ .md-content{font-size:14px;line-height:1.7;color:var(--text)}
284
+ .md-content h1,.md-content h2,.md-content h3{font-weight:600;margin:16px 0 8px}
285
+ .md-content code{font-family:var(--mono);font-size:12px;background:var(--surface-2);padding:2px 6px;border-radius:3px}
286
+
287
+ .loading{display:flex;align-items:center;justify-content:center;height:60vh;color:var(--text-3);font-size:13px}
288
+ .empty{color:var(--text-3);font-size:13px;padding:32px;text-align:center;background:var(--surface);border-radius:var(--r);border:1px solid var(--border-subtle)}
289
+
290
+ .sec-head{display:flex;align-items:center;gap:8px;margin-bottom:12px}
291
+ .sec-title{font-size:11px;font-weight:700;letter-spacing:.09em;text-transform:uppercase;color:var(--text-3)}
292
+ .sec-badge{font-size:11px;font-family:var(--mono);color:var(--text-3);background:var(--surface-2);border:1px solid var(--border-subtle);padding:1px 8px;border-radius:20px}
293
+
294
+ /* ── MOBILE HAMBURGER ──────────────────── */
295
+ .mobile-toggle{
296
+ display:none;position:fixed;top:12px;left:12px;z-index:60;
297
+ width:32px;height:32px;border-radius:6px;border:none;
298
+ background:none;cursor:pointer;align-items:center;justify-content:center;
299
+ padding:0;
300
+ }
301
+ .mobile-toggle svg{color:var(--text-2)}
302
+ .sidebar-overlay{display:none;position:fixed;inset:0;z-index:45;background:oklch(0 0 0/.3)}
303
+
304
+ /* ── RESPONSIVE ────────────────────────── */
305
+ @media(max-width:768px){
306
+ .mobile-toggle{display:flex}
307
+ .sidebar{transform:translateX(-100%);transition:transform .2s}
308
+ .sidebar.open{transform:translateX(0)}
309
+ .sidebar-overlay.open{display:block}
310
+ .main{margin-left:0}
311
+ .topbar{padding:0 16px;padding-left:52px}
312
+ .page{padding:20px 16px 60px}
313
+ .page-title{font-size:20px}
314
+ .stats-row{grid-template-columns:1fr 1fr}
315
+ .card-grid{grid-template-columns:1fr}
316
+ .kanban{flex-direction:column}
317
+ .kanban-col{min-width:0}
318
+ .tab-tasks{display:none}
319
+ .task-table thead{display:none}
320
+ .task-table,.task-table tbody,.task-table tr,.task-table td{display:block;width:100%}
321
+ .task-table tr{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--r);padding:12px;margin-bottom:8px;position:relative}
322
+ .task-table td{border:none;padding:2px 0;font-size:12px}
323
+ .task-table td:first-child{font-size:14px;font-weight:600;margin-bottom:6px}
324
+ .task-table td:last-child{position:absolute;top:12px;right:12px}
325
+ .doc-table td{display:inline-block;width:auto;padding:2px 4px}
326
+ .doc-table td:first-child{display:block;width:100%;margin-bottom:4px}
327
+ .doc-table td:last-child{position:static;display:block;width:100%;margin-top:4px;word-break:break-all}
328
+ .search-bar{flex-direction:column}
329
+ .search-btn{width:100%}
330
+ .form-row{flex-direction:column}
331
+ .form-input{width:100%}
332
+ .login-card{padding:28px 20px}
333
+ .login-logo{width:100%}
334
+ }
335
+ </style>
336
+ </head>
337
+ <body>
338
+ <div id="root"></div>
339
+ <script type="text/babel">
340
+ const { useState, useEffect, useCallback } = React;
341
+
342
+ const TOKEN_KEY = 'dwork_token';
343
+ const THEME_KEY = 'dwork_theme';
344
+ const API_BASE = `http://${window.location.hostname}:${Number(window.location.port) - 1}`;
345
+
346
+ /* ── PALETTES ─────────────────────────────────────────── */
347
+ const PALETTES = {
348
+ cloud: {'--bg':'oklch(0.965 0.008 248)','--sidebar-bg':'oklch(0.99 0.003 250)','--surface':'oklch(1 0 0)','--surface-2':'oklch(0.95 0.010 248)','--border':'oklch(0.87 0.018 248)','--border-subtle':'oklch(0.92 0.010 248)','--text':'oklch(0.20 0.06 252)','--text-2':'oklch(0.42 0.05 250)','--text-3':'oklch(0.60 0.04 248)','--accent':'oklch(0.50 0.22 252)','--accent-light':'oklch(0.94 0.06 252)'},
349
+ ocean: {'--bg':'oklch(0.11 0.04 254)','--sidebar-bg':'oklch(0.13 0.046 254)','--surface':'oklch(0.16 0.04 252)','--surface-2':'oklch(0.20 0.04 250)','--border':'oklch(0.26 0.05 250)','--border-subtle':'oklch(0.19 0.04 252)','--text':'oklch(0.93 0.015 245)','--text-2':'oklch(0.65 0.06 245)','--text-3':'oklch(0.47 0.05 248)','--accent':'oklch(0.68 0.20 245)','--accent-light':'oklch(0.68 0.20 245 /.15)'},
350
+ };
351
+
352
+ function applyPalette(name) {
353
+ const p = PALETTES[name] || PALETTES.cloud;
354
+ Object.entries(p).forEach(([k, v]) => document.documentElement.style.setProperty(k, v));
355
+ }
356
+
357
+ /* ── API HELPER ───────────────────────────────────────── */
358
+ function api(path, opts = {}) {
359
+ const token = localStorage.getItem(TOKEN_KEY) || '';
360
+ const headers = { 'Authorization': `Bearer ${token}` };
361
+ if (opts.body) headers['Content-Type'] = 'application/json';
362
+ return fetch(`${API_BASE}${path}`, { ...opts, headers }).then(r => {
363
+ if (!r.ok) throw new Error(`${r.status}`);
364
+ return r.json();
365
+ });
366
+ }
367
+
368
+ /* ── ROUTER ───────────────────────────────────────────── */
369
+ function useRouter() {
370
+ const [hash, setHash] = useState(window.location.hash || '#/');
371
+ useEffect(() => {
372
+ const h = () => setHash(window.location.hash || '#/');
373
+ window.addEventListener('hashchange', h);
374
+ return () => window.removeEventListener('hashchange', h);
375
+ }, []);
376
+ const nav = (p) => { window.location.hash = p; };
377
+ const route = hash.replace('#', '') || '/';
378
+ const segs = route.split('/').filter(Boolean);
379
+ return { route, segs, nav };
380
+ }
381
+
382
+ /* ── SVG ICONS ────────────────────────────────────────── */
383
+ const Icon = {
384
+ projects: () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
385
+ overview: () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg>,
386
+ search: () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>,
387
+ };
388
+
389
+ /* ── LOGIN ────────────────────────────────────────────── */
390
+ function LoginScreen({ onAuth }) {
391
+ const [token, setToken] = useState('');
392
+ const [error, setError] = useState('');
393
+ const handleSubmit = async (e) => {
394
+ e.preventDefault();
395
+ try {
396
+ localStorage.setItem(TOKEN_KEY, token);
397
+ await api('/overview');
398
+ onAuth();
399
+ } catch {
400
+ setError('Invalid token or server not running');
401
+ localStorage.removeItem(TOKEN_KEY);
402
+ }
403
+ };
404
+ return (
405
+ <div className="login-bg">
406
+ <form className="login-card" onSubmit={handleSubmit}>
407
+ <img className="login-logo" src="/logo-dwork-complete.png" alt="dwork" />
408
+ <div className="login-token-label">Access token</div>
409
+ <input className={`login-input ${error ? 'err' : ''}`} type="password" placeholder="sk-dwk_..."
410
+ value={token} onChange={e => { setToken(e.target.value); setError(''); }} autoFocus />
411
+ {error ? <div className="login-err">{error}</div> : <div className="login-hint">Find your token in ~/.dwork/config.json</div>}
412
+ <button className="login-btn" type="submit">Connect</button>
413
+ </form>
414
+ </div>
415
+ );
416
+ }
417
+
418
+ /* ── SIDEBAR ──────────────────────────────────────────── */
419
+ function Sidebar({ view, nav, onLogout, sidebarOpen }) {
420
+ const [palette, setPalette] = useState(localStorage.getItem(THEME_KEY) || 'cloud');
421
+ useEffect(() => { applyPalette(palette); }, [palette]);
422
+ const togglePalette = (name) => { setPalette(name); localStorage.setItem(THEME_KEY, name); };
423
+
424
+ return (
425
+ <div className={`sidebar ${sidebarOpen ? 'open' : ''}`}>
426
+ <div className="sb-logo">
427
+ <img src="/logo-dwork.png" alt="dwork" />
428
+ <div className="sb-logo-info">
429
+ <span className="sb-logo-text">dwork</span>
430
+ <span className="sb-version">v0.1.0</span>
431
+ </div>
432
+ </div>
433
+ <div className="sb-nav">
434
+ <button className={`sb-link ${view === 'overview' ? 'active' : ''}`} onClick={() => nav('#/overview')}>
435
+ <Icon.overview /> Overview
436
+ </button>
437
+ <button className={`sb-link ${view === 'projects' ? 'active' : ''}`} onClick={() => nav('#/projects')}>
438
+ <Icon.projects /> Projects
439
+ </button>
440
+ <button className={`sb-link ${view === 'search' ? 'active' : ''}`} onClick={() => nav('#/search')}>
441
+ <Icon.search /> Search
442
+ </button>
443
+ </div>
444
+ <div className="sb-footer">
445
+ <div className="palette-toggle">
446
+ <span className="palette-toggle-label">Theme</span>
447
+ <div className="palette-toggle-switch">
448
+ <button className={`palette-toggle-opt ${palette === 'cloud' ? 'active' : ''}`} onClick={() => togglePalette('cloud')}>&#9788; Light</button>
449
+ <button className={`palette-toggle-opt ${palette === 'ocean' ? 'active' : ''}`} onClick={() => togglePalette('ocean')}>&#9679; Dark</button>
450
+ </div>
451
+ </div>
452
+ <div className="sb-user">
453
+ <div className="sb-user-info"><span className="sb-user-name">Admin</span><span className="sb-user-role">read+write</span></div>
454
+ <button className="sb-logout" onClick={onLogout}>Logout</button>
455
+ </div>
456
+ </div>
457
+ </div>
458
+ );
459
+ }
460
+
461
+ /* ── PROJECTS VIEW ────────────────────────────────────── */
462
+ function ProjectsView({ nav }) {
463
+ const [projects, setProjects] = useState([]);
464
+ const [showNew, setShowNew] = useState(false);
465
+ const [slug, setSlug] = useState('');
466
+ const [name, setName] = useState('');
467
+
468
+ useEffect(() => { api('/projects').then(setProjects).catch(() => {}); }, []);
469
+
470
+ const createProject = async (e) => {
471
+ e.preventDefault();
472
+ if (!slug || !name) return;
473
+ await api('/projects', { method: 'POST', body: JSON.stringify({ slug, name }) });
474
+ setSlug(''); setName(''); setShowNew(false);
475
+ api('/projects').then(setProjects);
476
+ };
477
+
478
+ return (
479
+ <div className="page">
480
+ <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:20}}>
481
+ <h1 className="page-title" style={{margin:0}}>Projects</h1>
482
+ <button className="form-btn" onClick={() => setShowNew(true)}>+ New Project</button>
483
+ </div>
484
+ {showNew && (
485
+ <div className="modal-overlay" onClick={() => setShowNew(false)}>
486
+ <form className="modal" onClick={e => e.stopPropagation()} onSubmit={createProject}>
487
+ <div className="modal-title">New Project</div>
488
+ <div className="modal-field">
489
+ <label>Slug</label>
490
+ <input placeholder="my-project" value={slug} onChange={e => setSlug(e.target.value)} autoFocus />
491
+ </div>
492
+ <div className="modal-field">
493
+ <label>Name</label>
494
+ <input placeholder="My Project" value={name} onChange={e => setName(e.target.value)} />
495
+ </div>
496
+ <div className="modal-actions">
497
+ <button type="button" className="btn-cancel" onClick={() => setShowNew(false)}>Cancel</button>
498
+ <button type="submit" className="btn-primary">Create</button>
499
+ </div>
500
+ </form>
501
+ </div>
502
+ )}
503
+ {projects.length === 0 ? (
504
+ <div className="empty">No projects yet. Create one to get started.</div>
505
+ ) : (
506
+ <div className="card-grid">
507
+ {projects.map(p => (
508
+ <div key={p.slug} className="card" onClick={() => nav(`#/project/${p.slug}`)}>
509
+ <div className="card-name">{p.name}</div>
510
+ <div className="card-desc">{p.description || p.slug}</div>
511
+ <div className="card-stats">
512
+ {Object.entries(p.taskCounts || {}).map(([s, c]) => (
513
+ <span key={s} className={`stat-badge s-${s}`}>{s}: {c}</span>
514
+ ))}
515
+ {Object.keys(p.taskCounts || {}).length === 0 && <span className="stat-badge s-todo">No tasks</span>}
516
+ </div>
517
+ </div>
518
+ ))}
519
+ </div>
520
+ )}
521
+ </div>
522
+ );
523
+ }
524
+
525
+ /* ── KANBAN ────────────────────────────────────────────── */
526
+ const COLUMNS = [
527
+ { key: 'todo', label: 'To Do', color: 'var(--text-3)' },
528
+ { key: 'refinement', label: 'Refinement', color: 'var(--yellow)' },
529
+ { key: 'doing', label: 'Doing', color: 'var(--blue)' },
530
+ { key: 'blocked', label: 'Blocked', color: 'var(--red)' },
531
+ { key: 'done', label: 'Done', color: 'var(--green)' },
532
+ ];
533
+
534
+ function KanbanView({ slug, tasks, onUpdate }) {
535
+ const [dragging, setDragging] = useState(null);
536
+ const [dragOver, setDragOver] = useState(null);
537
+ const [selectedTask, setSelectedTask] = useState(null);
538
+ const colRefs = React.useRef({});
539
+
540
+ const grouped = {};
541
+ COLUMNS.forEach(c => grouped[c.key] = []);
542
+ tasks.forEach(t => { if (grouped[t.status]) grouped[t.status].push(t); });
543
+
544
+ const handleDragStart = (e, task) => { setDragging(task.id); e.dataTransfer.effectAllowed = 'move'; };
545
+ const handleDragOver = (e, colKey) => { e.preventDefault(); setDragOver(colKey); };
546
+ const moveTo = async (taskId, newStatus) => {
547
+ const task = tasks.find(t => t.id === taskId);
548
+ if (!task || task.status === newStatus) return;
549
+ try { await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ status: newStatus }) }); onUpdate(); } catch {}
550
+ };
551
+ const handleDrop = async (e, newStatus) => {
552
+ e.preventDefault(); setDragOver(null);
553
+ if (!dragging) return;
554
+ await moveTo(dragging, newStatus);
555
+ setDragging(null);
556
+ };
557
+ const handleDelete = async (e, taskId) => {
558
+ e.stopPropagation();
559
+ if (!confirm('Delete this task?')) return;
560
+ try { await api(`/tasks/${taskId}`, { method: 'DELETE' }); onUpdate(); } catch {}
561
+ };
562
+
563
+ const colAtPoint = (x, y) => {
564
+ for (const [key, el] of Object.entries(colRefs.current)) {
565
+ if (!el) continue;
566
+ const r = el.getBoundingClientRect();
567
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return key;
568
+ }
569
+ return null;
570
+ };
571
+ const onTouchStart = (task) => { setDragging(task.id); };
572
+ const onTouchMove = (e) => {
573
+ const touch = e.touches[0];
574
+ const col = colAtPoint(touch.clientX, touch.clientY);
575
+ setDragOver(col);
576
+ };
577
+ const onTouchEnd = async (e) => {
578
+ if (!dragging) return;
579
+ const touch = e.changedTouches[0];
580
+ const col = colAtPoint(touch.clientX, touch.clientY);
581
+ setDragOver(null);
582
+ if (col) await moveTo(dragging, col);
583
+ setDragging(null);
584
+ };
585
+
586
+ return (
587
+ <div className="kanban">
588
+ {COLUMNS.map(col => (
589
+ <div key={col.key} className="kanban-col" ref={el => colRefs.current[col.key] = el}
590
+ onDragOver={e => handleDragOver(e, col.key)} onDragLeave={() => setDragOver(null)} onDrop={e => handleDrop(e, col.key)}>
591
+ <div className="kanban-col-header" style={{color: col.color}}>
592
+ {col.label} <span className="kanban-col-count">{grouped[col.key].length}</span>
593
+ </div>
594
+ <div className={`kanban-cards ${dragOver === col.key ? 'drag-over' : ''}`}>
595
+ {grouped[col.key].map(task => (
596
+ <div key={task.id} className={`kanban-card ${dragging === task.id ? 'dragging' : ''}`}
597
+ draggable onDragStart={e => handleDragStart(e, task)} onDragEnd={() => setDragging(null)}
598
+ onTouchStart={() => onTouchStart(task)} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd}
599
+ onClick={() => { if (!dragging) setSelectedTask(task); }} style={{cursor:'pointer'}}>
600
+ <div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start'}}>
601
+ <div className="kanban-card-title">{task.title}</div>
602
+ <button className="del-btn" onClick={e => handleDelete(e, task.id)} title="Delete">×</button>
603
+ </div>
604
+ <div className="kanban-card-meta">
605
+ <span className={task.priority.toLowerCase()}>{task.priority}</span>
606
+ {task.type !== 'task' && <span className="s-todo">{task.type}</span>}
607
+ {task.estimate && <span style={{background:'var(--surface-2)',color:'var(--text-3)'}}>{task.estimate}</span>}
608
+ {task.deadline && <span style={{background:'var(--surface-2)',color:'var(--text-3)'}}>{task.deadline}</span>}
609
+ </div>
610
+ </div>
611
+ ))}
612
+ </div>
613
+ </div>
614
+ ))}
615
+ {selectedTask && <TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} onUpdate={onUpdate} />}
616
+ </div>
617
+ );
618
+ }
619
+
620
+ /* ── TASK TABLE ───────────────────────────────────────── */
621
+ function TaskTableView({ tasks, onDelete }) {
622
+ if (tasks.length === 0) return <div className="empty">No tasks</div>;
623
+ const handleDelete = async (taskId) => {
624
+ if (!confirm('Delete this task?')) return;
625
+ try { await api(`/tasks/${taskId}`, { method: 'DELETE' }); onDelete(); } catch {}
626
+ };
627
+ return (
628
+ <table className="task-table">
629
+ <thead><tr><th>Title</th><th>Status</th><th>Priority</th><th>Type</th><th>Estimate</th><th>Deadline</th><th></th></tr></thead>
630
+ <tbody>
631
+ {tasks.map(t => (
632
+ <tr key={t.id}>
633
+ <td style={{fontWeight:500}}>{t.title}</td>
634
+ <td><span className={`stat-badge s-${t.status}`}>{t.status}</span></td>
635
+ <td><span className={`stat-badge ${t.priority.toLowerCase()}`}>{t.priority}</span></td>
636
+ <td>{t.type}</td>
637
+ <td style={{fontFamily:'var(--mono)',fontSize:12}}>{t.estimate || '—'}</td>
638
+ <td style={{fontFamily:'var(--mono)',fontSize:12}}>{t.deadline || '—'}</td>
639
+ <td><button className="del-btn" onClick={() => handleDelete(t.id)} title="Delete">×</button></td>
640
+ </tr>
641
+ ))}
642
+ </tbody>
643
+ </table>
644
+ );
645
+ }
646
+
647
+ /* ── PROJECT DETAIL ───────────────────────────────────── */
648
+ function DocsView({ docs, onSave }) {
649
+ const [activeDoc, setActiveDoc] = useState(null);
650
+ const [content, setContent] = useState('');
651
+ const [saving, setSaving] = useState(false);
652
+ const [dirty, setDirty] = useState(false);
653
+
654
+ const openDoc = async (doc) => {
655
+ try {
656
+ const full = await api(`/docs/${doc.id}`);
657
+ setContent(full.content || '');
658
+ setActiveDoc(full);
659
+ setDirty(false);
660
+ } catch {}
661
+ };
662
+ const saveDoc = async () => {
663
+ if (!activeDoc) return;
664
+ setSaving(true);
665
+ try { await api(`/docs/${activeDoc.id}`, { method: 'PATCH', body: JSON.stringify({ body: content }) }); setDirty(false); if (onSave) onSave(); } catch {}
666
+ setSaving(false);
667
+ };
668
+ const close = () => { setActiveDoc(null); setContent(''); setDirty(false); };
669
+
670
+ const tree = React.useMemo(() => {
671
+ const root = { _files: [] };
672
+ docs.forEach(d => {
673
+ const parts = d.file_path.split('/');
674
+ const fileName = parts.pop();
675
+ let node = root;
676
+ parts.forEach(dir => {
677
+ if (!node[dir]) node[dir] = { _files: [] };
678
+ node = node[dir];
679
+ });
680
+ node._files.push({ ...d, fileName });
681
+ });
682
+ return root;
683
+ }, [docs]);
684
+
685
+ const FileIcon = () => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{flexShrink:0,opacity:.5}}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>;
686
+ const FolderIcon = ({ open }) => <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{flexShrink:0,color:'var(--accent)'}}>{open
687
+ ? <path d="M5 19a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h4l2 2h6a2 2 0 0 1 2 2v1M5 19h14a2 2 0 0 0 2-2l-3-7H4l-1 7a2 2 0 0 0 2 2Z"/>
688
+ : <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>
689
+ }</svg>;
690
+
691
+ const TreeFolder = ({ name, node, depth }) => {
692
+ const [open, setOpen] = useState(true);
693
+ const dirs = Object.keys(node).filter(k => k !== '_files').sort();
694
+ return (
695
+ <div>
696
+ <div onClick={() => setOpen(!open)} style={{
697
+ display:'flex',alignItems:'center',gap:8,padding:'6px 10px',paddingLeft:depth*20+10,
698
+ cursor:'pointer',borderRadius:6,fontSize:13,fontWeight:500,color:'var(--text)',
699
+ transition:'background .1s'
700
+ }} onMouseEnter={e => e.currentTarget.style.background='var(--surface-2)'}
701
+ onMouseLeave={e => e.currentTarget.style.background='none'}>
702
+ <FolderIcon open={open} />
703
+ <span>{name}</span>
704
+ <span style={{fontSize:10,color:'var(--text-3)',fontFamily:'var(--mono)',marginLeft:'auto'}}>{node._files.length + dirs.length}</span>
705
+ </div>
706
+ {open && (
707
+ <div>
708
+ {dirs.map(d => <TreeFolder key={d} name={d} node={node[d]} depth={depth+1} />)}
709
+ {node._files.map(f => (
710
+ <div key={f.id} onClick={() => openDoc(f)} style={{
711
+ display:'flex',alignItems:'center',gap:8,padding:'5px 10px',paddingLeft:(depth+1)*20+10,
712
+ cursor:'pointer',borderRadius:6,fontSize:13,color:'var(--text-2)',
713
+ transition:'background .1s,color .1s'
714
+ }} onMouseEnter={e => { e.currentTarget.style.background='var(--surface-2)'; e.currentTarget.style.color='var(--text)'; }}
715
+ onMouseLeave={e => { e.currentTarget.style.background='none'; e.currentTarget.style.color='var(--text-2)'; }}>
716
+ <FileIcon />
717
+ <span>{f.fileName}</span>
718
+ <span className="stat-badge s-todo" style={{marginLeft:'auto',fontSize:9}}>{f.type}</span>
719
+ </div>
720
+ ))}
721
+ </div>
722
+ )}
723
+ </div>
724
+ );
725
+ };
726
+
727
+ if (activeDoc) return (
728
+ <div>
729
+ <div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:16}}>
730
+ <button className="back-btn" onClick={close}>← Back to docs</button>
731
+ <div style={{display:'flex',gap:8,alignItems:'center'}}>
732
+ {dirty && <span style={{fontSize:11,color:'var(--text-3)'}}>Unsaved changes</span>}
733
+ <button className="form-btn" onClick={saveDoc} disabled={!dirty || saving}>{saving ? 'Saving...' : 'Save'}</button>
734
+ </div>
735
+ </div>
736
+ <div style={{display:'flex',alignItems:'center',gap:8,marginBottom:12}}>
737
+ <span style={{fontSize:16,fontWeight:600}}>{activeDoc.title}</span>
738
+ <span className="stat-badge s-todo">{activeDoc.type}</span>
739
+ </div>
740
+ <textarea style={{
741
+ width:'100%',minHeight:'60vh',padding:16,border:'1.5px solid var(--border)',borderRadius:'var(--r)',
742
+ background:'var(--surface)',fontFamily:'var(--mono)',fontSize:13,lineHeight:1.7,color:'var(--text)',
743
+ resize:'vertical',outline:'none',transition:'border-color .15s'
744
+ }} value={content} onChange={e => { setContent(e.target.value); setDirty(true); }}
745
+ onFocus={e => e.target.style.borderColor = 'var(--accent)'}
746
+ onBlur={e => e.target.style.borderColor = 'var(--border)'} />
747
+ </div>
748
+ );
749
+
750
+ if (docs.length === 0) return <div className="empty">No documents</div>;
751
+ const dirs = Object.keys(tree).filter(k => k !== '_files').sort();
752
+ return (
753
+ <div style={{background:'var(--surface)',border:'1px solid var(--border-subtle)',borderRadius:'var(--r)',padding:'8px 4px'}}>
754
+ {dirs.map(d => <TreeFolder key={d} name={d} node={tree[d]} depth={0} />)}
755
+ {tree._files.map(f => (
756
+ <div key={f.id} onClick={() => openDoc(f)} style={{
757
+ display:'flex',alignItems:'center',gap:8,padding:'5px 10px',paddingLeft:10,
758
+ cursor:'pointer',borderRadius:6,fontSize:13,color:'var(--text-2)',
759
+ transition:'background .1s,color .1s'
760
+ }} onMouseEnter={e => { e.currentTarget.style.background='var(--surface-2)'; e.currentTarget.style.color='var(--text)'; }}
761
+ onMouseLeave={e => { e.currentTarget.style.background='none'; e.currentTarget.style.color='var(--text-2)'; }}>
762
+ <FileIcon />
763
+ <span>{f.fileName}</span>
764
+ <span className="stat-badge s-todo" style={{marginLeft:'auto',fontSize:9}}>{f.type}</span>
765
+ </div>
766
+ ))}
767
+ </div>
768
+ );
769
+ }
770
+
771
+ function ProjectDetail({ slug, nav }) {
772
+ const [project, setProject] = useState(null);
773
+ const [tasks, setTasks] = useState([]);
774
+ const [docs, setDocs] = useState([]);
775
+ const [tab, setTab] = useState('kanban');
776
+ const [showAddTask, setShowAddTask] = useState(false);
777
+ const [newTitle, setNewTitle] = useState('');
778
+ const [newPriority, setNewPriority] = useState('P2');
779
+
780
+ const load = useCallback(() => {
781
+ api(`/projects/${slug}`).then(setProject).catch(() => {});
782
+ api(`/projects/${slug}/tasks`).then(setTasks).catch(() => {});
783
+ api(`/projects/${slug}/docs`).then(setDocs).catch(() => {});
784
+ }, [slug]);
785
+
786
+ useEffect(() => { load(); }, [load]);
787
+
788
+ const addTask = async (e) => {
789
+ e.preventDefault();
790
+ if (!newTitle) return;
791
+ await api(`/projects/${slug}/tasks`, { method: 'POST', body: JSON.stringify({ title: newTitle, priority: newPriority }) });
792
+ setNewTitle(''); setShowAddTask(false);
793
+ load();
794
+ };
795
+
796
+ if (!project) return <div className="loading">Loading...</div>;
797
+
798
+ return (
799
+ <div className="page">
800
+ <button className="back-btn" onClick={() => nav('#/projects')}>← All Projects</button>
801
+ <div style={{display:'flex',justifyContent:'space-between',alignItems:'flex-start',marginBottom:8}}>
802
+ <div>
803
+ <h1 className="page-title" style={{margin:0}}>{project.name}</h1>
804
+ <div className="page-subtitle" style={{margin:'4px 0 0'}}>{project.description || project.slug}</div>
805
+ </div>
806
+ <button className="form-btn" onClick={() => setShowAddTask(true)}>+ Add Task</button>
807
+ </div>
808
+ {showAddTask && (
809
+ <div className="modal-overlay" onClick={() => setShowAddTask(false)}>
810
+ <form className="modal" onClick={e => e.stopPropagation()} onSubmit={addTask}>
811
+ <div className="modal-title">Add Task</div>
812
+ <div className="modal-field">
813
+ <label>Title</label>
814
+ <input placeholder="Task title" value={newTitle} onChange={e => setNewTitle(e.target.value)} autoFocus />
815
+ </div>
816
+ <div className="modal-field">
817
+ <label>Priority</label>
818
+ <select value={newPriority} onChange={e => setNewPriority(e.target.value)}>
819
+ <option value="P0">P0 — Critical</option><option value="P1">P1 — High</option>
820
+ <option value="P2">P2 — Medium</option><option value="P3">P3 — Low</option>
821
+ </select>
822
+ </div>
823
+ <div className="modal-actions">
824
+ <button type="button" className="btn-cancel" onClick={() => setShowAddTask(false)}>Cancel</button>
825
+ <button type="submit" className="btn-primary">Add Task</button>
826
+ </div>
827
+ </form>
828
+ </div>
829
+ )}
830
+ <div className="tabs">
831
+ {['kanban','tasks','docs'].map(t => (
832
+ <button key={t} className={`tab ${tab === t ? 'active' : ''}${t === 'tasks' ? ' tab-tasks' : ''}`} onClick={() => setTab(t)}>
833
+ {t.charAt(0).toUpperCase() + t.slice(1)}
834
+ </button>
835
+ ))}
836
+ </div>
837
+ {tab === 'kanban' && <KanbanView slug={slug} tasks={tasks} onUpdate={load} />}
838
+ {tab === 'tasks' && <TaskTableView tasks={tasks} onDelete={load} />}
839
+ {tab === 'docs' && <DocsView docs={docs} onSave={load} />}
840
+ </div>
841
+ );
842
+ }
843
+
844
+ /* ── SEARCH VIEW ──────────────────────────────────────── */
845
+ function SearchView() {
846
+ const [query, setQuery] = useState('');
847
+ const [results, setResults] = useState([]);
848
+ const [searched, setSearched] = useState(false);
849
+
850
+ const doSearch = async (e) => {
851
+ e.preventDefault();
852
+ if (!query.trim()) return;
853
+ try { const r = await api('/search', { method: 'POST', body: JSON.stringify({ query }) }); setResults(r); } catch { setResults([]); }
854
+ setSearched(true);
855
+ };
856
+
857
+ return (
858
+ <div className="page">
859
+ <h1 className="page-title">Search</h1>
860
+ <form onSubmit={doSearch}>
861
+ <div className="search-bar">
862
+ <input className="search-input" placeholder="Search tasks and docs..." value={query} onChange={e => setQuery(e.target.value)} autoFocus />
863
+ <button className="search-btn" type="submit">Search</button>
864
+ </div>
865
+ </form>
866
+ {searched && results.length === 0 && <div className="empty">No results found</div>}
867
+ {results.map((r, i) => (
868
+ <div key={i} className="search-result">
869
+ <div className="search-result-type" style={{color: r.type === 'task' ? 'var(--blue)' : 'var(--green)'}}>{r.type}</div>
870
+ <div className="search-result-title">{r.title}</div>
871
+ <div className="search-result-meta">
872
+ {r.projectSlug}
873
+ {r.taskStatus && <> · <span className={`stat-badge s-${r.taskStatus}`}>{r.taskStatus}</span></>}
874
+ {r.taskPriority && <> · <span className={`stat-badge ${r.taskPriority.toLowerCase()}`}>{r.taskPriority}</span></>}
875
+ {r.docType && <> · {r.docType}</>}
876
+ </div>
877
+ </div>
878
+ ))}
879
+ </div>
880
+ );
881
+ }
882
+
883
+ /* ── TASK DETAIL MODAL ────────────────────────────────── */
884
+ function TaskDetailModal({ task, onClose, onUpdate }) {
885
+ const [content, setContent] = useState('');
886
+ const [docId, setDocId] = useState(null);
887
+ const [loading, setLoading] = useState(true);
888
+ const [saving, setSaving] = useState(false);
889
+ const [saved, setSaved] = useState(false);
890
+ const [creating, setCreating] = useState(false);
891
+
892
+ const projectSlug = task._project || task.project_slug;
893
+
894
+ useEffect(() => {
895
+ let cancelled = false;
896
+ (async () => {
897
+ setLoading(true);
898
+ if (task.detail_path) {
899
+ const docs = await api(`/projects/${projectSlug}/docs`).catch(() => []);
900
+ const doc = docs.find(d => d.file_path === task.detail_path);
901
+ if (doc && !cancelled) {
902
+ const full = await api(`/docs/${doc.id}`).catch(() => null);
903
+ if (full && !cancelled) {
904
+ setDocId(doc.id);
905
+ const body = full.content.replace(/^---[\s\S]*?---\n*/, '');
906
+ setContent(body);
907
+ }
908
+ }
909
+ }
910
+ if (!cancelled) setLoading(false);
911
+ })();
912
+ return () => { cancelled = true; };
913
+ }, [task]);
914
+
915
+ const handleSave = async () => {
916
+ if (!docId) return;
917
+ setSaving(true);
918
+ await api(`/docs/${docId}`, { method: 'PATCH', body: JSON.stringify({ body: content }) }).catch(() => {});
919
+ setSaving(false);
920
+ setSaved(true);
921
+ setTimeout(() => setSaved(false), 2000);
922
+ if (onUpdate) onUpdate();
923
+ };
924
+
925
+ const handleCreate = async () => {
926
+ setCreating(true);
927
+ const slug = task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '').slice(0, 40);
928
+ const body = `# ${task.title}\n\n`;
929
+ const result = await api(`/projects/${projectSlug}/docs`, {
930
+ method: 'POST',
931
+ body: JSON.stringify({ title: task.title, body, type: 'detail' })
932
+ }).catch(() => null);
933
+ if (result) {
934
+ await api(`/tasks/${task.id}`, {
935
+ method: 'PATCH',
936
+ body: JSON.stringify({ detail: result.file_path })
937
+ }).catch(() => {});
938
+ setDocId(result.id);
939
+ setContent(body);
940
+ if (onUpdate) onUpdate();
941
+ }
942
+ setCreating(false);
943
+ };
944
+
945
+ return (
946
+ <div className="modal-overlay" onClick={onClose}>
947
+ <div className="modal modal-wide" onClick={e => e.stopPropagation()}>
948
+ <div className="modal-title">{task.title}</div>
949
+ <div className="task-detail-header">
950
+ <span className="badge badge-status">{task.status}</span>
951
+ <span className="badge badge-priority">{task.priority}</span>
952
+ {(task._projectName || projectSlug) && <span className="badge badge-project">{task._projectName || projectSlug}</span>}
953
+ {task.type !== 'task' && <span className="badge badge-status">{task.type}</span>}
954
+ {task.estimate && <span className="badge badge-priority">{task.estimate}</span>}
955
+ {task.deadline && <span className="badge badge-priority">{task.deadline}</span>}
956
+ </div>
957
+ {loading ? (
958
+ <div className="task-detail-empty">Loading...</div>
959
+ ) : docId ? (
960
+ <div>
961
+ <textarea className="task-detail-editor" value={content} onChange={e => setContent(e.target.value)} />
962
+ <div className="modal-actions" style={{alignItems:'center'}}>
963
+ <span className={`task-detail-saved ${saved ? 'show' : ''}`}>Saved</span>
964
+ <button className="btn-cancel" onClick={onClose}>Close</button>
965
+ <button className="btn-primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</button>
966
+ </div>
967
+ </div>
968
+ ) : (
969
+ <div>
970
+ <div className="task-detail-empty">No detail document linked to this task.</div>
971
+ <div className="modal-actions">
972
+ <button className="btn-cancel" onClick={onClose}>Close</button>
973
+ <button className="btn-primary" onClick={handleCreate} disabled={creating}>{creating ? 'Creating...' : 'Add Detail'}</button>
974
+ </div>
975
+ </div>
976
+ )}
977
+ </div>
978
+ </div>
979
+ );
980
+ }
981
+
982
+ /* ── OVERVIEW VIEW ────────────────────────────────────── */
983
+ function OverviewView({ nav }) {
984
+ const [data, setData] = useState(null);
985
+ const [allTasks, setAllTasks] = useState([]);
986
+ const [dragOver, setDragOver] = useState(null);
987
+ const [dragging, setDragging] = useState(null);
988
+ const [selectedTask, setSelectedTask] = useState(null);
989
+ const colRefs = React.useRef({});
990
+
991
+ const loadAll = useCallback(async () => {
992
+ const overview = await api('/overview').catch(() => null);
993
+ if (!overview) return;
994
+ setData(overview);
995
+ const taskArrays = await Promise.all(
996
+ overview.projects.map(p => api(`/projects/${p.slug}/tasks`).then(tasks => tasks.map(t => ({ ...t, _project: p.slug, _projectName: p.name }))).catch(() => []))
997
+ );
998
+ setAllTasks(taskArrays.flat());
999
+ }, []);
1000
+ useEffect(() => { loadAll(); }, [loadAll]);
1001
+
1002
+ const grouped = {};
1003
+ COLUMNS.forEach(c => grouped[c.key] = []);
1004
+ allTasks.forEach(t => { if (grouped[t.status]) grouped[t.status].push(t); });
1005
+
1006
+ const moveTo = async (taskId, newStatus) => {
1007
+ const task = allTasks.find(t => t.id === taskId);
1008
+ if (!task || task.status === newStatus) return;
1009
+ try { await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ status: newStatus }) }); loadAll(); } catch {}
1010
+ };
1011
+ const handleDragStart = (e, task) => { setDragging(task.id); e.dataTransfer.effectAllowed = 'move'; };
1012
+ const handleDrop = async (e, newStatus) => { e.preventDefault(); setDragOver(null); if (dragging) { await moveTo(dragging, newStatus); setDragging(null); } };
1013
+ const colAtPoint = (x, y) => {
1014
+ for (const [key, el] of Object.entries(colRefs.current)) {
1015
+ if (!el) continue;
1016
+ const r = el.getBoundingClientRect();
1017
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return key;
1018
+ }
1019
+ return null;
1020
+ };
1021
+ const onTouchStart = (task) => { setDragging(task.id); };
1022
+ const onTouchMove = (e) => { const t = e.touches[0]; setDragOver(colAtPoint(t.clientX, t.clientY)); };
1023
+ const onTouchEnd = async (e) => {
1024
+ if (!dragging) return;
1025
+ const t = e.changedTouches[0];
1026
+ const col = colAtPoint(t.clientX, t.clientY);
1027
+ setDragOver(null);
1028
+ if (col) await moveTo(dragging, col);
1029
+ setDragging(null);
1030
+ };
1031
+
1032
+ if (!data) return <div className="loading">Loading...</div>;
1033
+
1034
+ return (
1035
+ <div className="page">
1036
+ <h1 className="page-title">Overview</h1>
1037
+ <div className="stats-row">
1038
+ <div className="stat-card"><div className="stat-num">{data.totalProjects}</div><div className="stat-lbl">Projects</div></div>
1039
+ <div className="stat-card"><div className="stat-num">{data.totalTasks}</div><div className="stat-lbl">Tasks</div></div>
1040
+ <div className="stat-card"><div className="stat-num">{data.totalDocs}</div><div className="stat-lbl">Documents</div></div>
1041
+ </div>
1042
+ <div className="sec-head"><span className="sec-title">Projects</span><span className="sec-badge">{data.projects.length}</span></div>
1043
+ <div className="card-grid" style={{marginBottom:28}}>
1044
+ {data.projects.map(p => (
1045
+ <div key={p.slug} className="card" onClick={() => nav(`#/project/${p.slug}`)}>
1046
+ <div className="card-name">{p.name}</div>
1047
+ <div style={{display:'flex',gap:6,marginTop:8,flexWrap:'wrap'}}>
1048
+ {Object.entries(p.tasksByStatus || {}).map(([s, c]) => (
1049
+ <span key={s} className={`stat-badge s-${s}`}>{s}: {c}</span>
1050
+ ))}
1051
+ </div>
1052
+ </div>
1053
+ ))}
1054
+ </div>
1055
+ <div className="sec-head"><span className="sec-title">All Tasks</span><span className="sec-badge">{allTasks.length}</span></div>
1056
+ <div className="kanban">
1057
+ {COLUMNS.map(col => (
1058
+ <div key={col.key} className="kanban-col" ref={el => colRefs.current[col.key] = el}
1059
+ onDragOver={e => { e.preventDefault(); setDragOver(col.key); }} onDragLeave={() => setDragOver(null)} onDrop={e => handleDrop(e, col.key)}>
1060
+ <div className="kanban-col-header" style={{color: col.color}}>
1061
+ {col.label} <span className="kanban-col-count">{grouped[col.key].length}</span>
1062
+ </div>
1063
+ <div className={`kanban-cards ${dragOver === col.key ? 'drag-over' : ''}`}>
1064
+ {grouped[col.key].map(task => (
1065
+ <div key={task.id} className={`kanban-card ${dragging === task.id ? 'dragging' : ''}`}
1066
+ draggable onDragStart={e => handleDragStart(e, task)} onDragEnd={() => setDragging(null)}
1067
+ onTouchStart={() => onTouchStart(task)} onTouchMove={onTouchMove} onTouchEnd={onTouchEnd}
1068
+ onClick={() => { if (!dragging) setSelectedTask(task); }} style={{cursor:'pointer'}}>
1069
+ <div className="kanban-card-title">{task.title}</div>
1070
+ <div className="kanban-card-meta">
1071
+ <span className={task.priority.toLowerCase()}>{task.priority}</span>
1072
+ <span style={{background:'var(--accent-light)',color:'var(--accent)',fontSize:9,padding:'2px 6px',borderRadius:3,fontFamily:'var(--mono)',fontWeight:600}}>{task._projectName}</span>
1073
+ </div>
1074
+ </div>
1075
+ ))}
1076
+ </div>
1077
+ </div>
1078
+ ))}
1079
+ </div>
1080
+ {selectedTask && <TaskDetailModal task={selectedTask} onClose={() => setSelectedTask(null)} onUpdate={loadAll} />}
1081
+ </div>
1082
+ );
1083
+ }
1084
+
1085
+ /* ── APP ──────────────────────────────────────────────── */
1086
+ function App() {
1087
+ const [authed, setAuthed] = useState(!!localStorage.getItem(TOKEN_KEY));
1088
+ const [sidebarOpen, setSidebarOpen] = useState(false);
1089
+ const { route, segs, nav } = useRouter();
1090
+
1091
+ const handleLogout = () => { localStorage.removeItem(TOKEN_KEY); setAuthed(false); };
1092
+ const mobileNav = (path) => { nav(path); setSidebarOpen(false); };
1093
+
1094
+ if (!authed) return <LoginScreen onAuth={() => setAuthed(true)} />;
1095
+
1096
+ const view = segs[0] === 'project' && segs[1] ? 'project-detail' : (segs[0] || 'overview');
1097
+ const projectSlug = segs[0] === 'project' ? segs[1] : null;
1098
+
1099
+ return (
1100
+ <div className="shell">
1101
+ <button className="mobile-toggle" onClick={() => setSidebarOpen(!sidebarOpen)}>
1102
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
1103
+ </button>
1104
+ <div className={`sidebar-overlay ${sidebarOpen ? 'open' : ''}`} onClick={() => setSidebarOpen(false)} />
1105
+ <Sidebar view={view === 'project-detail' ? 'projects' : view} nav={mobileNav} onLogout={handleLogout} sidebarOpen={sidebarOpen} />
1106
+ <div className="main">
1107
+ <div className="topbar">
1108
+ <span className="topbar-title">
1109
+ {view === 'projects' && 'Projects'}
1110
+ {view === 'project-detail' && projectSlug}
1111
+ {view === 'overview' && 'Overview'}
1112
+ {view === 'search' && 'Search'}
1113
+ </span>
1114
+ </div>
1115
+ {view === 'projects' && <ProjectsView nav={nav} />}
1116
+ {view === 'project-detail' && projectSlug && <ProjectDetail slug={projectSlug} nav={nav} />}
1117
+ {view === 'overview' && <OverviewView nav={nav} />}
1118
+ {view === 'search' && <SearchView />}
1119
+ </div>
1120
+ </div>
1121
+ );
1122
+ }
1123
+
1124
+ ReactDOM.createRoot(document.getElementById('root')).render(<App />);
1125
+ </script>
1126
+ </body>
1127
+ </html>