@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,583 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose Bridge
|
|
3
|
+
*
|
|
4
|
+
* Handles server communication between the Lit compose dialog and the server.
|
|
5
|
+
* Manages file uploads, deferred submit flow, and toast notifications.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ComposeSubmitDetail } from "./components/compose-types.js";
|
|
9
|
+
import type { ComposeAttachment } from "./components/compose-types.js";
|
|
10
|
+
import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
|
|
11
|
+
import type { JantComposeEditor } from "./components/jant-compose-editor.js";
|
|
12
|
+
import { AudioProcessor } from "./audio-processor.js";
|
|
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";
|
|
19
|
+
import {
|
|
20
|
+
showToast,
|
|
21
|
+
showPersistentToast,
|
|
22
|
+
replaceWithAutoClose,
|
|
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
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Upload manager ──────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** Track in-flight upload promises keyed by clientId */
|
|
46
|
+
const uploadPromises = new Map<string, Promise<string | null>>();
|
|
47
|
+
|
|
48
|
+
/** Track attachments removed while their upload is still in flight */
|
|
49
|
+
const removedClientIds = new Set<string>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Upload a single file: process with ImageProcessor, then POST to /api/upload.
|
|
53
|
+
* Returns the mediaId on success, null on failure.
|
|
54
|
+
*/
|
|
55
|
+
async function uploadFile(
|
|
56
|
+
file: File,
|
|
57
|
+
clientId: string,
|
|
58
|
+
editor: JantComposeEditor | null,
|
|
59
|
+
): Promise<string | null> {
|
|
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
|
+
|
|
147
|
+
// Update status to uploading
|
|
148
|
+
editor?.updateAttachmentStatus(clientId, "uploading", null, null);
|
|
149
|
+
|
|
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
|
+
}
|
|
185
|
+
|
|
186
|
+
// Small files: existing single-request upload
|
|
187
|
+
const formData = new FormData();
|
|
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);
|
|
195
|
+
|
|
196
|
+
const res = await fetch("/api/upload", {
|
|
197
|
+
method: "POST",
|
|
198
|
+
body: formData,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (!res.ok) {
|
|
202
|
+
const data = await res.json();
|
|
203
|
+
const error = data.error ?? "Upload failed";
|
|
204
|
+
editor?.updateAttachmentStatus(clientId, "error", null, error);
|
|
205
|
+
showToast(error, "error");
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const data = await res.json();
|
|
210
|
+
const mediaId = data.id as string;
|
|
211
|
+
editor?.updateAttachmentStatus(clientId, "done", mediaId, null);
|
|
212
|
+
return mediaId;
|
|
213
|
+
} catch {
|
|
214
|
+
editor?.updateAttachmentStatus(clientId, "error", null, "Upload failed");
|
|
215
|
+
showToast("Upload failed", "error");
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Attachment removal handler ───────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
document.addEventListener("jant:attachment-removed", (e: Event) => {
|
|
223
|
+
const { clientId, mediaId } = (
|
|
224
|
+
e as CustomEvent<{ clientId: string; mediaId: string | null }>
|
|
225
|
+
).detail;
|
|
226
|
+
|
|
227
|
+
if (mediaId) {
|
|
228
|
+
// Upload already finished — fire-and-forget delete
|
|
229
|
+
fetch(`/api/upload/${mediaId}`, { method: "DELETE" }).catch(() => {});
|
|
230
|
+
} else {
|
|
231
|
+
// Upload still in flight — mark for cleanup after it finishes
|
|
232
|
+
removedClientIds.add(clientId);
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// ── File selection handler ──────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
document.addEventListener("jant:files-selected", (e: Event) => {
|
|
239
|
+
const event = e as CustomEvent<{
|
|
240
|
+
files: { file: File; clientId: string }[];
|
|
241
|
+
}>;
|
|
242
|
+
const editor = getComposeEditorFromEventTarget(event.target);
|
|
243
|
+
|
|
244
|
+
for (const { file, clientId } of event.detail.files) {
|
|
245
|
+
const promise = uploadFile(file, clientId, editor).then((mediaId) => {
|
|
246
|
+
// If the attachment was removed while uploading, delete it immediately
|
|
247
|
+
if (removedClientIds.has(clientId)) {
|
|
248
|
+
removedClientIds.delete(clientId);
|
|
249
|
+
if (mediaId) {
|
|
250
|
+
fetch(`/api/upload/${mediaId}`, { method: "DELETE" }).catch(() => {});
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return mediaId;
|
|
255
|
+
});
|
|
256
|
+
uploadPromises.set(clientId, promise);
|
|
257
|
+
promise.finally(() => uploadPromises.delete(clientId));
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── Reply trigger handler ───────────────────────────────────────────
|
|
262
|
+
|
|
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(
|
|
286
|
+
"jant-compose-dialog",
|
|
287
|
+
) as JantComposeDialog | null;
|
|
288
|
+
dialog?.openReply(postId, { contentHtml, dateText });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── Submit handler ──────────────────────────────────────────────────
|
|
292
|
+
|
|
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
|
+
}
|
|
313
|
+
|
|
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;
|
|
331
|
+
}
|
|
332
|
+
|
|
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);
|
|
349
|
+
}
|
|
350
|
+
} catch {
|
|
351
|
+
// Upload failed — skip this item
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return clientIdToMediaId;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ── Deferred submit handler ─────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
interface DeferredDetail extends ComposeSubmitDetail {
|
|
361
|
+
pendingAttachments: ComposeAttachment[];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
365
|
+
const event = e as CustomEvent<DeferredDetail>;
|
|
366
|
+
const detail = event.detail;
|
|
367
|
+
const composeEl =
|
|
368
|
+
getComposeDialogFromEventTarget(event.target) ??
|
|
369
|
+
(document.querySelector("jant-compose-dialog") as JantComposeDialog | null);
|
|
370
|
+
const isPageMode = !!composeEl?.pageMode;
|
|
371
|
+
|
|
372
|
+
// Get labels for toast messages
|
|
373
|
+
const labels = composeEl?.labels;
|
|
374
|
+
const uploadingMsg = labels?.uploading ?? "Uploading...";
|
|
375
|
+
const hasPending = detail.pendingAttachments.length > 0;
|
|
376
|
+
|
|
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;
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
// Wait for all pending uploads to complete
|
|
415
|
+
const pendingClientIds = detail.pendingAttachments.map((a) => a.clientId);
|
|
416
|
+
const pendingPromises = pendingClientIds
|
|
417
|
+
.map((id) => uploadPromises.get(id))
|
|
418
|
+
.filter((p): p is Promise<string | null> => p !== undefined);
|
|
419
|
+
|
|
420
|
+
const results = await Promise.all(pendingPromises);
|
|
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
|
+
|
|
436
|
+
// Merge newly completed mediaIds with already-done ones
|
|
437
|
+
const newMediaIds = results.filter((id): id is string => id !== null);
|
|
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);
|
|
448
|
+
|
|
449
|
+
// Merge alt text: for pending attachments that just uploaded,
|
|
450
|
+
// map their clientId → mediaId and include their alt text
|
|
451
|
+
const mediaAlts = { ...detail.mediaAlts };
|
|
452
|
+
for (const att of detail.pendingAttachments) {
|
|
453
|
+
if (att.alt) {
|
|
454
|
+
const mediaId = mediaClientIdMap.get(att.clientId);
|
|
455
|
+
if (mediaId) {
|
|
456
|
+
mediaAlts[mediaId] = att.alt;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
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,
|
|
503
|
+
headers: {
|
|
504
|
+
"Content-Type": "application/json",
|
|
505
|
+
Accept: "application/json",
|
|
506
|
+
},
|
|
507
|
+
body: JSON.stringify(bodyPayload),
|
|
508
|
+
});
|
|
509
|
+
|
|
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
|
+
|
|
537
|
+
const data = await res.json();
|
|
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);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const data = await res.json();
|
|
563
|
+
|
|
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();
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
if (!leavePageAfterConfirmSave()) {
|
|
575
|
+
resetPageCompose();
|
|
576
|
+
}
|
|
577
|
+
toastMsg(data.toast ?? "Draft saved.");
|
|
578
|
+
}
|
|
579
|
+
} catch {
|
|
580
|
+
clearPageLoading();
|
|
581
|
+
toastMsg("Something went wrong", "error");
|
|
582
|
+
}
|
|
583
|
+
});
|
|
@@ -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,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lazy-loaded slug generation
|
|
3
|
+
*
|
|
4
|
+
* Wraps the `slugify` function from `url.ts` behind a dynamic import so
|
|
5
|
+
* `limax` (used for i18n-aware transliteration) doesn't bloat the main
|
|
6
|
+
* client bundle. Vite code-splits it into a separate chunk.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { slugify, preloadSlug } from "./lazy-slugify.js";
|
|
11
|
+
*
|
|
12
|
+
* preloadSlug(); // start loading in background
|
|
13
|
+
* const s = await slugify("你好世界"); // "ni-hao-shi-jie"
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type SlugifyFn = (text: string) => string;
|
|
18
|
+
|
|
19
|
+
let slugifyFn: SlugifyFn | undefined;
|
|
20
|
+
let loadingPromise: Promise<SlugifyFn> | undefined;
|
|
21
|
+
|
|
22
|
+
function load(): Promise<SlugifyFn> {
|
|
23
|
+
if (slugifyFn) return Promise.resolve(slugifyFn);
|
|
24
|
+
if (!loadingPromise) {
|
|
25
|
+
loadingPromise = import("../lib/url.js").then((mod) => {
|
|
26
|
+
slugifyFn = mod.slugify;
|
|
27
|
+
return mod.slugify;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
return loadingPromise;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start loading the slug library in the background.
|
|
35
|
+
* Call this early (e.g. when a form mounts) so `slugify()` resolves instantly later.
|
|
36
|
+
*/
|
|
37
|
+
export function preloadSlug(): void {
|
|
38
|
+
load();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate a URL-safe slug from the given text.
|
|
43
|
+
* Handles CJK scripts via pinyin transliteration.
|
|
44
|
+
*
|
|
45
|
+
* @param text - The input string to slugify
|
|
46
|
+
* @returns A lowercased, hyphen-separated slug
|
|
47
|
+
*/
|
|
48
|
+
export async function slugify(text: string): Promise<string> {
|
|
49
|
+
const fn = await load();
|
|
50
|
+
return fn(text);
|
|
51
|
+
}
|