@jant/core 0.3.7 → 0.3.8
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/app.js +4 -0
- package/dist/client.js +1 -0
- package/dist/db/schema.js +13 -0
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/image.js +3 -3
- package/dist/lib/media-helpers.js +43 -0
- package/dist/lib/nav-reorder.js +27 -0
- package/dist/lib/navigation.js +35 -0
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/timeline.js +115 -0
- package/dist/routes/api/upload.js +9 -5
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +83 -126
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +38 -51
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +1 -1
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +2 -0
- package/dist/theme/components/timeline/ArticleCard.js +50 -0
- package/dist/theme/components/timeline/ImageCard.js +86 -0
- package/dist/theme/components/timeline/LinkCard.js +62 -0
- package/dist/theme/components/timeline/NoteCard.js +37 -0
- package/dist/theme/components/timeline/QuoteCard.js +51 -0
- package/dist/theme/components/timeline/ThreadPreview.js +52 -0
- package/dist/theme/components/timeline/TimelineFeed.js +43 -0
- package/dist/theme/components/timeline/TimelineItem.js +25 -0
- package/dist/theme/components/timeline/index.js +8 -0
- package/dist/theme/layouts/DashLayout.js +8 -0
- package/dist/theme/layouts/SiteLayout.js +160 -0
- package/dist/theme/layouts/index.js +1 -0
- package/dist/types/sortablejs.d.js +5 -0
- package/package.json +3 -2
- package/src/__tests__/helpers/db.ts +10 -0
- package/src/app.tsx +4 -0
- package/src/client.ts +1 -0
- package/src/db/migrations/0003_add_navigation_links.sql +8 -0
- package/src/db/migrations/meta/0003_snapshot.json +821 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +13 -0
- package/src/i18n/locales/en.po +100 -32
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +102 -55
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +102 -55
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +5 -0
- package/src/lib/__tests__/theme-components.test.ts +107 -0
- package/src/lib/image.ts +3 -3
- package/src/lib/media-helpers.ts +54 -0
- package/src/lib/nav-reorder.ts +26 -0
- package/src/lib/navigation.ts +46 -0
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +8 -8
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/timeline.tsx +145 -0
- package/src/routes/api/upload.ts +9 -5
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +111 -122
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +33 -42
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/media.test.ts +34 -7
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +2 -1
- package/src/services/navigation.ts +165 -0
- package/src/services/post.ts +48 -1
- package/src/styles/components.css +59 -0
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +12 -0
- package/src/theme/components/timeline/ArticleCard.tsx +57 -0
- package/src/theme/components/timeline/ImageCard.tsx +80 -0
- package/src/theme/components/timeline/LinkCard.tsx +66 -0
- package/src/theme/components/timeline/NoteCard.tsx +41 -0
- package/src/theme/components/timeline/QuoteCard.tsx +55 -0
- package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
- package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
- package/src/theme/components/timeline/TimelineItem.tsx +39 -0
- package/src/theme/components/timeline/index.ts +8 -0
- package/src/theme/layouts/DashLayout.tsx +10 -0
- package/src/theme/layouts/SiteLayout.tsx +184 -0
- package/src/theme/layouts/index.ts +1 -0
- package/src/types/sortablejs.d.ts +23 -0
- package/src/types.ts +61 -0
- package/dist/app.d.ts +0 -38
- package/dist/app.d.ts.map +0 -1
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -10
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/schema.d.ts +0 -1543
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/i18n/Trans.d.ts +0 -25
- package/dist/i18n/Trans.d.ts.map +0 -1
- package/dist/i18n/context.d.ts +0 -69
- package/dist/i18n/context.d.ts.map +0 -1
- package/dist/i18n/detect.d.ts +0 -20
- package/dist/i18n/detect.d.ts.map +0 -1
- package/dist/i18n/i18n.d.ts +0 -32
- package/dist/i18n/i18n.d.ts.map +0 -1
- package/dist/i18n/index.d.ts +0 -41
- package/dist/i18n/index.d.ts.map +0 -1
- package/dist/i18n/locales/en.d.ts +0 -3
- package/dist/i18n/locales/en.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hans.d.ts +0 -3
- package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hant.d.ts +0 -3
- package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
- package/dist/i18n/locales.d.ts +0 -11
- package/dist/i18n/locales.d.ts.map +0 -1
- package/dist/i18n/middleware.d.ts +0 -21
- package/dist/i18n/middleware.d.ts.map +0 -1
- package/dist/index.d.ts +0 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/config.d.ts +0 -83
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/constants.d.ts +0 -37
- package/dist/lib/constants.d.ts.map +0 -1
- package/dist/lib/image.d.ts +0 -73
- package/dist/lib/image.d.ts.map +0 -1
- package/dist/lib/index.d.ts +0 -9
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/lib/markdown.d.ts +0 -60
- package/dist/lib/markdown.d.ts.map +0 -1
- package/dist/lib/schemas.d.ts +0 -130
- package/dist/lib/schemas.d.ts.map +0 -1
- package/dist/lib/sqid.d.ts +0 -60
- package/dist/lib/sqid.d.ts.map +0 -1
- package/dist/lib/sse.d.ts +0 -192
- package/dist/lib/sse.d.ts.map +0 -1
- package/dist/lib/theme.d.ts +0 -44
- package/dist/lib/theme.d.ts.map +0 -1
- package/dist/lib/time.d.ts +0 -90
- package/dist/lib/time.d.ts.map +0 -1
- package/dist/lib/url.d.ts +0 -82
- package/dist/lib/url.d.ts.map +0 -1
- package/dist/middleware/auth.d.ts +0 -24
- package/dist/middleware/auth.d.ts.map +0 -1
- package/dist/middleware/onboarding.d.ts +0 -26
- package/dist/middleware/onboarding.d.ts.map +0 -1
- package/dist/routes/api/posts.d.ts +0 -13
- package/dist/routes/api/posts.d.ts.map +0 -1
- package/dist/routes/api/search.d.ts +0 -13
- package/dist/routes/api/search.d.ts.map +0 -1
- package/dist/routes/api/upload.d.ts +0 -16
- package/dist/routes/api/upload.d.ts.map +0 -1
- package/dist/routes/dash/collections.d.ts +0 -13
- package/dist/routes/dash/collections.d.ts.map +0 -1
- package/dist/routes/dash/index.d.ts +0 -15
- package/dist/routes/dash/index.d.ts.map +0 -1
- package/dist/routes/dash/media.d.ts +0 -16
- package/dist/routes/dash/media.d.ts.map +0 -1
- package/dist/routes/dash/pages.d.ts +0 -15
- package/dist/routes/dash/pages.d.ts.map +0 -1
- package/dist/routes/dash/posts.d.ts +0 -13
- package/dist/routes/dash/posts.d.ts.map +0 -1
- package/dist/routes/dash/redirects.d.ts +0 -13
- package/dist/routes/dash/redirects.d.ts.map +0 -1
- package/dist/routes/dash/settings.d.ts +0 -15
- package/dist/routes/dash/settings.d.ts.map +0 -1
- package/dist/routes/feed/rss.d.ts +0 -13
- package/dist/routes/feed/rss.d.ts.map +0 -1
- package/dist/routes/feed/sitemap.d.ts +0 -13
- package/dist/routes/feed/sitemap.d.ts.map +0 -1
- package/dist/routes/pages/archive.d.ts +0 -15
- package/dist/routes/pages/archive.d.ts.map +0 -1
- package/dist/routes/pages/collection.d.ts +0 -13
- package/dist/routes/pages/collection.d.ts.map +0 -1
- package/dist/routes/pages/home.d.ts +0 -13
- package/dist/routes/pages/home.d.ts.map +0 -1
- package/dist/routes/pages/page.d.ts +0 -15
- package/dist/routes/pages/page.d.ts.map +0 -1
- package/dist/routes/pages/post.d.ts +0 -13
- package/dist/routes/pages/post.d.ts.map +0 -1
- package/dist/routes/pages/search.d.ts +0 -13
- package/dist/routes/pages/search.d.ts.map +0 -1
- package/dist/services/collection.d.ts +0 -32
- package/dist/services/collection.d.ts.map +0 -1
- package/dist/services/index.d.ts +0 -28
- package/dist/services/index.d.ts.map +0 -1
- package/dist/services/media.d.ts +0 -34
- package/dist/services/media.d.ts.map +0 -1
- package/dist/services/post.d.ts +0 -31
- package/dist/services/post.d.ts.map +0 -1
- package/dist/services/redirect.d.ts +0 -15
- package/dist/services/redirect.d.ts.map +0 -1
- package/dist/services/search.d.ts +0 -26
- package/dist/services/search.d.ts.map +0 -1
- package/dist/services/settings.d.ts +0 -18
- package/dist/services/settings.d.ts.map +0 -1
- package/dist/theme/color-themes.d.ts +0 -30
- package/dist/theme/color-themes.d.ts.map +0 -1
- package/dist/theme/components/ActionButtons.d.ts +0 -43
- package/dist/theme/components/ActionButtons.d.ts.map +0 -1
- package/dist/theme/components/CrudPageHeader.d.ts +0 -23
- package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
- package/dist/theme/components/DangerZone.d.ts +0 -36
- package/dist/theme/components/DangerZone.d.ts.map +0 -1
- package/dist/theme/components/EmptyState.d.ts +0 -27
- package/dist/theme/components/EmptyState.d.ts.map +0 -1
- package/dist/theme/components/ListItemRow.d.ts +0 -15
- package/dist/theme/components/ListItemRow.d.ts.map +0 -1
- package/dist/theme/components/MediaGallery.d.ts +0 -13
- package/dist/theme/components/MediaGallery.d.ts.map +0 -1
- package/dist/theme/components/PageForm.d.ts +0 -14
- package/dist/theme/components/PageForm.d.ts.map +0 -1
- package/dist/theme/components/Pagination.d.ts +0 -46
- package/dist/theme/components/Pagination.d.ts.map +0 -1
- package/dist/theme/components/PostForm.d.ts +0 -16
- package/dist/theme/components/PostForm.d.ts.map +0 -1
- package/dist/theme/components/PostList.d.ts +0 -10
- package/dist/theme/components/PostList.d.ts.map +0 -1
- package/dist/theme/components/ThreadView.d.ts +0 -15
- package/dist/theme/components/ThreadView.d.ts.map +0 -1
- package/dist/theme/components/TypeBadge.d.ts +0 -12
- package/dist/theme/components/TypeBadge.d.ts.map +0 -1
- package/dist/theme/components/VisibilityBadge.d.ts +0 -12
- package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
- package/dist/theme/components/index.d.ts +0 -14
- package/dist/theme/components/index.d.ts.map +0 -1
- package/dist/theme/index.d.ts +0 -21
- package/dist/theme/index.d.ts.map +0 -1
- package/dist/theme/layouts/BaseLayout.d.ts +0 -23
- package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
- package/dist/theme/layouts/DashLayout.d.ts +0 -17
- package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
- package/dist/theme/layouts/index.d.ts +0 -3
- package/dist/theme/layouts/index.d.ts.map +0 -1
- package/dist/types.d.ts +0 -237
- package/dist/types.d.ts.map +0 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Helper Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared logic for building MediaAttachment maps from raw media data.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Media, MediaAttachment } from "../types.js";
|
|
8
|
+
import { getMediaUrl, getImageUrl } from "./image.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Builds a map of post IDs to MediaAttachment arrays from raw media data.
|
|
12
|
+
*
|
|
13
|
+
* Transforms raw Media objects (with R2 keys) into MediaAttachment objects
|
|
14
|
+
* (with public URLs and preview URLs) suitable for rendering.
|
|
15
|
+
*
|
|
16
|
+
* @param rawMediaMap - Map of post IDs to raw Media arrays from the media service
|
|
17
|
+
* @param r2PublicUrl - Optional R2 public URL for direct CDN access
|
|
18
|
+
* @param imageTransformUrl - Optional image transformation service URL
|
|
19
|
+
* @returns Map of post IDs to MediaAttachment arrays
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```ts
|
|
23
|
+
* const rawMediaMap = await services.media.getByPostIds(postIds);
|
|
24
|
+
* const mediaMap = buildMediaMap(rawMediaMap, c.env.R2_PUBLIC_URL, c.env.IMAGE_TRANSFORM_URL);
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export function buildMediaMap(
|
|
28
|
+
rawMediaMap: Map<number, Media[]>,
|
|
29
|
+
r2PublicUrl?: string,
|
|
30
|
+
imageTransformUrl?: string,
|
|
31
|
+
): Map<number, MediaAttachment[]> {
|
|
32
|
+
const mediaMap = new Map<number, MediaAttachment[]>();
|
|
33
|
+
for (const [postId, mediaList] of rawMediaMap) {
|
|
34
|
+
mediaMap.set(
|
|
35
|
+
postId,
|
|
36
|
+
mediaList.map((m) => ({
|
|
37
|
+
id: m.id,
|
|
38
|
+
url: getMediaUrl(m.id, m.r2Key, r2PublicUrl),
|
|
39
|
+
previewUrl: getImageUrl(
|
|
40
|
+
getMediaUrl(m.id, m.r2Key, r2PublicUrl),
|
|
41
|
+
imageTransformUrl,
|
|
42
|
+
{ width: 400, quality: 80, format: "auto", fit: "cover" },
|
|
43
|
+
),
|
|
44
|
+
alt: m.alt,
|
|
45
|
+
blurhash: m.blurhash,
|
|
46
|
+
width: m.width,
|
|
47
|
+
height: m.height,
|
|
48
|
+
position: m.position,
|
|
49
|
+
mimeType: m.mimeType,
|
|
50
|
+
})),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return mediaMap;
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Link Reorder
|
|
3
|
+
*
|
|
4
|
+
* Initializes SortableJS on the navigation links list in the dashboard.
|
|
5
|
+
* Auto-detects the list element and only activates when present.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Sortable from "sortablejs";
|
|
9
|
+
|
|
10
|
+
const list = document.getElementById("nav-links-list");
|
|
11
|
+
if (list) {
|
|
12
|
+
Sortable.create(list, {
|
|
13
|
+
animation: 150,
|
|
14
|
+
handle: "[data-id]",
|
|
15
|
+
onEnd() {
|
|
16
|
+
const ids = [...list.querySelectorAll<HTMLElement>("[data-id]")].map(
|
|
17
|
+
(el) => Number(el.dataset.id),
|
|
18
|
+
);
|
|
19
|
+
fetch("/dash/navigation/reorder", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ ids }),
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Helper
|
|
3
|
+
*
|
|
4
|
+
* Provides shared data fetching for public page navigation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Context } from "hono";
|
|
8
|
+
import { getSiteName } from "./config.js";
|
|
9
|
+
import type { NavigationLink } from "../types.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Navigation data needed by SiteLayout
|
|
13
|
+
*/
|
|
14
|
+
export interface NavigationData {
|
|
15
|
+
navigationLinks: NavigationLink[];
|
|
16
|
+
currentPath: string;
|
|
17
|
+
siteName: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fetch navigation data for public pages.
|
|
22
|
+
*
|
|
23
|
+
* Ensures default links exist (Home, Archive, RSS) and returns
|
|
24
|
+
* the current path and site name alongside the links.
|
|
25
|
+
*
|
|
26
|
+
* @param c - Hono context
|
|
27
|
+
* @returns Navigation data for SiteLayout
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* const navData = await getNavigationData(c);
|
|
32
|
+
* return c.html(
|
|
33
|
+
* <BaseLayout c={c}>
|
|
34
|
+
* <SiteLayout {...navData}>
|
|
35
|
+
* <MyContent />
|
|
36
|
+
* </SiteLayout>
|
|
37
|
+
* </BaseLayout>
|
|
38
|
+
* );
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export async function getNavigationData(c: Context): Promise<NavigationData> {
|
|
42
|
+
const navigationLinks = await c.var.services.navigationLinks.ensureDefaults();
|
|
43
|
+
const currentPath = new URL(c.req.url).pathname;
|
|
44
|
+
const siteName = await getSiteName(c);
|
|
45
|
+
return { navigationLinks, currentPath, siteName };
|
|
46
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Component Resolution
|
|
3
|
+
*
|
|
4
|
+
* Resolves theme-overridable components, falling back to defaults.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FC } from "hono/jsx";
|
|
8
|
+
import type {
|
|
9
|
+
PostType,
|
|
10
|
+
ThemeComponents,
|
|
11
|
+
TimelineCardProps,
|
|
12
|
+
ThreadPreviewProps,
|
|
13
|
+
TimelineFeedProps,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
|
|
16
|
+
const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
|
|
17
|
+
note: "NoteCard",
|
|
18
|
+
article: "ArticleCard",
|
|
19
|
+
link: "LinkCard",
|
|
20
|
+
quote: "QuoteCard",
|
|
21
|
+
image: "ImageCard",
|
|
22
|
+
page: "NoteCard",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the card component for a given post type.
|
|
27
|
+
*
|
|
28
|
+
* Checks theme overrides first, then falls back to the provided default card component.
|
|
29
|
+
*
|
|
30
|
+
* @param type - The post type to resolve a card for
|
|
31
|
+
* @param defaults - Map of post type to default card component
|
|
32
|
+
* @param themeComponents - Optional theme component overrides
|
|
33
|
+
* @returns The resolved card component
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const Card = resolveCardComponent("article", DEFAULT_CARD_MAP, c.var.config.theme?.components);
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function resolveCardComponent(
|
|
41
|
+
type: PostType,
|
|
42
|
+
defaults: Record<PostType, FC<TimelineCardProps>>,
|
|
43
|
+
themeComponents?: ThemeComponents,
|
|
44
|
+
): FC<TimelineCardProps> {
|
|
45
|
+
const key = THEME_KEY_MAP[type];
|
|
46
|
+
const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
|
|
47
|
+
return override ?? defaults[type];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the ThreadPreview component.
|
|
52
|
+
*
|
|
53
|
+
* @param defaultComponent - The default ThreadPreview component
|
|
54
|
+
* @param themeComponents - Optional theme component overrides
|
|
55
|
+
* @returns The resolved ThreadPreview component
|
|
56
|
+
*/
|
|
57
|
+
export function resolveThreadPreview(
|
|
58
|
+
defaultComponent: FC<ThreadPreviewProps>,
|
|
59
|
+
themeComponents?: ThemeComponents,
|
|
60
|
+
): FC<ThreadPreviewProps> {
|
|
61
|
+
return themeComponents?.ThreadPreview ?? defaultComponent;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves the TimelineFeed component.
|
|
66
|
+
*
|
|
67
|
+
* @param defaultComponent - The default TimelineFeed component
|
|
68
|
+
* @param themeComponents - Optional theme component overrides
|
|
69
|
+
* @returns The resolved TimelineFeed component
|
|
70
|
+
*/
|
|
71
|
+
export function resolveTimelineFeed(
|
|
72
|
+
defaultComponent: FC<TimelineFeedProps>,
|
|
73
|
+
themeComponents?: ThemeComponents,
|
|
74
|
+
): FC<TimelineFeedProps> {
|
|
75
|
+
return themeComponents?.TimelineFeed ?? defaultComponent;
|
|
76
|
+
}
|
|
@@ -50,7 +50,7 @@ describe("Posts API Routes", () => {
|
|
|
50
50
|
originalName: "test.jpg",
|
|
51
51
|
mimeType: "image/jpeg",
|
|
52
52
|
size: 1024,
|
|
53
|
-
r2Key: "
|
|
53
|
+
r2Key: "media/2025/01/test.jpg",
|
|
54
54
|
width: 800,
|
|
55
55
|
height: 600,
|
|
56
56
|
});
|
|
@@ -145,7 +145,7 @@ describe("Posts API Routes", () => {
|
|
|
145
145
|
originalName: "test.jpg",
|
|
146
146
|
mimeType: "image/jpeg",
|
|
147
147
|
size: 1024,
|
|
148
|
-
r2Key: "
|
|
148
|
+
r2Key: "media/2025/01/test.jpg",
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
await services.media.attachToPost(post.id, [media.id]);
|
|
@@ -223,14 +223,14 @@ describe("Posts API Routes", () => {
|
|
|
223
223
|
originalName: "a.jpg",
|
|
224
224
|
mimeType: "image/jpeg",
|
|
225
225
|
size: 1024,
|
|
226
|
-
r2Key: "
|
|
226
|
+
r2Key: "media/2025/01/a.jpg",
|
|
227
227
|
});
|
|
228
228
|
const m2 = await services.media.create({
|
|
229
229
|
filename: "b.jpg",
|
|
230
230
|
originalName: "b.jpg",
|
|
231
231
|
mimeType: "image/jpeg",
|
|
232
232
|
size: 2048,
|
|
233
|
-
r2Key: "
|
|
233
|
+
r2Key: "media/2025/01/b.jpg",
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
const res = await app.request("/api/posts", {
|
|
@@ -282,7 +282,7 @@ describe("Posts API Routes", () => {
|
|
|
282
282
|
originalName: "a.jpg",
|
|
283
283
|
mimeType: "image/jpeg",
|
|
284
284
|
size: 1024,
|
|
285
|
-
r2Key: "
|
|
285
|
+
r2Key: "media/2025/01/a.jpg",
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
const res = await app.request("/api/posts", {
|
|
@@ -404,7 +404,7 @@ describe("Posts API Routes", () => {
|
|
|
404
404
|
originalName: "a.jpg",
|
|
405
405
|
mimeType: "image/jpeg",
|
|
406
406
|
size: 1024,
|
|
407
|
-
r2Key: "
|
|
407
|
+
r2Key: "media/2025/01/a.jpg",
|
|
408
408
|
});
|
|
409
409
|
|
|
410
410
|
await services.media.attachToPost(post.id, [m1.id]);
|
|
@@ -414,7 +414,7 @@ describe("Posts API Routes", () => {
|
|
|
414
414
|
originalName: "b.jpg",
|
|
415
415
|
mimeType: "image/jpeg",
|
|
416
416
|
size: 2048,
|
|
417
|
-
r2Key: "
|
|
417
|
+
r2Key: "media/2025/01/b.jpg",
|
|
418
418
|
});
|
|
419
419
|
|
|
420
420
|
const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
|
|
@@ -443,7 +443,7 @@ describe("Posts API Routes", () => {
|
|
|
443
443
|
originalName: "a.jpg",
|
|
444
444
|
mimeType: "image/jpeg",
|
|
445
445
|
size: 1024,
|
|
446
|
-
r2Key: "
|
|
446
|
+
r2Key: "media/2025/01/a.jpg",
|
|
447
447
|
});
|
|
448
448
|
|
|
449
449
|
await services.media.attachToPost(post.id, [m1.id]);
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline API Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the timeline data assembly logic via the service layer.
|
|
5
|
+
* The actual route handler renders JSX components which require the Lingui SWC
|
|
6
|
+
* plugin (not available in vitest). We test the underlying service operations
|
|
7
|
+
* that power the timeline API instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
11
|
+
import { createTestDatabase } from "../../../__tests__/helpers/db.js";
|
|
12
|
+
import { createPostService } from "../../../services/post.js";
|
|
13
|
+
import { createMediaService } from "../../../services/media.js";
|
|
14
|
+
import { buildMediaMap } from "../../../lib/media-helpers.js";
|
|
15
|
+
import type { Database } from "../../../db/index.js";
|
|
16
|
+
import type { PostWithMedia, TimelineItemData } from "../../../types.js";
|
|
17
|
+
|
|
18
|
+
describe("Timeline data assembly", () => {
|
|
19
|
+
let db: Database;
|
|
20
|
+
let postService: ReturnType<typeof createPostService>;
|
|
21
|
+
let mediaService: ReturnType<typeof createMediaService>;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
const testDb = createTestDatabase();
|
|
25
|
+
db = testDb.db as unknown as Database;
|
|
26
|
+
postService = createPostService(db);
|
|
27
|
+
mediaService = createMediaService(db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("assembles timeline items with media attachments", async () => {
|
|
31
|
+
const post = await postService.create({
|
|
32
|
+
type: "note",
|
|
33
|
+
content: "Hello",
|
|
34
|
+
visibility: "featured",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const posts = await postService.list({
|
|
38
|
+
visibility: ["featured", "quiet"],
|
|
39
|
+
excludeReplies: true,
|
|
40
|
+
excludeTypes: ["page"],
|
|
41
|
+
limit: 21,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(posts).toHaveLength(1);
|
|
45
|
+
expect(posts[0]?.id).toBe(post.id);
|
|
46
|
+
|
|
47
|
+
// Build media map
|
|
48
|
+
const postIds = posts.map((p) => p.id);
|
|
49
|
+
const rawMediaMap = await mediaService.getByPostIds(postIds);
|
|
50
|
+
const mediaMap = buildMediaMap(rawMediaMap);
|
|
51
|
+
|
|
52
|
+
// Assemble items
|
|
53
|
+
const items: TimelineItemData[] = posts.map((p) => ({
|
|
54
|
+
post: { ...p, mediaAttachments: mediaMap.get(p.id) ?? [] },
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
expect(items).toHaveLength(1);
|
|
58
|
+
expect(items[0]?.post.mediaAttachments).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("identifies thread roots and builds thread previews", async () => {
|
|
62
|
+
const root = await postService.create({
|
|
63
|
+
type: "note",
|
|
64
|
+
content: "Thread root",
|
|
65
|
+
visibility: "featured",
|
|
66
|
+
});
|
|
67
|
+
await postService.create({
|
|
68
|
+
type: "note",
|
|
69
|
+
content: "Reply 1",
|
|
70
|
+
replyToId: root.id,
|
|
71
|
+
});
|
|
72
|
+
await postService.create({
|
|
73
|
+
type: "note",
|
|
74
|
+
content: "Reply 2",
|
|
75
|
+
replyToId: root.id,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const posts = await postService.list({
|
|
79
|
+
visibility: ["featured", "quiet"],
|
|
80
|
+
excludeReplies: true,
|
|
81
|
+
excludeTypes: ["page"],
|
|
82
|
+
limit: 21,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(posts).toHaveLength(1);
|
|
86
|
+
|
|
87
|
+
const postIds = posts.map((p) => p.id);
|
|
88
|
+
const replyCounts = await postService.getReplyCounts(postIds);
|
|
89
|
+
const threadRootIds = postIds.filter(
|
|
90
|
+
(id) => (replyCounts.get(id) ?? 0) > 0,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(threadRootIds).toEqual([root.id]);
|
|
94
|
+
expect(replyCounts.get(root.id)).toBe(2);
|
|
95
|
+
|
|
96
|
+
const threadPreviews = await postService.getThreadPreviews(threadRootIds);
|
|
97
|
+
const replies = threadPreviews.get(root.id);
|
|
98
|
+
expect(replies).toHaveLength(2);
|
|
99
|
+
expect(replies?.[0]?.content).toBe("Reply 1");
|
|
100
|
+
|
|
101
|
+
// Assemble items
|
|
102
|
+
const rawMediaMap = await mediaService.getByPostIds(postIds);
|
|
103
|
+
const mediaMap = buildMediaMap(rawMediaMap);
|
|
104
|
+
|
|
105
|
+
const items: TimelineItemData[] = posts.map((post) => {
|
|
106
|
+
const postWithMedia: PostWithMedia = {
|
|
107
|
+
...post,
|
|
108
|
+
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
112
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
113
|
+
|
|
114
|
+
if (replyCount > 0 && previewReplies) {
|
|
115
|
+
return {
|
|
116
|
+
post: postWithMedia,
|
|
117
|
+
threadPreview: {
|
|
118
|
+
replies: previewReplies.map((r) => ({
|
|
119
|
+
...r,
|
|
120
|
+
mediaAttachments: [],
|
|
121
|
+
})),
|
|
122
|
+
totalReplyCount: replyCount,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { post: postWithMedia };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(items).toHaveLength(1);
|
|
131
|
+
expect(items[0]?.threadPreview).toBeDefined();
|
|
132
|
+
expect(items[0]?.threadPreview?.replies).toHaveLength(2);
|
|
133
|
+
expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("excludes pages from timeline", async () => {
|
|
137
|
+
await postService.create({
|
|
138
|
+
type: "note",
|
|
139
|
+
content: "A note",
|
|
140
|
+
visibility: "quiet",
|
|
141
|
+
});
|
|
142
|
+
await postService.create({
|
|
143
|
+
type: "page",
|
|
144
|
+
content: "A page",
|
|
145
|
+
visibility: "quiet",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const posts = await postService.list({
|
|
149
|
+
visibility: ["featured", "quiet"],
|
|
150
|
+
excludeReplies: true,
|
|
151
|
+
excludeTypes: ["page"],
|
|
152
|
+
limit: 21,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(posts).toHaveLength(1);
|
|
156
|
+
expect(posts[0]?.type).toBe("note");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("excludes replies from top-level list", async () => {
|
|
160
|
+
const root = await postService.create({
|
|
161
|
+
type: "note",
|
|
162
|
+
content: "Root",
|
|
163
|
+
visibility: "quiet",
|
|
164
|
+
});
|
|
165
|
+
await postService.create({
|
|
166
|
+
type: "note",
|
|
167
|
+
content: "Reply",
|
|
168
|
+
replyToId: root.id,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const posts = await postService.list({
|
|
172
|
+
visibility: ["featured", "quiet"],
|
|
173
|
+
excludeReplies: true,
|
|
174
|
+
excludeTypes: ["page"],
|
|
175
|
+
limit: 21,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(posts).toHaveLength(1);
|
|
179
|
+
expect(posts[0]?.content).toBe("Root");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("supports cursor pagination for load more", async () => {
|
|
183
|
+
const posts = [];
|
|
184
|
+
for (let i = 0; i < 5; i++) {
|
|
185
|
+
posts.push(
|
|
186
|
+
await postService.create({
|
|
187
|
+
type: "note",
|
|
188
|
+
content: `Post ${i}`,
|
|
189
|
+
visibility: "quiet",
|
|
190
|
+
publishedAt: 1000 + i,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// First page
|
|
196
|
+
const page1 = await postService.list({
|
|
197
|
+
visibility: ["featured", "quiet"],
|
|
198
|
+
excludeReplies: true,
|
|
199
|
+
excludeTypes: ["page"],
|
|
200
|
+
limit: 3,
|
|
201
|
+
});
|
|
202
|
+
expect(page1).toHaveLength(3);
|
|
203
|
+
|
|
204
|
+
// Second page using cursor
|
|
205
|
+
const lastPost = page1[page1.length - 1];
|
|
206
|
+
expect(lastPost).toBeDefined();
|
|
207
|
+
const page2 = await postService.list({
|
|
208
|
+
visibility: ["featured", "quiet"],
|
|
209
|
+
excludeReplies: true,
|
|
210
|
+
excludeTypes: ["page"],
|
|
211
|
+
limit: 3,
|
|
212
|
+
cursor: lastPost?.id,
|
|
213
|
+
});
|
|
214
|
+
expect(page2).toHaveLength(2);
|
|
215
|
+
expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("correctly determines hasMore flag", async () => {
|
|
219
|
+
for (let i = 0; i < 3; i++) {
|
|
220
|
+
await postService.create({
|
|
221
|
+
type: "note",
|
|
222
|
+
content: `Post ${i}`,
|
|
223
|
+
visibility: "quiet",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Request limit + 1 to check for more
|
|
228
|
+
const pageSize = 2;
|
|
229
|
+
const posts = await postService.list({
|
|
230
|
+
visibility: ["featured", "quiet"],
|
|
231
|
+
excludeReplies: true,
|
|
232
|
+
excludeTypes: ["page"],
|
|
233
|
+
limit: pageSize + 1,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const hasMore = posts.length > pageSize;
|
|
237
|
+
expect(hasMore).toBe(true);
|
|
238
|
+
|
|
239
|
+
const displayPosts = posts.slice(0, pageSize);
|
|
240
|
+
expect(displayPosts).toHaveLength(2);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline API Routes
|
|
3
|
+
*
|
|
4
|
+
* Provides load-more functionality for the timeline feed via SSE.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { Bindings, PostWithMedia, TimelineItemData } from "../../types.js";
|
|
9
|
+
import type { AppVariables } from "../../app.js";
|
|
10
|
+
import { sse } from "../../lib/sse.js";
|
|
11
|
+
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
12
|
+
import { TimelineItem } from "../../theme/components/timeline/TimelineItem.js";
|
|
13
|
+
import { ThreadPreview } from "../../theme/components/timeline/ThreadPreview.js";
|
|
14
|
+
|
|
15
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
16
|
+
|
|
17
|
+
const PAGE_SIZE = 20;
|
|
18
|
+
|
|
19
|
+
export const timelineApiRoutes = new Hono<Env>();
|
|
20
|
+
|
|
21
|
+
timelineApiRoutes.get("/", async (c) => {
|
|
22
|
+
const cursorParam = c.req.query("cursor");
|
|
23
|
+
const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
|
|
24
|
+
|
|
25
|
+
if (!cursor || isNaN(cursor)) {
|
|
26
|
+
return c.json({ error: "cursor parameter required" }, 400);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fetch one extra to determine if there are more
|
|
30
|
+
const posts = await c.var.services.posts.list({
|
|
31
|
+
visibility: ["featured", "quiet"],
|
|
32
|
+
excludeReplies: true,
|
|
33
|
+
excludeTypes: ["page"],
|
|
34
|
+
limit: PAGE_SIZE + 1,
|
|
35
|
+
cursor,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const hasMore = posts.length > PAGE_SIZE;
|
|
39
|
+
const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
|
|
40
|
+
|
|
41
|
+
if (displayPosts.length === 0) {
|
|
42
|
+
return sse(c, async (stream) => {
|
|
43
|
+
stream.remove("#load-more-container");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Build media map
|
|
48
|
+
const postIds = displayPosts.map((p) => p.id);
|
|
49
|
+
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
50
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
51
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
52
|
+
const mediaMap = buildMediaMap(rawMediaMap, r2PublicUrl, imageTransformUrl);
|
|
53
|
+
|
|
54
|
+
// Get reply counts to identify thread roots
|
|
55
|
+
const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
|
|
56
|
+
const threadRootIds = postIds.filter((id) => (replyCounts.get(id) ?? 0) > 0);
|
|
57
|
+
|
|
58
|
+
// Get thread previews
|
|
59
|
+
const threadPreviews = await c.var.services.posts.getThreadPreviews(
|
|
60
|
+
threadRootIds,
|
|
61
|
+
3,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
// Load media for preview replies
|
|
65
|
+
const previewReplyIds: number[] = [];
|
|
66
|
+
for (const replies of threadPreviews.values()) {
|
|
67
|
+
for (const reply of replies) {
|
|
68
|
+
previewReplyIds.push(reply.id);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const previewMediaMap =
|
|
72
|
+
previewReplyIds.length > 0
|
|
73
|
+
? buildMediaMap(
|
|
74
|
+
await c.var.services.media.getByPostIds(previewReplyIds),
|
|
75
|
+
r2PublicUrl,
|
|
76
|
+
imageTransformUrl,
|
|
77
|
+
)
|
|
78
|
+
: new Map();
|
|
79
|
+
|
|
80
|
+
// Assemble timeline items
|
|
81
|
+
const items: TimelineItemData[] = displayPosts.map((post) => {
|
|
82
|
+
const postWithMedia: PostWithMedia = {
|
|
83
|
+
...post,
|
|
84
|
+
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
88
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
89
|
+
|
|
90
|
+
if (replyCount > 0 && previewReplies) {
|
|
91
|
+
return {
|
|
92
|
+
post: postWithMedia,
|
|
93
|
+
threadPreview: {
|
|
94
|
+
replies: previewReplies.map((r) => ({
|
|
95
|
+
...r,
|
|
96
|
+
mediaAttachments: previewMediaMap.get(r.id) ?? [],
|
|
97
|
+
})),
|
|
98
|
+
totalReplyCount: replyCount,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return { post: postWithMedia };
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Render items to HTML
|
|
107
|
+
const itemsHtml = items
|
|
108
|
+
.map((item) => {
|
|
109
|
+
if (item.threadPreview) {
|
|
110
|
+
return (
|
|
111
|
+
<ThreadPreview
|
|
112
|
+
rootPost={item.post}
|
|
113
|
+
previewReplies={item.threadPreview.replies}
|
|
114
|
+
totalReplyCount={item.threadPreview.totalReplyCount}
|
|
115
|
+
/>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return <TimelineItem item={item} />;
|
|
119
|
+
})
|
|
120
|
+
.map((jsx) => jsx.toString())
|
|
121
|
+
.join("");
|
|
122
|
+
|
|
123
|
+
// Determine next cursor
|
|
124
|
+
const lastPost = displayPosts[displayPosts.length - 1];
|
|
125
|
+
const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
|
|
126
|
+
|
|
127
|
+
// Build load-more button HTML
|
|
128
|
+
const loadMoreHtml = nextCursor
|
|
129
|
+
? `<div id="load-more-container" class="mt-6 text-center"><button class="btn btn-outline" data-on:click="@get('/api/timeline?cursor=${nextCursor}')">Load more</button></div>`
|
|
130
|
+
: "";
|
|
131
|
+
|
|
132
|
+
return sse(c, async (stream) => {
|
|
133
|
+
// Append new items to the feed
|
|
134
|
+
stream.patchElements(itemsHtml, {
|
|
135
|
+
mode: "append",
|
|
136
|
+
selector: "#timeline-feed",
|
|
137
|
+
});
|
|
138
|
+
// Replace or remove the load-more container
|
|
139
|
+
if (loadMoreHtml) {
|
|
140
|
+
stream.patchElements(loadMoreHtml);
|
|
141
|
+
} else {
|
|
142
|
+
stream.remove("#load-more-container");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
});
|
package/src/routes/api/upload.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { Hono } from "hono";
|
|
9
9
|
import { html } from "hono/html";
|
|
10
|
+
import { uuidv7 } from "uuidv7";
|
|
10
11
|
import type { Bindings } from "../../types.js";
|
|
11
12
|
import type { AppVariables } from "../../app.js";
|
|
12
13
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
@@ -156,12 +157,14 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
156
157
|
return c.json({ error: "File too large (max 10MB)" }, 400);
|
|
157
158
|
}
|
|
158
159
|
|
|
159
|
-
// Generate unique filename
|
|
160
|
+
// Generate unique filename using UUIDv7
|
|
160
161
|
const ext = file.name.split(".").pop() || "bin";
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
const
|
|
162
|
+
const id = uuidv7();
|
|
163
|
+
const date = new Date();
|
|
164
|
+
const year = date.getUTCFullYear();
|
|
165
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
166
|
+
const filename = `${id}.${ext}`;
|
|
167
|
+
const r2Key = `media/${year}/${month}/${filename}`;
|
|
165
168
|
|
|
166
169
|
try {
|
|
167
170
|
// Upload to R2
|
|
@@ -173,6 +176,7 @@ uploadApiRoutes.post("/", async (c) => {
|
|
|
173
176
|
|
|
174
177
|
// Save to database
|
|
175
178
|
const media = await c.var.services.media.create({
|
|
179
|
+
id,
|
|
176
180
|
filename,
|
|
177
181
|
originalName: file.name,
|
|
178
182
|
mimeType: file.type,
|