@jant/core 0.3.27 → 0.3.28
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/dist/client/client.css +1 -0
- package/dist/client/client.js +31561 -0
- package/dist/index.js +15209 -15
- package/package.json +21 -15
- package/src/__tests__/helpers/app.ts +19 -3
- package/src/__tests__/helpers/db.ts +44 -0
- package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
- package/src/app.tsx +111 -174
- package/src/client.ts +13 -0
- package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
- package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
- package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
- package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
- package/src/db/schema.ts +24 -4
- package/src/i18n/locales/en.po +810 -385
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +733 -522
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +733 -522
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +7 -11
- package/src/index.ts +1 -1
- package/src/lib/__tests__/icons.test.ts +178 -0
- package/src/lib/__tests__/resolve-config.test.ts +184 -0
- package/src/lib/__tests__/schemas.test.ts +12 -6
- package/src/lib/__tests__/theme.test.ts +62 -0
- package/src/lib/__tests__/timezones.test.ts +1 -1
- package/src/lib/__tests__/url.test.ts +12 -0
- package/src/lib/__tests__/view.test.ts +1 -5
- package/src/lib/avatar-upload.ts +18 -10
- package/src/lib/collection-form-bridge.ts +52 -0
- package/src/lib/collections-reorder.ts +28 -0
- package/src/lib/compose-bridge.ts +251 -0
- package/src/lib/errors.ts +116 -0
- package/src/lib/excerpt.ts +1 -1
- package/src/lib/favicon.ts +3 -5
- package/src/lib/html.ts +22 -0
- package/src/lib/icon-catalog.ts +181 -0
- package/src/lib/icons.ts +202 -0
- package/src/lib/navigation.ts +18 -33
- package/src/lib/pagination.ts +3 -2
- package/src/lib/post-form-bridge.ts +136 -0
- package/src/lib/render.tsx +11 -4
- package/src/lib/resolve-config.ts +157 -0
- package/src/lib/schemas.ts +76 -12
- package/src/lib/settings-bridge.ts +139 -0
- package/src/lib/storage.ts +37 -16
- package/src/lib/theme.ts +5 -7
- package/src/lib/timeline.ts +4 -8
- package/src/lib/toast.ts +134 -0
- package/src/lib/upload.ts +71 -0
- package/src/lib/url.ts +9 -1
- package/src/lib/version.ts +16 -0
- package/src/lib/view.ts +9 -10
- package/src/middleware/__tests__/auth.test.ts +6 -28
- package/src/middleware/__tests__/onboarding.test.ts +1 -1
- package/src/middleware/auth.ts +6 -12
- package/src/middleware/config.ts +51 -0
- package/src/middleware/error-handler.ts +56 -0
- package/src/middleware/onboarding.ts +1 -1
- package/src/preset.css +6 -0
- package/src/routes/__tests__/compose.test.ts +104 -17
- package/src/routes/api/__tests__/collections.test.ts +93 -2
- package/src/routes/api/__tests__/posts.test.ts +2 -1
- package/src/routes/api/__tests__/settings.test.ts +1 -1
- package/src/routes/api/collections.ts +64 -68
- package/src/routes/api/nav-items.ts +21 -59
- package/src/routes/api/pages.ts +18 -46
- package/src/routes/api/posts.ts +64 -86
- package/src/routes/api/search.ts +6 -4
- package/src/routes/api/settings.ts +8 -24
- package/src/routes/api/upload.ts +55 -53
- package/src/routes/auth/__tests__/setup.test.ts +118 -0
- package/src/routes/auth/reset.tsx +17 -66
- package/src/routes/auth/setup.tsx +67 -11
- package/src/routes/auth/signin.tsx +44 -8
- package/src/routes/compose.tsx +194 -0
- package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
- package/src/routes/dash/__tests__/pages.test.ts +2 -2
- package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
- package/src/routes/dash/appearance.tsx +173 -0
- package/src/routes/dash/collections.tsx +80 -14
- package/src/routes/dash/index.tsx +12 -14
- package/src/routes/dash/media.tsx +46 -49
- package/src/routes/dash/pages.tsx +85 -37
- package/src/routes/dash/posts.tsx +60 -23
- package/src/routes/dash/redirects.tsx +43 -33
- package/src/routes/dash/settings.tsx +234 -214
- package/src/routes/feed/__tests__/rss.test.ts +7 -3
- package/src/routes/feed/rss.ts +11 -16
- package/src/routes/feed/sitemap.ts +15 -9
- package/src/routes/pages/__tests__/collections.test.ts +9 -8
- package/src/routes/pages/archive.tsx +2 -2
- package/src/routes/pages/collection.tsx +76 -9
- package/src/routes/pages/collections.tsx +3 -1
- package/src/routes/pages/featured.tsx +2 -2
- package/src/routes/pages/home.tsx +3 -3
- package/src/routes/pages/latest.tsx +2 -2
- package/src/routes/pages/page.tsx +2 -2
- package/src/routes/pages/post.tsx +2 -2
- package/src/routes/pages/search.tsx +2 -2
- package/src/services/__tests__/collection.test.ts +324 -34
- package/src/services/__tests__/media.test.ts +1 -1
- package/src/services/__tests__/page.test.ts +116 -1
- package/src/services/auth.ts +88 -0
- package/src/services/collection.ts +169 -30
- package/src/services/index.ts +8 -3
- package/src/services/media.ts +39 -12
- package/src/services/navigation.ts +17 -5
- package/src/services/page.ts +24 -4
- package/src/services/post.ts +87 -19
- package/src/services/search.ts +0 -1
- package/src/services/settings.ts +21 -13
- package/src/style.css +3 -0
- package/src/styles/components.css +42 -1
- package/src/styles/tokens.css +4 -0
- package/src/styles/ui.css +902 -73
- package/src/types/app-context.ts +25 -0
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +60 -23
- package/src/types/entities.ts +12 -2
- package/src/types/lingui-react-macro.d.ts +3 -3
- package/src/types/operations.ts +2 -4
- package/src/types/views.ts +1 -3
- package/src/ui/__tests__/font-themes.test.ts +27 -8
- package/src/ui/color-themes.ts +1 -1
- package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
- package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
- package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
- package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
- package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
- package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
- package/src/ui/components/collection-types.ts +45 -0
- package/src/ui/components/compose-types.ts +75 -0
- package/src/ui/components/jant-collection-form.ts +512 -0
- package/src/ui/components/jant-compose-dialog.ts +494 -0
- package/src/ui/components/jant-compose-editor.ts +799 -0
- package/src/ui/components/jant-post-form.ts +290 -0
- package/src/ui/components/jant-settings-avatar.ts +231 -0
- package/src/ui/components/jant-settings-general.ts +436 -0
- package/src/ui/components/post-form-template.ts +260 -0
- package/src/ui/components/post-form-types.ts +87 -0
- package/src/ui/components/settings-types.ts +62 -0
- package/src/ui/compose/ComposeDialog.tsx +141 -385
- package/src/ui/compose/ComposePrompt.tsx +3 -3
- package/src/ui/dash/PostList.tsx +55 -61
- package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
- package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
- package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
- package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
- package/src/ui/dash/collections/CollectionForm.tsx +130 -117
- package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
- package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
- package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
- package/src/ui/dash/index.ts +1 -1
- package/src/ui/dash/posts/PostForm.tsx +248 -0
- package/src/ui/dash/settings/AccountContent.tsx +69 -80
- package/src/ui/dash/settings/GeneralContent.tsx +159 -478
- package/src/ui/dash/settings/SettingsNav.tsx +4 -4
- package/src/ui/font-themes.ts +115 -32
- package/src/ui/layouts/BaseLayout.tsx +49 -19
- package/src/ui/layouts/DashLayout.tsx +14 -9
- package/src/ui/layouts/SiteLayout.tsx +38 -23
- package/src/ui/pages/CollectionPage.tsx +12 -2
- package/src/ui/pages/CollectionsPage.tsx +27 -27
- package/src/ui/pages/HomePage.tsx +15 -6
- package/src/ui/pages/SearchPage.tsx +1 -2
- package/src/ui/shared/CollectionsSidebar.tsx +59 -0
- package/src/ui/shared/Pagination.tsx +2 -2
- package/dist/app.js +0 -267
- package/dist/auth.js +0 -39
- package/dist/client.js +0 -13
- package/dist/db/index.js +0 -10
- package/dist/db/schema.js +0 -224
- package/dist/i18n/Trans.js +0 -24
- package/dist/i18n/context.js +0 -58
- package/dist/i18n/detect.js +0 -26
- package/dist/i18n/i18n.js +0 -49
- package/dist/i18n/index.js +0 -44
- package/dist/i18n/locales/en.js +0 -1
- package/dist/i18n/locales/zh-Hans.js +0 -1
- package/dist/i18n/locales/zh-Hant.js +0 -1
- package/dist/i18n/locales.js +0 -13
- package/dist/i18n/middleware.js +0 -30
- package/dist/lib/avatar-upload.js +0 -134
- package/dist/lib/config.js +0 -143
- package/dist/lib/constants.js +0 -50
- package/dist/lib/excerpt.js +0 -76
- package/dist/lib/favicon.js +0 -102
- package/dist/lib/feed.js +0 -123
- package/dist/lib/image-processor.js +0 -187
- package/dist/lib/image.js +0 -97
- package/dist/lib/index.js +0 -7
- package/dist/lib/markdown.js +0 -83
- package/dist/lib/media-helpers.js +0 -49
- package/dist/lib/media-upload.js +0 -104
- package/dist/lib/nav-reorder.js +0 -27
- package/dist/lib/navigation.js +0 -79
- package/dist/lib/pagination.js +0 -44
- package/dist/lib/render.js +0 -53
- package/dist/lib/schemas.js +0 -174
- package/dist/lib/sqid.js +0 -72
- package/dist/lib/sse.js +0 -218
- package/dist/lib/storage.js +0 -164
- package/dist/lib/theme.js +0 -65
- package/dist/lib/time.js +0 -159
- package/dist/lib/timeline.js +0 -95
- package/dist/lib/timezones.js +0 -388
- package/dist/lib/url.js +0 -89
- package/dist/lib/view.js +0 -217
- package/dist/middleware/auth.js +0 -52
- package/dist/middleware/onboarding.js +0 -41
- package/dist/routes/api/collections.js +0 -124
- package/dist/routes/api/nav-items.js +0 -104
- package/dist/routes/api/pages.js +0 -91
- package/dist/routes/api/posts.js +0 -218
- package/dist/routes/api/search.js +0 -48
- package/dist/routes/api/settings.js +0 -68
- package/dist/routes/api/upload.js +0 -246
- package/dist/routes/auth/reset.js +0 -221
- package/dist/routes/auth/setup.js +0 -194
- package/dist/routes/auth/signin.js +0 -176
- package/dist/routes/compose.js +0 -48
- package/dist/routes/dash/collections.js +0 -115
- package/dist/routes/dash/index.js +0 -118
- package/dist/routes/dash/media.js +0 -106
- package/dist/routes/dash/pages.js +0 -294
- package/dist/routes/dash/posts.js +0 -244
- package/dist/routes/dash/redirects.js +0 -257
- package/dist/routes/dash/settings.js +0 -379
- package/dist/routes/feed/rss.js +0 -62
- package/dist/routes/feed/sitemap.js +0 -49
- package/dist/routes/pages/archive.js +0 -62
- package/dist/routes/pages/collection.js +0 -34
- package/dist/routes/pages/collections.js +0 -28
- package/dist/routes/pages/featured.js +0 -36
- package/dist/routes/pages/home.js +0 -64
- package/dist/routes/pages/latest.js +0 -45
- package/dist/routes/pages/page.js +0 -68
- package/dist/routes/pages/post.js +0 -44
- package/dist/routes/pages/search.js +0 -54
- package/dist/services/collection.js +0 -109
- package/dist/services/index.js +0 -24
- package/dist/services/media.js +0 -117
- package/dist/services/navigation.js +0 -91
- package/dist/services/page.js +0 -84
- package/dist/services/post.js +0 -229
- package/dist/services/redirect.js +0 -48
- package/dist/services/search.js +0 -67
- package/dist/services/settings.js +0 -68
- package/dist/types/bindings.js +0 -3
- package/dist/types/config.js +0 -147
- package/dist/types/constants.js +0 -27
- package/dist/types/entities.js +0 -3
- package/dist/types/lingui-react-macro.d.js +0 -9
- package/dist/types/operations.js +0 -3
- package/dist/types/props.js +0 -3
- package/dist/types/sortablejs.d.js +0 -5
- package/dist/types/views.js +0 -5
- package/dist/types.js +0 -11
- package/dist/ui/color-themes.js +0 -268
- package/dist/ui/compose/ComposeDialog.js +0 -467
- package/dist/ui/compose/ComposePrompt.js +0 -55
- package/dist/ui/dash/ActionButtons.js +0 -46
- package/dist/ui/dash/CrudPageHeader.js +0 -22
- package/dist/ui/dash/DangerZone.js +0 -36
- package/dist/ui/dash/FormatBadge.js +0 -27
- package/dist/ui/dash/ListItemRow.js +0 -21
- package/dist/ui/dash/PageForm.js +0 -195
- package/dist/ui/dash/PostForm.js +0 -395
- package/dist/ui/dash/PostList.js +0 -83
- package/dist/ui/dash/StatusBadge.js +0 -46
- package/dist/ui/dash/collections/CollectionForm.js +0 -152
- package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
- package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
- package/dist/ui/dash/index.js +0 -10
- package/dist/ui/dash/media/MediaListContent.js +0 -166
- package/dist/ui/dash/media/ViewMediaContent.js +0 -212
- package/dist/ui/dash/pages/LinkFormContent.js +0 -130
- package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
- package/dist/ui/dash/settings/AccountContent.js +0 -209
- package/dist/ui/dash/settings/AppearanceContent.js +0 -259
- package/dist/ui/dash/settings/GeneralContent.js +0 -536
- package/dist/ui/dash/settings/SettingsNav.js +0 -41
- package/dist/ui/feed/LinkCard.js +0 -72
- package/dist/ui/feed/NoteCard.js +0 -58
- package/dist/ui/feed/QuoteCard.js +0 -63
- package/dist/ui/feed/ThreadPreview.js +0 -48
- package/dist/ui/feed/TimelineFeed.js +0 -41
- package/dist/ui/feed/TimelineItem.js +0 -27
- package/dist/ui/font-themes.js +0 -36
- package/dist/ui/layouts/BaseLayout.js +0 -153
- package/dist/ui/layouts/DashLayout.js +0 -141
- package/dist/ui/layouts/SiteLayout.js +0 -169
- package/dist/ui/pages/ArchivePage.js +0 -143
- package/dist/ui/pages/CollectionPage.js +0 -70
- package/dist/ui/pages/CollectionsPage.js +0 -76
- package/dist/ui/pages/FeaturedPage.js +0 -24
- package/dist/ui/pages/HomePage.js +0 -24
- package/dist/ui/pages/PostPage.js +0 -55
- package/dist/ui/pages/SearchPage.js +0 -122
- package/dist/ui/pages/SinglePage.js +0 -23
- package/dist/ui/shared/EmptyState.js +0 -27
- package/dist/ui/shared/MediaGallery.js +0 -35
- package/dist/ui/shared/Pagination.js +0 -195
- package/dist/ui/shared/ThreadView.js +0 -108
- package/dist/ui/shared/index.js +0 -5
- package/dist/vendor/datastar.js +0 -1606
- package/src/lib/__tests__/config.test.ts +0 -192
- package/src/lib/config.ts +0 -167
- package/src/routes/compose.ts +0 -63
- package/src/ui/dash/PostForm.tsx +0 -360
- package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
package/src/lib/avatar-upload.ts
CHANGED
|
@@ -51,13 +51,10 @@ function resizeToSquarePng(img: HTMLImageElement, size: number): Promise<Blob> {
|
|
|
51
51
|
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, size, size);
|
|
52
52
|
|
|
53
53
|
return new Promise((resolve, reject) => {
|
|
54
|
-
canvas.toBlob(
|
|
55
|
-
(blob)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
},
|
|
59
|
-
"image/png",
|
|
60
|
-
);
|
|
54
|
+
canvas.toBlob((blob) => {
|
|
55
|
+
if (blob) resolve(blob);
|
|
56
|
+
else reject(new Error("Failed to create PNG blob"));
|
|
57
|
+
}, "image/png");
|
|
61
58
|
});
|
|
62
59
|
}
|
|
63
60
|
|
|
@@ -84,7 +81,18 @@ async function handleAvatarUpload(
|
|
|
84
81
|
// Load the image
|
|
85
82
|
const img = await loadImage(file);
|
|
86
83
|
|
|
87
|
-
//
|
|
84
|
+
// Resize avatar to 512x512 PNG (skip for SVG — scalable and already small)
|
|
85
|
+
let avatarFile: File | Blob = file;
|
|
86
|
+
let avatarFilename = file.name;
|
|
87
|
+
if (file.type !== "image/svg+xml") {
|
|
88
|
+
const png512 = await resizeToSquarePng(img, 512);
|
|
89
|
+
avatarFile = new File([png512], file.name.replace(/\.[^.]+$/, ".png"), {
|
|
90
|
+
type: "image/png",
|
|
91
|
+
});
|
|
92
|
+
avatarFilename = (avatarFile as File).name;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Generate favicon variants in parallel
|
|
88
96
|
const [png16, png32, png180] = await Promise.all([
|
|
89
97
|
resizeToSquarePng(img, 16),
|
|
90
98
|
resizeToSquarePng(img, 32),
|
|
@@ -105,9 +113,9 @@ async function handleAvatarUpload(
|
|
|
105
113
|
if (label)
|
|
106
114
|
label.textContent = input.dataset.textUploading || "Uploading...";
|
|
107
115
|
|
|
108
|
-
// Build FormData with
|
|
116
|
+
// Build FormData with resized avatar + variants
|
|
109
117
|
const formData = new FormData();
|
|
110
|
-
formData.append("file",
|
|
118
|
+
formData.append("file", avatarFile, avatarFilename);
|
|
111
119
|
formData.append("favicon", icoBlob, "favicon.ico");
|
|
112
120
|
formData.append("appleTouch", png180, "apple-touch-icon.png");
|
|
113
121
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection Form Bridge
|
|
3
|
+
*
|
|
4
|
+
* Handles communication between <jant-collection-form> and the server.
|
|
5
|
+
* Listens for `jant:collection-submit`, POSTs JSON to the endpoint, and
|
|
6
|
+
* redirects on success. Displays toasts on failure.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CollectionSubmitDetail } from "../ui/components/collection-types.js";
|
|
10
|
+
import type { JantCollectionForm } from "../ui/components/jant-collection-form.js";
|
|
11
|
+
import { showToast } from "./toast.js";
|
|
12
|
+
|
|
13
|
+
document.addEventListener("jant:collection-submit", async (event: Event) => {
|
|
14
|
+
const customEvent = event as CustomEvent<CollectionSubmitDetail>;
|
|
15
|
+
const detail = customEvent.detail;
|
|
16
|
+
const formEl =
|
|
17
|
+
customEvent.target instanceof HTMLElement
|
|
18
|
+
? (customEvent.target as JantCollectionForm)
|
|
19
|
+
: document.querySelector<JantCollectionForm>("jant-collection-form");
|
|
20
|
+
|
|
21
|
+
if (!detail?.endpoint || !formEl) return;
|
|
22
|
+
|
|
23
|
+
formEl.loading = true;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(detail.endpoint, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
Accept: "application/json",
|
|
31
|
+
},
|
|
32
|
+
body: JSON.stringify(detail.data),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
throw new Error(`HTTP ${res.status}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const json = await res.json();
|
|
40
|
+
|
|
41
|
+
if (json?.status === "redirect" && typeof json.url === "string") {
|
|
42
|
+
window.location.href = json.url;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
showToast("Saved successfully.");
|
|
47
|
+
} catch {
|
|
48
|
+
showToast("Failed to save collection. Please try again.", "error");
|
|
49
|
+
} finally {
|
|
50
|
+
formEl.loading = false;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection Reorder
|
|
3
|
+
*
|
|
4
|
+
* Initializes SortableJS on the collections list in the dashboard.
|
|
5
|
+
* Auto-detects the list element and only activates when present.
|
|
6
|
+
* Sends prefixed string IDs (e.g. "c-1", "d-2") to support mixed
|
|
7
|
+
* collections and dividers in a unified sort order.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import Sortable from "sortablejs";
|
|
11
|
+
|
|
12
|
+
const list = document.getElementById("collections-list");
|
|
13
|
+
if (list) {
|
|
14
|
+
Sortable.create(list, {
|
|
15
|
+
animation: 150,
|
|
16
|
+
handle: "[data-id]",
|
|
17
|
+
onEnd() {
|
|
18
|
+
const items = [...list.querySelectorAll<HTMLElement>("[data-id]")]
|
|
19
|
+
.map((el) => el.dataset.id)
|
|
20
|
+
.filter((id): id is string => id !== undefined);
|
|
21
|
+
fetch("/dash/collections/reorder", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
headers: { "Content-Type": "application/json" },
|
|
24
|
+
body: JSON.stringify({ items }),
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
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 "../ui/components/compose-types.js";
|
|
9
|
+
import type { ComposeAttachment } from "../ui/components/compose-types.js";
|
|
10
|
+
import type { JantComposeDialog } from "../ui/components/jant-compose-dialog.js";
|
|
11
|
+
import type { JantComposeEditor } from "../ui/components/jant-compose-editor.js";
|
|
12
|
+
import { ImageProcessor } from "./image-processor.js";
|
|
13
|
+
import {
|
|
14
|
+
showToast,
|
|
15
|
+
showPersistentToast,
|
|
16
|
+
replaceWithAutoClose,
|
|
17
|
+
} from "./toast.js";
|
|
18
|
+
|
|
19
|
+
// ── Upload manager ──────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/** Track in-flight upload promises keyed by clientId */
|
|
22
|
+
const uploadPromises = new Map<string, Promise<string | null>>();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Upload a single file: process with ImageProcessor, then POST to /api/upload.
|
|
26
|
+
* Returns the mediaId on success, null on failure.
|
|
27
|
+
*/
|
|
28
|
+
async function uploadFile(
|
|
29
|
+
file: File,
|
|
30
|
+
clientId: string,
|
|
31
|
+
editor: JantComposeEditor | null,
|
|
32
|
+
): Promise<string | null> {
|
|
33
|
+
try {
|
|
34
|
+
// Update status to uploading
|
|
35
|
+
editor?.updateAttachmentStatus(clientId, "uploading", null, null);
|
|
36
|
+
|
|
37
|
+
// Process image (resize, convert to WebP)
|
|
38
|
+
const processed = await ImageProcessor.processToFile(file);
|
|
39
|
+
|
|
40
|
+
// Upload to server
|
|
41
|
+
const formData = new FormData();
|
|
42
|
+
formData.append("file", processed);
|
|
43
|
+
|
|
44
|
+
const res = await fetch("/api/upload", {
|
|
45
|
+
method: "POST",
|
|
46
|
+
body: formData,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
const error = data.error ?? "Upload failed";
|
|
52
|
+
editor?.updateAttachmentStatus(clientId, "error", null, error);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const data = await res.json();
|
|
57
|
+
const mediaId = data.id as string;
|
|
58
|
+
editor?.updateAttachmentStatus(clientId, "done", mediaId, null);
|
|
59
|
+
return mediaId;
|
|
60
|
+
} catch {
|
|
61
|
+
editor?.updateAttachmentStatus(clientId, "error", null, "Upload failed");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getEditor(): JantComposeEditor | null {
|
|
67
|
+
return document.querySelector("jant-compose-editor");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── File selection handler ──────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
document.addEventListener("jant:files-selected", (e: Event) => {
|
|
73
|
+
const event = e as CustomEvent<{
|
|
74
|
+
files: { file: File; clientId: string }[];
|
|
75
|
+
}>;
|
|
76
|
+
const editor = getEditor();
|
|
77
|
+
|
|
78
|
+
for (const { file, clientId } of event.detail.files) {
|
|
79
|
+
const promise = uploadFile(file, clientId, editor);
|
|
80
|
+
uploadPromises.set(clientId, promise);
|
|
81
|
+
promise.finally(() => uploadPromises.delete(clientId));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ── Submit handler ──────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
document.addEventListener("jant:compose-submit", async (e: Event) => {
|
|
88
|
+
const event = e as CustomEvent<ComposeSubmitDetail>;
|
|
89
|
+
const detail = event.detail;
|
|
90
|
+
const dialog = document.getElementById(
|
|
91
|
+
"compose-dialog",
|
|
92
|
+
) as HTMLDialogElement | null;
|
|
93
|
+
const composeEl = document.querySelector(
|
|
94
|
+
"jant-compose-dialog",
|
|
95
|
+
) as JantComposeDialog | null;
|
|
96
|
+
|
|
97
|
+
if (!composeEl) return;
|
|
98
|
+
composeEl.loading = true;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const res = await fetch("/compose", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: {
|
|
104
|
+
"Content-Type": "application/json",
|
|
105
|
+
Accept: "application/json",
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
format: detail.format,
|
|
109
|
+
title: detail.title || undefined,
|
|
110
|
+
body: detail.body || undefined,
|
|
111
|
+
url: detail.url || undefined,
|
|
112
|
+
quoteText: detail.quoteText || undefined,
|
|
113
|
+
status: detail.status,
|
|
114
|
+
rating: detail.rating || undefined,
|
|
115
|
+
collectionIds:
|
|
116
|
+
detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
|
|
117
|
+
mediaIds: detail.mediaIds.length > 0 ? detail.mediaIds : undefined,
|
|
118
|
+
mediaAlts:
|
|
119
|
+
Object.keys(detail.mediaAlts).length > 0
|
|
120
|
+
? detail.mediaAlts
|
|
121
|
+
: undefined,
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
if (!res.ok) {
|
|
126
|
+
const data = await res.json();
|
|
127
|
+
showToast(data.error ?? "Something went wrong", "error");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const data = await res.json();
|
|
132
|
+
|
|
133
|
+
if (data.status === "draft") {
|
|
134
|
+
showToast(data.toast ?? "Draft saved.");
|
|
135
|
+
} else if (data.status === "published" && data.cardHtml) {
|
|
136
|
+
const timeline = document.getElementById("timeline-items");
|
|
137
|
+
if (timeline) {
|
|
138
|
+
document.getElementById("empty-timeline")?.remove();
|
|
139
|
+
timeline.insertAdjacentHTML("afterbegin", data.cardHtml);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
dialog?.close();
|
|
144
|
+
// Prevent browser from restoring focus to the trigger button
|
|
145
|
+
(document.activeElement as HTMLElement)?.blur();
|
|
146
|
+
composeEl.reset();
|
|
147
|
+
} catch {
|
|
148
|
+
showToast("Something went wrong", "error");
|
|
149
|
+
} finally {
|
|
150
|
+
composeEl.loading = false;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── Deferred submit handler ─────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
interface DeferredDetail extends ComposeSubmitDetail {
|
|
157
|
+
pendingAttachments: ComposeAttachment[];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
161
|
+
const event = e as CustomEvent<DeferredDetail>;
|
|
162
|
+
const detail = event.detail;
|
|
163
|
+
const composeEl = document.querySelector(
|
|
164
|
+
"jant-compose-dialog",
|
|
165
|
+
) as JantComposeDialog | null;
|
|
166
|
+
|
|
167
|
+
// Get labels for toast messages
|
|
168
|
+
const labels = composeEl?.labels;
|
|
169
|
+
const uploadingMsg = labels?.uploading ?? "Uploading...";
|
|
170
|
+
const publishedMsg = labels?.published ?? "Published!";
|
|
171
|
+
|
|
172
|
+
// Show persistent toast
|
|
173
|
+
showPersistentToast("compose-deferred", uploadingMsg);
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
// Wait for all pending uploads to complete
|
|
177
|
+
const pendingClientIds = detail.pendingAttachments.map((a) => a.clientId);
|
|
178
|
+
const pendingPromises = pendingClientIds
|
|
179
|
+
.map((id) => uploadPromises.get(id))
|
|
180
|
+
.filter((p): p is Promise<string | null> => p !== undefined);
|
|
181
|
+
|
|
182
|
+
const results = await Promise.all(pendingPromises);
|
|
183
|
+
|
|
184
|
+
// Merge newly completed mediaIds with already-done ones
|
|
185
|
+
const newMediaIds = results.filter((id): id is string => id !== null);
|
|
186
|
+
const allMediaIds = [...detail.mediaIds, ...newMediaIds];
|
|
187
|
+
|
|
188
|
+
// Merge alt text: for pending attachments that just uploaded,
|
|
189
|
+
// map their clientId → mediaId and include their alt text
|
|
190
|
+
const mediaAlts = { ...detail.mediaAlts };
|
|
191
|
+
for (const att of detail.pendingAttachments) {
|
|
192
|
+
if (att.alt) {
|
|
193
|
+
// Find the mediaId from the upload result by matching clientId position
|
|
194
|
+
const idx = pendingClientIds.indexOf(att.clientId);
|
|
195
|
+
const mediaId = results[idx];
|
|
196
|
+
if (mediaId) {
|
|
197
|
+
mediaAlts[mediaId] = att.alt;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// POST to /compose
|
|
203
|
+
const res = await fetch("/compose", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: {
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
Accept: "application/json",
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
format: detail.format,
|
|
211
|
+
title: detail.title || undefined,
|
|
212
|
+
body: detail.body || undefined,
|
|
213
|
+
url: detail.url || undefined,
|
|
214
|
+
quoteText: detail.quoteText || undefined,
|
|
215
|
+
status: detail.status,
|
|
216
|
+
rating: detail.rating || undefined,
|
|
217
|
+
collectionIds:
|
|
218
|
+
detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
|
|
219
|
+
mediaIds: allMediaIds.length > 0 ? allMediaIds : undefined,
|
|
220
|
+
mediaAlts: Object.keys(mediaAlts).length > 0 ? mediaAlts : undefined,
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
const data = await res.json();
|
|
226
|
+
replaceWithAutoClose(
|
|
227
|
+
"compose-deferred",
|
|
228
|
+
data.error ?? "Something went wrong",
|
|
229
|
+
"error",
|
|
230
|
+
);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const data = await res.json();
|
|
235
|
+
|
|
236
|
+
if (data.status === "published" && data.cardHtml) {
|
|
237
|
+
const timeline = document.getElementById("timeline-items");
|
|
238
|
+
if (timeline) {
|
|
239
|
+
document.getElementById("empty-timeline")?.remove();
|
|
240
|
+
timeline.insertAdjacentHTML("afterbegin", data.cardHtml);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
replaceWithAutoClose(
|
|
245
|
+
"compose-deferred",
|
|
246
|
+
data.status === "draft" ? (data.toast ?? "Draft saved.") : publishedMsg,
|
|
247
|
+
);
|
|
248
|
+
} catch {
|
|
249
|
+
replaceWithAutoClose("compose-deferred", "Something went wrong", "error");
|
|
250
|
+
}
|
|
251
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain Error Classes
|
|
3
|
+
*
|
|
4
|
+
* Typed errors per coding-standards.md error taxonomy.
|
|
5
|
+
* Services throw these; the error handler middleware maps them to HTTP responses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Base class for all domain errors.
|
|
10
|
+
* Each subclass maps to a specific HTTP status code.
|
|
11
|
+
*/
|
|
12
|
+
export class DomainError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
message: string,
|
|
15
|
+
public readonly statusCode: number,
|
|
16
|
+
public readonly code: string,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = this.constructor.name;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Invalid input — 400 */
|
|
24
|
+
export class ValidationError extends DomainError {
|
|
25
|
+
constructor(
|
|
26
|
+
message: string,
|
|
27
|
+
public readonly details?: unknown,
|
|
28
|
+
) {
|
|
29
|
+
super(message, 400, "VALIDATION_ERROR");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Not authenticated — 401 */
|
|
34
|
+
export class UnauthorizedError extends DomainError {
|
|
35
|
+
constructor(message = "Unauthorized") {
|
|
36
|
+
super(message, 401, "UNAUTHORIZED");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Authenticated but not allowed — 403 */
|
|
41
|
+
export class ForbiddenError extends DomainError {
|
|
42
|
+
constructor(message = "Forbidden") {
|
|
43
|
+
super(message, 403, "FORBIDDEN");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Resource doesn't exist — 404 */
|
|
48
|
+
export class NotFoundError extends DomainError {
|
|
49
|
+
constructor(resource = "Resource") {
|
|
50
|
+
super(`${resource} not found`, 404, "NOT_FOUND");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** State conflict (e.g. duplicate) — 409 */
|
|
55
|
+
export class ConflictError extends DomainError {
|
|
56
|
+
constructor(message: string) {
|
|
57
|
+
super(message, 409, "CONFLICT");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Too many requests — 429 */
|
|
62
|
+
export class RateLimitError extends DomainError {
|
|
63
|
+
constructor(message = "Too many requests") {
|
|
64
|
+
super(message, 429, "RATE_LIMIT");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Third-party failure — 500 */
|
|
69
|
+
export class ExternalServiceError extends DomainError {
|
|
70
|
+
constructor(message: string) {
|
|
71
|
+
super(message, 500, "EXTERNAL_SERVICE_ERROR");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// Route Helpers
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Asserts a value is not null/undefined, throwing NotFoundError if it is.
|
|
81
|
+
*
|
|
82
|
+
* @param value - The value to check
|
|
83
|
+
* @param resource - Resource name for the error message
|
|
84
|
+
* @returns The non-null value
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* const post = assertFound(await services.posts.getById(id), "Post");
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function assertFound<T>(
|
|
91
|
+
value: T | null | undefined,
|
|
92
|
+
resource: string,
|
|
93
|
+
): T {
|
|
94
|
+
if (value == null) {
|
|
95
|
+
throw new NotFoundError(resource);
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse a route parameter as a positive integer, throwing ValidationError if invalid.
|
|
102
|
+
*
|
|
103
|
+
* @param value - Raw string parameter from the route
|
|
104
|
+
* @returns Parsed integer
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* const id = parseIntParam(c.req.param("id"));
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export function parseIntParam(value: string): number {
|
|
111
|
+
const id = parseInt(value, 10);
|
|
112
|
+
if (isNaN(id) || id < 1) {
|
|
113
|
+
throw new ValidationError("Invalid ID");
|
|
114
|
+
}
|
|
115
|
+
return id;
|
|
116
|
+
}
|
package/src/lib/excerpt.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface HtmlExcerpt {
|
|
|
61
61
|
export function getHtmlExcerpt(bodyHtml: string): HtmlExcerpt {
|
|
62
62
|
// Honor manual <!--more--> marker
|
|
63
63
|
if (bodyHtml.includes("<!--more-->")) {
|
|
64
|
-
const excerpt = bodyHtml.split("<!--more-->")[0]
|
|
64
|
+
const excerpt = bodyHtml.split("<!--more-->")[0] ?? "";
|
|
65
65
|
return { excerpt, hasMore: true };
|
|
66
66
|
}
|
|
67
67
|
|
package/src/lib/favicon.ts
CHANGED
|
@@ -34,9 +34,7 @@ export const FAVICON_SIZES = {
|
|
|
34
34
|
* ]);
|
|
35
35
|
* ```
|
|
36
36
|
*/
|
|
37
|
-
export function encodeIco(
|
|
38
|
-
entries: { size: number; png: ArrayBuffer }[],
|
|
39
|
-
): Blob {
|
|
37
|
+
export function encodeIco(entries: { size: number; png: ArrayBuffer }[]): Blob {
|
|
40
38
|
const headerSize = 6;
|
|
41
39
|
const dirEntrySize = 16;
|
|
42
40
|
const dirSize = entries.length * dirEntrySize;
|
|
@@ -54,7 +52,7 @@ export function encodeIco(
|
|
|
54
52
|
|
|
55
53
|
const pngBuffers: ArrayBuffer[] = [];
|
|
56
54
|
for (let i = 0; i < entries.length; i++) {
|
|
57
|
-
const entry = entries[i]
|
|
55
|
+
const entry = entries[i] as (typeof entries)[number];
|
|
58
56
|
const offset = headerSize + i * dirEntrySize;
|
|
59
57
|
|
|
60
58
|
// Width/height: 0 means 256
|
|
@@ -89,7 +87,7 @@ export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
|
89
87
|
const bytes = new Uint8Array(buffer);
|
|
90
88
|
let binary = "";
|
|
91
89
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
92
|
-
binary += String.fromCharCode(bytes[i]
|
|
90
|
+
binary += String.fromCharCode(bytes[i] as number);
|
|
93
91
|
}
|
|
94
92
|
return btoa(binary);
|
|
95
93
|
}
|
package/src/lib/html.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Escape HTML special characters for safe insertion into HTML strings.
|
|
7
|
+
*
|
|
8
|
+
* @param str - The string to escape
|
|
9
|
+
* @returns The escaped string
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* escapeHtml('<script>alert("xss")</script>')
|
|
13
|
+
* // '<script>alert("xss")</script>'
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function escapeHtml(str: string): string {
|
|
17
|
+
return str
|
|
18
|
+
.replace(/&/g, "&")
|
|
19
|
+
.replace(/</g, "<")
|
|
20
|
+
.replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """);
|
|
22
|
+
}
|