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