@jant/core 0.3.36 → 0.3.37
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/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
package/src/services/page.ts
DELETED
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Page Service
|
|
3
|
-
*
|
|
4
|
-
* CRUD operations for standalone pages (about, now, etc.)
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { eq, desc, sql, and } from "drizzle-orm";
|
|
8
|
-
import type { Database } from "../db/index.js";
|
|
9
|
-
import { pages, navItems } from "../db/schema.js";
|
|
10
|
-
import { now } from "../lib/time.js";
|
|
11
|
-
import { render as renderMarkdown } from "../lib/markdown.js";
|
|
12
|
-
import type { Page, Status, CreatePage, UpdatePage } from "../types.js";
|
|
13
|
-
import type { PathRegistryService } from "./path-registry.js";
|
|
14
|
-
import { ConflictError } from "../lib/errors.js";
|
|
15
|
-
|
|
16
|
-
export interface PageFilters {
|
|
17
|
-
status?: Status;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface PageService {
|
|
21
|
-
getById(id: number): Promise<Page | null>;
|
|
22
|
-
getBySlug(slug: string): Promise<Page | null>;
|
|
23
|
-
list(filters?: PageFilters): Promise<Page[]>;
|
|
24
|
-
listNotInNav(): Promise<Page[]>;
|
|
25
|
-
create(data: CreatePage): Promise<Page>;
|
|
26
|
-
update(id: number, data: UpdatePage): Promise<Page | null>;
|
|
27
|
-
delete(id: number): Promise<boolean>;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
|
|
31
|
-
function isUniqueConstraintError(err: unknown): boolean {
|
|
32
|
-
let current: unknown = err;
|
|
33
|
-
while (current) {
|
|
34
|
-
const msg = String(current);
|
|
35
|
-
if (
|
|
36
|
-
msg.includes("UNIQUE constraint") ||
|
|
37
|
-
msg.includes("SQLITE_CONSTRAINT")
|
|
38
|
-
) {
|
|
39
|
-
return true;
|
|
40
|
-
}
|
|
41
|
-
current =
|
|
42
|
-
current instanceof Error && current.cause !== current
|
|
43
|
-
? current.cause
|
|
44
|
-
: undefined;
|
|
45
|
-
}
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function createPageService(
|
|
50
|
-
db: Database,
|
|
51
|
-
pathRegistry: PathRegistryService,
|
|
52
|
-
): PageService {
|
|
53
|
-
function toPage(row: typeof pages.$inferSelect): Page {
|
|
54
|
-
return {
|
|
55
|
-
id: row.id,
|
|
56
|
-
slug: row.slug,
|
|
57
|
-
title: row.title,
|
|
58
|
-
body: row.body,
|
|
59
|
-
bodyHtml: row.bodyHtml,
|
|
60
|
-
status: row.status as Status,
|
|
61
|
-
createdAt: row.createdAt,
|
|
62
|
-
updatedAt: row.updatedAt,
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
async getById(id) {
|
|
68
|
-
const result = await db
|
|
69
|
-
.select()
|
|
70
|
-
.from(pages)
|
|
71
|
-
.where(eq(pages.id, id))
|
|
72
|
-
.limit(1);
|
|
73
|
-
return result[0] ? toPage(result[0]) : null;
|
|
74
|
-
},
|
|
75
|
-
|
|
76
|
-
async getBySlug(slug) {
|
|
77
|
-
const result = await db
|
|
78
|
-
.select()
|
|
79
|
-
.from(pages)
|
|
80
|
-
.where(eq(pages.slug, slug))
|
|
81
|
-
.limit(1);
|
|
82
|
-
return result[0] ? toPage(result[0]) : null;
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
async list(filters?: PageFilters) {
|
|
86
|
-
const conditions = [];
|
|
87
|
-
if (filters?.status) {
|
|
88
|
-
conditions.push(eq(pages.status, filters.status));
|
|
89
|
-
}
|
|
90
|
-
const rows = await db
|
|
91
|
-
.select()
|
|
92
|
-
.from(pages)
|
|
93
|
-
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
94
|
-
.orderBy(desc(pages.createdAt));
|
|
95
|
-
return rows.map(toPage);
|
|
96
|
-
},
|
|
97
|
-
|
|
98
|
-
async listNotInNav() {
|
|
99
|
-
const rows = await db
|
|
100
|
-
.select()
|
|
101
|
-
.from(pages)
|
|
102
|
-
.where(
|
|
103
|
-
sql`${pages.id} NOT IN (SELECT ${navItems.pageId} FROM ${navItems} WHERE ${navItems.pageId} IS NOT NULL)`,
|
|
104
|
-
)
|
|
105
|
-
.orderBy(desc(pages.createdAt));
|
|
106
|
-
return rows.map(toPage);
|
|
107
|
-
},
|
|
108
|
-
|
|
109
|
-
async create(data) {
|
|
110
|
-
// Validate and reserve path before DB insert — throws friendly
|
|
111
|
-
// ConflictError/ValidationError instead of a raw UNIQUE constraint error.
|
|
112
|
-
// Uses placeholder owner ID; corrected to real ID after insert.
|
|
113
|
-
await pathRegistry.claim(data.slug, "page", 0);
|
|
114
|
-
|
|
115
|
-
const timestamp = now();
|
|
116
|
-
const bodyHtml = data.body ? renderMarkdown(data.body) : null;
|
|
117
|
-
|
|
118
|
-
let page: Page;
|
|
119
|
-
try {
|
|
120
|
-
const result = await db
|
|
121
|
-
.insert(pages)
|
|
122
|
-
.values({
|
|
123
|
-
slug: data.slug,
|
|
124
|
-
title: data.title ?? null,
|
|
125
|
-
body: data.body ?? null,
|
|
126
|
-
bodyHtml,
|
|
127
|
-
status: data.status ?? "published",
|
|
128
|
-
createdAt: timestamp,
|
|
129
|
-
updatedAt: timestamp,
|
|
130
|
-
})
|
|
131
|
-
.returning();
|
|
132
|
-
|
|
133
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
134
|
-
page = toPage(result[0]!);
|
|
135
|
-
} catch (err) {
|
|
136
|
-
await pathRegistry.release(data.slug);
|
|
137
|
-
// Surface DB unique constraint failures as a friendly error
|
|
138
|
-
if (isUniqueConstraintError(err)) {
|
|
139
|
-
throw new ConflictError(`Slug "${data.slug}" is already in use`);
|
|
140
|
-
}
|
|
141
|
-
throw err;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Update registry with actual page ID
|
|
145
|
-
await pathRegistry.release(data.slug);
|
|
146
|
-
await pathRegistry.claim(data.slug, "page", page.id);
|
|
147
|
-
|
|
148
|
-
return page;
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
async update(id, data) {
|
|
152
|
-
const existing = await this.getById(id);
|
|
153
|
-
if (!existing) return null;
|
|
154
|
-
|
|
155
|
-
const slugChanging =
|
|
156
|
-
data.slug !== undefined && data.slug !== existing.slug;
|
|
157
|
-
|
|
158
|
-
// If slug is changing, claim the new path first (validates before modifying)
|
|
159
|
-
if (slugChanging) {
|
|
160
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by slugChanging check
|
|
161
|
-
await pathRegistry.claim(data.slug!, "page", id);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const timestamp = now();
|
|
165
|
-
const updates: Partial<typeof pages.$inferInsert> = {
|
|
166
|
-
updatedAt: timestamp,
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
if (data.slug !== undefined) updates.slug = data.slug;
|
|
170
|
-
if (data.title !== undefined) updates.title = data.title;
|
|
171
|
-
if (data.status !== undefined) updates.status = data.status;
|
|
172
|
-
|
|
173
|
-
if (data.body !== undefined) {
|
|
174
|
-
updates.body = data.body;
|
|
175
|
-
updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// If slug changed, update related nav_items
|
|
179
|
-
if (slugChanging) {
|
|
180
|
-
await db
|
|
181
|
-
.update(navItems)
|
|
182
|
-
.set({ url: `/${data.slug}`, updatedAt: timestamp })
|
|
183
|
-
.where(eq(navItems.pageId, id));
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// If title changed, update related nav_items label
|
|
187
|
-
if (data.title !== undefined && data.title !== existing.title) {
|
|
188
|
-
await db
|
|
189
|
-
.update(navItems)
|
|
190
|
-
.set({ label: data.title ?? existing.slug, updatedAt: timestamp })
|
|
191
|
-
.where(eq(navItems.pageId, id));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const result = await db
|
|
195
|
-
.update(pages)
|
|
196
|
-
.set(updates)
|
|
197
|
-
.where(eq(pages.id, id))
|
|
198
|
-
.returning();
|
|
199
|
-
|
|
200
|
-
// Release old slug from registry after successful update
|
|
201
|
-
if (slugChanging) {
|
|
202
|
-
await pathRegistry.release(existing.slug);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
return result[0] ? toPage(result[0]) : null;
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
async delete(id) {
|
|
209
|
-
// Release path registry entries for this page
|
|
210
|
-
await pathRegistry.releaseByOwner("page", id);
|
|
211
|
-
// nav_items with page_id FK have ON DELETE CASCADE, so they auto-delete
|
|
212
|
-
const result = await db.delete(pages).where(eq(pages.id, id)).returning();
|
|
213
|
-
return result.length > 0;
|
|
214
|
-
},
|
|
215
|
-
};
|
|
216
|
-
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Path Registry Service
|
|
3
|
-
*
|
|
4
|
-
* Central registry for URL path ownership. Every entity (page, post, redirect)
|
|
5
|
-
* that claims a URL path registers it here. The table's PRIMARY KEY on path
|
|
6
|
-
* provides DB-level uniqueness. Reserved system paths are rejected at the
|
|
7
|
-
* service layer.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { eq, and } from "drizzle-orm";
|
|
11
|
-
import type { Database } from "../db/index.js";
|
|
12
|
-
import { pathRegistry } from "../db/schema.js";
|
|
13
|
-
import { now } from "../lib/time.js";
|
|
14
|
-
import { normalizePath } from "../lib/url.js";
|
|
15
|
-
import { isReservedPath } from "../lib/constants.js";
|
|
16
|
-
import { ValidationError, ConflictError } from "../lib/errors.js";
|
|
17
|
-
|
|
18
|
-
export type OwnerType = "page" | "post" | "redirect";
|
|
19
|
-
|
|
20
|
-
export interface PathRegistryEntry {
|
|
21
|
-
path: string;
|
|
22
|
-
ownerType: OwnerType;
|
|
23
|
-
ownerId: number;
|
|
24
|
-
createdAt: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface PathRegistryService {
|
|
28
|
-
/**
|
|
29
|
-
* Claim a path for an entity. Rejects reserved paths and conflicts.
|
|
30
|
-
* Idempotent: re-claiming the same path for the same owner is a no-op.
|
|
31
|
-
*
|
|
32
|
-
* @param path - The URL path to claim
|
|
33
|
-
* @param ownerType - The type of entity claiming the path
|
|
34
|
-
* @param ownerId - The ID of the entity claiming the path
|
|
35
|
-
* @returns The registry entry
|
|
36
|
-
*/
|
|
37
|
-
claim(
|
|
38
|
-
path: string,
|
|
39
|
-
ownerType: OwnerType,
|
|
40
|
-
ownerId: number,
|
|
41
|
-
): Promise<PathRegistryEntry>;
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Release a claimed path.
|
|
45
|
-
*
|
|
46
|
-
* @param path - The URL path to release
|
|
47
|
-
*/
|
|
48
|
-
release(path: string): Promise<void>;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Release all paths owned by a specific entity.
|
|
52
|
-
*
|
|
53
|
-
* @param ownerType - The type of entity
|
|
54
|
-
* @param ownerId - The ID of the entity
|
|
55
|
-
*/
|
|
56
|
-
releaseByOwner(ownerType: OwnerType, ownerId: number): Promise<void>;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Look up a path in the registry.
|
|
60
|
-
*
|
|
61
|
-
* @param path - The URL path to look up
|
|
62
|
-
* @returns The registry entry, or null if not claimed
|
|
63
|
-
*/
|
|
64
|
-
getByPath(path: string): Promise<PathRegistryEntry | null>;
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Check if a path is available (not reserved and not claimed).
|
|
68
|
-
*
|
|
69
|
-
* @param path - The URL path to check
|
|
70
|
-
* @returns true if the path is available
|
|
71
|
-
*/
|
|
72
|
-
isAvailable(path: string): Promise<boolean>;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function createPathRegistryService(db: Database): PathRegistryService {
|
|
76
|
-
function toEntry(row: typeof pathRegistry.$inferSelect): PathRegistryEntry {
|
|
77
|
-
return {
|
|
78
|
-
path: row.path,
|
|
79
|
-
ownerType: row.ownerType as OwnerType,
|
|
80
|
-
ownerId: row.ownerId,
|
|
81
|
-
createdAt: row.createdAt,
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
async claim(path, ownerType, ownerId) {
|
|
87
|
-
const normalized = normalizePath(path);
|
|
88
|
-
|
|
89
|
-
if (isReservedPath(normalized)) {
|
|
90
|
-
throw new ValidationError(
|
|
91
|
-
`Path "${normalized}" is reserved and cannot be used`,
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Check existing claim
|
|
96
|
-
const existing = await db
|
|
97
|
-
.select()
|
|
98
|
-
.from(pathRegistry)
|
|
99
|
-
.where(eq(pathRegistry.path, normalized))
|
|
100
|
-
.limit(1);
|
|
101
|
-
|
|
102
|
-
if (existing[0]) {
|
|
103
|
-
const entry = toEntry(existing[0]);
|
|
104
|
-
// Idempotent: same owner re-claiming is a no-op
|
|
105
|
-
if (entry.ownerType === ownerType && entry.ownerId === ownerId) {
|
|
106
|
-
return entry;
|
|
107
|
-
}
|
|
108
|
-
throw new ConflictError(`Path "${normalized}" is already in use`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const timestamp = now();
|
|
112
|
-
await db.insert(pathRegistry).values({
|
|
113
|
-
path: normalized,
|
|
114
|
-
ownerType,
|
|
115
|
-
ownerId,
|
|
116
|
-
createdAt: timestamp,
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
return { path: normalized, ownerType, ownerId, createdAt: timestamp };
|
|
120
|
-
},
|
|
121
|
-
|
|
122
|
-
async release(path) {
|
|
123
|
-
const normalized = normalizePath(path);
|
|
124
|
-
await db.delete(pathRegistry).where(eq(pathRegistry.path, normalized));
|
|
125
|
-
},
|
|
126
|
-
|
|
127
|
-
async releaseByOwner(ownerType, ownerId) {
|
|
128
|
-
await db
|
|
129
|
-
.delete(pathRegistry)
|
|
130
|
-
.where(
|
|
131
|
-
and(
|
|
132
|
-
eq(pathRegistry.ownerType, ownerType),
|
|
133
|
-
eq(pathRegistry.ownerId, ownerId),
|
|
134
|
-
),
|
|
135
|
-
);
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
async getByPath(path) {
|
|
139
|
-
const normalized = normalizePath(path);
|
|
140
|
-
const result = await db
|
|
141
|
-
.select()
|
|
142
|
-
.from(pathRegistry)
|
|
143
|
-
.where(eq(pathRegistry.path, normalized))
|
|
144
|
-
.limit(1);
|
|
145
|
-
return result[0] ? toEntry(result[0]) : null;
|
|
146
|
-
},
|
|
147
|
-
|
|
148
|
-
async isAvailable(path) {
|
|
149
|
-
const normalized = normalizePath(path);
|
|
150
|
-
if (isReservedPath(normalized)) return false;
|
|
151
|
-
|
|
152
|
-
const existing = await db
|
|
153
|
-
.select()
|
|
154
|
-
.from(pathRegistry)
|
|
155
|
-
.where(eq(pathRegistry.path, normalized))
|
|
156
|
-
.limit(1);
|
|
157
|
-
return existing.length === 0;
|
|
158
|
-
},
|
|
159
|
-
};
|
|
160
|
-
}
|
package/src/services/redirect.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Redirect Service
|
|
3
|
-
*
|
|
4
|
-
* URL redirect management for path changes
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { eq } from "drizzle-orm";
|
|
8
|
-
import type { Database } from "../db/index.js";
|
|
9
|
-
import { redirects } from "../db/schema.js";
|
|
10
|
-
import { now } from "../lib/time.js";
|
|
11
|
-
import { normalizePath } from "../lib/url.js";
|
|
12
|
-
import type { Redirect } from "../types.js";
|
|
13
|
-
import type { PathRegistryService } from "./path-registry.js";
|
|
14
|
-
import { ConflictError } from "../lib/errors.js";
|
|
15
|
-
|
|
16
|
-
export interface RedirectService {
|
|
17
|
-
getByPath(fromPath: string): Promise<Redirect | null>;
|
|
18
|
-
create(fromPath: string, toPath: string, type?: 301 | 302): Promise<Redirect>;
|
|
19
|
-
delete(id: number): Promise<boolean>;
|
|
20
|
-
list(): Promise<Redirect[]>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function createRedirectService(
|
|
24
|
-
db: Database,
|
|
25
|
-
pathRegistry: PathRegistryService,
|
|
26
|
-
): RedirectService {
|
|
27
|
-
function toRedirect(row: typeof redirects.$inferSelect): Redirect {
|
|
28
|
-
return {
|
|
29
|
-
id: row.id,
|
|
30
|
-
fromPath: row.fromPath,
|
|
31
|
-
toPath: row.toPath,
|
|
32
|
-
type: row.type as 301 | 302,
|
|
33
|
-
createdAt: row.createdAt,
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
async getByPath(fromPath) {
|
|
39
|
-
const normalized = normalizePath(fromPath);
|
|
40
|
-
const result = await db
|
|
41
|
-
.select()
|
|
42
|
-
.from(redirects)
|
|
43
|
-
.where(eq(redirects.fromPath, normalized))
|
|
44
|
-
.limit(1);
|
|
45
|
-
return result[0] ? toRedirect(result[0]) : null;
|
|
46
|
-
},
|
|
47
|
-
|
|
48
|
-
async create(fromPath, toPath, type = 301) {
|
|
49
|
-
const timestamp = now();
|
|
50
|
-
const normalizedFrom = normalizePath(fromPath);
|
|
51
|
-
|
|
52
|
-
// Check if path is claimed by a non-redirect entity
|
|
53
|
-
const existingClaim = await pathRegistry.getByPath(normalizedFrom);
|
|
54
|
-
if (existingClaim && existingClaim.ownerType !== "redirect") {
|
|
55
|
-
throw new ConflictError(`Path "${normalizedFrom}" is already in use`);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Delete existing redirect from this path if any (upsert behavior)
|
|
59
|
-
if (existingClaim?.ownerType === "redirect") {
|
|
60
|
-
await pathRegistry.release(normalizedFrom);
|
|
61
|
-
}
|
|
62
|
-
await db.delete(redirects).where(eq(redirects.fromPath, normalizedFrom));
|
|
63
|
-
|
|
64
|
-
const result = await db
|
|
65
|
-
.insert(redirects)
|
|
66
|
-
.values({
|
|
67
|
-
fromPath: normalizedFrom,
|
|
68
|
-
toPath,
|
|
69
|
-
type,
|
|
70
|
-
createdAt: timestamp,
|
|
71
|
-
})
|
|
72
|
-
.returning();
|
|
73
|
-
|
|
74
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
75
|
-
const redirect = toRedirect(result[0]!);
|
|
76
|
-
|
|
77
|
-
await pathRegistry.claim(normalizedFrom, "redirect", redirect.id);
|
|
78
|
-
|
|
79
|
-
return redirect;
|
|
80
|
-
},
|
|
81
|
-
|
|
82
|
-
async delete(id) {
|
|
83
|
-
// Release path registry entries for this redirect
|
|
84
|
-
await pathRegistry.releaseByOwner("redirect", id);
|
|
85
|
-
const result = await db
|
|
86
|
-
.delete(redirects)
|
|
87
|
-
.where(eq(redirects.id, id))
|
|
88
|
-
.returning();
|
|
89
|
-
return result.length > 0;
|
|
90
|
-
},
|
|
91
|
-
|
|
92
|
-
async list() {
|
|
93
|
-
const rows = await db.select().from(redirects);
|
|
94
|
-
return rows.map(toRedirect);
|
|
95
|
-
},
|
|
96
|
-
};
|
|
97
|
-
}
|
package/src/ui/dash/PageForm.tsx
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Page Creation/Edit Form
|
|
3
|
-
*
|
|
4
|
-
* For managing standalone pages (about, now, etc.)
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { FC } from "hono/jsx";
|
|
8
|
-
import type { Page } from "../../types.js";
|
|
9
|
-
import { useLingui } from "@lingui/react/macro";
|
|
10
|
-
|
|
11
|
-
export interface PageFormProps {
|
|
12
|
-
page?: Page;
|
|
13
|
-
action: string;
|
|
14
|
-
cancelUrl?: string;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export const PageForm: FC<PageFormProps> = ({
|
|
18
|
-
page,
|
|
19
|
-
action,
|
|
20
|
-
cancelUrl = "/dash/pages",
|
|
21
|
-
}) => {
|
|
22
|
-
const { t } = useLingui();
|
|
23
|
-
const isEdit = !!page;
|
|
24
|
-
|
|
25
|
-
const signals = JSON.stringify({
|
|
26
|
-
title: page?.title ?? "",
|
|
27
|
-
slug: page?.slug ?? "",
|
|
28
|
-
body: page?.body ?? "",
|
|
29
|
-
status: page?.status ?? "published",
|
|
30
|
-
}).replace(/</g, "\\u003c");
|
|
31
|
-
|
|
32
|
-
return (
|
|
33
|
-
<form
|
|
34
|
-
data-page-form
|
|
35
|
-
{...(isEdit ? { "data-page-edit": "" } : {})}
|
|
36
|
-
data-signals={signals}
|
|
37
|
-
data-on:submit__prevent={`@post('${action}')`}
|
|
38
|
-
data-indicator="_loading"
|
|
39
|
-
class="flex flex-col gap-4"
|
|
40
|
-
>
|
|
41
|
-
<div id="page-form-message"></div>
|
|
42
|
-
|
|
43
|
-
{/* Title */}
|
|
44
|
-
<div class="field">
|
|
45
|
-
<label class="label">
|
|
46
|
-
{t({
|
|
47
|
-
message: "Title",
|
|
48
|
-
comment: "@context: Page form field label - title",
|
|
49
|
-
})}
|
|
50
|
-
</label>
|
|
51
|
-
<input
|
|
52
|
-
type="text"
|
|
53
|
-
data-bind="title"
|
|
54
|
-
class="input"
|
|
55
|
-
placeholder={t({
|
|
56
|
-
message: "Page title...",
|
|
57
|
-
comment: "@context: Page title placeholder",
|
|
58
|
-
})}
|
|
59
|
-
required
|
|
60
|
-
/>
|
|
61
|
-
</div>
|
|
62
|
-
|
|
63
|
-
{/* Slug */}
|
|
64
|
-
<div class="field">
|
|
65
|
-
<label class="label">
|
|
66
|
-
{t({
|
|
67
|
-
message: "Slug",
|
|
68
|
-
comment: "@context: Page form field label - URL slug",
|
|
69
|
-
})}
|
|
70
|
-
</label>
|
|
71
|
-
<div class="flex items-center gap-2">
|
|
72
|
-
<span class="text-muted-foreground">/</span>
|
|
73
|
-
<input
|
|
74
|
-
type="text"
|
|
75
|
-
data-bind="slug"
|
|
76
|
-
class="input flex-1"
|
|
77
|
-
placeholder="about"
|
|
78
|
-
pattern="[a-z0-9\-]+"
|
|
79
|
-
title={t({
|
|
80
|
-
message: "Lowercase letters, numbers, and hyphens only",
|
|
81
|
-
comment: "@context: Page slug validation message",
|
|
82
|
-
})}
|
|
83
|
-
required
|
|
84
|
-
/>
|
|
85
|
-
</div>
|
|
86
|
-
<p class="text-xs text-muted-foreground mt-1">
|
|
87
|
-
{t({
|
|
88
|
-
message:
|
|
89
|
-
"The URL path for this page. Use lowercase letters, numbers, and hyphens.",
|
|
90
|
-
comment: "@context: Page slug helper text",
|
|
91
|
-
})}
|
|
92
|
-
</p>
|
|
93
|
-
</div>
|
|
94
|
-
|
|
95
|
-
{/* Body */}
|
|
96
|
-
<div class="field">
|
|
97
|
-
<label class="label">
|
|
98
|
-
{t({
|
|
99
|
-
message: "Content",
|
|
100
|
-
comment: "@context: Page form field label - content",
|
|
101
|
-
})}
|
|
102
|
-
</label>
|
|
103
|
-
<textarea
|
|
104
|
-
data-bind="body"
|
|
105
|
-
class="textarea min-h-48"
|
|
106
|
-
placeholder={t({
|
|
107
|
-
message: "Page content (Markdown supported)...",
|
|
108
|
-
comment: "@context: Page content placeholder",
|
|
109
|
-
})}
|
|
110
|
-
required
|
|
111
|
-
>
|
|
112
|
-
{page?.body ?? ""}
|
|
113
|
-
</textarea>
|
|
114
|
-
</div>
|
|
115
|
-
|
|
116
|
-
{/* Status */}
|
|
117
|
-
<div class="field">
|
|
118
|
-
<label class="label">
|
|
119
|
-
{t({
|
|
120
|
-
message: "Status",
|
|
121
|
-
comment: "@context: Page form field label - publish status",
|
|
122
|
-
})}
|
|
123
|
-
</label>
|
|
124
|
-
<select data-bind="status" class="select">
|
|
125
|
-
<option
|
|
126
|
-
value="published"
|
|
127
|
-
selected={page?.status === "published" || !page}
|
|
128
|
-
>
|
|
129
|
-
{t({
|
|
130
|
-
message: "Published",
|
|
131
|
-
comment: "@context: Page status option - published",
|
|
132
|
-
})}
|
|
133
|
-
</option>
|
|
134
|
-
<option value="draft" selected={page?.status === "draft"}>
|
|
135
|
-
{t({
|
|
136
|
-
message: "Draft",
|
|
137
|
-
comment: "@context: Page status option - draft",
|
|
138
|
-
})}
|
|
139
|
-
</option>
|
|
140
|
-
</select>
|
|
141
|
-
<p class="text-xs text-muted-foreground mt-1">
|
|
142
|
-
{t({
|
|
143
|
-
message:
|
|
144
|
-
"Published pages are accessible via their slug. Drafts are not visible.",
|
|
145
|
-
comment: "@context: Page status helper text",
|
|
146
|
-
})}
|
|
147
|
-
</p>
|
|
148
|
-
</div>
|
|
149
|
-
|
|
150
|
-
{/* Submit */}
|
|
151
|
-
<div class="flex gap-2">
|
|
152
|
-
<button type="submit" class="btn" data-attr:disabled="$_loading">
|
|
153
|
-
<svg
|
|
154
|
-
data-show="$_loading"
|
|
155
|
-
style="display:none"
|
|
156
|
-
class="animate-spin size-4"
|
|
157
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
158
|
-
viewBox="0 0 24 24"
|
|
159
|
-
fill="none"
|
|
160
|
-
stroke="currentColor"
|
|
161
|
-
stroke-width="2"
|
|
162
|
-
stroke-linecap="round"
|
|
163
|
-
stroke-linejoin="round"
|
|
164
|
-
role="status"
|
|
165
|
-
>
|
|
166
|
-
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
167
|
-
</svg>
|
|
168
|
-
{isEdit
|
|
169
|
-
? t({
|
|
170
|
-
message: "Update Page",
|
|
171
|
-
comment: "@context: Button to update existing page",
|
|
172
|
-
})
|
|
173
|
-
: t({
|
|
174
|
-
message: "Create Page",
|
|
175
|
-
comment: "@context: Button to create new page",
|
|
176
|
-
})}
|
|
177
|
-
</button>
|
|
178
|
-
<a href={cancelUrl} class="btn-outline">
|
|
179
|
-
{t({
|
|
180
|
-
message: "Cancel",
|
|
181
|
-
comment: "@context: Button to cancel and go back",
|
|
182
|
-
})}
|
|
183
|
-
</a>
|
|
184
|
-
</div>
|
|
185
|
-
</form>
|
|
186
|
-
);
|
|
187
|
-
};
|