@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
|
@@ -128,13 +128,25 @@ function calculateDimensions(
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
export interface ProcessResult {
|
|
132
|
+
blob: Blob;
|
|
133
|
+
width: number;
|
|
134
|
+
height: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface ProcessToFileResult {
|
|
138
|
+
file: File;
|
|
139
|
+
width: number;
|
|
140
|
+
height: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
131
143
|
/**
|
|
132
144
|
* Process image file
|
|
133
145
|
*/
|
|
134
146
|
async function process(
|
|
135
147
|
file: File,
|
|
136
148
|
options: ProcessOptions = {},
|
|
137
|
-
): Promise<
|
|
149
|
+
): Promise<ProcessResult> {
|
|
138
150
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
139
151
|
|
|
140
152
|
// Read file buffer for EXIF
|
|
@@ -185,11 +197,11 @@ async function process(
|
|
|
185
197
|
ctx.restore();
|
|
186
198
|
|
|
187
199
|
// Export as WebP
|
|
188
|
-
|
|
200
|
+
const blob = await new Promise<Blob>((resolve, reject) => {
|
|
189
201
|
canvas.toBlob(
|
|
190
|
-
(
|
|
191
|
-
if (
|
|
192
|
-
resolve(
|
|
202
|
+
(b) => {
|
|
203
|
+
if (b) {
|
|
204
|
+
resolve(b);
|
|
193
205
|
} else {
|
|
194
206
|
reject(new Error("Failed to create blob"));
|
|
195
207
|
}
|
|
@@ -198,6 +210,8 @@ async function process(
|
|
|
198
210
|
opts.quality,
|
|
199
211
|
);
|
|
200
212
|
});
|
|
213
|
+
|
|
214
|
+
return { blob, width, height };
|
|
201
215
|
}
|
|
202
216
|
|
|
203
217
|
/**
|
|
@@ -206,14 +220,18 @@ async function process(
|
|
|
206
220
|
async function processToFile(
|
|
207
221
|
file: File,
|
|
208
222
|
options: ProcessOptions = {},
|
|
209
|
-
): Promise<
|
|
210
|
-
const blob = await process(file, options);
|
|
223
|
+
): Promise<ProcessToFileResult> {
|
|
224
|
+
const { blob, width, height } = await process(file, options);
|
|
211
225
|
|
|
212
226
|
// Generate new filename with .webp extension
|
|
213
227
|
const originalName = file.name.replace(/\.[^.]+$/, "");
|
|
214
228
|
const newName = `${originalName}.webp`;
|
|
215
229
|
|
|
216
|
-
return
|
|
230
|
+
return {
|
|
231
|
+
file: new File([blob], newName, { type: "image/webp" }),
|
|
232
|
+
width,
|
|
233
|
+
height,
|
|
234
|
+
};
|
|
217
235
|
}
|
|
218
236
|
|
|
219
237
|
export const ImageProcessor = { process, processToFile };
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Media Metadata Extraction
|
|
3
|
+
*
|
|
4
|
+
* Extracts dimensions and blurhash from image/video files using
|
|
5
|
+
* Canvas API and the blurhash library.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { encode } from "blurhash";
|
|
9
|
+
|
|
10
|
+
export interface MediaMetadata {
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
blurhash?: string;
|
|
14
|
+
waveform?: string;
|
|
15
|
+
poster?: Blob;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract metadata (width, height, blurhash) from an image file.
|
|
20
|
+
* Uses a small canvas (max 32px) for blurhash computation.
|
|
21
|
+
*/
|
|
22
|
+
export async function extractImageMetadata(
|
|
23
|
+
file: File,
|
|
24
|
+
): Promise<{ width: number; height: number; blurhash: string }> {
|
|
25
|
+
const img = await loadImage(file);
|
|
26
|
+
const { width, height } = img;
|
|
27
|
+
|
|
28
|
+
// Scale down for blurhash — max 32px on the longest side
|
|
29
|
+
const scale = Math.min(32 / width, 32 / height, 1);
|
|
30
|
+
const bw = Math.max(Math.round(width * scale), 1);
|
|
31
|
+
const bh = Math.max(Math.round(height * scale), 1);
|
|
32
|
+
|
|
33
|
+
const canvas = document.createElement("canvas");
|
|
34
|
+
canvas.width = bw;
|
|
35
|
+
canvas.height = bh;
|
|
36
|
+
const ctx = canvas.getContext("2d");
|
|
37
|
+
if (!ctx) throw new Error("Failed to get canvas context");
|
|
38
|
+
ctx.drawImage(img, 0, 0, bw, bh);
|
|
39
|
+
|
|
40
|
+
const imageData = ctx.getImageData(0, 0, bw, bh);
|
|
41
|
+
const blurhash = encode(imageData.data, bw, bh, 4, 3);
|
|
42
|
+
|
|
43
|
+
return { width, height, blurhash };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extract metadata from a video file.
|
|
48
|
+
* Loads the video to get dimensions, then seeks to `min(duration * 0.1, 3)` and
|
|
49
|
+
* captures a frame for blurhash (32px canvas) and a poster image (640px WebP).
|
|
50
|
+
* Uses an 8s timeout — returns only dimensions if capture times out.
|
|
51
|
+
*/
|
|
52
|
+
export async function extractVideoMetadata(file: File): Promise<{
|
|
53
|
+
width: number;
|
|
54
|
+
height: number;
|
|
55
|
+
blurhash?: string;
|
|
56
|
+
poster?: Blob;
|
|
57
|
+
}> {
|
|
58
|
+
const url = URL.createObjectURL(file);
|
|
59
|
+
try {
|
|
60
|
+
const video = document.createElement("video");
|
|
61
|
+
video.muted = true;
|
|
62
|
+
video.preload = "auto";
|
|
63
|
+
|
|
64
|
+
// Wait for metadata to load (includes duration)
|
|
65
|
+
const { width, height, duration } = await new Promise<{
|
|
66
|
+
width: number;
|
|
67
|
+
height: number;
|
|
68
|
+
duration: number;
|
|
69
|
+
}>((resolve, reject) => {
|
|
70
|
+
video.onloadedmetadata = () =>
|
|
71
|
+
resolve({
|
|
72
|
+
width: video.videoWidth,
|
|
73
|
+
height: video.videoHeight,
|
|
74
|
+
duration: video.duration,
|
|
75
|
+
});
|
|
76
|
+
video.onerror = () => reject(new Error("Failed to load video metadata"));
|
|
77
|
+
video.src = url;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Try to capture frame for blurhash + poster (8s timeout)
|
|
81
|
+
let blurhash: string | undefined;
|
|
82
|
+
let poster: Blob | undefined;
|
|
83
|
+
try {
|
|
84
|
+
const seekTime = Math.min(duration * 0.1, 3);
|
|
85
|
+
const result = await Promise.race([
|
|
86
|
+
captureVideoFrameAndPoster(video, width, height, seekTime),
|
|
87
|
+
timeout(8000),
|
|
88
|
+
]);
|
|
89
|
+
blurhash = result.blurhash;
|
|
90
|
+
poster = result.poster;
|
|
91
|
+
} catch {
|
|
92
|
+
// Timeout or capture failed — return dimensions only
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { width, height, blurhash, poster };
|
|
96
|
+
} finally {
|
|
97
|
+
URL.revokeObjectURL(url);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract waveform peak amplitudes from an audio file.
|
|
103
|
+
* Decodes via Web Audio API and returns a JSON string of ~100 normalized peak values (0–1).
|
|
104
|
+
*
|
|
105
|
+
* @param file - Audio file to extract peaks from
|
|
106
|
+
* @returns JSON string of peak values, e.g. "[0.2,0.8,0.5,...]"
|
|
107
|
+
*/
|
|
108
|
+
export async function extractAudioWaveform(file: File): Promise<string> {
|
|
109
|
+
const buffer = await file.arrayBuffer();
|
|
110
|
+
const audioCtx = new AudioContext();
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const decoded = await audioCtx.decodeAudioData(buffer);
|
|
114
|
+
const raw = decoded.getChannelData(0);
|
|
115
|
+
const count = 100;
|
|
116
|
+
const step = Math.max(1, Math.floor(raw.length / count));
|
|
117
|
+
const peaks: number[] = new Array(count);
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < count; i++) {
|
|
120
|
+
let max = 0;
|
|
121
|
+
const start = i * step;
|
|
122
|
+
const end = Math.min(start + step, raw.length);
|
|
123
|
+
for (let j = start; j < end; j++) {
|
|
124
|
+
const v = Math.abs(raw[j]);
|
|
125
|
+
if (v > max) max = v;
|
|
126
|
+
}
|
|
127
|
+
peaks[i] = max;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let maxPeak = 0;
|
|
131
|
+
for (const p of peaks) if (p > maxPeak) maxPeak = p;
|
|
132
|
+
if (maxPeak > 0) {
|
|
133
|
+
for (let i = 0; i < count; i++)
|
|
134
|
+
peaks[i] = Math.round((peaks[i] / maxPeak) * 100) / 100;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return JSON.stringify(peaks);
|
|
138
|
+
} finally {
|
|
139
|
+
await audioCtx.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract metadata from any media file based on MIME type.
|
|
145
|
+
*/
|
|
146
|
+
export async function extractMediaMetadata(file: File): Promise<MediaMetadata> {
|
|
147
|
+
try {
|
|
148
|
+
if (file.type.startsWith("image/")) {
|
|
149
|
+
return await extractImageMetadata(file);
|
|
150
|
+
}
|
|
151
|
+
if (file.type.startsWith("video/")) {
|
|
152
|
+
const result = await extractVideoMetadata(file);
|
|
153
|
+
return {
|
|
154
|
+
width: result.width,
|
|
155
|
+
height: result.height,
|
|
156
|
+
blurhash: result.blurhash,
|
|
157
|
+
poster: result.poster,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (file.type.startsWith("audio/")) {
|
|
161
|
+
const waveform = await extractAudioWaveform(file);
|
|
162
|
+
return { waveform };
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Extraction failed — return empty metadata
|
|
166
|
+
}
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Helpers ---
|
|
171
|
+
|
|
172
|
+
function loadImage(file: File): Promise<HTMLImageElement> {
|
|
173
|
+
return new Promise((resolve, reject) => {
|
|
174
|
+
const img = new Image();
|
|
175
|
+
const url = URL.createObjectURL(file);
|
|
176
|
+
img.onload = () => {
|
|
177
|
+
URL.revokeObjectURL(url);
|
|
178
|
+
resolve(img);
|
|
179
|
+
};
|
|
180
|
+
img.onerror = () => {
|
|
181
|
+
URL.revokeObjectURL(url);
|
|
182
|
+
reject(new Error("Failed to load image"));
|
|
183
|
+
};
|
|
184
|
+
img.src = url;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function captureVideoFrameAndPoster(
|
|
189
|
+
video: HTMLVideoElement,
|
|
190
|
+
width: number,
|
|
191
|
+
height: number,
|
|
192
|
+
seekTime: number,
|
|
193
|
+
): Promise<{ blurhash: string; poster?: Blob }> {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
video.currentTime = seekTime;
|
|
196
|
+
video.onseeked = () => {
|
|
197
|
+
try {
|
|
198
|
+
// Blurhash: small 32px canvas
|
|
199
|
+
const scale = Math.min(32 / width, 32 / height, 1);
|
|
200
|
+
const bw = Math.max(Math.round(width * scale), 1);
|
|
201
|
+
const bh = Math.max(Math.round(height * scale), 1);
|
|
202
|
+
|
|
203
|
+
const bhCanvas = document.createElement("canvas");
|
|
204
|
+
bhCanvas.width = bw;
|
|
205
|
+
bhCanvas.height = bh;
|
|
206
|
+
const bhCtx = bhCanvas.getContext("2d");
|
|
207
|
+
if (!bhCtx) throw new Error("Failed to get canvas context");
|
|
208
|
+
bhCtx.drawImage(video, 0, 0, bw, bh);
|
|
209
|
+
|
|
210
|
+
const imageData = bhCtx.getImageData(0, 0, bw, bh);
|
|
211
|
+
const blurhash = encode(imageData.data, bw, bh, 4, 3);
|
|
212
|
+
|
|
213
|
+
// Poster: 640px wide WebP
|
|
214
|
+
const posterScale = Math.min(640 / width, 1);
|
|
215
|
+
const pw = Math.round(width * posterScale);
|
|
216
|
+
const ph = Math.round(height * posterScale);
|
|
217
|
+
|
|
218
|
+
const posterCanvas = document.createElement("canvas");
|
|
219
|
+
posterCanvas.width = pw;
|
|
220
|
+
posterCanvas.height = ph;
|
|
221
|
+
const pCtx = posterCanvas.getContext("2d");
|
|
222
|
+
if (!pCtx) {
|
|
223
|
+
resolve({ blurhash });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
pCtx.drawImage(video, 0, 0, pw, ph);
|
|
227
|
+
|
|
228
|
+
posterCanvas.toBlob(
|
|
229
|
+
(blob) => {
|
|
230
|
+
resolve({ blurhash, poster: blob ?? undefined });
|
|
231
|
+
},
|
|
232
|
+
"image/webp",
|
|
233
|
+
0.8,
|
|
234
|
+
);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
reject(err);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
video.onerror = () => reject(new Error("Video seek failed"));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function timeout(ms: number): Promise<never> {
|
|
244
|
+
return new Promise((_, reject) =>
|
|
245
|
+
setTimeout(() => reject(new Error("Timeout")), ms),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Multipart Upload Helper
|
|
3
|
+
*
|
|
4
|
+
* Transparently handles chunked uploads for files that exceed the
|
|
5
|
+
* Cloudflare Workers 100MB request body limit. Used by compose-bridge
|
|
6
|
+
* when a file is larger than MULTIPART_THRESHOLD.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Files at or above this size use multipart upload (95MB, below 100MB Worker limit) */
|
|
10
|
+
export const MULTIPART_THRESHOLD = 95 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
/** Size of each upload chunk (50MB) */
|
|
13
|
+
const CHUNK_SIZE = 50 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export interface MultipartUploadResult {
|
|
16
|
+
id: string;
|
|
17
|
+
filename: string;
|
|
18
|
+
url: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
size: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MultipartUploadOptions {
|
|
24
|
+
file: File;
|
|
25
|
+
metadata: {
|
|
26
|
+
width?: number;
|
|
27
|
+
height?: number;
|
|
28
|
+
blurhash?: string;
|
|
29
|
+
waveform?: string;
|
|
30
|
+
poster?: Blob;
|
|
31
|
+
};
|
|
32
|
+
onProgress?: (progress: number) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Upload a large file using the multipart upload protocol.
|
|
37
|
+
*
|
|
38
|
+
* @param options - File, metadata, and optional progress callback
|
|
39
|
+
* @returns The uploaded media record
|
|
40
|
+
* @throws Error if any step of the upload fails
|
|
41
|
+
*/
|
|
42
|
+
export async function uploadMultipart(
|
|
43
|
+
options: MultipartUploadOptions,
|
|
44
|
+
): Promise<MultipartUploadResult> {
|
|
45
|
+
const { file, metadata, onProgress } = options;
|
|
46
|
+
|
|
47
|
+
// 1. Initiate the multipart upload
|
|
48
|
+
const initRes = await fetch("/api/upload/multipart", {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
filename: file.name,
|
|
53
|
+
contentType: file.type,
|
|
54
|
+
size: file.size,
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (!initRes.ok) {
|
|
59
|
+
const data = await initRes.json();
|
|
60
|
+
throw new Error(data.error ?? "Failed to start upload");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { id, uploadId, storageKey, filename, originalName } =
|
|
64
|
+
(await initRes.json()) as {
|
|
65
|
+
id: string;
|
|
66
|
+
uploadId: string;
|
|
67
|
+
storageKey: string;
|
|
68
|
+
filename: string;
|
|
69
|
+
originalName: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// 2. Upload poster if present (small file, single request)
|
|
74
|
+
let posterKey: string | undefined;
|
|
75
|
+
if (metadata.poster) {
|
|
76
|
+
const posterForm = new FormData();
|
|
77
|
+
posterForm.append("poster", metadata.poster, "poster.webp");
|
|
78
|
+
|
|
79
|
+
const posterRes = await fetch(`/api/upload/multipart/${id}/poster`, {
|
|
80
|
+
method: "PUT",
|
|
81
|
+
body: posterForm,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!posterRes.ok) {
|
|
85
|
+
throw new Error("Failed to upload poster");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const posterData = (await posterRes.json()) as { posterKey: string };
|
|
89
|
+
posterKey = posterData.posterKey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Slice file into chunks and upload each part
|
|
93
|
+
const totalSize = file.size;
|
|
94
|
+
const totalParts = Math.ceil(totalSize / CHUNK_SIZE);
|
|
95
|
+
const parts: { partNumber: number; etag: string }[] = [];
|
|
96
|
+
let uploadedBytes = 0;
|
|
97
|
+
|
|
98
|
+
for (let i = 0; i < totalParts; i++) {
|
|
99
|
+
const start = i * CHUNK_SIZE;
|
|
100
|
+
const end = Math.min(start + CHUNK_SIZE, totalSize);
|
|
101
|
+
const chunk = file.slice(start, end);
|
|
102
|
+
const partNumber = i + 1;
|
|
103
|
+
|
|
104
|
+
const partRes = await fetch(
|
|
105
|
+
`/api/upload/multipart/${id}/part?partNumber=${partNumber}&storageKey=${encodeURIComponent(storageKey)}&uploadId=${encodeURIComponent(uploadId)}`,
|
|
106
|
+
{
|
|
107
|
+
method: "PUT",
|
|
108
|
+
body: chunk,
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!partRes.ok) {
|
|
113
|
+
throw new Error(`Failed to upload part ${partNumber}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const partData = (await partRes.json()) as {
|
|
117
|
+
partNumber: number;
|
|
118
|
+
etag: string;
|
|
119
|
+
};
|
|
120
|
+
parts.push(partData);
|
|
121
|
+
|
|
122
|
+
uploadedBytes += end - start;
|
|
123
|
+
onProgress?.(uploadedBytes / totalSize);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 4. Complete the multipart upload — send all metadata for DB record
|
|
127
|
+
const completeRes = await fetch(`/api/upload/multipart/${id}/complete`, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
body: JSON.stringify({
|
|
131
|
+
storageKey,
|
|
132
|
+
uploadId,
|
|
133
|
+
parts,
|
|
134
|
+
filename,
|
|
135
|
+
originalName,
|
|
136
|
+
contentType: file.type,
|
|
137
|
+
size: file.size,
|
|
138
|
+
width: metadata.width,
|
|
139
|
+
height: metadata.height,
|
|
140
|
+
blurhash: metadata.blurhash,
|
|
141
|
+
waveform: metadata.waveform,
|
|
142
|
+
posterKey,
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!completeRes.ok) {
|
|
147
|
+
throw new Error("Failed to complete upload");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (await completeRes.json()) as MultipartUploadResult;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Abort on any failure — fire-and-forget cleanup
|
|
153
|
+
fetch(`/api/upload/multipart/${id}/abort`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "Content-Type": "application/json" },
|
|
156
|
+
body: JSON.stringify({ storageKey, uploadId }),
|
|
157
|
+
}).catch(() => {});
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
* - `jant:post-load-media` → fetch media picker HTML and manage selections
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
PostSubmitDetail,
|
|
11
|
+
PostFormLabels,
|
|
12
|
+
} from "./components/post-form-types.js";
|
|
10
13
|
import type { JantPostForm } from "./components/jant-post-form.js";
|
|
11
14
|
import { showToast } from "./toast.js";
|
|
12
15
|
|
|
@@ -57,16 +60,64 @@ async function handlePostSubmit(event: Event) {
|
|
|
57
60
|
} catch {
|
|
58
61
|
// Ignore JSON parse failure; keep fallback message.
|
|
59
62
|
}
|
|
63
|
+
|
|
64
|
+
// Auto-save as draft when a new publish fails
|
|
65
|
+
if (detail.data.status === "published" && !detail.isEdit) {
|
|
66
|
+
try {
|
|
67
|
+
const retryRes = await fetch(detail.endpoint, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: {
|
|
70
|
+
"Content-Type": "application/json",
|
|
71
|
+
Accept: "application/json",
|
|
72
|
+
},
|
|
73
|
+
body: JSON.stringify({ ...detail.data, status: "draft" }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (retryRes.ok) {
|
|
77
|
+
const retryJson = await retryRes.json();
|
|
78
|
+
const labelsAttr = formEl.getAttribute("labels");
|
|
79
|
+
let fallbackMsg = "Couldn't publish. Saved as draft.";
|
|
80
|
+
if (labelsAttr) {
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(
|
|
83
|
+
labelsAttr,
|
|
84
|
+
) as Partial<PostFormLabels>;
|
|
85
|
+
if (parsed.draftFallbackMessage)
|
|
86
|
+
fallbackMsg = parsed.draftFallbackMessage;
|
|
87
|
+
} catch {
|
|
88
|
+
// Ignore parse failure; use default message
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
showToast(fallbackMsg);
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
retryJson?.status === "redirect" &&
|
|
95
|
+
typeof retryJson.url === "string"
|
|
96
|
+
) {
|
|
97
|
+
formEl.clearDirty();
|
|
98
|
+
window.location.href = retryJson.url;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
formEl.clearDirty();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Retry failed — fall through to show original error
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
60
109
|
throw new Error(message);
|
|
61
110
|
}
|
|
62
111
|
|
|
63
112
|
const json = await res.json();
|
|
64
113
|
|
|
65
114
|
if (json?.status === "redirect" && typeof json.url === "string") {
|
|
115
|
+
formEl.clearDirty();
|
|
66
116
|
window.location.href = json.url;
|
|
67
117
|
return;
|
|
68
118
|
}
|
|
69
119
|
|
|
120
|
+
formEl.clearDirty();
|
|
70
121
|
showToast(detail.messages.success);
|
|
71
122
|
} catch (err) {
|
|
72
123
|
const message =
|
|
@@ -14,13 +14,6 @@ import type { JantSettingsGeneral } from "./components/jant-settings-general.js"
|
|
|
14
14
|
import type { JantSettingsAvatar } from "./components/jant-settings-avatar.js";
|
|
15
15
|
import { showToast } from "./toast.js";
|
|
16
16
|
|
|
17
|
-
function updateSidebarSiteName(siteName: string) {
|
|
18
|
-
const el = document.getElementById("site-name");
|
|
19
|
-
if (el) el.textContent = siteName;
|
|
20
|
-
const titleEl = document.querySelector("title");
|
|
21
|
-
if (titleEl) titleEl.textContent = `Settings - ${siteName}`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
17
|
// ── Settings save handler ───────────────────────────────────────────
|
|
25
18
|
|
|
26
19
|
document.addEventListener("jant:settings-save", async (e: Event) => {
|
|
@@ -59,11 +52,6 @@ document.addEventListener("jant:settings-save", async (e: Event) => {
|
|
|
59
52
|
showToast(json.toast);
|
|
60
53
|
}
|
|
61
54
|
|
|
62
|
-
// Update sidebar site name when general settings are saved
|
|
63
|
-
if (section === "general" && json.siteName) {
|
|
64
|
-
updateSidebarSiteName(json.siteName);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
55
|
// Notify the component that save succeeded
|
|
68
56
|
if (section === "avatar-display") {
|
|
69
57
|
avatarEl?.saved();
|