@jant/core 0.3.36 → 0.3.38
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/client/toast.ts
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* Appends a temporary notification to `#toast-container`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/** Ensure the toast container is in the top layer (above <dialog> etc.) */
|
|
9
|
+
function ensureTopLayer(container: HTMLElement): void {
|
|
10
|
+
if (!container.matches(":popover-open")) {
|
|
11
|
+
container.showPopover();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
const TOAST_ICONS = {
|
|
9
16
|
success:
|
|
10
17
|
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>',
|
|
@@ -12,6 +19,26 @@ const TOAST_ICONS = {
|
|
|
12
19
|
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>',
|
|
13
20
|
};
|
|
14
21
|
|
|
22
|
+
/** Build toast inner content using safe DOM APIs (icon is trusted, text uses textContent). */
|
|
23
|
+
function setToastContent(
|
|
24
|
+
toast: HTMLElement,
|
|
25
|
+
type: "success" | "error",
|
|
26
|
+
message: string,
|
|
27
|
+
action?: { label: string; href: string },
|
|
28
|
+
): void {
|
|
29
|
+
toast.innerHTML = TOAST_ICONS[type];
|
|
30
|
+
const span = document.createElement("span");
|
|
31
|
+
span.textContent = message;
|
|
32
|
+
toast.appendChild(span);
|
|
33
|
+
if (action) {
|
|
34
|
+
const a = document.createElement("a");
|
|
35
|
+
a.href = action.href;
|
|
36
|
+
a.className = "toast-action";
|
|
37
|
+
a.textContent = action.label;
|
|
38
|
+
toast.appendChild(a);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
15
42
|
/**
|
|
16
43
|
* Show a toast notification.
|
|
17
44
|
*
|
|
@@ -31,9 +58,44 @@ export function showToast(
|
|
|
31
58
|
const container = document.getElementById("toast-container");
|
|
32
59
|
if (!container) return;
|
|
33
60
|
|
|
61
|
+
ensureTopLayer(container);
|
|
62
|
+
|
|
34
63
|
const toast = document.createElement("div");
|
|
35
64
|
toast.className = `toast toast-${type}`;
|
|
36
|
-
toast
|
|
65
|
+
setToastContent(toast, type, message);
|
|
66
|
+
container.appendChild(toast);
|
|
67
|
+
|
|
68
|
+
setTimeout(() => {
|
|
69
|
+
toast.classList.add("toast-out");
|
|
70
|
+
toast.addEventListener("animationend", () => toast.remove());
|
|
71
|
+
}, 3000);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Show a toast with an action link.
|
|
76
|
+
*
|
|
77
|
+
* @param message - Text to display
|
|
78
|
+
* @param action - Action link with label and href
|
|
79
|
+
* @param type - Visual style: "success" (default) or "error"
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* showToastWithAction("Post published.", { label: "View", href: "/p/abc" });
|
|
83
|
+
*/
|
|
84
|
+
export function showToastWithAction(
|
|
85
|
+
message: string,
|
|
86
|
+
action: { label: string; href: string },
|
|
87
|
+
type: "success" | "error" = "success",
|
|
88
|
+
): void {
|
|
89
|
+
if (!message) return;
|
|
90
|
+
|
|
91
|
+
const container = document.getElementById("toast-container");
|
|
92
|
+
if (!container) return;
|
|
93
|
+
|
|
94
|
+
ensureTopLayer(container);
|
|
95
|
+
|
|
96
|
+
const toast = document.createElement("div");
|
|
97
|
+
toast.className = `toast toast-${type}`;
|
|
98
|
+
setToastContent(toast, type, message, action);
|
|
37
99
|
container.appendChild(toast);
|
|
38
100
|
|
|
39
101
|
setTimeout(() => {
|
|
@@ -61,10 +123,12 @@ export function showPersistentToast(
|
|
|
61
123
|
const container = document.getElementById("toast-container");
|
|
62
124
|
if (!container) return null;
|
|
63
125
|
|
|
126
|
+
ensureTopLayer(container);
|
|
127
|
+
|
|
64
128
|
const toast = document.createElement("div");
|
|
65
129
|
toast.className = `toast toast-${type}`;
|
|
66
130
|
toast.id = `toast-${id}`;
|
|
67
|
-
toast
|
|
131
|
+
setToastContent(toast, type, message);
|
|
68
132
|
container.appendChild(toast);
|
|
69
133
|
|
|
70
134
|
return toast;
|
|
@@ -125,7 +189,41 @@ export function replaceWithAutoClose(
|
|
|
125
189
|
}
|
|
126
190
|
|
|
127
191
|
toast.className = `toast toast-${type}`;
|
|
128
|
-
toast.
|
|
192
|
+
toast.replaceChildren();
|
|
193
|
+
setToastContent(toast, type, message);
|
|
194
|
+
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
toast.classList.add("toast-out");
|
|
197
|
+
toast.addEventListener("animationend", () => toast.remove());
|
|
198
|
+
}, 3000);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Replace a persistent toast with an auto-dismissing one that has an action link.
|
|
203
|
+
*
|
|
204
|
+
* @param id - The toast identifier
|
|
205
|
+
* @param message - New message text
|
|
206
|
+
* @param action - Action link with label and href
|
|
207
|
+
* @param type - Visual style: "success" (default) or "error"
|
|
208
|
+
*
|
|
209
|
+
* @example
|
|
210
|
+
* replaceWithAutoCloseAction("upload", "Post published.", { label: "View", href: "/p/abc" });
|
|
211
|
+
*/
|
|
212
|
+
export function replaceWithAutoCloseAction(
|
|
213
|
+
id: string,
|
|
214
|
+
message: string,
|
|
215
|
+
action: { label: string; href: string },
|
|
216
|
+
type: "success" | "error" = "success",
|
|
217
|
+
): void {
|
|
218
|
+
const toast = document.getElementById(`toast-${id}`);
|
|
219
|
+
if (!toast) {
|
|
220
|
+
showToastWithAction(message, action, type);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
toast.className = `toast toast-${type}`;
|
|
225
|
+
toast.replaceChildren();
|
|
226
|
+
setToastContent(toast, type, message, action);
|
|
129
227
|
|
|
130
228
|
setTimeout(() => {
|
|
131
229
|
toast.classList.add("toast-out");
|
|
@@ -13,8 +13,23 @@ declare module "sortablejs" {
|
|
|
13
13
|
|
|
14
14
|
interface SortableOptions {
|
|
15
15
|
animation?: number;
|
|
16
|
+
bubbleScroll?: boolean;
|
|
17
|
+
chosenClass?: string;
|
|
18
|
+
direction?: "horizontal" | "vertical";
|
|
19
|
+
dragClass?: string;
|
|
20
|
+
fallbackTolerance?: number;
|
|
21
|
+
filter?: string;
|
|
22
|
+
forceAutoScrollFallback?: boolean;
|
|
23
|
+
ghostClass?: string;
|
|
16
24
|
handle?: string;
|
|
25
|
+
onChoose?: (event: SortableEvent) => void;
|
|
26
|
+
onStart?: (event: SortableEvent) => void;
|
|
27
|
+
onUnchoose?: (event: SortableEvent) => void;
|
|
17
28
|
onEnd?: (event: SortableEvent) => void;
|
|
29
|
+
preventOnFilter?: boolean;
|
|
30
|
+
scroll?: boolean | HTMLElement;
|
|
31
|
+
scrollSensitivity?: number;
|
|
32
|
+
scrollSpeed?: number;
|
|
18
33
|
}
|
|
19
34
|
|
|
20
35
|
interface SortableInstance {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Upload Helper with Metadata
|
|
3
|
+
*
|
|
4
|
+
* Processes images via ImageProcessor, extracts dimensions + blurhash,
|
|
5
|
+
* and uploads with metadata attached to the FormData.
|
|
6
|
+
* Used by paste-image, image-node replace, and fullscreen compose.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ImageProcessor } from "./image-processor.js";
|
|
10
|
+
import { extractImageMetadata } from "./media-metadata.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Process an image file and upload it with dimension/blurhash metadata.
|
|
14
|
+
*
|
|
15
|
+
* @returns The server response with url and id
|
|
16
|
+
*/
|
|
17
|
+
export async function uploadWithMetadata(
|
|
18
|
+
file: File,
|
|
19
|
+
): Promise<{ url: string; id: string }> {
|
|
20
|
+
// Process image (resize, convert to WebP)
|
|
21
|
+
const {
|
|
22
|
+
file: processed,
|
|
23
|
+
width,
|
|
24
|
+
height,
|
|
25
|
+
} = await ImageProcessor.processToFile(file);
|
|
26
|
+
|
|
27
|
+
// Extract blurhash from the processed file
|
|
28
|
+
let blurhash: string | undefined;
|
|
29
|
+
try {
|
|
30
|
+
const meta = await extractImageMetadata(processed);
|
|
31
|
+
blurhash = meta.blurhash;
|
|
32
|
+
} catch {
|
|
33
|
+
// Blurhash extraction failed — upload without it
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const formData = new FormData();
|
|
37
|
+
formData.append("file", processed);
|
|
38
|
+
formData.append("width", String(width));
|
|
39
|
+
formData.append("height", String(height));
|
|
40
|
+
if (blurhash) {
|
|
41
|
+
formData.append("blurhash", blurhash);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await fetch("/api/upload", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
body: formData,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(`Upload failed: ${response.status}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (await response.json()) as { url: string; id: string };
|
|
54
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Video Processor
|
|
3
|
+
*
|
|
4
|
+
* Processes videos before upload using mediabunny:
|
|
5
|
+
* - Transcodes to H.264/AAC MP4 (universal playback)
|
|
6
|
+
* - Resizes to max 1920×1080
|
|
7
|
+
* - Extracts poster frame + blurhash during processing
|
|
8
|
+
*
|
|
9
|
+
* Requires WebCodecs API support — check `isSupported()` before use.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
Input,
|
|
14
|
+
Output,
|
|
15
|
+
Mp4OutputFormat,
|
|
16
|
+
BufferTarget,
|
|
17
|
+
BlobSource,
|
|
18
|
+
CanvasSink,
|
|
19
|
+
Conversion,
|
|
20
|
+
QUALITY_HIGH,
|
|
21
|
+
ALL_FORMATS,
|
|
22
|
+
} from "mediabunny";
|
|
23
|
+
import { encode } from "blurhash";
|
|
24
|
+
|
|
25
|
+
const MAX_WIDTH = 1920;
|
|
26
|
+
const MAX_HEIGHT = 1080;
|
|
27
|
+
const POSTER_WIDTH = 640;
|
|
28
|
+
const BLURHASH_SIZE = 32;
|
|
29
|
+
|
|
30
|
+
export interface VideoProcessResult {
|
|
31
|
+
file: File;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
poster?: Blob;
|
|
35
|
+
blurhash?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if the browser supports WebCodecs-based video processing.
|
|
40
|
+
*
|
|
41
|
+
* @returns `true` if `VideoEncoder` is available in the current environment
|
|
42
|
+
*/
|
|
43
|
+
function isSupported(): boolean {
|
|
44
|
+
return typeof VideoEncoder !== "undefined";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract a poster frame, blurhash, and source dimensions from a video file.
|
|
49
|
+
* Seeks to `min(duration × 0.1, 3s)` and captures the frame.
|
|
50
|
+
* Also returns the original video dimensions so the caller can compute
|
|
51
|
+
* the correct output size without opening a second Input instance.
|
|
52
|
+
*
|
|
53
|
+
* @param file - Source video file
|
|
54
|
+
* @returns Poster blob (640px-wide WebP), blurhash string, and source dimensions
|
|
55
|
+
*/
|
|
56
|
+
async function extractPoster(file: File): Promise<{
|
|
57
|
+
poster?: Blob;
|
|
58
|
+
blurhash?: string;
|
|
59
|
+
sourceWidth?: number;
|
|
60
|
+
sourceHeight?: number;
|
|
61
|
+
}> {
|
|
62
|
+
const input = new Input({
|
|
63
|
+
source: new BlobSource(file),
|
|
64
|
+
formats: ALL_FORMATS,
|
|
65
|
+
});
|
|
66
|
+
try {
|
|
67
|
+
const videoTrack = await input.getPrimaryVideoTrack();
|
|
68
|
+
if (!videoTrack) return {};
|
|
69
|
+
|
|
70
|
+
const sourceWidth = videoTrack.displayWidth;
|
|
71
|
+
const sourceHeight = videoTrack.displayHeight;
|
|
72
|
+
|
|
73
|
+
const duration = await input.computeDuration();
|
|
74
|
+
const seekTime = Math.min(duration * 0.1, 3);
|
|
75
|
+
|
|
76
|
+
const sink = new CanvasSink(videoTrack);
|
|
77
|
+
const wrapped = await sink.getCanvas(seekTime);
|
|
78
|
+
if (!wrapped) return { sourceWidth, sourceHeight };
|
|
79
|
+
|
|
80
|
+
const canvas = wrapped.canvas as HTMLCanvasElement;
|
|
81
|
+
|
|
82
|
+
// Poster: 640px wide WebP
|
|
83
|
+
const srcW = canvas.width;
|
|
84
|
+
const srcH = canvas.height;
|
|
85
|
+
const posterScale = Math.min(POSTER_WIDTH / srcW, 1);
|
|
86
|
+
const pw = Math.round(srcW * posterScale);
|
|
87
|
+
const ph = Math.round(srcH * posterScale);
|
|
88
|
+
|
|
89
|
+
const posterCanvas = document.createElement("canvas");
|
|
90
|
+
posterCanvas.width = pw;
|
|
91
|
+
posterCanvas.height = ph;
|
|
92
|
+
const pCtx = posterCanvas.getContext("2d");
|
|
93
|
+
if (!pCtx) return { sourceWidth, sourceHeight };
|
|
94
|
+
pCtx.drawImage(canvas, 0, 0, pw, ph);
|
|
95
|
+
|
|
96
|
+
const poster = await new Promise<Blob | undefined>((resolve) => {
|
|
97
|
+
posterCanvas.toBlob(
|
|
98
|
+
(blob) => resolve(blob ?? undefined),
|
|
99
|
+
"image/webp",
|
|
100
|
+
0.8,
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Blurhash: 32px canvas, 4×3 components
|
|
105
|
+
const bhScale = Math.min(BLURHASH_SIZE / srcW, BLURHASH_SIZE / srcH, 1);
|
|
106
|
+
const bw = Math.max(Math.round(srcW * bhScale), 1);
|
|
107
|
+
const bh = Math.max(Math.round(srcH * bhScale), 1);
|
|
108
|
+
|
|
109
|
+
const bhCanvas = document.createElement("canvas");
|
|
110
|
+
bhCanvas.width = bw;
|
|
111
|
+
bhCanvas.height = bh;
|
|
112
|
+
const bhCtx = bhCanvas.getContext("2d");
|
|
113
|
+
if (!bhCtx) return { poster, sourceWidth, sourceHeight };
|
|
114
|
+
bhCtx.drawImage(canvas, 0, 0, bw, bh);
|
|
115
|
+
|
|
116
|
+
const imageData = bhCtx.getImageData(0, 0, bw, bh);
|
|
117
|
+
const blurhash = encode(imageData.data, bw, bh, 4, 3);
|
|
118
|
+
|
|
119
|
+
return { poster, blurhash, sourceWidth, sourceHeight };
|
|
120
|
+
} catch {
|
|
121
|
+
return {};
|
|
122
|
+
} finally {
|
|
123
|
+
input.dispose();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Process a video file: transcode to H.264/AAC MP4, resize to fit within
|
|
129
|
+
* 1920×1080, and extract poster frame + blurhash.
|
|
130
|
+
*
|
|
131
|
+
* @param file - Source video file
|
|
132
|
+
* @param onProgress - Optional callback receiving progress from 0 to 1
|
|
133
|
+
* @returns Processed MP4 file with dimensions, poster, and blurhash
|
|
134
|
+
*/
|
|
135
|
+
async function processToFile(
|
|
136
|
+
file: File,
|
|
137
|
+
onProgress?: (progress: number) => void,
|
|
138
|
+
): Promise<VideoProcessResult> {
|
|
139
|
+
// Extract poster + blurhash + source dimensions (separate Input instance,
|
|
140
|
+
// so the transcoding Input below starts with clean demuxer state).
|
|
141
|
+
const { poster, blurhash, sourceWidth, sourceHeight } =
|
|
142
|
+
await extractPoster(file);
|
|
143
|
+
|
|
144
|
+
// Compute output size preserving the original aspect ratio
|
|
145
|
+
let width = MAX_WIDTH;
|
|
146
|
+
let height = MAX_HEIGHT;
|
|
147
|
+
if (sourceWidth && sourceHeight) {
|
|
148
|
+
const scale = Math.min(
|
|
149
|
+
MAX_WIDTH / sourceWidth,
|
|
150
|
+
MAX_HEIGHT / sourceHeight,
|
|
151
|
+
1,
|
|
152
|
+
);
|
|
153
|
+
width = Math.round(sourceWidth * scale);
|
|
154
|
+
height = Math.round(sourceHeight * scale);
|
|
155
|
+
}
|
|
156
|
+
// H.264 requires even dimensions
|
|
157
|
+
width += width % 2;
|
|
158
|
+
height += height % 2;
|
|
159
|
+
|
|
160
|
+
// Transcode to MP4 H.264/AAC (fresh Input — not shared with extractPoster)
|
|
161
|
+
const input = new Input({
|
|
162
|
+
source: new BlobSource(file),
|
|
163
|
+
formats: ALL_FORMATS,
|
|
164
|
+
});
|
|
165
|
+
const target = new BufferTarget();
|
|
166
|
+
const output = new Output({
|
|
167
|
+
format: new Mp4OutputFormat({ fastStart: "in-memory" }),
|
|
168
|
+
target,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const conversion = await Conversion.init({
|
|
173
|
+
input,
|
|
174
|
+
output,
|
|
175
|
+
video: {
|
|
176
|
+
codec: "avc",
|
|
177
|
+
width,
|
|
178
|
+
height,
|
|
179
|
+
fit: "contain",
|
|
180
|
+
bitrate: QUALITY_HIGH,
|
|
181
|
+
},
|
|
182
|
+
audio: {
|
|
183
|
+
codec: "aac",
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (onProgress) {
|
|
188
|
+
conversion.onProgress = onProgress;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
await conversion.execute();
|
|
192
|
+
|
|
193
|
+
const buffer = target.buffer;
|
|
194
|
+
if (!buffer) throw new Error("Video processing produced no output");
|
|
195
|
+
|
|
196
|
+
const originalName = file.name.replace(/\.[^.]+$/, "");
|
|
197
|
+
const mp4File = new File([buffer], `${originalName}.mp4`, {
|
|
198
|
+
type: "video/mp4",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return { file: mp4File, width, height, poster, blurhash };
|
|
202
|
+
} finally {
|
|
203
|
+
input.dispose();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export const VideoProcessor = { isSupported, processToFile };
|
package/src/client.ts
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
import "./vendor/datastar.js";
|
|
11
11
|
import "basecoat-css/all";
|
|
12
12
|
import "./client/image-processor.js";
|
|
13
|
-
import "./client/media-upload.js";
|
|
14
13
|
import "./client/avatar-upload.js";
|
|
15
14
|
|
|
16
15
|
// Lit Web Components (and their bridge modules)
|
|
@@ -30,7 +29,11 @@ import "./client/components/jant-collection-sidebar.js";
|
|
|
30
29
|
import "./client/collection-form-bridge.js";
|
|
31
30
|
import "./client/components/jant-post-form.js";
|
|
32
31
|
import "./client/post-form-bridge.js";
|
|
33
|
-
import "./client/page-slug-bridge.js";
|
|
34
32
|
import "./client/components/jant-nav-manager.js";
|
|
35
33
|
import "./client/nav-manager-bridge.js";
|
|
34
|
+
import "./client/audio-player.js";
|
|
36
35
|
import "./client/components/jant-media-lightbox.js";
|
|
36
|
+
import "./client/components/jant-text-preview.js";
|
|
37
|
+
import "./client/components/jant-post-menu.js";
|
|
38
|
+
import "./client/thread-context.js";
|
|
39
|
+
import "./client/archive-nav.js";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration Integrity Tests
|
|
3
|
+
*
|
|
4
|
+
* Ensures every migration SQL file is tracked in the Drizzle journal.
|
|
5
|
+
* Hand-written migrations bypass drizzle-kit and won't have journal entries,
|
|
6
|
+
* which breaks `drizzle-kit generate` for future schema changes.
|
|
7
|
+
*
|
|
8
|
+
* Fix: always run `mise run db-generate` instead of writing SQL by hand.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from "vitest";
|
|
12
|
+
import { readdirSync, readFileSync } from "fs";
|
|
13
|
+
import { resolve } from "path";
|
|
14
|
+
|
|
15
|
+
const MIGRATIONS_DIR = resolve(import.meta.dirname, "../migrations");
|
|
16
|
+
const JOURNAL_PATH = resolve(MIGRATIONS_DIR, "meta/_journal.json");
|
|
17
|
+
|
|
18
|
+
interface JournalEntry {
|
|
19
|
+
idx: number;
|
|
20
|
+
version: string;
|
|
21
|
+
when: number;
|
|
22
|
+
tag: string;
|
|
23
|
+
breakpoints: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Journal {
|
|
27
|
+
version: string;
|
|
28
|
+
dialect: string;
|
|
29
|
+
entries: JournalEntry[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readJournal(): Journal {
|
|
33
|
+
return JSON.parse(readFileSync(JOURNAL_PATH, "utf-8"));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function listMigrationFiles(): string[] {
|
|
37
|
+
return readdirSync(MIGRATIONS_DIR)
|
|
38
|
+
.filter((f) => f.endsWith(".sql"))
|
|
39
|
+
.sort();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("migration integrity", () => {
|
|
43
|
+
it("every SQL file has a corresponding journal entry", () => {
|
|
44
|
+
const journal = readJournal();
|
|
45
|
+
const tags = new Set(journal.entries.map((e) => e.tag));
|
|
46
|
+
const sqlFiles = listMigrationFiles();
|
|
47
|
+
|
|
48
|
+
const untracked = sqlFiles
|
|
49
|
+
.map((f) => f.replace(".sql", ""))
|
|
50
|
+
.filter((tag) => !tags.has(tag));
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
untracked,
|
|
54
|
+
[
|
|
55
|
+
"These migration files are not tracked in meta/_journal.json.",
|
|
56
|
+
"This usually means they were hand-written instead of generated with `mise run db-generate`.",
|
|
57
|
+
"Fix: update src/db/schema.ts first, then run `mise run db-generate`.",
|
|
58
|
+
`Untracked files: ${untracked.map((t) => `${t}.sql`).join(", ")}`,
|
|
59
|
+
].join("\n"),
|
|
60
|
+
).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("every journal entry has a corresponding SQL file", () => {
|
|
64
|
+
const journal = readJournal();
|
|
65
|
+
const sqlFiles = new Set(
|
|
66
|
+
listMigrationFiles().map((f) => f.replace(".sql", "")),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const missing = journal.entries
|
|
70
|
+
.map((e) => e.tag)
|
|
71
|
+
.filter((tag) => !sqlFiles.has(tag));
|
|
72
|
+
|
|
73
|
+
expect(
|
|
74
|
+
missing,
|
|
75
|
+
[
|
|
76
|
+
"These journal entries have no matching SQL file.",
|
|
77
|
+
`Missing files: ${missing.map((t) => `${t}.sql`).join(", ")}`,
|
|
78
|
+
].join("\n"),
|
|
79
|
+
).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("journal entries have sequential idx values", () => {
|
|
83
|
+
const journal = readJournal();
|
|
84
|
+
for (let i = 0; i < journal.entries.length; i++) {
|
|
85
|
+
const entry = journal.entries[i];
|
|
86
|
+
if (entry) expect(entry.idx).toBe(i);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("latest migration has a snapshot file", () => {
|
|
91
|
+
const journal = readJournal();
|
|
92
|
+
const lastEntry = journal.entries[journal.entries.length - 1];
|
|
93
|
+
if (!lastEntry) return;
|
|
94
|
+
|
|
95
|
+
const prefix = lastEntry.tag.split("_")[0];
|
|
96
|
+
const snapshotPath = resolve(
|
|
97
|
+
MIGRATIONS_DIR,
|
|
98
|
+
`meta/${prefix}_snapshot.json`,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
let exists = false;
|
|
102
|
+
try {
|
|
103
|
+
readFileSync(snapshotPath);
|
|
104
|
+
exists = true;
|
|
105
|
+
} catch {
|
|
106
|
+
// file doesn't exist
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
expect(
|
|
110
|
+
exists,
|
|
111
|
+
[
|
|
112
|
+
`Missing snapshot for latest migration: meta/${prefix}_snapshot.json`,
|
|
113
|
+
"This means the migration was not generated by drizzle-kit.",
|
|
114
|
+
"Fix: run `mise run db-generate` to regenerate it properly.",
|
|
115
|
+
].join("\n"),
|
|
116
|
+
).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
});
|
package/src/db/index.ts
CHANGED
|
@@ -12,3 +12,55 @@ export function createDatabase(d1: D1Database) {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export { schema };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* D1 enforces a lower SQL variable limit than standard SQLite (~999).
|
|
18
|
+
* Keep batch size well under the limit to leave room for other
|
|
19
|
+
* query parameters besides the IN-list.
|
|
20
|
+
*/
|
|
21
|
+
const BATCH_SIZE = 50;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run a query function in batches to avoid SQLite's variable limit.
|
|
25
|
+
* Splits `items` into chunks, calls `fn` for each chunk, and merges
|
|
26
|
+
* the resulting Maps.
|
|
27
|
+
*
|
|
28
|
+
* @param items - Array of IDs to batch
|
|
29
|
+
* @param fn - Async function that takes a chunk and returns a Map
|
|
30
|
+
* @returns Merged Map from all batches
|
|
31
|
+
*/
|
|
32
|
+
export async function batchQuery<K, V>(
|
|
33
|
+
items: K[],
|
|
34
|
+
fn: (chunk: K[]) => Promise<Map<K, V>>,
|
|
35
|
+
): Promise<Map<K, V>> {
|
|
36
|
+
if (items.length <= BATCH_SIZE) return fn(items);
|
|
37
|
+
|
|
38
|
+
const result = new Map<K, V>();
|
|
39
|
+
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
|
40
|
+
const chunk = items.slice(i, i + BATCH_SIZE);
|
|
41
|
+
const partial = await fn(chunk);
|
|
42
|
+
for (const [k, v] of partial) {
|
|
43
|
+
result.set(k, v);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Like `batchQuery` but for functions that return an array of rows
|
|
51
|
+
* rather than a Map.
|
|
52
|
+
*/
|
|
53
|
+
export async function batchQueryRows<K, R>(
|
|
54
|
+
items: K[],
|
|
55
|
+
fn: (chunk: K[]) => Promise<R[]>,
|
|
56
|
+
): Promise<R[]> {
|
|
57
|
+
if (items.length <= BATCH_SIZE) return fn(items);
|
|
58
|
+
|
|
59
|
+
const result: R[] = [];
|
|
60
|
+
for (let i = 0; i < items.length; i += BATCH_SIZE) {
|
|
61
|
+
const chunk = items.slice(i, i + BATCH_SIZE);
|
|
62
|
+
const partial = await fn(chunk);
|
|
63
|
+
result.push(...partial);
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|