@a83/orbiter-admin 0.3.4 → 0.3.7
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.
- package/package.json +1 -1
- package/public/editor.html +8 -4
- package/public/entries.html +13 -30
- package/public/schema.html +46 -3
- package/src/routes/auth.js +35 -1
- package/src/routes/entries.js +21 -0
- package/src/server.js +11 -1
package/package.json
CHANGED
package/public/editor.html
CHANGED
|
@@ -1201,9 +1201,13 @@
|
|
|
1201
1201
|
const b=getFocusedBlock(); if (!b) return;
|
|
1202
1202
|
// Skip block-level Enter/Backspace handling when focus is inside a table cell
|
|
1203
1203
|
if (b.dataset.type==='table') return;
|
|
1204
|
-
if (e.key==='Enter'
|
|
1204
|
+
if (e.key==='Enter') {
|
|
1205
1205
|
e.preventDefault();
|
|
1206
|
-
const type=b.dataset.type;
|
|
1206
|
+
const type=b.dataset.type;
|
|
1207
|
+
if (type==='pre' && !e.shiftKey) { document.execCommand('insertText',false,'\n'); syncToHidden(); scheduleAutosave(); return; }
|
|
1208
|
+
if (type==='pre' && e.shiftKey) { const next=createBlock('p',''); b.after(next); focusStart(next); syncToHidden(); scheduleAutosave(); updatePreview(); return; }
|
|
1209
|
+
if (e.shiftKey) return;
|
|
1210
|
+
const newType=(type==='ul'||type==='ol')?type:'p';
|
|
1207
1211
|
const next=createBlock(newType,''); b.after(next); focusStart(next);
|
|
1208
1212
|
syncToHidden(); scheduleAutosave(); updatePreview(); return;
|
|
1209
1213
|
}
|
|
@@ -1333,12 +1337,12 @@
|
|
|
1333
1337
|
{label:'Heading 2', icon:'H2', hint:'##', type:'h2'},
|
|
1334
1338
|
{label:'Heading 3', icon:'H3', hint:'###', type:'h3'},
|
|
1335
1339
|
{label:'Quote', icon:'❝', hint:'>', type:'blockquote'},
|
|
1336
|
-
{label:'Code block', icon:'{}', hint:'
|
|
1340
|
+
{label:'Code block', icon:'{}', hint:'code', type:'pre'},
|
|
1337
1341
|
{label:'Callout', icon:'ℹ', hint:'note', type:'callout'},
|
|
1338
1342
|
{label:'Table', icon:'▦', hint:'tbl', type:'table'},
|
|
1339
1343
|
{label:'List', icon:'·', hint:'-', type:'ul'},
|
|
1340
1344
|
{label:'Numbered', icon:'1.', hint:'1.', type:'ol'},
|
|
1341
|
-
{label:'Divider', icon:'—', hint:'
|
|
1345
|
+
{label:'Divider', icon:'—', hint:'hr', type:'hr'},
|
|
1342
1346
|
{label:'Image', icon:'⊡', hint:'img', type:'image'},
|
|
1343
1347
|
{label:'Video', icon:'▶', hint:'vid', type:'video'},
|
|
1344
1348
|
];
|
package/public/entries.html
CHANGED
|
@@ -311,37 +311,20 @@
|
|
|
311
311
|
});
|
|
312
312
|
|
|
313
313
|
// Bulk actions
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
document.getElementById('bulk-draft').addEventListener('click', async () => {
|
|
326
|
-
await Promise.all([...selected].map(slug =>
|
|
327
|
-
fetch(`/api/collections/${colId}/entries/${slug}/status`, {
|
|
328
|
-
method: 'PATCH', credentials: 'include',
|
|
329
|
-
headers: { 'Content-Type': 'application/json' },
|
|
330
|
-
body: JSON.stringify({ status: 'draft' }),
|
|
331
|
-
})
|
|
332
|
-
));
|
|
333
|
-
loadEntries();
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
document.getElementById('bulk-delete').addEventListener('click', async () => {
|
|
337
|
-
if (!confirm(`Delete ${selected.size} entr${selected.size !== 1 ? 'ies' : 'y'}?`)) return;
|
|
338
|
-
await Promise.all([...selected].map(slug =>
|
|
339
|
-
fetch(`/api/collections/${colId}/entries/${slug}`, {
|
|
340
|
-
method: 'DELETE', credentials: 'include',
|
|
341
|
-
})
|
|
342
|
-
));
|
|
314
|
+
async function bulkAction(action) {
|
|
315
|
+
const slugs = [...selected];
|
|
316
|
+
if (!slugs.length) return;
|
|
317
|
+
if (action === 'delete' && !confirm(`Delete ${slugs.length} entr${slugs.length !== 1 ? 'ies' : 'y'}?`)) return;
|
|
318
|
+
await fetch(`/api/collections/${colId}/entries/bulk`, {
|
|
319
|
+
method: 'POST', credentials: 'include',
|
|
320
|
+
headers: { 'Content-Type': 'application/json' },
|
|
321
|
+
body: JSON.stringify({ action, slugs }),
|
|
322
|
+
});
|
|
343
323
|
loadEntries();
|
|
344
|
-
}
|
|
324
|
+
}
|
|
325
|
+
document.getElementById('bulk-publish').addEventListener('click', () => bulkAction('publish'));
|
|
326
|
+
document.getElementById('bulk-draft').addEventListener('click', () => bulkAction('draft'));
|
|
327
|
+
document.getElementById('bulk-delete').addEventListener('click', () => bulkAction('delete'));
|
|
345
328
|
|
|
346
329
|
// New entry modal
|
|
347
330
|
const overlay = document.getElementById('modal-overlay');
|
package/public/schema.html
CHANGED
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
.section-label { font-size:9px; letter-spacing:0.28em; text-transform:uppercase; color:var(--muted); margin-bottom:14px; display:flex; align-items:center; gap:8px; }
|
|
39
39
|
.section-label::before { content:"—"; color:var(--gold); }
|
|
40
40
|
.field-rows { background:var(--bg2); border:1px solid var(--line); margin-bottom:14px; border-radius:var(--radius); overflow:hidden; min-height:10px; }
|
|
41
|
+
.fld-drag { cursor:grab; color:var(--muted); opacity:0.4; font-size:13px; padding:0 4px; user-select:none; }
|
|
42
|
+
.fld-drag:hover { opacity:0.9; }
|
|
43
|
+
.fld-row-dragging { opacity:0.4; }
|
|
44
|
+
.fld-row-over { outline:2px solid var(--accent); outline-offset:-2px; }
|
|
41
45
|
.editor-actions { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:20px 0 0; border-top:1px solid var(--line); margin-top:20px; }
|
|
42
46
|
.banner { padding:8px 16px; font-size:10px; display:flex; align-items:center; gap:6px; border-radius:var(--radius); margin-bottom:14px; }
|
|
43
47
|
.banner-ok::before { content:"✓ "; }
|
|
@@ -163,7 +167,7 @@
|
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
const S = {
|
|
166
|
-
row: 'display:grid;grid-template-columns:1fr 260px;align-items:start;gap:
|
|
170
|
+
row: 'display:grid;grid-template-columns:20px 1fr 260px;align-items:start;gap:16px;padding:14px 18px 14px 10px;border-bottom:1px solid var(--line2);background:var(--bg2);',
|
|
167
171
|
input: 'width:100%;background:var(--bg0);border:1px solid var(--line);padding:7px 9px;color:var(--heading);font-family:var(--mono);font-size:11px;outline:none;appearance:none;box-sizing:border-box;',
|
|
168
172
|
iKey: 'width:100%;background:var(--bg0);border:1px solid var(--line);padding:7px 9px;color:var(--heading);font-family:var(--mono);font-size:12px;outline:none;box-sizing:border-box;',
|
|
169
173
|
iLbl: 'width:100%;background:var(--bg0);border:1px solid var(--line);padding:6px 9px;color:var(--muted);font-family:var(--mono);font-size:10px;outline:none;box-sizing:border-box;',
|
|
@@ -185,7 +189,9 @@
|
|
|
185
189
|
function addFieldRow(container, key='', label='', type='string', required=false, options='', collection='', multiple=true) {
|
|
186
190
|
const div = document.createElement('div');
|
|
187
191
|
div.style.cssText = S.row;
|
|
192
|
+
div.setAttribute('draggable','true');
|
|
188
193
|
div.innerHTML = `
|
|
194
|
+
<span class="fld-drag" title="Drag to reorder">⠿</span>
|
|
189
195
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
|
190
196
|
<input class="f-key" style="${S.iKey}" placeholder="field_key" value="${escHtml(key)}" />
|
|
191
197
|
<input class="f-label" style="${S.iLbl}" placeholder="Field label" value="${escHtml(label)}" />
|
|
@@ -237,6 +243,40 @@
|
|
|
237
243
|
container.appendChild(div);
|
|
238
244
|
}
|
|
239
245
|
|
|
246
|
+
function enableFieldDrag(container) {
|
|
247
|
+
let dragSrc = null;
|
|
248
|
+
container.addEventListener('dragstart', e => {
|
|
249
|
+
const row = e.target.closest('[draggable]');
|
|
250
|
+
if (!row || !row.querySelector('.fld-drag')?.contains(e.target) && e.target.className !== 'fld-drag') { e.preventDefault(); return; }
|
|
251
|
+
dragSrc = row;
|
|
252
|
+
row.classList.add('fld-row-dragging');
|
|
253
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
254
|
+
});
|
|
255
|
+
container.addEventListener('dragend', () => {
|
|
256
|
+
dragSrc?.classList.remove('fld-row-dragging');
|
|
257
|
+
container.querySelectorAll('.fld-row-over').forEach(r => r.classList.remove('fld-row-over'));
|
|
258
|
+
dragSrc = null;
|
|
259
|
+
});
|
|
260
|
+
container.addEventListener('dragover', e => {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
const row = e.target.closest('[draggable]');
|
|
263
|
+
container.querySelectorAll('.fld-row-over').forEach(r => r.classList.remove('fld-row-over'));
|
|
264
|
+
if (row && row !== dragSrc) row.classList.add('fld-row-over');
|
|
265
|
+
});
|
|
266
|
+
container.addEventListener('drop', e => {
|
|
267
|
+
e.preventDefault();
|
|
268
|
+
const row = e.target.closest('[draggable]');
|
|
269
|
+
if (!dragSrc || !row || row === dragSrc) return;
|
|
270
|
+
row.classList.remove('fld-row-over');
|
|
271
|
+
const rows = [...container.children];
|
|
272
|
+
const from = rows.indexOf(dragSrc);
|
|
273
|
+
const to = rows.indexOf(row);
|
|
274
|
+
container.removeChild(dragSrc);
|
|
275
|
+
if (from < to) row.after(dragSrc);
|
|
276
|
+
else row.before(dragSrc);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
240
280
|
function serializeSchema(container) {
|
|
241
281
|
const schema = {};
|
|
242
282
|
container.querySelectorAll(':scope > div[style]').forEach(row=>{
|
|
@@ -296,8 +336,10 @@
|
|
|
296
336
|
document.getElementById('new-col-id').addEventListener('input',e=>{
|
|
297
337
|
e.target.value=e.target.value.toLowerCase().replace(/[^a-z0-9_]/g,'_').replace(/^_+/,'');
|
|
298
338
|
});
|
|
299
|
-
document.getElementById('
|
|
300
|
-
|
|
339
|
+
const newRowsEl = document.getElementById('new-field-rows');
|
|
340
|
+
enableFieldDrag(newRowsEl);
|
|
341
|
+
document.getElementById('btn-add-new').addEventListener('click',()=>addFieldRow(newRowsEl));
|
|
342
|
+
addFieldRow(newRowsEl,'title','Title','string',true);
|
|
301
343
|
document.getElementById('btn-save-new').addEventListener('click',async()=>{
|
|
302
344
|
const id=document.getElementById('new-col-id').value.trim();
|
|
303
345
|
const label=document.getElementById('new-col-label').value.trim();
|
|
@@ -356,6 +398,7 @@
|
|
|
356
398
|
</div>
|
|
357
399
|
`;
|
|
358
400
|
const rowsEl=document.getElementById('edit-field-rows');
|
|
401
|
+
enableFieldDrag(rowsEl);
|
|
359
402
|
Object.entries(schema).forEach(([k,f])=>addFieldRow(rowsEl,k,f.label??k,f.type??'string',!!f.required,(f.options??[]).join(', '),f.collection??'',f.multiple!==false));
|
|
360
403
|
document.getElementById('btn-add-edit').addEventListener('click',()=>addFieldRow(rowsEl));
|
|
361
404
|
// Load existing preview URL for this collection
|
package/src/routes/auth.js
CHANGED
|
@@ -4,8 +4,40 @@ import { openPod, verifyPassword, generateToken } from '@a83/orbiter-core';
|
|
|
4
4
|
|
|
5
5
|
export const authRoutes = new Hono();
|
|
6
6
|
|
|
7
|
+
const LOGIN_MAX = 5;
|
|
8
|
+
const LOGIN_WINDOW = 15 * 60 * 1000; // 15 min
|
|
9
|
+
const loginAttempts = new Map(); // ip → { count, resetAt }
|
|
10
|
+
|
|
11
|
+
function getRealIp(c) {
|
|
12
|
+
return c.req.header('x-forwarded-for')?.split(',')[0].trim()
|
|
13
|
+
?? c.req.header('x-real-ip')
|
|
14
|
+
?? 'unknown';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function checkRateLimit(ip) {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const rec = loginAttempts.get(ip);
|
|
20
|
+
if (rec && rec.resetAt > now && rec.count >= LOGIN_MAX) return false;
|
|
21
|
+
if (!rec || rec.resetAt <= now) loginAttempts.set(ip, { count: 0, resetAt: now + LOGIN_WINDOW });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function recordFailure(ip) {
|
|
26
|
+
const rec = loginAttempts.get(ip);
|
|
27
|
+
if (rec) rec.count++;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function clearAttempts(ip) {
|
|
31
|
+
loginAttempts.delete(ip);
|
|
32
|
+
}
|
|
33
|
+
|
|
7
34
|
// POST /api/auth/login
|
|
8
35
|
authRoutes.post('/login', async (c) => {
|
|
36
|
+
const ip = getRealIp(c);
|
|
37
|
+
if (!checkRateLimit(ip)) {
|
|
38
|
+
return c.json({ error: 'Too many login attempts. Try again in 15 minutes.' }, 429);
|
|
39
|
+
}
|
|
40
|
+
|
|
9
41
|
const { username, password } = await c.req.json();
|
|
10
42
|
if (!username || !password) return c.json({ error: 'Missing credentials' }, 400);
|
|
11
43
|
|
|
@@ -13,8 +45,10 @@ authRoutes.post('/login', async (c) => {
|
|
|
13
45
|
const user = db.getUserByUsername(username);
|
|
14
46
|
if (!user || !(await verifyPassword(password, user.password))) {
|
|
15
47
|
db.close();
|
|
48
|
+
recordFailure(ip);
|
|
16
49
|
return c.json({ error: 'Invalid username or password' }, 401);
|
|
17
50
|
}
|
|
51
|
+
clearAttempts(ip);
|
|
18
52
|
|
|
19
53
|
const token = generateToken();
|
|
20
54
|
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
|
@@ -24,7 +58,7 @@ authRoutes.post('/login', async (c) => {
|
|
|
24
58
|
|
|
25
59
|
setCookie(c, 'orb_sess', token, {
|
|
26
60
|
httpOnly: true,
|
|
27
|
-
sameSite: '
|
|
61
|
+
sameSite: 'Strict',
|
|
28
62
|
path: '/',
|
|
29
63
|
maxAge: 30 * 24 * 60 * 60,
|
|
30
64
|
});
|
package/src/routes/entries.js
CHANGED
|
@@ -11,6 +11,27 @@ function fireWebhook(podPath) {
|
|
|
11
11
|
if (url) fetch(url, { method: 'POST' }).catch(() => {});
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// POST /api/collections/:id/entries/bulk — bulk publish or delete
|
|
15
|
+
entryRoutes.post('/:collectionId/entries/bulk', async (c) => {
|
|
16
|
+
const { collectionId } = c.req.param();
|
|
17
|
+
const { action, slugs } = await c.req.json();
|
|
18
|
+
if (!Array.isArray(slugs) || !slugs.length) return c.json({ error: 'slugs required' }, 400);
|
|
19
|
+
if (!['publish', 'draft', 'delete'].includes(action)) return c.json({ error: 'Invalid action' }, 400);
|
|
20
|
+
const db = openPod(c.get('podPath'));
|
|
21
|
+
if (action === 'delete') {
|
|
22
|
+
slugs.forEach(slug => db.deleteEntry(collectionId, slug));
|
|
23
|
+
} else {
|
|
24
|
+
const status = action === 'publish' ? 'published' : 'draft';
|
|
25
|
+
slugs.forEach(slug => {
|
|
26
|
+
const entry = db.getEntry(collectionId, slug);
|
|
27
|
+
if (entry) db.updateEntry(collectionId, slug, { slug, data: entry.data, status });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
db.close();
|
|
31
|
+
if (action === 'publish') fireWebhook(c.get('podPath'));
|
|
32
|
+
return c.json({ ok: true, count: slugs.length });
|
|
33
|
+
});
|
|
34
|
+
|
|
14
35
|
// PATCH /api/collections/:id/entries/reorder — set sort_order by slug array
|
|
15
36
|
entryRoutes.patch('/:collectionId/entries/reorder', async (c) => {
|
|
16
37
|
const { collectionId } = c.req.param();
|
package/src/server.js
CHANGED
|
@@ -8,6 +8,8 @@ import { dirname, join } from 'node:path';
|
|
|
8
8
|
// Ensure CWD is the package root so serveStatic finds ./public
|
|
9
9
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
10
|
process.chdir(join(__dirname, '..'));
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { openPod } from '@a83/orbiter-core';
|
|
11
13
|
import { authRoutes } from './routes/auth.js';
|
|
12
14
|
import { collectionRoutes } from './routes/collections.js';
|
|
13
15
|
import { entryRoutes } from './routes/entries.js';
|
|
@@ -22,6 +24,10 @@ import { infoRoutes } from './routes/info.js';
|
|
|
22
24
|
import { importRoutes } from './routes/import.js';
|
|
23
25
|
import { requireAuth } from './middleware/auth.js';
|
|
24
26
|
|
|
27
|
+
const { version: adminVersion } = JSON.parse(
|
|
28
|
+
readFileSync(join(__dirname, '../package.json'), 'utf8')
|
|
29
|
+
);
|
|
30
|
+
|
|
25
31
|
const POD_PATH = process.env.ORBITER_POD;
|
|
26
32
|
if (!POD_PATH) {
|
|
27
33
|
console.error('Error: ORBITER_POD environment variable is required.');
|
|
@@ -68,7 +74,11 @@ export function createApp(podPath) {
|
|
|
68
74
|
|
|
69
75
|
app.route('/api', api);
|
|
70
76
|
|
|
71
|
-
app.get('/health', (c) =>
|
|
77
|
+
app.get('/health', (c) => {
|
|
78
|
+
let podOk = false;
|
|
79
|
+
try { const db = openPod(podPath); db.close(); podOk = true; } catch {}
|
|
80
|
+
return c.json({ ok: podOk, version: adminVersion, pod: podPath, uptime: Math.floor(process.uptime()) });
|
|
81
|
+
});
|
|
72
82
|
|
|
73
83
|
// Redirect root to login
|
|
74
84
|
app.get('/', (c) => c.redirect('/login.html'));
|