@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,240 @@
1
+ /**
2
+ * Upload API Routes
3
+ *
4
+ * Handles file uploads to R2 storage.
5
+ * Supports both JSON and SSE (Datastar) responses.
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import { html } from "hono/html";
10
+ import type { Bindings } from "../../types.js";
11
+ import type { AppVariables } from "../../app.js";
12
+ import { requireAuthApi } from "../../middleware/auth.js";
13
+ import { getMediaUrl, getImageUrl } from "../../lib/image.js";
14
+ import { sse } from "../../lib/sse.js";
15
+
16
+ type Env = { Bindings: Bindings; Variables: AppVariables };
17
+
18
+ export const uploadApiRoutes = new Hono<Env>();
19
+
20
+ // Require auth for all upload routes
21
+ uploadApiRoutes.use("*", requireAuthApi());
22
+
23
+ /**
24
+ * Render a media card HTML string for SSE response
25
+ */
26
+ function renderMediaCard(
27
+ media: { id: string; r2Key: string; mimeType: string; originalName: string; alt: string | null; size: number },
28
+ r2PublicUrl?: string,
29
+ imageTransformUrl?: string
30
+ ): string {
31
+ const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
32
+ const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
33
+ width: 300,
34
+ quality: 80,
35
+ format: "auto",
36
+ fit: "cover",
37
+ });
38
+ const isImage = media.mimeType.startsWith("image/");
39
+ const displayName = media.alt || media.originalName;
40
+ const sizeStr = formatSize(media.size);
41
+
42
+ if (isImage) {
43
+ return html`
44
+ <div class="group relative" data-media-id="${media.id}">
45
+ <button
46
+ type="button"
47
+ class="block w-full aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary cursor-pointer"
48
+ data-on-click="$$lightboxSrc = '${fullUrl}'; document.getElementById('lightbox').showModal()"
49
+ >
50
+ <img
51
+ src="${thumbnailUrl}"
52
+ alt="${displayName}"
53
+ class="w-full h-full object-cover"
54
+ loading="lazy"
55
+ />
56
+ </button>
57
+ <a href="/dash/media/${media.id}" class="block mt-2 text-xs truncate hover:underline" title="${media.originalName}">
58
+ ${media.originalName}
59
+ </a>
60
+ <div class="text-xs text-muted-foreground">${sizeStr}</div>
61
+ </div>
62
+ `.toString();
63
+ }
64
+
65
+ return html`
66
+ <div class="group relative" data-media-id="${media.id}">
67
+ <a
68
+ href="/dash/media/${media.id}"
69
+ class="block aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary"
70
+ >
71
+ <div class="w-full h-full flex items-center justify-center text-muted-foreground">
72
+ <span class="text-xs">${media.mimeType}</span>
73
+ </div>
74
+ </a>
75
+ <a href="/dash/media/${media.id}" class="block mt-2 text-xs truncate hover:underline" title="${media.originalName}">
76
+ ${media.originalName}
77
+ </a>
78
+ <div class="text-xs text-muted-foreground">${sizeStr}</div>
79
+ </div>
80
+ `.toString();
81
+ }
82
+
83
+ function formatSize(bytes: number): string {
84
+ if (bytes < 1024) return `${bytes} B`;
85
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
86
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
87
+ }
88
+
89
+ /**
90
+ * Check if request wants SSE response (from Datastar)
91
+ */
92
+ function wantsSSE(c: { req: { header: (name: string) => string | undefined } }): boolean {
93
+ const accept = c.req.header("accept") || "";
94
+ return accept.includes("text/event-stream");
95
+ }
96
+
97
+ // Upload a file
98
+ uploadApiRoutes.post("/", async (c) => {
99
+ if (!c.env.R2) {
100
+ if (wantsSSE(c)) {
101
+ return sse(c, async (stream) => {
102
+ await stream.patchSignals({ _uploadError: "R2 storage not configured" });
103
+ });
104
+ }
105
+ return c.json({ error: "R2 storage not configured" }, 500);
106
+ }
107
+
108
+ const formData = await c.req.formData();
109
+ const file = formData.get("file") as File | null;
110
+
111
+ if (!file) {
112
+ if (wantsSSE(c)) {
113
+ return sse(c, async (stream) => {
114
+ await stream.patchSignals({ _uploadError: "No file provided" });
115
+ });
116
+ }
117
+ return c.json({ error: "No file provided" }, 400);
118
+ }
119
+
120
+ // Validate file type
121
+ const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"];
122
+ if (!allowedTypes.includes(file.type)) {
123
+ if (wantsSSE(c)) {
124
+ return sse(c, async (stream) => {
125
+ await stream.patchSignals({ _uploadError: "File type not allowed" });
126
+ });
127
+ }
128
+ return c.json({ error: "File type not allowed" }, 400);
129
+ }
130
+
131
+ // Validate file size (max 10MB)
132
+ const maxSize = 10 * 1024 * 1024;
133
+ if (file.size > maxSize) {
134
+ if (wantsSSE(c)) {
135
+ return sse(c, async (stream) => {
136
+ await stream.patchSignals({ _uploadError: "File too large (max 10MB)" });
137
+ });
138
+ }
139
+ return c.json({ error: "File too large (max 10MB)" }, 400);
140
+ }
141
+
142
+ // Generate unique filename
143
+ const ext = file.name.split(".").pop() || "bin";
144
+ const timestamp = Date.now();
145
+ const random = Math.random().toString(36).substring(2, 8);
146
+ const filename = `${timestamp}-${random}.${ext}`;
147
+ const r2Key = `uploads/${filename}`;
148
+
149
+ try {
150
+ // Upload to R2
151
+ await c.env.R2.put(r2Key, file.stream(), {
152
+ httpMetadata: {
153
+ contentType: file.type,
154
+ },
155
+ });
156
+
157
+ // Save to database
158
+ const media = await c.var.services.media.create({
159
+ filename,
160
+ originalName: file.name,
161
+ mimeType: file.type,
162
+ size: file.size,
163
+ r2Key,
164
+ });
165
+
166
+ // SSE response for Datastar
167
+ if (wantsSSE(c)) {
168
+ const cardHtml = renderMediaCard(
169
+ media,
170
+ c.env.R2_PUBLIC_URL,
171
+ c.env.IMAGE_TRANSFORM_URL
172
+ );
173
+
174
+ return sse(c, async (stream) => {
175
+ // Replace placeholder with real media card
176
+ await stream.patchElements(cardHtml, {
177
+ mode: "outer",
178
+ selector: "#upload-placeholder",
179
+ });
180
+ });
181
+ }
182
+
183
+ // JSON response for API clients
184
+ const publicUrl = getMediaUrl(media.id, r2Key, c.env.R2_PUBLIC_URL);
185
+ return c.json({
186
+ id: media.id,
187
+ filename: media.filename,
188
+ url: publicUrl,
189
+ mimeType: media.mimeType,
190
+ size: media.size,
191
+ });
192
+ } catch (err) {
193
+ // eslint-disable-next-line no-console -- Error logging is intentional
194
+ console.error("Upload error:", err);
195
+
196
+ // Return error - client will handle updating the placeholder
197
+ return c.json({ error: "Upload failed" }, 500);
198
+ }
199
+ });
200
+
201
+ // List uploaded files (JSON only)
202
+ uploadApiRoutes.get("/", async (c) => {
203
+ const limit = parseInt(c.req.query("limit") ?? "50", 10);
204
+ const mediaList = await c.var.services.media.list(limit);
205
+
206
+ return c.json({
207
+ media: mediaList.map((m) => ({
208
+ id: m.id,
209
+ filename: m.filename,
210
+ url: getMediaUrl(m.id, m.r2Key, c.env.R2_PUBLIC_URL),
211
+ mimeType: m.mimeType,
212
+ size: m.size,
213
+ createdAt: m.createdAt,
214
+ })),
215
+ });
216
+ });
217
+
218
+ // Delete a file
219
+ uploadApiRoutes.delete("/:id", async (c) => {
220
+ const id = c.req.param("id");
221
+ const media = await c.var.services.media.getById(id);
222
+ if (!media) {
223
+ return c.json({ error: "Not found" }, 404);
224
+ }
225
+
226
+ // Delete from R2
227
+ if (c.env.R2) {
228
+ try {
229
+ await c.env.R2.delete(media.r2Key);
230
+ } catch (err) {
231
+ // eslint-disable-next-line no-console -- Error logging is intentional
232
+ console.error("R2 delete error:", err);
233
+ }
234
+ }
235
+
236
+ // Delete from database
237
+ await c.var.services.media.delete(id);
238
+
239
+ return c.json({ success: true });
240
+ });
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Dashboard Collections Routes
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 { DashLayout } from "../../theme/layouts/index.js";
10
+ import { EmptyState, ListItemRow, ActionButtons, CrudPageHeader, DangerZone } from "../../theme/components/index.js";
11
+ import * as sqid from "../../lib/sqid.js";
12
+
13
+ type Env = { Bindings: Bindings; Variables: AppVariables };
14
+
15
+ export const collectionsRoutes = new Hono<Env>();
16
+
17
+ function CollectionsListContent({ collections }: { collections: Collection[] }) {
18
+ const { t } = useLingui();
19
+
20
+ return (
21
+ <>
22
+ <CrudPageHeader
23
+ title={t({ message: "Collections", comment: "@context: Dashboard heading" })}
24
+ ctaLabel={t({ message: "New Collection", comment: "@context: Button to create new collection" })}
25
+ ctaHref="/dash/collections/new"
26
+ />
27
+
28
+ {collections.length === 0 ? (
29
+ <EmptyState
30
+ message={t({ message: "No collections yet.", comment: "@context: Empty state message" })}
31
+ ctaText={t({ message: "New Collection", comment: "@context: Button to create new collection" })}
32
+ ctaHref="/dash/collections/new"
33
+ />
34
+ ) : (
35
+ <div class="flex flex-col divide-y">
36
+ {collections.map((col) => (
37
+ <ListItemRow
38
+ key={col.id}
39
+ actions={
40
+ <ActionButtons
41
+ editHref={`/dash/collections/${col.id}/edit`}
42
+ editLabel={t({ message: "Edit", comment: "@context: Button to edit collection" })}
43
+ viewHref={`/c/${col.path}`}
44
+ viewLabel={t({ message: "View", comment: "@context: Button to view collection" })}
45
+ />
46
+ }
47
+ >
48
+ <a href={`/dash/collections/${col.id}`} class="font-medium hover:underline">
49
+ {col.title}
50
+ </a>
51
+ <p class="text-sm text-muted-foreground">/{col.path}</p>
52
+ {col.description && (
53
+ <p class="text-sm text-muted-foreground mt-1">{col.description}</p>
54
+ )}
55
+ </ListItemRow>
56
+ ))}
57
+ </div>
58
+ )}
59
+ </>
60
+ );
61
+ }
62
+
63
+ function NewCollectionContent() {
64
+ const { t } = useLingui();
65
+ return (
66
+ <>
67
+ <h1 class="text-2xl font-semibold mb-6">{t({ message: "New Collection", comment: "@context: Page heading" })}</h1>
68
+
69
+ <form method="post" action="/dash/collections" class="flex flex-col gap-4 max-w-lg">
70
+ <div class="field">
71
+ <label class="label">{t({ message: "Title", comment: "@context: Collection form field" })}</label>
72
+ <input type="text" name="title" class="input" required placeholder={t({ message: "My Collection", comment: "@context: Collection title placeholder" })} />
73
+ </div>
74
+
75
+ <div class="field">
76
+ <label class="label">{t({ message: "Slug", comment: "@context: Collection form field" })}</label>
77
+ <input
78
+ type="text"
79
+ name="path"
80
+ class="input"
81
+ required
82
+ placeholder="my-collection"
83
+ pattern="[a-z0-9-]+"
84
+ />
85
+ <p class="text-xs text-muted-foreground mt-1">
86
+ {t({ message: "URL-safe identifier (lowercase, numbers, hyphens)", comment: "@context: Collection path help text" })}
87
+ </p>
88
+ </div>
89
+
90
+ <div class="field">
91
+ <label class="label">{t({ message: "Description (optional)", comment: "@context: Collection form field" })}</label>
92
+ <textarea name="description" class="textarea" rows={3} placeholder={t({ message: "What's this collection about?", comment: "@context: Collection description placeholder" })} />
93
+ </div>
94
+
95
+ <div class="flex gap-2">
96
+ <button type="submit" class="btn">
97
+ {t({ message: "Create Collection", comment: "@context: Button to save new collection" })}
98
+ </button>
99
+ <a href="/dash/collections" class="btn-outline">
100
+ {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
101
+ </a>
102
+ </div>
103
+ </form>
104
+ </>
105
+ );
106
+ }
107
+
108
+ function ViewCollectionContent({ collection, posts }: { collection: Collection; posts: Post[] }) {
109
+ const { t } = useLingui();
110
+ const postsHeader = t({ message: "Posts in Collection ({count})", comment: "@context: Collection posts section heading", values: { count: String(posts.length) } });
111
+
112
+ return (
113
+ <>
114
+ <div class="flex items-center justify-between mb-6">
115
+ <div>
116
+ <h1 class="text-2xl font-semibold">{collection.title}</h1>
117
+ <p class="text-sm text-muted-foreground">/{collection.path}</p>
118
+ </div>
119
+ <ActionButtons
120
+ editHref={`/dash/collections/${collection.id}/edit`}
121
+ editLabel={t({ message: "Edit", comment: "@context: Button to edit collection" })}
122
+ viewHref={`/c/${collection.path}`}
123
+ viewLabel={t({ message: "View", comment: "@context: Button to view collection" })}
124
+ />
125
+ </div>
126
+
127
+ {collection.description && (
128
+ <p class="text-muted-foreground mb-6">{collection.description}</p>
129
+ )}
130
+
131
+ <div class="card">
132
+ <header>
133
+ <h2>{postsHeader}</h2>
134
+ </header>
135
+ <section>
136
+ {posts.length === 0 ? (
137
+ <p class="text-muted-foreground">{t({ message: "No posts in this collection.", comment: "@context: Empty state message" })}</p>
138
+ ) : (
139
+ <div class="flex flex-col divide-y">
140
+ {posts.map((post) => (
141
+ <div key={post.id} class="py-3 flex items-center gap-4">
142
+ <div class="flex-1 min-w-0">
143
+ <a
144
+ href={`/dash/posts/${sqid.encode(post.id)}`}
145
+ class="font-medium hover:underline"
146
+ >
147
+ {post.title || post.content?.slice(0, 50) || `Post #${post.id}`}
148
+ </a>
149
+ </div>
150
+ <form method="post" action={`/dash/collections/${collection.id}/remove-post`}>
151
+ <input type="hidden" name="postId" value={post.id} />
152
+ <button type="submit" class="btn-sm-ghost text-destructive">
153
+ {t({ message: "Remove", comment: "@context: Button to remove post from collection" })}
154
+ </button>
155
+ </form>
156
+ </div>
157
+ ))}
158
+ </div>
159
+ )}
160
+ </section>
161
+ </div>
162
+
163
+ <div class="mt-6">
164
+ <a href="/dash/collections" class="text-sm hover:underline">
165
+ {t({ message: "← Back to Collections", comment: "@context: Navigation link" })}
166
+ </a>
167
+ </div>
168
+ </>
169
+ );
170
+ }
171
+
172
+ function EditCollectionContent({ collection }: { collection: Collection }) {
173
+ const { t } = useLingui();
174
+
175
+ return (
176
+ <>
177
+ <h1 class="text-2xl font-semibold mb-6">{t({ message: "Edit Collection", comment: "@context: Page heading" })}</h1>
178
+
179
+ <form method="post" action={`/dash/collections/${collection.id}`} class="flex flex-col gap-4 max-w-lg">
180
+ <div class="field">
181
+ <label class="label">{t({ message: "Title", comment: "@context: Collection form field" })}</label>
182
+ <input type="text" name="title" class="input" required value={collection.title} />
183
+ </div>
184
+
185
+ <div class="field">
186
+ <label class="label">{t({ message: "Slug", comment: "@context: Collection form field" })}</label>
187
+ <input
188
+ type="text"
189
+ name="path"
190
+ class="input"
191
+ required
192
+ value={collection.path ?? ""}
193
+ pattern="[a-z0-9-]+"
194
+ />
195
+ </div>
196
+
197
+ <div class="field">
198
+ <label class="label">{t({ message: "Description (optional)", comment: "@context: Collection form field" })}</label>
199
+ <textarea name="description" class="textarea" rows={3}>
200
+ {collection.description ?? ""}
201
+ </textarea>
202
+ </div>
203
+
204
+ <div class="flex gap-2">
205
+ <button type="submit" class="btn">
206
+ {t({ message: "Update Collection", comment: "@context: Button to save collection changes" })}
207
+ </button>
208
+ <a href={`/dash/collections/${collection.id}`} class="btn-outline">
209
+ {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
210
+ </a>
211
+ </div>
212
+ </form>
213
+
214
+ <DangerZone
215
+ actionLabel={t({ message: "Delete Collection", comment: "@context: Button to delete collection" })}
216
+ formAction={`/dash/collections/${collection.id}/delete`}
217
+ confirmMessage="Are you sure you want to delete this collection?"
218
+ />
219
+ </>
220
+ );
221
+ }
222
+
223
+ // List collections
224
+ collectionsRoutes.get("/", async (c) => {
225
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
226
+ const collections = await c.var.services.collections.list();
227
+
228
+ return c.html(
229
+ <DashLayout c={c} title="Collections" siteName={siteName} currentPath="/dash/collections">
230
+ <CollectionsListContent collections={collections} />
231
+ </DashLayout>
232
+ );
233
+ });
234
+
235
+ // New collection form
236
+ collectionsRoutes.get("/new", async (c) => {
237
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
238
+
239
+ return c.html(
240
+ <DashLayout c={c} title="New Collection" siteName={siteName} currentPath="/dash/collections">
241
+ <NewCollectionContent />
242
+ </DashLayout>
243
+ );
244
+ });
245
+
246
+ // Create collection
247
+ collectionsRoutes.post("/", async (c) => {
248
+ const formData = await c.req.formData();
249
+
250
+ const title = formData.get("title") as string;
251
+ const path = formData.get("path") as string;
252
+ const description = formData.get("description") as string;
253
+
254
+ const collection = await c.var.services.collections.create({
255
+ title,
256
+ path,
257
+ description: description || undefined,
258
+ });
259
+
260
+ return c.redirect(`/dash/collections/${collection.id}`);
261
+ });
262
+
263
+ // View single collection
264
+ collectionsRoutes.get("/:id", async (c) => {
265
+ const id = parseInt(c.req.param("id"), 10);
266
+ if (isNaN(id)) return c.notFound();
267
+
268
+ const collection = await c.var.services.collections.getById(id);
269
+ if (!collection) return c.notFound();
270
+
271
+ const posts = await c.var.services.collections.getPosts(id);
272
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
273
+
274
+ return c.html(
275
+ <DashLayout c={c} title={collection.title} siteName={siteName} currentPath="/dash/collections">
276
+ <ViewCollectionContent collection={collection} posts={posts} />
277
+ </DashLayout>
278
+ );
279
+ });
280
+
281
+ // Edit collection form
282
+ collectionsRoutes.get("/:id/edit", async (c) => {
283
+ const id = parseInt(c.req.param("id"), 10);
284
+ if (isNaN(id)) return c.notFound();
285
+
286
+ const collection = await c.var.services.collections.getById(id);
287
+ if (!collection) return c.notFound();
288
+
289
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
290
+
291
+ return c.html(
292
+ <DashLayout c={c} title={`Edit: ${collection.title}`} siteName={siteName} currentPath="/dash/collections">
293
+ <EditCollectionContent collection={collection} />
294
+ </DashLayout>
295
+ );
296
+ });
297
+
298
+ // Update collection
299
+ collectionsRoutes.post("/:id", async (c) => {
300
+ const id = parseInt(c.req.param("id"), 10);
301
+ if (isNaN(id)) return c.notFound();
302
+
303
+ const formData = await c.req.formData();
304
+
305
+ const title = formData.get("title") as string;
306
+ const path = formData.get("path") as string;
307
+ const description = formData.get("description") as string;
308
+
309
+ await c.var.services.collections.update(id, {
310
+ title,
311
+ path,
312
+ description: description || undefined,
313
+ });
314
+
315
+ return c.redirect(`/dash/collections/${id}`);
316
+ });
317
+
318
+ // Delete collection
319
+ collectionsRoutes.post("/:id/delete", async (c) => {
320
+ const id = parseInt(c.req.param("id"), 10);
321
+ if (isNaN(id)) return c.notFound();
322
+
323
+ await c.var.services.collections.delete(id);
324
+
325
+ return c.redirect("/dash/collections");
326
+ });
327
+
328
+ // Remove post from collection
329
+ collectionsRoutes.post("/:id/remove-post", async (c) => {
330
+ const id = parseInt(c.req.param("id"), 10);
331
+ if (isNaN(id)) return c.notFound();
332
+
333
+ const formData = await c.req.formData();
334
+ const postId = parseInt(formData.get("postId") as string, 10);
335
+
336
+ if (!isNaN(postId)) {
337
+ await c.var.services.collections.removePost(id, postId);
338
+ }
339
+
340
+ return c.redirect(`/dash/collections/${id}`);
341
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Dashboard Index Route
3
+ *
4
+ * Example of using @lingui/react/macro with Hono JSX!
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import { Trans, useLingui } from "../../i18n/index.js";
9
+ import type { Bindings } from "../../types.js";
10
+ import type { AppVariables } from "../../app.js";
11
+ import { DashLayout } from "../../theme/layouts/index.js";
12
+
13
+ type Env = { Bindings: Bindings; Variables: AppVariables };
14
+
15
+ export const dashIndexRoutes = new Hono<Env>();
16
+
17
+ /**
18
+ * Dashboard content component
19
+ * Uses useLingui() from @lingui/react/macro - works with Hono JSX!
20
+ */
21
+ function DashboardContent({
22
+ publishedCount,
23
+ draftCount,
24
+ }: {
25
+ publishedCount: number;
26
+ draftCount: number;
27
+ }) {
28
+ // 🎉 Single layer! Just like React!
29
+ const { t } = useLingui();
30
+
31
+ return (
32
+ <div class="container py-8">
33
+ <h1 class="text-2xl font-semibold mb-6">
34
+ {/* ✅ No more nesting! */}
35
+ {t({ message: "Dashboard", comment: "@context: Dashboard main heading" })}
36
+ </h1>
37
+
38
+ <div class="grid gap-4 md:grid-cols-3 mb-6">
39
+ <div class="p-4 border rounded">
40
+ <p class="text-sm text-muted-foreground">
41
+ {t({ message: "Published", comment: "@context: Post status label" })}
42
+ </p>
43
+ <p class="text-3xl font-bold">{publishedCount}</p>
44
+ </div>
45
+
46
+ <div class="p-4 border rounded">
47
+ <p class="text-sm text-muted-foreground">
48
+ {t({ message: "Drafts", comment: "@context: Post status label" })}
49
+ </p>
50
+ <p class="text-3xl font-bold">{draftCount}</p>
51
+ </div>
52
+
53
+ <div class="p-4 border rounded">
54
+ <p class="text-sm text-muted-foreground mb-2">
55
+ {t({ message: "Quick Actions", comment: "@context: Dashboard section title" })}
56
+ </p>
57
+ <a href="/dash/posts/new" class="btn btn-primary w-full">
58
+ {t({ message: "New Post", comment: "@context: Button to create new post" })}
59
+ </a>
60
+ </div>
61
+ </div>
62
+
63
+ {/* ✅ Trans component with embedded JSX! */}
64
+ <p>
65
+ <Trans comment="@context: Help text with link">
66
+ Need help? Visit the <a href="/docs" class="underline">documentation</a>
67
+ </Trans>
68
+ </p>
69
+ </div>
70
+ );
71
+ }
72
+
73
+ dashIndexRoutes.get("/", async (c) => {
74
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
75
+
76
+ // Get some stats
77
+ const allPosts = await c.var.services.posts.list({ limit: 1000 });
78
+ const publishedPosts = allPosts.filter((p) => p.visibility !== "draft");
79
+ const draftPosts = allPosts.filter((p) => p.visibility === "draft");
80
+
81
+ return c.html(
82
+ <DashLayout c={c} title="Dashboard" siteName={siteName} currentPath="/dash">
83
+ <DashboardContent
84
+ publishedCount={publishedPosts.length}
85
+ draftCount={draftPosts.length}
86
+ />
87
+ </DashLayout>
88
+ );
89
+ });