@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,367 @@
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 — Entries</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
+ .bulk-bar {
14
+ display: none;
15
+ align-items: center;
16
+ gap: 10px;
17
+ padding: 8px 12px;
18
+ background: var(--accent-bg);
19
+ border: 1px solid rgba(139,124,248,.2);
20
+ border-radius: var(--radius);
21
+ margin-bottom: 12px;
22
+ font-size: 12px;
23
+ color: var(--text);
24
+ }
25
+ .bulk-bar.visible { display: flex; }
26
+ .bulk-bar-count { color: var(--accent); font-family: var(--mono); margin-right: 4px; }
27
+ .bulk-bar-spacer { flex: 1; }
28
+ .cb-col { width: 32px; padding-left: 16px !important; }
29
+ input[type=checkbox] { accent-color: var(--accent); width: 13px; height: 13px; cursor: pointer; }
30
+ /* row action buttons */
31
+ .row-actions { display:flex; gap:4px; justify-content:flex-end; align-items:center; }
32
+ .btn-row { display:inline-flex; align-items:center; justify-content:center; height:24px; padding:0 10px; font-size:10px; font-family:var(--mono); border-radius:4px; border:1px solid var(--line); background:none; color:var(--mid); cursor:pointer; transition:all 0.12s; text-decoration:none; letter-spacing:0; white-space:nowrap; line-height:1; }
33
+ .btn-row:hover { color:var(--heading); border-color:var(--mid); background:var(--hover-bg); }
34
+ .btn-row-toggle { min-width:80px; }
35
+ .btn-row-icon { padding:0; width:24px; font-size:12px; }
36
+ .btn-row-danger { color:var(--red); border-color:transparent; background:none; }
37
+ .btn-row-danger:hover { background:var(--red-bg); border-color:var(--red); }
38
+ /* filter tabs */
39
+ .filter-tab { display:inline-flex; align-items:center; padding:3px 11px; font-size:10px; font-family:var(--mono); border-radius:20px; border:1px solid var(--line); background:none; color:var(--muted); cursor:pointer; transition:all 0.12s; }
40
+ .filter-tab:hover { color:var(--text); border-color:var(--mid); }
41
+ .filter-tab.active { background:var(--accent-bg); color:var(--accent); border-color:var(--accent); }
42
+ </style>
43
+ </head>
44
+ <body>
45
+ <div class="app">
46
+ <header class="topbar">
47
+ <a class="logo" href="/dashboard.html"><div class="logo-mark">OR</div>Orbiter</a>
48
+ <div class="topbar-right">
49
+ <button class="search-trigger" id="search-btn" title="Search (⌘K)">
50
+ <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>
51
+ Search <kbd>⌘K</kbd>
52
+ </button>
53
+ <button class="scheme-toggle" id="scheme-toggle" title="Toggle scheme">◐</button>
54
+ <span class="user" id="topbar-user"></span>
55
+ <span class="logout" id="logout-btn">Sign out</span>
56
+ </div>
57
+ </header>
58
+ <nav class="sidebar">
59
+ <div class="nav-section">Content</div>
60
+ <a class="nav-item" href="/dashboard.html"><span class="nav-icon">◈</span>Dashboard</a>
61
+ <a class="nav-item active" href="/collections.html"><span class="nav-icon">⊞</span>Collections</a>
62
+ <div class="nav-section">Assets</div>
63
+ <a class="nav-item" href="/media.html"><span class="nav-icon">⊟</span>Media</a>
64
+ <div class="nav-section">System</div>
65
+ <a class="nav-item" href="/schema.html"><span class="nav-icon">◈</span>Schema</a>
66
+ <a class="nav-item" href="/build.html"><span class="nav-icon">▲</span>Build</a>
67
+ <a class="nav-item" href="/settings.html"><span class="nav-icon">◎</span>Settings</a>
68
+ <a class="nav-item admin-only" href="/users.html" style="display:none"><span class="nav-icon">◉</span>Users</a>
69
+ <div class="sidebar-footer">
70
+ <div class="pod-name" id="pod-name">content.pod</div>
71
+ <div class="pod-info" id="pod-info"></div>
72
+ <div class="pod-status"><span class="pod-dot"></span>pod synced</div>
73
+ </div>
74
+ </nav>
75
+ <main class="main">
76
+ <div class="page-header glass-card" style="display:flex;align-items:flex-start;justify-content:space-between;gap:16px">
77
+ <div>
78
+ <div style="font-size:11px;color:var(--muted);margin-bottom:4px">
79
+ <a href="/collections.html" style="color:var(--muted)">Collections</a>
80
+ <span style="margin:0 6px;color:var(--line)">/</span>
81
+ <span id="col-label" style="color:var(--mid)"></span>
82
+ </div>
83
+ <h1 class="page-title" id="page-title">Entries</h1>
84
+ <p class="page-sub" id="page-sub"></p>
85
+ </div>
86
+ <button class="btn btn-primary" id="new-btn" style="flex-shrink:0;margin-top:4px">+ New entry</button>
87
+ </div>
88
+
89
+ <!-- Filter + table glass card -->
90
+ <div class="glass-card entries-content-card">
91
+ <!-- Filter bar -->
92
+ <div class="entries-filter-bar" style="display:flex;gap:6px">
93
+ <button class="filter-tab active" data-status="">All</button>
94
+ <button class="filter-tab" data-status="published">Published</button>
95
+ <button class="filter-tab" data-status="draft">Drafts</button>
96
+ </div>
97
+
98
+ <!-- Bulk action bar -->
99
+ <div class="bulk-bar" id="bulk-bar">
100
+ <span><span class="bulk-bar-count" id="bulk-count">0</span> selected</span>
101
+ <button class="btn btn-ghost btn-sm" id="bulk-publish">Publish</button>
102
+ <button class="btn btn-ghost btn-sm" id="bulk-draft">Unpublish</button>
103
+ <span class="bulk-bar-spacer"></span>
104
+ <button class="btn btn-danger btn-sm" id="bulk-delete">Delete selected</button>
105
+ </div>
106
+
107
+ <div class="table-wrap" id="entries-wrap">
108
+ <div class="empty"><div class="spinner"></div></div>
109
+ </div>
110
+ </div>
111
+
112
+ <script type="module">
113
+ const params = new URLSearchParams(location.search);
114
+ const colId = params.get('col') ?? '';
115
+ const colLabel = decodeURIComponent(params.get('label') ?? colId);
116
+
117
+ const me = await fetch('/api/auth/me', { credentials: 'include' }).then(r => r.json()).catch(() => null);
118
+ if (!me?.user) { location.replace('/login.html'); }
119
+ document.getElementById('topbar-user').textContent = me.user.username;
120
+ if (me.user.role === 'admin') {
121
+ document.querySelectorAll('.admin-only').forEach(el => el.style.display = '');
122
+ }
123
+ document.getElementById('logout-btn').addEventListener('click', async () => {
124
+ await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
125
+ location.replace('/login.html');
126
+ });
127
+
128
+ document.getElementById('col-label').textContent = colLabel;
129
+ document.getElementById('page-title').textContent = colLabel;
130
+
131
+ let allEntries = [];
132
+ let activeFilter = '';
133
+ let selected = new Set();
134
+
135
+ function updateBulkBar() {
136
+ const bar = document.getElementById('bulk-bar');
137
+ if (selected.size > 0) {
138
+ bar.classList.add('visible');
139
+ document.getElementById('bulk-count').textContent = selected.size;
140
+ } else {
141
+ bar.classList.remove('visible');
142
+ }
143
+ }
144
+
145
+ async function loadEntries() {
146
+ selected.clear();
147
+ updateBulkBar();
148
+ const url = `/api/collections/${colId}/entries` + (activeFilter ? `?status=${activeFilter}` : '');
149
+ allEntries = await fetch(url, { credentials: 'include' }).then(r => r.json());
150
+ renderEntries(allEntries);
151
+ }
152
+
153
+ function renderEntries(entries) {
154
+ document.getElementById('page-sub').textContent = `${entries.length} entr${entries.length !== 1 ? 'ies' : 'y'}`;
155
+ // entry count shown in page-sub
156
+ const wrap = document.getElementById('entries-wrap');
157
+ if (entries.length === 0) {
158
+ wrap.innerHTML = '<div class="empty"><div class="empty-icon">◈</div>No entries yet</div>';
159
+ return;
160
+ }
161
+ wrap.innerHTML = `
162
+ <table>
163
+ <thead>
164
+ <tr>
165
+ <th class="cb-col"><input type="checkbox" id="check-all" title="Select all" /></th>
166
+ <th>Title / Slug</th><th>Status</th><th>Updated</th><th></th>
167
+ </tr>
168
+ </thead>
169
+ <tbody>
170
+ ${entries.map(e => {
171
+ const title = e.data?.title || e.slug;
172
+ const updated = e.updated_at ? e.updated_at.split(' ')[0] : '—';
173
+ const nextStatus = e.status === 'published' ? 'draft' : 'published';
174
+ const toggleLabel = e.status === 'published' ? 'Unpublish' : 'Publish';
175
+ return `<tr data-slug="${e.slug}">
176
+ <td class="cb-col"><input type="checkbox" class="row-cb" data-slug="${e.slug}" ${selected.has(e.slug) ? 'checked' : ''} /></td>
177
+ <td>
178
+ <div style="color:var(--heading);font-weight:500">${title}</div>
179
+ <div style="font-family:var(--mono);font-size:10px;color:var(--muted);margin-top:2px">${e.slug}</div>
180
+ </td>
181
+ <td><span class="badge badge-${e.status}">${e.status}</span></td>
182
+ <td style="font-family:var(--mono);font-size:11px;color:var(--muted)">${updated}</td>
183
+ <td style="width:1%;white-space:nowrap">
184
+ <div class="row-actions">
185
+ <a class="btn-row" href="/editor.html?collection=${colId}&slug=${e.slug}">Edit</a>
186
+ <button class="btn-row btn-row-toggle status-toggle" data-slug="${e.slug}" data-next="${nextStatus}">${toggleLabel}</button>
187
+ <button class="btn-row btn-row-icon dup-btn" data-slug="${e.slug}" title="Duplicate">⧉</button>
188
+ <button class="btn-row btn-row-danger delete-btn" data-slug="${e.slug}">Delete</button>
189
+ </div>
190
+ </td>
191
+ </tr>`;
192
+ }).join('')}
193
+ </tbody>
194
+ </table>`;
195
+
196
+ // Select all
197
+ document.getElementById('check-all').addEventListener('change', e => {
198
+ wrap.querySelectorAll('.row-cb').forEach(cb => {
199
+ cb.checked = e.target.checked;
200
+ if (e.target.checked) selected.add(cb.dataset.slug);
201
+ else selected.delete(cb.dataset.slug);
202
+ });
203
+ updateBulkBar();
204
+ });
205
+
206
+ // Row checkboxes
207
+ wrap.querySelectorAll('.row-cb').forEach(cb => {
208
+ cb.addEventListener('change', () => {
209
+ if (cb.checked) selected.add(cb.dataset.slug);
210
+ else selected.delete(cb.dataset.slug);
211
+ updateBulkBar();
212
+ });
213
+ });
214
+
215
+ // Status toggle
216
+ wrap.querySelectorAll('.status-toggle').forEach(btn => {
217
+ btn.addEventListener('click', async () => {
218
+ btn.disabled = true;
219
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/status`, {
220
+ method: 'PATCH', credentials: 'include',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ body: JSON.stringify({ status: btn.dataset.next }),
223
+ });
224
+ loadEntries();
225
+ });
226
+ });
227
+
228
+ // Duplicate
229
+ wrap.querySelectorAll('.dup-btn').forEach(btn => {
230
+ btn.addEventListener('click', async () => {
231
+ btn.disabled = true;
232
+ const res = await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}/duplicate`, {
233
+ method: 'POST', credentials: 'include',
234
+ });
235
+ if (res.ok) loadEntries();
236
+ else btn.disabled = false;
237
+ });
238
+ });
239
+
240
+ // Delete
241
+ wrap.querySelectorAll('.delete-btn').forEach(btn => {
242
+ btn.addEventListener('click', async () => {
243
+ if (!confirm(`Delete "${btn.dataset.slug}"?`)) return;
244
+ await fetch(`/api/collections/${colId}/entries/${btn.dataset.slug}`, {
245
+ method: 'DELETE', credentials: 'include',
246
+ });
247
+ loadEntries();
248
+ });
249
+ });
250
+ }
251
+
252
+ // Filter buttons
253
+ document.querySelectorAll('.filter-tab').forEach(btn => {
254
+ btn.addEventListener('click', () => {
255
+ document.querySelectorAll('.filter-tab').forEach(b => b.classList.remove('active'));
256
+ btn.classList.add('active');
257
+ activeFilter = btn.dataset.status;
258
+ loadEntries();
259
+ });
260
+ });
261
+
262
+ // Bulk actions
263
+ document.getElementById('bulk-publish').addEventListener('click', async () => {
264
+ await Promise.all([...selected].map(slug =>
265
+ fetch(`/api/collections/${colId}/entries/${slug}/status`, {
266
+ method: 'PATCH', credentials: 'include',
267
+ headers: { 'Content-Type': 'application/json' },
268
+ body: JSON.stringify({ status: 'published' }),
269
+ })
270
+ ));
271
+ loadEntries();
272
+ });
273
+
274
+ document.getElementById('bulk-draft').addEventListener('click', async () => {
275
+ await Promise.all([...selected].map(slug =>
276
+ fetch(`/api/collections/${colId}/entries/${slug}/status`, {
277
+ method: 'PATCH', credentials: 'include',
278
+ headers: { 'Content-Type': 'application/json' },
279
+ body: JSON.stringify({ status: 'draft' }),
280
+ })
281
+ ));
282
+ loadEntries();
283
+ });
284
+
285
+ document.getElementById('bulk-delete').addEventListener('click', async () => {
286
+ if (!confirm(`Delete ${selected.size} entr${selected.size !== 1 ? 'ies' : 'y'}?`)) return;
287
+ await Promise.all([...selected].map(slug =>
288
+ fetch(`/api/collections/${colId}/entries/${slug}`, {
289
+ method: 'DELETE', credentials: 'include',
290
+ })
291
+ ));
292
+ loadEntries();
293
+ });
294
+
295
+ // New entry modal
296
+ const overlay = document.getElementById('modal-overlay');
297
+ document.getElementById('new-btn').addEventListener('click', () => {
298
+ overlay.style.display = 'flex';
299
+ document.getElementById('new-slug').focus();
300
+ });
301
+ document.getElementById('modal-cancel').addEventListener('click', () => {
302
+ overlay.style.display = 'none';
303
+ });
304
+
305
+ // Auto-generate slug from title
306
+ document.getElementById('new-title').addEventListener('input', e => {
307
+ const slug = document.getElementById('new-slug');
308
+ if (!slug._touched) {
309
+ slug.value = e.target.value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').slice(0, 60);
310
+ }
311
+ });
312
+ document.getElementById('new-slug').addEventListener('input', e => {
313
+ e.target._touched = e.target.value.length > 0;
314
+ });
315
+
316
+ document.getElementById('modal-create').addEventListener('click', async () => {
317
+ const slug = document.getElementById('new-slug').value.trim();
318
+ const title = document.getElementById('new-title').value.trim();
319
+ const errEl = document.getElementById('modal-error');
320
+ if (!slug) { errEl.textContent = 'Slug is required'; errEl.style.display = 'block'; return; }
321
+ errEl.style.display = 'none';
322
+
323
+ const res = await fetch(`/api/collections/${colId}/entries`, {
324
+ method: 'POST', credentials: 'include',
325
+ headers: { 'Content-Type': 'application/json' },
326
+ body: JSON.stringify({ slug, data: { title }, status: 'draft' }),
327
+ });
328
+ if (res.ok) {
329
+ overlay.style.display = 'none';
330
+ location.href = `/editor.html?collection=${colId}&slug=${slug}`;
331
+ } else {
332
+ const d = await res.json();
333
+ errEl.textContent = d.error ?? 'Could not create entry';
334
+ errEl.style.display = 'block';
335
+ }
336
+ });
337
+
338
+ loadEntries();
339
+ </script>
340
+
341
+ <!-- New entry modal (inside .main so SPA navigation keeps it) -->
342
+ <div id="modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;align-items:center;justify-content:center">
343
+ <div style="background:var(--bg2);border:1px solid var(--line);border-radius:var(--radius);padding:28px 32px;width:400px;max-width:90vw">
344
+ <h3 style="font-size:14px;color:var(--heading);margin-bottom:20px;font-weight:500">New Entry</h3>
345
+ <div class="field">
346
+ <label class="label" for="new-slug">Slug</label>
347
+ <input class="input" id="new-slug" placeholder="my-entry-title" />
348
+ </div>
349
+ <div class="field">
350
+ <label class="label" for="new-title">Title</label>
351
+ <input class="input" id="new-title" placeholder="Entry title" />
352
+ </div>
353
+ <div id="modal-error" style="color:var(--red);font-size:12px;margin-bottom:12px;display:none"></div>
354
+ <div style="display:flex;gap:8px;justify-content:flex-end">
355
+ <button class="btn btn-ghost" id="modal-cancel">Cancel</button>
356
+ <button class="btn btn-primary" id="modal-create">Create</button>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ </main>
361
+ </div>
362
+
363
+ <script src="/search.js"></script>
364
+ <script src="/sidebar.js"></script>
365
+ <script src="/router.js"></script>
366
+ </body>
367
+ </html>
@@ -0,0 +1,6 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
+ <circle cx="16" cy="16" r="16" fill="#080e18"/>
3
+ <circle cx="16" cy="16" r="7.5" fill="none" stroke="#00c8a0" stroke-width="0.9"/>
4
+ <ellipse cx="16" cy="16" rx="13.5" ry="5.2" fill="none" stroke="#1898f8" stroke-width="0.75" opacity="0.75" transform="rotate(-20 16 16)"/>
5
+ <ellipse cx="16" cy="16" rx="15.2" ry="3.8" fill="none" stroke="#00c8a0" stroke-width="0.6" opacity="0.35" transform="rotate(55 16 16)"/>
6
+ </svg>