@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,163 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../../../layouts/Admin.astro';
|
|
3
|
+
import Modal from '../../../../components/Modal.astro';
|
|
4
|
+
import { getRepo } from '../../../../lib/repo';
|
|
5
|
+
import { slugify, type Status } from '../../../../lib/schema';
|
|
6
|
+
import { CONTENT } from '../../../../lib/content';
|
|
7
|
+
|
|
8
|
+
// One list page for every collection; [collection] is the config key from
|
|
9
|
+
// acms({ content.collections }). Unknown keys 404 — content in removed
|
|
10
|
+
// collections stays in the DB and comes back when the key is re-added.
|
|
11
|
+
const key = Astro.params.collection!;
|
|
12
|
+
const col = CONTENT.collections[key];
|
|
13
|
+
if (!col) return new Response(null, { status: 404, statusText: 'Not found' });
|
|
14
|
+
|
|
15
|
+
const { singular, plural, base } = col;
|
|
16
|
+
const lower = singular.toLowerCase();
|
|
17
|
+
// lower goes inside an inline confirm('…') — a quote in the name must not break it
|
|
18
|
+
const lowerJs = lower.replace(/['\\]/g, '\\$&');
|
|
19
|
+
const repo = await getRepo(Astro.locals);
|
|
20
|
+
|
|
21
|
+
if (Astro.request.method === 'POST') {
|
|
22
|
+
const form = await Astro.request.formData();
|
|
23
|
+
const op = String(form.get('op'));
|
|
24
|
+
if (op === 'create') {
|
|
25
|
+
const title = String(form.get('title') || 'Untitled');
|
|
26
|
+
let slug = slugify(title);
|
|
27
|
+
let n = 1;
|
|
28
|
+
while (await repo.getPostBySlug(slug, key)) slug = `${slugify(title)}-${++n}`;
|
|
29
|
+
const post = await repo.createPost({ title, slug, collection: key });
|
|
30
|
+
return Astro.redirect(`/admin/c/${key}/${post.id}`);
|
|
31
|
+
}
|
|
32
|
+
if (op === 'delete') {
|
|
33
|
+
await repo.deletePost(String(form.get('id')));
|
|
34
|
+
return Astro.redirect(`/admin/c/${key}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const all = await repo.listPosts(key);
|
|
39
|
+
const q = (Astro.url.searchParams.get('q') ?? '').toLowerCase();
|
|
40
|
+
const statusFilter = Astro.url.searchParams.get('status') ?? 'all';
|
|
41
|
+
const tagFilter = Astro.url.searchParams.get('tag') ?? '';
|
|
42
|
+
|
|
43
|
+
const posts = all.filter((p) => {
|
|
44
|
+
if (statusFilter !== 'all' && p.status !== statusFilter) return false;
|
|
45
|
+
if (tagFilter && !p.tags.includes(tagFilter)) return false;
|
|
46
|
+
if (q && !p.title.toLowerCase().includes(q) && !p.slug.toLowerCase().includes(q)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const allTags = [...new Set(all.flatMap((p) => p.tags))].sort();
|
|
51
|
+
const statusCounts = { all: all.length } as Record<string, number>;
|
|
52
|
+
for (const p of all) statusCounts[p.status] = (statusCounts[p.status] ?? 0) + 1;
|
|
53
|
+
const STATUSES: ('all' | Status)[] = ['all', 'draft', 'scheduled', 'published', 'archived'];
|
|
54
|
+
|
|
55
|
+
const showNew = Astro.url.searchParams.has('new');
|
|
56
|
+
const fmt = (ts: number) => new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
57
|
+
const filterHref = (patch: Record<string, string>) => {
|
|
58
|
+
const p = new URLSearchParams(Astro.url.search);
|
|
59
|
+
for (const [k, v] of Object.entries(patch)) v ? p.set(k, v) : p.delete(k);
|
|
60
|
+
const s = p.toString();
|
|
61
|
+
return `/admin/c/${key}${s ? `?${s}` : ''}`;
|
|
62
|
+
};
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
<Admin title={plural} section={`c:${key}`}>
|
|
66
|
+
<div class="page-head">
|
|
67
|
+
<h1>{plural}</h1>
|
|
68
|
+
<button class="btn primary" type="button" data-modal="new-post">New {lower}</button>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<Modal id="new-post" title={`New ${lower}`} open={showNew}>
|
|
72
|
+
<form method="post">
|
|
73
|
+
<input type="hidden" name="op" value="create" />
|
|
74
|
+
<label class="field">
|
|
75
|
+
<span>Title</span>
|
|
76
|
+
<input type="text" name="title" placeholder={`${singular} title`} required autofocus />
|
|
77
|
+
</label>
|
|
78
|
+
<div class="modal-foot">
|
|
79
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
80
|
+
<button class="btn primary" type="submit">Create {lower}</button>
|
|
81
|
+
</div>
|
|
82
|
+
<p class="hint" style="margin:12px 0 0">Creates a draft at {base}/<slug-from-title> and opens the editor — the slug is editable there.</p>
|
|
83
|
+
</form>
|
|
84
|
+
</Modal>
|
|
85
|
+
|
|
86
|
+
<div class="filter-bar">
|
|
87
|
+
<div class="filter-tabs">
|
|
88
|
+
{STATUSES.map((s) => (
|
|
89
|
+
<a class={`filter-tab ${statusFilter === s ? 'is-active' : ''}`} href={filterHref({ status: s === 'all' ? '' : s })}>
|
|
90
|
+
{s === 'all' ? 'All' : s[0].toUpperCase() + s.slice(1)}
|
|
91
|
+
<span class="filter-count">{statusCounts[s] ?? 0}</span>
|
|
92
|
+
</a>
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
<form method="get" style="display:flex;gap:8px;align-items:center">
|
|
96
|
+
{statusFilter !== 'all' && <input type="hidden" name="status" value={statusFilter} />}
|
|
97
|
+
{allTags.length > 0 && (
|
|
98
|
+
<select name="tag" onchange="this.form.submit()" style="width:auto">
|
|
99
|
+
<option value="">All tags</option>
|
|
100
|
+
{allTags.map((t) => <option value={t} selected={t === tagFilter}>{t}</option>)}
|
|
101
|
+
</select>
|
|
102
|
+
)}
|
|
103
|
+
<input type="search" name="q" placeholder={`Search ${plural.toLowerCase()}…`} value={q} style="min-width:180px" />
|
|
104
|
+
</form>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div class="card">
|
|
108
|
+
<table class="list">
|
|
109
|
+
<thead><tr><th>Title</th><th>Status</th><th>Tags</th><th>Updated</th><th></th></tr></thead>
|
|
110
|
+
<tbody>
|
|
111
|
+
{posts.map((p) => (
|
|
112
|
+
<tr>
|
|
113
|
+
<td>
|
|
114
|
+
<a class="t-title" href={`/admin/c/${key}/${p.id}`}>{p.featured && <span title="Featured" style="color:#e8a13a">★ </span>}{p.title}</a>
|
|
115
|
+
<div class="t-sub">{base}/{p.slug}{p.excerpt ? ` — ${p.excerpt.slice(0, 60)}${p.excerpt.length > 60 ? '…' : ''}` : ''}</div>
|
|
116
|
+
</td>
|
|
117
|
+
<td>
|
|
118
|
+
<span class={`pill ${p.status}`}>{p.status}</span>
|
|
119
|
+
{p.status === 'scheduled' && p.scheduledAt && (
|
|
120
|
+
<div class="t-sub" style="margin-top:2px">
|
|
121
|
+
{new Date(p.scheduledAt).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</td>
|
|
125
|
+
<td class="t-sub">{p.tags.join(', ')}</td>
|
|
126
|
+
<td class="t-sub">{fmt(p.updatedAt)}</td>
|
|
127
|
+
<td style="text-align:right">
|
|
128
|
+
<form class="inline-form" method="post" onsubmit={`return confirm('Delete this ${lowerJs} permanently? Consider archiving instead (from the editor).')`}>
|
|
129
|
+
<input type="hidden" name="op" value="delete" />
|
|
130
|
+
<input type="hidden" name="id" value={p.id} />
|
|
131
|
+
<button class="btn sm ghost danger" type="submit">Delete</button>
|
|
132
|
+
</form>
|
|
133
|
+
</td>
|
|
134
|
+
</tr>
|
|
135
|
+
))}
|
|
136
|
+
{posts.length === 0 && (
|
|
137
|
+
<tr>
|
|
138
|
+
<td colspan="5">
|
|
139
|
+
{all.length === 0 ? (
|
|
140
|
+
<div class="empty-state">
|
|
141
|
+
<b>No {plural.toLowerCase()} yet</b>
|
|
142
|
+
<p>Write your first {lower} — it stays a draft until you publish it.</p>
|
|
143
|
+
<button class="btn primary" type="button" data-modal="new-post">New {lower}</button>
|
|
144
|
+
</div>
|
|
145
|
+
) : (
|
|
146
|
+
<p class="hint" style="margin:0;padding:12px 0">No {plural.toLowerCase()} match the current filters.</p>
|
|
147
|
+
)}
|
|
148
|
+
</td>
|
|
149
|
+
</tr>
|
|
150
|
+
)}
|
|
151
|
+
</tbody>
|
|
152
|
+
</table>
|
|
153
|
+
</div>
|
|
154
|
+
</Admin>
|
|
155
|
+
|
|
156
|
+
<style>
|
|
157
|
+
.filter-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
158
|
+
.filter-tabs { display: flex; gap: 2px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 3px; }
|
|
159
|
+
.filter-tab { color: var(--muted); font-size: 12.5px; font-weight: 600; padding: 4px 11px; border-radius: 6px; }
|
|
160
|
+
.filter-tab:hover { color: var(--text); }
|
|
161
|
+
.filter-tab.is-active { background: var(--accent-soft); color: var(--text); }
|
|
162
|
+
.filter-count { color: var(--faint); font-size: 11px; margin-left: 5px; }
|
|
163
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { env } from '../../lib/env';
|
|
5
|
+
import DesignManager from '../../components/DesignManager';
|
|
6
|
+
import { listThemes, FONT_PAIRINGS } from '../../lib/theme';
|
|
7
|
+
import { postUrl } from '../../lib/content';
|
|
8
|
+
// ImagePicker (logo upload) styles live in editor.css
|
|
9
|
+
import '../../styles/editor.css';
|
|
10
|
+
|
|
11
|
+
const repo = await getRepo(Astro.locals);
|
|
12
|
+
const settings = await repo.getSettings();
|
|
13
|
+
// most recent published post powers the preview's "Post" tab
|
|
14
|
+
const [latestPost] = await repo.listPublishedPosts();
|
|
15
|
+
const postPreviewPath = latestPost ? postUrl(latestPost.slug) : null;
|
|
16
|
+
// serialize to plain data for the island — no component references
|
|
17
|
+
const themes = listThemes().map((t) => ({
|
|
18
|
+
slug: t.slug,
|
|
19
|
+
name: t.name,
|
|
20
|
+
description: t.description,
|
|
21
|
+
tokens: t.tokens,
|
|
22
|
+
post: t.post,
|
|
23
|
+
hasHome: Boolean(t.HomeTemplate),
|
|
24
|
+
}));
|
|
25
|
+
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<Admin title="Design" section="design" wide>
|
|
29
|
+
<div class="page-head">
|
|
30
|
+
<h1>Design</h1>
|
|
31
|
+
<a class="btn sm" href="/" target="_blank" rel="noopener">View site ↗</a>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<DesignManager
|
|
35
|
+
client:only="react"
|
|
36
|
+
settings={{ ...settings, draftsApiKey: '' }}
|
|
37
|
+
themes={themes}
|
|
38
|
+
pairings={[...FONT_PAIRINGS]}
|
|
39
|
+
unsplashEnabled={unsplashEnabled}
|
|
40
|
+
postPreviewPath={postPreviewPath}
|
|
41
|
+
/>
|
|
42
|
+
</Admin>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { userConfig } from 'virtual:acms-config';
|
|
5
|
+
import { CONTENT } from '../../lib/content';
|
|
6
|
+
|
|
7
|
+
const coreVersion = userConfig.coreVersion;
|
|
8
|
+
const repo = await getRepo(Astro.locals);
|
|
9
|
+
const [stats, pages, posts, links] = await Promise.all([
|
|
10
|
+
repo.getStats(),
|
|
11
|
+
repo.listPages(),
|
|
12
|
+
repo.listPosts(),
|
|
13
|
+
repo.listLinks(),
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const recent = [
|
|
17
|
+
...pages.map((p) => ({ kind: 'Page', title: p.title, href: `/admin/pages/${p.id}`, at: p.updatedAt, status: p.status })),
|
|
18
|
+
...posts.map((p) => ({
|
|
19
|
+
kind: (CONTENT.collections[p.collection] ?? CONTENT.posts).singular,
|
|
20
|
+
title: p.title,
|
|
21
|
+
href: `/admin/c/${p.collection}/${p.id}`,
|
|
22
|
+
at: p.updatedAt,
|
|
23
|
+
status: p.status,
|
|
24
|
+
})),
|
|
25
|
+
]
|
|
26
|
+
.sort((a, b) => b.at - a.at)
|
|
27
|
+
.slice(0, 8);
|
|
28
|
+
|
|
29
|
+
// per-collection counts for the stat row; content whose collection key was
|
|
30
|
+
// removed from config would otherwise be invisible, so it gets a notice
|
|
31
|
+
const countByCollection = new Map<string, number>();
|
|
32
|
+
for (const p of posts) countByCollection.set(p.collection, (countByCollection.get(p.collection) ?? 0) + 1);
|
|
33
|
+
const orphaned = [...countByCollection.entries()].filter(([key]) => !CONTENT.collections[key]);
|
|
34
|
+
|
|
35
|
+
const topLinks = [...links].sort((a, b) => b.clicks - a.clicks).slice(0, 5);
|
|
36
|
+
|
|
37
|
+
function ago(ts: number) {
|
|
38
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
39
|
+
if (s < 60) return 'just now';
|
|
40
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
41
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
42
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
43
|
+
}
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<Admin title="Dashboard" section="dashboard">
|
|
47
|
+
<div class="page-head">
|
|
48
|
+
<h1>Dashboard</h1>
|
|
49
|
+
<div style="display:flex;gap:8px">
|
|
50
|
+
{Object.entries(CONTENT.collections).map(([key, c]) => (
|
|
51
|
+
<a class="btn" href={`/admin/c/${key}?new=1`}>New {c.singular.toLowerCase()}</a>
|
|
52
|
+
))}
|
|
53
|
+
<a class="btn primary" href="/admin/pages?new=1">New page</a>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<div id="acms-update" hidden data-current={coreVersion} class="card card-pad" style="display:none;align-items:center;gap:10px;margin-bottom:14px;border-left:3px solid var(--accent)">
|
|
58
|
+
<div>
|
|
59
|
+
<b>ACMS <span id="acms-update-latest"></span> is available</b>
|
|
60
|
+
<span class="hint" style="margin-left:6px">you're on {coreVersion} — run <code>npx acms update</code>, then <code>npm run deploy</code>.</span>
|
|
61
|
+
</div>
|
|
62
|
+
<a class="btn sm ghost" style="margin-left:auto" href="https://github.com/zurielbm/acms-subir/releases" target="_blank" rel="noopener">What's new</a>
|
|
63
|
+
</div>
|
|
64
|
+
<script>
|
|
65
|
+
// Ghost-style update nudge: npm registry check, cached a day, silent on
|
|
66
|
+
// any failure (offline, unpublished package, CORS).
|
|
67
|
+
(async () => {
|
|
68
|
+
const el = document.getElementById('acms-update');
|
|
69
|
+
if (!el) return;
|
|
70
|
+
const current = el.dataset.current ?? '';
|
|
71
|
+
const KEY = 'acms-update-check';
|
|
72
|
+
let latest = '';
|
|
73
|
+
try {
|
|
74
|
+
const cached = JSON.parse(localStorage.getItem(KEY) ?? 'null');
|
|
75
|
+
if (cached && Date.now() - cached.at < 86_400_000) {
|
|
76
|
+
latest = cached.latest;
|
|
77
|
+
} else {
|
|
78
|
+
const res = await fetch('https://registry.npmjs.org/@acms-subir%2Fcore/latest');
|
|
79
|
+
if (!res.ok) return;
|
|
80
|
+
latest = (await res.json()).version ?? '';
|
|
81
|
+
localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), latest }));
|
|
82
|
+
}
|
|
83
|
+
} catch { return; }
|
|
84
|
+
const lt = (a: string, b: string) => {
|
|
85
|
+
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
|
86
|
+
for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
|
|
87
|
+
return false;
|
|
88
|
+
};
|
|
89
|
+
if (latest && lt(current, latest)) {
|
|
90
|
+
const slot = document.getElementById('acms-update-latest');
|
|
91
|
+
if (slot) slot.textContent = latest;
|
|
92
|
+
el.hidden = false;
|
|
93
|
+
el.style.display = 'flex';
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
{orphaned.length > 0 && (
|
|
99
|
+
<div class="card card-pad" style="margin-bottom:14px;border-left:3px solid var(--warn, #c77700)">
|
|
100
|
+
<b>Content in removed collections</b>
|
|
101
|
+
<p class="hint" style="margin:4px 0 0">
|
|
102
|
+
{orphaned.map(([key, n]) => `${n} ${n === 1 ? 'entry' : 'entries'} belong${n === 1 ? 's' : ''} to "${key}"`).join(' · ')}
|
|
103
|
+
— nothing was deleted; re-add the key under content.collections in astro.config to restore them.
|
|
104
|
+
</p>
|
|
105
|
+
</div>
|
|
106
|
+
)}
|
|
107
|
+
|
|
108
|
+
<div class="stats">
|
|
109
|
+
<div class="stat"><div class="label">Published pages</div><div class="value">{stats.publishedPages}</div></div>
|
|
110
|
+
{Object.entries(CONTENT.collections).map(([key, c]) => (
|
|
111
|
+
<div class="stat"><div class="label">{c.plural}</div><div class="value">{countByCollection.get(key) ?? 0}</div></div>
|
|
112
|
+
))}
|
|
113
|
+
<div class="stat"><div class="label">Drafts</div><div class="value">{stats.drafts}</div></div>
|
|
114
|
+
<div class="stat"><div class="label">Link clicks</div><div class="value">{stats.linkClicks}</div></div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div class="dash-grid">
|
|
118
|
+
<div class="card card-pad">
|
|
119
|
+
<p class="card-title">Recently edited</p>
|
|
120
|
+
<ul class="recent">
|
|
121
|
+
{recent.map((r) => (
|
|
122
|
+
<li>
|
|
123
|
+
<span class={`pill ${r.status}`}>{r.status}</span>
|
|
124
|
+
<a href={r.href}>{r.title}</a>
|
|
125
|
+
<span class="t-sub">{r.kind}</span>
|
|
126
|
+
<span class="when">{ago(r.at)}</span>
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
{recent.length === 0 && <li class="hint">Nothing yet — create a page or {CONTENT.posts.singular.toLowerCase()}.</li>}
|
|
130
|
+
</ul>
|
|
131
|
+
</div>
|
|
132
|
+
<div class="card card-pad">
|
|
133
|
+
<p class="card-title">Top links</p>
|
|
134
|
+
<ul class="recent">
|
|
135
|
+
{topLinks.map((l) => (
|
|
136
|
+
<li>
|
|
137
|
+
<span>{l.label}</span>
|
|
138
|
+
<span class="when">{l.clicks} clicks</span>
|
|
139
|
+
</li>
|
|
140
|
+
))}
|
|
141
|
+
{topLinks.length === 0 && <li class="hint">No links yet.</li>}
|
|
142
|
+
</ul>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</Admin>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { env } from '../../lib/env';
|
|
5
|
+
import LinksManager from '../../components/LinksManager';
|
|
6
|
+
// ImagePicker (profile photo) styles live in editor.css
|
|
7
|
+
import '../../styles/editor.css';
|
|
8
|
+
|
|
9
|
+
const repo = await getRepo(Astro.locals);
|
|
10
|
+
const [links, settings] = await Promise.all([repo.listLinks(), repo.getSettings()]);
|
|
11
|
+
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<Admin title="Links" section="links">
|
|
15
|
+
<div class="page-head">
|
|
16
|
+
<h1>Links</h1>
|
|
17
|
+
<a class="btn sm" href="/links" target="_blank" rel="noopener">View links page ↗</a>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<LinksManager client:only="react" links={links} settings={{ ...settings, draftsApiKey: '' }} unsplashEnabled={unsplashEnabled} />
|
|
21
|
+
</Admin>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { checkPassword, createSessionToken, verifyPassword, verifySessionToken, SESSION_COOKIE } from '../../lib/auth';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import { BRAND } from '../../lib/brand';
|
|
5
|
+
import '../../styles/admin.css';
|
|
6
|
+
|
|
7
|
+
if (verifySessionToken(Astro.cookies.get(SESSION_COOKIE)?.value)) {
|
|
8
|
+
return Astro.redirect('/admin');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let error = '';
|
|
12
|
+
if (Astro.request.method === 'POST') {
|
|
13
|
+
const form = await Astro.request.formData();
|
|
14
|
+
const email = String(form.get('email') ?? '').trim();
|
|
15
|
+
const password = String(form.get('password') ?? '');
|
|
16
|
+
|
|
17
|
+
// Email empty → the env master password (bootstrap owner). Email present →
|
|
18
|
+
// a user account created in /admin/users.
|
|
19
|
+
let sessionUserId: string | null = null;
|
|
20
|
+
if (!email && checkPassword(password)) {
|
|
21
|
+
sessionUserId = null; // default token = env owner
|
|
22
|
+
} else if (email) {
|
|
23
|
+
const repo = await getRepo(Astro.locals);
|
|
24
|
+
const auth = await repo.getUserAuth(email);
|
|
25
|
+
if (auth && (await verifyPassword(password, auth.passHash))) sessionUserId = auth.user.id;
|
|
26
|
+
else {
|
|
27
|
+
error = 'Wrong email or password.';
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
error = 'Wrong password.';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!error) {
|
|
34
|
+
Astro.cookies.set(SESSION_COOKIE, sessionUserId ? createSessionToken(sessionUserId) : createSessionToken(), {
|
|
35
|
+
httpOnly: true,
|
|
36
|
+
sameSite: 'lax',
|
|
37
|
+
path: '/',
|
|
38
|
+
maxAge: 7 * 24 * 60 * 60,
|
|
39
|
+
secure: Astro.url.protocol === 'https:',
|
|
40
|
+
});
|
|
41
|
+
return Astro.redirect('/admin');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
<!doctype html>
|
|
47
|
+
<html lang="en">
|
|
48
|
+
<head>
|
|
49
|
+
<meta charset="utf-8" />
|
|
50
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
51
|
+
<meta name="robots" content="noindex" />
|
|
52
|
+
<title>Sign in · {BRAND.name} admin</title>
|
|
53
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
54
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
55
|
+
</head>
|
|
56
|
+
<body>
|
|
57
|
+
<div class="login-wrap">
|
|
58
|
+
<div class="card card-pad login-card">
|
|
59
|
+
<div class="brand">
|
|
60
|
+
<svg width="26" height="26" viewBox="0 0 24 24" role="img" aria-label={BRAND.name}>
|
|
61
|
+
<rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="#2a2d36" />
|
|
62
|
+
<rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="#ee5d20" />
|
|
63
|
+
<rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="#2a2d36" opacity="0.42" />
|
|
64
|
+
</svg>
|
|
65
|
+
<div class="brand-name">{BRAND.name}</div>
|
|
66
|
+
</div>
|
|
67
|
+
{error && <div class="flash error">{error}</div>}
|
|
68
|
+
<form method="post">
|
|
69
|
+
<label class="field">
|
|
70
|
+
<span>Email</span>
|
|
71
|
+
<input type="email" name="email" autocomplete="username" placeholder="Leave empty for owner password" />
|
|
72
|
+
</label>
|
|
73
|
+
<label class="field">
|
|
74
|
+
<span>Password</span>
|
|
75
|
+
<input type="password" name="password" autofocus autocomplete="current-password" />
|
|
76
|
+
</label>
|
|
77
|
+
<button class="btn primary" style="width:100%;justify-content:center" type="submit">Sign in</button>
|
|
78
|
+
</form>
|
|
79
|
+
<p class="hint" style="margin-top:14px">No account? The owner password from <code>ADMIN_PASSWORD</code> in <code>.env</code> always works with email left empty.</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import MediaLibrary from '../../components/media/MediaLibrary';
|
|
4
|
+
import { getRepo } from '../../lib/repo';
|
|
5
|
+
import '../../styles/editor.css';
|
|
6
|
+
|
|
7
|
+
const repo = await getRepo(Astro.locals);
|
|
8
|
+
const media = await repo.listMedia();
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<Admin title="Media" section="media">
|
|
12
|
+
<MediaLibrary media={media} client:load />
|
|
13
|
+
</Admin>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
import Admin from '../../layouts/Admin.astro';
|
|
3
|
+
import { getRepo } from '../../lib/repo';
|
|
4
|
+
import NavManager from '../../components/NavManager';
|
|
5
|
+
|
|
6
|
+
const repo = await getRepo(Astro.locals);
|
|
7
|
+
const items = await repo.listNavItems();
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
<Admin title="Navigation" section="navigation">
|
|
11
|
+
<div class="page-head">
|
|
12
|
+
<h1>Navigation</h1>
|
|
13
|
+
<a class="btn sm" href="/" target="_blank" rel="noopener">View site ↗</a>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<NavManager client:only="react" items={items} />
|
|
17
|
+
</Admin>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getRepo } from '../../../lib/repo';
|
|
3
|
+
import { blocksToHtml } from '../../../lib/blocksToHtml';
|
|
4
|
+
import { createPreviewToken } from '../../../lib/preview';
|
|
5
|
+
import { resolveDesign } from '../../../lib/theme';
|
|
6
|
+
import { env } from '../../../lib/env';
|
|
7
|
+
import { BRAND } from '../../../lib/brand';
|
|
8
|
+
import PostEditor from '../../../components/editor/PostEditor';
|
|
9
|
+
import '../../../styles/admin.css';
|
|
10
|
+
import '../../../styles/editor.css';
|
|
11
|
+
|
|
12
|
+
const repo = await getRepo(Astro.locals);
|
|
13
|
+
const id = Astro.params.id!;
|
|
14
|
+
const page = await repo.getPage(id);
|
|
15
|
+
if (!page) return new Response(null, { status: 404 });
|
|
16
|
+
|
|
17
|
+
// Canonical content is the TipTap doc (as HTML). Legacy widget-block pages
|
|
18
|
+
// are converted on first open and become rich content on next save.
|
|
19
|
+
const initialHTML = page.bodyHtml || blocksToHtml(page.blocks);
|
|
20
|
+
const previewPath = `/preview/page/${page.id}`;
|
|
21
|
+
const sharePath = `${previewPath}?token=${createPreviewToken('page', page.id)}`;
|
|
22
|
+
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
23
|
+
const settings = await repo.getSettings();
|
|
24
|
+
const templates = Object.keys(resolveDesign(settings).theme.pageTemplates);
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<!doctype html>
|
|
28
|
+
<html lang="en">
|
|
29
|
+
<head>
|
|
30
|
+
<meta charset="utf-8" />
|
|
31
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
32
|
+
<meta name="robots" content="noindex" />
|
|
33
|
+
<title>{page.title || 'Untitled'} · {BRAND.name} editor</title>
|
|
34
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
35
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
36
|
+
</head>
|
|
37
|
+
<body>
|
|
38
|
+
<PostEditor
|
|
39
|
+
client:only="react"
|
|
40
|
+
kind="page"
|
|
41
|
+
page={page}
|
|
42
|
+
initialHTML={initialHTML}
|
|
43
|
+
unsplashEnabled={unsplashEnabled}
|
|
44
|
+
previewPath={previewPath}
|
|
45
|
+
sharePath={sharePath}
|
|
46
|
+
templates={templates}
|
|
47
|
+
/>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { I18N, localeHomeUrl } from '../../../lib/i18n';
|
|
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 title = String(form.get('title') || 'Untitled');
|
|
15
|
+
let slug = slugify(String(form.get('slug') || title));
|
|
16
|
+
// avoid slug collisions
|
|
17
|
+
let n = 1;
|
|
18
|
+
while (await repo.getPageBySlug(slug)) slug = `${slugify(title)}-${++n}`;
|
|
19
|
+
const page = await repo.createPage({ title, slug });
|
|
20
|
+
return Astro.redirect(`/admin/pages/${page.id}`);
|
|
21
|
+
}
|
|
22
|
+
if (op === 'delete') {
|
|
23
|
+
await repo.deletePage(String(form.get('id')));
|
|
24
|
+
return Astro.redirect('/admin/pages');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pages = await repo.listPages();
|
|
29
|
+
const showNew = Astro.url.searchParams.has('new');
|
|
30
|
+
const fmt = (ts: number) => new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
<Admin title="Pages" section="pages">
|
|
34
|
+
<div class="page-head">
|
|
35
|
+
<h1>Pages</h1>
|
|
36
|
+
<button class="btn primary" type="button" data-modal="new-page">New page</button>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<Modal id="new-page" title="New page" open={showNew}>
|
|
40
|
+
<form method="post">
|
|
41
|
+
<input type="hidden" name="op" value="create" />
|
|
42
|
+
<label class="field">
|
|
43
|
+
<span>Title</span>
|
|
44
|
+
<input type="text" name="title" placeholder="Page title" required autofocus data-slug-for="new-page-slug" />
|
|
45
|
+
</label>
|
|
46
|
+
<label class="field">
|
|
47
|
+
<span>Slug</span>
|
|
48
|
+
<div class="slug-wrap">
|
|
49
|
+
<span class="slug-prefix">/</span>
|
|
50
|
+
<input type="text" id="new-page-slug" name="slug" placeholder="auto from title" data-slug-dest />
|
|
51
|
+
</div>
|
|
52
|
+
</label>
|
|
53
|
+
<div class="modal-foot">
|
|
54
|
+
<button class="btn ghost" type="button" data-close>Cancel</button>
|
|
55
|
+
<button class="btn primary" type="submit">Create page</button>
|
|
56
|
+
</div>
|
|
57
|
+
<p class="hint" style="margin:12px 0 0">Creates a draft and opens the editor.</p>
|
|
58
|
+
</form>
|
|
59
|
+
</Modal>
|
|
60
|
+
|
|
61
|
+
<div class="card">
|
|
62
|
+
<table class="list">
|
|
63
|
+
<thead><tr><th>Title</th><th>Status</th><th>Updated</th><th></th></tr></thead>
|
|
64
|
+
<tbody>
|
|
65
|
+
{pages.map((p) => (
|
|
66
|
+
<tr>
|
|
67
|
+
<td>
|
|
68
|
+
<a class="t-title" href={`/admin/pages/${p.id}`}>{p.title}</a>
|
|
69
|
+
<div class="t-sub">{p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}`}</div>
|
|
70
|
+
</td>
|
|
71
|
+
<td>
|
|
72
|
+
<span class={`pill ${p.status}`}>{p.status}</span>
|
|
73
|
+
{p.isHome && <span class="pill home">home</span>}
|
|
74
|
+
{I18N.enabled && <span class="pill lang">{(p.locale || I18N.defaultLocale).toUpperCase()}</span>}
|
|
75
|
+
</td>
|
|
76
|
+
<td class="t-sub">{fmt(p.updatedAt)}</td>
|
|
77
|
+
<td style="text-align:right">
|
|
78
|
+
<form class="inline-form" method="post" onsubmit="return confirm('Delete this page?')">
|
|
79
|
+
<input type="hidden" name="op" value="delete" />
|
|
80
|
+
<input type="hidden" name="id" value={p.id} />
|
|
81
|
+
<button class="btn sm ghost danger" type="submit">Delete</button>
|
|
82
|
+
</form>
|
|
83
|
+
</td>
|
|
84
|
+
</tr>
|
|
85
|
+
))}
|
|
86
|
+
{pages.length === 0 && (
|
|
87
|
+
<tr>
|
|
88
|
+
<td colspan="4">
|
|
89
|
+
<div class="empty-state">
|
|
90
|
+
<b>No pages yet</b>
|
|
91
|
+
<p>Your site's static pages — about, contact, landing pages — live here.</p>
|
|
92
|
+
<button class="btn primary" type="button" data-modal="new-page">New page</button>
|
|
93
|
+
</div>
|
|
94
|
+
</td>
|
|
95
|
+
</tr>
|
|
96
|
+
)}
|
|
97
|
+
</tbody>
|
|
98
|
+
</table>
|
|
99
|
+
</div>
|
|
100
|
+
</Admin>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Legacy editor path for the built-in collection — 308 to the new home.
|
|
3
|
+
// /admin/c/[collection]/[id] re-canonicalizes if the post moved collections.
|
|
4
|
+
export const prerender = false;
|
|
5
|
+
return Astro.redirect(`/admin/c/posts/${Astro.params.id}${Astro.url.search}`, 308);
|
|
6
|
+
---
|