@jant/core 0.3.36 → 0.3.38
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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom URLs API Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { Bindings } from "../../types.js";
|
|
7
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
8
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
9
|
+
import { CreateCustomUrlSchema, parseValidated } from "../../lib/schemas.js";
|
|
10
|
+
import { parseIdParam, NotFoundError } from "../../lib/errors.js";
|
|
11
|
+
import { DEFAULT_PAGE_SIZE } from "../../lib/constants.js";
|
|
12
|
+
|
|
13
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
14
|
+
|
|
15
|
+
export const customUrlsApiRoutes = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
// List custom URLs (requires auth)
|
|
18
|
+
customUrlsApiRoutes.get("/", requireAuthApi(), async (c) => {
|
|
19
|
+
const pageParam = c.req.query("page");
|
|
20
|
+
const page = Math.max(1, parseInt(pageParam || "1", 10) || 1);
|
|
21
|
+
|
|
22
|
+
const [total, customUrls] = await Promise.all([
|
|
23
|
+
c.var.services.customUrls.count(),
|
|
24
|
+
c.var.services.customUrls.list({
|
|
25
|
+
limit: DEFAULT_PAGE_SIZE,
|
|
26
|
+
offset: (page - 1) * DEFAULT_PAGE_SIZE,
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const totalPages = Math.max(1, Math.ceil(total / DEFAULT_PAGE_SIZE));
|
|
31
|
+
return c.json({ customUrls, total, page, totalPages });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Create custom URL (requires auth)
|
|
35
|
+
customUrlsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
36
|
+
const body = parseValidated(CreateCustomUrlSchema, await c.req.json());
|
|
37
|
+
|
|
38
|
+
const redirectType = body.redirectType
|
|
39
|
+
? (parseInt(body.redirectType, 10) as 301 | 302)
|
|
40
|
+
: undefined;
|
|
41
|
+
|
|
42
|
+
// Resolve slug → ID for post/collection targets
|
|
43
|
+
let targetId = body.targetId;
|
|
44
|
+
if (body.targetType === "post" && body.targetId) {
|
|
45
|
+
const post = await c.var.services.posts.getBySlug(body.targetId);
|
|
46
|
+
if (!post) {
|
|
47
|
+
throw new NotFoundError(`Post with slug "${body.targetId}" not found`);
|
|
48
|
+
}
|
|
49
|
+
targetId = post.id;
|
|
50
|
+
}
|
|
51
|
+
if (body.targetType === "collection" && body.targetId) {
|
|
52
|
+
const col = await c.var.services.collections.getBySlug(body.targetId);
|
|
53
|
+
if (!col) {
|
|
54
|
+
throw new NotFoundError(
|
|
55
|
+
`Collection with slug "${body.targetId}" not found`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
targetId = col.id;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const customUrl = await c.var.services.customUrls.create({
|
|
62
|
+
path: body.path,
|
|
63
|
+
targetType: body.targetType,
|
|
64
|
+
targetId,
|
|
65
|
+
toPath: body.toPath,
|
|
66
|
+
redirectType,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return c.json(customUrl, 201);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Delete custom URL (requires auth)
|
|
73
|
+
customUrlsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
|
|
74
|
+
const id = parseIdParam(c.req.param("id"));
|
|
75
|
+
|
|
76
|
+
const success = await c.var.services.customUrls.delete(id);
|
|
77
|
+
if (!success) throw new NotFoundError("Custom URL");
|
|
78
|
+
|
|
79
|
+
return c.json({ success: true });
|
|
80
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Export API Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { Bindings } from "../../types.js";
|
|
7
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
8
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
9
|
+
import { createExportService } from "../../services/export.js";
|
|
10
|
+
|
|
11
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
|
+
|
|
13
|
+
export const exportApiRoutes = new Hono<Env>();
|
|
14
|
+
|
|
15
|
+
exportApiRoutes.post("/zola", requireAuthApi(), async (c) => {
|
|
16
|
+
const { services, appConfig } = c.var;
|
|
17
|
+
const exportService = createExportService(services, {
|
|
18
|
+
siteName: appConfig.siteName,
|
|
19
|
+
siteUrl: appConfig.siteUrl,
|
|
20
|
+
siteDescription: appConfig.siteDescription,
|
|
21
|
+
siteLanguage: appConfig.siteLanguage,
|
|
22
|
+
});
|
|
23
|
+
const zip = await exportService.generateZolaSite();
|
|
24
|
+
return new Response(zip, {
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "application/zip",
|
|
27
|
+
"Content-Disposition": 'attachment; filename="jant-export.zip"',
|
|
28
|
+
"Content-Length": String(zip.byteLength),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -7,20 +7,18 @@ import { z } from "zod";
|
|
|
7
7
|
import type { Bindings, NavItemType } from "../../types.js";
|
|
8
8
|
import type { AppVariables } from "../../types/app-context.js";
|
|
9
9
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
ReorderSchema,
|
|
13
|
-
parseValidated,
|
|
14
|
-
} from "../../lib/schemas.js";
|
|
15
|
-
import { assertFound, parseIntParam, NotFoundError } from "../../lib/errors.js";
|
|
10
|
+
import { CreateNavItemSchema, parseValidated } from "../../lib/schemas.js";
|
|
11
|
+
import { assertFound, parseIdParam, NotFoundError } from "../../lib/errors.js";
|
|
16
12
|
|
|
17
13
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
18
14
|
|
|
19
15
|
export const navItemsApiRoutes = new Hono<Env>();
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
const UpdateNavItemSchema = CreateNavItemSchema.partial();
|
|
18
|
+
|
|
19
|
+
const MoveSchema = z.object({
|
|
20
|
+
after: z.string().nullable().optional(),
|
|
21
|
+
before: z.string().nullable().optional(),
|
|
24
22
|
});
|
|
25
23
|
|
|
26
24
|
// List nav items
|
|
@@ -29,13 +27,21 @@ navItemsApiRoutes.get("/", async (c) => {
|
|
|
29
27
|
return c.json({ navItems: items });
|
|
30
28
|
});
|
|
31
29
|
|
|
32
|
-
//
|
|
33
|
-
navItemsApiRoutes.put("/
|
|
34
|
-
const
|
|
30
|
+
// Move nav item (requires auth) — must be before /:id
|
|
31
|
+
navItemsApiRoutes.put("/:id/move", requireAuthApi(), async (c) => {
|
|
32
|
+
const id = parseIdParam(c.req.param("id"));
|
|
33
|
+
const body = parseValidated(MoveSchema, await c.req.json());
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
const item = assertFound(
|
|
36
|
+
await c.var.services.navItems.move(
|
|
37
|
+
id,
|
|
38
|
+
body.after ?? null,
|
|
39
|
+
body.before ?? null,
|
|
40
|
+
),
|
|
41
|
+
"Nav item",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
return c.json(item);
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
// Create nav item (requires auth)
|
|
@@ -46,8 +52,6 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
46
52
|
type: body.type as NavItemType,
|
|
47
53
|
label: body.label,
|
|
48
54
|
url: body.url,
|
|
49
|
-
pageId: body.pageId,
|
|
50
|
-
position: body.position,
|
|
51
55
|
});
|
|
52
56
|
|
|
53
57
|
return c.json(item, 201);
|
|
@@ -55,7 +59,7 @@ navItemsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
55
59
|
|
|
56
60
|
// Update nav item (requires auth)
|
|
57
61
|
navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
58
|
-
const id =
|
|
62
|
+
const id = parseIdParam(c.req.param("id"));
|
|
59
63
|
const body = parseValidated(UpdateNavItemSchema, await c.req.json());
|
|
60
64
|
|
|
61
65
|
const item = assertFound(
|
|
@@ -68,7 +72,7 @@ navItemsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
68
72
|
|
|
69
73
|
// Delete nav item (requires auth)
|
|
70
74
|
navItemsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
|
|
71
|
-
const id =
|
|
75
|
+
const id = parseIdParam(c.req.param("id"));
|
|
72
76
|
|
|
73
77
|
const success = await c.var.services.navItems.delete(id);
|
|
74
78
|
if (!success) throw new NotFoundError("Nav item");
|
package/src/routes/api/posts.ts
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
-
import type { Bindings,
|
|
6
|
+
import type { Bindings, Media } from "../../types.js";
|
|
7
7
|
import type { AppVariables } from "../../types/app-context.js";
|
|
8
|
-
import
|
|
8
|
+
import { z } from "zod";
|
|
9
9
|
import {
|
|
10
10
|
CreatePostSchema,
|
|
11
11
|
UpdatePostSchema,
|
|
12
|
+
FormatSchema,
|
|
13
|
+
StatusSchema,
|
|
12
14
|
parseValidated,
|
|
13
15
|
} from "../../lib/schemas.js";
|
|
14
16
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
@@ -17,11 +19,7 @@ import {
|
|
|
17
19
|
getImageUrl,
|
|
18
20
|
getPublicUrlForProvider,
|
|
19
21
|
} from "../../lib/image.js";
|
|
20
|
-
import {
|
|
21
|
-
assertFound,
|
|
22
|
-
NotFoundError,
|
|
23
|
-
ValidationError,
|
|
24
|
-
} from "../../lib/errors.js";
|
|
22
|
+
import { assertFound, NotFoundError, parseIdParam } from "../../lib/errors.js";
|
|
25
23
|
|
|
26
24
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
27
25
|
|
|
@@ -49,31 +47,41 @@ function toMediaAttachment(
|
|
|
49
47
|
format: "auto",
|
|
50
48
|
fit: "scale-down",
|
|
51
49
|
});
|
|
50
|
+
const posterUrl = m.posterKey ? getMediaUrl(m.posterKey, publicUrl) : null;
|
|
52
51
|
|
|
53
52
|
return {
|
|
54
53
|
id: m.id,
|
|
55
54
|
url,
|
|
56
55
|
previewUrl,
|
|
56
|
+
posterUrl,
|
|
57
57
|
alt: m.alt,
|
|
58
58
|
blurhash: m.blurhash,
|
|
59
59
|
width: m.width,
|
|
60
60
|
height: m.height,
|
|
61
61
|
position: m.position,
|
|
62
62
|
mimeType: m.mimeType,
|
|
63
|
+
summary: m.summary,
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
const ListPostsQuerySchema = z.object({
|
|
68
|
+
format: FormatSchema.optional(),
|
|
69
|
+
status: StatusSchema.optional(),
|
|
70
|
+
cursor: z.string().optional(),
|
|
71
|
+
limit: z.coerce.number().int().min(1).max(100).optional().default(100),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// List posts (requires auth)
|
|
75
|
+
postsApiRoutes.get("/", requireAuthApi(), async (c) => {
|
|
76
|
+
const { format, status, cursor, limit } = parseValidated(
|
|
77
|
+
ListPostsQuerySchema,
|
|
78
|
+
c.req.query(),
|
|
79
|
+
);
|
|
72
80
|
|
|
73
81
|
const posts = await c.var.services.posts.list({
|
|
74
82
|
format,
|
|
75
83
|
status: status ?? "published",
|
|
76
|
-
cursor: cursor
|
|
84
|
+
cursor: cursor ?? undefined,
|
|
77
85
|
limit,
|
|
78
86
|
});
|
|
79
87
|
|
|
@@ -85,32 +93,33 @@ postsApiRoutes.get("/", async (c) => {
|
|
|
85
93
|
return c.json({
|
|
86
94
|
posts: posts.map((p) => ({
|
|
87
95
|
...p,
|
|
88
|
-
sqid: sqid.encode(p.id),
|
|
89
96
|
mediaAttachments: (mediaMap.get(p.id) ?? []).map((m) =>
|
|
90
97
|
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
91
98
|
),
|
|
92
99
|
})),
|
|
93
100
|
|
|
94
101
|
nextCursor:
|
|
95
|
-
posts.length === limit
|
|
96
|
-
? sqid.encode(posts[posts.length - 1]?.id ?? 0)
|
|
97
|
-
: null,
|
|
102
|
+
posts.length === limit ? (posts[posts.length - 1]?.id ?? null) : null,
|
|
98
103
|
});
|
|
99
104
|
});
|
|
100
105
|
|
|
101
|
-
// Get single post
|
|
102
|
-
postsApiRoutes.get("/:id", async (c) => {
|
|
103
|
-
const id =
|
|
104
|
-
if (!id) throw new ValidationError("Invalid ID");
|
|
106
|
+
// Get single post (requires auth)
|
|
107
|
+
postsApiRoutes.get("/:id", requireAuthApi(), async (c) => {
|
|
108
|
+
const id = parseIdParam(c.req.param("id"));
|
|
105
109
|
|
|
106
110
|
const post = assertFound(await c.var.services.posts.getById(id), "Post");
|
|
107
111
|
|
|
108
112
|
const mediaList = await c.var.services.media.getByPostId(post.id);
|
|
109
113
|
const { r2PublicUrl, imageTransformUrl, s3PublicUrl } = c.var.appConfig;
|
|
110
114
|
|
|
115
|
+
// Get collection IDs for this post
|
|
116
|
+
const postCollections =
|
|
117
|
+
await c.var.services.collections.getCollectionsByPostId(post.id);
|
|
118
|
+
const collectionIds = postCollections.map((col) => col.id);
|
|
119
|
+
|
|
111
120
|
return c.json({
|
|
112
121
|
...post,
|
|
113
|
-
|
|
122
|
+
collectionIds,
|
|
114
123
|
mediaAttachments: mediaList.map((m) =>
|
|
115
124
|
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
116
125
|
),
|
|
@@ -131,19 +140,18 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
131
140
|
format: body.format,
|
|
132
141
|
title: body.title,
|
|
133
142
|
body: body.body,
|
|
143
|
+
bodyMarkdown: body.bodyMarkdown,
|
|
144
|
+
slug: body.slug || undefined,
|
|
134
145
|
path: body.path || undefined,
|
|
135
146
|
status: body.status,
|
|
136
147
|
visibility: body.visibility,
|
|
137
148
|
pinned: body.pinned,
|
|
149
|
+
featured: body.featured,
|
|
138
150
|
url: body.url || undefined,
|
|
139
151
|
quoteText: body.quoteText,
|
|
140
152
|
rating: body.rating || undefined,
|
|
141
|
-
collectionIds: body.collectionIds
|
|
142
|
-
|
|
143
|
-
: undefined,
|
|
144
|
-
replyToId: body.replyToId
|
|
145
|
-
? (sqid.decode(body.replyToId) ?? undefined)
|
|
146
|
-
: undefined,
|
|
153
|
+
collectionIds: body.collectionIds,
|
|
154
|
+
replyToId: body.replyToId,
|
|
147
155
|
publishedAt: body.publishedAt,
|
|
148
156
|
},
|
|
149
157
|
{
|
|
@@ -163,7 +171,6 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
163
171
|
return c.json(
|
|
164
172
|
{
|
|
165
173
|
...post,
|
|
166
|
-
sqid: sqid.encode(post.id),
|
|
167
174
|
mediaAttachments: mediaList.map((m) =>
|
|
168
175
|
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
169
176
|
),
|
|
@@ -174,8 +181,7 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
174
181
|
|
|
175
182
|
// Update post (requires auth)
|
|
176
183
|
postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
177
|
-
const id =
|
|
178
|
-
if (!id) throw new ValidationError("Invalid ID");
|
|
184
|
+
const id = parseIdParam(c.req.param("id"));
|
|
179
185
|
|
|
180
186
|
const body = parseValidated(UpdatePostSchema, await c.req.json());
|
|
181
187
|
|
|
@@ -191,16 +197,16 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
191
197
|
format: body.format,
|
|
192
198
|
title: body.title,
|
|
193
199
|
body: body.body,
|
|
194
|
-
|
|
200
|
+
bodyMarkdown: body.bodyMarkdown,
|
|
201
|
+
slug: body.slug,
|
|
195
202
|
status: body.status,
|
|
196
203
|
visibility: body.visibility,
|
|
197
204
|
pinned: body.pinned,
|
|
205
|
+
featured: body.featured,
|
|
198
206
|
url: body.url,
|
|
199
207
|
quoteText: body.quoteText,
|
|
200
208
|
rating: body.rating || undefined,
|
|
201
|
-
collectionIds: body.collectionIds
|
|
202
|
-
? body.collectionIds
|
|
203
|
-
: undefined,
|
|
209
|
+
collectionIds: body.collectionIds,
|
|
204
210
|
publishedAt: body.publishedAt,
|
|
205
211
|
},
|
|
206
212
|
{
|
|
@@ -221,7 +227,6 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
221
227
|
|
|
222
228
|
return c.json({
|
|
223
229
|
...post,
|
|
224
|
-
sqid: sqid.encode(post.id),
|
|
225
230
|
mediaAttachments: mediaList.map((m) =>
|
|
226
231
|
toMediaAttachment(m, r2PublicUrl, imageTransformUrl, s3PublicUrl),
|
|
227
232
|
),
|
|
@@ -230,8 +235,7 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
230
235
|
|
|
231
236
|
// Delete post (requires auth)
|
|
232
237
|
postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
|
|
233
|
-
const id =
|
|
234
|
-
if (!id) throw new ValidationError("Invalid ID");
|
|
238
|
+
const id = parseIdParam(c.req.param("id"));
|
|
235
239
|
|
|
236
240
|
const success = await c.var.services.posts.delete(id, {
|
|
237
241
|
media: c.var.services.media,
|
package/src/routes/api/search.ts
CHANGED
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import type { Bindings } from "../../types.js";
|
|
7
7
|
import type { AppVariables } from "../../types/app-context.js";
|
|
8
|
-
import * as sqid from "../../lib/sqid.js";
|
|
9
8
|
import { ValidationError, ExternalServiceError } from "../../lib/errors.js";
|
|
10
9
|
|
|
11
10
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
@@ -36,13 +35,13 @@ searchApiRoutes.get("/", async (c) => {
|
|
|
36
35
|
return c.json({
|
|
37
36
|
query,
|
|
38
37
|
results: results.map((r) => ({
|
|
39
|
-
id:
|
|
38
|
+
id: r.post.id,
|
|
40
39
|
format: r.post.format,
|
|
41
40
|
title: r.post.title,
|
|
42
|
-
|
|
41
|
+
slug: r.post.slug,
|
|
43
42
|
snippet: r.snippet,
|
|
44
43
|
publishedAt: r.post.publishedAt,
|
|
45
|
-
url:
|
|
44
|
+
url: `/${r.post.slug}`,
|
|
46
45
|
})),
|
|
47
46
|
count: results.length,
|
|
48
47
|
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multipart Upload API Routes
|
|
3
|
+
*
|
|
4
|
+
* Handles chunked file uploads for files that exceed the Cloudflare Workers
|
|
5
|
+
* 100MB request body limit. Uses R2's native multipart upload API.
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* 1. POST / — Initiate: validate metadata, start R2 multipart upload
|
|
9
|
+
* 2. PUT /:id/part — Upload a single chunk (raw body, not FormData)
|
|
10
|
+
* 3. POST /:id/complete — Finalize: combine parts in R2, create DB record
|
|
11
|
+
* 4. POST /:id/abort — Cancel: discard uploaded parts
|
|
12
|
+
* 5. PUT /:id/poster — Upload poster frame (video thumbnails, small FormData)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Hono } from "hono";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import type { Bindings } from "../../types.js";
|
|
18
|
+
import type { AppVariables } from "../../types/app-context.js";
|
|
19
|
+
import { requireAuthApi } from "../../middleware/auth.js";
|
|
20
|
+
import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
|
|
21
|
+
import {
|
|
22
|
+
validateUploadFileMetadata,
|
|
23
|
+
generateStorageKey,
|
|
24
|
+
} from "../../lib/upload.js";
|
|
25
|
+
import { supportsMultipart } from "../../lib/storage.js";
|
|
26
|
+
import { ValidationError } from "../../lib/errors.js";
|
|
27
|
+
import { parseValidated } from "../../lib/schemas.js";
|
|
28
|
+
|
|
29
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
30
|
+
|
|
31
|
+
// ── Schemas ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const InitiateSchema = z.object({
|
|
34
|
+
filename: z.string().min(1),
|
|
35
|
+
contentType: z.string().min(1),
|
|
36
|
+
size: z.number().int().positive(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const UploadPartSchema = z.object({
|
|
40
|
+
storageKey: z.string().min(1),
|
|
41
|
+
uploadId: z.string().min(1),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const CompleteSchema = z.object({
|
|
45
|
+
storageKey: z.string().min(1),
|
|
46
|
+
uploadId: z.string().min(1),
|
|
47
|
+
parts: z.array(
|
|
48
|
+
z.object({
|
|
49
|
+
partNumber: z.number().int().positive(),
|
|
50
|
+
etag: z.string().min(1),
|
|
51
|
+
}),
|
|
52
|
+
),
|
|
53
|
+
filename: z.string().min(1),
|
|
54
|
+
originalName: z.string().min(1),
|
|
55
|
+
contentType: z.string().min(1),
|
|
56
|
+
size: z.number().int().positive(),
|
|
57
|
+
width: z.number().int().positive().optional(),
|
|
58
|
+
height: z.number().int().positive().optional(),
|
|
59
|
+
blurhash: z.string().max(200).optional(),
|
|
60
|
+
waveform: z.string().max(2000).optional(),
|
|
61
|
+
posterKey: z.string().optional(),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const AbortSchema = z.object({
|
|
65
|
+
storageKey: z.string().min(1),
|
|
66
|
+
uploadId: z.string().min(1),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── Routes ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
export const multipartUploadApiRoutes = new Hono<Env>();
|
|
72
|
+
|
|
73
|
+
// Require auth for all multipart routes
|
|
74
|
+
multipartUploadApiRoutes.use("*", requireAuthApi());
|
|
75
|
+
|
|
76
|
+
// POST / — Initiate a multipart upload
|
|
77
|
+
multipartUploadApiRoutes.post("/", async (c) => {
|
|
78
|
+
const storage = c.var.storage;
|
|
79
|
+
if (!storage || !supportsMultipart(storage)) {
|
|
80
|
+
return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const body = await c.req.json();
|
|
84
|
+
const data = parseValidated(InitiateSchema, body);
|
|
85
|
+
|
|
86
|
+
// Validate file type and size
|
|
87
|
+
const error = validateUploadFileMetadata(data.contentType, data.size, {
|
|
88
|
+
maxFileSizeMB: c.var.appConfig.uploadMaxFileSize,
|
|
89
|
+
});
|
|
90
|
+
if (error) {
|
|
91
|
+
throw new ValidationError(error);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { id, filename, storageKey } = generateStorageKey(data.filename);
|
|
95
|
+
|
|
96
|
+
const upload = await storage.createMultipartUpload(storageKey, {
|
|
97
|
+
contentType: data.contentType,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return c.json({
|
|
101
|
+
id,
|
|
102
|
+
uploadId: upload.uploadId,
|
|
103
|
+
storageKey,
|
|
104
|
+
filename,
|
|
105
|
+
originalName: data.filename,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// PUT /:id/part?partNumber=N&storageKey=...&uploadId=... — Upload a single part
|
|
110
|
+
multipartUploadApiRoutes.put("/:id/part", async (c) => {
|
|
111
|
+
const storage = c.var.storage;
|
|
112
|
+
if (!storage || !supportsMultipart(storage)) {
|
|
113
|
+
return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const storageKey = c.req.query("storageKey");
|
|
117
|
+
const uploadId = c.req.query("uploadId");
|
|
118
|
+
if (!storageKey || !uploadId) {
|
|
119
|
+
throw new ValidationError(
|
|
120
|
+
"storageKey and uploadId query parameters are required",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
parseValidated(UploadPartSchema, { storageKey, uploadId });
|
|
124
|
+
|
|
125
|
+
const partNumberRaw = c.req.query("partNumber");
|
|
126
|
+
if (!partNumberRaw) {
|
|
127
|
+
throw new ValidationError("partNumber query parameter is required");
|
|
128
|
+
}
|
|
129
|
+
const partNumber = parseInt(partNumberRaw, 10);
|
|
130
|
+
if (isNaN(partNumber) || partNumber < 1) {
|
|
131
|
+
throw new ValidationError("partNumber must be a positive integer");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const body = await c.req.arrayBuffer();
|
|
135
|
+
const part = await storage.uploadPart(storageKey, uploadId, partNumber, body);
|
|
136
|
+
|
|
137
|
+
return c.json({ partNumber: part.partNumber, etag: part.etag });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// POST /:id/complete — Finalize the upload
|
|
141
|
+
multipartUploadApiRoutes.post("/:id/complete", async (c) => {
|
|
142
|
+
const storage = c.var.storage;
|
|
143
|
+
if (!storage || !supportsMultipart(storage)) {
|
|
144
|
+
return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const id = c.req.param("id");
|
|
148
|
+
const body = await c.req.json();
|
|
149
|
+
const data = parseValidated(CompleteSchema, body);
|
|
150
|
+
|
|
151
|
+
// Validate file type and size
|
|
152
|
+
const validationError = validateUploadFileMetadata(
|
|
153
|
+
data.contentType,
|
|
154
|
+
data.size,
|
|
155
|
+
{ maxFileSizeMB: c.var.appConfig.uploadMaxFileSize },
|
|
156
|
+
);
|
|
157
|
+
if (validationError) {
|
|
158
|
+
throw new ValidationError(validationError);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Complete the R2 multipart upload
|
|
162
|
+
await storage.completeMultipartUpload(
|
|
163
|
+
data.storageKey,
|
|
164
|
+
data.uploadId,
|
|
165
|
+
data.parts,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Create the DB record
|
|
169
|
+
const media = await c.var.services.media.create({
|
|
170
|
+
id,
|
|
171
|
+
filename: data.filename,
|
|
172
|
+
originalName: data.originalName,
|
|
173
|
+
mimeType: data.contentType,
|
|
174
|
+
size: data.size,
|
|
175
|
+
storageKey: data.storageKey,
|
|
176
|
+
provider: c.var.appConfig.storageDriver,
|
|
177
|
+
width: data.width && data.width > 0 ? data.width : undefined,
|
|
178
|
+
height: data.height && data.height > 0 ? data.height : undefined,
|
|
179
|
+
blurhash: data.blurhash,
|
|
180
|
+
waveform: data.waveform,
|
|
181
|
+
posterKey: data.posterKey,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const mediaPublicUrl = getPublicUrlForProvider(
|
|
185
|
+
c.var.appConfig.storageDriver,
|
|
186
|
+
c.var.appConfig.r2PublicUrl,
|
|
187
|
+
c.var.appConfig.s3PublicUrl,
|
|
188
|
+
);
|
|
189
|
+
const publicUrl = getMediaUrl(data.storageKey, mediaPublicUrl);
|
|
190
|
+
|
|
191
|
+
return c.json({
|
|
192
|
+
id: media.id,
|
|
193
|
+
filename: media.filename,
|
|
194
|
+
url: publicUrl,
|
|
195
|
+
mimeType: media.mimeType,
|
|
196
|
+
size: media.size,
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// POST /:id/abort — Cancel the upload
|
|
201
|
+
multipartUploadApiRoutes.post("/:id/abort", async (c) => {
|
|
202
|
+
const storage = c.var.storage;
|
|
203
|
+
if (!storage || !supportsMultipart(storage)) {
|
|
204
|
+
return c.json({ error: "Storage doesn't support multipart uploads." }, 500);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const body = await c.req.json();
|
|
208
|
+
const data = parseValidated(AbortSchema, body);
|
|
209
|
+
|
|
210
|
+
await storage.abortMultipartUpload(data.storageKey, data.uploadId);
|
|
211
|
+
|
|
212
|
+
return c.json({ success: true });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// PUT /:id/poster — Upload poster frame (video thumbnails)
|
|
216
|
+
multipartUploadApiRoutes.put("/:id/poster", async (c) => {
|
|
217
|
+
const storage = c.var.storage;
|
|
218
|
+
if (!storage) {
|
|
219
|
+
return c.json({ error: "Storage not configured." }, 500);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const id = c.req.param("id");
|
|
223
|
+
const formData = await c.req.formData();
|
|
224
|
+
const posterFile = formData.get("poster") as File | null;
|
|
225
|
+
if (!posterFile) {
|
|
226
|
+
throw new ValidationError("No poster file provided");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!posterFile.type.startsWith("image/")) {
|
|
230
|
+
throw new ValidationError(
|
|
231
|
+
`Invalid file type "${posterFile.type}". Only image files are accepted for poster frames.`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const date = new Date();
|
|
236
|
+
const year = date.getUTCFullYear();
|
|
237
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
238
|
+
const posterKey = `media/${year}/${month}/${id}-poster.webp`;
|
|
239
|
+
|
|
240
|
+
await storage.put(posterKey, posterFile.stream(), {
|
|
241
|
+
contentType: "image/webp",
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return c.json({ posterKey });
|
|
245
|
+
});
|