@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.
Files changed (93) hide show
  1. package/bin/jant.js +188 -0
  2. package/drizzle.config.ts +10 -0
  3. package/lingui.config.ts +16 -0
  4. package/package.json +116 -0
  5. package/src/app.tsx +377 -0
  6. package/src/assets/datastar.min.js +8 -0
  7. package/src/auth.ts +38 -0
  8. package/src/client.ts +6 -0
  9. package/src/db/index.ts +14 -0
  10. package/src/db/migrations/0000_solid_moon_knight.sql +118 -0
  11. package/src/db/migrations/0001_add_search_fts.sql +40 -0
  12. package/src/db/migrations/0002_collection_path.sql +2 -0
  13. package/src/db/migrations/0003_collection_path_nullable.sql +21 -0
  14. package/src/db/migrations/0004_media_uuid.sql +35 -0
  15. package/src/db/migrations/meta/0000_snapshot.json +784 -0
  16. package/src/db/migrations/meta/_journal.json +41 -0
  17. package/src/db/schema.ts +159 -0
  18. package/src/i18n/EXAMPLES.md +235 -0
  19. package/src/i18n/README.md +296 -0
  20. package/src/i18n/Trans.tsx +31 -0
  21. package/src/i18n/context.tsx +101 -0
  22. package/src/i18n/detect.ts +100 -0
  23. package/src/i18n/i18n.ts +62 -0
  24. package/src/i18n/index.ts +65 -0
  25. package/src/i18n/locales/en.po +875 -0
  26. package/src/i18n/locales/en.ts +1 -0
  27. package/src/i18n/locales/zh-Hans.po +875 -0
  28. package/src/i18n/locales/zh-Hans.ts +1 -0
  29. package/src/i18n/locales/zh-Hant.po +875 -0
  30. package/src/i18n/locales/zh-Hant.ts +1 -0
  31. package/src/i18n/locales.ts +14 -0
  32. package/src/i18n/middleware.ts +59 -0
  33. package/src/index.ts +42 -0
  34. package/src/lib/assets.ts +47 -0
  35. package/src/lib/constants.ts +67 -0
  36. package/src/lib/image.ts +107 -0
  37. package/src/lib/index.ts +9 -0
  38. package/src/lib/markdown.ts +93 -0
  39. package/src/lib/schemas.ts +92 -0
  40. package/src/lib/sqid.ts +79 -0
  41. package/src/lib/sse.ts +152 -0
  42. package/src/lib/time.ts +117 -0
  43. package/src/lib/url.ts +107 -0
  44. package/src/middleware/auth.ts +59 -0
  45. package/src/routes/api/posts.ts +127 -0
  46. package/src/routes/api/search.ts +53 -0
  47. package/src/routes/api/upload.ts +240 -0
  48. package/src/routes/dash/collections.tsx +341 -0
  49. package/src/routes/dash/index.tsx +89 -0
  50. package/src/routes/dash/media.tsx +551 -0
  51. package/src/routes/dash/pages.tsx +245 -0
  52. package/src/routes/dash/posts.tsx +202 -0
  53. package/src/routes/dash/redirects.tsx +155 -0
  54. package/src/routes/dash/settings.tsx +93 -0
  55. package/src/routes/feed/rss.ts +119 -0
  56. package/src/routes/feed/sitemap.ts +75 -0
  57. package/src/routes/pages/archive.tsx +223 -0
  58. package/src/routes/pages/collection.tsx +79 -0
  59. package/src/routes/pages/home.tsx +93 -0
  60. package/src/routes/pages/page.tsx +64 -0
  61. package/src/routes/pages/post.tsx +81 -0
  62. package/src/routes/pages/search.tsx +162 -0
  63. package/src/services/collection.ts +180 -0
  64. package/src/services/index.ts +40 -0
  65. package/src/services/media.ts +97 -0
  66. package/src/services/post.ts +279 -0
  67. package/src/services/redirect.ts +74 -0
  68. package/src/services/search.ts +117 -0
  69. package/src/services/settings.ts +76 -0
  70. package/src/theme/components/ActionButtons.tsx +98 -0
  71. package/src/theme/components/CrudPageHeader.tsx +48 -0
  72. package/src/theme/components/DangerZone.tsx +77 -0
  73. package/src/theme/components/EmptyState.tsx +56 -0
  74. package/src/theme/components/ListItemRow.tsx +24 -0
  75. package/src/theme/components/PageForm.tsx +114 -0
  76. package/src/theme/components/Pagination.tsx +196 -0
  77. package/src/theme/components/PostForm.tsx +122 -0
  78. package/src/theme/components/PostList.tsx +68 -0
  79. package/src/theme/components/ThreadView.tsx +118 -0
  80. package/src/theme/components/TypeBadge.tsx +28 -0
  81. package/src/theme/components/VisibilityBadge.tsx +33 -0
  82. package/src/theme/components/index.ts +12 -0
  83. package/src/theme/index.ts +24 -0
  84. package/src/theme/layouts/BaseLayout.tsx +49 -0
  85. package/src/theme/layouts/DashLayout.tsx +108 -0
  86. package/src/theme/layouts/index.ts +2 -0
  87. package/src/theme/styles/main.css +52 -0
  88. package/src/types.ts +222 -0
  89. package/static/assets/datastar.min.js +7 -0
  90. package/static/assets/image-processor.js +234 -0
  91. package/tsconfig.json +16 -0
  92. package/vite.config.ts +82 -0
  93. package/wrangler.toml +21 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Dashboard Pages Routes
