@a83/orbiter-admin 0.2.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.
@@ -0,0 +1,100 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Orbiter Admin — Collections</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Space+Grotesk:wght@300;400;500;600&family=Noto+Serif+JP:wght@200;300&family=DM+Mono:wght@300;400&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/style.css" />
11
+ <script src="/theme.js"></script>
12
+ </head>
13
+ <body>
14
+ <div class="app">
15
+ <header class="topbar">
16
+ <a class="logo" href="/dashboard.html"><div class="logo-mark">OR</div>Orbiter</a>
17
+ <div class="topbar-right">
18
+ <button class="search-trigger" id="search-btn" title="Search (⌘K)">
19
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
20
+ Search <kbd>⌘K</kbd>
21
+ </button>
22
+ <button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
23
+ <span class="user" id="topbar-user"></span>
24
+ <span class="logout" id="logout-btn">Sign out</span>
25
+ </div>
26
+ </header>
27
+ <nav class="sidebar">
28
+ <div class="nav-section">Content</div>
29
+ <a class="nav-item" href="/dashboard.html"><span class="nav-icon">◈</span>Dashboard</a>
30
+ <a class="nav-item active" href="/collections.html"><span class="nav-icon">⊞</span>Collections</a>
31
+ <div class="nav-section">Assets</div>
32
+ <a class="nav-item" href="/media.html"><span class="nav-icon">⊟</span>Media</a>
33
+ <div class="nav-section">System</div>
34
+ <a class="nav-item" href="/settings.html"><span class="nav-icon">◎</span>Settings</a>
35
+ <a class="nav-item admin-only" href="/users.html" style="display:none"><span class="nav-icon">◉</span>Users</a>
36
+ <div class="sidebar-footer">
37
+ <div class="pod-name" id="pod-name">content.pod</div>
38
+ <div class="pod-info" id="pod-info"></div>
39
+ <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
40
+ </div>
41
+ </nav>
42
+ <main class="main">
43
+ <div class="page-header" style="display:flex;align-items:flex-start;justify-content:space-between">
44
+ <div>
45
+ <h1 class="page-title">Collections</h1>
46
+ <p class="page-sub">All content types in this pod</p>
47
+ </div>
48
+ </div>
49
+ <div class="table-wrap" id="collections-wrap">
50
+ <div class="empty"><div class="spinner"></div></div>
51
+ </div>
52
+
53
+ <script type="module">
54
+ const me = await fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()).catch(() => null);
55
+ if (!me?.user) { location.replace('/login.html'); }
56
+ document.getElementById('topbar-user').textContent = me.user.username;
57
+ if (me.user.role === 'admin') {
58
+ document.querySelectorAll('.admin-only').forEach(el => el.style.display = '');
59
+ }
60
+ document.getElementById('logout-btn').addEventListener('click', async () => {
61
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
62
+ location.replace('/login.html');
63
+ });
64
+
65
+ const cols = await fetch('/api/collections', { credentials: 'include' }).then(r => r.json());
66
+ const wrap = document.getElementById('collections-wrap');
67
+
68
+ if (cols.length === 0) {
69
+ wrap.innerHTML = '<div class="empty"><div class="empty-icon">⊞</div>No collections yet</div>';
70
+ } else {
71
+ wrap.innerHTML = `
72
+ <div class="table-header">
73
+ <span class="table-title">${cols.length} collection${cols.length !== 1 ? 's' : ''}</span>
74
+ </div>
75
+ <table>
76
+ <thead><tr><th>Label</th><th>ID</th><th>Fields</th><th>Entries</th><th></th></tr></thead>
77
+ <tbody>
78
+ ${cols.map(c => {
79
+ const fieldCount = Object.keys(c.schema ?? {}).length;
80
+ return `<tr>
81
+ <td style="color:var(--heading);font-weight:500">${c.label}</td>
82
+ <td style="font-family:var(--mono);color:var(--muted);font-size:11px">${c.id}</td>
83
+ <td style="font-family:var(--mono)">${fieldCount}</td>
84
+ <td style="font-family:var(--mono)">${c.total}</td>
85
+ <td style="text-align:right">
86
+ <a class="btn btn-ghost btn-sm" href="/entries.html?col=${c.id}&label=${encodeURIComponent(c.label)}">Open →</a>
87
+ </td>
88
+ </tr>`;
89
+ }).join('')}
90
+ </tbody>
91
+ </table>`;
92
+ }
93
+ </script>
94
+ </main>
95
+ </div>
96
+ <script src="/search.js"></script>
97
+ <script src="/sidebar.js"></script>
98
+ <script src="/router.js"></script>
99
+ </body>
100
+ </html>
@@ -0,0 +1,478 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Orbiter Admin — Dashboard</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=Space+Grotesk:wght@300;400;500;600&family=Noto+Serif+JP:wght@200;300&family=DM+Mono:wght@300;400&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="/style.css" />
11
+ <script src="/theme.js"></script>
12
+ <style>
13
+ /* Hero */
14
+ .hero { padding:28px 32px 20px; border-bottom:1px solid var(--line); background:var(--bg2); display:flex; align-items:flex-end; justify-content:space-between; gap:24px; }
15
+ .hero-site { font-family:var(--display); font-weight:500; font-size:24px; color:var(--heading); letter-spacing:0.03em; line-height:1.1; margin-bottom:4px; }
16
+ .hero-date { font-size:10px; color:var(--muted); letter-spacing:0.04em; }
17
+ .hero-right { display:flex; align-items:center; gap:10px; flex-shrink:0; }
18
+ .hero-url { font-size:10px; color:var(--accent); text-decoration:none; border:1px solid var(--line); padding:5px 12px; transition:border-color 0.15s; }
19
+ .hero-url:hover { border-color:var(--accent); }
20
+ .btn-deploy { display:flex; align-items:center; gap:6px; padding:7px 16px; background:transparent; border:1px solid var(--gold); color:var(--gold); font-family:var(--mono); font-size:10px; letter-spacing:0.13em; text-transform:uppercase; cursor:pointer; border-radius:var(--radius); transition:all 0.15s; }
21
+ .btn-deploy:hover { background:var(--gold); color:var(--bg0); }
22
+ .btn-deploy:disabled { opacity:0.5; cursor:not-allowed; }
23
+ .deploy-status { font-size:10px; color:var(--muted); }
24
+
25
+ /* Stats row */
26
+ .stats-row { display:grid; grid-template-columns:repeat(4,1fr); border-bottom:1px solid var(--line); }
27
+ .stat-cell { padding:20px 24px; border-right:1px solid var(--line); position:relative; }
28
+ .stat-cell:last-child { border-right:none; }
29
+ .stat-label { font-size:9px; letter-spacing:0.26em; text-transform:uppercase; color:var(--muted); margin-bottom:10px; }
30
+ .stat-num { font-family:var(--display); font-weight:300; font-size:36px; color:var(--heading); line-height:1; margin-bottom:6px; }
31
+ .stat-sub { font-size:10px; color:var(--muted); display:flex; align-items:center; gap:6px; }
32
+ .stat-bar { height:2px; background:var(--line); margin-top:12px; }
33
+ .stat-bar-fill { height:100%; transition:width 0.4s; }
34
+ .stat-bar-fill.jade { background:var(--jade); }
35
+ .stat-bar-fill.gold { background:var(--gold); }
36
+ .stat-bar-fill.accent { background:var(--accent); }
37
+ .stat-dot { width:5px; height:5px; border-radius:50%; flex-shrink:0; }
38
+ .stat-dot.jade { background:var(--jade); }
39
+ .stat-dot.gold { background:var(--gold); }
40
+
41
+ /* Content layout */
42
+ .dash-content { display:grid; grid-template-columns:1fr 300px; }
43
+ .content-main { padding:28px 32px; border-right:1px solid var(--line); }
44
+ .content-side { padding:24px 0; }
45
+
46
+ /* Section headers */
47
+ .section-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
48
+ .section-title { font-size:9px; letter-spacing:0.26em; text-transform:uppercase; color:var(--text); display:flex; align-items:center; gap:8px; }
49
+ .section-title::before { content:"—"; color:var(--gold); font-size:8px; }
50
+
51
+ /* Recent entries */
52
+ .entry-list { border:1px solid var(--line); }
53
+ .entry-row { display:grid; grid-template-columns:1fr 90px 70px; align-items:center; gap:16px; padding:11px 14px; border-bottom:1px solid var(--line2); text-decoration:none; transition:background 0.1s; }
54
+ .entry-row:hover { background:var(--line2); }
55
+ .entry-row:last-child { border-bottom:none; }
56
+ .entry-name { font-size:12px; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
57
+ .entry-coll { font-size:9px; color:var(--muted); margin-top:1px; letter-spacing:0.04em; }
58
+ .entry-date { font-size:10px; color:var(--muted); text-align:right; }
59
+ .pill { font-size:9px; padding:2px 7px; letter-spacing:0.06em; text-align:center; border-radius:2px; }
60
+ .pill.published { color:var(--jade); background:var(--jade-bg); }
61
+ .pill.draft { color:var(--gold); background:var(--gold-bg); }
62
+
63
+ /* Collection cards */
64
+ .side-section { padding:0 20px 24px; }
65
+ .side-section + .side-section { border-top:1px solid var(--line); padding-top:20px; }
66
+ .coll-card { padding:12px 0; border-bottom:1px solid var(--line2); }
67
+ .coll-card:last-child { border-bottom:none; }
68
+ .coll-card-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:8px; }
69
+ .coll-card-name { font-size:12px; color:var(--heading); display:flex; align-items:center; gap:7px; }
70
+ .coll-card-name::before { content:"▤"; font-size:9px; color:var(--muted); }
71
+ .coll-card-count { font-size:10px; color:var(--muted); }
72
+ .coll-progress { height:2px; background:var(--line); margin-bottom:6px; }
73
+ .coll-progress-fill { height:100%; background:var(--jade); transition:width 0.4s; }
74
+ .coll-card-meta { display:flex; justify-content:space-between; font-size:9px; color:var(--muted); }
75
+ .coll-card-meta .pub { color:var(--jade); }
76
+ .coll-actions { display:flex; gap:6px; margin-top:8px; }
77
+ .btn-sm { font-size:9px; padding:4px 10px; border:1px solid var(--line); color:var(--mid); background:transparent; font-family:var(--mono); cursor:pointer; text-decoration:none; letter-spacing:0.06em; display:inline-flex; align-items:center; gap:4px; border-radius:var(--radius); transition:all 0.12s; }
78
+ .btn-sm:hover { border-color:var(--gold); color:var(--gold); }
79
+ .btn-sm.primary { border-color:var(--gold); color:var(--gold); }
80
+ .btn-sm.primary:hover { background:var(--gold); color:var(--bg0); }
81
+
82
+ /* Workspace */
83
+ .workspace { display:grid; grid-template-columns:1fr 1fr; border-top:1px solid var(--line); }
84
+ .ws-panel { padding:24px 32px; }
85
+ .ws-panel + .ws-panel { border-left:1px solid var(--line); }
86
+ .ws-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
87
+ .ws-save-indicator { font-size:9px; color:var(--muted); transition:color 0.2s; }
88
+ .ws-export-btn { background:none; border:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:9px; padding:2px 8px; cursor:pointer; letter-spacing:0.06em; transition:all 0.12s; }
89
+ .ws-export-btn:hover { border-color:var(--accent); color:var(--accent); }
90
+ .notes-area { width:100%; min-height:160px; resize:vertical; background:var(--bg2); border:1px solid var(--line); padding:12px 14px; color:var(--text); font-family:var(--mono); font-size:12px; line-height:1.8; outline:none; border-radius:var(--radius); transition:border-color 0.15s; }
91
+ .notes-area:focus { border-color:var(--accent); }
92
+
93
+ /* Todos */
94
+ .todo-input-row { display:flex; align-items:center; gap:0; margin-bottom:12px; border:1px solid var(--line); background:var(--bg2); border-radius:var(--radius); }
95
+ .todo-prompt { font-family:var(--mono); font-size:11px; color:var(--muted); padding:7px 6px 7px 10px; flex-shrink:0; user-select:none; }
96
+ .todo-input { flex:1; background:transparent; border:none; padding:7px 4px; color:var(--text); font-family:var(--mono); font-size:11px; outline:none; }
97
+ .todo-add-btn { padding:7px 12px; background:transparent; border:none; border-left:1px solid var(--line); color:var(--muted); font-family:var(--mono); font-size:10px; cursor:pointer; letter-spacing:0.06em; transition:color 0.12s; flex-shrink:0; }
98
+ .todo-add-btn:hover { color:var(--heading); }
99
+ .todo-footer { display:flex; align-items:center; justify-content:space-between; padding-top:8px; min-height:20px; }
100
+ .todo-count { font-size:9px; color:var(--muted); }
101
+ .todo-clear-btn { background:none; border:none; font-family:var(--mono); font-size:9px; color:var(--muted); cursor:pointer; padding:0; letter-spacing:0.04em; transition:color 0.12s; }
102
+ .todo-clear-btn:hover { color:var(--heading); }
103
+
104
+ .empty-state { padding:40px 20px; text-align:center; color:var(--muted); font-size:11px; border:1px solid var(--line); line-height:2; }
105
+ </style>
106
+ </head>
107
+ <body>
108
+ <div class="app">
109
+
110
+ <!-- Topbar -->
111
+ <header class="topbar">
112
+ <a class="logo" href="/dashboard.html"><div class="logo-mark">OR</div>Orbiter</a>
113
+ <div class="topbar-right">
114
+ <button class="search-trigger" id="search-btn" title="Search (⌘K)">
115
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
116
+ Search <kbd>⌘K</kbd>
117
+ </button>
118
+ <button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
119
+ <span class="user" id="topbar-user"></span>
120
+ <span class="logout" id="logout-btn">Sign out</span>
121
+ </div>
122
+ </header>
123
+
124
+ <!-- Sidebar -->
125
+ <nav class="sidebar">
126
+ <div class="nav-section">Content</div>
127
+ <a class="nav-item active" href="/dashboard.html"><span class="nav-icon">◈</span>Dashboard</a>
128
+ <a class="nav-item" href="/collections.html"><span class="nav-icon">⊞</span>Collections</a>
129
+ <div class="nav-section">Assets</div>
130
+ <a class="nav-item" href="/media.html"><span class="nav-icon">⊟</span>Media</a>
131
+ <div class="nav-section">System</div>
132
+ <a class="nav-item" href="/schema.html"><span class="nav-icon">◈</span>Schema</a>
133
+ <a class="nav-item" href="/build.html"><span class="nav-icon">▲</span>Build</a>
134
+ <a class="nav-item" href="/settings.html"><span class="nav-icon">◎</span>Settings</a>
135
+ <a class="nav-item admin-only" href="/users.html" style="display:none"><span class="nav-icon">◉</span>Users</a>
136
+ <div class="sidebar-footer">
137
+ <div class="pod-name" id="pod-name">content.pod</div>
138
+ <div class="pod-info" id="pod-info"></div>
139
+ <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
140
+ </div>
141
+ </nav>
142
+
143
+ <!-- Main -->
144
+ <main class="main">
145
+
146
+ <!-- Hero -->
147
+ <div class="hero glass-card">
148
+ <div>
149
+ <div class="hero-site" id="hero-site">Orbiter</div>
150
+ <div class="hero-date" id="hero-date"></div>
151
+ </div>
152
+ <div class="hero-right">
153
+ <span class="deploy-status" id="deploy-status"></span>
154
+ <a href="#" target="_blank" class="hero-url" id="hero-url" style="display:none">↗ Open site</a>
155
+ <button class="btn-deploy" id="deploy-btn" style="display:none">↑ Deploy</button>
156
+ </div>
157
+ </div>
158
+
159
+ <!-- Stats row -->
160
+ <div class="stats-row glass-card">
161
+ <div class="stat-cell">
162
+ <div class="stat-label">Total entries</div>
163
+ <div class="stat-num" id="stat-total">—</div>
164
+ <div class="stat-sub" id="stat-collections-sub"></div>
165
+ <div class="stat-bar"><div class="stat-bar-fill accent" style="width:100%"></div></div>
166
+ </div>
167
+ <div class="stat-cell">
168
+ <div class="stat-label">Published</div>
169
+ <div class="stat-num" id="stat-published">—</div>
170
+ <div class="stat-sub"><span class="stat-dot jade"></span><span id="stat-published-pct">0%</span> of total</div>
171
+ <div class="stat-bar"><div class="stat-bar-fill jade" id="bar-published" style="width:0%"></div></div>
172
+ </div>
173
+ <div class="stat-cell">
174
+ <div class="stat-label">Drafts</div>
175
+ <div class="stat-num" id="stat-drafts">—</div>
176
+ <div class="stat-sub"><span class="stat-dot gold"></span>draft</div>
177
+ <div class="stat-bar"><div class="stat-bar-fill gold" id="bar-drafts" style="width:0%"></div></div>
178
+ </div>
179
+ <div class="stat-cell">
180
+ <div class="stat-label">Media</div>
181
+ <div class="stat-num" id="stat-media">—</div>
182
+ <div class="stat-sub">files</div>
183
+ <div class="stat-bar"><div class="stat-bar-fill accent" style="width:60%"></div></div>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- Content: recent entries + collection cards -->
188
+ <div class="dash-content glass-card">
189
+ <div class="content-main">
190
+ <div class="section-head">
191
+ <div class="section-title">Recently edited</div>
192
+ </div>
193
+ <div id="recent-body"><div class="empty"><div class="spinner"></div></div></div>
194
+ </div>
195
+ <div class="content-side">
196
+ <div class="side-section">
197
+ <div class="section-head">
198
+ <div class="section-title">Collections</div>
199
+ </div>
200
+ <div id="coll-cards"><div class="empty"><div class="spinner"></div></div></div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- Workspace: Notes + To-do -->
206
+ <div class="workspace glass-card">
207
+ <div class="ws-panel">
208
+ <div class="ws-head">
209
+ <div class="section-title">Notes</div>
210
+ <div style="display:flex;align-items:center;gap:10px">
211
+ <span class="ws-save-indicator" id="notes-indicator"></span>
212
+ <button class="ws-export-btn" id="export-notes" title="Export as Markdown">↓ md</button>
213
+ </div>
214
+ </div>
215
+ <textarea class="notes-area" id="notes-area" placeholder="Jot something down…"></textarea>
216
+ </div>
217
+ <div class="ws-panel">
218
+ <div class="ws-head">
219
+ <div class="section-title">To-Do</div>
220
+ <div style="display:flex;align-items:center;gap:10px">
221
+ <span class="ws-save-indicator" id="todos-indicator"></span>
222
+ <button class="ws-export-btn" id="export-todos" title="Export as Markdown">↓ md</button>
223
+ </div>
224
+ </div>
225
+ <div class="todo-input-row">
226
+ <span class="todo-prompt">›</span>
227
+ <input class="todo-input" id="todo-input" placeholder="Add a task…" type="text" />
228
+ <button class="todo-add-btn" id="todo-add-btn">↵ add</button>
229
+ </div>
230
+ <div id="todo-list"></div>
231
+ <div class="todo-footer">
232
+ <span class="todo-count" id="todo-count"></span>
233
+ <button class="todo-clear-btn" id="todo-clear-btn" style="display:none">Clear done</button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+
239
+ <script type="module">
240
+ // Auth
241
+ const me = await fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()).catch(() => null);
242
+ if (!me?.user) { location.replace('/login.html'); }
243
+ document.getElementById('topbar-user').textContent = me.user.username;
244
+ if (me.user.role === 'admin') document.querySelectorAll('.admin-only').forEach(el => el.style.display = '');
245
+ document.getElementById('logout-btn').addEventListener('click', async () => {
246
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
247
+ location.replace('/login.html');
248
+ });
249
+
250
+ // Hero date
251
+ document.getElementById('hero-date').textContent =
252
+ new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
253
+
254
+ // Load data in parallel
255
+ const [collections, meta, recent, mediaList] = await Promise.all([
256
+ fetch('/api/collections', { credentials: 'include' }).then(r => r.json()).catch(() => []),
257
+ fetch('/api/meta', { credentials: 'include' }).then(r => r.json()).catch(() => ({})),
258
+ fetch('/api/search/recent?limit=10', { credentials: 'include' }).then(r => r.json()).catch(() => []),
259
+ fetch('/api/media', { credentials: 'include' }).then(r => r.json()).catch(() => []),
260
+ ]);
261
+
262
+ // Hero site name + url
263
+ const siteName = meta['site.name'] || 'Orbiter';
264
+ document.getElementById('hero-site').textContent = siteName;
265
+ document.title = `${siteName} — Dashboard`;
266
+ const siteUrl = meta['site.url'];
267
+ if (siteUrl) {
268
+ const urlEl = document.getElementById('hero-url');
269
+ urlEl.href = siteUrl; urlEl.style.display = '';
270
+ }
271
+
272
+ // Deploy button
273
+ if (meta['build.webhook_url']) {
274
+ const deployBtn = document.getElementById('deploy-btn');
275
+ deployBtn.style.display = '';
276
+ deployBtn.addEventListener('click', async () => {
277
+ deployBtn.disabled = true;
278
+ deployBtn.textContent = '↑ Deploying…';
279
+ const statusEl = document.getElementById('deploy-status');
280
+ try {
281
+ const res = await fetch('/api/build/trigger', { method: 'POST', credentials: 'include' });
282
+ if (res.ok) { statusEl.textContent = '✓ triggered'; statusEl.style.color = 'var(--jade)'; }
283
+ else { statusEl.textContent = '✕ failed'; statusEl.style.color = 'var(--red)'; }
284
+ } catch { statusEl.textContent = 'network error'; }
285
+ deployBtn.textContent = '↑ Deploy';
286
+ deployBtn.disabled = false;
287
+ setTimeout(() => { statusEl.textContent = ''; }, 3000);
288
+ });
289
+ }
290
+
291
+ // Stats — fetch published counts per collection
292
+ const topLevel = collections.filter(c => !c.parent);
293
+ const collStats = await Promise.all(topLevel.map(async col => {
294
+ const all = await fetch(`/api/collections/${col.id}/entries`, { credentials: 'include' }).then(r => r.json()).catch(() => []);
295
+ const published = all.filter(e => e.status === 'published').length;
296
+ return { ...col, total: all.length, published, drafts: all.length - published };
297
+ }));
298
+
299
+ const totalEntries = collStats.reduce((n, c) => n + c.total, 0);
300
+ const totalPublished = collStats.reduce((n, c) => n + c.published, 0);
301
+ const totalDrafts = totalEntries - totalPublished;
302
+ const pubPct = totalEntries > 0 ? Math.round(totalPublished / totalEntries * 100) : 0;
303
+ const draftPct = totalEntries > 0 ? Math.round(totalDrafts / totalEntries * 100) : 0;
304
+
305
+ document.getElementById('stat-total').textContent = totalEntries;
306
+ document.getElementById('stat-collections-sub').textContent = topLevel.length + ' collections';
307
+ document.getElementById('stat-published').textContent = totalPublished;
308
+ document.getElementById('stat-published-pct').textContent = pubPct + '%';
309
+ document.getElementById('bar-published').style.width = pubPct + '%';
310
+ document.getElementById('stat-drafts').textContent = totalDrafts;
311
+ document.getElementById('bar-drafts').style.width = draftPct + '%';
312
+ document.getElementById('stat-media').textContent = mediaList.length;
313
+
314
+ // Recent entries
315
+ const recentBody = document.getElementById('recent-body');
316
+ if (!recent.length) {
317
+ recentBody.innerHTML = '<div class="empty-state">No entries yet</div>';
318
+ } else {
319
+ recentBody.innerHTML = `<div class="entry-list">${recent.map(e => {
320
+ const date = e.updated_at ? e.updated_at.split(' ')[0] : '';
321
+ return `<a class="entry-row" href="/editor.html?collection=${e.collection}&slug=${e.slug}">
322
+ <div>
323
+ <div class="entry-name">${e.title || e.slug}</div>
324
+ <div class="entry-coll">${e.label || e.collection}</div>
325
+ </div>
326
+ <div class="entry-date">${date}</div>
327
+ <div class="pill ${e.status}">${e.status}</div>
328
+ </a>`;
329
+ }).join('')}</div>`;
330
+ }
331
+
332
+ // Collection cards
333
+ const collCards = document.getElementById('coll-cards');
334
+ if (!collStats.length) {
335
+ collCards.innerHTML = '<div class="empty-state">No collections yet</div>';
336
+ } else {
337
+ collCards.innerHTML = collStats.map(col => {
338
+ const pct = col.total > 0 ? Math.round(col.published / col.total * 100) : 0;
339
+ return `<div class="coll-card">
340
+ <div class="coll-card-head">
341
+ <div class="coll-card-name">${col.label}</div>
342
+ <div class="coll-card-count">${col.total}</div>
343
+ </div>
344
+ <div class="coll-progress"><div class="coll-progress-fill" style="width:${pct}%"></div></div>
345
+ <div class="coll-card-meta">
346
+ <span class="pub">${col.published} published</span>
347
+ <span>${col.drafts} drafts</span>
348
+ </div>
349
+ <div class="coll-actions">
350
+ <a href="/entries.html?col=${col.id}&label=${encodeURIComponent(col.label)}" class="btn-sm primary">Open →</a>
351
+ </div>
352
+ </div>`;
353
+ }).join('');
354
+ }
355
+
356
+ // ── Notes ───────────────────────────────────────────────────────────
357
+ const notesArea = document.getElementById('notes-area');
358
+ const notesIndicator = document.getElementById('notes-indicator');
359
+ notesArea.value = meta['dashboard.notes'] ?? '';
360
+ let noteTimer;
361
+ notesArea.addEventListener('input', () => {
362
+ notesIndicator.textContent = '● unsaved'; notesIndicator.style.color = 'var(--gold)';
363
+ clearTimeout(noteTimer);
364
+ noteTimer = setTimeout(async () => {
365
+ notesIndicator.textContent = '↑ saving…'; notesIndicator.style.color = 'var(--muted)';
366
+ await fetch('/api/meta', {
367
+ method: 'PUT', credentials: 'include',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: JSON.stringify({ 'dashboard.notes': notesArea.value }),
370
+ });
371
+ notesIndicator.textContent = '✓ saved'; notesIndicator.style.color = 'var(--jade)';
372
+ setTimeout(() => notesIndicator.textContent = '', 2000);
373
+ }, 1200);
374
+ });
375
+ document.getElementById('export-notes').addEventListener('click', () => {
376
+ const text = notesArea.value.trim();
377
+ if (!text) return;
378
+ const a = document.createElement('a');
379
+ a.href = URL.createObjectURL(new Blob([text], { type: 'text/markdown' }));
380
+ a.download = 'notes.md'; a.click(); URL.revokeObjectURL(a.href);
381
+ });
382
+
383
+ // ── To-Do ───────────────────────────────────────────────────────────
384
+ let todos = [];
385
+ try { todos = JSON.parse(meta['dashboard.todos'] ?? '[]'); } catch {}
386
+ if (!Array.isArray(todos)) todos = [];
387
+
388
+ const todosIndicator = document.getElementById('todos-indicator');
389
+
390
+ function todoRender() {
391
+ const list = document.getElementById('todo-list');
392
+ const countEl = document.getElementById('todo-count');
393
+ const clearBtn = document.getElementById('todo-clear-btn');
394
+ const doneCount = todos.filter(t => t.done).length;
395
+ const openCount = todos.length - doneCount;
396
+ clearBtn.style.display = doneCount > 0 ? '' : 'none';
397
+ countEl.textContent = todos.length
398
+ ? openCount + ' open' + (doneCount ? ' · ' + doneCount + ' done' : '')
399
+ : '';
400
+ if (!todos.length) {
401
+ list.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:8px 0;font-family:var(--mono)">No tasks yet</div>';
402
+ return;
403
+ }
404
+ list.innerHTML = '';
405
+ todos.forEach((todo, idx) => {
406
+ const row = document.createElement('div');
407
+ row.style.cssText = `display:flex;align-items:center;gap:10px;padding:5px 0;border-bottom:1px solid var(--line2);${todo.done ? 'opacity:0.45;' : ''}`;
408
+ if (idx === 0) row.style.borderTop = '1px solid var(--line2)';
409
+
410
+ const toggle = document.createElement('button');
411
+ toggle.style.cssText = 'background:none;border:none;font-family:var(--mono);font-size:11px;cursor:pointer;padding:0;flex-shrink:0;white-space:nowrap;color:' + (todo.done ? 'var(--jade)' : 'var(--muted)') + ';';
412
+ toggle.textContent = todo.done ? '[×]' : '[ ]';
413
+ toggle.addEventListener('click', () => { todos[idx].done = !todos[idx].done; todoRender(); todoSave(); });
414
+
415
+ const text = document.createElement('div');
416
+ text.style.cssText = `flex:1;min-width:0;font-size:11px;font-family:var(--mono);color:${todo.done ? 'var(--muted)' : 'var(--text)'};${todo.done ? 'text-decoration:line-through;' : ''}white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`;
417
+ text.textContent = todo.text; text.title = todo.text;
418
+
419
+ const del = document.createElement('button');
420
+ del.style.cssText = 'background:none;border:none;font-family:var(--mono);font-size:11px;cursor:pointer;padding:0;flex-shrink:0;color:var(--line);';
421
+ del.textContent = '[del]';
422
+ del.addEventListener('mouseenter', () => del.style.color = 'var(--muted)');
423
+ del.addEventListener('mouseleave', () => del.style.color = 'var(--line)');
424
+ del.addEventListener('click', () => { todos.splice(idx, 1); todoRender(); todoSave(); });
425
+
426
+ row.appendChild(toggle); row.appendChild(text); row.appendChild(del);
427
+ list.appendChild(row);
428
+ });
429
+ }
430
+
431
+ async function todoSave() {
432
+ todosIndicator.textContent = '✓ saved'; todosIndicator.style.color = 'var(--jade)';
433
+ setTimeout(() => todosIndicator.textContent = '', 1500);
434
+ await fetch('/api/meta', {
435
+ method: 'PUT', credentials: 'include',
436
+ headers: { 'Content-Type': 'application/json' },
437
+ body: JSON.stringify({ 'dashboard.todos': JSON.stringify(todos) }),
438
+ }).catch(() => {});
439
+ }
440
+
441
+ function todoAdd() {
442
+ const input = document.getElementById('todo-input');
443
+ const text = input.value.trim();
444
+ if (!text) return;
445
+ todos.push({ id: Date.now(), text, done: false });
446
+ input.value = '';
447
+ todoRender(); todoSave();
448
+ }
449
+
450
+ document.getElementById('todo-add-btn').addEventListener('click', todoAdd);
451
+ document.getElementById('todo-input').addEventListener('keydown', e => { if (e.key === 'Enter') todoAdd(); });
452
+ document.getElementById('todo-clear-btn').addEventListener('click', () => {
453
+ todos = todos.filter(t => !t.done); todoRender(); todoSave();
454
+ });
455
+ document.getElementById('export-todos').addEventListener('click', () => {
456
+ if (!todos.length) return;
457
+ const date = new Date().toISOString().slice(0, 10);
458
+ const open = todos.filter(t => !t.done);
459
+ const done = todos.filter(t => t.done);
460
+ const lines = ['# To-Do — ' + date, ''];
461
+ open.forEach(t => lines.push('- [ ] ' + t.text));
462
+ if (open.length && done.length) lines.push('');
463
+ done.forEach(t => lines.push('- [x] ' + t.text));
464
+ const a = document.createElement('a');
465
+ a.href = URL.createObjectURL(new Blob([lines.join('\n')], { type: 'text/markdown' }));
466
+ a.download = 'todos-' + date + '.md'; a.click(); URL.revokeObjectURL(a.href);
467
+ });
468
+
469
+ todoRender();
470
+ </script>
471
+ </main>
472
+ </div>
473
+
474
+ <script src="/search.js"></script>
475
+ <script src="/sidebar.js"></script>
476
+ <script src="/router.js"></script>
477
+ </body>
478
+ </html>