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