@jant/core 0.3.35 → 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/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4564 -3013
- package/dist/index.js +12885 -8161
- package/package.json +23 -6
- package/src/__tests__/helpers/app.ts +10 -10
- package/src/__tests__/helpers/db.ts +91 -87
- package/src/app.tsx +157 -31
- 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/{lib → client}/avatar-upload.ts +4 -3
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
- package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +43 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/client/components/compose-types.ts +174 -0
- package/src/client/components/jant-collection-form.ts +667 -0
- package/src/client/components/jant-collection-sidebar.ts +805 -0
- package/src/client/components/jant-compose-dialog.ts +2161 -0
- package/src/client/components/jant-compose-editor.ts +1813 -0
- package/src/client/components/jant-compose-fullscreen.ts +283 -0
- package/src/client/components/jant-media-lightbox.ts +259 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
- package/src/{ui → client}/components/jant-post-form.ts +141 -12
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
- package/src/{ui → client}/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/{ui → client}/components/nav-manager-types.ts +6 -18
- package/src/{ui → client}/components/post-form-template.ts +137 -38
- package/src/{ui → client}/components/post-form-types.ts +15 -4
- package/src/client/compose-bridge.ts +583 -0
- package/src/{lib → client}/image-processor.ts +26 -8
- package/src/client/lazy-slugify.ts +51 -0
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/{lib → client}/post-form-bridge.ts +53 -2
- package/src/{lib → client}/settings-bridge.ts +3 -15
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +86 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +65 -0
- package/src/client/tiptap/image-node.ts +482 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +129 -0
- package/src/client/tiptap/slash-commands.ts +438 -0
- package/src/{lib → client}/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +44 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +27 -17
- 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 -140
- 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 +783 -1087
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +867 -812
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +878 -823
- 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__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +186 -65
- 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__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +140 -65
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +963 -0
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +77 -31
- 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 +22 -12
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +24 -5
- package/src/lib/resolve-config.ts +13 -2
- package/src/lib/schemas.ts +226 -58
- 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 +158 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +76 -34
- package/src/lib/tiptap-render.ts +191 -0
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +263 -14
- package/src/lib/url.ts +37 -22
- package/src/lib/view.ts +236 -55
- 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/error-handler.ts +3 -3
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +83 -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 +57 -31
- 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 +81 -62
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +92 -24
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +39 -31
- package/src/routes/auth/signin.tsx +13 -14
- package/src/routes/compose.tsx +27 -63
- package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +475 -99
- package/src/routes/feed/__tests__/rss.test.ts +22 -23
- package/src/routes/feed/rss.ts +6 -2
- 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 +36 -18
- package/src/routes/pages/archive.tsx +177 -37
- package/src/routes/pages/collection.tsx +43 -14
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +27 -3
- package/src/routes/pages/home.tsx +15 -14
- 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 +800 -230
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/__tests__/settings.test.ts +3 -3
- 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 +764 -172
- package/src/services/search.ts +161 -74
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +293 -62
- package/src/styles/tokens.css +93 -5
- package/src/styles/ui.css +4349 -766
- package/src/types/bindings.ts +8 -0
- package/src/types/config.ts +34 -4
- package/src/types/constants.ts +17 -2
- package/src/types/entities.ts +83 -37
- package/src/types/operations.ts +20 -27
- package/src/types/props.ts +52 -17
- package/src/types/views.ts +48 -24
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +255 -16
- 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 +12 -2
- package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
- package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +87 -146
- 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 +78 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
- 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 +116 -103
- package/src/ui/pages/ArchivePage.tsx +923 -95
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +182 -38
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +239 -4
- package/src/ui/shared/MediaGallery.tsx +475 -41
- 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/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/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/collections-reorder.ts +0 -28
- package/src/lib/compose-bridge.ts +0 -280
- package/src/lib/media-upload.ts +0 -148
- 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/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/routes/dash/index.tsx +0 -103
- package/src/routes/dash/media.tsx +0 -132
- package/src/routes/dash/pages.tsx +0 -239
- package/src/routes/dash/posts.tsx +0 -334
- package/src/routes/dash/redirects.tsx +0 -257
- 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 -203
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/types/sortablejs.d.ts +0 -29
- package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
- package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
- package/src/ui/components/compose-types.ts +0 -75
- package/src/ui/components/jant-collection-form.ts +0 -512
- package/src/ui/components/jant-compose-dialog.ts +0 -495
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/PageForm.tsx +0 -185
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/media/MediaListContent.tsx +0 -201
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -74
- package/src/ui/dash/posts/PostForm.tsx +0 -248
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- package/src/ui/layouts/DashLayout.tsx +0 -165
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
- /package/src/{ui → client}/components/settings-types.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import type {
|
|
10
10
|
NavManagerUpdateDetail,
|
|
11
11
|
NavManagerDeleteDetail,
|
|
12
|
-
} from "
|
|
12
|
+
} from "./components/nav-manager-types.js";
|
|
13
13
|
import { showToast } from "./toast.js";
|
|
14
14
|
|
|
15
15
|
document.addEventListener("jant:nav-update", async (event: Event) => {
|
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
* - `jant:post-load-media` → fetch media picker HTML and manage selections
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
10
|
-
|
|
9
|
+
import type {
|
|
10
|
+
PostSubmitDetail,
|
|
11
|
+
PostFormLabels,
|
|
12
|
+
} from "./components/post-form-types.js";
|
|
13
|
+
import type { JantPostForm } from "./components/jant-post-form.js";
|
|
11
14
|
import { showToast } from "./toast.js";
|
|
12
15
|
|
|
13
16
|
function findPostForm(
|
|
@@ -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 =
|
|
@@ -9,18 +9,11 @@
|
|
|
9
9
|
import type {
|
|
10
10
|
SettingsSaveDetail,
|
|
11
11
|
AvatarRemoveDetail,
|
|
12
|
-
} from "
|
|
13
|
-
import type { JantSettingsGeneral } from "
|
|
14
|
-
import type { JantSettingsAvatar } from "
|
|
12
|
+
} from "./components/settings-types.js";
|
|
13
|
+
import type { JantSettingsGeneral } from "./components/jant-settings-general.js";
|
|
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();
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread Context Interactions
|
|
3
|
+
*
|
|
4
|
+
* 1. Expand/collapse faded ancestor context via toggle button
|
|
5
|
+
* 2. Auto-scroll to current post on thread detail pages
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
function parsePixelValue(value: string, fallback: number): number {
|
|
9
|
+
const parsed = Number.parseFloat(value);
|
|
10
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getCollapsedMaxHeight(container: HTMLElement): number {
|
|
14
|
+
const value = getComputedStyle(container).getPropertyValue(
|
|
15
|
+
"--site-thread-context-max-height",
|
|
16
|
+
);
|
|
17
|
+
return parsePixelValue(value, 188);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getPendingImages(container: HTMLElement): HTMLImageElement[] {
|
|
21
|
+
return Array.from(container.querySelectorAll("img")).filter(
|
|
22
|
+
(image) => !image.complete,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function waitForContentToSettle(
|
|
27
|
+
container: HTMLElement,
|
|
28
|
+
callback: () => void,
|
|
29
|
+
): void {
|
|
30
|
+
const pendingImages = getPendingImages(container);
|
|
31
|
+
if (pendingImages.length === 0) {
|
|
32
|
+
requestAnimationFrame(() => {
|
|
33
|
+
requestAnimationFrame(callback);
|
|
34
|
+
});
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let remaining = pendingImages.length;
|
|
39
|
+
const handleDone = (): void => {
|
|
40
|
+
remaining -= 1;
|
|
41
|
+
if (remaining === 0) {
|
|
42
|
+
callback();
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
pendingImages.forEach((image) => {
|
|
47
|
+
image.addEventListener("load", handleDone, { once: true });
|
|
48
|
+
image.addEventListener("error", handleDone, { once: true });
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function updateThreadContextState(
|
|
53
|
+
container: HTMLElement,
|
|
54
|
+
toggle: HTMLElement,
|
|
55
|
+
allowExpand: boolean,
|
|
56
|
+
): void {
|
|
57
|
+
const collapsedMaxHeight = getCollapsedMaxHeight(container);
|
|
58
|
+
const isExpanded = container.classList.contains("expanded");
|
|
59
|
+
const overflows = container.scrollHeight > collapsedMaxHeight + 1;
|
|
60
|
+
const showMoreLabel = toggle.dataset.labelMore ?? "Show more";
|
|
61
|
+
const showLessLabel = toggle.dataset.labelLess ?? "Show less";
|
|
62
|
+
|
|
63
|
+
if (!overflows) {
|
|
64
|
+
if (allowExpand) {
|
|
65
|
+
container.classList.remove(
|
|
66
|
+
"thread-context-collapsed",
|
|
67
|
+
"thread-context-faded",
|
|
68
|
+
"expanded",
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
container.classList.add("thread-context-collapsed");
|
|
72
|
+
container.classList.remove("thread-context-faded", "expanded");
|
|
73
|
+
}
|
|
74
|
+
toggle.classList.add("hidden");
|
|
75
|
+
toggle.textContent = showMoreLabel;
|
|
76
|
+
toggle.setAttribute("aria-expanded", "false");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
container.classList.add("thread-context-collapsed");
|
|
81
|
+
container.classList.add("thread-context-faded");
|
|
82
|
+
toggle.classList.remove("hidden");
|
|
83
|
+
toggle.textContent = isExpanded ? showLessLabel : showMoreLabel;
|
|
84
|
+
toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function setupThreadContext(group: HTMLElement): void {
|
|
88
|
+
const container = group.querySelector<HTMLElement>("[data-thread-context]");
|
|
89
|
+
const toggle = group.querySelector<HTMLElement>(
|
|
90
|
+
"[data-thread-context-toggle]",
|
|
91
|
+
);
|
|
92
|
+
if (!container || !toggle) return;
|
|
93
|
+
|
|
94
|
+
let allowExpand = false;
|
|
95
|
+
updateThreadContextState(container, toggle, allowExpand);
|
|
96
|
+
|
|
97
|
+
waitForContentToSettle(container, () => {
|
|
98
|
+
allowExpand = true;
|
|
99
|
+
updateThreadContextState(container, toggle, allowExpand);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if ("ResizeObserver" in globalThis) {
|
|
103
|
+
const observer = new globalThis.ResizeObserver(() => {
|
|
104
|
+
updateThreadContextState(container, toggle, allowExpand);
|
|
105
|
+
});
|
|
106
|
+
observer.observe(container);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Expand/collapse: event delegation on toggle buttons
|
|
111
|
+
document.addEventListener("click", (e) => {
|
|
112
|
+
const toggle = (e.target as HTMLElement).closest<HTMLElement>(
|
|
113
|
+
"[data-thread-context-toggle]",
|
|
114
|
+
);
|
|
115
|
+
if (!toggle) return;
|
|
116
|
+
|
|
117
|
+
const container = toggle
|
|
118
|
+
.closest(".thread-group")
|
|
119
|
+
?.querySelector<HTMLElement>("[data-thread-context]");
|
|
120
|
+
if (!container) return;
|
|
121
|
+
|
|
122
|
+
container.classList.toggle("expanded");
|
|
123
|
+
updateThreadContextState(container, toggle, true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Auto-scroll to current post on detail pages
|
|
127
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
128
|
+
document.querySelectorAll(".thread-group").forEach((group) => {
|
|
129
|
+
if (group instanceof HTMLElement) {
|
|
130
|
+
setupThreadContext(group);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const current = document.querySelector("[data-post-current]");
|
|
135
|
+
if (!current) return;
|
|
136
|
+
|
|
137
|
+
requestAnimationFrame(() => {
|
|
138
|
+
current.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
139
|
+
});
|
|
140
|
+
});
|