@acms-subir/core 0.0.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/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- package/src/worker.ts +28 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Legacy path for the built-in collection — content lists live at
|
|
3
|
+
// /admin/c/[collection] now. 308 preserves the method so the old create/delete
|
|
4
|
+
// form POSTs land on the new handler too.
|
|
5
|
+
export const prerender = false;
|
|
6
|
+
return Astro.redirect(`/admin/c/posts${Astro.url.search}`, 308);
|
|
7
|
+
---
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { verifySessionToken, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
|
|
5
|
+
import { invalidateRedirectCache } from '../../middleware';
|
|
6
|
+
import { CONTENT } from '../../lib/content';
|
|
7
|
+
|
|
8
|
+
const repo = await getRepo(Astro.locals);
|
|
9
|
+
|
|
10
|
+
const sessionUserId = verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value);
|
|
11
|
+
const sessionUser = sessionUserId && sessionUserId !== OWNER_ENV ? await repo.getUser(sessionUserId) : null;
|
|
12
|
+
const isOwner = sessionUserId === OWNER_ENV || sessionUser?.role === 'owner';
|
|
13
|
+
|
|
14
|
+
const genKey = () =>
|
|
15
|
+
'acms_sk_' + [...crypto.getRandomValues(new Uint8Array(24))].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
16
|
+
|
|
17
|
+
// Accepts an exact URL (path stripped), 'host:*' for any-port, or '*'.
|
|
18
|
+
// Schemeless input gets https:// assumed.
|
|
19
|
+
function normalizeOrigin(value: string): string | null {
|
|
20
|
+
const v = value.trim();
|
|
21
|
+
if (!v) return null;
|
|
22
|
+
if (v === '*') return '*';
|
|
23
|
+
const withScheme = (s: string) => (s.includes('://') ? s : `https://${s}`);
|
|
24
|
+
try {
|
|
25
|
+
if (v.endsWith(':*')) return new URL(withScheme(v.slice(0, -2))).origin + ':*';
|
|
26
|
+
return new URL(withScheme(v)).origin;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let flash = '';
|
|
33
|
+
let flashError = '';
|
|
34
|
+
if (Astro.request.method === 'POST') {
|
|
35
|
+
const form = await Astro.request.formData();
|
|
36
|
+
const action = String(form.get('action') || 'site');
|
|
37
|
+
if (action === 'site') {
|
|
38
|
+
// site URL: normalize to a bare origin; empty is fine (falls back to the
|
|
39
|
+
// request origin until configured)
|
|
40
|
+
const rawUrl = String(form.get('siteUrl') ?? '').trim();
|
|
41
|
+
let siteUrl = '';
|
|
42
|
+
if (rawUrl) {
|
|
43
|
+
try {
|
|
44
|
+
siteUrl = new URL(rawUrl.includes('://') ? rawUrl : `https://${rawUrl}`).origin;
|
|
45
|
+
} catch {
|
|
46
|
+
flashError = 'Site URL doesn’t look like a URL — try e.g. https://example.com';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (!flashError) {
|
|
50
|
+
await repo.updateSettings({
|
|
51
|
+
siteTitle: String(form.get('siteTitle') || 'My Site'),
|
|
52
|
+
tagline: String(form.get('tagline') ?? ''),
|
|
53
|
+
footerText: String(form.get('footerText') ?? ''),
|
|
54
|
+
siteUrl,
|
|
55
|
+
});
|
|
56
|
+
flash = 'Settings saved.';
|
|
57
|
+
}
|
|
58
|
+
} else if (action === 'localization') {
|
|
59
|
+
const tz = String(form.get('timezone') ?? '').trim();
|
|
60
|
+
let tzOk = true;
|
|
61
|
+
if (tz) {
|
|
62
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); } catch { tzOk = false; }
|
|
63
|
+
}
|
|
64
|
+
if (!tzOk) {
|
|
65
|
+
flashError = `“${tz}” isn’t a valid timezone — use an IANA name like America/New_York.`;
|
|
66
|
+
} else {
|
|
67
|
+
await repo.updateSettings({ timezone: tz, locale: String(form.get('locale') ?? '').trim() });
|
|
68
|
+
flash = 'Timezone & language saved.';
|
|
69
|
+
}
|
|
70
|
+
} else if (action === 'codeInjection') {
|
|
71
|
+
if (!isOwner) {
|
|
72
|
+
flashError = 'Only owners can edit injected code.';
|
|
73
|
+
} else {
|
|
74
|
+
await repo.updateSettings({
|
|
75
|
+
headCode: String(form.get('headCode') ?? ''),
|
|
76
|
+
footerCode: String(form.get('footerCode') ?? ''),
|
|
77
|
+
});
|
|
78
|
+
flash = 'Code injection saved.';
|
|
79
|
+
}
|
|
80
|
+
} else if (action === 'announcement') {
|
|
81
|
+
// link must be a relative path or http(s) URL — never javascript: etc.
|
|
82
|
+
const rawLink = String(form.get('annLink') ?? '').trim();
|
|
83
|
+
const link = rawLink.startsWith('/') || /^https?:\/\//i.test(rawLink) ? rawLink : '';
|
|
84
|
+
if (rawLink && !link) {
|
|
85
|
+
flashError = 'Announcement link must start with / or https://';
|
|
86
|
+
} else {
|
|
87
|
+
await repo.updateSettings({
|
|
88
|
+
announcement: {
|
|
89
|
+
enabled: form.get('annEnabled') === 'on',
|
|
90
|
+
text: String(form.get('annText') ?? '').trim(),
|
|
91
|
+
link,
|
|
92
|
+
dismissible: form.get('annDismissible') === 'on',
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
flash = 'Announcement saved.';
|
|
96
|
+
}
|
|
97
|
+
} else if (action === 'addRedirect') {
|
|
98
|
+
const from = String(form.get('fromPath') ?? '').trim();
|
|
99
|
+
const to = String(form.get('toPath') ?? '').trim();
|
|
100
|
+
const code = String(form.get('code') ?? '301') === '302' ? 302 : 301;
|
|
101
|
+
try {
|
|
102
|
+
const r = await repo.createRedirect({ fromPath: from, toPath: to, code });
|
|
103
|
+
invalidateRedirectCache();
|
|
104
|
+
flash = `Redirecting ${r.fromPath} → ${r.toPath}.`;
|
|
105
|
+
} catch {
|
|
106
|
+
flashError = 'Both paths are required and must differ — e.g. /old-page → /new-page.';
|
|
107
|
+
}
|
|
108
|
+
} else if (action === 'removeRedirect') {
|
|
109
|
+
await repo.deleteRedirect(String(form.get('id') ?? ''));
|
|
110
|
+
invalidateRedirectCache();
|
|
111
|
+
flash = 'Redirect removed.';
|
|
112
|
+
} else if (!isOwner) {
|
|
113
|
+
flashError = 'Only owners can manage the drafts API.';
|
|
114
|
+
} else {
|
|
115
|
+
const current = await repo.getSettings();
|
|
116
|
+
if (action === 'generateKey') {
|
|
117
|
+
await repo.updateSettings({ draftsApiKey: genKey() });
|
|
118
|
+
flash = current.draftsApiKey ? 'API key regenerated — apps with the old key will get 401s.' : 'API key generated.';
|
|
119
|
+
} else if (action === 'revokeKey') {
|
|
120
|
+
await repo.updateSettings({ draftsApiKey: '' });
|
|
121
|
+
flash = 'API key revoked — the drafts API is off.';
|
|
122
|
+
} else if (action === 'addOrigin') {
|
|
123
|
+
const origin = normalizeOrigin(String(form.get('origin') ?? ''));
|
|
124
|
+
if (!origin) {
|
|
125
|
+
flashError = 'That doesn’t look like a URL — try e.g. https://app.example.com';
|
|
126
|
+
} else if (current.draftsAllowedOrigins.includes(origin)) {
|
|
127
|
+
flash = `${origin} is already allowed.`;
|
|
128
|
+
} else {
|
|
129
|
+
await repo.updateSettings({ draftsAllowedOrigins: [...current.draftsAllowedOrigins, origin] });
|
|
130
|
+
flash = `Allowed ${origin}.`;
|
|
131
|
+
}
|
|
132
|
+
} else if (action === 'removeOrigin') {
|
|
133
|
+
const origin = String(form.get('origin') ?? '');
|
|
134
|
+
await repo.updateSettings({ draftsAllowedOrigins: current.draftsAllowedOrigins.filter((o) => o !== origin) });
|
|
135
|
+
flash = `Removed ${origin}.`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const settings = await repo.getSettings();
|
|
141
|
+
const redirects = await repo.listRedirects();
|
|
142
|
+
const backend = (Astro.locals as { runtime?: { env?: { DB?: unknown } } }).runtime?.env?.DB
|
|
143
|
+
? 'd1'
|
|
144
|
+
: 'sqlite';
|
|
145
|
+
|
|
146
|
+
const apiKey = settings.draftsApiKey;
|
|
147
|
+
const maskedKey = apiKey ? `${apiKey.slice(0, 8)}${'•'.repeat(12)}${apiKey.slice(-4)}` : '';
|
|
148
|
+
|
|
149
|
+
// Quick-add presets for the origin allowlist; extending this list is the
|
|
150
|
+
// one-line change when a new app needs access.
|
|
151
|
+
const ORIGIN_PRESETS: { label: string; origin: string }[] = [
|
|
152
|
+
{ label: 'tariu', origin: 'https://tariu.app' },
|
|
153
|
+
{ label: 'Local dev (localhost)', origin: 'http://localhost:*' },
|
|
154
|
+
{ label: 'Any origin (*)', origin: '*' },
|
|
155
|
+
];
|
|
156
|
+
const remainingPresets = ORIGIN_PRESETS.filter((p) => !settings.draftsAllowedOrigins.includes(p.origin));
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
<Admin title="Settings" section="settings">
|
|
160
|
+
<div class="page-head"><h1>Settings</h1></div>
|
|
161
|
+
|
|
162
|
+
{flash && <div class="flash">{flash}</div>}
|
|
163
|
+
{flashError && <div class="flash error">{flashError}</div>}
|
|
164
|
+
|
|
165
|
+
<div class="card card-pad">
|
|
166
|
+
<p class="card-title">Site</p>
|
|
167
|
+
<form method="post">
|
|
168
|
+
<input type="hidden" name="action" value="site" />
|
|
169
|
+
<div class="field-row">
|
|
170
|
+
<label class="field"><span>Site title</span><input type="text" name="siteTitle" value={settings.siteTitle} /></label>
|
|
171
|
+
<label class="field"><span>Tagline</span><input type="text" name="tagline" value={settings.tagline} /></label>
|
|
172
|
+
</div>
|
|
173
|
+
<label class="field">
|
|
174
|
+
<span>Site URL</span>
|
|
175
|
+
<input type="text" name="siteUrl" value={settings.siteUrl} placeholder="https://example.com" />
|
|
176
|
+
</label>
|
|
177
|
+
<p class="hint">
|
|
178
|
+
The public address of this site. Used to build canonical URLs, the sitemap, the RSS feed,
|
|
179
|
+
and social-share previews. Leave empty to use whatever address the site is served from.
|
|
180
|
+
</p>
|
|
181
|
+
<label class="field">
|
|
182
|
+
<span>Footer</span>
|
|
183
|
+
<textarea name="footerText" rows="3" placeholder={`${settings.siteTitle}${settings.tagline ? ` — ${settings.tagline}` : ''}`}>{settings.footerText}</textarea>
|
|
184
|
+
</label>
|
|
185
|
+
<p class="hint">
|
|
186
|
+
Shown at the bottom of every public page. Markdown works — e.g.
|
|
187
|
+
<code>© 2026 Your Site · [Twitter](https://twitter.com/…)</code>. Leave empty to fall back to
|
|
188
|
+
“title — tagline”.
|
|
189
|
+
</p>
|
|
190
|
+
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
191
|
+
</form>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{isOwner && (
|
|
195
|
+
<div class="card card-pad">
|
|
196
|
+
<p class="card-title">External drafts API</p>
|
|
197
|
+
<p style="margin:0">
|
|
198
|
+
Lets outside apps push drafts to <code>POST /api/drafts</code> — they land in Posts as drafts
|
|
199
|
+
for you to refine and publish. The key can only create drafts, never publish, edit, or delete.
|
|
200
|
+
</p>
|
|
201
|
+
|
|
202
|
+
{apiKey ? (
|
|
203
|
+
<div class="da-key-row">
|
|
204
|
+
<code class="da-key" title="API key">{maskedKey}</code>
|
|
205
|
+
<button class="btn" type="button" id="da-copy" data-key={apiKey}>Copy</button>
|
|
206
|
+
<form method="post">
|
|
207
|
+
<input type="hidden" name="action" value="generateKey" />
|
|
208
|
+
<button class="btn" type="submit">Regenerate</button>
|
|
209
|
+
</form>
|
|
210
|
+
<form method="post">
|
|
211
|
+
<input type="hidden" name="action" value="revokeKey" />
|
|
212
|
+
<button class="btn danger" type="submit">Revoke</button>
|
|
213
|
+
</form>
|
|
214
|
+
</div>
|
|
215
|
+
) : (
|
|
216
|
+
<div class="da-empty">
|
|
217
|
+
<span class="hint">No API key yet — external apps can’t reach <code>/api/drafts</code>.</span>
|
|
218
|
+
<form method="post" style="margin:0">
|
|
219
|
+
<input type="hidden" name="action" value="generateKey" />
|
|
220
|
+
<button class="btn primary" type="submit">Generate key</button>
|
|
221
|
+
</form>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
{apiKey && (
|
|
225
|
+
<p class="hint" style="margin-top:8px">
|
|
226
|
+
Send it as <code>Authorization: Bearer <key></code>. Regenerating or revoking takes
|
|
227
|
+
effect immediately.
|
|
228
|
+
</p>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
<div class="da-origins">
|
|
232
|
+
<p class="da-subtitle">Allowed origins <span class="hint">(CORS)</span></p>
|
|
233
|
+
<p class="hint" style="margin:0 0 10px">
|
|
234
|
+
Browser apps on these URLs can call the API. Tools like curl or scripts are never blocked
|
|
235
|
+
by this list. Empty list = no browser app gets through.
|
|
236
|
+
</p>
|
|
237
|
+
{settings.draftsAllowedOrigins.length > 0 && (
|
|
238
|
+
<div class="da-chips">
|
|
239
|
+
{settings.draftsAllowedOrigins.map((origin) => (
|
|
240
|
+
<form method="post" class="da-chip">
|
|
241
|
+
<input type="hidden" name="action" value="removeOrigin" />
|
|
242
|
+
<input type="hidden" name="origin" value={origin} />
|
|
243
|
+
<code>{origin}</code>
|
|
244
|
+
<button class="da-chip-x" type="submit" title={`Remove ${origin}`}>✕</button>
|
|
245
|
+
</form>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
<form method="post" class="da-add-row">
|
|
250
|
+
<input type="hidden" name="action" value="addOrigin" />
|
|
251
|
+
<input type="text" name="origin" placeholder="https://another-app.example.com" />
|
|
252
|
+
<button class="btn" type="submit">Add</button>
|
|
253
|
+
</form>
|
|
254
|
+
{remainingPresets.length > 0 && (
|
|
255
|
+
<div class="da-presets">
|
|
256
|
+
<span class="hint">Quick add:</span>
|
|
257
|
+
{remainingPresets.map((p) => (
|
|
258
|
+
<form method="post" style="margin:0">
|
|
259
|
+
<input type="hidden" name="action" value="addOrigin" />
|
|
260
|
+
<input type="hidden" name="origin" value={p.origin} />
|
|
261
|
+
<button class="da-preset" type="submit">+ {p.label}</button>
|
|
262
|
+
</form>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
|
|
270
|
+
<div class="card card-pad">
|
|
271
|
+
<p class="card-title">Timezone & language</p>
|
|
272
|
+
<form method="post">
|
|
273
|
+
<input type="hidden" name="action" value="localization" />
|
|
274
|
+
<div class="field-row">
|
|
275
|
+
<label class="field">
|
|
276
|
+
<span>Publication timezone</span>
|
|
277
|
+
<input type="text" name="timezone" value={settings.timezone} placeholder="America/New_York" list="tz-suggestions" />
|
|
278
|
+
</label>
|
|
279
|
+
<label class="field">
|
|
280
|
+
<span>Locale</span>
|
|
281
|
+
<input type="text" name="locale" value={settings.locale} placeholder="en-US" />
|
|
282
|
+
</label>
|
|
283
|
+
</div>
|
|
284
|
+
<datalist id="tz-suggestions">
|
|
285
|
+
{['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Guatemala', 'America/Mexico_City', 'Europe/London', 'Europe/Madrid', 'Asia/Tokyo'].map((tz) => <option value={tz} />)}
|
|
286
|
+
</datalist>
|
|
287
|
+
<p class="hint">
|
|
288
|
+
Dates on the public site render in this timezone and locale (e.g. <code>es-GT</code> for Spanish).
|
|
289
|
+
Empty = server timezone and English.
|
|
290
|
+
</p>
|
|
291
|
+
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
292
|
+
</form>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
{isOwner && (
|
|
296
|
+
<div class="card card-pad">
|
|
297
|
+
<p class="card-title">Code injection</p>
|
|
298
|
+
<p style="margin:0">
|
|
299
|
+
Raw HTML added to every public page — analytics snippets, verification tags, custom fonts.
|
|
300
|
+
Scripts here run with full access to your pages, so paste only code you trust.
|
|
301
|
+
</p>
|
|
302
|
+
<form method="post">
|
|
303
|
+
<input type="hidden" name="action" value="codeInjection" />
|
|
304
|
+
<label class="field" style="margin-top:12px">
|
|
305
|
+
<span>Site header <span class="hint">(inside <code><head></code>)</span></span>
|
|
306
|
+
<textarea name="headCode" rows="4" placeholder={'<script defer data-domain="example.com" src="…"></script>'} style="font-family:monospace;font-size:12.5px">{settings.headCode}</textarea>
|
|
307
|
+
</label>
|
|
308
|
+
<label class="field">
|
|
309
|
+
<span>Site footer <span class="hint">(before <code></body></code>)</span></span>
|
|
310
|
+
<textarea name="footerCode" rows="4" style="font-family:monospace;font-size:12.5px">{settings.footerCode}</textarea>
|
|
311
|
+
</label>
|
|
312
|
+
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
313
|
+
</form>
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
|
|
317
|
+
<div class="card card-pad">
|
|
318
|
+
<p class="card-title">Announcement bar</p>
|
|
319
|
+
<form method="post">
|
|
320
|
+
<input type="hidden" name="action" value="announcement" />
|
|
321
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
322
|
+
<input type="checkbox" name="annEnabled" checked={settings.announcement.enabled} />
|
|
323
|
+
<span>Show a bar above the site header</span>
|
|
324
|
+
</label>
|
|
325
|
+
<div class="field-row">
|
|
326
|
+
<label class="field"><span>Text</span><input type="text" name="annText" value={settings.announcement.text} placeholder="🎉 Something worth announcing" /></label>
|
|
327
|
+
<label class="field"><span>Link (optional)</span><input type="text" name="annLink" value={settings.announcement.link} placeholder={`${CONTENT.posts.base}/announcement`} /></label>
|
|
328
|
+
</div>
|
|
329
|
+
<label class="field pe-check" style="flex-direction:row;align-items:center;gap:8px">
|
|
330
|
+
<input type="checkbox" name="annDismissible" checked={settings.announcement.dismissible} />
|
|
331
|
+
<span>Visitors can dismiss it <span class="hint">(remembered per browser until the text changes)</span></span>
|
|
332
|
+
</label>
|
|
333
|
+
<div class="form-actions"><button class="btn primary" type="submit">Save</button></div>
|
|
334
|
+
</form>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
<div class="card card-pad">
|
|
338
|
+
<p class="card-title">Redirects</p>
|
|
339
|
+
<p style="margin:0">
|
|
340
|
+
Forward old URLs to new ones. Redirects only fire when a URL would otherwise 404, so they can
|
|
341
|
+
never shadow real pages. Renaming a published post or page adds one here automatically.
|
|
342
|
+
</p>
|
|
343
|
+
{redirects.length > 0 && (
|
|
344
|
+
<div class="rd-list">
|
|
345
|
+
{redirects.map((r) => (
|
|
346
|
+
<div class="rd-row">
|
|
347
|
+
<code class="rd-path">{r.fromPath}</code>
|
|
348
|
+
<span class="rd-arrow">→</span>
|
|
349
|
+
<code class="rd-path">{r.toPath}</code>
|
|
350
|
+
<span class="rd-code">{r.code}</span>
|
|
351
|
+
<form method="post" style="margin:0">
|
|
352
|
+
<input type="hidden" name="action" value="removeRedirect" />
|
|
353
|
+
<input type="hidden" name="id" value={r.id} />
|
|
354
|
+
<button class="da-chip-x" type="submit" title={`Remove redirect from ${r.fromPath}`}>✕</button>
|
|
355
|
+
</form>
|
|
356
|
+
</div>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
)}
|
|
360
|
+
<form method="post" class="rd-add">
|
|
361
|
+
<input type="hidden" name="action" value="addRedirect" />
|
|
362
|
+
<input type="text" name="fromPath" placeholder="/old-path" required />
|
|
363
|
+
<span class="rd-arrow">→</span>
|
|
364
|
+
<input type="text" name="toPath" placeholder="/new-path or https://elsewhere.com" required />
|
|
365
|
+
<select name="code">
|
|
366
|
+
<option value="301">301 permanent</option>
|
|
367
|
+
<option value="302">302 temporary</option>
|
|
368
|
+
</select>
|
|
369
|
+
<button class="btn" type="submit">Add</button>
|
|
370
|
+
</form>
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<div class="card card-pad">
|
|
374
|
+
<p class="card-title">Backup</p>
|
|
375
|
+
<p style="margin:0">
|
|
376
|
+
Download everything as one ZIP — pages, posts, tags, links, navigation, users, settings
|
|
377
|
+
(<code>content.json</code>) plus every uploaded image.
|
|
378
|
+
</p>
|
|
379
|
+
<p class="hint" style="margin-top:8px">
|
|
380
|
+
Works the same on every backend. Passwords are never exported — after restoring to a fresh
|
|
381
|
+
install, sign in with the owner password from <code>.env</code> and reset accounts.
|
|
382
|
+
</p>
|
|
383
|
+
<div class="form-actions"><a class="btn primary" href="/api/backup" download>Download backup</a></div>
|
|
384
|
+
|
|
385
|
+
{isOwner && (
|
|
386
|
+
<div class="rs-restore">
|
|
387
|
+
<p class="da-subtitle">Restore from backup</p>
|
|
388
|
+
<p class="hint" style="margin:0 0 10px">
|
|
389
|
+
Pick a backup ZIP to see what's inside, then restore. <strong>Restoring replaces all
|
|
390
|
+
current content</strong> — posts, pages, media, settings, everything.
|
|
391
|
+
</p>
|
|
392
|
+
<div class="rs-row">
|
|
393
|
+
<input type="file" id="rs-file" accept=".zip,application/zip" />
|
|
394
|
+
<button class="btn" type="button" id="rs-analyze">Analyze</button>
|
|
395
|
+
<button class="btn danger" type="button" id="rs-go" hidden>Restore now</button>
|
|
396
|
+
</div>
|
|
397
|
+
<div class="hint" id="rs-report" style="margin-top:10px"></div>
|
|
398
|
+
</div>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div class="card card-pad">
|
|
403
|
+
<p class="card-title">Backend</p>
|
|
404
|
+
<p style="margin:0">
|
|
405
|
+
Content backend: <span class="pill home">{backend}</span>
|
|
406
|
+
</p>
|
|
407
|
+
<p class="hint" style="margin-top:8px">
|
|
408
|
+
Picked automatically: <code>d1</code> when the Cloudflare <code>DB</code> binding is present
|
|
409
|
+
(Workers deploys and wrangler dev), otherwise <code>sqlite</code> (local file at
|
|
410
|
+
<code>SQLITE_URL</code>, zero setup). Both use the same content shapes.
|
|
411
|
+
</p>
|
|
412
|
+
</div>
|
|
413
|
+
</Admin>
|
|
414
|
+
|
|
415
|
+
<style>
|
|
416
|
+
.rs-restore { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 14px; }
|
|
417
|
+
.rs-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
|
418
|
+
.rd-list { margin-top: 14px; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; }
|
|
419
|
+
.rd-row {
|
|
420
|
+
display: flex; align-items: center; gap: 10px; padding: 8px 12px;
|
|
421
|
+
border-bottom: 1px solid var(--border); font-size: 13px;
|
|
422
|
+
}
|
|
423
|
+
.rd-row:last-child { border-bottom: none; }
|
|
424
|
+
.rd-path { background: var(--bg); border-radius: 6px; padding: 3px 8px; font-size: 12.5px; }
|
|
425
|
+
.rd-arrow { color: var(--muted); }
|
|
426
|
+
.rd-code { margin-left: auto; font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
427
|
+
.rd-add { display: flex; gap: 8px; align-items: center; margin-top: 14px; flex-wrap: wrap; }
|
|
428
|
+
.rd-add input[type='text'] { flex: 1; min-width: 140px; }
|
|
429
|
+
.da-key-row { display: flex; gap: 8px; align-items: center; margin-top: 14px; flex-wrap: wrap; }
|
|
430
|
+
.da-key {
|
|
431
|
+
flex: 1; min-width: 220px; background: var(--bg); border: 1px solid var(--border);
|
|
432
|
+
border-radius: 7px; padding: 7px 10px; font-size: 12.5px; color: var(--muted);
|
|
433
|
+
}
|
|
434
|
+
.da-key-row form { margin: 0; }
|
|
435
|
+
.da-empty {
|
|
436
|
+
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
437
|
+
border: 1px dashed var(--border); border-radius: 9px; padding: 12px 14px; margin-top: 14px;
|
|
438
|
+
}
|
|
439
|
+
.da-origins { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 14px; }
|
|
440
|
+
.da-subtitle { font-size: 13px; font-weight: 600; margin: 0 0 4px; }
|
|
441
|
+
.da-chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
442
|
+
.da-chip {
|
|
443
|
+
display: inline-flex; align-items: center; gap: 7px; margin: 0;
|
|
444
|
+
background: var(--accent-soft); border: 1px solid rgb(110 123 255 / 30%);
|
|
445
|
+
border-radius: 99px; padding: 3px 6px 3px 12px;
|
|
446
|
+
}
|
|
447
|
+
.da-chip code { font-size: 12px; background: none; border: none; padding: 0; }
|
|
448
|
+
.da-chip-x {
|
|
449
|
+
background: none; border: none; color: var(--muted); cursor: pointer;
|
|
450
|
+
font-size: 12px; padding: 2px 5px; border-radius: 99px; line-height: 1;
|
|
451
|
+
}
|
|
452
|
+
.da-chip-x:hover { color: var(--red); background: rgb(248 113 113 / 12%); }
|
|
453
|
+
.da-add-row { display: flex; gap: 8px; margin: 0 0 12px; }
|
|
454
|
+
.da-add-row input[type='text'] { flex: 1; }
|
|
455
|
+
.da-presets { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
|
456
|
+
.da-presets form { margin: 0; }
|
|
457
|
+
.da-preset {
|
|
458
|
+
background: none; border: 1px dashed var(--border); border-radius: 99px;
|
|
459
|
+
padding: 3px 12px; font-size: 12px; color: var(--muted); cursor: pointer; font-family: inherit;
|
|
460
|
+
}
|
|
461
|
+
.da-preset:hover { color: var(--accent); border-color: var(--accent); }
|
|
462
|
+
</style>
|
|
463
|
+
|
|
464
|
+
<script>
|
|
465
|
+
// backup restore: analyze (dry run) → confirm → restore
|
|
466
|
+
const rsFile = document.getElementById('rs-file') as HTMLInputElement | null;
|
|
467
|
+
const rsAnalyze = document.getElementById('rs-analyze');
|
|
468
|
+
const rsGo = document.getElementById('rs-go');
|
|
469
|
+
const rsReport = document.getElementById('rs-report');
|
|
470
|
+
async function postBackup(dry: boolean) {
|
|
471
|
+
const f = rsFile?.files?.[0];
|
|
472
|
+
if (!f || !rsReport) return null;
|
|
473
|
+
rsReport.textContent = dry ? 'Analyzing…' : 'Restoring…';
|
|
474
|
+
const form = new FormData();
|
|
475
|
+
form.set('file', f);
|
|
476
|
+
const res = await fetch(`/api/restore${dry ? '?dry=1' : ''}`, { method: 'POST', body: form });
|
|
477
|
+
const data = await res.json().catch(() => null);
|
|
478
|
+
if (!res.ok || !data) {
|
|
479
|
+
rsReport.textContent = `Error: ${data?.error ?? res.statusText}`;
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
return data;
|
|
483
|
+
}
|
|
484
|
+
rsAnalyze?.addEventListener('click', async () => {
|
|
485
|
+
const data = await postBackup(true);
|
|
486
|
+
if (!data || !rsReport) return;
|
|
487
|
+
const r = data.report;
|
|
488
|
+
rsReport.innerHTML =
|
|
489
|
+
`Backup from <b>${r.exportedAt ? new Date(r.exportedAt).toLocaleString() : 'unknown date'}</b>: ` +
|
|
490
|
+
`${r.posts} posts, ${r.pages} pages, ${r.media} media files, ${r.tags} tags, ` +
|
|
491
|
+
`${r.links} links, ${r.navItems} nav items, ${r.users} users.` +
|
|
492
|
+
(r.missingBinaries ? ` <b>⚠ ${r.missingBinaries} media binaries missing from the archive.</b>` : '');
|
|
493
|
+
rsGo?.removeAttribute('hidden');
|
|
494
|
+
});
|
|
495
|
+
rsGo?.addEventListener('click', async () => {
|
|
496
|
+
if (!confirm('Replace ALL current content with this backup? This cannot be undone.')) return;
|
|
497
|
+
const data = await postBackup(false);
|
|
498
|
+
if (!data || !rsReport) return;
|
|
499
|
+
rsReport.innerHTML = `<b>Restored:</b> ${data.restored.posts} posts, ${data.restored.pages} pages, ${data.restored.media} media files. Reloading…`;
|
|
500
|
+
setTimeout(() => location.reload(), 1200);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const copyBtn = document.getElementById('da-copy');
|
|
504
|
+
if (copyBtn) {
|
|
505
|
+
copyBtn.addEventListener('click', async () => {
|
|
506
|
+
await navigator.clipboard.writeText(copyBtn.dataset.key ?? '');
|
|
507
|
+
const original = copyBtn.textContent;
|
|
508
|
+
copyBtn.textContent = 'Copied!';
|
|
509
|
+
setTimeout(() => (copyBtn.textContent = original), 1500);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
</script>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import { slugify } from '../../lib/schema';
|
|
6
|
+
import { CONTENT, tagUrl } from '../../lib/content';
|
|
7
|
+
|
|
8
|
+
const repo = await getRepo(Astro.locals);
|
|
9
|
+
|
|
10
|
+
if (Astro.request.method === 'POST') {
|
|
11
|
+
const form = await Astro.request.formData();
|
|
12
|
+
const op = String(form.get('op'));
|
|
13
|
+
if (op === 'create') {
|
|
14
|
+
const name = String(form.get('name') || '').trim();
|
|
15
|
+
if (name) {
|
|
16
|
+
const slug = slugify(String(form.get('slug') || name));
|
|
17
|
+
if (!(await repo.getTagBySlug(slug))) {
|
|
18
|
+
await repo.createTag({ name, slug, description: String(form.get('description') ?? '') });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if (op === 'update') {
|
|
23
|
+
await repo.updateTag(String(form.get('id')), {
|
|
24
|
+
name: String(form.get('name') ?? '').trim() || undefined,
|
|
25
|
+
slug: slugify(String(form.get('slug') ?? '')),
|
|
26
|
+
description: String(form.get('description') ?? ''),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (op === 'delete') await repo.deleteTag(String(form.get('id')));
|
|
30
|
+
return Astro.redirect('/admin/tags');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const [tags, posts] = await Promise.all([repo.listTags(), repo.listPosts()]);
|
|
34
|
+
const countFor = (name: string) =>
|
|
35
|
+
posts.filter((p) => p.tags.some((t) => t.toLowerCase() === name.toLowerCase())).length;
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
<Admin title="Tags" section="tags">
|
|
39
|
+
<div class="page-head">
|
|
40
|
+
<h1>Tags</h1>
|
|
41
|
+
<button class="btn primary" type="button" data-modal="new-tag">New tag</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<Modal id="new-tag" title="New tag">
|
|
45
|
+
<form method="post">
|
|
46
|
+
<input type="hidden" name="op" value="create" />
|
|
47
|
+
<label class="field">
|
|
48
|
+
<span>Name</span>
|
|
49
|
+
<input type="text" name="name" placeholder="Tag name" required autofocus data-slug-for="new-tag-slug" />
|
|
50
|
+
</label>
|
|
51
|
+
<label class="field">
|
|
52
|
+
<span>Slug</span>
|
|
53
|
+
<div class="slug-wrap">
|
|
54
|
+
<span class="slug-prefix">{CONTENT.tags.base}/</span>
|
|
55
|
+
<input type="text" id="new-tag-slug" name="slug" placeholder="auto from name" data-slug-dest />
|
|
56
|
+
</div>
|
|
57
|
+
</label>
|
|
58
|
+
<label class="field">
|
|
59
|
+
<span>Description <em class="hint">(optional)</em></span>
|
|
60
|
+
<input type="text" name="description" placeholder="Shown on the tag archive page" />
|
|
61
|
+
</label>
|
|
62
|
+
<div class="modal-foot">
|
|
63
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
64
|
+
<button class="btn primary" type="submit">Add tag</button>
|
|
65
|
+
</div>
|
|
66
|
+
<p class="hint" style="margin:12px 0 0">Tags typed on a {CONTENT.posts.singular.toLowerCase()} are registered automatically — this adds slugs and descriptions for the public archives.</p>
|
|
67
|
+
</form>
|
|
68
|
+
</Modal>
|
|
69
|
+
|
|
70
|
+
<div class="card rowlist">
|
|
71
|
+
{tags.map((t) => {
|
|
72
|
+
const n = countFor(t.name);
|
|
73
|
+
return (
|
|
74
|
+
<div class="row">
|
|
75
|
+
<form class="row-edit" method="post">
|
|
76
|
+
<input type="hidden" name="op" value="update" />
|
|
77
|
+
<input type="hidden" name="id" value={t.id} />
|
|
78
|
+
<input type="text" class="bare strong" name="name" value={t.name} aria-label="Name" style="flex:0 1 180px" />
|
|
79
|
+
<input type="text" class="bare mono dim" name="slug" value={t.slug} aria-label="Slug" style="flex:0 1 160px" />
|
|
80
|
+
<input type="text" class="bare dim" name="description" value={t.description} placeholder="Add a description…" aria-label="Description" style="flex:1" />
|
|
81
|
+
<button class="btn sm primary reveal" type="submit">Save</button>
|
|
82
|
+
</form>
|
|
83
|
+
<span class="meta">{n} {(n === 1 ? CONTENT.posts.singular : CONTENT.posts.plural).toLowerCase()}</span>
|
|
84
|
+
<a class="btn sm ghost reveal" href={tagUrl(t.slug)} target="_blank" rel="noopener">View ↗</a>
|
|
85
|
+
<form class="inline-form reveal" method="post" onsubmit={`return confirm('Delete this tag? ${CONTENT.posts.plural.replace(/['\\]/g, '\\$&')} keep the tag name; only the registry entry and its archive page go away.')`}>
|
|
86
|
+
<input type="hidden" name="op" value="delete" />
|
|
87
|
+
<input type="hidden" name="id" value={t.id} />
|
|
88
|
+
<button class="btn sm ghost danger" type="submit">Delete</button>
|
|
89
|
+
</form>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
{tags.length === 0 && (
|
|
94
|
+
<div class="empty-state">
|
|
95
|
+
<b>No tags yet</b>
|
|
96
|
+
<p>Create one here, or just type a tag on any {CONTENT.posts.singular.toLowerCase()} — it registers automatically.</p>
|
|
97
|
+
<button class="btn primary" type="button" data-modal="new-tag">New tag</button>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
</Admin>
|