@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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Danger Zone Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a section for destructive actions (like delete) with
|
|
5
|
+
* consistent styling and confirmation prompts
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FC, PropsWithChildren } from "hono/jsx";
|
|
9
|
+
import { useLingui } from "../../i18n/index.js";
|
|
10
|
+
|
|
11
|
+
export interface DangerZoneProps extends PropsWithChildren {
|
|
12
|
+
/**
|
|
13
|
+
* Title for the danger zone section
|
|
14
|
+
* @default "Danger Zone"
|
|
15
|
+
*/
|
|
16
|
+
title?: string;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Optional description or warning text
|
|
20
|
+
*/
|
|
21
|
+
description?: string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Label for the destructive action button
|
|
25
|
+
*/
|
|
26
|
+
actionLabel: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Form action URL for the destructive operation
|
|
30
|
+
*/
|
|
31
|
+
formAction: string;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Confirmation message to show before executing action
|
|
35
|
+
*/
|
|
36
|
+
confirmMessage?: string;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether the action button should be disabled
|
|
40
|
+
*/
|
|
41
|
+
disabled?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DangerZone: FC<DangerZoneProps> = ({
|
|
45
|
+
title,
|
|
46
|
+
description,
|
|
47
|
+
actionLabel,
|
|
48
|
+
formAction,
|
|
49
|
+
confirmMessage,
|
|
50
|
+
disabled = false,
|
|
51
|
+
children,
|
|
52
|
+
}) => {
|
|
53
|
+
const { t } = useLingui();
|
|
54
|
+
|
|
55
|
+
const defaultTitle = t({
|
|
56
|
+
message: "Danger Zone",
|
|
57
|
+
comment: "@context: Section heading for dangerous/destructive actions",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div class="mt-8 pt-8 border-t">
|
|
62
|
+
<h2 class="text-lg font-medium text-destructive mb-4">{title || defaultTitle}</h2>
|
|
63
|
+
{description && <p class="text-sm text-muted-foreground mb-4">{description}</p>}
|
|
64
|
+
{children}
|
|
65
|
+
<form method="post" action={formAction}>
|
|
66
|
+
<button
|
|
67
|
+
type="submit"
|
|
68
|
+
class="btn-destructive"
|
|
69
|
+
disabled={disabled}
|
|
70
|
+
onclick={confirmMessage ? `return confirm('${confirmMessage}')` : undefined}
|
|
71
|
+
>
|
|
72
|
+
{actionLabel}
|
|
73
|
+
</button>
|
|
74
|
+
</form>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Empty State Component
|
|
3
|
+
*
|
|
4
|
+
* Displays a message when a list or collection has no items,
|
|
5
|
+
* optionally with a call-to-action button
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FC } from "hono/jsx";
|
|
9
|
+
|
|
10
|
+
export interface EmptyStateProps {
|
|
11
|
+
/**
|
|
12
|
+
* Message to display when empty
|
|
13
|
+
*/
|
|
14
|
+
message: string;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Optional call-to-action button text
|
|
18
|
+
*/
|
|
19
|
+
ctaText?: string;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Optional call-to-action button href
|
|
23
|
+
*/
|
|
24
|
+
ctaHref?: string;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether to center the content with padding (default: true)
|
|
28
|
+
*/
|
|
29
|
+
centered?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const EmptyState: FC<EmptyStateProps> = ({
|
|
33
|
+
message,
|
|
34
|
+
ctaText,
|
|
35
|
+
ctaHref,
|
|
36
|
+
centered = true,
|
|
37
|
+
}) => {
|
|
38
|
+
if (!centered) {
|
|
39
|
+
return (
|
|
40
|
+
<p class="text-muted-foreground">
|
|
41
|
+
{message}
|
|
42
|
+
</p>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div class="text-center py-12 text-muted-foreground">
|
|
48
|
+
<p>{message}</p>
|
|
49
|
+
{ctaText && ctaHref && (
|
|
50
|
+
<a href={ctaHref} class="btn mt-4">
|
|
51
|
+
{ctaText}
|
|
52
|
+
</a>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List Item Row Component
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent layout for list items in dashboard CRUD pages.
|
|
5
|
+
* Handles responsive spacing, overflow, and action button placement.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FC, PropsWithChildren } from "hono/jsx";
|
|
9
|
+
|
|
10
|
+
export interface ListItemRowProps extends PropsWithChildren {
|
|
11
|
+
/**
|
|
12
|
+
* Action buttons to display on the right side
|
|
13
|
+
*/
|
|
14
|
+
actions?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ListItemRow: FC<ListItemRowProps> = ({ children, actions }) => {
|
|
18
|
+
return (
|
|
19
|
+
<div class="py-4 flex items-start gap-4">
|
|
20
|
+
<div class="flex-1 min-w-0">{children}</div>
|
|
21
|
+
{actions && <div class="flex items-center gap-2">{actions}</div>}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Creation/Edit Form
|
|
3
|
+
*
|
|
4
|
+
* For managing custom pages (posts with type="page")
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FC } from "hono/jsx";
|
|
8
|
+
import type { Post } from "../../types.js";
|
|
9
|
+
import { useLingui } from "../../i18n/index.js";
|
|
10
|
+
|
|
11
|
+
export interface PageFormProps {
|
|
12
|
+
page?: Post;
|
|
13
|
+
action: string;
|
|
14
|
+
cancelUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const PageForm: FC<PageFormProps> = ({
|
|
18
|
+
page,
|
|
19
|
+
action,
|
|
20
|
+
cancelUrl = "/dash/pages",
|
|
21
|
+
}) => {
|
|
22
|
+
const { t } = useLingui();
|
|
23
|
+
const isEdit = !!page;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<form method="post" action={action} class="flex flex-col gap-4">
|
|
27
|
+
{/* Hidden type field */}
|
|
28
|
+
<input type="hidden" name="type" value="page" />
|
|
29
|
+
|
|
30
|
+
{/* Title */}
|
|
31
|
+
<div class="field">
|
|
32
|
+
<label class="label">
|
|
33
|
+
{t({ message: "Title", comment: "@context: Page form field label - title" })}
|
|
34
|
+
</label>
|
|
35
|
+
<input
|
|
36
|
+
type="text"
|
|
37
|
+
name="title"
|
|
38
|
+
class="input"
|
|
39
|
+
placeholder={t({ message: "Page title...", comment: "@context: Page title placeholder" })}
|
|
40
|
+
value={page?.title ?? ""}
|
|
41
|
+
required
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Path */}
|
|
46
|
+
<div class="field">
|
|
47
|
+
<label class="label">
|
|
48
|
+
{t({ message: "Path", comment: "@context: Page form field label - URL path" })}
|
|
49
|
+
</label>
|
|
50
|
+
<div class="flex items-center gap-2">
|
|
51
|
+
<span class="text-muted-foreground">/</span>
|
|
52
|
+
<input
|
|
53
|
+
type="text"
|
|
54
|
+
name="path"
|
|
55
|
+
class="input flex-1"
|
|
56
|
+
placeholder="about"
|
|
57
|
+
value={page?.path ?? ""}
|
|
58
|
+
pattern="[a-z0-9\-]+"
|
|
59
|
+
title={t({ message: "Lowercase letters, numbers, and hyphens only", comment: "@context: Page path validation message" })}
|
|
60
|
+
required
|
|
61
|
+
/>
|
|
62
|
+
</div>
|
|
63
|
+
<p class="text-xs text-muted-foreground mt-1">
|
|
64
|
+
{t({ message: "The URL path for this page. Use lowercase letters, numbers, and hyphens.", comment: "@context: Page path helper text" })}
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Content */}
|
|
69
|
+
<div class="field">
|
|
70
|
+
<label class="label">
|
|
71
|
+
{t({ message: "Content", comment: "@context: Page form field label - content" })}
|
|
72
|
+
</label>
|
|
73
|
+
<textarea
|
|
74
|
+
name="content"
|
|
75
|
+
class="textarea min-h-48"
|
|
76
|
+
placeholder={t({ message: "Page content (Markdown supported)...", comment: "@context: Page content placeholder" })}
|
|
77
|
+
required
|
|
78
|
+
>
|
|
79
|
+
{page?.content ?? ""}
|
|
80
|
+
</textarea>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
{/* Visibility */}
|
|
84
|
+
<div class="field">
|
|
85
|
+
<label class="label">
|
|
86
|
+
{t({ message: "Status", comment: "@context: Page form field label - publish status" })}
|
|
87
|
+
</label>
|
|
88
|
+
<select name="visibility" class="select">
|
|
89
|
+
<option value="unlisted" selected={page?.visibility === "unlisted" || !page}>
|
|
90
|
+
{t({ message: "Published", comment: "@context: Page status option - published" })}
|
|
91
|
+
</option>
|
|
92
|
+
<option value="draft" selected={page?.visibility === "draft"}>
|
|
93
|
+
{t({ message: "Draft", comment: "@context: Page status option - draft" })}
|
|
94
|
+
</option>
|
|
95
|
+
</select>
|
|
96
|
+
<p class="text-xs text-muted-foreground mt-1">
|
|
97
|
+
{t({ message: "Published pages are accessible via their path. Drafts are not visible.", comment: "@context: Page status helper text" })}
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* Submit */}
|
|
102
|
+
<div class="flex gap-2">
|
|
103
|
+
<button type="submit" class="btn">
|
|
104
|
+
{isEdit
|
|
105
|
+
? t({ message: "Update Page", comment: "@context: Button to update existing page" })
|
|
106
|
+
: t({ message: "Create Page", comment: "@context: Button to create new page" })}
|
|
107
|
+
</button>
|
|
108
|
+
<a href={cancelUrl} class="btn-outline">
|
|
109
|
+
{t({ message: "Cancel", comment: "@context: Button to cancel and go back" })}
|
|
110
|
+
</a>
|
|
111
|
+
</div>
|
|
112
|
+
</form>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination Component
|
|
3
|
+
*
|
|
4
|
+
* Cursor-based pagination for post lists
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FC } from "hono/jsx";
|
|
8
|
+
import { useLingui } from "../../i18n/index.js";
|
|
9
|
+
|
|
10
|
+
export interface PaginationProps {
|
|
11
|
+
/** Base URL for pagination links (e.g., "/archive", "/search?q=test") */
|
|
12
|
+
baseUrl: string;
|
|
13
|
+
/** Whether there are more items after the current page */
|
|
14
|
+
hasMore: boolean;
|
|
15
|
+
/** Cursor for the next page (typically the last item's ID) */
|
|
16
|
+
nextCursor?: number | string;
|
|
17
|
+
/** Cursor for the previous page */
|
|
18
|
+
prevCursor?: number | string;
|
|
19
|
+
/** Parameter name for cursor (default: "cursor") */
|
|
20
|
+
cursorParam?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const Pagination: FC<PaginationProps> = ({
|
|
24
|
+
baseUrl,
|
|
25
|
+
hasMore,
|
|
26
|
+
nextCursor,
|
|
27
|
+
prevCursor,
|
|
28
|
+
cursorParam = "cursor",
|
|
29
|
+
}) => {
|
|
30
|
+
const { t } = useLingui();
|
|
31
|
+
const hasPrev = prevCursor !== undefined;
|
|
32
|
+
const hasNext = hasMore && nextCursor !== undefined;
|
|
33
|
+
|
|
34
|
+
if (!hasPrev && !hasNext) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Build URL with cursor parameter
|
|
39
|
+
const buildUrl = (cursor: number | string) => {
|
|
40
|
+
const url = new URL(baseUrl, "http://localhost");
|
|
41
|
+
url.searchParams.set(cursorParam, String(cursor));
|
|
42
|
+
return `${url.pathname}${url.search}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const prevText = t({ message: "Previous", comment: "@context: Pagination button - previous page" });
|
|
46
|
+
const nextText = t({ message: "Next", comment: "@context: Pagination button - next page" });
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<nav class="flex items-center justify-between py-4" aria-label="Pagination">
|
|
50
|
+
<div>
|
|
51
|
+
{hasPrev ? (
|
|
52
|
+
<a
|
|
53
|
+
href={buildUrl(prevCursor)}
|
|
54
|
+
class="btn-outline text-sm"
|
|
55
|
+
>
|
|
56
|
+
← {prevText}
|
|
57
|
+
</a>
|
|
58
|
+
) : (
|
|
59
|
+
<span class="btn-outline text-sm opacity-50 cursor-not-allowed">
|
|
60
|
+
← {prevText}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<div>
|
|
66
|
+
{hasNext ? (
|
|
67
|
+
<a
|
|
68
|
+
href={buildUrl(nextCursor)}
|
|
69
|
+
class="btn-outline text-sm"
|
|
70
|
+
>
|
|
71
|
+
{nextText} →
|
|
72
|
+
</a>
|
|
73
|
+
) : (
|
|
74
|
+
<span class="btn-outline text-sm opacity-50 cursor-not-allowed">
|
|
75
|
+
{nextText} →
|
|
76
|
+
</span>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
</nav>
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Simple "Load More" style pagination
|
|
85
|
+
*/
|
|
86
|
+
export interface LoadMoreProps {
|
|
87
|
+
/** URL for loading more items */
|
|
88
|
+
href: string;
|
|
89
|
+
/** Whether there are more items to load */
|
|
90
|
+
hasMore: boolean;
|
|
91
|
+
/** Button text */
|
|
92
|
+
text?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const LoadMore: FC<LoadMoreProps> = ({
|
|
96
|
+
href,
|
|
97
|
+
hasMore,
|
|
98
|
+
text,
|
|
99
|
+
}) => {
|
|
100
|
+
const { t } = useLingui();
|
|
101
|
+
if (!hasMore) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const buttonText = text ?? t({ message: "Load more", comment: "@context: Pagination button - load more items" });
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<div class="text-center py-4">
|
|
109
|
+
<a href={href} class="btn-outline">
|
|
110
|
+
{buttonText}
|
|
111
|
+
</a>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Page-based pagination (for search results etc.)
|
|
118
|
+
*/
|
|
119
|
+
export interface PagePaginationProps {
|
|
120
|
+
/** Base URL (query params will be added) */
|
|
121
|
+
baseUrl: string;
|
|
122
|
+
/** Current page (1-indexed) */
|
|
123
|
+
currentPage: number;
|
|
124
|
+
/** Whether there are more pages */
|
|
125
|
+
hasMore: boolean;
|
|
126
|
+
/** Page parameter name (default: "page") */
|
|
127
|
+
pageParam?: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const PagePagination: FC<PagePaginationProps> = ({
|
|
131
|
+
baseUrl,
|
|
132
|
+
currentPage,
|
|
133
|
+
hasMore,
|
|
134
|
+
pageParam = "page",
|
|
135
|
+
}) => {
|
|
136
|
+
const { t } = useLingui();
|
|
137
|
+
const hasPrev = currentPage > 1;
|
|
138
|
+
const hasNext = hasMore;
|
|
139
|
+
|
|
140
|
+
if (!hasPrev && !hasNext) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Build URL with page parameter
|
|
145
|
+
const buildUrl = (page: number) => {
|
|
146
|
+
const url = new URL(baseUrl, "http://localhost");
|
|
147
|
+
if (page > 1) {
|
|
148
|
+
url.searchParams.set(pageParam, String(page));
|
|
149
|
+
} else {
|
|
150
|
+
url.searchParams.delete(pageParam);
|
|
151
|
+
}
|
|
152
|
+
return `${url.pathname}${url.search}`;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const prevText = t({ message: "Previous", comment: "@context: Pagination button - previous page" });
|
|
156
|
+
const nextText = t({ message: "Next", comment: "@context: Pagination button - next page" });
|
|
157
|
+
const pageText = t({ message: "Page {page}", comment: "@context: Pagination - current page indicator", values: { page: String(currentPage) } });
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<nav class="flex items-center justify-between py-4" aria-label="Pagination">
|
|
161
|
+
<div>
|
|
162
|
+
{hasPrev ? (
|
|
163
|
+
<a
|
|
164
|
+
href={buildUrl(currentPage - 1)}
|
|
165
|
+
class="btn-outline text-sm"
|
|
166
|
+
>
|
|
167
|
+
← {prevText}
|
|
168
|
+
</a>
|
|
169
|
+
) : (
|
|
170
|
+
<span class="btn-outline text-sm opacity-50 cursor-not-allowed">
|
|
171
|
+
← {prevText}
|
|
172
|
+
</span>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<span class="text-sm text-muted-foreground">
|
|
177
|
+
{pageText}
|
|
178
|
+
</span>
|
|
179
|
+
|
|
180
|
+
<div>
|
|
181
|
+
{hasNext ? (
|
|
182
|
+
<a
|
|
183
|
+
href={buildUrl(currentPage + 1)}
|
|
184
|
+
class="btn-outline text-sm"
|
|
185
|
+
>
|
|
186
|
+
{nextText} →
|
|
187
|
+
</a>
|
|
188
|
+
) : (
|
|
189
|
+
<span class="btn-outline text-sm opacity-50 cursor-not-allowed">
|
|
190
|
+
{nextText} →
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
</nav>
|
|
195
|
+
);
|
|
196
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post Creation/Edit Form
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FC } from "hono/jsx";
|
|
6
|
+
import type { Post } from "../../types.js";
|
|
7
|
+
import { useLingui } from "../../i18n/index.js";
|
|
8
|
+
|
|
9
|
+
export interface PostFormProps {
|
|
10
|
+
post?: Post;
|
|
11
|
+
action: string;
|
|
12
|
+
method?: "get" | "post";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const PostForm: FC<PostFormProps> = ({ post, action, method = "post" }) => {
|
|
16
|
+
const { t } = useLingui();
|
|
17
|
+
const isEdit = !!post;
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<form method={method} action={action} class="flex flex-col gap-4">
|
|
21
|
+
{/* Type selector */}
|
|
22
|
+
<div class="field">
|
|
23
|
+
<label class="label">{t({ message: "Type", comment: "@context: Post form field - post type" })}</label>
|
|
24
|
+
<select name="type" class="select" required>
|
|
25
|
+
<option value="note" selected={post?.type === "note"}>
|
|
26
|
+
{t({ message: "Note", comment: "@context: Post type option" })}
|
|
27
|
+
</option>
|
|
28
|
+
<option value="article" selected={post?.type === "article"}>
|
|
29
|
+
{t({ message: "Article", comment: "@context: Post type option" })}
|
|
30
|
+
</option>
|
|
31
|
+
<option value="link" selected={post?.type === "link"}>
|
|
32
|
+
{t({ message: "Link", comment: "@context: Post type option" })}
|
|
33
|
+
</option>
|
|
34
|
+
<option value="quote" selected={post?.type === "quote"}>
|
|
35
|
+
{t({ message: "Quote", comment: "@context: Post type option" })}
|
|
36
|
+
</option>
|
|
37
|
+
<option value="image" selected={post?.type === "image"}>
|
|
38
|
+
{t({ message: "Image", comment: "@context: Post type option" })}
|
|
39
|
+
</option>
|
|
40
|
+
</select>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{/* Title (optional) */}
|
|
44
|
+
<div class="field">
|
|
45
|
+
<label class="label">{t({ message: "Title (optional)", comment: "@context: Post form field" })}</label>
|
|
46
|
+
<input
|
|
47
|
+
type="text"
|
|
48
|
+
name="title"
|
|
49
|
+
class="input"
|
|
50
|
+
placeholder={t({ message: "Post title...", comment: "@context: Post title placeholder" })}
|
|
51
|
+
value={post?.title ?? ""}
|
|
52
|
+
/>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Content */}
|
|
56
|
+
<div class="field">
|
|
57
|
+
<label class="label">{t({ message: "Content", comment: "@context: Post form field" })}</label>
|
|
58
|
+
<textarea
|
|
59
|
+
name="content"
|
|
60
|
+
class="textarea min-h-32"
|
|
61
|
+
placeholder={t({ message: "What's on your mind?", comment: "@context: Post content placeholder" })}
|
|
62
|
+
required
|
|
63
|
+
>
|
|
64
|
+
{post?.content ?? ""}
|
|
65
|
+
</textarea>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Source URL (for link/quote types) */}
|
|
69
|
+
<div class="field">
|
|
70
|
+
<label class="label">{t({ message: "Source URL (optional)", comment: "@context: Post form field" })}</label>
|
|
71
|
+
<input
|
|
72
|
+
type="url"
|
|
73
|
+
name="sourceUrl"
|
|
74
|
+
class="input"
|
|
75
|
+
placeholder="https://..."
|
|
76
|
+
value={post?.sourceUrl ?? ""}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Visibility */}
|
|
81
|
+
<div class="field">
|
|
82
|
+
<label class="label">{t({ message: "Visibility", comment: "@context: Post form field" })}</label>
|
|
83
|
+
<select name="visibility" class="select">
|
|
84
|
+
<option value="quiet" selected={post?.visibility === "quiet" || !post}>
|
|
85
|
+
{t({ message: "Quiet (normal)", comment: "@context: Post visibility option" })}
|
|
86
|
+
</option>
|
|
87
|
+
<option value="featured" selected={post?.visibility === "featured"}>
|
|
88
|
+
{t({ message: "Featured", comment: "@context: Post visibility option" })}
|
|
89
|
+
</option>
|
|
90
|
+
<option value="unlisted" selected={post?.visibility === "unlisted"}>
|
|
91
|
+
{t({ message: "Unlisted", comment: "@context: Post visibility option" })}
|
|
92
|
+
</option>
|
|
93
|
+
<option value="draft" selected={post?.visibility === "draft"}>
|
|
94
|
+
{t({ message: "Draft", comment: "@context: Post visibility option" })}
|
|
95
|
+
</option>
|
|
96
|
+
</select>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Custom path (optional) */}
|
|
100
|
+
<div class="field">
|
|
101
|
+
<label class="label">{t({ message: "Custom Path (optional)", comment: "@context: Post form field" })}</label>
|
|
102
|
+
<input
|
|
103
|
+
type="text"
|
|
104
|
+
name="path"
|
|
105
|
+
class="input"
|
|
106
|
+
placeholder="my-custom-url"
|
|
107
|
+
value={post?.path ?? ""}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Submit */}
|
|
112
|
+
<div class="flex gap-2">
|
|
113
|
+
<button type="submit" class="btn">
|
|
114
|
+
{isEdit ? t({ message: "Update", comment: "@context: Button to update existing post" }) : t({ message: "Publish", comment: "@context: Button to publish new post" })}
|
|
115
|
+
</button>
|
|
116
|
+
<a href="/dash/posts" class="btn-outline">
|
|
117
|
+
{t({ message: "Cancel", comment: "@context: Button to cancel form" })}
|
|
118
|
+
</a>
|
|
119
|
+
</div>
|
|
120
|
+
</form>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post List Component
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { FC } from "hono/jsx";
|
|
6
|
+
import { useLingui } from "../../i18n/index.js";
|
|
7
|
+
import type { Post } from "../../types.js";
|
|
8
|
+
import * as sqid from "../../lib/sqid.js";
|
|
9
|
+
import * as time from "../../lib/time.js";
|
|
10
|
+
import { VisibilityBadge } from "./VisibilityBadge.js";
|
|
11
|
+
import { TypeBadge } from "./TypeBadge.js";
|
|
12
|
+
import { EmptyState } from "./EmptyState.js";
|
|
13
|
+
import { ListItemRow } from "./ListItemRow.js";
|
|
14
|
+
import { ActionButtons } from "./ActionButtons.js";
|
|
15
|
+
|
|
16
|
+
export interface PostListProps {
|
|
17
|
+
posts: Post[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const PostList: FC<PostListProps> = ({ posts }) => {
|
|
21
|
+
const { t } = useLingui();
|
|
22
|
+
if (posts.length === 0) {
|
|
23
|
+
return (
|
|
24
|
+
<EmptyState
|
|
25
|
+
message={t({ message: "No posts yet.", comment: "@context: Empty state message when no posts exist" })}
|
|
26
|
+
ctaText={t({ message: "Create your first post", comment: "@context: Button in empty state to create first post" })}
|
|
27
|
+
ctaHref="/dash/posts/new"
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div class="flex flex-col divide-y">
|
|
34
|
+
{posts.map((post) => (
|
|
35
|
+
<ListItemRow
|
|
36
|
+
key={post.id}
|
|
37
|
+
actions={
|
|
38
|
+
<ActionButtons
|
|
39
|
+
editHref={`/dash/posts/${sqid.encode(post.id)}/edit`}
|
|
40
|
+
editLabel={t({ message: "Edit", comment: "@context: Button to edit post" })}
|
|
41
|
+
viewHref={`/p/${sqid.encode(post.id)}`}
|
|
42
|
+
viewLabel={t({ message: "View", comment: "@context: Button to view post on public site" })}
|
|
43
|
+
/>
|
|
44
|
+
}
|
|
45
|
+
>
|
|
46
|
+
<div class="flex items-center gap-2 mb-1">
|
|
47
|
+
<TypeBadge type={post.type} />
|
|
48
|
+
<VisibilityBadge visibility={post.visibility} />
|
|
49
|
+
<span class="text-xs text-muted-foreground">
|
|
50
|
+
{time.formatDate(post.publishedAt)}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
<a
|
|
54
|
+
href={`/dash/posts/${sqid.encode(post.id)}`}
|
|
55
|
+
class="font-medium hover:underline"
|
|
56
|
+
>
|
|
57
|
+
{post.title || post.content?.slice(0, 60) || t({ message: "Untitled", comment: "@context: Default title for untitled post" })}
|
|
58
|
+
</a>
|
|
59
|
+
{post.content && !post.title && (
|
|
60
|
+
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
|
61
|
+
{post.content.slice(0, 120)}
|
|
62
|
+
</p>
|
|
63
|
+
)}
|
|
64
|
+
</ListItemRow>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
};
|