@acms-subir/core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/cli.mjs +63 -9
- package/integration.mjs +11 -0
- package/package.json +10 -9
- package/src/actions/index.ts +184 -6
- package/src/components/DesignManager.tsx +53 -17
- package/src/components/LinksManager.tsx +110 -48
- package/src/components/NavManager.tsx +83 -37
- package/src/components/editor/PostEditor.tsx +228 -55
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +70 -8
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/layouts/Site.astro +10 -1
- package/src/lib/analytics.ts +121 -0
- package/src/lib/designPreview.ts +14 -0
- package/src/lib/drafts.ts +77 -0
- package/src/lib/dynamicBlocks.ts +43 -3
- package/src/lib/repo/migrations.ts +37 -0
- package/src/lib/repo/sql.ts +127 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +53 -0
- package/src/pages/[...slug].astro +3 -1
- package/src/pages/admin/c/[collection]/[id].astro +10 -3
- package/src/pages/admin/c/[collection]/index.astro +1 -0
- package/src/pages/admin/design.astro +6 -1
- package/src/pages/admin/index.astro +5 -2
- package/src/pages/admin/pages/[id].astro +10 -2
- package/src/pages/admin/pages/index.astro +1 -0
- package/src/pages/admin/settings.astro +148 -0
- package/src/pages/blog/[slug].astro +4 -1
- package/src/pages/index.astro +4 -1
- package/src/pages/links.astro +10 -4
- package/src/pages/preview/[type]/[id].astro +9 -4
- package/src/styles/admin.css +4 -0
- package/src/styles/cards.css +23 -0
- package/src/styles/editor.css +53 -1
- package/src/styles/site.css +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c568c1d: Manual publish: nothing goes live until you press Update
|
|
8
|
+
|
|
9
|
+
- Posts & pages (incl. the homepage): editing a live (published or scheduled)
|
|
10
|
+
document no longer pushes autosaves to the public site. Edits stage in a
|
|
11
|
+
private pending draft (new `draft` column, migration v22); the editor shows
|
|
12
|
+
"Published · edited — Saved, not live yet", the publish popover gains
|
|
13
|
+
**Update — push changes live** and **Discard pending changes**, and admin
|
|
14
|
+
lists mark pending documents with an `edited` pill. `/preview/…` (and share
|
|
15
|
+
links) render the pending version — exactly what Update will publish.
|
|
16
|
+
Deferred side-effects: the slug-rename 301 and the search index only update
|
|
17
|
+
at Update time, so staged edits can never leak through search or move a
|
|
18
|
+
live URL early. Unpublishing folds pending edits into the document; the
|
|
19
|
+
scheduled-publish sweep publishes the live copy and keeps pending edits
|
|
20
|
+
pending. Drafts survive backup/restore.
|
|
21
|
+
- Design screen: theme, token, logo, favicon and share-image changes stage in
|
|
22
|
+
`settings.draftDesign` (migration v23). The preview iframe renders the
|
|
23
|
+
staged design via the admin-only `?designPreview=1` flag while visitors
|
|
24
|
+
keep the live one; **Publish design** applies it, **Discard** reverts.
|
|
25
|
+
- Navigation & Links managers: the auto-save debounce is gone. Edits stay
|
|
26
|
+
local with an "Unsaved changes — not live yet" bar; **Save changes**
|
|
27
|
+
commits everything at once (add/edit/delete/reorder + links profile),
|
|
28
|
+
**Discard** restores the saved state, and leaving the page with unsaved
|
|
29
|
+
edits warns first.
|
|
30
|
+
|
|
3
31
|
## 0.1.0
|
|
4
32
|
|
|
5
33
|
### Minor Changes
|
package/cli.mjs
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// `acms` — tiny maintenance CLI installed with @acms-subir/core.
|
|
3
3
|
// acms update check the registry, install the latest core, print what's new
|
|
4
4
|
// acms update --check just report whether an update exists (exit 1 if behind)
|
|
5
|
+
// acms update --next follow the canary channel (`next` dist-tag); sticky once installed
|
|
6
|
+
// acms update --latest leave the canary channel and go back to stable
|
|
5
7
|
// acms doctor sanity-check the project setup
|
|
6
8
|
// acms add cloudflare retrofit Workers deployment (wrangler.jsonc, deps, D1/R2)
|
|
7
9
|
// acms add docker retrofit self-hosting (Dockerfile, compose.yml)
|
|
@@ -23,16 +25,39 @@ const installedVersion = () => {
|
|
|
23
25
|
const p = installedPkgJson();
|
|
24
26
|
try { return p ? JSON.parse(readFileSync(p, 'utf8')).version : null; } catch { return null; }
|
|
25
27
|
};
|
|
26
|
-
const latestVersion = async () => {
|
|
27
|
-
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}
|
|
28
|
+
const latestVersion = async (tag = 'latest') => {
|
|
29
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PKG)}/${tag}`, {
|
|
28
30
|
signal: AbortSignal.timeout(10_000),
|
|
29
31
|
});
|
|
32
|
+
if (res.status === 404 && tag !== 'latest') {
|
|
33
|
+
throw Object.assign(new Error(`the '${tag}' channel has no releases yet`), { noTag: true });
|
|
34
|
+
}
|
|
30
35
|
if (!res.ok) throw new Error(`registry responded ${res.status}`);
|
|
31
36
|
return (await res.json()).version;
|
|
32
37
|
};
|
|
38
|
+
const splitPre = (v) => {
|
|
39
|
+
const i = v.indexOf('-');
|
|
40
|
+
return i === -1 ? [v, ''] : [v.slice(0, i), v.slice(i + 1)];
|
|
41
|
+
};
|
|
42
|
+
const isCanary = (v) => splitPre(v)[1] !== '';
|
|
33
43
|
const semverLt = (a, b) => {
|
|
34
|
-
const
|
|
44
|
+
const [aBase, aPre] = splitPre(a), [bBase, bPre] = splitPre(b);
|
|
45
|
+
const pa = aBase.split('.').map(Number), pb = bBase.split('.').map(Number);
|
|
35
46
|
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; }
|
|
47
|
+
// equal bases: a release outranks any of its prereleases; two prereleases
|
|
48
|
+
// compare identifier by identifier, numerically where both sides are numbers
|
|
49
|
+
if (!aPre) return false;
|
|
50
|
+
if (!bPre) return true;
|
|
51
|
+
const as = aPre.split('.'), bs = bPre.split('.');
|
|
52
|
+
for (let i = 0; i < Math.max(as.length, bs.length); i++) {
|
|
53
|
+
const x = as[i], y = bs[i];
|
|
54
|
+
if (x === undefined) return true;
|
|
55
|
+
if (y === undefined) return false;
|
|
56
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
|
57
|
+
if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) < Number(y); }
|
|
58
|
+
else if (nx !== ny) return nx;
|
|
59
|
+
else if (x !== y) return x < y;
|
|
60
|
+
}
|
|
36
61
|
return false;
|
|
37
62
|
};
|
|
38
63
|
|
|
@@ -65,18 +90,32 @@ if (cmd === 'update') {
|
|
|
65
90
|
const checkOnly = process.argv.includes('--check');
|
|
66
91
|
const current = installedVersion();
|
|
67
92
|
if (!current) { console.error(`✗ ${PKG} is not installed here — run this from your site's folder.`); process.exit(1); }
|
|
93
|
+
// Channel: --next opts into canary prereleases and is sticky — once a canary
|
|
94
|
+
// is installed, a plain `acms update` keeps following the next tag until
|
|
95
|
+
// --latest switches back to stable.
|
|
96
|
+
const backToStable = process.argv.includes('--latest') && isCanary(current);
|
|
97
|
+
const channel = process.argv.includes('--latest') ? 'latest'
|
|
98
|
+
: process.argv.includes('--next') || isCanary(current) ? 'next'
|
|
99
|
+
: 'latest';
|
|
68
100
|
let latest;
|
|
69
|
-
try { latest = await latestVersion(); } catch (e) {
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
try { latest = await latestVersion(channel); } catch (e) {
|
|
102
|
+
console.error(e.noTag ? `✗ ${e.message[0].toUpperCase()}${e.message.slice(1)}.` : `✗ Could not reach the npm registry (${e.message}).`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
// Leaving the canary channel is usually a downgrade (0.1.1-next.x → 0.1.0),
|
|
106
|
+
// so "am I behind" is the wrong question there — switch unconditionally.
|
|
107
|
+
if (!backToStable && !semverLt(current, latest)) {
|
|
108
|
+
console.log(`✓ Already up to date (${current}${channel === 'next' ? ', canary channel' : ''}).`);
|
|
72
109
|
process.exit(0);
|
|
73
110
|
}
|
|
74
111
|
if (checkOnly) {
|
|
75
|
-
console.log(`Update available: ${current} → ${latest}. Run: npx acms update`);
|
|
112
|
+
console.log(`Update available: ${current} → ${latest}. Run: npx acms update${channel === 'next' ? ' --next' : backToStable ? ' --latest' : ''}`);
|
|
76
113
|
process.exit(1);
|
|
77
114
|
}
|
|
78
|
-
console.log(
|
|
79
|
-
|
|
115
|
+
console.log(backToStable
|
|
116
|
+
? `Returning to the stable channel: ${PKG} ${current} → ${latest}…`
|
|
117
|
+
: `Updating ${PKG} ${current} → ${latest}${channel === 'next' ? ' (canary)' : ''}…`);
|
|
118
|
+
const res = spawnSync('npm', ['install', `${PKG}@${channel}`, '--no-audit', '--no-fund'], { stdio: 'inherit' });
|
|
80
119
|
if (res.status !== 0) process.exit(res.status ?? 1);
|
|
81
120
|
console.log(`\n✓ Updated to ${latest}.`);
|
|
82
121
|
const notes = releaseNotes(changelogFor(installedPkgJson()), current, latest);
|
|
@@ -86,9 +125,14 @@ if (cmd === 'update') {
|
|
|
86
125
|
console.log(`\n ${PKG} ${r.version}`);
|
|
87
126
|
if (r.body) console.log(r.body.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
88
127
|
}
|
|
128
|
+
} else if (channel === 'next') {
|
|
129
|
+
console.log(`\nWhat's new: canary build — notes arrive with the next stable release.`);
|
|
89
130
|
} else {
|
|
90
131
|
console.log(`\nWhat's new: https://www.npmjs.com/package/${PKG}/v/${latest}`);
|
|
91
132
|
}
|
|
133
|
+
if (channel === 'next') {
|
|
134
|
+
console.log(`\nYou're on the canary channel; \`npx acms update --latest\` returns to stable.`);
|
|
135
|
+
}
|
|
92
136
|
const t = deployTargets();
|
|
93
137
|
const steps = [
|
|
94
138
|
...(t.cloudflare ? [' npm run deploy # Cloudflare Workers'] : []),
|
|
@@ -269,6 +313,14 @@ data
|
|
|
269
313
|
.dev.vars
|
|
270
314
|
.wrangler
|
|
271
315
|
.git
|
|
316
|
+
`);
|
|
317
|
+
writeNew('.env.example', `# Copy to .env and fill in real values — compose.yml reads .env automatically.
|
|
318
|
+
# .env itself is gitignored; this file is the committed record of what to set.
|
|
319
|
+
ADMIN_PASSWORD=change-me
|
|
320
|
+
# Random secret used to sign the session cookie (openssl rand -hex 32)
|
|
321
|
+
SESSION_SECRET=
|
|
322
|
+
# SQLite file used by the Node target (a volume mounts /app/data in Docker)
|
|
323
|
+
SQLITE_URL=file:./data/acms.db
|
|
272
324
|
`);
|
|
273
325
|
setScript('build:node', 'DEPLOY_TARGET=node astro build');
|
|
274
326
|
writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
@@ -309,6 +361,8 @@ data
|
|
|
309
361
|
Usage:
|
|
310
362
|
acms update Update the CMS core to the latest release
|
|
311
363
|
acms update --check Report whether an update exists (exit 1 if behind)
|
|
364
|
+
acms update --next Follow the canary channel (prereleases); later updates stay on it
|
|
365
|
+
acms update --latest Leave the canary channel and return to stable releases
|
|
312
366
|
acms doctor Check this project's setup
|
|
313
367
|
acms add cloudflare Add Workers deployment to this project (--provision creates D1 + R2)
|
|
314
368
|
acms add docker Add Docker self-hosting to this project`);
|
package/integration.mjs
CHANGED
|
@@ -357,6 +357,17 @@ export default function acms(options = {}) {
|
|
|
357
357
|
vite: { resolve: { alias: { 'react-dom/server': 'react-dom/server.edge' } } },
|
|
358
358
|
});
|
|
359
359
|
}
|
|
360
|
+
// The Docker runtime image installs only @libsql/client, so the
|
|
361
|
+
// standalone bundle must carry every other dependency. Left to its
|
|
362
|
+
// default, Vite externalizes package imports (react-dom, astro's
|
|
363
|
+
// runtime, zod, marked, …) and the server crashes on its first
|
|
364
|
+
// import in the container. @libsql/client stays external: it is
|
|
365
|
+
// dynamically imported and ships native bindings.
|
|
366
|
+
if (target === 'node' && command === 'build') {
|
|
367
|
+
updateConfig({
|
|
368
|
+
vite: { ssr: { noExternal: true, external: ['@libsql/client'] } },
|
|
369
|
+
});
|
|
370
|
+
}
|
|
360
371
|
},
|
|
361
372
|
// fires only for `astro dev` — builds and previews never phone anywhere
|
|
362
373
|
'astro:server:start': ({ logger }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acms-subir/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"workers"
|
|
23
23
|
],
|
|
24
24
|
"bin": {
|
|
25
|
-
"acms": "
|
|
25
|
+
"acms": "cli.mjs"
|
|
26
26
|
},
|
|
27
27
|
"exports": {
|
|
28
28
|
".": "./integration.mjs",
|
|
@@ -48,13 +48,14 @@
|
|
|
48
48
|
"@dnd-kit/sortable": "^10.0.0",
|
|
49
49
|
"@dnd-kit/utilities": "^3.2.2",
|
|
50
50
|
"@libsql/client": "^0.15.0",
|
|
51
|
-
"@tiptap/extension-code-block-lowlight": "^3.
|
|
52
|
-
"@tiptap/extension-
|
|
53
|
-
"@tiptap/
|
|
54
|
-
"@tiptap/
|
|
55
|
-
"@tiptap/
|
|
56
|
-
"@tiptap/
|
|
57
|
-
"@tiptap/
|
|
51
|
+
"@tiptap/extension-code-block-lowlight": "^3.30.1",
|
|
52
|
+
"@tiptap/extension-highlight": "3.30.1",
|
|
53
|
+
"@tiptap/extension-table": "^3.30.1",
|
|
54
|
+
"@tiptap/extensions": "^3.30.1",
|
|
55
|
+
"@tiptap/pm": "^3.30.1",
|
|
56
|
+
"@tiptap/react": "^3.30.1",
|
|
57
|
+
"@tiptap/starter-kit": "^3.30.1",
|
|
58
|
+
"@tiptap/suggestion": "^3.30.1",
|
|
58
59
|
"hast-util-to-html": "^9.0.5",
|
|
59
60
|
"lowlight": "^3.3.0",
|
|
60
61
|
"marked": "^15.0.0",
|
package/src/actions/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { isPrivateHost } from '../lib/net';
|
|
|
12
12
|
import { normalizeCalendarSrc } from '../lib/calendar/src';
|
|
13
13
|
import { getCalendarEvents } from '../lib/calendar/ics';
|
|
14
14
|
import { siteLocale } from '../lib/dates';
|
|
15
|
-
import { normalizePageLocale } from '../lib/i18n';
|
|
15
|
+
import { normalizePageLocale, localeHomeUrl } from '../lib/i18n';
|
|
16
16
|
|
|
17
17
|
// All admin mutations live here. The middleware only guards /admin pages,
|
|
18
18
|
// so every action re-checks the session itself.
|
|
@@ -71,8 +71,17 @@ export const server = {
|
|
|
71
71
|
if (data.collection !== undefined && !CONTENT.collections[data.collection]) {
|
|
72
72
|
throw new ActionError({ code: 'BAD_REQUEST', message: `Unknown collection "${data.collection}".` });
|
|
73
73
|
}
|
|
74
|
-
const
|
|
75
|
-
|
|
74
|
+
const before = await repo.getPost(id);
|
|
75
|
+
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
76
|
+
// A live (published/scheduled) post never takes content edits
|
|
77
|
+
// directly: they stage into the pending draft until the user hits
|
|
78
|
+
// Update. Status transitions stay deliberate, immediate writes.
|
|
79
|
+
const transitions = data.status !== undefined && data.status !== before.status;
|
|
80
|
+
if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
|
|
81
|
+
const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
|
|
82
|
+
await repo.savePostDraft(id, staged);
|
|
83
|
+
return { post: (await repo.getPost(id))! };
|
|
84
|
+
}
|
|
76
85
|
await repo.updatePost(id, data);
|
|
77
86
|
// any new tag names become registry entries with slugs for archives
|
|
78
87
|
if (data.tags !== undefined) await repo.ensureTags(data.tags);
|
|
@@ -80,7 +89,32 @@ export const server = {
|
|
|
80
89
|
if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
81
90
|
// a published post whose slug or collection changed leaves a 301
|
|
82
91
|
// behind so old links keep working
|
|
83
|
-
if (before
|
|
92
|
+
if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
|
|
93
|
+
await repo
|
|
94
|
+
.createRedirect({
|
|
95
|
+
fromPath: collectionUrl(before.collection, before.slug),
|
|
96
|
+
toPath: collectionUrl(post.collection, post.slug),
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {});
|
|
99
|
+
invalidateRedirectCache();
|
|
100
|
+
}
|
|
101
|
+
return { post };
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
// Update button: copy the pending draft onto the live columns. The
|
|
105
|
+
// deferred side-effects (tag registry, slug-change 301) happen here —
|
|
106
|
+
// the moment the change actually goes live.
|
|
107
|
+
applyUpdate: defineAction({
|
|
108
|
+
input: z.object({ id: z.string() }),
|
|
109
|
+
handler: async ({ id }, ctx) => {
|
|
110
|
+
requireAdmin(ctx);
|
|
111
|
+
const repo = await getRepo(ctx.locals);
|
|
112
|
+
const before = await repo.getPost(id);
|
|
113
|
+
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
114
|
+
await repo.applyPostDraft(id);
|
|
115
|
+
const post = (await repo.getPost(id))!;
|
|
116
|
+
if (post.tags.length) await repo.ensureTags(post.tags);
|
|
117
|
+
if (before.status === 'published' && (before.slug !== post.slug || before.collection !== post.collection)) {
|
|
84
118
|
await repo
|
|
85
119
|
.createRedirect({
|
|
86
120
|
fromPath: collectionUrl(before.collection, before.slug),
|
|
@@ -92,6 +126,17 @@ export const server = {
|
|
|
92
126
|
return { post };
|
|
93
127
|
},
|
|
94
128
|
}),
|
|
129
|
+
discardChanges: defineAction({
|
|
130
|
+
input: z.object({ id: z.string() }),
|
|
131
|
+
handler: async ({ id }, ctx) => {
|
|
132
|
+
requireAdmin(ctx);
|
|
133
|
+
const repo = await getRepo(ctx.locals);
|
|
134
|
+
await repo.discardPostDraft(id);
|
|
135
|
+
const post = await repo.getPost(id);
|
|
136
|
+
if (!post) throw new ActionError({ code: 'NOT_FOUND', message: 'Post not found.' });
|
|
137
|
+
return { post };
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
95
140
|
delete: defineAction({
|
|
96
141
|
input: z.object({ id: z.string() }),
|
|
97
142
|
handler: async ({ id }, ctx) => {
|
|
@@ -125,24 +170,59 @@ export const server = {
|
|
|
125
170
|
if (data.slug !== undefined) data.slug = slugify(data.slug);
|
|
126
171
|
// default locale stores as '', unknown codes fall back to it
|
|
127
172
|
if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
|
|
128
|
-
const before =
|
|
173
|
+
const before = await repo.getPage(id);
|
|
174
|
+
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
129
175
|
const cleanBlocks =
|
|
130
176
|
blocks === undefined
|
|
131
177
|
? undefined
|
|
132
178
|
: blocks
|
|
133
179
|
.filter((b) => widgetTypes.includes(b.type as WidgetType))
|
|
134
180
|
.map((b) => ({ ...b, type: b.type as WidgetType, config: parseConfig(b.type as WidgetType, b.config) as Record<string, unknown> }));
|
|
181
|
+
// live pages stage their edits until Update, same as posts
|
|
182
|
+
const transitions = data.status !== undefined && data.status !== before.status;
|
|
183
|
+
if ((before.status === 'published' || before.status === 'scheduled') && !transitions) {
|
|
184
|
+
const { status: _status, ...staged } = data;
|
|
185
|
+
await repo.savePageDraft(id, { ...staged, blocks: cleanBlocks });
|
|
186
|
+
return { page: (await repo.getPage(id))! };
|
|
187
|
+
}
|
|
135
188
|
await repo.updatePage(id, { ...data, blocks: cleanBlocks });
|
|
136
189
|
const page = await repo.getPage(id);
|
|
137
190
|
if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
138
191
|
// renaming a published slug leaves a 301 behind so old links keep working
|
|
139
|
-
if (before
|
|
192
|
+
if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
|
|
140
193
|
await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
|
|
141
194
|
invalidateRedirectCache();
|
|
142
195
|
}
|
|
143
196
|
return { page };
|
|
144
197
|
},
|
|
145
198
|
}),
|
|
199
|
+
applyUpdate: defineAction({
|
|
200
|
+
input: z.object({ id: z.string() }),
|
|
201
|
+
handler: async ({ id }, ctx) => {
|
|
202
|
+
requireAdmin(ctx);
|
|
203
|
+
const repo = await getRepo(ctx.locals);
|
|
204
|
+
const before = await repo.getPage(id);
|
|
205
|
+
if (!before) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
206
|
+
await repo.applyPageDraft(id);
|
|
207
|
+
const page = (await repo.getPage(id))!;
|
|
208
|
+
if (before.status === 'published' && before.slug !== page.slug && !page.isHome) {
|
|
209
|
+
await repo.createRedirect({ fromPath: `/${before.slug}`, toPath: `/${page.slug}` }).catch(() => {});
|
|
210
|
+
invalidateRedirectCache();
|
|
211
|
+
}
|
|
212
|
+
return { page };
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
discardChanges: defineAction({
|
|
216
|
+
input: z.object({ id: z.string() }),
|
|
217
|
+
handler: async ({ id }, ctx) => {
|
|
218
|
+
requireAdmin(ctx);
|
|
219
|
+
const repo = await getRepo(ctx.locals);
|
|
220
|
+
await repo.discardPageDraft(id);
|
|
221
|
+
const page = await repo.getPage(id);
|
|
222
|
+
if (!page) throw new ActionError({ code: 'NOT_FOUND', message: 'Page not found.' });
|
|
223
|
+
return { page };
|
|
224
|
+
},
|
|
225
|
+
}),
|
|
146
226
|
delete: defineAction({
|
|
147
227
|
input: z.object({ id: z.string() }),
|
|
148
228
|
handler: async ({ id }, ctx) => {
|
|
@@ -309,6 +389,41 @@ export const server = {
|
|
|
309
389
|
return { ok: true };
|
|
310
390
|
},
|
|
311
391
|
}),
|
|
392
|
+
// Design-screen staging: edits stage in settings.draftDesign (previewed
|
|
393
|
+
// via ?designPreview=1) until "Publish design" applies them live.
|
|
394
|
+
saveDesignDraft: defineAction({
|
|
395
|
+
input: z.object({
|
|
396
|
+
activeTheme: z.string().optional(),
|
|
397
|
+
designOverrides: z.record(z.any()).optional(),
|
|
398
|
+
siteLogo: z.string().optional(),
|
|
399
|
+
siteFavicon: z.string().optional(),
|
|
400
|
+
siteCover: z.string().optional(),
|
|
401
|
+
}),
|
|
402
|
+
handler: async (data, ctx) => {
|
|
403
|
+
requireAdmin(ctx);
|
|
404
|
+
const repo = await getRepo(ctx.locals);
|
|
405
|
+
await repo.saveDesignDraft(data);
|
|
406
|
+
return { ok: true };
|
|
407
|
+
},
|
|
408
|
+
}),
|
|
409
|
+
applyDesign: defineAction({
|
|
410
|
+
input: z.object({}),
|
|
411
|
+
handler: async (_input, ctx) => {
|
|
412
|
+
requireAdmin(ctx);
|
|
413
|
+
const repo = await getRepo(ctx.locals);
|
|
414
|
+
await repo.applyDesignDraft();
|
|
415
|
+
return { ok: true };
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
discardDesign: defineAction({
|
|
419
|
+
input: z.object({}),
|
|
420
|
+
handler: async (_input, ctx) => {
|
|
421
|
+
requireAdmin(ctx);
|
|
422
|
+
const repo = await getRepo(ctx.locals);
|
|
423
|
+
await repo.discardDesignDraft();
|
|
424
|
+
return { ok: true };
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
312
427
|
},
|
|
313
428
|
|
|
314
429
|
tags: {
|
|
@@ -322,6 +437,41 @@ export const server = {
|
|
|
322
437
|
}),
|
|
323
438
|
},
|
|
324
439
|
|
|
440
|
+
snippets: {
|
|
441
|
+
list: defineAction({
|
|
442
|
+
input: z.object({}),
|
|
443
|
+
handler: async (_input, ctx) => {
|
|
444
|
+
requireAdmin(ctx);
|
|
445
|
+
const repo = await getRepo(ctx.locals);
|
|
446
|
+
return { snippets: await repo.listSnippets() };
|
|
447
|
+
},
|
|
448
|
+
}),
|
|
449
|
+
create: defineAction({
|
|
450
|
+
input: z.object({ name: z.string().min(1).max(80), contentJson: z.string().min(2) }),
|
|
451
|
+
handler: async ({ name, contentJson }, ctx) => {
|
|
452
|
+
requireAdmin(ctx);
|
|
453
|
+
// must be a JSON array of ProseMirror nodes — reject anything else
|
|
454
|
+
// before it can break the editor on re-insert
|
|
455
|
+
let parsed: unknown;
|
|
456
|
+
try { parsed = JSON.parse(contentJson); } catch { parsed = null; }
|
|
457
|
+
if (!Array.isArray(parsed) || !parsed.length) {
|
|
458
|
+
throw new ActionError({ code: 'BAD_REQUEST', message: 'Select some content first.' });
|
|
459
|
+
}
|
|
460
|
+
const repo = await getRepo(ctx.locals);
|
|
461
|
+
return { snippet: await repo.createSnippet({ name, contentJson }) };
|
|
462
|
+
},
|
|
463
|
+
}),
|
|
464
|
+
delete: defineAction({
|
|
465
|
+
input: z.object({ id: z.string() }),
|
|
466
|
+
handler: async ({ id }, ctx) => {
|
|
467
|
+
requireAdmin(ctx);
|
|
468
|
+
const repo = await getRepo(ctx.locals);
|
|
469
|
+
await repo.deleteSnippet(id);
|
|
470
|
+
return { ok: true };
|
|
471
|
+
},
|
|
472
|
+
}),
|
|
473
|
+
},
|
|
474
|
+
|
|
325
475
|
users: {
|
|
326
476
|
list: defineAction({
|
|
327
477
|
input: z.object({}),
|
|
@@ -333,6 +483,34 @@ export const server = {
|
|
|
333
483
|
}),
|
|
334
484
|
},
|
|
335
485
|
|
|
486
|
+
linkTargets: {
|
|
487
|
+
// ⌘K internal link search: published posts + pages matched by title or
|
|
488
|
+
// URL. Drafts are excluded on purpose — linking to one would 404 until
|
|
489
|
+
// it's published.
|
|
490
|
+
search: defineAction({
|
|
491
|
+
input: z.object({ query: z.string().min(1) }),
|
|
492
|
+
handler: async ({ query }, ctx) => {
|
|
493
|
+
requireAdmin(ctx);
|
|
494
|
+
const repo = await getRepo(ctx.locals);
|
|
495
|
+
const q = query.toLowerCase();
|
|
496
|
+
const [posts, pages] = await Promise.all([repo.listPosts(), repo.listPages()]);
|
|
497
|
+
const targets = [
|
|
498
|
+
...pages
|
|
499
|
+
.filter((p) => p.status === 'published')
|
|
500
|
+
.map((p) => ({ kind: 'Page', title: p.title, url: p.isHome ? localeHomeUrl(p.locale) : `/${p.slug}` })),
|
|
501
|
+
...posts
|
|
502
|
+
.filter((p) => p.status === 'published')
|
|
503
|
+
.map((p) => ({
|
|
504
|
+
kind: (CONTENT.collections[p.collection] ?? CONTENT.posts).singular,
|
|
505
|
+
title: p.title,
|
|
506
|
+
url: collectionUrl(p.collection, p.slug),
|
|
507
|
+
})),
|
|
508
|
+
].filter((t) => t.title.toLowerCase().includes(q) || t.url.toLowerCase().includes(q));
|
|
509
|
+
return { targets: targets.slice(0, 8) };
|
|
510
|
+
},
|
|
511
|
+
}),
|
|
512
|
+
},
|
|
513
|
+
|
|
336
514
|
bookmark: {
|
|
337
515
|
// Server-side link-metadata fetch for the bookmark card (no CORS in the
|
|
338
516
|
// browser). Fetches the page, scrapes OG/meta tags, returns a compact
|
|
@@ -7,6 +7,10 @@ import { ImagePicker } from './editor/ImagePicker';
|
|
|
7
7
|
// token overrides on top (saved to settings.designOverrides — the theme files
|
|
8
8
|
// are never written to), set the site logo, and export the effective design
|
|
9
9
|
// as a committable theme.json.
|
|
10
|
+
//
|
|
11
|
+
// Every change stages into settings.draftDesign — the preview iframe renders
|
|
12
|
+
// the staged design (?designPreview=1, admin-only) while visitors keep the
|
|
13
|
+
// live one until "Publish design" applies it.
|
|
10
14
|
|
|
11
15
|
type SaveState = 'saved' | 'saving' | 'error';
|
|
12
16
|
|
|
@@ -33,6 +37,8 @@ interface Pairing { key: string; label: string; headingFont: string; bodyFont: s
|
|
|
33
37
|
|
|
34
38
|
interface Props {
|
|
35
39
|
settings: Settings;
|
|
40
|
+
// true when settings.draftDesign held staged changes at load time
|
|
41
|
+
pendingDesign: boolean;
|
|
36
42
|
themes: ThemeInfo[];
|
|
37
43
|
pairings: Pairing[];
|
|
38
44
|
unsplashEnabled: boolean;
|
|
@@ -80,7 +86,8 @@ const DEVICES = [
|
|
|
80
86
|
] as const;
|
|
81
87
|
type DeviceKey = (typeof DEVICES)[number]['key'];
|
|
82
88
|
|
|
83
|
-
export default function DesignManager({ settings, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
|
|
89
|
+
export default function DesignManager({ settings, pendingDesign, themes, pairings, unsplashEnabled, postPreviewPath }: Props) {
|
|
90
|
+
const [pending, setPending] = useState(pendingDesign);
|
|
84
91
|
const [activeTheme, setActiveTheme] = useState(settings.activeTheme || 'ink');
|
|
85
92
|
const [overrides, setOverrides] = useState<Record<string, unknown>>(
|
|
86
93
|
(settings.designOverrides as Record<string, unknown>) ?? {}
|
|
@@ -113,22 +120,45 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
|
|
|
113
120
|
|
|
114
121
|
const theme = themes.find((t) => t.slug === activeTheme) ?? themes[0];
|
|
115
122
|
|
|
123
|
+
// saves stage into settings.draftDesign — the live site is untouched
|
|
124
|
+
const doSave = async () => {
|
|
125
|
+
const { activeTheme, overrides, siteLogo, siteFavicon, siteCover } = latest.current;
|
|
126
|
+
const { error } = await actions.settings.saveDesignDraft({
|
|
127
|
+
activeTheme,
|
|
128
|
+
designOverrides: overrides,
|
|
129
|
+
siteLogo,
|
|
130
|
+
siteFavicon,
|
|
131
|
+
siteCover,
|
|
132
|
+
});
|
|
133
|
+
if (error) { setSaveState('error'); return false; }
|
|
134
|
+
setSaveState('saved');
|
|
135
|
+
setPending(true);
|
|
136
|
+
setTick((t) => t + 1);
|
|
137
|
+
return true;
|
|
138
|
+
};
|
|
139
|
+
|
|
116
140
|
const scheduleSave = () => {
|
|
117
141
|
setSaveState('saving');
|
|
118
142
|
if (timer.current) clearTimeout(timer.current);
|
|
119
|
-
timer.current = setTimeout(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
timer.current = setTimeout(doSave, 500);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Publish design: flush any queued save into the staged blob, then apply it
|
|
147
|
+
const publishDesign = async () => {
|
|
148
|
+
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
|
149
|
+
if (!(await doSave())) return;
|
|
150
|
+
const { error } = await actions.settings.applyDesign({});
|
|
151
|
+
if (error) { setSaveState('error'); return; }
|
|
152
|
+
setPending(false);
|
|
153
|
+
setTick((t) => t + 1);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const discardDesign = async () => {
|
|
157
|
+
if (!window.confirm('Discard the staged design changes and go back to the live design? This cannot be undone.')) return;
|
|
158
|
+
if (timer.current) { clearTimeout(timer.current); timer.current = null; }
|
|
159
|
+
const { error } = await actions.settings.discardDesign({});
|
|
160
|
+
if (error) { setSaveState('error'); return; }
|
|
161
|
+
location.reload(); // rehydrate the controls from the live design
|
|
132
162
|
};
|
|
133
163
|
|
|
134
164
|
const setOv = (key: string, value: unknown) => {
|
|
@@ -186,7 +216,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
|
|
|
186
216
|
<div className="dm-cardhead">
|
|
187
217
|
<p className="dm-sub">
|
|
188
218
|
Active theme: <b>{theme.name}</b> · loaded from <code>src/themes/{theme.slug}/</code> —
|
|
189
|
-
values below override it.
|
|
219
|
+
values below override it. Changes stage privately in the preview; press <b>Publish design</b> to go live.
|
|
190
220
|
</p>
|
|
191
221
|
<div className="dm-headactions">
|
|
192
222
|
{overrideCount > 0 && (
|
|
@@ -195,8 +225,14 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
|
|
|
195
225
|
</button>
|
|
196
226
|
)}
|
|
197
227
|
<span className={`lm-savestate ${saveState}`}>
|
|
198
|
-
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : '
|
|
228
|
+
{saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : pending ? 'Staged — not live yet' : 'Live design'}
|
|
199
229
|
</span>
|
|
230
|
+
{pending && (
|
|
231
|
+
<>
|
|
232
|
+
<button className="btn sm ghost" type="button" onClick={discardDesign}>Discard</button>
|
|
233
|
+
<button className="btn sm primary" type="button" onClick={publishDesign}>Publish design</button>
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
200
236
|
</div>
|
|
201
237
|
</div>
|
|
202
238
|
|
|
@@ -494,7 +530,7 @@ export default function DesignManager({ settings, themes, pairings, unsplashEnab
|
|
|
494
530
|
<div className="dm-frame" ref={frameRef}>
|
|
495
531
|
<iframe
|
|
496
532
|
key={`${previewPage}-${tick}`}
|
|
497
|
-
src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?design=${tick}`}
|
|
533
|
+
src={`${previewPage === 'post' && postPreviewPath ? postPreviewPath : '/'}?designPreview=1&design=${tick}`}
|
|
498
534
|
title="Site preview"
|
|
499
535
|
style={{
|
|
500
536
|
width: device.width,
|