@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,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
|
+
});
|