@a83/orbiter-admin 0.2.0 → 0.3.1
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 +149 -45
- package/package.json +2 -2
- package/public/editor.html +45 -1
- package/public/settings.html +99 -11
- package/src/routes/entries.js +16 -2
- package/src/routes/media.js +97 -25
- package/src/routes/meta.js +3 -0
package/README.md
CHANGED
|
@@ -1,45 +1,70 @@
|
|
|
1
1
|
# @a83/orbiter-admin
|
|
2
2
|
|
|
3
|
-
Standalone admin
|
|
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
|
+
[](https://www.npmjs.com/package/@a83/orbiter-admin)
|
|
6
|
+
[](https://github.com/aeon022/orbiter/blob/main/LICENSE)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
---
|
|
8
9
|
|
|
9
|
-
Orbiter stores everything in a single `.pod` file (SQLite). This package is the admin interface: a Hono HTTP server that
|
|
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
|
-
|
|
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
|
|
37
|
+
ORBITER_POD=/absolute/path/to/content.pod npx @a83/orbiter-admin
|
|
24
38
|
```
|
|
25
39
|
|
|
26
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
##
|
|
75
|
+
## Environment variables
|
|
51
76
|
|
|
52
|
-
| Variable | Required | Default
|
|
53
|
-
|
|
54
|
-
| `ORBITER_POD` | yes
|
|
55
|
-
| `PORT` | no | `4322`
|
|
56
|
-
| `ADMIN_ORIGIN` | no |
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
---
|
|
65
140
|
|
|
66
|
-
|
|
141
|
+
## Build webhook
|
|
67
142
|
|
|
68
|
-
|
|
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
|
-
|
|
147
|
+
Works with Netlify build hooks, Vercel deploy hooks, and GitHub Actions `workflow_dispatch`.
|
|
71
148
|
|
|
72
|
-
|
|
149
|
+
---
|
|
73
150
|
|
|
74
|
-
|
|
151
|
+
## Media backends
|
|
75
152
|
|
|
76
|
-
**
|
|
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
|
|
170
|
+
Three themes × two schemes (dark/light) × two layouts (classic/glass). Switchable live — preference saved to `localStorage`.
|
|
83
171
|
|
|
84
|
-
| Theme
|
|
85
|
-
|
|
86
|
-
| Space
|
|
87
|
-
|
|
|
88
|
-
|
|
|
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** | Japandi — slate, mauve, moss | Japandi light |
|
|
176
|
+
| **Catppuccin** | Mocha | Latte |
|
|
90
177
|
|
|
91
|
-
Glass layout
|
|
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-
|
|
112
|
-
| `@a83/orbiter-
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Standalone admin server for Orbiter CMS",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"directory": "packages/admin"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@a83/orbiter-core": "^0.
|
|
29
|
+
"@a83/orbiter-core": "^0.3.1",
|
|
30
30
|
"@hono/node-server": "^1.14.4",
|
|
31
31
|
"hono": "^4.7.11"
|
|
32
32
|
}
|
package/public/editor.html
CHANGED
|
@@ -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')
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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">
|
package/public/settings.html
CHANGED
|
@@ -363,6 +363,72 @@
|
|
|
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
|
+
<option value="s3" ${get('media.backend')==='s3' ? 'selected':''}>s3 — S3-compatible (AWS, R2, B2, MinIO)</option>
|
|
375
|
+
</select>
|
|
376
|
+
</div>
|
|
377
|
+
|
|
378
|
+
<div id="media-local-fields" style="display:none">
|
|
379
|
+
<div class="setting-row">
|
|
380
|
+
<div><div class="setting-label">Local path</div><div class="setting-desc">Absolute or relative path on the server</div></div>
|
|
381
|
+
<input class="input" name="media.local_path" value="${get('media.local_path')}" placeholder="./media" />
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
|
|
385
|
+
<div id="media-s3-fields" style="display:none">
|
|
386
|
+
<div class="setting-row">
|
|
387
|
+
<div><div class="setting-label">Bucket</div></div>
|
|
388
|
+
<input class="input" name="media.s3_bucket" value="${get('media.s3_bucket')}" placeholder="my-media-bucket" />
|
|
389
|
+
</div>
|
|
390
|
+
<div class="setting-row">
|
|
391
|
+
<div><div class="setting-label">Region</div><div class="setting-desc">AWS region or "auto" for R2</div></div>
|
|
392
|
+
<input class="input" name="media.s3_region" value="${get('media.s3_region')||'auto'}" placeholder="eu-central-1" />
|
|
393
|
+
</div>
|
|
394
|
+
<div class="setting-row">
|
|
395
|
+
<div><div class="setting-label">Endpoint</div><div class="setting-desc">Custom endpoint for R2, B2, MinIO — leave blank for AWS</div></div>
|
|
396
|
+
<input class="input" name="media.s3_endpoint" value="${get('media.s3_endpoint')}" placeholder="https://xxxx.r2.cloudflarestorage.com" />
|
|
397
|
+
</div>
|
|
398
|
+
<div class="setting-row">
|
|
399
|
+
<div><div class="setting-label">Access Key ID</div></div>
|
|
400
|
+
<input class="input" type="password" name="media.s3_access_key" value="${get('media.s3_access_key')}" autocomplete="off" placeholder="Access key ID" />
|
|
401
|
+
</div>
|
|
402
|
+
<div class="setting-row">
|
|
403
|
+
<div><div class="setting-label">Secret Access Key</div></div>
|
|
404
|
+
<input class="input" type="password" name="media.s3_secret_key" value="${get('media.s3_secret_key')}" autocomplete="off" placeholder="Secret access key" />
|
|
405
|
+
</div>
|
|
406
|
+
<div class="setting-row">
|
|
407
|
+
<div><div class="setting-label">Public URL</div><div class="setting-desc">Base URL for serving files, e.g. your R2 public domain or CDN</div></div>
|
|
408
|
+
<input class="input" name="media.s3_public_url" value="${get('media.s3_public_url')}" placeholder="https://media.example.com" />
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
|
|
412
|
+
<div id="media-github-fields" style="display:none">
|
|
413
|
+
<div class="setting-row">
|
|
414
|
+
<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>
|
|
415
|
+
<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)" />
|
|
416
|
+
</div>
|
|
417
|
+
<div class="setting-row">
|
|
418
|
+
<div><div class="setting-label">Repository</div><div class="setting-desc">owner/repo — can differ from your content repo</div></div>
|
|
419
|
+
<input class="input" name="media.github_repo" value="${get('media.github_repo')}" placeholder="owner/my-media" />
|
|
420
|
+
</div>
|
|
421
|
+
<div class="setting-row">
|
|
422
|
+
<div><div class="setting-label">Branch</div></div>
|
|
423
|
+
<input class="input" name="media.github_branch" value="${get('media.github_branch')||'main'}" placeholder="main" />
|
|
424
|
+
</div>
|
|
425
|
+
<div class="setting-row">
|
|
426
|
+
<div><div class="setting-label">Directory</div><div class="setting-desc">Subdirectory inside the repo</div></div>
|
|
427
|
+
<input class="input" name="media.github_dir" value="${get('media.github_dir')||'media'}" placeholder="media" />
|
|
428
|
+
</div>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
|
|
366
432
|
<div class="save-row">
|
|
367
433
|
<button type="submit" class="btn-save">Save settings</button>
|
|
368
434
|
</div>
|
|
@@ -546,22 +612,44 @@
|
|
|
546
612
|
</div>
|
|
547
613
|
`;
|
|
548
614
|
|
|
615
|
+
// Media backend: show/hide conditional fields
|
|
616
|
+
function updateMediaFields() {
|
|
617
|
+
const v = document.getElementById('media-backend-select')?.value ?? 'blob';
|
|
618
|
+
document.getElementById('media-local-fields').style.display = v === 'local' ? '' : 'none';
|
|
619
|
+
document.getElementById('media-s3-fields').style.display = v === 's3' ? '' : 'none';
|
|
620
|
+
document.getElementById('media-github-fields').style.display = v === 'github' ? '' : 'none';
|
|
621
|
+
}
|
|
622
|
+
document.getElementById('media-backend-select')?.addEventListener('change', updateMediaFields);
|
|
623
|
+
updateMediaFields();
|
|
624
|
+
|
|
549
625
|
// Site form
|
|
550
626
|
document.getElementById('site-form').addEventListener('submit', async e => {
|
|
551
627
|
e.preventDefault();
|
|
552
628
|
const fd = new FormData(e.target);
|
|
553
629
|
await saveMeta([
|
|
554
|
-
['site.name',
|
|
555
|
-
['site.url',
|
|
556
|
-
['site.description',
|
|
557
|
-
['site.locale',
|
|
558
|
-
['site.locales',
|
|
559
|
-
['build.webhook_url',fd.get('build.webhook_url')],
|
|
560
|
-
['github.token',
|
|
561
|
-
['github.repo',
|
|
562
|
-
['github.branch',
|
|
563
|
-
['
|
|
564
|
-
['
|
|
630
|
+
['site.name', fd.get('site.name')],
|
|
631
|
+
['site.url', fd.get('site.url')],
|
|
632
|
+
['site.description', fd.get('site.description')],
|
|
633
|
+
['site.locale', fd.get('site.locale')],
|
|
634
|
+
['site.locales', fd.get('site.locales')],
|
|
635
|
+
['build.webhook_url', fd.get('build.webhook_url')],
|
|
636
|
+
['github.token', fd.get('github.token')],
|
|
637
|
+
['github.repo', fd.get('github.repo')],
|
|
638
|
+
['github.branch', fd.get('github.branch')],
|
|
639
|
+
['media.backend', fd.get('media.backend')],
|
|
640
|
+
['media.local_path', fd.get('media.local_path')],
|
|
641
|
+
['media.github_token', fd.get('media.github_token')],
|
|
642
|
+
['media.github_repo', fd.get('media.github_repo')],
|
|
643
|
+
['media.github_branch', fd.get('media.github_branch')],
|
|
644
|
+
['media.github_dir', fd.get('media.github_dir')],
|
|
645
|
+
['media.s3_bucket', fd.get('media.s3_bucket')],
|
|
646
|
+
['media.s3_region', fd.get('media.s3_region')],
|
|
647
|
+
['media.s3_endpoint', fd.get('media.s3_endpoint')],
|
|
648
|
+
['media.s3_access_key', fd.get('media.s3_access_key')],
|
|
649
|
+
['media.s3_secret_key', fd.get('media.s3_secret_key')],
|
|
650
|
+
['media.s3_public_url', fd.get('media.s3_public_url')],
|
|
651
|
+
['api.enabled', fd.get('api.enabled') ? '1' : '0'],
|
|
652
|
+
['api.token', fd.get('api.token')],
|
|
565
653
|
]);
|
|
566
654
|
showBanner('site-banner','banner-ok','Settings saved');
|
|
567
655
|
});
|
package/src/routes/entries.js
CHANGED
|
@@ -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
|
|
49
|
-
const
|
|
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
|
});
|
package/src/routes/media.js
CHANGED
|
@@ -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
|
|
17
|
-
mediaRoutes.get('/:id/raw', (c) => {
|
|
18
|
-
const db
|
|
19
|
-
const item
|
|
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
|
|
33
|
-
const file
|
|
34
|
-
const alt
|
|
35
|
-
const folder
|
|
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
|
|
40
|
-
const id
|
|
41
|
-
const db
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
});
|
package/src/routes/meta.js
CHANGED
|
@@ -7,6 +7,9 @@ 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',
|
|
12
|
+
'media.s3_bucket', 'media.s3_region', 'media.s3_endpoint', 'media.s3_access_key', 'media.s3_secret_key', 'media.s3_public_url',
|
|
10
13
|
'api.enabled', 'api.token',
|
|
11
14
|
'dashboard.notes', 'dashboard.todos',
|
|
12
15
|
'ui.theme',
|