@jant/core 0.3.21 → 0.3.22
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 +1 -1
- package/dist/index.js +8 -0
- package/dist/lib/feed.js +112 -0
- package/dist/lib/navigation.js +9 -9
- package/dist/lib/render.js +48 -0
- package/dist/lib/theme-components.js +18 -18
- package/dist/lib/view.js +228 -0
- package/dist/routes/api/timeline.js +20 -16
- package/dist/routes/feed/rss.js +34 -78
- package/dist/routes/feed/sitemap.js +11 -26
- package/dist/routes/pages/archive.js +18 -195
- package/dist/routes/pages/collection.js +16 -70
- package/dist/routes/pages/home.js +25 -47
- package/dist/routes/pages/page.js +15 -27
- package/dist/routes/pages/post.js +25 -79
- package/dist/routes/pages/search.js +20 -130
- package/dist/theme/components/MediaGallery.js +10 -10
- package/dist/theme/components/index.js +1 -1
- package/dist/theme/components/timeline/ArticleCard.js +7 -11
- package/dist/theme/components/timeline/ImageCard.js +10 -13
- package/dist/theme/components/timeline/LinkCard.js +4 -7
- package/dist/theme/components/timeline/NoteCard.js +5 -8
- package/dist/theme/components/timeline/QuoteCard.js +3 -6
- package/dist/theme/components/timeline/ThreadPreview.js +9 -10
- package/dist/theme/components/timeline/TimelineFeed.js +8 -5
- package/dist/theme/components/timeline/TimelineItem.js +22 -2
- package/dist/theme/components/timeline/index.js +1 -1
- package/dist/theme/index.js +6 -3
- package/dist/theme/layouts/SiteLayout.js +10 -39
- package/dist/theme/pages/ArchivePage.js +157 -0
- package/dist/theme/pages/CollectionPage.js +63 -0
- package/dist/theme/pages/HomePage.js +26 -0
- package/dist/theme/pages/PostPage.js +48 -0
- package/dist/theme/pages/SearchPage.js +120 -0
- package/dist/theme/pages/SinglePage.js +23 -0
- package/dist/theme/pages/index.js +11 -0
- package/package.json +2 -1
- package/src/app.tsx +1 -1
- package/src/i18n/locales/en.po +31 -31
- package/src/i18n/locales/zh-Hans.po +31 -31
- package/src/i18n/locales/zh-Hant.po +31 -31
- package/src/index.ts +51 -2
- package/src/lib/__tests__/theme-components.test.ts +33 -14
- package/src/lib/__tests__/view.test.ts +375 -0
- package/src/lib/feed.ts +148 -0
- package/src/lib/navigation.ts +11 -11
- package/src/lib/render.tsx +67 -0
- package/src/lib/theme-components.ts +27 -35
- package/src/lib/view.ts +318 -0
- package/src/routes/api/__tests__/timeline.test.ts +3 -3
- package/src/routes/api/timeline.tsx +32 -25
- package/src/routes/feed/rss.ts +47 -94
- package/src/routes/feed/sitemap.ts +8 -30
- package/src/routes/pages/archive.tsx +24 -209
- package/src/routes/pages/collection.tsx +19 -75
- package/src/routes/pages/home.tsx +42 -76
- package/src/routes/pages/page.tsx +17 -28
- package/src/routes/pages/post.tsx +28 -86
- package/src/routes/pages/search.tsx +29 -151
- package/src/services/search.ts +2 -8
- package/src/theme/components/MediaGallery.tsx +12 -12
- package/src/theme/components/index.ts +1 -0
- package/src/theme/components/timeline/ArticleCard.tsx +7 -19
- package/src/theme/components/timeline/ImageCard.tsx +10 -20
- package/src/theme/components/timeline/LinkCard.tsx +4 -11
- package/src/theme/components/timeline/NoteCard.tsx +5 -12
- package/src/theme/components/timeline/QuoteCard.tsx +3 -10
- package/src/theme/components/timeline/ThreadPreview.tsx +5 -5
- package/src/theme/components/timeline/TimelineFeed.tsx +7 -3
- package/src/theme/components/timeline/TimelineItem.tsx +43 -4
- package/src/theme/components/timeline/index.ts +1 -1
- package/src/theme/index.ts +7 -3
- package/src/theme/layouts/SiteLayout.tsx +25 -77
- package/src/theme/layouts/index.ts +2 -1
- package/src/theme/pages/ArchivePage.tsx +160 -0
- package/src/theme/pages/CollectionPage.tsx +60 -0
- package/src/theme/pages/HomePage.tsx +42 -0
- package/src/theme/pages/PostPage.tsx +44 -0
- package/src/theme/pages/SearchPage.tsx +128 -0
- package/src/theme/pages/SinglePage.tsx +24 -0
- package/src/theme/pages/index.ts +13 -0
- package/src/types.ts +262 -38
package/src/routes/feed/rss.ts
CHANGED
|
@@ -3,24 +3,27 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
-
import type {
|
|
6
|
+
import type { Context } from "hono";
|
|
7
|
+
import type { Bindings, FeedData } from "../../types.js";
|
|
7
8
|
import type { AppVariables } from "../../app.js";
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
9
|
+
import { defaultRssRenderer, defaultAtomRenderer } from "../../lib/feed.js";
|
|
10
|
+
import { getSiteLanguage } from "../../lib/config.js";
|
|
11
|
+
import { buildMediaMap } from "../../lib/media-helpers.js";
|
|
12
|
+
import { createMediaContext, toPostViews } from "../../lib/view.js";
|
|
11
13
|
|
|
12
14
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
13
15
|
|
|
14
16
|
export const rssRoutes = new Hono<Env>();
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Build FeedData from the Hono context.
|
|
20
|
+
*/
|
|
21
|
+
async function buildFeedData(c: Context<Env>): Promise<FeedData> {
|
|
18
22
|
const all = await c.var.services.settings.getAll();
|
|
19
23
|
const siteName = all["SITE_NAME"] ?? "Jant";
|
|
20
24
|
const siteDescription = all["SITE_DESCRIPTION"] ?? "";
|
|
21
25
|
const siteUrl = c.env.SITE_URL;
|
|
22
|
-
const
|
|
23
|
-
const s3PublicUrl = c.env.S3_PUBLIC_URL;
|
|
26
|
+
const siteLanguage = await getSiteLanguage(c);
|
|
24
27
|
|
|
25
28
|
const posts = await c.var.services.posts.list({
|
|
26
29
|
visibility: ["featured", "quiet"],
|
|
@@ -29,45 +32,41 @@ rssRoutes.get("/", async (c) => {
|
|
|
29
32
|
|
|
30
33
|
// Batch load media for enclosures
|
|
31
34
|
const postIds = posts.map((p) => p.id);
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
35
|
+
const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
36
|
+
const mediaCtx = createMediaContext(c);
|
|
37
|
+
const mediaMap = buildMediaMap(
|
|
38
|
+
rawMediaMap,
|
|
39
|
+
mediaCtx.r2PublicUrl,
|
|
40
|
+
mediaCtx.imageTransformUrl,
|
|
41
|
+
mediaCtx.s3PublicUrl,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Transform to PostView[] with media
|
|
45
|
+
const postViews = toPostViews(
|
|
46
|
+
posts.map((p) => ({
|
|
47
|
+
...p,
|
|
48
|
+
mediaAttachments: mediaMap.get(p.id) ?? [],
|
|
49
|
+
})),
|
|
50
|
+
mediaCtx,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
siteName,
|
|
55
|
+
siteDescription,
|
|
56
|
+
siteUrl,
|
|
57
|
+
siteLanguage,
|
|
58
|
+
posts: postViews,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
46
61
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
<link>${link}</link>
|
|
51
|
-
<guid isPermaLink="true">${link}</guid>
|
|
52
|
-
<pubDate>${pubDate}</pubDate>
|
|
53
|
-
<description><![CDATA[${post.contentHtml || ""}]]></description>${enclosure}
|
|
54
|
-
</item>`;
|
|
55
|
-
})
|
|
56
|
-
.join("");
|
|
62
|
+
// RSS 2.0 Feed - main feed at /feed
|
|
63
|
+
rssRoutes.get("/", async (c) => {
|
|
64
|
+
const feedData = await buildFeedData(c);
|
|
57
65
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
<channel>
|
|
61
|
-
<title>${escapeXml(siteName)}</title>
|
|
62
|
-
<link>${siteUrl}</link>
|
|
63
|
-
<description>${escapeXml(siteDescription)}</description>
|
|
64
|
-
<language>en</language>
|
|
65
|
-
<atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
|
|
66
|
-
${items}
|
|
67
|
-
</channel>
|
|
68
|
-
</rss>`;
|
|
66
|
+
const renderer = c.var.config.theme?.feed?.rss ?? defaultRssRenderer;
|
|
67
|
+
const xml = renderer(feedData);
|
|
69
68
|
|
|
70
|
-
return new Response(
|
|
69
|
+
return new Response(xml, {
|
|
71
70
|
headers: {
|
|
72
71
|
"Content-Type": "application/rss+xml; charset=utf-8",
|
|
73
72
|
},
|
|
@@ -76,60 +75,14 @@ rssRoutes.get("/", async (c) => {
|
|
|
76
75
|
|
|
77
76
|
// Atom Feed
|
|
78
77
|
rssRoutes.get("/atom.xml", async (c) => {
|
|
79
|
-
const
|
|
80
|
-
const siteName = all["SITE_NAME"] ?? "Jant";
|
|
81
|
-
const siteDescription = all["SITE_DESCRIPTION"] ?? "";
|
|
82
|
-
const siteUrl = c.env.SITE_URL;
|
|
83
|
-
|
|
84
|
-
const posts = await c.var.services.posts.list({
|
|
85
|
-
visibility: ["featured", "quiet"],
|
|
86
|
-
limit: 50,
|
|
87
|
-
});
|
|
78
|
+
const feedData = await buildFeedData(c);
|
|
88
79
|
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
|
|
92
|
-
const title = post.title || `Post #${post.id}`;
|
|
93
|
-
const updated = time.toISOString(post.updatedAt);
|
|
94
|
-
const published = time.toISOString(post.publishedAt);
|
|
80
|
+
const renderer = c.var.config.theme?.feed?.atom ?? defaultAtomRenderer;
|
|
81
|
+
const xml = renderer(feedData);
|
|
95
82
|
|
|
96
|
-
|
|
97
|
-
<entry>
|
|
98
|
-
<title>${escapeXml(title)}</title>
|
|
99
|
-
<link href="${link}" rel="alternate"/>
|
|
100
|
-
<id>${link}</id>
|
|
101
|
-
<published>${published}</published>
|
|
102
|
-
<updated>${updated}</updated>
|
|
103
|
-
<content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
|
|
104
|
-
</entry>`;
|
|
105
|
-
})
|
|
106
|
-
.join("");
|
|
107
|
-
|
|
108
|
-
const now = time.toISOString(time.now());
|
|
109
|
-
|
|
110
|
-
const atom = `<?xml version="1.0" encoding="UTF-8"?>
|
|
111
|
-
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
112
|
-
<title>${escapeXml(siteName)}</title>
|
|
113
|
-
<subtitle>${escapeXml(siteDescription)}</subtitle>
|
|
114
|
-
<link href="${siteUrl}" rel="alternate"/>
|
|
115
|
-
<link href="${siteUrl}/feed/atom.xml" rel="self"/>
|
|
116
|
-
<id>${siteUrl}/</id>
|
|
117
|
-
<updated>${now}</updated>
|
|
118
|
-
${entries}
|
|
119
|
-
</feed>`;
|
|
120
|
-
|
|
121
|
-
return new Response(atom, {
|
|
83
|
+
return new Response(xml, {
|
|
122
84
|
headers: {
|
|
123
85
|
"Content-Type": "application/atom+xml; charset=utf-8",
|
|
124
86
|
},
|
|
125
87
|
});
|
|
126
88
|
});
|
|
127
|
-
|
|
128
|
-
function escapeXml(str: string): string {
|
|
129
|
-
return str
|
|
130
|
-
.replace(/&/g, "&")
|
|
131
|
-
.replace(/</g, "<")
|
|
132
|
-
.replace(/>/g, ">")
|
|
133
|
-
.replace(/"/g, """)
|
|
134
|
-
.replace(/'/g, "'");
|
|
135
|
-
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import type { Bindings } from "../../types.js";
|
|
7
7
|
import type { AppVariables } from "../../app.js";
|
|
8
|
-
import
|
|
9
|
-
import
|
|
8
|
+
import { defaultSitemapRenderer } from "../../lib/feed.js";
|
|
9
|
+
import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
|
|
10
10
|
|
|
11
11
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
12
|
|
|
@@ -21,36 +21,14 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
|
|
|
21
21
|
limit: 1000,
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const lastmod = time.toISOString(post.updatedAt).split("T")[0];
|
|
28
|
-
const priority = post.visibility === "featured" ? "0.8" : "0.6";
|
|
24
|
+
// Transform to PostView[]
|
|
25
|
+
const mediaCtx = createMediaContext(c);
|
|
26
|
+
const postViews = toPostViewsFromPosts(posts, mediaCtx);
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
<loc>${loc}</loc>
|
|
33
|
-
<lastmod>${lastmod}</lastmod>
|
|
34
|
-
<priority>${priority}</priority>
|
|
35
|
-
</url>`;
|
|
36
|
-
})
|
|
37
|
-
.join("");
|
|
28
|
+
const renderer = c.var.config.theme?.feed?.sitemap ?? defaultSitemapRenderer;
|
|
29
|
+
const xml = renderer({ siteUrl, posts: postViews });
|
|
38
30
|
|
|
39
|
-
|
|
40
|
-
const homepageUrl = `
|
|
41
|
-
<url>
|
|
42
|
-
<loc>${siteUrl}/</loc>
|
|
43
|
-
<priority>1.0</priority>
|
|
44
|
-
<changefreq>daily</changefreq>
|
|
45
|
-
</url>`;
|
|
46
|
-
|
|
47
|
-
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
|
48
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
49
|
-
${homepageUrl}
|
|
50
|
-
${urls}
|
|
51
|
-
</urlset>`;
|
|
52
|
-
|
|
53
|
-
return new Response(sitemap, {
|
|
31
|
+
return new Response(xml, {
|
|
54
32
|
headers: {
|
|
55
33
|
"Content-Type": "application/xml; charset=utf-8",
|
|
56
34
|
},
|
|
@@ -5,15 +5,13 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Hono } from "hono";
|
|
8
|
-
import {
|
|
9
|
-
import type { Bindings, Post, PostType } from "../../types.js";
|
|
8
|
+
import type { Bindings, PostType } from "../../types.js";
|
|
10
9
|
import type { AppVariables } from "../../app.js";
|
|
11
|
-
import { BaseLayout, SiteLayout } from "../../theme/layouts/index.js";
|
|
12
|
-
import { Pagination } from "../../theme/components/index.js";
|
|
13
10
|
import { POST_TYPES } from "../../types.js";
|
|
14
|
-
import
|
|
15
|
-
import * as time from "../../lib/time.js";
|
|
11
|
+
import { ArchivePage as DefaultArchivePage } from "../../theme/pages/ArchivePage.js";
|
|
16
12
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
13
|
+
import { renderPublicPage } from "../../lib/render.js";
|
|
14
|
+
import { createMediaContext, toArchiveGroups } from "../../lib/view.js";
|
|
17
15
|
|
|
18
16
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
19
17
|
|
|
@@ -21,191 +19,6 @@ const PAGE_SIZE = 50;
|
|
|
21
19
|
|
|
22
20
|
export const archiveRoutes = new Hono<Env>();
|
|
23
21
|
|
|
24
|
-
function getTypeLabel(type: string): string {
|
|
25
|
-
const { t } = useLingui();
|
|
26
|
-
const labels: Record<string, string> = {
|
|
27
|
-
note: t({ message: "Note", comment: "@context: Post type label - note" }),
|
|
28
|
-
article: t({
|
|
29
|
-
message: "Article",
|
|
30
|
-
comment: "@context: Post type label - article",
|
|
31
|
-
}),
|
|
32
|
-
link: t({ message: "Link", comment: "@context: Post type label - link" }),
|
|
33
|
-
quote: t({
|
|
34
|
-
message: "Quote",
|
|
35
|
-
comment: "@context: Post type label - quote",
|
|
36
|
-
}),
|
|
37
|
-
image: t({
|
|
38
|
-
message: "Image",
|
|
39
|
-
comment: "@context: Post type label - image",
|
|
40
|
-
}),
|
|
41
|
-
page: t({ message: "Page", comment: "@context: Post type label - page" }),
|
|
42
|
-
};
|
|
43
|
-
return labels[type] ?? type;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function getTypeLabelPlural(type: string): string {
|
|
47
|
-
const { t } = useLingui();
|
|
48
|
-
const labels: Record<string, string> = {
|
|
49
|
-
note: t({
|
|
50
|
-
message: "Notes",
|
|
51
|
-
comment: "@context: Post type label plural - notes",
|
|
52
|
-
}),
|
|
53
|
-
article: t({
|
|
54
|
-
message: "Articles",
|
|
55
|
-
comment: "@context: Post type label plural - articles",
|
|
56
|
-
}),
|
|
57
|
-
link: t({
|
|
58
|
-
message: "Links",
|
|
59
|
-
comment: "@context: Post type label plural - links",
|
|
60
|
-
}),
|
|
61
|
-
quote: t({
|
|
62
|
-
message: "Quotes",
|
|
63
|
-
comment: "@context: Post type label plural - quotes",
|
|
64
|
-
}),
|
|
65
|
-
image: t({
|
|
66
|
-
message: "Images",
|
|
67
|
-
comment: "@context: Post type label plural - images",
|
|
68
|
-
}),
|
|
69
|
-
page: t({
|
|
70
|
-
message: "Pages",
|
|
71
|
-
comment: "@context: Post type label plural - pages",
|
|
72
|
-
}),
|
|
73
|
-
};
|
|
74
|
-
return labels[type] ?? `${type}s`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function formatYearMonth(yearMonth: string): string {
|
|
78
|
-
const [year, month] = yearMonth.split("-");
|
|
79
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- yearMonth format YYYY-MM guarantees both year and month exist
|
|
80
|
-
const date = new Date(parseInt(year!, 10), parseInt(month!, 10) - 1);
|
|
81
|
-
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function ArchiveContent({
|
|
85
|
-
displayPosts,
|
|
86
|
-
hasMore,
|
|
87
|
-
nextCursor,
|
|
88
|
-
type,
|
|
89
|
-
grouped,
|
|
90
|
-
replyCounts,
|
|
91
|
-
}: {
|
|
92
|
-
displayPosts: Post[];
|
|
93
|
-
hasMore: boolean;
|
|
94
|
-
nextCursor?: number;
|
|
95
|
-
type?: string;
|
|
96
|
-
grouped: Map<string, Post[]>;
|
|
97
|
-
replyCounts: Map<number, number>;
|
|
98
|
-
}) {
|
|
99
|
-
const { t } = useLingui();
|
|
100
|
-
const title = type
|
|
101
|
-
? getTypeLabelPlural(type)
|
|
102
|
-
: t({ message: "Archive", comment: "@context: Archive page title" });
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<div>
|
|
106
|
-
<header class="mb-8">
|
|
107
|
-
<h1 class="text-2xl font-semibold">{title}</h1>
|
|
108
|
-
|
|
109
|
-
{/* Type filter */}
|
|
110
|
-
<nav class="flex flex-wrap gap-2 mt-4">
|
|
111
|
-
<a
|
|
112
|
-
href="/archive"
|
|
113
|
-
class={`badge ${!type ? "badge-primary" : "badge-outline"}`}
|
|
114
|
-
>
|
|
115
|
-
{t({
|
|
116
|
-
message: "All",
|
|
117
|
-
comment: "@context: Archive filter - all types",
|
|
118
|
-
})}
|
|
119
|
-
</a>
|
|
120
|
-
{POST_TYPES.filter((t) => t !== "page").map((typeKey) => (
|
|
121
|
-
<a
|
|
122
|
-
key={typeKey}
|
|
123
|
-
href={`/archive?type=${typeKey}`}
|
|
124
|
-
class={`badge ${type === typeKey ? "badge-primary" : "badge-outline"}`}
|
|
125
|
-
>
|
|
126
|
-
{getTypeLabelPlural(typeKey)}
|
|
127
|
-
</a>
|
|
128
|
-
))}
|
|
129
|
-
</nav>
|
|
130
|
-
</header>
|
|
131
|
-
|
|
132
|
-
<main>
|
|
133
|
-
{displayPosts.length === 0 ? (
|
|
134
|
-
<p class="text-muted-foreground">
|
|
135
|
-
{t({
|
|
136
|
-
message: "No posts found.",
|
|
137
|
-
comment: "@context: Archive empty state",
|
|
138
|
-
})}
|
|
139
|
-
</p>
|
|
140
|
-
) : (
|
|
141
|
-
Array.from(grouped.entries()).map(([yearMonth, monthPosts]) => (
|
|
142
|
-
<section key={yearMonth} class="mb-8">
|
|
143
|
-
<h2 class="text-lg font-medium mb-4 text-muted-foreground">
|
|
144
|
-
{formatYearMonth(yearMonth)}
|
|
145
|
-
</h2>
|
|
146
|
-
<div class="flex flex-col gap-3">
|
|
147
|
-
{monthPosts.map((post) => {
|
|
148
|
-
const replyCount = replyCounts.get(post.id);
|
|
149
|
-
return (
|
|
150
|
-
<article key={post.id} class="flex items-baseline gap-4">
|
|
151
|
-
<time
|
|
152
|
-
class="text-sm text-muted-foreground w-12 shrink-0"
|
|
153
|
-
datetime={time.toISOString(post.publishedAt)}
|
|
154
|
-
>
|
|
155
|
-
{new Date(post.publishedAt * 1000).getDate()}
|
|
156
|
-
</time>
|
|
157
|
-
<div class="flex-1 min-w-0">
|
|
158
|
-
<a
|
|
159
|
-
href={`/p/${sqid.encode(post.id)}`}
|
|
160
|
-
class="hover:underline"
|
|
161
|
-
>
|
|
162
|
-
{post.title ||
|
|
163
|
-
post.content?.slice(0, 80) ||
|
|
164
|
-
`Post #${post.id}`}
|
|
165
|
-
</a>
|
|
166
|
-
{!type && (
|
|
167
|
-
<span class="ml-2 badge-outline text-xs">
|
|
168
|
-
{getTypeLabel(post.type)}
|
|
169
|
-
</span>
|
|
170
|
-
)}
|
|
171
|
-
{replyCount && replyCount > 0 && (
|
|
172
|
-
<span class="ml-2 text-xs text-muted-foreground">
|
|
173
|
-
(
|
|
174
|
-
{replyCount === 1
|
|
175
|
-
? t({
|
|
176
|
-
message: "1 reply",
|
|
177
|
-
comment:
|
|
178
|
-
"@context: Archive post reply indicator - single",
|
|
179
|
-
})
|
|
180
|
-
: t({
|
|
181
|
-
message: "{count} replies",
|
|
182
|
-
comment:
|
|
183
|
-
"@context: Archive post reply indicator - plural",
|
|
184
|
-
values: { count: String(replyCount) },
|
|
185
|
-
})}
|
|
186
|
-
)
|
|
187
|
-
</span>
|
|
188
|
-
)}
|
|
189
|
-
</div>
|
|
190
|
-
</article>
|
|
191
|
-
);
|
|
192
|
-
})}
|
|
193
|
-
</div>
|
|
194
|
-
</section>
|
|
195
|
-
))
|
|
196
|
-
)}
|
|
197
|
-
</main>
|
|
198
|
-
|
|
199
|
-
{/* Pagination */}
|
|
200
|
-
<Pagination
|
|
201
|
-
baseUrl={type ? `/archive?type=${type}` : "/archive"}
|
|
202
|
-
hasMore={hasMore}
|
|
203
|
-
nextCursor={nextCursor}
|
|
204
|
-
/>
|
|
205
|
-
</div>
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
22
|
// Archive page - all posts
|
|
210
23
|
archiveRoutes.get("/", async (c) => {
|
|
211
24
|
const typeParam = c.req.query("type") as PostType | undefined;
|
|
@@ -230,10 +43,6 @@ archiveRoutes.get("/", async (c) => {
|
|
|
230
43
|
const hasMore = posts.length > PAGE_SIZE;
|
|
231
44
|
const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
|
|
232
45
|
|
|
233
|
-
// Get reply counts for thread indicators
|
|
234
|
-
const postIds = displayPosts.map((p) => p.id);
|
|
235
|
-
const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
|
|
236
|
-
|
|
237
46
|
// Get next cursor
|
|
238
47
|
const nextCursor =
|
|
239
48
|
hasMore && displayPosts.length > 0
|
|
@@ -253,18 +62,24 @@ archiveRoutes.get("/", async (c) => {
|
|
|
253
62
|
grouped.get(key)!.push(post);
|
|
254
63
|
}
|
|
255
64
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
65
|
+
// Transform to View Models
|
|
66
|
+
const mediaCtx = createMediaContext(c);
|
|
67
|
+
const groups = toArchiveGroups(grouped, mediaCtx);
|
|
68
|
+
|
|
69
|
+
const components = c.var.config.theme?.components;
|
|
70
|
+
const Page = components?.ArchivePage ?? DefaultArchivePage;
|
|
71
|
+
|
|
72
|
+
return renderPublicPage(c, {
|
|
73
|
+
title: `Archive - ${navData.siteName}`,
|
|
74
|
+
navData,
|
|
75
|
+
content: (
|
|
76
|
+
<Page
|
|
77
|
+
groups={groups}
|
|
78
|
+
hasMore={hasMore}
|
|
79
|
+
nextCursor={nextCursor}
|
|
80
|
+
type={type}
|
|
81
|
+
theme={components}
|
|
82
|
+
/>
|
|
83
|
+
),
|
|
84
|
+
});
|
|
270
85
|
});
|
|
@@ -3,77 +3,17 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
-
import {
|
|
7
|
-
import type { Bindings, Collection, Post } from "../../types.js";
|
|
6
|
+
import type { Bindings } from "../../types.js";
|
|
8
7
|
import type { AppVariables } from "../../app.js";
|
|
9
|
-
import {
|
|
10
|
-
import * as sqid from "../../lib/sqid.js";
|
|
11
|
-
import * as time from "../../lib/time.js";
|
|
8
|
+
import { CollectionPage as DefaultCollectionPage } from "../../theme/pages/CollectionPage.js";
|
|
12
9
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
10
|
+
import { renderPublicPage } from "../../lib/render.js";
|
|
11
|
+
import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
|
|
13
12
|
|
|
14
13
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
15
14
|
|
|
16
15
|
export const collectionRoutes = new Hono<Env>();
|
|
17
16
|
|
|
18
|
-
function CollectionContent({
|
|
19
|
-
collection,
|
|
20
|
-
posts,
|
|
21
|
-
}: {
|
|
22
|
-
collection: Collection;
|
|
23
|
-
posts: Post[];
|
|
24
|
-
}) {
|
|
25
|
-
const { t } = useLingui();
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<div>
|
|
29
|
-
<header class="mb-8">
|
|
30
|
-
<h1 class="text-2xl font-semibold">{collection.title}</h1>
|
|
31
|
-
{collection.description && (
|
|
32
|
-
<p class="text-muted-foreground mt-2">{collection.description}</p>
|
|
33
|
-
)}
|
|
34
|
-
</header>
|
|
35
|
-
|
|
36
|
-
<main class="flex flex-col gap-6">
|
|
37
|
-
{posts.length === 0 ? (
|
|
38
|
-
<p class="text-muted-foreground">
|
|
39
|
-
{t({
|
|
40
|
-
message: "No posts in this collection.",
|
|
41
|
-
comment: "@context: Empty state message",
|
|
42
|
-
})}
|
|
43
|
-
</p>
|
|
44
|
-
) : (
|
|
45
|
-
posts.map((post) => (
|
|
46
|
-
<article key={post.id} class="h-entry">
|
|
47
|
-
{post.title && (
|
|
48
|
-
<h2 class="p-name text-lg font-medium mb-2">
|
|
49
|
-
<a
|
|
50
|
-
href={`/p/${sqid.encode(post.id)}`}
|
|
51
|
-
class="u-url hover:underline"
|
|
52
|
-
>
|
|
53
|
-
{post.title}
|
|
54
|
-
</a>
|
|
55
|
-
</h2>
|
|
56
|
-
)}
|
|
57
|
-
<div
|
|
58
|
-
class="e-content prose prose-sm"
|
|
59
|
-
dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
|
|
60
|
-
/>
|
|
61
|
-
<footer class="mt-2 text-sm text-muted-foreground">
|
|
62
|
-
<time
|
|
63
|
-
class="dt-published"
|
|
64
|
-
datetime={time.toISOString(post.publishedAt)}
|
|
65
|
-
>
|
|
66
|
-
{time.formatDate(post.publishedAt)}
|
|
67
|
-
</time>
|
|
68
|
-
</footer>
|
|
69
|
-
</article>
|
|
70
|
-
))
|
|
71
|
-
)}
|
|
72
|
-
</main>
|
|
73
|
-
</div>
|
|
74
|
-
);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
17
|
collectionRoutes.get("/:path", async (c) => {
|
|
78
18
|
const path = c.req.param("path");
|
|
79
19
|
|
|
@@ -83,15 +23,19 @@ collectionRoutes.get("/:path", async (c) => {
|
|
|
83
23
|
const posts = await c.var.services.collections.getPosts(collection.id);
|
|
84
24
|
const navData = await getNavigationData(c);
|
|
85
25
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
26
|
+
// Transform to View Models
|
|
27
|
+
const mediaCtx = createMediaContext(c);
|
|
28
|
+
const postViews = toPostViewsFromPosts(posts, mediaCtx);
|
|
29
|
+
|
|
30
|
+
const components = c.var.config.theme?.components;
|
|
31
|
+
const Page = components?.CollectionPage ?? DefaultCollectionPage;
|
|
32
|
+
|
|
33
|
+
return renderPublicPage(c, {
|
|
34
|
+
title: `${collection.title} - ${navData.siteName}`,
|
|
35
|
+
description: collection.description ?? undefined,
|
|
36
|
+
navData,
|
|
37
|
+
content: (
|
|
38
|
+
<Page collection={collection} posts={postViews} theme={components} />
|
|
39
|
+
),
|
|
40
|
+
});
|
|
97
41
|
});
|