@a83/orbiter-admin 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -1,45 +1,70 @@
1
1
  # @a83/orbiter-admin
2
2
 
3
- Standalone admin UI for Orbiter CMS. A self-contained Node.js server no Astro required.
3
+ Standalone admin server for [Orbiter CMS](https://orbiter.sh) a self-contained Hono HTTP server that reads and writes a `.pod` file.
4
4
 
5
- ---
5
+ [![npm](https://img.shields.io/npm/v/@a83/orbiter-admin?color=8b7cf8)](https://www.npmjs.com/package/@a83/orbiter-admin)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/aeon022/orbiter/blob/main/LICENSE)
6
7
 
7
- ## What it is
8
+ ---
8
9
 
9
- Orbiter stores everything in a single `.pod` file (SQLite). This package is the admin interface: a Hono HTTP server that reads and writes that file. It runs independently from your public site, on its own port or subdomain.
10
+ Orbiter stores everything — content, media, schema, users — in a single `.pod` file (SQLite). This package is the admin interface: a Hono HTTP server that runs on its own port, independent from your Astro site.
10
11
 
11
12
  ```
12
13
  content.pod ← shared file
13
-
14
- orbiter-admin orbiter-integration
15
- (this package) (Astro, reads at build time)
14
+
15
+ @a83/orbiter-admin @a83/orbiter-integration
16
+ writes content reads at Astro build time
17
+ port 4322 your public site
16
18
  ```
17
19
 
20
+ Run the admin on a VPS or a separate service. The Astro site can be deployed anywhere — Netlify, Vercel, static hosting — as long as it can reach the same `.pod` file (same server, shared volume, or periodic rsync).
21
+
22
+ ---
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm install @a83/orbiter-admin
28
+ ```
29
+
30
+ Requires **Node.js 20+**.
31
+
18
32
  ---
19
33
 
20
34
  ## Start
21
35
 
22
36
  ```bash
23
- ORBITER_POD=/absolute/path/to/content.pod npm start
37
+ ORBITER_POD=/absolute/path/to/content.pod npx @a83/orbiter-admin
24
38
  ```
25
39
 
26
- Development (auto-reload on changes):
40
+ Or add to `package.json`:
41
+
42
+ ```json
43
+ {
44
+ "scripts": {
45
+ "admin": "orbiter-admin"
46
+ },
47
+ "dependencies": {
48
+ "@a83/orbiter-admin": "latest"
49
+ }
50
+ }
51
+ ```
27
52
 
28
53
  ```bash
29
- ORBITER_POD=/absolute/path/to/content.pod npm run dev
54
+ ORBITER_POD=/absolute/path/to/content.pod npm run admin
30
55
  ```
31
56
 
32
- Opens at **http://localhost:4322**
57
+ Opens at **[http://localhost:4322](http://localhost:4322)** — redirects to `/login.html`.
33
58
 
34
- > Use an absolute path. The server changes its working directory internally, so relative paths break.
59
+ > **Use an absolute path** for `ORBITER_POD`. The server changes its working directory internally, so relative paths resolve incorrectly.
35
60
 
36
61
  ---
37
62
 
38
- ## Demo
63
+ ## Demo (from the monorepo)
39
64
 
40
65
  ```bash
41
- # From the monorepo root
42
- npm run seed
66
+ git clone https://github.com/aeon022/orbiter.git
67
+ cd orbiter && npm install && npm run seed
43
68
  ORBITER_POD=$(pwd)/apps/demo/demo.pod npm run dev --workspace=packages/admin
44
69
  ```
45
70
 
@@ -47,50 +72,110 @@ Login: `admin` / `admin`
47
72
 
48
73
  ---
49
74
 
50
- ## Env vars
75
+ ## Environment variables
51
76
 
52
- | Variable | Required | Default | Description |
53
- |-----------------|----------|---------------|------------------------------------------|
54
- | `ORBITER_POD` | yes | — | Absolute path to the `.pod` file |
55
- | `PORT` | no | `4322` | HTTP port |
56
- | `ADMIN_ORIGIN` | no | `localhost:4321,localhost:4322` | Allowed CORS origins (comma-separated) |
77
+ | Variable | Required | Default | Description |
78
+ |-----------------|----------|---------|-------------|
79
+ | `ORBITER_POD` | **yes** | — | Absolute path to the `.pod` file |
80
+ | `PORT` | no | `4322` | HTTP port |
81
+ | `ADMIN_ORIGIN` | no | `*` | Allowed CORS origins (comma-separated) |
57
82
 
58
83
  ---
59
84
 
60
85
  ## What's inside
61
86
 
62
- **Dashboard** — entry counts, recently edited content, notes, to-do list, build trigger.
87
+ ### Dashboard
88
+ Entry counts per collection, recently updated entries, persistent scratchpad + to-do list, build webhook status and manual trigger.
89
+
90
+ ### Entry editor
91
+ All schema fields rendered as inputs. Autosave, version history with restore, draft/published toggle.
92
+
93
+ **Rich-media block editor:**
94
+ - **Inline image blocks** — float left, right, center, or full width; text wraps naturally
95
+ - **Video blocks** — paste a YouTube, Vimeo, Wistia, or direct `.mp4`/`.webm` URL; renders as responsive 16:9 embed
96
+ - **`/` block picker** — type `/img` or `/vid` to insert
97
+ - **Relation picker** — pick entries from another collection
98
+
99
+ **Media picker — three tabs:**
100
+ - **Library** — browse all uploaded files
101
+ - **From URL** — paste a Dropbox, Google Drive, OneDrive, or any public URL; Orbiter fetches and stores the file server-side (bypasses CORS)
102
+ - **External link** — paste a URL to store a reference without downloading; great for Cloudinary, hosted assets, or large files you don't want to copy
103
+
104
+ ### Media library
105
+ Upload, browse, and manage files. Images, video, PDF, any file type. Folder categories, type filter, inline preview, copy URL, alt text. Configurable backend (see below).
106
+
107
+ ### Schema editor
108
+ Add, reorder, and remove fields on any collection. Changes take effect immediately — no migration or restart needed.
109
+
110
+ | Field type | Input |
111
+ |------------|-------|
112
+ | `string` | Single-line text |
113
+ | `richtext` | Block editor |
114
+ | `number` | Numeric |
115
+ | `url` / `email` | With validation |
116
+ | `date` / `datetime` | Date picker |
117
+ | `select` | Dropdown |
118
+ | `array` | Tag input |
119
+ | `media` | Media library picker |
120
+ | `relation` | Entry picker (cross-collection) |
121
+
122
+ ### Settings
123
+ Site name, URL, locale, build webhook URL, media backend, GitHub sync, public API token, theme.
124
+
125
+ ### Users
126
+ Create and manage admin/editor accounts (admin role only). Roles:
127
+
128
+ | Feature | editor | admin |
129
+ |---------|--------|-------|
130
+ | Create / edit / delete entries | ✅ | ✅ |
131
+ | Manage media | ✅ | ✅ |
132
+ | Edit schema | ✅ | ✅ |
133
+ | Site settings | ✅ | ✅ |
134
+ | Manage users | ❌ | ✅ |
135
+
136
+ ### Import
137
+ WordPress WXR importer — upload the `.xml` export from WordPress Tools → Export; Orbiter converts posts, pages, categories, tags, and featured images.
63
138
 
64
- **Collections** — browse and manage entries per collection.
139
+ ---
65
140
 
66
- **Editor** all schema fields rendered as inputs, autosave, version history, draft/published toggle. Inline image blocks with float alignment (left/right/center/full). Video embedding — YouTube, Vimeo, Wistia, direct mp4/webm URL. Media picker with cloud URL import (Dropbox, Google Drive, OneDrive). Relation picker, conditional field visibility.
141
+ ## Build webhook
67
142
 
68
- **Media library** upload, browse and manage files. Stored as BLOBs in the pod. Images, video, PDF, any file type.
143
+ Configure a webhook URL in **Settings Build**. Orbiter fires a `POST` to it:
144
+ - **Automatically** — whenever an entry transitions from draft to published
145
+ - **Manually** — via the **Trigger build** button on the dashboard
69
146
 
70
- **Schema** add, reorder, and remove fields on any collection. Live changes, no restart.
147
+ Works with Netlify build hooks, Vercel deploy hooks, and GitHub Actions `workflow_dispatch`.
71
148
 
72
- **Settings** — site name, URL, locale, API token, build webhook, theme.
149
+ ---
73
150
 
74
- **Users** manage admin users (admin role only).
151
+ ## Media backends
75
152
 
76
- **Import**WordPress WXR importer.
153
+ Configure in **Settings → Media storage**. No restart required stored in the pod.
154
+
155
+ | Backend | Where files go | Best for |
156
+ |---------|---------------|----------|
157
+ | `blob` | SQLite BLOB in the `.pod` file (default) | Small–medium sites |
158
+ | `local` | Directory on the server (`media.local_path`) | Self-hosted VPS with persistent disk |
159
+ | `github` | GitHub Contents API → jsDelivr CDN | Open-source projects, free global CDN |
160
+ | **External link** | URL stored, nothing fetched | Dropbox, Drive, Cloudinary, any public URL |
161
+
162
+ For the **GitHub backend**, files are served from `cdn.jsdelivr.net/gh/owner/repo@branch/path` — no egress cost, cached globally. Configure repo, branch, directory, and token in Settings.
163
+
164
+ For **External links**, use the External link tab in the image picker. Orbiter makes a `HEAD` request to detect mime type, then stores only the URL. `/orbiter/media/[id]` redirects — no change needed in templates.
77
165
 
78
166
  ---
79
167
 
80
168
  ## Themes
81
169
 
82
- Three themes, two schemes (dark / light), two layouts (classic / glass).
170
+ Three themes × two schemes (dark/light) × two layouts (classic/glass). Switchable live — preference saved to `localStorage`.
83
171
 
84
- | Theme | Character |
85
- |-------------|----------------------------------------|
86
- | Space | Dark: space station HUD — cyan + blue |
87
- | | Light: solar commandice blue |
88
- | Zen | Japandi slate, mauve, moss |
89
- | Catppuccin | Mocha (dark) / Latte (light) |
172
+ | Theme | Dark | Light |
173
+ |-------|------|-------|
174
+ | **Space** | Space station HUD — cyan + electric blue | Solar Command — ice blue |
175
+ | **Zen** | Japandislate, mauve, moss | Japandi light |
176
+ | **Catppuccin** | Mocha | Latte |
90
177
 
91
- Glass layout is the default — frosted panels, backdrop blur, animated gradient background.
92
-
93
- Preference saved to `localStorage`.
178
+ **Glass layout** (default) — frosted panels, backdrop blur, animated gradient orbs. Classic grid also available.
94
179
 
95
180
  ---
96
181
 
@@ -98,7 +183,26 @@ Preference saved to `localStorage`.
98
183
 
99
184
  ```bash
100
185
  curl http://localhost:4322/health
101
- # {"ok":true,"pod":"/path/to/content.pod"}
186
+ # {"ok":true,"pod":"/absolute/path/to/content.pod"}
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Docker
192
+
193
+ ```dockerfile
194
+ FROM node:20-alpine
195
+ WORKDIR /app
196
+ RUN npm install @a83/orbiter-admin
197
+ EXPOSE 4322
198
+ CMD ["node", "node_modules/@a83/orbiter-admin/src/server.js"]
199
+ ```
200
+
201
+ ```bash
202
+ docker run -p 4322:4322 \
203
+ -e ORBITER_POD=/data/content.pod \
204
+ -v $(pwd)/content.pod:/data/content.pod \
205
+ my-orbiter-admin
102
206
  ```
103
207
 
104
208
  ---
@@ -107,9 +211,9 @@ curl http://localhost:4322/health
107
211
 
108
212
  | Package | Description |
109
213
  |---------|-------------|
110
- | `@a83/orbiter-core` | SQLite engine, pod management, auth |
111
- | `@a83/orbiter-integration` | Astro integration, `orbiter:collections` virtual module |
112
- | `@a83/orbiter-admin` | This package standalone admin server |
113
- | `@a83/orbiter-cli` | `orbiter init`, `add-user`, `export`, `pack`, `unpack` |
214
+ | [`@a83/orbiter-core`](https://www.npmjs.com/package/@a83/orbiter-core) | SQLite engine, pod management, auth, media backends |
215
+ | [`@a83/orbiter-admin`](https://www.npmjs.com/package/@a83/orbiter-admin) | **This package** standalone admin server |
216
+ | [`@a83/orbiter-integration`](https://www.npmjs.com/package/@a83/orbiter-integration) | Astro integration, `orbiter:collections` virtual module |
217
+ | [`@a83/orbiter-cli`](https://www.npmjs.com/package/@a83/orbiter-cli) | `orbiter init`, `add-user`, `export`, `pack`, `unpack` |
114
218
 
115
- **orbiter.sh** · MIT · [github.com/aeon022/orbiter](https://github.com/aeon022/orbiter)
219
+ **[orbiter.sh](https://orbiter.sh)** · MIT · [github.com/aeon022/orbiter](https://github.com/aeon022/orbiter)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a83/orbiter-admin",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Standalone admin server for Orbiter CMS",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -1378,7 +1378,8 @@
1378
1378
  document.querySelectorAll('.img-pick-pane').forEach(p=>p.classList.remove('active'));
1379
1379
  btn.classList.add('active');
1380
1380
  document.getElementById('img-pick-pane-'+tab).classList.add('active');
1381
- if (tab==='url') setTimeout(()=>document.getElementById('img-url-inp').focus(), 40);
1381
+ if (tab==='url') setTimeout(()=>document.getElementById('img-url-inp').focus(), 40);
1382
+ if (tab==='link') setTimeout(()=>document.getElementById('img-link-inp').focus(), 40);
1382
1383
  };
1383
1384
 
1384
1385
  window.importFromUrl = async function() {
@@ -1411,6 +1412,36 @@
1411
1412
  if (e.key==='Enter') { e.preventDefault(); importFromUrl(); }
1412
1413
  });
1413
1414
 
1415
+ window.addExternalLink = async function() {
1416
+ const inp = document.getElementById('img-link-inp');
1417
+ const url = inp.value.trim();
1418
+ if (!url) return;
1419
+ const btn = document.getElementById('img-link-btn');
1420
+ const status = document.getElementById('img-link-status');
1421
+ btn.disabled=true; btn.textContent='Wird verknüpft…'; status.textContent='';
1422
+ try {
1423
+ const res = await fetch('/api/media/add-link', {
1424
+ method:'POST', credentials:'include',
1425
+ headers:{'Content-Type':'application/json'},
1426
+ body: JSON.stringify({ url, alt:'', folder:'' })
1427
+ });
1428
+ if (!res.ok) { const e=await res.json().catch(()=>({})); status.textContent='Fehler: '+(e.error||res.status); return; }
1429
+ const media = await res.json();
1430
+ mediaData.unshift(media);
1431
+ closeImgPicker();
1432
+ doInsertImg(media.id, media.alt??'');
1433
+ inp.value='';
1434
+ } catch(e) {
1435
+ status.textContent='Netzwerkfehler: '+e.message;
1436
+ } finally {
1437
+ btn.disabled=false; btn.textContent='Verknüpfen';
1438
+ }
1439
+ };
1440
+
1441
+ document.getElementById('img-link-inp').addEventListener('keydown', e=>{
1442
+ if (e.key==='Enter') { e.preventDefault(); addExternalLink(); }
1443
+ });
1444
+
1414
1445
  function escHtml(str) {
1415
1446
  return String(str??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1416
1447
  }
@@ -1540,6 +1571,7 @@
1540
1571
  <div class="img-pick-tabs">
1541
1572
  <button class="img-pick-tab active" onclick="imgPickTab('library',this)">Library</button>
1542
1573
  <button class="img-pick-tab" onclick="imgPickTab('url',this)">From URL</button>
1574
+ <button class="img-pick-tab" onclick="imgPickTab('link',this)">External link</button>
1543
1575
  </div>
1544
1576
  <div id="img-pick-pane-library" class="img-pick-pane active">
1545
1577
  <div class="img-pick-body">
@@ -1559,6 +1591,18 @@
1559
1591
  <button id="img-url-btn" class="img-url-btn" onclick="importFromUrl()">Importieren</button>
1560
1592
  </div>
1561
1593
  </div>
1594
+ <div id="img-pick-pane-link" class="img-pick-pane">
1595
+ <div class="img-url-pane">
1596
+ <input id="img-link-inp" class="img-url-inp" type="url" placeholder="https://…" />
1597
+ <div class="img-url-hint">
1598
+ Datei bleibt extern — Orbiter speichert nur den Link.<br>
1599
+ <strong>Dropbox</strong>, <strong>Google Drive</strong>, <strong>OneDrive</strong>,
1600
+ <strong>Cloudinary</strong>, jede öffentlich zugängliche URL.
1601
+ </div>
1602
+ <div id="img-link-status" class="img-url-status"></div>
1603
+ <button id="img-link-btn" class="img-url-btn" onclick="addExternalLink()">Verknüpfen</button>
1604
+ </div>
1605
+ </div>
1562
1606
  </div>
1563
1607
  </div>
1564
1608
  <input type="file" id="img-file-inp" accept="image/*" style="display:none">
@@ -363,6 +363,44 @@
363
363
  </div>
364
364
  </div>
365
365
 
366
+ <div class="settings-group">
367
+ <div class="group-header">Media storage</div>
368
+ <div class="setting-row">
369
+ <div><div class="setting-label">Backend</div><div class="setting-desc">Where uploaded files are stored</div></div>
370
+ <select class="input" name="media.backend" id="media-backend-select">
371
+ <option value="blob" ${(get('media.backend')||'blob')==='blob' ? 'selected':''}>blob — SQLite BLOB (default)</option>
372
+ <option value="local" ${get('media.backend')==='local' ? 'selected':''}>local — filesystem</option>
373
+ <option value="github" ${get('media.backend')==='github' ? 'selected':''}>github — GitHub + jsDelivr CDN</option>
374
+ </select>
375
+ </div>
376
+
377
+ <div id="media-local-fields" style="display:none">
378
+ <div class="setting-row">
379
+ <div><div class="setting-label">Local path</div><div class="setting-desc">Absolute or relative path on the server</div></div>
380
+ <input class="input" name="media.local_path" value="${get('media.local_path')}" placeholder="./media" />
381
+ </div>
382
+ </div>
383
+
384
+ <div id="media-github-fields" style="display:none">
385
+ <div class="setting-row">
386
+ <div><div class="setting-label">GitHub token</div><div class="setting-desc">Uses the token from the GitHub section above if left blank</div></div>
387
+ <input class="input" type="password" name="media.github_token" value="${get('media.github_token')}" autocomplete="off" placeholder="ghp_… (optional, falls back to GitHub token above)" />
388
+ </div>
389
+ <div class="setting-row">
390
+ <div><div class="setting-label">Repository</div><div class="setting-desc">owner/repo — can differ from your content repo</div></div>
391
+ <input class="input" name="media.github_repo" value="${get('media.github_repo')}" placeholder="owner/my-media" />
392
+ </div>
393
+ <div class="setting-row">
394
+ <div><div class="setting-label">Branch</div></div>
395
+ <input class="input" name="media.github_branch" value="${get('media.github_branch')||'main'}" placeholder="main" />
396
+ </div>
397
+ <div class="setting-row">
398
+ <div><div class="setting-label">Directory</div><div class="setting-desc">Subdirectory inside the repo</div></div>
399
+ <input class="input" name="media.github_dir" value="${get('media.github_dir')||'media'}" placeholder="media" />
400
+ </div>
401
+ </div>
402
+ </div>
403
+
366
404
  <div class="save-row">
367
405
  <button type="submit" class="btn-save">Save settings</button>
368
406
  </div>
@@ -546,22 +584,37 @@
546
584
  </div>
547
585
  `;
548
586
 
587
+ // Media backend: show/hide conditional fields
588
+ function updateMediaFields() {
589
+ const v = document.getElementById('media-backend-select')?.value ?? 'blob';
590
+ document.getElementById('media-local-fields').style.display = v === 'local' ? '' : 'none';
591
+ document.getElementById('media-github-fields').style.display = v === 'github' ? '' : 'none';
592
+ }
593
+ document.getElementById('media-backend-select')?.addEventListener('change', updateMediaFields);
594
+ updateMediaFields();
595
+
549
596
  // Site form
550
597
  document.getElementById('site-form').addEventListener('submit', async e => {
551
598
  e.preventDefault();
552
599
  const fd = new FormData(e.target);
553
600
  await saveMeta([
554
- ['site.name', fd.get('site.name')],
555
- ['site.url', fd.get('site.url')],
556
- ['site.description', fd.get('site.description')],
557
- ['site.locale', fd.get('site.locale')],
558
- ['site.locales', fd.get('site.locales')],
559
- ['build.webhook_url',fd.get('build.webhook_url')],
560
- ['github.token', fd.get('github.token')],
561
- ['github.repo', fd.get('github.repo')],
562
- ['github.branch', fd.get('github.branch')],
563
- ['api.enabled', fd.get('api.enabled') ? '1' : '0'],
564
- ['api.token', fd.get('api.token')],
601
+ ['site.name', fd.get('site.name')],
602
+ ['site.url', fd.get('site.url')],
603
+ ['site.description', fd.get('site.description')],
604
+ ['site.locale', fd.get('site.locale')],
605
+ ['site.locales', fd.get('site.locales')],
606
+ ['build.webhook_url', fd.get('build.webhook_url')],
607
+ ['github.token', fd.get('github.token')],
608
+ ['github.repo', fd.get('github.repo')],
609
+ ['github.branch', fd.get('github.branch')],
610
+ ['media.backend', fd.get('media.backend')],
611
+ ['media.local_path', fd.get('media.local_path')],
612
+ ['media.github_token', fd.get('media.github_token')],
613
+ ['media.github_repo', fd.get('media.github_repo')],
614
+ ['media.github_branch', fd.get('media.github_branch')],
615
+ ['media.github_dir', fd.get('media.github_dir')],
616
+ ['api.enabled', fd.get('api.enabled') ? '1' : '0'],
617
+ ['api.token', fd.get('api.token')],
565
618
  ]);
566
619
  showBanner('site-banner','banner-ok','Settings saved');
567
620
  });
@@ -3,6 +3,14 @@ import { openPod } from '@a83/orbiter-core';
3
3
 
4
4
  export const entryRoutes = new Hono();
5
5
 
6
+ function fireWebhook(podPath) {
7
+ const db = openPod(podPath);
8
+ const url = db.getMeta('build.webhook_url') ?? '';
9
+ db.setMeta('build.last_triggered', new Date().toISOString());
10
+ db.close();
11
+ if (url) fetch(url, { method: 'POST' }).catch(() => {});
12
+ }
13
+
6
14
  // GET /api/collections/:id/entries?status=draft|published
7
15
  entryRoutes.get('/:collectionId/entries', (c) => {
8
16
  const { collectionId } = c.req.param();
@@ -45,11 +53,16 @@ entryRoutes.put('/:collectionId/entries/:slug', async (c) => {
45
53
  const { collectionId, slug } = c.req.param();
46
54
  const body = await c.req.json();
47
55
 
48
- const db = openPod(c.get('podPath'));
49
- const ok = db.updateEntry(collectionId, slug, body);
56
+ const db = openPod(c.get('podPath'));
57
+ const before = db.getEntry(collectionId, slug);
58
+ const ok = db.updateEntry(collectionId, slug, body);
50
59
  if (!ok) { db.close(); return c.json({ error: 'Not found' }, 404); }
51
60
  const updated = db.getEntry(collectionId, body.slug ?? slug);
52
61
  db.close();
62
+
63
+ if (body.status === 'published' && before?.status !== 'published') {
64
+ fireWebhook(c.get('podPath'));
65
+ }
53
66
  return c.json(updated);
54
67
  });
55
68
 
@@ -99,5 +112,6 @@ entryRoutes.patch('/:collectionId/entries/:slug/status', async (c) => {
99
112
  if (!entry) { db.close(); return c.json({ error: 'Not found' }, 404); }
100
113
  db.updateEntry(collectionId, slug, { slug, data: entry.data, status });
101
114
  db.close();
115
+ if (status === 'published') fireWebhook(c.get('podPath'));
102
116
  return c.json({ ok: true });
103
117
  });
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { openPod } from '@a83/orbiter-core';
2
+ import { openPod, getMediaBackend } from '@a83/orbiter-core';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
5
5
  export const mediaRoutes = new Hono();
@@ -13,12 +13,34 @@ mediaRoutes.get('/', (c) => {
13
13
  return c.json(items);
14
14
  });
15
15
 
16
- // GET /api/media/:id/raw — serve the binary
17
- mediaRoutes.get('/:id/raw', (c) => {
18
- const db = openPod(c.get('podPath'));
19
- const item = db.getMediaItem(c.req.param('id'));
16
+ // GET /api/media/:id/raw — serve binary or redirect to CDN
17
+ mediaRoutes.get('/:id/raw', async (c) => {
18
+ const db = openPod(c.get('podPath'));
19
+ const item = db.getMediaItem(c.req.param('id'));
20
+ if (!item) { db.close(); return c.json({ error: 'Not found' }, 404); }
21
+
22
+ // External backends: redirect to CDN/storage URL
23
+ if (item.url) {
24
+ db.close();
25
+ return c.redirect(item.url, 302);
26
+ }
27
+
28
+ // Local backend: read from disk
29
+ if (item.path) {
30
+ const backend = getMediaBackend(db);
31
+ const result = await backend.get(item.id).catch(() => null);
32
+ db.close();
33
+ if (!result?.data) return c.json({ error: 'File not found on disk' }, 404);
34
+ return new Response(result.data, {
35
+ headers: {
36
+ 'Content-Type': item.mime_type,
37
+ 'Cache-Control': 'public, max-age=31536000, immutable',
38
+ },
39
+ });
40
+ }
41
+
42
+ // Default: serve BLOB from SQLite
20
43
  db.close();
21
- if (!item) return c.json({ error: 'Not found' }, 404);
22
44
  return new Response(item.data, {
23
45
  headers: {
24
46
  'Content-Type': item.mime_type,
@@ -29,25 +51,31 @@ mediaRoutes.get('/:id/raw', (c) => {
29
51
 
30
52
  // POST /api/media — multipart upload
31
53
  mediaRoutes.post('/', async (c) => {
32
- const form = await c.req.formData();
33
- const file = form.get('file');
34
- const alt = form.get('alt')?.toString() ?? null;
35
- const folder = form.get('folder')?.toString() ?? '';
54
+ const form = await c.req.formData();
55
+ const file = form.get('file');
56
+ const alt = form.get('alt')?.toString() ?? null;
57
+ const folder = form.get('folder')?.toString() ?? '';
36
58
 
37
59
  if (!file || typeof file === 'string') return c.json({ error: 'No file provided' }, 400);
38
60
 
39
- const buffer = Buffer.from(await file.arrayBuffer());
40
- const id = randomUUID();
41
- const db = openPod(c.get('podPath'));
42
- db.insertMedia(id, file.name, file.type, buffer.byteLength, buffer, alt, folder);
43
- const item = db.getMediaItem(id);
44
- db.close();
61
+ const buffer = Buffer.from(await file.arrayBuffer());
62
+ const id = randomUUID();
63
+ const db = openPod(c.get('podPath'));
45
64
 
46
- const { data: _, ...meta } = item;
47
- return c.json(meta, 201);
65
+ try {
66
+ const backend = getMediaBackend(db);
67
+ await backend.upload(id, file.name, file.type, buffer.byteLength, buffer, alt, folder);
68
+ const item = db.getMediaItem(id);
69
+ db.close();
70
+ const { data: _, ...meta } = item;
71
+ return c.json(meta, 201);
72
+ } catch (err) {
73
+ db.close();
74
+ return c.json({ error: err.message }, 500);
75
+ }
48
76
  });
49
77
 
50
- // POST /api/media/import-url — server-side fetch from a public URL (Dropbox, GDrive, etc.)
78
+ // POST /api/media/import-url — server-side fetch from a public URL
51
79
  mediaRoutes.post('/import-url', async (c) => {
52
80
  let body;
53
81
  try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); }
@@ -76,20 +104,64 @@ mediaRoutes.post('/import-url', async (c) => {
76
104
  const filename = url.split('/').pop()?.split('?')[0] || 'imported';
77
105
  const id = randomUUID();
78
106
  const db = openPod(c.get('podPath'));
79
- db.insertMedia(id, filename, mime, buffer.byteLength, buffer, alt ?? null, folder ?? '');
107
+
108
+ try {
109
+ const backend = getMediaBackend(db);
110
+ await backend.upload(id, filename, mime, buffer.byteLength, buffer, alt ?? null, folder ?? '');
111
+ const item = db.getMediaItem(id);
112
+ db.close();
113
+ const { data: _, ...meta } = item;
114
+ return c.json(meta, 201);
115
+ } catch (err) {
116
+ db.close();
117
+ return c.json({ error: err.message }, 500);
118
+ }
119
+ });
120
+
121
+ // POST /api/media/add-link — store an external URL reference, no data fetched
122
+ mediaRoutes.post('/add-link', async (c) => {
123
+ let body;
124
+ try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); }
125
+ const { url, alt, folder, filename: providedName } = body;
126
+ if (!url) return c.json({ error: 'No URL provided' }, 400);
127
+
128
+ // Detect mime via HEAD request, fall back to extension sniffing
129
+ let mime = 'application/octet-stream';
130
+ try {
131
+ const head = await fetch(url, { method: 'HEAD', redirect: 'follow', headers: { 'User-Agent': 'Orbiter-Admin/1.0' } });
132
+ const ct = head.headers.get('content-type');
133
+ if (ct) mime = ct.split(';')[0].trim();
134
+ } catch {
135
+ const ext = url.split('?')[0].split('.').pop()?.toLowerCase();
136
+ const extMap = { jpg:'image/jpeg', jpeg:'image/jpeg', png:'image/png', gif:'image/gif',
137
+ webp:'image/webp', avif:'image/avif', svg:'image/svg+xml',
138
+ mp4:'video/mp4', webm:'video/webm', pdf:'application/pdf' };
139
+ if (extMap[ext]) mime = extMap[ext];
140
+ }
141
+
142
+ const filename = providedName || url.split('/').pop()?.split('?')[0] || 'link';
143
+ const id = randomUUID();
144
+ const db = openPod(c.get('podPath'));
145
+ db.insertMedia(id, filename, mime, 0, null, alt ?? null, folder ?? '', url, null);
80
146
  const item = db.getMediaItem(id);
81
147
  db.close();
82
-
83
148
  const { data: _, ...meta } = item;
84
149
  return c.json(meta, 201);
85
150
  });
86
151
 
87
152
  // DELETE /api/media/:id
88
- mediaRoutes.delete('/:id', (c) => {
153
+ mediaRoutes.delete('/:id', async (c) => {
89
154
  const db = openPod(c.get('podPath'));
90
155
  const item = db.getMediaItem(c.req.param('id'));
91
156
  if (!item) { db.close(); return c.json({ error: 'Not found' }, 404); }
92
- db.deleteMedia(item.id);
93
- db.close();
94
- return c.json({ ok: true });
157
+
158
+ try {
159
+ const backend = getMediaBackend(db);
160
+ await backend.delete(item.id);
161
+ db.close();
162
+ return c.json({ ok: true });
163
+ } catch (err) {
164
+ db.close();
165
+ return c.json({ error: err.message }, 500);
166
+ }
95
167
  });
@@ -7,6 +7,8 @@ const ALLOWED_KEYS = [
7
7
  'site.name', 'site.url', 'site.description', 'site.locale', 'site.locales',
8
8
  'build.webhook_url',
9
9
  'github.token', 'github.repo', 'github.branch',
10
+ 'media.backend', 'media.local_path',
11
+ 'media.github_token', 'media.github_repo', 'media.github_branch', 'media.github_dir',
10
12
  'api.enabled', 'api.token',
11
13
  'dashboard.notes', 'dashboard.todos',
12
14
  'ui.theme',