@jant/core 0.0.1
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/jant.js +188 -0
- package/drizzle.config.ts +10 -0
- package/lingui.config.ts +16 -0
- package/package.json +116 -0
- package/src/app.tsx +377 -0
- package/src/assets/datastar.min.js +8 -0
- package/src/auth.ts +38 -0
- package/src/client.ts +6 -0
- package/src/db/index.ts +14 -0
- package/src/db/migrations/0000_solid_moon_knight.sql +118 -0
- package/src/db/migrations/0001_add_search_fts.sql +40 -0
- package/src/db/migrations/0002_collection_path.sql +2 -0
- package/src/db/migrations/0003_collection_path_nullable.sql +21 -0
- package/src/db/migrations/0004_media_uuid.sql +35 -0
- package/src/db/migrations/meta/0000_snapshot.json +784 -0
- package/src/db/migrations/meta/_journal.json +41 -0
- package/src/db/schema.ts +159 -0
- package/src/i18n/EXAMPLES.md +235 -0
- package/src/i18n/README.md +296 -0
- package/src/i18n/Trans.tsx +31 -0
- package/src/i18n/context.tsx +101 -0
- package/src/i18n/detect.ts +100 -0
- package/src/i18n/i18n.ts +62 -0
- package/src/i18n/index.ts +65 -0
- package/src/i18n/locales/en.po +875 -0
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/zh-Hans.po +875 -0
- package/src/i18n/locales/zh-Hans.ts +1 -0
- package/src/i18n/locales/zh-Hant.po +875 -0
- package/src/i18n/locales/zh-Hant.ts +1 -0
- package/src/i18n/locales.ts +14 -0
- package/src/i18n/middleware.ts +59 -0
- package/src/index.ts +42 -0
- package/src/lib/assets.ts +47 -0
- package/src/lib/constants.ts +67 -0
- package/src/lib/image.ts +107 -0
- package/src/lib/index.ts +9 -0
- package/src/lib/markdown.ts +93 -0
- package/src/lib/schemas.ts +92 -0
- package/src/lib/sqid.ts +79 -0
- package/src/lib/sse.ts +152 -0
- package/src/lib/time.ts +117 -0
- package/src/lib/url.ts +107 -0
- package/src/middleware/auth.ts +59 -0
- package/src/routes/api/posts.ts +127 -0
- package/src/routes/api/search.ts +53 -0
- package/src/routes/api/upload.ts +240 -0
- package/src/routes/dash/collections.tsx +341 -0
- package/src/routes/dash/index.tsx +89 -0
- package/src/routes/dash/media.tsx +551 -0
- package/src/routes/dash/pages.tsx +245 -0
- package/src/routes/dash/posts.tsx +202 -0
- package/src/routes/dash/redirects.tsx +155 -0
- package/src/routes/dash/settings.tsx +93 -0
- package/src/routes/feed/rss.ts +119 -0
- package/src/routes/feed/sitemap.ts +75 -0
- package/src/routes/pages/archive.tsx +223 -0
- package/src/routes/pages/collection.tsx +79 -0
- package/src/routes/pages/home.tsx +93 -0
- package/src/routes/pages/page.tsx +64 -0
- package/src/routes/pages/post.tsx +81 -0
- package/src/routes/pages/search.tsx +162 -0
- package/src/services/collection.ts +180 -0
- package/src/services/index.ts +40 -0
- package/src/services/media.ts +97 -0
- package/src/services/post.ts +279 -0
- package/src/services/redirect.ts +74 -0
- package/src/services/search.ts +117 -0
- package/src/services/settings.ts +76 -0
- package/src/theme/components/ActionButtons.tsx +98 -0
- package/src/theme/components/CrudPageHeader.tsx +48 -0
- package/src/theme/components/DangerZone.tsx +77 -0
- package/src/theme/components/EmptyState.tsx +56 -0
- package/src/theme/components/ListItemRow.tsx +24 -0
- package/src/theme/components/PageForm.tsx +114 -0
- package/src/theme/components/Pagination.tsx +196 -0
- package/src/theme/components/PostForm.tsx +122 -0
- package/src/theme/components/PostList.tsx +68 -0
- package/src/theme/components/ThreadView.tsx +118 -0
- package/src/theme/components/TypeBadge.tsx +28 -0
- package/src/theme/components/VisibilityBadge.tsx +33 -0
- package/src/theme/components/index.ts +12 -0
- package/src/theme/index.ts +24 -0
- package/src/theme/layouts/BaseLayout.tsx +49 -0
- package/src/theme/layouts/DashLayout.tsx +108 -0
- package/src/theme/layouts/index.ts +2 -0
- package/src/theme/styles/main.css +52 -0
- package/src/types.ts +222 -0
- package/static/assets/datastar.min.js +7 -0
- package/static/assets/image-processor.js +234 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +82 -0
- package/wrangler.toml +21 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSS Feed Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { Bindings } from "../../types.js";
|
|
7
|
+
import type { AppVariables } from "../../app.js";
|
|
8
|
+
import * as sqid from "../../lib/sqid.js";
|
|
9
|
+
import * as time from "../../lib/time.js";
|
|
10
|
+
|
|
11
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
|
+
|
|
13
|
+
export const rssRoutes = new Hono<Env>();
|
|
14
|
+
|
|
15
|
+
// RSS 2.0 Feed - main feed at /feed
|
|
16
|
+
rssRoutes.get("/", async (c) => {
|
|
17
|
+
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
18
|
+
const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
|
|
19
|
+
const siteUrl = c.env.SITE_URL;
|
|
20
|
+
|
|
21
|
+
const posts = await c.var.services.posts.list({
|
|
22
|
+
visibility: ["featured", "quiet"],
|
|
23
|
+
limit: 50,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const items = posts
|
|
27
|
+
.map((post) => {
|
|
28
|
+
const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
|
|
29
|
+
const title = post.title || `Post #${post.id}`;
|
|
30
|
+
const pubDate = new Date(post.publishedAt * 1000).toUTCString();
|
|
31
|
+
|
|
32
|
+
return `
|
|
33
|
+
<item>
|
|
34
|
+
<title><![CDATA[${escapeXml(title)}]]></title>
|
|
35
|
+
<link>${link}</link>
|
|
36
|
+
<guid isPermaLink="true">${link}</guid>
|
|
37
|
+
<pubDate>${pubDate}</pubDate>
|
|
38
|
+
<description><![CDATA[${post.contentHtml || ""}]]></description>
|
|
39
|
+
</item>`;
|
|
40
|
+
})
|
|
41
|
+
.join("");
|
|
42
|
+
|
|
43
|
+
const rss = `<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
|
45
|
+
<channel>
|
|
46
|
+
<title>${escapeXml(siteName)}</title>
|
|
47
|
+
<link>${siteUrl}</link>
|
|
48
|
+
<description>${escapeXml(siteDescription)}</description>
|
|
49
|
+
<language>en</language>
|
|
50
|
+
<atom:link href="${siteUrl}/feed" rel="self" type="application/rss+xml"/>
|
|
51
|
+
${items}
|
|
52
|
+
</channel>
|
|
53
|
+
</rss>`;
|
|
54
|
+
|
|
55
|
+
return new Response(rss, {
|
|
56
|
+
headers: {
|
|
57
|
+
"Content-Type": "application/rss+xml; charset=utf-8",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Atom Feed
|
|
63
|
+
rssRoutes.get("/atom.xml", async (c) => {
|
|
64
|
+
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
65
|
+
const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
|
|
66
|
+
const siteUrl = c.env.SITE_URL;
|
|
67
|
+
|
|
68
|
+
const posts = await c.var.services.posts.list({
|
|
69
|
+
visibility: ["featured", "quiet"],
|
|
70
|
+
limit: 50,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const entries = posts
|
|
74
|
+
.map((post) => {
|
|
75
|
+
const link = `${siteUrl}/p/${sqid.encode(post.id)}`;
|
|
76
|
+
const title = post.title || `Post #${post.id}`;
|
|
77
|
+
const updated = time.toISOString(post.updatedAt);
|
|
78
|
+
const published = time.toISOString(post.publishedAt);
|
|
79
|
+
|
|
80
|
+
return `
|
|
81
|
+
<entry>
|
|
82
|
+
<title>${escapeXml(title)}</title>
|
|
83
|
+
<link href="${link}" rel="alternate"/>
|
|
84
|
+
<id>${link}</id>
|
|
85
|
+
<published>${published}</published>
|
|
86
|
+
<updated>${updated}</updated>
|
|
87
|
+
<content type="html"><![CDATA[${post.contentHtml || ""}]]></content>
|
|
88
|
+
</entry>`;
|
|
89
|
+
})
|
|
90
|
+
.join("");
|
|
91
|
+
|
|
92
|
+
const now = time.toISOString(time.now());
|
|
93
|
+
|
|
94
|
+
const atom = `<?xml version="1.0" encoding="UTF-8"?>
|
|
95
|
+
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
96
|
+
<title>${escapeXml(siteName)}</title>
|
|
97
|
+
<subtitle>${escapeXml(siteDescription)}</subtitle>
|
|
98
|
+
<link href="${siteUrl}" rel="alternate"/>
|
|
99
|
+
<link href="${siteUrl}/feed/atom.xml" rel="self"/>
|
|
100
|
+
<id>${siteUrl}/</id>
|
|
101
|
+
<updated>${now}</updated>
|
|
102
|
+
${entries}
|
|
103
|
+
</feed>`;
|
|
104
|
+
|
|
105
|
+
return new Response(atom, {
|
|
106
|
+
headers: {
|
|
107
|
+
"Content-Type": "application/atom+xml; charset=utf-8",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
function escapeXml(str: string): string {
|
|
113
|
+
return str
|
|
114
|
+
.replace(/&/g, "&")
|
|
115
|
+
.replace(/</g, "<")
|
|
116
|
+
.replace(/>/g, ">")
|
|
117
|
+
.replace(/"/g, """)
|
|
118
|
+
.replace(/'/g, "'");
|
|
119
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { Bindings } from "../../types.js";
|
|
7
|
+
import type { AppVariables } from "../../app.js";
|
|
8
|
+
import * as sqid from "../../lib/sqid.js";
|
|
9
|
+
import * as time from "../../lib/time.js";
|
|
10
|
+
|
|
11
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
12
|
+
|
|
13
|
+
export const sitemapRoutes = new Hono<Env>();
|
|
14
|
+
|
|
15
|
+
// XML Sitemap
|
|
16
|
+
sitemapRoutes.get("/sitemap.xml", async (c) => {
|
|
17
|
+
const siteUrl = c.env.SITE_URL;
|
|
18
|
+
|
|
19
|
+
const posts = await c.var.services.posts.list({
|
|
20
|
+
visibility: ["featured", "quiet"],
|
|
21
|
+
limit: 1000,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const urls = posts
|
|
25
|
+
.map((post) => {
|
|
26
|
+
const loc = `${siteUrl}/p/${sqid.encode(post.id)}`;
|
|
27
|
+
const lastmod = time.toISOString(post.updatedAt).split("T")[0];
|
|
28
|
+
const priority = post.visibility === "featured" ? "0.8" : "0.6";
|
|
29
|
+
|
|
30
|
+
return `
|
|
31
|
+
<url>
|
|
32
|
+
<loc>${loc}</loc>
|
|
33
|
+
<lastmod>${lastmod}</lastmod>
|
|
34
|
+
<priority>${priority}</priority>
|
|
35
|
+
</url>`;
|
|
36
|
+
})
|
|
37
|
+
.join("");
|
|
38
|
+
|
|
39
|
+
// Add homepage
|
|
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, {
|
|
54
|
+
headers: {
|
|
55
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// robots.txt
|
|
61
|
+
sitemapRoutes.get("/robots.txt", (c) => {
|
|
62
|
+
const siteUrl = c.env.SITE_URL;
|
|
63
|
+
|
|
64
|
+
const robots = `User-agent: *
|
|
65
|
+
Allow: /
|
|
66
|
+
|
|
67
|
+
Sitemap: ${siteUrl}/sitemap.xml
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
return new Response(robots, {
|
|
71
|
+
headers: {
|
|
72
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Archive Page Route
|
|
3
|
+
*
|
|
4
|
+
* Shows all posts, optionally filtered by type
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { useLingui } from "../../i18n/index.js";
|
|
9
|
+
import type { Bindings, Post, PostType } from "../../types.js";
|
|
10
|
+
import type { AppVariables } from "../../app.js";
|
|
11
|
+
import { BaseLayout } from "../../theme/layouts/index.js";
|
|
12
|
+
import { Pagination } from "../../theme/components/index.js";
|
|
13
|
+
import { POST_TYPES } from "../../types.js";
|
|
14
|
+
import * as sqid from "../../lib/sqid.js";
|
|
15
|
+
import * as time from "../../lib/time.js";
|
|
16
|
+
|
|
17
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
18
|
+
|
|
19
|
+
const PAGE_SIZE = 50;
|
|
20
|
+
|
|
21
|
+
export const archiveRoutes = new Hono<Env>();
|
|
22
|
+
|
|
23
|
+
function getTypeLabel(type: string): string {
|
|
24
|
+
const { t } = useLingui();
|
|
25
|
+
const labels: Record<string, string> = {
|
|
26
|
+
note: t({ message: "Note", comment: "@context: Post type label - note" }),
|
|
27
|
+
article: t({ message: "Article", comment: "@context: Post type label - article" }),
|
|
28
|
+
link: t({ message: "Link", comment: "@context: Post type label - link" }),
|
|
29
|
+
quote: t({ message: "Quote", comment: "@context: Post type label - quote" }),
|
|
30
|
+
image: t({ message: "Image", comment: "@context: Post type label - image" }),
|
|
31
|
+
page: t({ message: "Page", comment: "@context: Post type label - page" }),
|
|
32
|
+
};
|
|
33
|
+
return labels[type] ?? type;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getTypeLabelPlural(type: string): string {
|
|
37
|
+
const { t } = useLingui();
|
|
38
|
+
const labels: Record<string, string> = {
|
|
39
|
+
note: t({ message: "Notes", comment: "@context: Post type label plural - notes" }),
|
|
40
|
+
article: t({ message: "Articles", comment: "@context: Post type label plural - articles" }),
|
|
41
|
+
link: t({ message: "Links", comment: "@context: Post type label plural - links" }),
|
|
42
|
+
quote: t({ message: "Quotes", comment: "@context: Post type label plural - quotes" }),
|
|
43
|
+
image: t({ message: "Images", comment: "@context: Post type label plural - images" }),
|
|
44
|
+
page: t({ message: "Pages", comment: "@context: Post type label plural - pages" }),
|
|
45
|
+
};
|
|
46
|
+
return labels[type] ?? `${type}s`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatYearMonth(yearMonth: string): string {
|
|
50
|
+
const [year, month] = yearMonth.split("-");
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- yearMonth format YYYY-MM guarantees both year and month exist
|
|
52
|
+
const date = new Date(parseInt(year!, 10), parseInt(month!, 10) - 1);
|
|
53
|
+
return date.toLocaleDateString("en-US", { year: "numeric", month: "long" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function ArchiveContent({
|
|
57
|
+
displayPosts,
|
|
58
|
+
hasMore,
|
|
59
|
+
nextCursor,
|
|
60
|
+
type,
|
|
61
|
+
grouped,
|
|
62
|
+
replyCounts,
|
|
63
|
+
}: {
|
|
64
|
+
displayPosts: Post[];
|
|
65
|
+
hasMore: boolean;
|
|
66
|
+
nextCursor?: number;
|
|
67
|
+
type?: string;
|
|
68
|
+
grouped: Map<string, Post[]>;
|
|
69
|
+
replyCounts: Map<number, number>;
|
|
70
|
+
}) {
|
|
71
|
+
const { t } = useLingui();
|
|
72
|
+
const title = type
|
|
73
|
+
? getTypeLabelPlural(type)
|
|
74
|
+
: t({ message: "Archive", comment: "@context: Archive page title" });
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div class="container py-8">
|
|
78
|
+
<header class="mb-8">
|
|
79
|
+
<h1 class="text-2xl font-semibold">{title}</h1>
|
|
80
|
+
|
|
81
|
+
{/* Type filter */}
|
|
82
|
+
<nav class="flex flex-wrap gap-2 mt-4">
|
|
83
|
+
<a
|
|
84
|
+
href="/archive"
|
|
85
|
+
class={`badge ${!type ? "badge-primary" : "badge-outline"}`}
|
|
86
|
+
>
|
|
87
|
+
{t({ message: "All", comment: "@context: Archive filter - all types" })}
|
|
88
|
+
</a>
|
|
89
|
+
{POST_TYPES.filter((t) => t !== "page").map((typeKey) => (
|
|
90
|
+
<a
|
|
91
|
+
key={typeKey}
|
|
92
|
+
href={`/archive?type=${typeKey}`}
|
|
93
|
+
class={`badge ${type === typeKey ? "badge-primary" : "badge-outline"}`}
|
|
94
|
+
>
|
|
95
|
+
{getTypeLabelPlural(typeKey)}
|
|
96
|
+
</a>
|
|
97
|
+
))}
|
|
98
|
+
</nav>
|
|
99
|
+
</header>
|
|
100
|
+
|
|
101
|
+
<main>
|
|
102
|
+
{displayPosts.length === 0 ? (
|
|
103
|
+
<p class="text-muted-foreground">
|
|
104
|
+
{t({ message: "No posts found.", comment: "@context: Archive empty state" })}
|
|
105
|
+
</p>
|
|
106
|
+
) : (
|
|
107
|
+
Array.from(grouped.entries()).map(([yearMonth, monthPosts]) => (
|
|
108
|
+
<section key={yearMonth} class="mb-8">
|
|
109
|
+
<h2 class="text-lg font-medium mb-4 text-muted-foreground">
|
|
110
|
+
{formatYearMonth(yearMonth)}
|
|
111
|
+
</h2>
|
|
112
|
+
<div class="flex flex-col gap-3">
|
|
113
|
+
{monthPosts.map((post) => {
|
|
114
|
+
const replyCount = replyCounts.get(post.id);
|
|
115
|
+
return (
|
|
116
|
+
<article key={post.id} class="flex items-baseline gap-4">
|
|
117
|
+
<time
|
|
118
|
+
class="text-sm text-muted-foreground w-12 shrink-0"
|
|
119
|
+
datetime={time.toISOString(post.publishedAt)}
|
|
120
|
+
>
|
|
121
|
+
{new Date(post.publishedAt * 1000).getDate()}
|
|
122
|
+
</time>
|
|
123
|
+
<div class="flex-1 min-w-0">
|
|
124
|
+
<a
|
|
125
|
+
href={`/p/${sqid.encode(post.id)}`}
|
|
126
|
+
class="hover:underline"
|
|
127
|
+
>
|
|
128
|
+
{post.title || post.content?.slice(0, 80) || `Post #${post.id}`}
|
|
129
|
+
</a>
|
|
130
|
+
{!type && (
|
|
131
|
+
<span class="ml-2 badge-outline text-xs">{getTypeLabel(post.type)}</span>
|
|
132
|
+
)}
|
|
133
|
+
{replyCount && replyCount > 0 && (
|
|
134
|
+
<span class="ml-2 text-xs text-muted-foreground">
|
|
135
|
+
({replyCount === 1
|
|
136
|
+
? t({ message: "1 reply", comment: "@context: Archive post reply indicator - single" })
|
|
137
|
+
: t({ message: "{count} replies", comment: "@context: Archive post reply indicator - plural", values: { count: String(replyCount) } })})
|
|
138
|
+
</span>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</article>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</div>
|
|
145
|
+
</section>
|
|
146
|
+
))
|
|
147
|
+
)}
|
|
148
|
+
</main>
|
|
149
|
+
|
|
150
|
+
{/* Pagination */}
|
|
151
|
+
<Pagination
|
|
152
|
+
baseUrl={type ? `/archive?type=${type}` : "/archive"}
|
|
153
|
+
hasMore={hasMore}
|
|
154
|
+
nextCursor={nextCursor}
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
<nav class="mt-4">
|
|
158
|
+
<a href="/" class="text-sm hover:underline">
|
|
159
|
+
← {t({ message: "Back to home", comment: "@context: Navigation link back to home page" })}
|
|
160
|
+
</a>
|
|
161
|
+
</nav>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Archive page - all posts
|
|
167
|
+
archiveRoutes.get("/", async (c) => {
|
|
168
|
+
const typeParam = c.req.query("type") as PostType | undefined;
|
|
169
|
+
const type = typeParam && POST_TYPES.includes(typeParam) ? typeParam : undefined;
|
|
170
|
+
|
|
171
|
+
// Parse cursor
|
|
172
|
+
const cursorParam = c.req.query("cursor");
|
|
173
|
+
const cursor = cursorParam ? parseInt(cursorParam, 10) : undefined;
|
|
174
|
+
|
|
175
|
+
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
176
|
+
|
|
177
|
+
// Fetch one extra to check for more
|
|
178
|
+
const posts = await c.var.services.posts.list({
|
|
179
|
+
type,
|
|
180
|
+
visibility: ["featured", "quiet"],
|
|
181
|
+
excludeReplies: true,
|
|
182
|
+
cursor,
|
|
183
|
+
limit: PAGE_SIZE + 1,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const hasMore = posts.length > PAGE_SIZE;
|
|
187
|
+
const displayPosts = hasMore ? posts.slice(0, PAGE_SIZE) : posts;
|
|
188
|
+
|
|
189
|
+
// Get reply counts for thread indicators
|
|
190
|
+
const postIds = displayPosts.map((p) => p.id);
|
|
191
|
+
const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
|
|
192
|
+
|
|
193
|
+
// Get next cursor
|
|
194
|
+
const nextCursor = hasMore && displayPosts.length > 0
|
|
195
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Length check above guarantees element exists
|
|
196
|
+
? displayPosts[displayPosts.length - 1]!.id
|
|
197
|
+
: undefined;
|
|
198
|
+
|
|
199
|
+
// Group posts by year-month
|
|
200
|
+
const grouped = new Map<string, typeof displayPosts>();
|
|
201
|
+
for (const post of displayPosts) {
|
|
202
|
+
const date = new Date(post.publishedAt * 1000);
|
|
203
|
+
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
204
|
+
if (!grouped.has(key)) {
|
|
205
|
+
grouped.set(key, []);
|
|
206
|
+
}
|
|
207
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Map.set() above guarantees key exists
|
|
208
|
+
grouped.get(key)!.push(post);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return c.html(
|
|
212
|
+
<BaseLayout title={`Archive - ${siteName}`} c={c}>
|
|
213
|
+
<ArchiveContent
|
|
214
|
+
displayPosts={displayPosts}
|
|
215
|
+
hasMore={hasMore}
|
|
216
|
+
nextCursor={nextCursor}
|
|
217
|
+
type={type}
|
|
218
|
+
grouped={grouped}
|
|
219
|
+
replyCounts={replyCounts}
|
|
220
|
+
/>
|
|
221
|
+
</BaseLayout>
|
|
222
|
+
);
|
|
223
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection Page Route
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { useLingui } from "../../i18n/index.js";
|
|
7
|
+
import type { Bindings, Collection, Post } from "../../types.js";
|
|
8
|
+
import type { AppVariables } from "../../app.js";
|
|
9
|
+
import { BaseLayout } from "../../theme/layouts/index.js";
|
|
10
|
+
import * as sqid from "../../lib/sqid.js";
|
|
11
|
+
import * as time from "../../lib/time.js";
|
|
12
|
+
|
|
13
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
14
|
+
|
|
15
|
+
export const collectionRoutes = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
function CollectionContent({ collection, posts }: { collection: Collection; posts: Post[] }) {
|
|
18
|
+
const { t } = useLingui();
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div class="container py-8">
|
|
22
|
+
<header class="mb-8">
|
|
23
|
+
<h1 class="text-2xl font-semibold">{collection.title}</h1>
|
|
24
|
+
{collection.description && (
|
|
25
|
+
<p class="text-muted-foreground mt-2">{collection.description}</p>
|
|
26
|
+
)}
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
<main class="flex flex-col gap-6">
|
|
30
|
+
{posts.length === 0 ? (
|
|
31
|
+
<p class="text-muted-foreground">{t({ message: "No posts in this collection.", comment: "@context: Empty state message" })}</p>
|
|
32
|
+
) : (
|
|
33
|
+
posts.map((post) => (
|
|
34
|
+
<article key={post.id} class="h-entry">
|
|
35
|
+
{post.title && (
|
|
36
|
+
<h2 class="p-name text-lg font-medium mb-2">
|
|
37
|
+
<a href={`/p/${sqid.encode(post.id)}`} class="u-url hover:underline">
|
|
38
|
+
{post.title}
|
|
39
|
+
</a>
|
|
40
|
+
</h2>
|
|
41
|
+
)}
|
|
42
|
+
<div
|
|
43
|
+
class="e-content prose prose-sm"
|
|
44
|
+
dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
|
|
45
|
+
/>
|
|
46
|
+
<footer class="mt-2 text-sm text-muted-foreground">
|
|
47
|
+
<time class="dt-published" datetime={time.toISOString(post.publishedAt)}>
|
|
48
|
+
{time.formatDate(post.publishedAt)}
|
|
49
|
+
</time>
|
|
50
|
+
</footer>
|
|
51
|
+
</article>
|
|
52
|
+
))
|
|
53
|
+
)}
|
|
54
|
+
</main>
|
|
55
|
+
|
|
56
|
+
<nav class="mt-8">
|
|
57
|
+
<a href="/" class="text-sm hover:underline">
|
|
58
|
+
{t({ message: "← Back to home", comment: "@context: Navigation link" })}
|
|
59
|
+
</a>
|
|
60
|
+
</nav>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
collectionRoutes.get("/:path", async (c) => {
|
|
66
|
+
const path = c.req.param("path");
|
|
67
|
+
|
|
68
|
+
const collection = await c.var.services.collections.getByPath(path);
|
|
69
|
+
if (!collection) return c.notFound();
|
|
70
|
+
|
|
71
|
+
const posts = await c.var.services.collections.getPosts(collection.id);
|
|
72
|
+
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
73
|
+
|
|
74
|
+
return c.html(
|
|
75
|
+
<BaseLayout title={`${collection.title} - ${siteName}`} description={collection.description ?? undefined} c={c}>
|
|
76
|
+
<CollectionContent collection={collection} posts={posts} />
|
|
77
|
+
</BaseLayout>
|
|
78
|
+
);
|
|
79
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home Page Route
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import { useLingui } from "../../i18n/index.js";
|
|
7
|
+
import type { Bindings, Post } from "../../types.js";
|
|
8
|
+
import type { AppVariables } from "../../app.js";
|
|
9
|
+
import { BaseLayout } from "../../theme/layouts/index.js";
|
|
10
|
+
import * as sqid from "../../lib/sqid.js";
|
|
11
|
+
import * as time from "../../lib/time.js";
|
|
12
|
+
|
|
13
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
14
|
+
|
|
15
|
+
export const homeRoutes = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
function HomeContent({ siteName, posts }: { siteName: string; posts: Post[] }) {
|
|
18
|
+
const { t } = useLingui();
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div class="container py-8">
|
|
22
|
+
<header class="mb-8 flex items-center justify-between">
|
|
23
|
+
<h1 class="text-2xl font-semibold">{siteName}</h1>
|
|
24
|
+
<nav class="flex items-center gap-4 text-sm">
|
|
25
|
+
<a href="/archive" class="text-muted-foreground hover:text-foreground">
|
|
26
|
+
{t({ message: "Archive", comment: "@context: Navigation link to archive page" })}
|
|
27
|
+
</a>
|
|
28
|
+
<a href="/feed" class="text-muted-foreground hover:text-foreground">
|
|
29
|
+
RSS
|
|
30
|
+
</a>
|
|
31
|
+
</nav>
|
|
32
|
+
</header>
|
|
33
|
+
|
|
34
|
+
<main class="flex flex-col gap-6">
|
|
35
|
+
{posts.length === 0 ? (
|
|
36
|
+
<p class="text-muted-foreground">{t({ message: "No posts yet.", comment: "@context: Empty state message on home page" })}</p>
|
|
37
|
+
) : (
|
|
38
|
+
posts.map((post) => (
|
|
39
|
+
<article key={post.id} class="h-entry">
|
|
40
|
+
{post.title && (
|
|
41
|
+
<h2 class="p-name text-lg font-medium mb-2">
|
|
42
|
+
<a href={`/p/${sqid.encode(post.id)}`} class="u-url hover:underline">
|
|
43
|
+
{post.title}
|
|
44
|
+
</a>
|
|
45
|
+
</h2>
|
|
46
|
+
)}
|
|
47
|
+
<div
|
|
48
|
+
class="e-content prose prose-sm"
|
|
49
|
+
dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
|
|
50
|
+
/>
|
|
51
|
+
<footer class="mt-2 text-sm text-muted-foreground">
|
|
52
|
+
<time class="dt-published" datetime={time.toISOString(post.publishedAt)}>
|
|
53
|
+
{time.formatDate(post.publishedAt)}
|
|
54
|
+
</time>
|
|
55
|
+
{post.visibility === "featured" && (
|
|
56
|
+
<span class="ml-2 text-xs">{t({ message: "Featured", comment: "@context: Post visibility badge" })}</span>
|
|
57
|
+
)}
|
|
58
|
+
</footer>
|
|
59
|
+
</article>
|
|
60
|
+
))
|
|
61
|
+
)}
|
|
62
|
+
</main>
|
|
63
|
+
|
|
64
|
+
{posts.length >= 20 && (
|
|
65
|
+
<nav class="mt-8 text-center">
|
|
66
|
+
<a href="/archive" class="text-sm text-muted-foreground hover:text-foreground">
|
|
67
|
+
{t({ message: "View all posts →", comment: "@context: Link to view all posts on archive page" })}
|
|
68
|
+
</a>
|
|
69
|
+
</nav>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
homeRoutes.get("/", async (c) => {
|
|
76
|
+
const isComplete = await c.var.services.settings.isOnboardingComplete();
|
|
77
|
+
if (!isComplete) {
|
|
78
|
+
return c.redirect("/setup");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
82
|
+
|
|
83
|
+
const posts = await c.var.services.posts.list({
|
|
84
|
+
visibility: ["featured", "quiet"],
|
|
85
|
+
limit: 20,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return c.html(
|
|
89
|
+
<BaseLayout title={siteName} c={c}>
|
|
90
|
+
<HomeContent siteName={siteName} posts={posts} />
|
|
91
|
+
</BaseLayout>
|
|
92
|
+
);
|
|
93
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Page Route
|
|
3
|
+
*
|
|
4
|
+
* Catch-all route for custom pages accessible via their path field
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { useLingui } from "../../i18n/index.js";
|
|
9
|
+
import type { Bindings, Post } from "../../types.js";
|
|
10
|
+
import type { AppVariables } from "../../app.js";
|
|
11
|
+
import { BaseLayout } from "../../theme/layouts/index.js";
|
|
12
|
+
|
|
13
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
14
|
+
|
|
15
|
+
export const pageRoutes = new Hono<Env>();
|
|
16
|
+
|
|
17
|
+
function PageContent({ page }: { page: Post }) {
|
|
18
|
+
const { t } = useLingui();
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div class="container py-8 max-w-2xl">
|
|
22
|
+
<article class="h-entry">
|
|
23
|
+
{page.title && <h1 class="p-name text-3xl font-semibold mb-6">{page.title}</h1>}
|
|
24
|
+
|
|
25
|
+
<div
|
|
26
|
+
class="e-content prose"
|
|
27
|
+
dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }}
|
|
28
|
+
/>
|
|
29
|
+
</article>
|
|
30
|
+
|
|
31
|
+
<nav class="mt-8 pt-6 border-t">
|
|
32
|
+
<a href="/" class="text-sm hover:underline">
|
|
33
|
+
← {t({ message: "Back to home", comment: "@context: Navigation link back to home page" })}
|
|
34
|
+
</a>
|
|
35
|
+
</nav>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Catch-all for custom page paths
|
|
41
|
+
pageRoutes.get("/:path", async (c) => {
|
|
42
|
+
const path = c.req.param("path");
|
|
43
|
+
|
|
44
|
+
// Look up page by path
|
|
45
|
+
const page = await c.var.services.posts.getByPath(path);
|
|
46
|
+
|
|
47
|
+
// Not found or not a page
|
|
48
|
+
if (!page || page.type !== "page") {
|
|
49
|
+
return c.notFound();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Don't show drafts
|
|
53
|
+
if (page.visibility === "draft") {
|
|
54
|
+
return c.notFound();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
|
|
58
|
+
|
|
59
|
+
return c.html(
|
|
60
|
+
<BaseLayout title={`${page.title} - ${siteName}`} description={page.content?.slice(0, 160)} c={c}>
|
|
61
|
+
<PageContent page={page} />
|
|
62
|
+
</BaseLayout>
|
|
63
|
+
);
|
|
64
|
+
});
|