3
+ *
4
+ * Management for custom pages (posts with type="page")
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 { DashLayout } from "../../theme/layouts/index.js";
12
+ import { PageForm, VisibilityBadge, EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../theme/components/index.js";
13
+ import * as sqid from "../../lib/sqid.js";
14
+ import * as time from "../../lib/time.js";
15
+ import { VisibilitySchema, parseFormData } from "../../lib/schemas.js";
16
+
17
+ type Env = { Bindings: Bindings; Variables: AppVariables };
18
+
19
+ export const pagesRoutes = new Hono<Env>();
20
+
21
+ function PagesListContent({ pages }: { pages: Post[] }) {
22
+ const { t } = useLingui();
23
+
24
+ return (
25
+ <>
26
+ <CrudPageHeader
27
+ title={t({ message: "Pages", comment: "@context: Pages main heading" })}
28
+ ctaLabel={t({ message: "New Page", comment: "@context: Button to create new page" })}
29
+ ctaHref="/dash/pages/new"
30
+ />
31
+
32
+ {pages.length === 0 ? (
33
+ <EmptyState
34
+ message={t({ message: "No pages yet.", comment: "@context: Empty state message when no pages exist" })}
35
+ ctaText={t({ message: "Create your first page", comment: "@context: Button in empty state to create first page" })}
36
+ ctaHref="/dash/pages/new"
37
+ />
38
+ ) : (
39
+ <div class="flex flex-col divide-y">
40
+ {pages.map((page) => (
41
+ <ListItemRow
42
+ key={page.id}
43
+ actions={
44
+ <ActionButtons
45
+ editHref={`/dash/pages/${sqid.encode(page.id)}/edit`}
46
+ editLabel={t({ message: "Edit", comment: "@context: Button to edit page" })}
47
+ viewHref={page.visibility !== "draft" && page.path ? `/${page.path}` : undefined}
48
+ viewLabel={t({ message: "View", comment: "@context: Button to view page on public site" })}
49
+ />
50
+ }
51
+ >
52
+ <div class="flex items-center gap-2 mb-1">
53
+ <VisibilityBadge visibility={page.visibility} />
54
+ <span class="text-xs text-muted-foreground">
55
+ {time.formatDate(page.updatedAt)}
56
+ </span>
57
+ </div>
58
+ <a
59
+ href={`/dash/pages/${sqid.encode(page.id)}`}
60
+ class="font-medium hover:underline"
61
+ >
62
+ {page.title || t({ message: "Untitled", comment: "@context: Default title for untitled page" })}
63
+ </a>
64
+ <p class="text-sm text-muted-foreground mt-1">
65
+ /{page.path}
66
+ </p>
67
+ </ListItemRow>
68
+ ))}
69
+ </div>
70
+ )}
71
+ </>
72
+ );
73
+ }
74
+
75
+ function NewPageContent() {
76
+ const { t } = useLingui();
77
+ return (
78
+ <>
79
+ <h1 class="text-2xl font-semibold mb-6">
80
+ {t({ message: "New Page", comment: "@context: New page main heading" })}
81
+ </h1>
82
+ <PageForm action="/dash/pages" />
83
+ </>
84
+ );
85
+ }
86
+
87
+ function ViewPageContent({ page }: { page: Post }) {
88
+ const { t } = useLingui();
89
+ return (
90
+ <>
91
+ <div class="flex items-center justify-between mb-6">
92
+ <div>
93
+ <h1 class="text-2xl font-semibold">{page.title || t({ message: "Page", comment: "@context: Default page heading when untitled" })}</h1>
94
+ {page.path && (
95
+ <p class="text-muted-foreground mt-1">/{page.path}</p>
96
+ )}
97
+ </div>
98
+ <ActionButtons
99
+ editHref={`/dash/pages/${sqid.encode(page.id)}/edit`}
100
+ editLabel={t({ message: "Edit", comment: "@context: Button to edit page" })}
101
+ viewHref={page.visibility !== "draft" && page.path ? `/${page.path}` : undefined}
102
+ viewLabel={t({ message: "View", comment: "@context: Button to view page on public site" })}
103
+ />
104
+ </div>
105
+
106
+ <div class="card">
107
+ <section>
108
+ <div class="prose" dangerouslySetInnerHTML={{ __html: page.contentHtml || "" }} />
109
+ </section>
110
+ </div>
111
+
112
+ <DangerZone
113
+ actionLabel={t({ message: "Delete Page", comment: "@context: Button to delete page" })}
114
+ formAction={`/dash/pages/${sqid.encode(page.id)}/delete`}
115
+ confirmMessage="Are you sure you want to delete this page?"
116
+ />
117
+ </>
118
+ );
119
+ }
120
+
121
+ function EditPageContent({ page }: { page: Post }) {
122
+ const { t } = useLingui();
123
+ return (
124
+ <>
125
+ <h1 class="text-2xl font-semibold mb-6">
126
+ {t({ message: "Edit Page", comment: "@context: Edit page main heading" })}
127
+ </h1>
128
+ <PageForm page={page} action={`/dash/pages/${sqid.encode(page.id)}`} />
129
+ </>
130
+ );
131
+ }
132
+
133
+ // List pages
134
+ pagesRoutes.get("/", async (c) => {
135
+ const pages = await c.var.services.posts.list({
136
+ type: "page",
137
+ visibility: ["unlisted", "draft"],
138
+ limit: 100,
139
+ });
140
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
141
+
142
+ return c.html(
143
+ <DashLayout c={c} title="Pages" siteName={siteName} currentPath="/dash/pages">
144
+ <PagesListContent pages={pages} />
145
+ </DashLayout>
146
+ );
147
+ });
148
+
149
+ // New page form
150
+ pagesRoutes.get("/new", async (c) => {
151
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
152
+
153
+ return c.html(
154
+ <DashLayout c={c} title="New Page" siteName={siteName} currentPath="/dash/pages">
155
+ <NewPageContent />
156
+ </DashLayout>
157
+ );
158
+ });
159
+
160
+ // Create page
161
+ pagesRoutes.post("/", async (c) => {
162
+ const formData = await c.req.formData();
163
+
164
+ const title = formData.get("title") as string;
165
+ const content = formData.get("content") as string;
166
+ const visibility = parseFormData(formData, "visibility", VisibilitySchema);
167
+ const path = formData.get("path") as string;
168
+
169
+ const page = await c.var.services.posts.create({
170
+ type: "page",
171
+ title,
172
+ content,
173
+ visibility,
174
+ path: path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
175
+ });
176
+
177
+ return c.redirect(`/dash/pages/${sqid.encode(page.id)}`);
178
+ });
179
+
180
+ // View single page
181
+ pagesRoutes.get("/:id", async (c) => {
182
+ const id = sqid.decode(c.req.param("id"));
183
+ if (!id) return c.notFound();
184
+
185
+ const page = await c.var.services.posts.getById(id);
186
+ if (!page || page.type !== "page") return c.notFound();
187
+
188
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
189
+
190
+ return c.html(
191
+ <DashLayout c={c} title={page.title || "Page"} siteName={siteName} currentPath="/dash/pages">
192
+ <ViewPageContent page={page} />
193
+ </DashLayout>
194
+ );
195
+ });
196
+
197
+ // Edit page form
198
+ pagesRoutes.get("/:id/edit", async (c) => {
199
+ const id = sqid.decode(c.req.param("id"));
200
+ if (!id) return c.notFound();
201
+
202
+ const page = await c.var.services.posts.getById(id);
203
+ if (!page || page.type !== "page") return c.notFound();
204
+
205
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
206
+
207
+ return c.html(
208
+ <DashLayout c={c} title={`Edit: ${page.title || "Page"}`} siteName={siteName} currentPath="/dash/pages">
209
+ <EditPageContent page={page} />
210
+ </DashLayout>
211
+ );
212
+ });
213
+
214
+ // Update page
215
+ pagesRoutes.post("/:id", async (c) => {
216
+ const id = sqid.decode(c.req.param("id"));
217
+ if (!id) return c.notFound();
218
+
219
+ const formData = await c.req.formData();
220
+
221
+ const title = formData.get("title") as string;
222
+ const content = formData.get("content") as string;
223
+ const visibility = parseFormData(formData, "visibility", VisibilitySchema);
224
+ const path = formData.get("path") as string;
225
+
226
+ await c.var.services.posts.update(id, {
227
+ type: "page",
228
+ title,
229
+ content,
230
+ visibility,
231
+ path: path.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
232
+ });
233
+
234
+ return c.redirect(`/dash/pages/${sqid.encode(id)}`);
235
+ });
236
+
237
+ // Delete page
238
+ pagesRoutes.post("/:id/delete", async (c) => {
239
+ const id = sqid.decode(c.req.param("id"));
240
+ if (!id) return c.notFound();
241
+
242
+ await c.var.services.posts.delete(id);
243
+
244
+ return c.redirect("/dash/pages");
245
+ });
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Dashboard Posts Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { z } from "zod";
7
+ import { useLingui } from "../../i18n/index.js";
8
+ import type { Bindings, Post } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { DashLayout } from "../../theme/layouts/index.js";
11
+ import { PostForm, PostList, CrudPageHeader, ActionButtons } from "../../theme/components/index.js";
12
+ import * as sqid from "../../lib/sqid.js";
13
+ import {
14
+ PostTypeSchema,
15
+ VisibilitySchema,
16
+ parseFormData,
17
+ parseFormDataOptional,
18
+ } from "../../lib/schemas.js";
19
+
20
+ type Env = { Bindings: Bindings; Variables: AppVariables };
21
+
22
+ export const postsRoutes = new Hono<Env>();
23
+
24
+ function PostsListContent({ posts }: { posts: Post[] }) {
25
+ const { t } = useLingui();
26
+ return (
27
+ <>
28
+ <CrudPageHeader
29
+ title={t({ message: "Posts", comment: "@context: Dashboard heading" })}
30
+ ctaLabel={t({ message: "New Post", comment: "@context: Button to create new post" })}
31
+ ctaHref="/dash/posts/new"
32
+ />
33
+ <PostList posts={posts} />
34
+ </>
35
+ );
36
+ }
37
+
38
+ function NewPostContent() {
39
+ const { t } = useLingui();
40
+ return (
41
+ <>
42
+ <h1 class="text-2xl font-semibold mb-6">{t({ message: "New Post", comment: "@context: Page heading" })}</h1>
43
+ <PostForm action="/dash/posts" />
44
+ </>
45
+ );
46
+ }
47
+
48
+ // List posts
49
+ postsRoutes.get("/", async (c) => {
50
+ const posts = await c.var.services.posts.list({
51
+ visibility: ["featured", "quiet", "unlisted", "draft"],
52
+ });
53
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
54
+
55
+ return c.html(
56
+ <DashLayout c={c} title="Posts" siteName={siteName} currentPath="/dash/posts">
57
+ <PostsListContent posts={posts} />
58
+ </DashLayout>
59
+ );
60
+ });
61
+
62
+ // New post form
63
+ postsRoutes.get("/new", async (c) => {
64
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
65
+
66
+ return c.html(
67
+ <DashLayout c={c} title="New Post" siteName={siteName} currentPath="/dash/posts">
68
+ <NewPostContent />
69
+ </DashLayout>
70
+ );
71
+ });
72
+
73
+ // Create post
74
+ postsRoutes.post("/", async (c) => {
75
+ const formData = await c.req.formData();
76
+
77
+ // Validate and parse form data
78
+ const type = parseFormData(formData, "type", PostTypeSchema);
79
+ const visibility = parseFormData(formData, "visibility", VisibilitySchema);
80
+ const title = parseFormDataOptional(formData, "title", z.string());
81
+ const content = formData.get("content") as string;
82
+ const sourceUrl = parseFormDataOptional(formData, "sourceUrl", z.string());
83
+ const path = parseFormDataOptional(formData, "path", z.string());
84
+
85
+ const post = await c.var.services.posts.create({
86
+ type,
87
+ title,
88
+ content,
89
+ visibility,
90
+ sourceUrl,
91
+ path,
92
+ });
93
+
94
+ return c.redirect(`/dash/posts/${sqid.encode(post.id)}`);
95
+ });
96
+
97
+ function ViewPostContent({ post }: { post: Post }) {
98
+ const { t } = useLingui();
99
+ const defaultTitle = t({ message: "Post", comment: "@context: Default post title" });
100
+
101
+ return (
102
+ <>
103
+ <div class="flex items-center justify-between mb-6">
104
+ <h1 class="text-2xl font-semibold">{post.title || defaultTitle}</h1>
105
+ <ActionButtons
106
+ editHref={`/dash/posts/${sqid.encode(post.id)}/edit`}
107
+ editLabel={t({ message: "Edit", comment: "@context: Button to edit post" })}
108
+ viewHref={`/p/${sqid.encode(post.id)}`}
109
+ viewLabel={t({ message: "View", comment: "@context: Button to view post" })}
110
+ />
111
+ </div>
112
+
113
+ <div class="card">
114
+ <section>
115
+ <div class="prose" dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }} />
116
+ </section>
117
+ </div>
118
+ </>
119
+ );
120
+ }
121
+
122
+ function EditPostContent({ post }: { post: Post }) {
123
+ const { t } = useLingui();
124
+ return (
125
+ <>
126
+ <h1 class="text-2xl font-semibold mb-6">{t({ message: "Edit Post", comment: "@context: Page heading" })}</h1>
127
+ <PostForm post={post} action={`/dash/posts/${sqid.encode(post.id)}`} />
128
+ </>
129
+ );
130
+ }
131
+
132
+ // View single post
133
+ postsRoutes.get("/:id", async (c) => {
134
+ const id = sqid.decode(c.req.param("id"));
135
+ if (!id) return c.notFound();
136
+
137
+ const post = await c.var.services.posts.getById(id);
138
+ if (!post) return c.notFound();
139
+
140
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
141
+ const pageTitle = post.title || "Post";
142
+
143
+ return c.html(
144
+ <DashLayout c={c} title={pageTitle} siteName={siteName} currentPath="/dash/posts">
145
+ <ViewPostContent post={post} />
146
+ </DashLayout>
147
+ );
148
+ });
149
+
150
+ // Edit post form
151
+ postsRoutes.get("/:id/edit", async (c) => {
152
+ const id = sqid.decode(c.req.param("id"));
153
+ if (!id) return c.notFound();
154
+
155
+ const post = await c.var.services.posts.getById(id);
156
+ if (!post) return c.notFound();
157
+
158
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
159
+
160
+ return c.html(
161
+ <DashLayout c={c} title={`Edit: ${post.title || "Post"}`} siteName={siteName} currentPath="/dash/posts">
162
+ <EditPostContent post={post} />
163
+ </DashLayout>
164
+ );
165
+ });
166
+
167
+ // Update post
168
+ postsRoutes.post("/:id", async (c) => {
169
+ const id = sqid.decode(c.req.param("id"));
170
+ if (!id) return c.notFound();
171
+
172
+ const formData = await c.req.formData();
173
+
174
+ // Validate and parse form data
175
+ const type = parseFormData(formData, "type", PostTypeSchema);
176
+ const visibility = parseFormData(formData, "visibility", VisibilitySchema);
177
+ const title = parseFormDataOptional(formData, "title", z.string()) || null;
178
+ const content = parseFormDataOptional(formData, "content", z.string()) || null;
179
+ const sourceUrl = parseFormDataOptional(formData, "sourceUrl", z.string()) || null;
180
+ const path = parseFormDataOptional(formData, "path", z.string()) || null;
181
+
182
+ await c.var.services.posts.update(id, {
183
+ type,
184
+ title,
185
+ content,
186
+ visibility,
187
+ sourceUrl,
188
+ path,
189
+ });
190
+
191
+ return c.redirect(`/dash/posts/${sqid.encode(id)}`);
192
+ });
193
+
194
+ // Delete post
195
+ postsRoutes.post("/:id/delete", async (c) => {
196
+ const id = sqid.decode(c.req.param("id"));
197
+ if (!id) return c.notFound();
198
+
199
+ await c.var.services.posts.delete(id);
200
+
201
+ return c.redirect("/dash/posts");
202
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Dashboard Redirects Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "../../i18n/index.js";
7
+ import type { Bindings, Redirect } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import { DashLayout } from "../../theme/layouts/index.js";
10
+ import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader } from "../../theme/components/index.js";
11
+
12
+ type Env = { Bindings: Bindings; Variables: AppVariables };
13
+
14
+ export const redirectsRoutes = new Hono<Env>();
15
+
16
+ function RedirectsListContent({ redirects }: { redirects: Redirect[] }) {
17
+ const { t } = useLingui();
18
+
19
+ return (
20
+ <>
21
+ <CrudPageHeader
22
+ title={t({ message: "Redirects", comment: "@context: Dashboard heading" })}
23
+ ctaLabel={t({ message: "New Redirect", comment: "@context: Button to create new redirect" })}
24
+ ctaHref="/dash/redirects/new"
25
+ />
26
+
27
+ {redirects.length === 0 ? (
28
+ <EmptyState
29
+ message={t({ message: "No redirects configured.", comment: "@context: Empty state message" })}
30
+ ctaText={t({ message: "New Redirect", comment: "@context: Button to create new redirect" })}
31
+ ctaHref="/dash/redirects/new"
32
+ />
33
+ ) : (
34
+ <div class="flex flex-col divide-y">
35
+ {redirects.map((r) => (
36
+ <ListItemRow
37
+ key={r.id}
38
+ actions={
39
+ <ActionButtons
40
+ deleteAction={`/dash/redirects/${r.id}/delete`}
41
+ deleteLabel={t({ message: "Delete", comment: "@context: Button to delete redirect" })}
42
+ />
43
+ }
44
+ >
45
+ <div class="flex items-center gap-2">
46
+ <code class="text-sm bg-muted px-1 rounded">{r.fromPath}</code>
47
+ <span class="text-muted-foreground">→</span>
48
+ <code class="text-sm bg-muted px-1 rounded">{r.toPath}</code>
49
+ <span class="badge-outline">{r.type}</span>
50
+ </div>
51
+ </ListItemRow>
52
+ ))}
53
+ </div>
54
+ )}
55
+ </>
56
+ );
57
+ }
58
+
59
+ function NewRedirectContent() {
60
+ const { t } = useLingui();
61
+
62
+ return (
63
+ <>
64
+ <h1 class="text-2xl font-semibold mb-6">{t({ message: "New Redirect", comment: "@context: Page heading" })}</h1>
65
+
66
+ <form method="post" action="/dash/redirects" class="flex flex-col gap-4 max-w-lg">
67
+ <div class="field">
68
+ <label class="label">{t({ message: "From Path", comment: "@context: Redirect form field" })}</label>
69
+ <input
70
+ type="text"
71
+ name="fromPath"
72
+ class="input"
73
+ placeholder="/old-path"
74
+ required
75
+ />
76
+ <p class="text-xs text-muted-foreground mt-1">{t({ message: "The path to redirect from", comment: "@context: Redirect from path help text" })}</p>
77
+ </div>
78
+
79
+ <div class="field">
80
+ <label class="label">{t({ message: "To Path", comment: "@context: Redirect form field" })}</label>
81
+ <input
82
+ type="text"
83
+ name="toPath"
84
+ class="input"
85
+ placeholder="/new-path or https://..."
86
+ required
87
+ />
88
+ <p class="text-xs text-muted-foreground mt-1">{t({ message: "The destination path or URL", comment: "@context: Redirect to path help text" })}</p>
89
+ </div>
90
+
91
+ <div class="field">
92
+ <label class="label">{t({ message: "Type", comment: "@context: Redirect form field" })}</label>
93
+ <select name="type" class="select">
94
+ <option value="301">{t({ message: "301 (Permanent)", comment: "@context: Redirect type option" })}</option>
95
+ <option value="302">{t({ message: "302 (Temporary)", comment: "@context: Redirect type option" })}</option>
96
+ </select>
97
+ </div>
98
+
99
+ <div class="flex gap-2">
100
+ <button type="submit" class="btn">
101
+ {t({ message: "Create Redirect", comment: "@context: Button to save new redirect" })}
102
+ </button>
103
+ <a href="/dash/redirects" class="btn-outline">
104
+ {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
105
+ </a>
106
+ </div>
107
+ </form>
108
+ </>
109
+ );
110
+ }
111
+
112
+ // List redirects
113
+ redirectsRoutes.get("/", async (c) => {
114
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
115
+ const redirects = await c.var.services.redirects.list();
116
+
117
+ return c.html(
118
+ <DashLayout c={c} title="Redirects" siteName={siteName} currentPath="/dash/redirects">
119
+ <RedirectsListContent redirects={redirects} />
120
+ </DashLayout>
121
+ );
122
+ });
123
+
124
+ // New redirect form
125
+ redirectsRoutes.get("/new", async (c) => {
126
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
127
+
128
+ return c.html(
129
+ <DashLayout c={c} title="New Redirect" siteName={siteName} currentPath="/dash/redirects">
130
+ <NewRedirectContent />
131
+ </DashLayout>
132
+ );
133
+ });
134
+
135
+ // Create redirect
136
+ redirectsRoutes.post("/", async (c) => {
137
+ const formData = await c.req.formData();
138
+
139
+ const fromPath = formData.get("fromPath") as string;
140
+ const toPath = formData.get("toPath") as string;
141
+ const type = parseInt(formData.get("type") as string, 10) as 301 | 302;
142
+
143
+ await c.var.services.redirects.create(fromPath, toPath, type);
144
+
145
+ return c.redirect("/dash/redirects");
146
+ });
147
+
148
+ // Delete redirect
149
+ redirectsRoutes.post("/:id/delete", async (c) => {
150
+ const id = parseInt(c.req.param("id"), 10);
151
+ if (!isNaN(id)) {
152
+ await c.var.services.redirects.delete(id);
153
+ }
154
+ return c.redirect("/dash/redirects");
155
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Dashboard Settings Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "../../i18n/index.js";
7
+ import type { Bindings } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import { DashLayout } from "../../theme/layouts/index.js";
10
+
11
+ type Env = { Bindings: Bindings; Variables: AppVariables };
12
+
13
+ export const settingsRoutes = new Hono<Env>();
14
+
15
+ function SettingsContent({ siteName, siteDescription, siteLanguage }: { siteName: string; siteDescription: string; siteLanguage: string }) {
16
+ const { t } = useLingui();
17
+
18
+ return (
19
+ <>
20
+ <h1 class="text-2xl font-semibold mb-6">{t({ message: "Settings", comment: "@context: Dashboard heading" })}</h1>
21
+
22
+ <form method="post" action="/dash/settings" class="flex flex-col gap-6 max-w-lg">
23
+ <div class="card">
24
+ <header>
25
+ <h2>{t({ message: "General", comment: "@context: Settings section heading" })}</h2>
26
+ </header>
27
+ <section class="flex flex-col gap-4">
28
+ <div class="field">
29
+ <label class="label">{t({ message: "Site Name", comment: "@context: Settings form field" })}</label>
30
+ <input type="text" name="siteName" class="input" value={siteName} required />
31
+ </div>
32
+
33
+ <div class="field">
34
+ <label class="label">{t({ message: "Site Description", comment: "@context: Settings form field" })}</label>
35
+ <textarea name="siteDescription" class="textarea" rows={3}>
36
+ {siteDescription}
37
+ </textarea>
38
+ </div>
39
+
40
+ <div class="field">
41
+ <label class="label">{t({ message: "Language", comment: "@context: Settings form field" })}</label>
42
+ <select name="siteLanguage" class="select">
43
+ <option value="en" selected={siteLanguage === "en"}>
44
+ English
45
+ </option>
46
+ <option value="zh-Hans" selected={siteLanguage === "zh-Hans"}>
47
+ 简体中文
48
+ </option>
49
+ <option value="zh-Hant" selected={siteLanguage === "zh-Hant"}>
50
+ 繁體中文
51
+ </option>
52
+ </select>
53
+ </div>
54
+ </section>
55
+ </div>
56
+
57
+ <button type="submit" class="btn">
58
+ {t({ message: "Save Settings", comment: "@context: Button to save settings" })}
59
+ </button>
60
+ </form>
61
+ </>
62
+ );
63
+ }
64
+
65
+ // Settings page
66
+ settingsRoutes.get("/", async (c) => {
67
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
68
+ const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
69
+ const siteLanguage = (await c.var.services.settings.get("SITE_LANGUAGE")) ?? "en";
70
+
71
+ return c.html(
72
+ <DashLayout c={c} title="Settings" siteName={siteName} currentPath="/dash/settings">
73
+ <SettingsContent siteName={siteName} siteDescription={siteDescription} siteLanguage={siteLanguage} />
74
+ </DashLayout>
75
+ );
76
+ });
77
+
78
+ // Update settings
79
+ settingsRoutes.post("/", async (c) => {
80
+ const formData = await c.req.formData();
81
+
82
+ const siteName = formData.get("siteName") as string;
83
+ const siteDescription = formData.get("siteDescription") as string;
84
+ const siteLanguage = formData.get("siteLanguage") as string;
85
+
86
+ await c.var.services.settings.setMany({
87
+ SITE_NAME: siteName,
88
+ SITE_DESCRIPTION: siteDescription,
89
+ SITE_LANGUAGE: siteLanguage,
90
+ });
91
+
92
+ return c.redirect("/dash/settings?saved=1");
93
+ });