@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
|
@@ -9,12 +9,36 @@ import type { ComposeSubmitDetail } from "./components/compose-types.js";
|
|
|
9
9
|
import type { ComposeAttachment } from "./components/compose-types.js";
|
|
10
10
|
import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
|
|
11
11
|
import type { JantComposeEditor } from "./components/jant-compose-editor.js";
|
|
12
|
+
import { AudioProcessor } from "./audio-processor.js";
|
|
12
13
|
import { ImageProcessor } from "./image-processor.js";
|
|
14
|
+
import { VideoProcessor } from "./video-processor.js";
|
|
15
|
+
import {
|
|
16
|
+
extractMediaMetadata,
|
|
17
|
+
extractAudioWaveform,
|
|
18
|
+
} from "./media-metadata.js";
|
|
13
19
|
import {
|
|
14
20
|
showToast,
|
|
15
21
|
showPersistentToast,
|
|
16
22
|
replaceWithAutoClose,
|
|
17
23
|
} from "./toast.js";
|
|
24
|
+
import { MULTIPART_THRESHOLD, uploadMultipart } from "./multipart-upload.js";
|
|
25
|
+
import { getMediaCategory } from "../lib/upload.js";
|
|
26
|
+
|
|
27
|
+
function getComposeEditorFromEventTarget(
|
|
28
|
+
target: globalThis.EventTarget | null,
|
|
29
|
+
): JantComposeEditor | null {
|
|
30
|
+
return target instanceof globalThis.Element
|
|
31
|
+
? (target.closest("jant-compose-editor") as JantComposeEditor | null)
|
|
32
|
+
: null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getComposeDialogFromEventTarget(
|
|
36
|
+
target: globalThis.EventTarget | null,
|
|
37
|
+
): JantComposeDialog | null {
|
|
38
|
+
return target instanceof globalThis.Element
|
|
39
|
+
? (target.closest("jant-compose-dialog") as JantComposeDialog | null)
|
|
40
|
+
: null;
|
|
41
|
+
}
|
|
18
42
|
|
|
19
43
|
// ── Upload manager ──────────────────────────────────────────────────
|
|
20
44
|
|
|
@@ -34,17 +58,140 @@ async function uploadFile(
|
|
|
34
58
|
editor: JantComposeEditor | null,
|
|
35
59
|
): Promise<string | null> {
|
|
36
60
|
try {
|
|
61
|
+
let toUpload: File;
|
|
62
|
+
let width: number | undefined;
|
|
63
|
+
let height: number | undefined;
|
|
64
|
+
let blurhash: string | undefined;
|
|
65
|
+
let waveform: string | undefined;
|
|
66
|
+
let poster: Blob | undefined;
|
|
67
|
+
|
|
68
|
+
if (file.type.startsWith("video/")) {
|
|
69
|
+
// Video: transcode with mediabunny (requires WebCodecs)
|
|
70
|
+
if (!VideoProcessor.isSupported()) {
|
|
71
|
+
editor?.updateAttachmentStatus(
|
|
72
|
+
clientId,
|
|
73
|
+
"error",
|
|
74
|
+
null,
|
|
75
|
+
"Your browser doesn't support video processing. Use Chrome or Edge to upload videos.",
|
|
76
|
+
);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
editor?.updateAttachmentStatus(clientId, "processing", null, null);
|
|
81
|
+
const result = await VideoProcessor.processToFile(file, (progress) => {
|
|
82
|
+
editor?.updateAttachmentProgress(clientId, progress);
|
|
83
|
+
});
|
|
84
|
+
toUpload = result.file;
|
|
85
|
+
width = result.width;
|
|
86
|
+
height = result.height;
|
|
87
|
+
blurhash = result.blurhash;
|
|
88
|
+
poster = result.poster;
|
|
89
|
+
} else if (file.type.startsWith("audio/")) {
|
|
90
|
+
// Audio: transcode to AAC (.m4a) (requires WebCodecs)
|
|
91
|
+
if (!AudioProcessor.isSupported()) {
|
|
92
|
+
editor?.updateAttachmentStatus(
|
|
93
|
+
clientId,
|
|
94
|
+
"error",
|
|
95
|
+
null,
|
|
96
|
+
"Your browser doesn't support audio processing. Use Chrome or Edge to upload audio.",
|
|
97
|
+
);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Extract waveform from the original file before AudioProcessor runs
|
|
102
|
+
try {
|
|
103
|
+
waveform = await extractAudioWaveform(file);
|
|
104
|
+
} catch {
|
|
105
|
+
// Waveform extraction is best-effort
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
editor?.updateAttachmentStatus(clientId, "processing", null, null);
|
|
109
|
+
const result = await AudioProcessor.processToFile(file, (progress) => {
|
|
110
|
+
editor?.updateAttachmentProgress(clientId, progress);
|
|
111
|
+
});
|
|
112
|
+
toUpload = result.file;
|
|
113
|
+
} else if (
|
|
114
|
+
file.type.startsWith("image/") ||
|
|
115
|
+
/\.heic$/i.test(file.name) ||
|
|
116
|
+
/\.heif$/i.test(file.name)
|
|
117
|
+
) {
|
|
118
|
+
// Image: convert HEIC/HEIF if needed, then resize + convert to WebP
|
|
119
|
+
let imageFile = file;
|
|
120
|
+
try {
|
|
121
|
+
const { isHeic, heicTo } = await import("heic-to");
|
|
122
|
+
if (await isHeic(file)) {
|
|
123
|
+
editor?.updateAttachmentStatus(clientId, "processing", null, null);
|
|
124
|
+
const blob = await heicTo({
|
|
125
|
+
blob: file,
|
|
126
|
+
type: "image/jpeg",
|
|
127
|
+
quality: 0.92,
|
|
128
|
+
});
|
|
129
|
+
imageFile = new File([blob], file.name.replace(/\.heic$/i, ".jpg"), {
|
|
130
|
+
type: "image/jpeg",
|
|
131
|
+
});
|
|
132
|
+
editor?.updateAttachmentPreview(clientId, imageFile);
|
|
133
|
+
}
|
|
134
|
+
const result = await ImageProcessor.processToFile(imageFile);
|
|
135
|
+
toUpload = result.file;
|
|
136
|
+
width = result.width;
|
|
137
|
+
height = result.height;
|
|
138
|
+
} catch {
|
|
139
|
+
editor?.removeAttachment(clientId);
|
|
140
|
+
showToast("Image format not supported.", "error");
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
toUpload = file;
|
|
145
|
+
}
|
|
146
|
+
|
|
37
147
|
// Update status to uploading
|
|
38
148
|
editor?.updateAttachmentStatus(clientId, "uploading", null, null);
|
|
39
149
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
150
|
+
// Extract metadata for non-video files (video metadata comes from VideoProcessor)
|
|
151
|
+
// Audio waveform is already extracted above (before AudioProcessor runs).
|
|
152
|
+
if (!file.type.startsWith("video/")) {
|
|
153
|
+
const meta = await extractMediaMetadata(toUpload);
|
|
154
|
+
width ??= meta.width;
|
|
155
|
+
height ??= meta.height;
|
|
156
|
+
blurhash ??= meta.blurhash;
|
|
157
|
+
waveform ??= meta.waveform;
|
|
158
|
+
poster ??= meta.poster;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Large files: use multipart upload to avoid Worker body size limit
|
|
162
|
+
if (toUpload.size >= MULTIPART_THRESHOLD) {
|
|
163
|
+
const result = await uploadMultipart({
|
|
164
|
+
file: toUpload,
|
|
165
|
+
metadata: { width, height, blurhash, waveform, poster },
|
|
166
|
+
onProgress: (p) => editor?.updateAttachmentProgress(clientId, p),
|
|
167
|
+
});
|
|
168
|
+
editor?.updateAttachmentStatus(clientId, "done", result.id, null);
|
|
169
|
+
return result.id;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// For text-category files, read content and include summary
|
|
173
|
+
let summary: string | undefined;
|
|
174
|
+
const category = getMediaCategory(file.type);
|
|
175
|
+
if (category === "text" && file.type !== "text/x-tiptap+json") {
|
|
176
|
+
try {
|
|
177
|
+
const textContent = await file.text();
|
|
178
|
+
const trimmed = textContent.replace(/\s+/g, " ").trim();
|
|
179
|
+
summary =
|
|
180
|
+
trimmed.length <= 100 ? trimmed : trimmed.slice(0, 100) + "\u2026";
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore — summary is optional
|
|
183
|
+
}
|
|
184
|
+
}
|
|
44
185
|
|
|
45
|
-
//
|
|
186
|
+
// Small files: existing single-request upload
|
|
46
187
|
const formData = new FormData();
|
|
47
188
|
formData.append("file", toUpload);
|
|
189
|
+
if (width) formData.append("width", String(width));
|
|
190
|
+
if (height) formData.append("height", String(height));
|
|
191
|
+
if (blurhash) formData.append("blurhash", blurhash);
|
|
192
|
+
if (waveform) formData.append("waveform", waveform);
|
|
193
|
+
if (poster) formData.append("poster", poster, "poster.webp");
|
|
194
|
+
if (summary) formData.append("summary", summary);
|
|
48
195
|
|
|
49
196
|
const res = await fetch("/api/upload", {
|
|
50
197
|
method: "POST",
|
|
@@ -55,6 +202,7 @@ async function uploadFile(
|
|
|
55
202
|
const data = await res.json();
|
|
56
203
|
const error = data.error ?? "Upload failed";
|
|
57
204
|
editor?.updateAttachmentStatus(clientId, "error", null, error);
|
|
205
|
+
showToast(error, "error");
|
|
58
206
|
return null;
|
|
59
207
|
}
|
|
60
208
|
|
|
@@ -64,14 +212,11 @@ async function uploadFile(
|
|
|
64
212
|
return mediaId;
|
|
65
213
|
} catch {
|
|
66
214
|
editor?.updateAttachmentStatus(clientId, "error", null, "Upload failed");
|
|
215
|
+
showToast("Upload failed", "error");
|
|
67
216
|
return null;
|
|
68
217
|
}
|
|
69
218
|
}
|
|
70
219
|
|
|
71
|
-
function getEditor(): JantComposeEditor | null {
|
|
72
|
-
return document.querySelector("jant-compose-editor");
|
|
73
|
-
}
|
|
74
|
-
|
|
75
220
|
// ── Attachment removal handler ───────────────────────────────────────
|
|
76
221
|
|
|
77
222
|
document.addEventListener("jant:attachment-removed", (e: Event) => {
|
|
@@ -94,7 +239,7 @@ document.addEventListener("jant:files-selected", (e: Event) => {
|
|
|
94
239
|
const event = e as CustomEvent<{
|
|
95
240
|
files: { file: File; clientId: string }[];
|
|
96
241
|
}>;
|
|
97
|
-
const editor =
|
|
242
|
+
const editor = getComposeEditorFromEventTarget(event.target);
|
|
98
243
|
|
|
99
244
|
for (const { file, clientId } of event.detail.files) {
|
|
100
245
|
const promise = uploadFile(file, clientId, editor).then((mediaId) => {
|
|
@@ -113,74 +258,102 @@ document.addEventListener("jant:files-selected", (e: Event) => {
|
|
|
113
258
|
}
|
|
114
259
|
});
|
|
115
260
|
|
|
116
|
-
// ──
|
|
261
|
+
// ── Reply trigger handler ───────────────────────────────────────────
|
|
117
262
|
|
|
118
|
-
document.addEventListener("
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
263
|
+
document.addEventListener("click", (e: MouseEvent) => {
|
|
264
|
+
const trigger = (e.target as HTMLElement).closest<HTMLButtonElement>(
|
|
265
|
+
"[data-reply-trigger]",
|
|
266
|
+
);
|
|
267
|
+
if (!trigger) return;
|
|
268
|
+
|
|
269
|
+
const article = trigger.closest<HTMLElement>("article[data-post]");
|
|
270
|
+
if (!article) return;
|
|
271
|
+
|
|
272
|
+
const postId = article.dataset.postId;
|
|
273
|
+
if (!postId) return;
|
|
274
|
+
|
|
275
|
+
// Capture rendered content from the DOM — reuses server-rendered cards
|
|
276
|
+
// (NoteCard, LinkCard, QuoteCard) with all formats, media, and attachments
|
|
277
|
+
const clone = article.cloneNode(true) as HTMLElement;
|
|
278
|
+
clone.querySelector("[data-post-meta]")?.remove();
|
|
279
|
+
clone.querySelector(".post-status-badges")?.remove();
|
|
280
|
+
const contentHtml = clone.innerHTML;
|
|
281
|
+
|
|
282
|
+
const timeEl = article.querySelector<HTMLElement>("time.dt-published");
|
|
283
|
+
const dateText = timeEl?.textContent?.trim() ?? "";
|
|
284
|
+
|
|
285
|
+
const dialog = document.querySelector(
|
|
125
286
|
"jant-compose-dialog",
|
|
126
287
|
) as JantComposeDialog | null;
|
|
288
|
+
dialog?.openReply(postId, { contentHtml, dateText });
|
|
289
|
+
});
|
|
127
290
|
|
|
128
|
-
|
|
129
|
-
composeEl.loading = true;
|
|
291
|
+
// ── Submit handler ──────────────────────────────────────────────────
|
|
130
292
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
? detail.mediaAlts
|
|
152
|
-
: undefined,
|
|
153
|
-
}),
|
|
154
|
-
});
|
|
293
|
+
/** Build the JSON body for both create and update requests */
|
|
294
|
+
function buildPostBody(detail: ComposeSubmitDetail) {
|
|
295
|
+
return {
|
|
296
|
+
format: detail.format,
|
|
297
|
+
title: detail.title || undefined,
|
|
298
|
+
body: detail.body || undefined,
|
|
299
|
+
url: detail.url || undefined,
|
|
300
|
+
quoteText: detail.quoteText || undefined,
|
|
301
|
+
status: detail.status,
|
|
302
|
+
visibility: detail.visibility || undefined,
|
|
303
|
+
featured: detail.featured || undefined,
|
|
304
|
+
rating: detail.rating || undefined,
|
|
305
|
+
collectionIds:
|
|
306
|
+
detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
|
|
307
|
+
mediaIds: detail.mediaIds.length > 0 ? detail.mediaIds : undefined,
|
|
308
|
+
mediaAlts:
|
|
309
|
+
Object.keys(detail.mediaAlts).length > 0 ? detail.mediaAlts : undefined,
|
|
310
|
+
replyToId: detail.replyToId || undefined,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
155
313
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Upload text attachments as files to /api/upload.
|
|
316
|
+
* Returns a map of clientId → mediaId for newly uploaded items.
|
|
317
|
+
*/
|
|
318
|
+
async function uploadTextAttachments(
|
|
319
|
+
attachedTexts: ComposeSubmitDetail["attachedTexts"],
|
|
320
|
+
): Promise<Map<string, string>> {
|
|
321
|
+
const clientIdToMediaId = new Map<string, string>();
|
|
322
|
+
|
|
323
|
+
for (const item of attachedTexts) {
|
|
324
|
+
// Always re-upload text attachments with content (content may have been edited)
|
|
325
|
+
if (item.bodyJson === null) {
|
|
326
|
+
// No content — keep existing mediaId if present
|
|
327
|
+
if (item.mediaId) {
|
|
328
|
+
clientIdToMediaId.set(item.clientId, item.mediaId);
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
160
331
|
}
|
|
161
332
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
333
|
+
const envelope = { json: item.bodyJson, html: item.bodyHtml ?? "" };
|
|
334
|
+
const blob = new Blob([JSON.stringify(envelope)], {
|
|
335
|
+
type: "text/x-tiptap+json",
|
|
336
|
+
});
|
|
337
|
+
const formData = new FormData();
|
|
338
|
+
formData.append("file", blob, "attached-text.json");
|
|
339
|
+
formData.append("summary", item.summary);
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetch("/api/upload", {
|
|
343
|
+
method: "POST",
|
|
344
|
+
body: formData,
|
|
345
|
+
});
|
|
346
|
+
if (res.ok) {
|
|
347
|
+
const data = await res.json();
|
|
348
|
+
clientIdToMediaId.set(item.clientId, data.id as string);
|
|
171
349
|
}
|
|
350
|
+
} catch {
|
|
351
|
+
// Upload failed — skip this item
|
|
172
352
|
}
|
|
173
|
-
|
|
174
|
-
dialog?.close();
|
|
175
|
-
// Prevent browser from restoring focus to the trigger button
|
|
176
|
-
(document.activeElement as HTMLElement)?.blur();
|
|
177
|
-
composeEl.reset();
|
|
178
|
-
} catch {
|
|
179
|
-
showToast("Something went wrong", "error");
|
|
180
|
-
} finally {
|
|
181
|
-
composeEl.loading = false;
|
|
182
353
|
}
|
|
183
|
-
|
|
354
|
+
|
|
355
|
+
return clientIdToMediaId;
|
|
356
|
+
}
|
|
184
357
|
|
|
185
358
|
// ── Deferred submit handler ─────────────────────────────────────────
|
|
186
359
|
|
|
@@ -191,17 +364,51 @@ interface DeferredDetail extends ComposeSubmitDetail {
|
|
|
191
364
|
document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
192
365
|
const event = e as CustomEvent<DeferredDetail>;
|
|
193
366
|
const detail = event.detail;
|
|
194
|
-
const composeEl =
|
|
195
|
-
|
|
196
|
-
|
|
367
|
+
const composeEl =
|
|
368
|
+
getComposeDialogFromEventTarget(event.target) ??
|
|
369
|
+
(document.querySelector("jant-compose-dialog") as JantComposeDialog | null);
|
|
370
|
+
const isPageMode = !!composeEl?.pageMode;
|
|
197
371
|
|
|
198
372
|
// Get labels for toast messages
|
|
199
373
|
const labels = composeEl?.labels;
|
|
200
374
|
const uploadingMsg = labels?.uploading ?? "Uploading...";
|
|
201
|
-
const
|
|
375
|
+
const hasPending = detail.pendingAttachments.length > 0;
|
|
202
376
|
|
|
203
|
-
// Show persistent toast
|
|
204
|
-
|
|
377
|
+
// Show persistent toast only when uploads are still in flight
|
|
378
|
+
if (hasPending) {
|
|
379
|
+
showPersistentToast("compose-deferred", uploadingMsg);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Show result toast — replaces persistent toast if one exists, otherwise shows a new one */
|
|
383
|
+
const toastMsg = (msg: string, type: "success" | "error" = "success") => {
|
|
384
|
+
if (hasPending) {
|
|
385
|
+
replaceWithAutoClose("compose-deferred", msg, type);
|
|
386
|
+
} else {
|
|
387
|
+
showToast(msg, type);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const resetPageCompose = () => {
|
|
391
|
+
if (!isPageMode || !composeEl) return;
|
|
392
|
+
composeEl.reset();
|
|
393
|
+
composeEl.updateComplete.then(() => {
|
|
394
|
+
composeEl
|
|
395
|
+
.querySelector<JantComposeEditor>("jant-compose-editor")
|
|
396
|
+
?.focusInput();
|
|
397
|
+
});
|
|
398
|
+
};
|
|
399
|
+
const clearPageLoading = () => {
|
|
400
|
+
if (!isPageMode || !composeEl) return;
|
|
401
|
+
composeEl.loading = false;
|
|
402
|
+
};
|
|
403
|
+
const leavePageAfterConfirmSave = () => {
|
|
404
|
+
if (!isPageMode || !composeEl) return false;
|
|
405
|
+
if (!composeEl.consumePageLeaveRequest()) return false;
|
|
406
|
+
composeEl.preparePageLeave();
|
|
407
|
+
globalThis.location.assign(composeEl.closeHref || "/");
|
|
408
|
+
return true;
|
|
409
|
+
};
|
|
410
|
+
const isEdit = !!detail.editPostId;
|
|
411
|
+
let draftFallback: "upload" | "server" | null = null;
|
|
205
412
|
|
|
206
413
|
try {
|
|
207
414
|
// Wait for all pending uploads to complete
|
|
@@ -212,71 +419,165 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
|
212
419
|
|
|
213
420
|
const results = await Promise.all(pendingPromises);
|
|
214
421
|
|
|
422
|
+
// If any pending upload failed:
|
|
423
|
+
// - For new publishes: filter out failed uploads and save as draft
|
|
424
|
+
// - Otherwise: abort
|
|
425
|
+
const failedCount = results.filter((id) => id === null).length;
|
|
426
|
+
if (failedCount > 0) {
|
|
427
|
+
if (detail.status === "published" && !isEdit) {
|
|
428
|
+
draftFallback = "upload";
|
|
429
|
+
} else {
|
|
430
|
+
clearPageLoading();
|
|
431
|
+
toastMsg("Upload failed. Post not created.", "error");
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
215
436
|
// Merge newly completed mediaIds with already-done ones
|
|
216
437
|
const newMediaIds = results.filter((id): id is string => id !== null);
|
|
217
|
-
|
|
438
|
+
|
|
439
|
+
// Build clientId → mediaId map for file attachments
|
|
440
|
+
const mediaClientIdMap = new Map<string, string>();
|
|
441
|
+
for (const att of detail.pendingAttachments) {
|
|
442
|
+
const idx = pendingClientIds.indexOf(att.clientId);
|
|
443
|
+
const mediaId = results[idx];
|
|
444
|
+
if (mediaId) mediaClientIdMap.set(att.clientId, mediaId);
|
|
445
|
+
}
|
|
446
|
+
// Upload text attachments as files
|
|
447
|
+
const textMediaMap = await uploadTextAttachments(detail.attachedTexts);
|
|
218
448
|
|
|
219
449
|
// Merge alt text: for pending attachments that just uploaded,
|
|
220
450
|
// map their clientId → mediaId and include their alt text
|
|
221
451
|
const mediaAlts = { ...detail.mediaAlts };
|
|
222
452
|
for (const att of detail.pendingAttachments) {
|
|
223
453
|
if (att.alt) {
|
|
224
|
-
|
|
225
|
-
const idx = pendingClientIds.indexOf(att.clientId);
|
|
226
|
-
const mediaId = results[idx];
|
|
454
|
+
const mediaId = mediaClientIdMap.get(att.clientId);
|
|
227
455
|
if (mediaId) {
|
|
228
456
|
mediaAlts[mediaId] = att.alt;
|
|
229
457
|
}
|
|
230
458
|
}
|
|
231
459
|
}
|
|
232
460
|
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
461
|
+
// Build clientId → mediaId for all file attachments.
|
|
462
|
+
// Uses mediaClientMap captured at submit time (editor may be reset by now).
|
|
463
|
+
const fileClientIdMap = new Map<string, string>(mediaClientIdMap);
|
|
464
|
+
for (const [cid, mid] of Object.entries(detail.mediaClientMap ?? {})) {
|
|
465
|
+
fileClientIdMap.set(cid, mid);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Build final ordered list from attachmentOrder
|
|
469
|
+
let allMediaIds: string[];
|
|
470
|
+
if (detail.attachmentOrder && detail.attachmentOrder.length > 0) {
|
|
471
|
+
allMediaIds = detail.attachmentOrder
|
|
472
|
+
.map((clientId) => {
|
|
473
|
+
// Check file attachments
|
|
474
|
+
const fileId = fileClientIdMap.get(clientId);
|
|
475
|
+
if (fileId) return fileId;
|
|
476
|
+
// Check text attachments
|
|
477
|
+
const textId = textMediaMap.get(clientId);
|
|
478
|
+
if (textId) return textId;
|
|
479
|
+
return null;
|
|
480
|
+
})
|
|
481
|
+
.filter((id): id is string => id !== null);
|
|
482
|
+
} else {
|
|
483
|
+
// Fallback: combine in order
|
|
484
|
+
allMediaIds = [
|
|
485
|
+
...detail.mediaIds,
|
|
486
|
+
...newMediaIds,
|
|
487
|
+
...Array.from(textMediaMap.values()),
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const endpoint = isEdit ? `/api/posts/${detail.editPostId}` : "/compose";
|
|
492
|
+
const method = isEdit ? "PUT" : "POST";
|
|
493
|
+
|
|
494
|
+
const bodyPayload = buildPostBody({
|
|
495
|
+
...detail,
|
|
496
|
+
status: draftFallback ? "draft" : detail.status,
|
|
497
|
+
mediaIds: allMediaIds,
|
|
498
|
+
mediaAlts,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const res = await fetch(endpoint, {
|
|
502
|
+
method,
|
|
236
503
|
headers: {
|
|
237
504
|
"Content-Type": "application/json",
|
|
238
505
|
Accept: "application/json",
|
|
239
506
|
},
|
|
240
|
-
body: JSON.stringify(
|
|
241
|
-
format: detail.format,
|
|
242
|
-
title: detail.title || undefined,
|
|
243
|
-
body: detail.body || undefined,
|
|
244
|
-
url: detail.url || undefined,
|
|
245
|
-
quoteText: detail.quoteText || undefined,
|
|
246
|
-
status: detail.status,
|
|
247
|
-
rating: detail.rating || undefined,
|
|
248
|
-
collectionIds:
|
|
249
|
-
detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
|
|
250
|
-
mediaIds: allMediaIds.length > 0 ? allMediaIds : undefined,
|
|
251
|
-
mediaAlts: Object.keys(mediaAlts).length > 0 ? mediaAlts : undefined,
|
|
252
|
-
}),
|
|
507
|
+
body: JSON.stringify(bodyPayload),
|
|
253
508
|
});
|
|
254
509
|
|
|
255
510
|
if (!res.ok) {
|
|
511
|
+
// Server error on a new publish: retry as draft
|
|
512
|
+
if (detail.status === "published" && !isEdit && !draftFallback) {
|
|
513
|
+
const retryPayload = { ...bodyPayload, status: "draft" };
|
|
514
|
+
const retryRes = await fetch(endpoint, {
|
|
515
|
+
method,
|
|
516
|
+
headers: {
|
|
517
|
+
"Content-Type": "application/json",
|
|
518
|
+
Accept: "application/json",
|
|
519
|
+
},
|
|
520
|
+
body: JSON.stringify(retryPayload),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
if (retryRes.ok) {
|
|
524
|
+
draftFallback = "server";
|
|
525
|
+
const retryData = await retryRes.json();
|
|
526
|
+
const fallbackMsg =
|
|
527
|
+
labels?.publishFailedDraft ?? "Couldn't publish. Saved as draft.";
|
|
528
|
+
if (!leavePageAfterConfirmSave()) {
|
|
529
|
+
resetPageCompose();
|
|
530
|
+
}
|
|
531
|
+
toastMsg(fallbackMsg);
|
|
532
|
+
if (retryData.toast) toastMsg(retryData.toast);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
256
537
|
const data = await res.json();
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
538
|
+
clearPageLoading();
|
|
539
|
+
toastMsg(data.error ?? "Something went wrong", "error");
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (isEdit) {
|
|
544
|
+
toastMsg("Post updated.");
|
|
545
|
+
if (isPageMode) {
|
|
546
|
+
globalThis.location.assign(globalThis.location.pathname);
|
|
547
|
+
} else {
|
|
548
|
+
globalThis.location.reload();
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Upload fallback: show specific message instead of normal flow
|
|
554
|
+
if (draftFallback === "upload") {
|
|
555
|
+
const fallbackMsg =
|
|
556
|
+
labels?.uploadFailedDraft ?? "Some uploads failed. Saved as draft.";
|
|
557
|
+
resetPageCompose();
|
|
558
|
+
toastMsg(fallbackMsg);
|
|
262
559
|
return;
|
|
263
560
|
}
|
|
264
561
|
|
|
265
562
|
const data = await res.json();
|
|
266
563
|
|
|
267
|
-
if (data.status === "published"
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
564
|
+
if (data.status === "published") {
|
|
565
|
+
if (isPageMode && data.permalink) {
|
|
566
|
+
composeEl?.preparePageLeave?.();
|
|
567
|
+
globalThis.location.assign(data.permalink);
|
|
568
|
+
} else {
|
|
569
|
+
// Reload the page so the timeline picks up the new post via a
|
|
570
|
+
// full assembleTimeline() pass (correct thread previews, filters, etc.)
|
|
571
|
+
globalThis.location.reload();
|
|
272
572
|
}
|
|
573
|
+
} else {
|
|
574
|
+
if (!leavePageAfterConfirmSave()) {
|
|
575
|
+
resetPageCompose();
|
|
576
|
+
}
|
|
577
|
+
toastMsg(data.toast ?? "Draft saved.");
|
|
273
578
|
}
|
|
274
|
-
|
|
275
|
-
replaceWithAutoClose(
|
|
276
|
-
"compose-deferred",
|
|
277
|
-
data.status === "draft" ? (data.toast ?? "Draft saved.") : publishedMsg,
|
|
278
|
-
);
|
|
279
579
|
} catch {
|
|
280
|
-
|
|
580
|
+
clearPageLoading();
|
|
581
|
+
toastMsg("Something went wrong", "error");
|
|
281
582
|
}
|
|
282
583
|
});
|