@a83/orbiter-admin 0.3.10 → 0.3.11
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 +15 -1
- package/public/entries.html +28 -1
- package/src/routes/entries.js +86 -0
package/package.json
CHANGED
package/public/editor.html
CHANGED
|
@@ -633,10 +633,11 @@
|
|
|
633
633
|
}
|
|
634
634
|
|
|
635
635
|
const versHtml = versionsData.slice(0,8).map((v,i)=>`
|
|
636
|
-
<div class="version-row">
|
|
636
|
+
<div class="version-row" data-vid="${v.id}">
|
|
637
637
|
<div class="v-dot${i===0?' cur':''}"></div>
|
|
638
638
|
<div class="v-hash">${v.id.slice(0,7)}</div>
|
|
639
639
|
<div class="v-time">${new Date(v.created_at).toLocaleDateString()}</div>
|
|
640
|
+
${i===0 ? '' : `<button class="btn-row v-restore-btn" data-vid="${v.id}" style="margin-left:auto;font-size:9px;padding:2px 6px">Restore</button>`}
|
|
640
641
|
</div>
|
|
641
642
|
`).join('');
|
|
642
643
|
|
|
@@ -756,6 +757,19 @@
|
|
|
756
757
|
saveEntry('scheduled');
|
|
757
758
|
});
|
|
758
759
|
|
|
760
|
+
// Version restore buttons
|
|
761
|
+
panel.querySelectorAll('.v-restore-btn').forEach(btn => {
|
|
762
|
+
btn.addEventListener('click', async () => {
|
|
763
|
+
if (!confirm('Restore this version? Current content will be overwritten.')) return;
|
|
764
|
+
const vid = btn.dataset.vid;
|
|
765
|
+
const targetSlug = currentSlug || SLUG;
|
|
766
|
+
const res = await fetch(`/api/collections/${COLLECTION}/entries/${targetSlug}/versions/${vid}/restore`, {
|
|
767
|
+
method: 'POST', credentials: 'include',
|
|
768
|
+
});
|
|
769
|
+
if (res.ok) { location.reload(); } else { alert('Restore failed.'); }
|
|
770
|
+
});
|
|
771
|
+
});
|
|
772
|
+
|
|
759
773
|
// Slug input → slug preview
|
|
760
774
|
document.getElementById('slug-input').addEventListener('input', e=>{
|
|
761
775
|
e.target.dataset.manual='1';
|
package/public/entries.html
CHANGED
|
@@ -90,7 +90,11 @@
|
|
|
90
90
|
<h1 class="page-title" id="page-title">Entries</h1>
|
|
91
91
|
<p class="page-sub" id="page-sub"></p>
|
|
92
92
|
</div>
|
|
93
|
-
<
|
|
93
|
+
<div style="display:flex;gap:6px;align-items:center;flex-shrink:0;margin-top:4px">
|
|
94
|
+
<a id="export-btn" href="#" class="btn btn-ghost btn-sm" title="Export as CSV">↓ CSV</a>
|
|
95
|
+
<label class="btn btn-ghost btn-sm" style="cursor:pointer" title="Import from CSV">↑ CSV<input type="file" id="import-file" accept=".csv" style="display:none" /></label>
|
|
96
|
+
<button class="btn btn-primary" id="new-btn">+ New entry</button>
|
|
97
|
+
</div>
|
|
94
98
|
</div>
|
|
95
99
|
|
|
96
100
|
<!-- Filter + table glass card -->
|
|
@@ -366,6 +370,29 @@
|
|
|
366
370
|
await bulkAction('permanent');
|
|
367
371
|
});
|
|
368
372
|
|
|
373
|
+
// CSV Export
|
|
374
|
+
document.getElementById('export-btn').href = `/api/collections/${colId}/entries/export.csv`;
|
|
375
|
+
|
|
376
|
+
// CSV Import
|
|
377
|
+
document.getElementById('import-file').addEventListener('change', async e => {
|
|
378
|
+
const file = e.target.files[0];
|
|
379
|
+
if (!file) return;
|
|
380
|
+
const text = await file.text();
|
|
381
|
+
const res = await fetch(`/api/collections/${colId}/entries/import.csv`, {
|
|
382
|
+
method: 'POST', credentials: 'include',
|
|
383
|
+
headers: { 'Content-Type': 'text/csv' },
|
|
384
|
+
body: text,
|
|
385
|
+
});
|
|
386
|
+
const json = await res.json();
|
|
387
|
+
if (res.ok) {
|
|
388
|
+
alert(`Import complete: ${json.created} created, ${json.updated} updated${json.skipped ? ', ' + json.skipped + ' skipped' : ''}.`);
|
|
389
|
+
renderEntries();
|
|
390
|
+
} else {
|
|
391
|
+
alert('Import failed: ' + (json.error ?? 'Unknown error'));
|
|
392
|
+
}
|
|
393
|
+
e.target.value = '';
|
|
394
|
+
});
|
|
395
|
+
|
|
369
396
|
// New entry modal
|
|
370
397
|
const overlay = document.getElementById('modal-overlay');
|
|
371
398
|
document.getElementById('new-btn').addEventListener('click', () => {
|
package/src/routes/entries.js
CHANGED
|
@@ -170,6 +170,92 @@ entryRoutes.get('/:collectionId/entries/:slug/versions', (c) => {
|
|
|
170
170
|
return c.json(versions);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
+
// POST /api/collections/:id/entries/:slug/versions/:versionId/restore
|
|
174
|
+
entryRoutes.post('/:collectionId/entries/:slug/versions/:versionId/restore', (c) => {
|
|
175
|
+
const { collectionId, slug, versionId } = c.req.param();
|
|
176
|
+
const db = openPod(c.get('podPath'));
|
|
177
|
+
const entry = db.getEntry(collectionId, slug);
|
|
178
|
+
if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
|
|
179
|
+
const ok = db.restoreVersion(entry.id, versionId);
|
|
180
|
+
if (ok) db.logAudit(entry.id, c.get('user')?.username ?? 'unknown', 'restore_version');
|
|
181
|
+
db.close();
|
|
182
|
+
if (!ok) return c.json({ error: 'Version not found' }, 404);
|
|
183
|
+
return c.json({ ok: true });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// GET /api/collections/:id/entries/export.csv
|
|
187
|
+
entryRoutes.get('/:collectionId/entries/export.csv', (c) => {
|
|
188
|
+
const { collectionId } = c.req.param();
|
|
189
|
+
const db = openPod(c.get('podPath'));
|
|
190
|
+
const col = db.getCollection(collectionId);
|
|
191
|
+
if (!col) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
192
|
+
const entries = db.getEntries(collectionId);
|
|
193
|
+
const schema = col.schema ? JSON.parse(col.schema) : {};
|
|
194
|
+
const fields = Object.keys(schema);
|
|
195
|
+
const headers = ['slug', 'status', ...fields];
|
|
196
|
+
const csvEsc = v => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
|
197
|
+
const rows = [headers.map(csvEsc).join(',')];
|
|
198
|
+
for (const e of entries) {
|
|
199
|
+
rows.push(headers.map(h => {
|
|
200
|
+
if (h === 'slug') return csvEsc(e.slug);
|
|
201
|
+
if (h === 'status') return csvEsc(e.status);
|
|
202
|
+
const v = e.data[h];
|
|
203
|
+
return csvEsc(Array.isArray(v) ? v.join(';') : v);
|
|
204
|
+
}).join(','));
|
|
205
|
+
}
|
|
206
|
+
db.close();
|
|
207
|
+
return new Response(rows.join('\n'), {
|
|
208
|
+
headers: {
|
|
209
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
210
|
+
'Content-Disposition': `attachment; filename="${collectionId}.csv"`,
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// POST /api/collections/:id/entries/import.csv
|
|
216
|
+
entryRoutes.post('/:collectionId/entries/import.csv', async (c) => {
|
|
217
|
+
const { collectionId } = c.req.param();
|
|
218
|
+
const db = openPod(c.get('podPath'));
|
|
219
|
+
if (!db.getCollection(collectionId)) { db.close(); return c.json({ error: 'Collection not found' }, 404); }
|
|
220
|
+
const text = await c.req.text();
|
|
221
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
222
|
+
if (lines.length < 2) { db.close(); return c.json({ error: 'Empty CSV' }, 400); }
|
|
223
|
+
const parseCsv = line => {
|
|
224
|
+
const cols = []; let cur = ''; let inQ = false;
|
|
225
|
+
for (let i = 0; i < line.length; i++) {
|
|
226
|
+
const ch = line[i];
|
|
227
|
+
if (ch === '"' && !inQ) { inQ = true; continue; }
|
|
228
|
+
if (ch === '"' && inQ && line[i+1] === '"') { cur += '"'; i++; continue; }
|
|
229
|
+
if (ch === '"' && inQ) { inQ = false; continue; }
|
|
230
|
+
if (ch === ',' && !inQ) { cols.push(cur); cur = ''; continue; }
|
|
231
|
+
cur += ch;
|
|
232
|
+
}
|
|
233
|
+
cols.push(cur);
|
|
234
|
+
return cols;
|
|
235
|
+
};
|
|
236
|
+
const headers = parseCsv(lines[0]);
|
|
237
|
+
let created = 0, updated = 0, skipped = 0;
|
|
238
|
+
for (const line of lines.slice(1)) {
|
|
239
|
+
const vals = parseCsv(line);
|
|
240
|
+
const row = Object.fromEntries(headers.map((h, i) => [h, vals[i] ?? '']));
|
|
241
|
+
if (!row.slug) { skipped++; continue; }
|
|
242
|
+
const data = {};
|
|
243
|
+
for (const h of headers.filter(h => h !== 'slug' && h !== 'status')) {
|
|
244
|
+
data[h] = row[h];
|
|
245
|
+
}
|
|
246
|
+
const status = ['draft','published'].includes(row.status) ? row.status : 'draft';
|
|
247
|
+
if (db.getEntry(collectionId, row.slug)) {
|
|
248
|
+
db.updateEntry(collectionId, row.slug, { slug: row.slug, data, status });
|
|
249
|
+
updated++;
|
|
250
|
+
} else {
|
|
251
|
+
db.createEntry(collectionId, row.slug, data, status);
|
|
252
|
+
created++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
db.close();
|
|
256
|
+
return c.json({ ok: true, created, updated, skipped });
|
|
257
|
+
});
|
|
258
|
+
|
|
173
259
|
// POST /api/collections/:id/entries/:slug/duplicate
|
|
174
260
|
entryRoutes.post('/:collectionId/entries/:slug/duplicate', (c) => {
|
|
175
261
|
const { collectionId, slug } = c.req.param();
|