@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,721 @@
|
|
|
1
|
+
import type { ContentRepository, Stats } from './types';
|
|
2
|
+
import { Page, Post, Link, Media, NavItem, Redirect, Settings, Tag, User, normalizeRedirectPath, slugify, type WidgetInstance } from '../schema';
|
|
3
|
+
import { MIGRATIONS, MIGRATIONS_TABLE } from './migrations';
|
|
4
|
+
import { rankByQuery } from './search';
|
|
5
|
+
import { SEED } from '../brand';
|
|
6
|
+
import { CONTENT, postsIndexUrl } from '../content';
|
|
7
|
+
|
|
8
|
+
// Shared SQL implementation of ContentRepository. Cloudflare D1 and libSQL
|
|
9
|
+
// are both SQLite, so every statement, mapper and behavior lives here once;
|
|
10
|
+
// the adapters (d1.ts / sqlite.ts) only supply a thin SqlDriver over their
|
|
11
|
+
// respective client APIs.
|
|
12
|
+
|
|
13
|
+
type Row = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
export interface SqlStatement {
|
|
16
|
+
sql: string;
|
|
17
|
+
args?: unknown[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SqlDriver {
|
|
21
|
+
all(sql: string, args?: unknown[]): Promise<Row[]>;
|
|
22
|
+
run(sql: string, args?: unknown[]): Promise<void>;
|
|
23
|
+
// atomic where the backend supports it (D1 batch / libSQL write batch)
|
|
24
|
+
batch(stmts: SqlStatement[]): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function rowToPage(r: Row): Page {
|
|
28
|
+
return Page.parse({
|
|
29
|
+
id: r.id, slug: r.slug, title: r.title, status: r.status,
|
|
30
|
+
isHome: Boolean(r.is_home), locale: r.locale ?? '',
|
|
31
|
+
blocks: JSON.parse(String(r.blocks ?? '[]')),
|
|
32
|
+
bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
|
|
33
|
+
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '',
|
|
34
|
+
template: r.template ?? '',
|
|
35
|
+
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
function rowToPost(r: Row, authorIds?: string[]): Post {
|
|
39
|
+
return Post.parse({
|
|
40
|
+
id: r.id, collection: r.collection ?? 'posts', slug: r.slug, title: r.title, body: r.body ?? '',
|
|
41
|
+
bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '', excerpt: r.excerpt ?? '',
|
|
42
|
+
tags: JSON.parse(String(r.tags ?? '[]')), cover: r.cover ?? '', coverAlt: r.cover_alt ?? '',
|
|
43
|
+
seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
|
|
44
|
+
featured: Boolean(r.featured), authorId: r.author_id ?? null,
|
|
45
|
+
authorIds: authorIds ?? (r.author_id ? [String(r.author_id)] : []),
|
|
46
|
+
template: r.template ?? '',
|
|
47
|
+
publishedAt: r.published_at === null ? null : Number(r.published_at),
|
|
48
|
+
scheduledAt: r.scheduled_at === null ? null : Number(r.scheduled_at),
|
|
49
|
+
createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function rowToTag(r: Row): Tag {
|
|
53
|
+
return Tag.parse({ id: r.id, name: r.name, slug: r.slug, description: r.description ?? '' });
|
|
54
|
+
}
|
|
55
|
+
function rowToUser(r: Row): User {
|
|
56
|
+
return User.parse({
|
|
57
|
+
id: r.id, name: r.name, email: r.email, role: r.role,
|
|
58
|
+
bio: r.bio ?? '', avatar: r.avatar ?? '', slug: r.slug ?? '',
|
|
59
|
+
createdAt: Number(r.created_at),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function rowToMedia(r: Row): Media {
|
|
63
|
+
return Media.parse({
|
|
64
|
+
id: r.id, key: r.key, filename: r.filename, mime: r.mime,
|
|
65
|
+
width: Number(r.width), height: Number(r.height), size: Number(r.size),
|
|
66
|
+
alt: r.alt ?? '', variants: JSON.parse(String(r.variants ?? '[]')),
|
|
67
|
+
createdAt: Number(r.created_at),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function rowToLink(r: Row): Link {
|
|
71
|
+
return Link.parse({
|
|
72
|
+
id: r.id, kind: r.kind ?? 'link', label: r.label, url: r.url, icon: r.icon ?? '',
|
|
73
|
+
html: r.html ?? '', meta: JSON.parse(String(r.meta ?? '{}')),
|
|
74
|
+
order: Number(r.ord), clicks: Number(r.clicks),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class SqlRepository implements ContentRepository {
|
|
79
|
+
private driver!: SqlDriver;
|
|
80
|
+
private ready: Promise<void>;
|
|
81
|
+
|
|
82
|
+
constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
83
|
+
this.ready = this.init(driver);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async init(driver: SqlDriver | (() => Promise<SqlDriver>)) {
|
|
87
|
+
this.driver = typeof driver === 'function' ? await driver() : driver;
|
|
88
|
+
await this.migrate();
|
|
89
|
+
await this.seed();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async first(sql: string, args?: unknown[]): Promise<Row | null> {
|
|
93
|
+
const rows = await this.driver.all(sql, args);
|
|
94
|
+
return rows[0] ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async migrate() {
|
|
98
|
+
await this.driver.run(MIGRATIONS_TABLE);
|
|
99
|
+
const r = await this.first('SELECT MAX(version) AS v FROM migrations');
|
|
100
|
+
let applied = Number(r?.v ?? 0);
|
|
101
|
+
if (applied === 0) {
|
|
102
|
+
// Pre-migrations DB: the baseline tables already exist, mark v1 applied.
|
|
103
|
+
const baseline = await this.first("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'pages'");
|
|
104
|
+
if (baseline) {
|
|
105
|
+
await this.driver.run('INSERT INTO migrations (version, applied_at) VALUES (1, ?)', [Date.now()]);
|
|
106
|
+
applied = 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const m of MIGRATIONS.filter((m) => m.version > applied)) {
|
|
110
|
+
await this.driver.batch([
|
|
111
|
+
...m.statements.map((sql) => ({ sql })),
|
|
112
|
+
{ sql: 'INSERT INTO migrations (version, applied_at) VALUES (?, ?)', args: [m.version, Date.now()] },
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async seed() {
|
|
118
|
+
const existing = await this.first('SELECT id FROM settings WHERE id = 1');
|
|
119
|
+
if (existing) return;
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
const uuid = () => crypto.randomUUID();
|
|
122
|
+
const homeBlocks: WidgetInstance[] = [
|
|
123
|
+
{ id: uuid(), type: 'hero', config: { heading: SEED.heroHeading, sub: SEED.heroSub, ctaLabel: `Read the ${CONTENT.posts.plural.toLowerCase()}`, ctaHref: postsIndexUrl(), align: 'center' } },
|
|
124
|
+
{ id: uuid(), type: 'text', config: { markdown: 'This home page was built in the **widget editor** — open `/admin` to change it.' } },
|
|
125
|
+
];
|
|
126
|
+
await this.driver.batch([
|
|
127
|
+
{ sql: 'INSERT INTO settings (id, site_title, tagline) VALUES (1, ?, ?)', args: [SEED.siteTitle, SEED.tagline] },
|
|
128
|
+
{
|
|
129
|
+
sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?, ?)',
|
|
130
|
+
args: [uuid(), 'home', 'Home', 'published', JSON.stringify(homeBlocks), now, now],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
sql: 'INSERT INTO posts (id, slug, title, body, status, published_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
134
|
+
args: [uuid(), 'hello-world', 'Hello, world', `This is your first ${CONTENT.posts.singular.toLowerCase()}. Edit or delete it in the [admin](/admin).`, 'published', now, now, now],
|
|
135
|
+
},
|
|
136
|
+
{ sql: 'INSERT INTO links (id, label, url, ord) VALUES (?, ?, ?, 0)', args: [uuid(), 'GitHub', 'https://github.com'] },
|
|
137
|
+
{ sql: 'INSERT INTO links (id, label, url, ord) VALUES (?, ?, ?, 1)', args: [uuid(), 'Astro docs', 'https://docs.astro.build'] },
|
|
138
|
+
]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- pages ----
|
|
142
|
+
async listPages() {
|
|
143
|
+
await this.ready;
|
|
144
|
+
const rows = await this.driver.all('SELECT * FROM pages ORDER BY updated_at DESC');
|
|
145
|
+
return rows.map(rowToPage);
|
|
146
|
+
}
|
|
147
|
+
async getPage(id: string) {
|
|
148
|
+
await this.ready;
|
|
149
|
+
const r = await this.first('SELECT * FROM pages WHERE id = ?', [id]);
|
|
150
|
+
return r ? rowToPage(r) : null;
|
|
151
|
+
}
|
|
152
|
+
async getPageBySlug(slug: string) {
|
|
153
|
+
await this.ready;
|
|
154
|
+
const r = await this.first('SELECT * FROM pages WHERE slug = ?', [slug]);
|
|
155
|
+
return r ? rowToPage(r) : null;
|
|
156
|
+
}
|
|
157
|
+
// omitted/'' = the default locale's home ('' is how default-locale rows
|
|
158
|
+
// store their locale, so pre-i18n databases keep working unchanged)
|
|
159
|
+
async getHomePage(locale?: string) {
|
|
160
|
+
await this.ready;
|
|
161
|
+
const r = await this.first('SELECT * FROM pages WHERE is_home = 1 AND locale = ? LIMIT 1', [locale ?? '']);
|
|
162
|
+
return r ? rowToPage(r) : null;
|
|
163
|
+
}
|
|
164
|
+
async createPage({ title, slug }: { title: string; slug: string }) {
|
|
165
|
+
await this.ready;
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const id = crypto.randomUUID();
|
|
168
|
+
await this.driver.run(
|
|
169
|
+
'INSERT INTO pages (id, slug, title, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
170
|
+
[id, slug, title, 'draft', now, now]
|
|
171
|
+
);
|
|
172
|
+
return (await this.getPage(id))!;
|
|
173
|
+
}
|
|
174
|
+
async updatePage(id: string, data: Parameters<ContentRepository['updatePage']>[1]) {
|
|
175
|
+
await this.ready;
|
|
176
|
+
const sets: string[] = ['updated_at = ?'];
|
|
177
|
+
const args: unknown[] = [Date.now()];
|
|
178
|
+
if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
|
|
179
|
+
if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
|
|
180
|
+
if (data.status !== undefined) { sets.push('status = ?'); args.push(data.status); }
|
|
181
|
+
if (data.seoTitle !== undefined) { sets.push('seo_title = ?'); args.push(data.seoTitle); }
|
|
182
|
+
if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
|
|
183
|
+
if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
|
|
184
|
+
if (data.blocks !== undefined) { sets.push('blocks = ?'); args.push(JSON.stringify(data.blocks)); }
|
|
185
|
+
if (data.bodyJson !== undefined) { sets.push('body_json = ?'); args.push(data.bodyJson); }
|
|
186
|
+
if (data.bodyHtml !== undefined) { sets.push('body_html = ?'); args.push(data.bodyHtml); }
|
|
187
|
+
if (data.locale !== undefined) { sets.push('locale = ?'); args.push(data.locale); }
|
|
188
|
+
if (data.isHome !== undefined) {
|
|
189
|
+
if (data.isHome) {
|
|
190
|
+
// "is home" is per-locale: only displace the home of the locale this
|
|
191
|
+
// page is in (or is being moved to in this same save)
|
|
192
|
+
const locale =
|
|
193
|
+
data.locale !== undefined
|
|
194
|
+
? data.locale
|
|
195
|
+
: String((await this.first('SELECT locale FROM pages WHERE id = ?', [id]))?.locale ?? '');
|
|
196
|
+
await this.driver.run('UPDATE pages SET is_home = 0 WHERE locale = ?', [locale]);
|
|
197
|
+
}
|
|
198
|
+
sets.push('is_home = ?'); args.push(data.isHome ? 1 : 0);
|
|
199
|
+
}
|
|
200
|
+
args.push(id);
|
|
201
|
+
await this.driver.run(`UPDATE pages SET ${sets.join(', ')} WHERE id = ?`, args);
|
|
202
|
+
}
|
|
203
|
+
async deletePage(id: string) {
|
|
204
|
+
await this.ready;
|
|
205
|
+
await this.driver.run('DELETE FROM pages WHERE id = ?', [id]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- posts ----
|
|
209
|
+
// one query loads every post↔author pair; callers merge per post id
|
|
210
|
+
private async authorMap(): Promise<Map<string, string[]>> {
|
|
211
|
+
const rows = await this.driver.all('SELECT post_id, author_id FROM post_authors ORDER BY ord ASC');
|
|
212
|
+
const map = new Map<string, string[]>();
|
|
213
|
+
for (const r of rows) {
|
|
214
|
+
const list = map.get(String(r.post_id)) ?? [];
|
|
215
|
+
list.push(String(r.author_id));
|
|
216
|
+
map.set(String(r.post_id), list);
|
|
217
|
+
}
|
|
218
|
+
return map;
|
|
219
|
+
}
|
|
220
|
+
private async postAuthors(id: string): Promise<string[]> {
|
|
221
|
+
const rows = await this.driver.all('SELECT author_id FROM post_authors WHERE post_id = ? ORDER BY ord ASC', [id]);
|
|
222
|
+
return rows.map((r) => String(r.author_id));
|
|
223
|
+
}
|
|
224
|
+
async listPosts(collection?: string) {
|
|
225
|
+
await this.ready;
|
|
226
|
+
const [rows, authors] = await Promise.all([
|
|
227
|
+
collection
|
|
228
|
+
? this.driver.all('SELECT * FROM posts WHERE collection = ? ORDER BY updated_at DESC', [collection])
|
|
229
|
+
: this.driver.all('SELECT * FROM posts ORDER BY updated_at DESC'),
|
|
230
|
+
this.authorMap(),
|
|
231
|
+
]);
|
|
232
|
+
return rows.map((r) => rowToPost(r, authors.get(String(r.id))));
|
|
233
|
+
}
|
|
234
|
+
async listPublishedPosts(collection?: string) {
|
|
235
|
+
await this.ready;
|
|
236
|
+
const [rows, authors] = await Promise.all([
|
|
237
|
+
collection
|
|
238
|
+
? this.driver.all("SELECT * FROM posts WHERE status = 'published' AND collection = ? ORDER BY published_at DESC", [collection])
|
|
239
|
+
: this.driver.all("SELECT * FROM posts WHERE status = 'published' ORDER BY published_at DESC"),
|
|
240
|
+
this.authorMap(),
|
|
241
|
+
]);
|
|
242
|
+
return rows.map((r) => rowToPost(r, authors.get(String(r.id))));
|
|
243
|
+
}
|
|
244
|
+
async getPost(id: string) {
|
|
245
|
+
await this.ready;
|
|
246
|
+
const r = await this.first('SELECT * FROM posts WHERE id = ?', [id]);
|
|
247
|
+
return r ? rowToPost(r, await this.postAuthors(id)) : null;
|
|
248
|
+
}
|
|
249
|
+
async getPostBySlug(slug: string, collection?: string) {
|
|
250
|
+
await this.ready;
|
|
251
|
+
const r = collection
|
|
252
|
+
? await this.first('SELECT * FROM posts WHERE slug = ? AND collection = ?', [slug, collection])
|
|
253
|
+
: await this.first('SELECT * FROM posts WHERE slug = ?', [slug]);
|
|
254
|
+
return r ? rowToPost(r, await this.postAuthors(String(r.id))) : null;
|
|
255
|
+
}
|
|
256
|
+
async createPost({ title, slug, collection = 'posts' }: { title: string; slug: string; collection?: string }) {
|
|
257
|
+
await this.ready;
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
const id = crypto.randomUUID();
|
|
260
|
+
await this.driver.run(
|
|
261
|
+
'INSERT INTO posts (id, slug, title, collection, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
262
|
+
[id, slug, title, collection, 'draft', now, now]
|
|
263
|
+
);
|
|
264
|
+
return (await this.getPost(id))!;
|
|
265
|
+
}
|
|
266
|
+
async updatePost(id: string, data: Parameters<ContentRepository['updatePost']>[1]) {
|
|
267
|
+
await this.ready;
|
|
268
|
+
const current = await this.getPost(id);
|
|
269
|
+
if (!current) return;
|
|
270
|
+
const sets: string[] = ['updated_at = ?'];
|
|
271
|
+
const args: unknown[] = [Date.now()];
|
|
272
|
+
if (data.title !== undefined) { sets.push('title = ?'); args.push(data.title); }
|
|
273
|
+
if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
|
|
274
|
+
if (data.collection !== undefined) { sets.push('collection = ?'); args.push(data.collection); }
|
|
275
|
+
if (data.body !== undefined) { sets.push('body = ?'); args.push(data.body); }
|
|
276
|
+
if (data.bodyJson !== undefined) { sets.push('body_json = ?'); args.push(data.bodyJson); }
|
|
277
|
+
if (data.bodyHtml !== undefined) { sets.push('body_html = ?'); args.push(data.bodyHtml); }
|
|
278
|
+
if (data.excerpt !== undefined) { sets.push('excerpt = ?'); args.push(data.excerpt); }
|
|
279
|
+
if (data.tags !== undefined) { sets.push('tags = ?'); args.push(JSON.stringify(data.tags)); }
|
|
280
|
+
if (data.cover !== undefined) { sets.push('cover = ?'); args.push(data.cover); }
|
|
281
|
+
if (data.coverAlt !== undefined) { sets.push('cover_alt = ?'); args.push(data.coverAlt); }
|
|
282
|
+
if (data.seoTitle !== undefined) { sets.push('seo_title = ?'); args.push(data.seoTitle); }
|
|
283
|
+
if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
|
|
284
|
+
if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
|
|
285
|
+
if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
|
|
286
|
+
// authorIds is the source of truth; author_id mirrors the first entry.
|
|
287
|
+
// The join rows are written AFTER the main UPDATE succeeds, so a failed
|
|
288
|
+
// update (e.g. duplicate slug) can't leave the two out of sync.
|
|
289
|
+
let authorJoin: string[] | null = null;
|
|
290
|
+
if (data.authorIds !== undefined) {
|
|
291
|
+
authorJoin = data.authorIds;
|
|
292
|
+
sets.push('author_id = ?'); args.push(data.authorIds[0] ?? null);
|
|
293
|
+
} else if (data.authorId !== undefined) {
|
|
294
|
+
authorJoin = data.authorId ? [data.authorId] : [];
|
|
295
|
+
sets.push('author_id = ?'); args.push(data.authorId);
|
|
296
|
+
}
|
|
297
|
+
if (data.publishedAt !== undefined) { sets.push('published_at = ?'); args.push(data.publishedAt); }
|
|
298
|
+
if (data.scheduledAt !== undefined) { sets.push('scheduled_at = ?'); args.push(data.scheduledAt); }
|
|
299
|
+
if (data.status !== undefined) {
|
|
300
|
+
sets.push('status = ?'); args.push(data.status);
|
|
301
|
+
if (data.status === 'published' && current.publishedAt === null && data.publishedAt === undefined) {
|
|
302
|
+
sets.push('published_at = ?'); args.push(Date.now());
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
args.push(id);
|
|
306
|
+
await this.driver.run(`UPDATE posts SET ${sets.join(', ')} WHERE id = ?`, args);
|
|
307
|
+
if (authorJoin !== null) {
|
|
308
|
+
await this.driver.batch([
|
|
309
|
+
{ sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
|
|
310
|
+
...authorJoin.map((aid, i) => ({
|
|
311
|
+
sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)',
|
|
312
|
+
args: [id, aid, i],
|
|
313
|
+
})),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async deletePost(id: string) {
|
|
318
|
+
await this.ready;
|
|
319
|
+
await this.driver.batch([
|
|
320
|
+
{ sql: 'DELETE FROM posts WHERE id = ?', args: [id] },
|
|
321
|
+
{ sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
|
|
322
|
+
]);
|
|
323
|
+
}
|
|
324
|
+
async publishDuePosts(now: number) {
|
|
325
|
+
await this.ready;
|
|
326
|
+
const due = await this.first(
|
|
327
|
+
"SELECT COUNT(*) AS n FROM posts WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
|
|
328
|
+
[now]
|
|
329
|
+
);
|
|
330
|
+
const n = Number(due?.n ?? 0);
|
|
331
|
+
if (!n) return 0;
|
|
332
|
+
await this.driver.run(
|
|
333
|
+
"UPDATE posts SET status = 'published', published_at = COALESCE(published_at, scheduled_at), scheduled_at = NULL, updated_at = ? WHERE status = 'scheduled' AND scheduled_at IS NOT NULL AND scheduled_at <= ?",
|
|
334
|
+
[now, now]
|
|
335
|
+
);
|
|
336
|
+
return n;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---- media ----
|
|
340
|
+
async listMedia() {
|
|
341
|
+
await this.ready;
|
|
342
|
+
const rows = await this.driver.all('SELECT * FROM media ORDER BY created_at DESC');
|
|
343
|
+
return rows.map(rowToMedia);
|
|
344
|
+
}
|
|
345
|
+
async getMedia(id: string) {
|
|
346
|
+
await this.ready;
|
|
347
|
+
const r = await this.first('SELECT * FROM media WHERE id = ?', [id]);
|
|
348
|
+
return r ? rowToMedia(r) : null;
|
|
349
|
+
}
|
|
350
|
+
async createMedia(data: Parameters<ContentRepository['createMedia']>[0]) {
|
|
351
|
+
await this.ready;
|
|
352
|
+
await this.driver.run(
|
|
353
|
+
'INSERT INTO media (id, key, filename, mime, width, height, size, alt, variants, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
354
|
+
[data.id, data.key, data.filename, data.mime, data.width, data.height, data.size, data.alt, JSON.stringify(data.variants), Date.now()]
|
|
355
|
+
);
|
|
356
|
+
return (await this.getMedia(data.id))!;
|
|
357
|
+
}
|
|
358
|
+
async updateMedia(id: string, data: Parameters<ContentRepository['updateMedia']>[1]) {
|
|
359
|
+
await this.ready;
|
|
360
|
+
if (data.alt === undefined) return;
|
|
361
|
+
await this.driver.run('UPDATE media SET alt = ? WHERE id = ?', [data.alt, id]);
|
|
362
|
+
}
|
|
363
|
+
async deleteMedia(id: string) {
|
|
364
|
+
await this.ready;
|
|
365
|
+
await this.driver.run('DELETE FROM media WHERE id = ?', [id]);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- links ----
|
|
369
|
+
async listLinks() {
|
|
370
|
+
await this.ready;
|
|
371
|
+
const rows = await this.driver.all('SELECT * FROM links ORDER BY ord ASC');
|
|
372
|
+
return rows.map(rowToLink);
|
|
373
|
+
}
|
|
374
|
+
async createLink({ label, url, icon = '', kind = 'link', html = '', meta = {} }: Parameters<ContentRepository['createLink']>[0]) {
|
|
375
|
+
await this.ready;
|
|
376
|
+
const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS next FROM links');
|
|
377
|
+
const id = crypto.randomUUID();
|
|
378
|
+
await this.driver.run(
|
|
379
|
+
'INSERT INTO links (id, kind, label, url, icon, html, meta, ord) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
|
380
|
+
[id, kind, label, url, icon, html, JSON.stringify(meta), Number(next?.next ?? 0)]
|
|
381
|
+
);
|
|
382
|
+
const links = await this.listLinks();
|
|
383
|
+
return links.find((l) => l.id === id)!;
|
|
384
|
+
}
|
|
385
|
+
async updateLink(id: string, data: Parameters<ContentRepository['updateLink']>[1]) {
|
|
386
|
+
await this.ready;
|
|
387
|
+
const sets: string[] = [];
|
|
388
|
+
const args: unknown[] = [];
|
|
389
|
+
if (data.label !== undefined) { sets.push('label = ?'); args.push(data.label); }
|
|
390
|
+
if (data.url !== undefined) { sets.push('url = ?'); args.push(data.url); }
|
|
391
|
+
if (data.icon !== undefined) { sets.push('icon = ?'); args.push(data.icon); }
|
|
392
|
+
if (data.html !== undefined) { sets.push('html = ?'); args.push(data.html); }
|
|
393
|
+
if (data.meta !== undefined) { sets.push('meta = ?'); args.push(JSON.stringify(data.meta)); }
|
|
394
|
+
if (!sets.length) return;
|
|
395
|
+
args.push(id);
|
|
396
|
+
await this.driver.run(`UPDATE links SET ${sets.join(', ')} WHERE id = ?`, args);
|
|
397
|
+
}
|
|
398
|
+
async moveLink(id: string, dir: 'up' | 'down') {
|
|
399
|
+
await this.ready;
|
|
400
|
+
const links = await this.listLinks();
|
|
401
|
+
const i = links.findIndex((l) => l.id === id);
|
|
402
|
+
const j = dir === 'up' ? i - 1 : i + 1;
|
|
403
|
+
if (i < 0 || j < 0 || j >= links.length) return;
|
|
404
|
+
await this.driver.batch([
|
|
405
|
+
{ sql: 'UPDATE links SET ord = ? WHERE id = ?', args: [links[j].order, links[i].id] },
|
|
406
|
+
{ sql: 'UPDATE links SET ord = ? WHERE id = ?', args: [links[i].order, links[j].id] },
|
|
407
|
+
]);
|
|
408
|
+
}
|
|
409
|
+
async reorderLinks(ids: string[]) {
|
|
410
|
+
await this.ready;
|
|
411
|
+
if (!ids.length) return;
|
|
412
|
+
await this.driver.batch(ids.map((id, i) => ({ sql: 'UPDATE links SET ord = ? WHERE id = ?', args: [i, id] })));
|
|
413
|
+
}
|
|
414
|
+
async deleteLink(id: string) {
|
|
415
|
+
await this.ready;
|
|
416
|
+
await this.driver.run('DELETE FROM links WHERE id = ?', [id]);
|
|
417
|
+
}
|
|
418
|
+
async trackClick(id: string) {
|
|
419
|
+
await this.ready;
|
|
420
|
+
const r = await this.first('SELECT url FROM links WHERE id = ?', [id]);
|
|
421
|
+
if (!r) return null;
|
|
422
|
+
await this.driver.run('UPDATE links SET clicks = clicks + 1 WHERE id = ?', [id]);
|
|
423
|
+
return String(r.url);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- tags ----
|
|
427
|
+
async listTags() {
|
|
428
|
+
await this.ready;
|
|
429
|
+
const rows = await this.driver.all('SELECT * FROM tags ORDER BY name COLLATE NOCASE ASC');
|
|
430
|
+
return rows.map(rowToTag);
|
|
431
|
+
}
|
|
432
|
+
async getTagBySlug(slug: string) {
|
|
433
|
+
await this.ready;
|
|
434
|
+
const r = await this.first('SELECT * FROM tags WHERE slug = ?', [slug]);
|
|
435
|
+
return r ? rowToTag(r) : null;
|
|
436
|
+
}
|
|
437
|
+
async createTag({ name, slug, description = '' }: { name: string; slug: string; description?: string }) {
|
|
438
|
+
await this.ready;
|
|
439
|
+
const id = crypto.randomUUID();
|
|
440
|
+
await this.driver.run('INSERT INTO tags (id, name, slug, description) VALUES (?, ?, ?, ?)', [id, name, slug, description]);
|
|
441
|
+
const r = await this.first('SELECT * FROM tags WHERE id = ?', [id]);
|
|
442
|
+
return rowToTag(r!);
|
|
443
|
+
}
|
|
444
|
+
async updateTag(id: string, data: Parameters<ContentRepository['updateTag']>[1]) {
|
|
445
|
+
await this.ready;
|
|
446
|
+
const sets: string[] = [];
|
|
447
|
+
const args: unknown[] = [];
|
|
448
|
+
if (data.name !== undefined) { sets.push('name = ?'); args.push(data.name); }
|
|
449
|
+
if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
|
|
450
|
+
if (data.description !== undefined) { sets.push('description = ?'); args.push(data.description); }
|
|
451
|
+
if (!sets.length) return;
|
|
452
|
+
args.push(id);
|
|
453
|
+
await this.driver.run(`UPDATE tags SET ${sets.join(', ')} WHERE id = ?`, args);
|
|
454
|
+
}
|
|
455
|
+
async deleteTag(id: string) {
|
|
456
|
+
await this.ready;
|
|
457
|
+
await this.driver.run('DELETE FROM tags WHERE id = ?', [id]);
|
|
458
|
+
}
|
|
459
|
+
async ensureTags(names: string[]) {
|
|
460
|
+
await this.ready;
|
|
461
|
+
if (!names.length) return;
|
|
462
|
+
const tags = await this.listTags();
|
|
463
|
+
const existing = new Set(tags.map((t) => t.name.toLowerCase()));
|
|
464
|
+
const slugs = new Set(tags.map((t) => t.slug));
|
|
465
|
+
for (const name of names) {
|
|
466
|
+
if (existing.has(name.toLowerCase())) continue;
|
|
467
|
+
let slug = slugify(name);
|
|
468
|
+
let n = 1;
|
|
469
|
+
while (slugs.has(slug)) slug = `${slugify(name)}-${++n}`;
|
|
470
|
+
await this.driver.run('INSERT INTO tags (id, name, slug) VALUES (?, ?, ?)', [crypto.randomUUID(), name, slug]);
|
|
471
|
+
existing.add(name.toLowerCase());
|
|
472
|
+
slugs.add(slug);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ---- users ----
|
|
477
|
+
async listUsers() {
|
|
478
|
+
await this.ready;
|
|
479
|
+
const rows = await this.driver.all('SELECT * FROM users ORDER BY created_at ASC');
|
|
480
|
+
return rows.map(rowToUser);
|
|
481
|
+
}
|
|
482
|
+
async getUser(id: string) {
|
|
483
|
+
await this.ready;
|
|
484
|
+
const r = await this.first('SELECT * FROM users WHERE id = ?', [id]);
|
|
485
|
+
return r ? rowToUser(r) : null;
|
|
486
|
+
}
|
|
487
|
+
async createUser({ name, email, role, passHash }: Parameters<ContentRepository['createUser']>[0]) {
|
|
488
|
+
await this.ready;
|
|
489
|
+
const id = crypto.randomUUID();
|
|
490
|
+
await this.driver.run(
|
|
491
|
+
'INSERT INTO users (id, name, email, role, pass_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
492
|
+
[id, name, email.toLowerCase(), role, passHash, Date.now()]
|
|
493
|
+
);
|
|
494
|
+
return (await this.getUser(id))!;
|
|
495
|
+
}
|
|
496
|
+
async updateUser(id: string, data: Parameters<ContentRepository['updateUser']>[1]) {
|
|
497
|
+
await this.ready;
|
|
498
|
+
const sets: string[] = [];
|
|
499
|
+
const args: unknown[] = [];
|
|
500
|
+
if (data.name !== undefined) { sets.push('name = ?'); args.push(data.name); }
|
|
501
|
+
if (data.email !== undefined) { sets.push('email = ?'); args.push(data.email.toLowerCase()); }
|
|
502
|
+
if (data.role !== undefined) { sets.push('role = ?'); args.push(data.role); }
|
|
503
|
+
if (data.passHash !== undefined) { sets.push('pass_hash = ?'); args.push(data.passHash); }
|
|
504
|
+
if (data.bio !== undefined) { sets.push('bio = ?'); args.push(data.bio); }
|
|
505
|
+
if (data.avatar !== undefined) { sets.push('avatar = ?'); args.push(data.avatar); }
|
|
506
|
+
if (data.slug !== undefined) { sets.push('slug = ?'); args.push(data.slug); }
|
|
507
|
+
if (!sets.length) return;
|
|
508
|
+
args.push(id);
|
|
509
|
+
await this.driver.run(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`, args);
|
|
510
|
+
}
|
|
511
|
+
async deleteUser(id: string) {
|
|
512
|
+
await this.ready;
|
|
513
|
+
await this.driver.run('DELETE FROM users WHERE id = ?', [id]);
|
|
514
|
+
}
|
|
515
|
+
async getUserAuth(email: string) {
|
|
516
|
+
await this.ready;
|
|
517
|
+
const r = await this.first('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
|
518
|
+
return r ? { user: rowToUser(r), passHash: String(r.pass_hash) } : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---- navigation ----
|
|
522
|
+
async listNavItems() {
|
|
523
|
+
await this.ready;
|
|
524
|
+
const rows = await this.driver.all('SELECT * FROM nav_items ORDER BY ord ASC');
|
|
525
|
+
return rows.map((r) => NavItem.parse({ id: r.id, label: r.label, url: r.url, area: r.area, order: Number(r.ord) }));
|
|
526
|
+
}
|
|
527
|
+
async createNavItem({ label, url, area }: { label: string; url: string; area: NavItem['area'] }) {
|
|
528
|
+
await this.ready;
|
|
529
|
+
const next = await this.first('SELECT COALESCE(MAX(ord), -1) + 1 AS next FROM nav_items');
|
|
530
|
+
const id = crypto.randomUUID();
|
|
531
|
+
await this.driver.run(
|
|
532
|
+
'INSERT INTO nav_items (id, label, url, area, ord) VALUES (?, ?, ?, ?, ?)',
|
|
533
|
+
[id, label, url, area, Number(next?.next ?? 0)]
|
|
534
|
+
);
|
|
535
|
+
return (await this.listNavItems()).find((n) => n.id === id)!;
|
|
536
|
+
}
|
|
537
|
+
async updateNavItem(id: string, data: Partial<Pick<NavItem, 'label' | 'url' | 'area'>>) {
|
|
538
|
+
await this.ready;
|
|
539
|
+
const sets: string[] = [];
|
|
540
|
+
const args: unknown[] = [];
|
|
541
|
+
if (data.label !== undefined) { sets.push('label = ?'); args.push(data.label); }
|
|
542
|
+
if (data.url !== undefined) { sets.push('url = ?'); args.push(data.url); }
|
|
543
|
+
if (data.area !== undefined) { sets.push('area = ?'); args.push(data.area); }
|
|
544
|
+
if (!sets.length) return;
|
|
545
|
+
args.push(id);
|
|
546
|
+
await this.driver.run(`UPDATE nav_items SET ${sets.join(', ')} WHERE id = ?`, args);
|
|
547
|
+
}
|
|
548
|
+
async reorderNavItems(ids: string[]) {
|
|
549
|
+
await this.ready;
|
|
550
|
+
if (!ids.length) return;
|
|
551
|
+
await this.driver.batch(ids.map((id, i) => ({ sql: 'UPDATE nav_items SET ord = ? WHERE id = ?', args: [i, id] })));
|
|
552
|
+
}
|
|
553
|
+
async deleteNavItem(id: string) {
|
|
554
|
+
await this.ready;
|
|
555
|
+
await this.driver.run('DELETE FROM nav_items WHERE id = ?', [id]);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ---- search ----
|
|
559
|
+
async searchPublished(query: string) {
|
|
560
|
+
await this.ready;
|
|
561
|
+
const q = `%${query.replace(/[%_]/g, ' ').trim()}%`;
|
|
562
|
+
if (q === '%%') return { posts: [], pages: [] };
|
|
563
|
+
const [posts, pages, authors] = await Promise.all([
|
|
564
|
+
this.driver.all(
|
|
565
|
+
"SELECT * FROM posts WHERE status = 'published' AND (title LIKE ? OR excerpt LIKE ? OR body_html LIKE ? OR body LIKE ?) ORDER BY published_at DESC LIMIT 50",
|
|
566
|
+
[q, q, q, q]
|
|
567
|
+
),
|
|
568
|
+
this.driver.all(
|
|
569
|
+
"SELECT * FROM pages WHERE status = 'published' AND (title LIKE ? OR body_html LIKE ?) ORDER BY updated_at DESC LIMIT 50",
|
|
570
|
+
[q, q]
|
|
571
|
+
),
|
|
572
|
+
this.authorMap(),
|
|
573
|
+
]);
|
|
574
|
+
return {
|
|
575
|
+
posts: rankByQuery(posts.map((r) => rowToPost(r, authors.get(String(r.id)))), query),
|
|
576
|
+
pages: pages.map(rowToPage),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ---- restore ----
|
|
581
|
+
async restoreAll(content: Parameters<ContentRepository['restoreAll']>[0]) {
|
|
582
|
+
await this.ready;
|
|
583
|
+
const stmts: SqlStatement[] = [
|
|
584
|
+
...['posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects'].map((t) => ({
|
|
585
|
+
sql: `DELETE FROM ${t}`,
|
|
586
|
+
})),
|
|
587
|
+
...content.pages.map((p) => ({
|
|
588
|
+
sql: 'INSERT INTO pages (id, slug, title, status, is_home, blocks, body_json, body_html, seo_title, seo_desc, template, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
589
|
+
args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, p.createdAt, p.updatedAt],
|
|
590
|
+
})),
|
|
591
|
+
// p.collection defaults to 'posts' via the zod schema, so pre-v19
|
|
592
|
+
// backups restore cleanly into the built-in collection
|
|
593
|
+
...content.posts.map((p) => ({
|
|
594
|
+
sql: 'INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, published_at, scheduled_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
595
|
+
args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? p.authorId, p.template, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
|
|
596
|
+
})),
|
|
597
|
+
...content.posts.flatMap((p) =>
|
|
598
|
+
(p.authorIds.length ? p.authorIds : p.authorId ? [p.authorId] : []).map((aid, i) => ({
|
|
599
|
+
sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)',
|
|
600
|
+
args: [p.id, aid, i] as unknown[],
|
|
601
|
+
}))
|
|
602
|
+
),
|
|
603
|
+
...content.media.map((m) => ({
|
|
604
|
+
sql: 'INSERT INTO media (id, key, filename, mime, width, height, size, alt, variants, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
605
|
+
args: [m.id, m.key, m.filename, m.mime, m.width, m.height, m.size, m.alt, JSON.stringify(m.variants), m.createdAt],
|
|
606
|
+
})),
|
|
607
|
+
...content.links.map((l) => ({
|
|
608
|
+
sql: 'INSERT INTO links (id, kind, label, url, icon, html, meta, ord, clicks) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
609
|
+
args: [l.id, l.kind, l.label, l.url, l.icon, l.html, JSON.stringify(l.meta), l.order, l.clicks],
|
|
610
|
+
})),
|
|
611
|
+
...content.tags.map((t) => ({
|
|
612
|
+
sql: 'INSERT INTO tags (id, name, slug, description) VALUES (?, ?, ?, ?)',
|
|
613
|
+
args: [t.id, t.name, t.slug, t.description],
|
|
614
|
+
})),
|
|
615
|
+
...content.navItems.map((n) => ({
|
|
616
|
+
sql: 'INSERT INTO nav_items (id, label, url, area, ord) VALUES (?, ?, ?, ?, ?)',
|
|
617
|
+
args: [n.id, n.label, n.url, n.area, n.order],
|
|
618
|
+
})),
|
|
619
|
+
// accounts restore without hashes — sign in via ADMIN_PASSWORD and reset
|
|
620
|
+
...content.users.map((u) => ({
|
|
621
|
+
sql: 'INSERT INTO users (id, name, email, role, pass_hash, bio, avatar, slug, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
622
|
+
args: [u.id, u.name, u.email, u.role, '', u.bio, u.avatar, u.slug, u.createdAt],
|
|
623
|
+
})),
|
|
624
|
+
...content.redirects.map((r) => ({
|
|
625
|
+
sql: 'INSERT INTO redirects (id, from_path, to_path, code, created_at) VALUES (?, ?, ?, ?, ?)',
|
|
626
|
+
args: [r.id, r.fromPath, r.toPath, r.code, r.createdAt],
|
|
627
|
+
})),
|
|
628
|
+
];
|
|
629
|
+
await this.driver.batch(stmts);
|
|
630
|
+
await this.updateSettings(content.settings);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---- redirects ----
|
|
634
|
+
async listRedirects() {
|
|
635
|
+
await this.ready;
|
|
636
|
+
const rows = await this.driver.all('SELECT * FROM redirects ORDER BY created_at DESC');
|
|
637
|
+
return rows.map((r) =>
|
|
638
|
+
Redirect.parse({ id: r.id, fromPath: r.from_path, toPath: r.to_path, code: Number(r.code), createdAt: Number(r.created_at) })
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
async createRedirect({ fromPath, toPath, code = 301 }: Parameters<ContentRepository['createRedirect']>[0]) {
|
|
642
|
+
await this.ready;
|
|
643
|
+
const from = normalizeRedirectPath(fromPath);
|
|
644
|
+
const to = normalizeRedirectPath(toPath);
|
|
645
|
+
if (!from || !to || from === to) throw new Error('Invalid redirect.');
|
|
646
|
+
await this.driver.batch([
|
|
647
|
+
// retarget chains (a→old becomes a→new) and drop any rule that would
|
|
648
|
+
// shadow the destination, then upsert on from_path
|
|
649
|
+
{ sql: 'UPDATE redirects SET to_path = ? WHERE to_path = ?', args: [to, from] },
|
|
650
|
+
{ sql: 'DELETE FROM redirects WHERE from_path = ?', args: [to] },
|
|
651
|
+
{
|
|
652
|
+
sql: 'INSERT INTO redirects (id, from_path, to_path, code, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(from_path) DO UPDATE SET to_path = excluded.to_path, code = excluded.code',
|
|
653
|
+
args: [crypto.randomUUID(), from, to, code, Date.now()],
|
|
654
|
+
},
|
|
655
|
+
{ sql: 'DELETE FROM redirects WHERE from_path = to_path' },
|
|
656
|
+
]);
|
|
657
|
+
return (await this.listRedirects()).find((r) => r.fromPath === from)!;
|
|
658
|
+
}
|
|
659
|
+
async deleteRedirect(id: string) {
|
|
660
|
+
await this.ready;
|
|
661
|
+
await this.driver.run('DELETE FROM redirects WHERE id = ?', [id]);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// ---- settings & stats ----
|
|
665
|
+
async getSettings() {
|
|
666
|
+
await this.ready;
|
|
667
|
+
const r = await this.first('SELECT * FROM settings WHERE id = 1');
|
|
668
|
+
return Settings.parse({
|
|
669
|
+
siteTitle: r?.site_title ?? 'My Site', tagline: r?.tagline ?? '', footerText: r?.footer_text ?? '',
|
|
670
|
+
linksName: r?.links_name ?? '', linksBio: r?.links_bio ?? '', linksAvatar: r?.links_avatar ?? '',
|
|
671
|
+
activeTheme: r?.active_theme ?? '', designOverrides: JSON.parse(String(r?.design_overrides ?? '{}')),
|
|
672
|
+
siteLogo: r?.site_logo ?? '',
|
|
673
|
+
siteUrl: r?.site_url ?? '', siteFavicon: r?.site_favicon ?? '', siteCover: r?.site_cover ?? '',
|
|
674
|
+
announcement: JSON.parse(String(r?.announcement ?? '{}')),
|
|
675
|
+
headCode: r?.head_code ?? '', footerCode: r?.footer_code ?? '',
|
|
676
|
+
timezone: r?.timezone ?? '', locale: r?.locale ?? '',
|
|
677
|
+
draftsApiKey: r?.drafts_api_key ?? '',
|
|
678
|
+
draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
async updateSettings(data: Partial<Settings>) {
|
|
682
|
+
await this.ready;
|
|
683
|
+
const current = await this.getSettings();
|
|
684
|
+
await this.driver.run(
|
|
685
|
+
'UPDATE settings SET site_title = ?, tagline = ?, footer_text = ?, links_name = ?, links_bio = ?, links_avatar = ?, active_theme = ?, design_overrides = ?, site_logo = ?, site_url = ?, site_favicon = ?, site_cover = ?, announcement = ?, head_code = ?, footer_code = ?, timezone = ?, locale = ?, drafts_api_key = ?, drafts_allowed_origins = ? WHERE id = 1',
|
|
686
|
+
[
|
|
687
|
+
data.siteTitle ?? current.siteTitle, data.tagline ?? current.tagline,
|
|
688
|
+
data.footerText ?? current.footerText,
|
|
689
|
+
data.linksName ?? current.linksName, data.linksBio ?? current.linksBio,
|
|
690
|
+
data.linksAvatar ?? current.linksAvatar,
|
|
691
|
+
data.activeTheme ?? current.activeTheme,
|
|
692
|
+
JSON.stringify(data.designOverrides ?? current.designOverrides),
|
|
693
|
+
data.siteLogo ?? current.siteLogo,
|
|
694
|
+
data.siteUrl ?? current.siteUrl,
|
|
695
|
+
data.siteFavicon ?? current.siteFavicon,
|
|
696
|
+
data.siteCover ?? current.siteCover,
|
|
697
|
+
JSON.stringify(data.announcement ?? current.announcement),
|
|
698
|
+
data.headCode ?? current.headCode,
|
|
699
|
+
data.footerCode ?? current.footerCode,
|
|
700
|
+
data.timezone ?? current.timezone,
|
|
701
|
+
data.locale ?? current.locale,
|
|
702
|
+
data.draftsApiKey ?? current.draftsApiKey,
|
|
703
|
+
JSON.stringify(data.draftsAllowedOrigins ?? current.draftsAllowedOrigins),
|
|
704
|
+
]
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
async getStats(): Promise<Stats> {
|
|
708
|
+
await this.ready;
|
|
709
|
+
const r = await this.first(`SELECT
|
|
710
|
+
(SELECT COUNT(*) FROM pages WHERE status = 'published') AS pub_pages,
|
|
711
|
+
(SELECT COUNT(*) FROM posts) AS posts,
|
|
712
|
+
(SELECT COUNT(*) FROM posts WHERE status = 'draft') + (SELECT COUNT(*) FROM pages WHERE status = 'draft') AS drafts,
|
|
713
|
+
(SELECT COALESCE(SUM(clicks), 0) FROM links) AS clicks`);
|
|
714
|
+
return {
|
|
715
|
+
publishedPages: Number(r?.pub_pages ?? 0),
|
|
716
|
+
posts: Number(r?.posts ?? 0),
|
|
717
|
+
drafts: Number(r?.drafts ?? 0),
|
|
718
|
+
linkClicks: Number(r?.clicks ?? 0),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
}
|