@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,279 @@
1
+ /**
2
+ * Post Service
3
+ *
4
+ * CRUD operations for posts with Thread support
5
+ */
6
+
7
+ import { eq, and, isNull, desc, or, inArray, sql } from "drizzle-orm";
8
+ import type { Database } from "../db/index.js";
9
+ import { posts } from "../db/schema.js";
10
+ import { now } from "../lib/time.js";
11
+ import { extractDomain } from "../lib/url.js";
12
+ import { render as renderMarkdown } from "../lib/markdown.js";
13
+ import type { PostType, Visibility, Post, CreatePost, UpdatePost } from "../types.js";
14
+
15
+ export interface PostFilters {
16
+ type?: PostType;
17
+ visibility?: Visibility | Visibility[];
18
+ includeDeleted?: boolean;
19
+ threadId?: number;
20
+ /** Exclude posts that are replies (have threadId set) */
21
+ excludeReplies?: boolean;
22
+ limit?: number;
23
+ cursor?: number; // post id for cursor pagination
24
+ }
25
+
26
+ export interface PostService {
27
+ getById(id: number): Promise<Post | null>;
28
+ getByPath(path: string): Promise<Post | null>;
29
+ list(filters?: PostFilters): Promise<Post[]>;
30
+ create(data: CreatePost): Promise<Post>;
31
+ update(id: number, data: UpdatePost): Promise<Post | null>;
32
+ delete(id: number): Promise<boolean>;
33
+ getThread(rootId: number): Promise<Post[]>;
34
+ updateThreadVisibility(rootId: number, visibility: Visibility): Promise<void>;
35
+ /** Get reply counts for multiple posts */
36
+ getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
37
+ }
38
+
39
+ export function createPostService(db: Database): PostService {
40
+ // Helper to map DB row to Post type
41
+ function toPost(row: typeof posts.$inferSelect): Post {
42
+ return {
43
+ id: row.id,
44
+ type: row.type as PostType,
45
+ visibility: row.visibility as Visibility,
46
+ title: row.title,
47
+ path: row.path,
48
+ content: row.content,
49
+ contentHtml: row.contentHtml,
50
+ sourceUrl: row.sourceUrl,
51
+ sourceName: row.sourceName,
52
+ sourceDomain: row.sourceDomain,
53
+ replyToId: row.replyToId,
54
+ threadId: row.threadId,
55
+ deletedAt: row.deletedAt,
56
+ publishedAt: row.publishedAt,
57
+ createdAt: row.createdAt,
58
+ updatedAt: row.updatedAt,
59
+ };
60
+ }
61
+
62
+ return {
63
+ async getById(id) {
64
+ const result = await db
65
+ .select()
66
+ .from(posts)
67
+ .where(and(eq(posts.id, id), isNull(posts.deletedAt)))
68
+ .limit(1);
69
+ return result[0] ? toPost(result[0]) : null;
70
+ },
71
+
72
+ async getByPath(path) {
73
+ const result = await db
74
+ .select()
75
+ .from(posts)
76
+ .where(and(eq(posts.path, path), isNull(posts.deletedAt)))
77
+ .limit(1);
78
+ return result[0] ? toPost(result[0]) : null;
79
+ },
80
+
81
+ async list(filters = {}) {
82
+ const conditions = [];
83
+
84
+ // Visibility filter
85
+ if (filters.visibility) {
86
+ if (Array.isArray(filters.visibility)) {
87
+ conditions.push(inArray(posts.visibility, filters.visibility));
88
+ } else {
89
+ conditions.push(eq(posts.visibility, filters.visibility));
90
+ }
91
+ }
92
+
93
+ // Type filter
94
+ if (filters.type) {
95
+ conditions.push(eq(posts.type, filters.type));
96
+ }
97
+
98
+ // Thread filter
99
+ if (filters.threadId) {
100
+ conditions.push(eq(posts.threadId, filters.threadId));
101
+ }
102
+
103
+ // Exclude replies (posts that are part of a thread but not the root)
104
+ if (filters.excludeReplies) {
105
+ conditions.push(isNull(posts.threadId));
106
+ }
107
+
108
+ // Exclude deleted unless specified
109
+ if (!filters.includeDeleted) {
110
+ conditions.push(isNull(posts.deletedAt));
111
+ }
112
+
113
+ // Cursor pagination
114
+ if (filters.cursor) {
115
+ conditions.push(sql`${posts.id} < ${filters.cursor}`);
116
+ }
117
+
118
+ const query = db
119
+ .select()
120
+ .from(posts)
121
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
122
+ .orderBy(desc(posts.publishedAt), desc(posts.id))
123
+ .limit(filters.limit ?? 100);
124
+
125
+ const rows = await query;
126
+ return rows.map(toPost);
127
+ },
128
+
129
+ async create(data) {
130
+ const timestamp = now();
131
+
132
+ // Process content
133
+ const contentHtml = data.content ? renderMarkdown(data.content) : null;
134
+
135
+ // Extract domain from source URL
136
+ const sourceDomain = data.sourceUrl ? extractDomain(data.sourceUrl) : null;
137
+
138
+ // Handle thread relationship
139
+ let threadId: number | null = null;
140
+ let visibility = data.visibility ?? "quiet";
141
+
142
+ if (data.replyToId) {
143
+ const parent = await this.getById(data.replyToId);
144
+ if (parent) {
145
+ // thread_id = parent's thread_id or parent's id (if parent is root)
146
+ threadId = parent.threadId ?? parent.id;
147
+ // Inherit visibility from root
148
+ const root = parent.threadId ? await this.getById(parent.threadId) : parent;
149
+ if (root) {
150
+ visibility = root.visibility;
151
+ }
152
+ }
153
+ }
154
+
155
+ const result = await db
156
+ .insert(posts)
157
+ .values({
158
+ type: data.type,
159
+ visibility,
160
+ title: data.title ?? null,
161
+ path: data.path ?? null,
162
+ content: data.content ?? null,
163
+ contentHtml,
164
+ sourceUrl: data.sourceUrl ?? null,
165
+ sourceName: data.sourceName ?? null,
166
+ sourceDomain,
167
+ replyToId: data.replyToId ?? null,
168
+ threadId,
169
+ publishedAt: data.publishedAt ?? timestamp,
170
+ createdAt: timestamp,
171
+ updatedAt: timestamp,
172
+ })
173
+ .returning();
174
+
175
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
176
+ return toPost(result[0]!);
177
+ },
178
+
179
+ async update(id, data) {
180
+ const existing = await this.getById(id);
181
+ if (!existing) return null;
182
+
183
+ const timestamp = now();
184
+ const updates: Partial<typeof posts.$inferInsert> = { updatedAt: timestamp };
185
+
186
+ if (data.type !== undefined) updates.type = data.type;
187
+ if (data.title !== undefined) updates.title = data.title;
188
+ if (data.path !== undefined) updates.path = data.path;
189
+ if (data.publishedAt !== undefined) updates.publishedAt = data.publishedAt;
190
+ if (data.sourceUrl !== undefined) {
191
+ updates.sourceUrl = data.sourceUrl;
192
+ updates.sourceDomain = data.sourceUrl ? extractDomain(data.sourceUrl) : null;
193
+ }
194
+ if (data.sourceName !== undefined) updates.sourceName = data.sourceName;
195
+
196
+ if (data.content !== undefined) {
197
+ updates.content = data.content;
198
+ updates.contentHtml = data.content ? renderMarkdown(data.content) : null;
199
+ }
200
+
201
+ // Handle visibility change - cascade to thread if this is root
202
+ if (data.visibility !== undefined && data.visibility !== existing.visibility) {
203
+ updates.visibility = data.visibility;
204
+ // If this is a root post, cascade visibility to all thread posts
205
+ if (!existing.threadId) {
206
+ await this.updateThreadVisibility(id, data.visibility);
207
+ }
208
+ }
209
+
210
+ const result = await db.update(posts).set(updates).where(eq(posts.id, id)).returning();
211
+
212
+ return result[0] ? toPost(result[0]) : null;
213
+ },
214
+
215
+ async delete(id) {
216
+ const existing = await this.getById(id);
217
+ if (!existing) return false;
218
+
219
+ const timestamp = now();
220
+
221
+ // If this is a thread root, soft delete all posts in the thread
222
+ if (!existing.threadId) {
223
+ await db
224
+ .update(posts)
225
+ .set({ deletedAt: timestamp, updatedAt: timestamp })
226
+ .where(or(eq(posts.id, id), eq(posts.threadId, id)));
227
+ } else {
228
+ // Just delete this single post
229
+ await db
230
+ .update(posts)
231
+ .set({ deletedAt: timestamp, updatedAt: timestamp })
232
+ .where(eq(posts.id, id));
233
+ }
234
+
235
+ return true;
236
+ },
237
+
238
+ async getThread(rootId) {
239
+ const rows = await db
240
+ .select()
241
+ .from(posts)
242
+ .where(
243
+ and(or(eq(posts.id, rootId), eq(posts.threadId, rootId)), isNull(posts.deletedAt))
244
+ )
245
+ .orderBy(posts.createdAt);
246
+
247
+ return rows.map(toPost);
248
+ },
249
+
250
+ async updateThreadVisibility(rootId, visibility) {
251
+ const timestamp = now();
252
+ await db
253
+ .update(posts)
254
+ .set({ visibility, updatedAt: timestamp })
255
+ .where(eq(posts.threadId, rootId));
256
+ },
257
+
258
+ async getReplyCounts(postIds) {
259
+ if (postIds.length === 0) return new Map();
260
+
261
+ const rows = await db
262
+ .select({
263
+ threadId: posts.threadId,
264
+ count: sql<number>`count(*)`.as("count"),
265
+ })
266
+ .from(posts)
267
+ .where(and(inArray(posts.threadId, postIds), isNull(posts.deletedAt)))
268
+ .groupBy(posts.threadId);
269
+
270
+ const counts = new Map<number, number>();
271
+ for (const row of rows) {
272
+ if (row.threadId !== null) {
273
+ counts.set(row.threadId, row.count);
274
+ }
275
+ }
276
+ return counts;
277
+ },
278
+ };
279
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Redirect Service
3
+ *
4
+ * URL redirect management for path changes
5
+ */
6
+
7
+ import { eq } from "drizzle-orm";
8
+ import type { Database } from "../db/index.js";
9
+ import { redirects } from "../db/schema.js";
10
+ import { now } from "../lib/time.js";
11
+ import { normalizePath } from "../lib/url.js";
12
+ import type { Redirect } from "../types.js";
13
+
14
+ export interface RedirectService {
15
+ getByPath(fromPath: string): Promise<Redirect | null>;
16
+ create(fromPath: string, toPath: string, type?: 301 | 302): Promise<Redirect>;
17
+ delete(id: number): Promise<boolean>;
18
+ list(): Promise<Redirect[]>;
19
+ }
20
+
21
+ export function createRedirectService(db: Database): RedirectService {
22
+ function toRedirect(row: typeof redirects.$inferSelect): Redirect {
23
+ return {
24
+ id: row.id,
25
+ fromPath: row.fromPath,
26
+ toPath: row.toPath,
27
+ type: row.type as 301 | 302,
28
+ createdAt: row.createdAt,
29
+ };
30
+ }
31
+
32
+ return {
33
+ async getByPath(fromPath) {
34
+ const normalized = normalizePath(fromPath);
35
+ const result = await db
36
+ .select()
37
+ .from(redirects)
38
+ .where(eq(redirects.fromPath, normalized))
39
+ .limit(1);
40
+ return result[0] ? toRedirect(result[0]) : null;
41
+ },
42
+
43
+ async create(fromPath, toPath, type = 301) {
44
+ const timestamp = now();
45
+ const normalizedFrom = normalizePath(fromPath);
46
+
47
+ // Delete existing redirect from this path if any
48
+ await db.delete(redirects).where(eq(redirects.fromPath, normalizedFrom));
49
+
50
+ const result = await db
51
+ .insert(redirects)
52
+ .values({
53
+ fromPath: normalizedFrom,
54
+ toPath,
55
+ type,
56
+ createdAt: timestamp,
57
+ })
58
+ .returning();
59
+
60
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
61
+ return toRedirect(result[0]!);
62
+ },
63
+
64
+ async delete(id) {
65
+ const result = await db.delete(redirects).where(eq(redirects.id, id)).returning();
66
+ return result.length > 0;
67
+ },
68
+
69
+ async list() {
70
+ const rows = await db.select().from(redirects);
71
+ return rows.map(toRedirect);
72
+ },
73
+ };
74
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Search Service
3
+ *
4
+ * Full-text search using FTS5
5
+ */
6
+
7
+ import type { Post, Visibility } from "../types.js";
8
+
9
+ export interface SearchResult {
10
+ post: Post;
11
+ /** FTS5 rank score (lower is better) */
12
+ rank: number;
13
+ /** Highlighted snippet from content */
14
+ snippet?: string;
15
+ }
16
+
17
+ export interface SearchOptions {
18
+ /** Limit number of results */
19
+ limit?: number;
20
+ /** Offset for pagination */
21
+ offset?: number;
22
+ /** Filter by visibility */
23
+ visibility?: Visibility[];
24
+ }
25
+
26
+ export interface SearchService {
27
+ search(query: string, options?: SearchOptions): Promise<SearchResult[]>;
28
+ }
29
+
30
+ interface RawSearchRow {
31
+ id: number;
32
+ type: string;
33
+ visibility: string;
34
+ title: string | null;
35
+ path: string | null;
36
+ content: string | null;
37
+ content_html: string | null;
38
+ source_url: string | null;
39
+ source_name: string | null;
40
+ source_domain: string | null;
41
+ reply_to_id: number | null;
42
+ thread_id: number | null;
43
+ deleted_at: number | null;
44
+ published_at: number;
45
+ created_at: number;
46
+ updated_at: number;
47
+ rank: number;
48
+ snippet: string;
49
+ }
50
+
51
+ export function createSearchService(d1: D1Database): SearchService {
52
+ return {
53
+ async search(query, options = {}) {
54
+ const limit = options.limit ?? 20;
55
+ const offset = options.offset ?? 0;
56
+ const visibility = options.visibility ?? ["featured", "quiet"];
57
+
58
+ // Escape and prepare the query for FTS5
59
+ // FTS5 uses * for prefix matching
60
+ const ftsQuery = query
61
+ .trim()
62
+ .split(/\s+/)
63
+ .filter((term) => term.length > 0)
64
+ .map((term) => `"${term.replace(/"/g, '""')}"*`)
65
+ .join(" ");
66
+
67
+ if (!ftsQuery) {
68
+ return [];
69
+ }
70
+
71
+ // Build visibility placeholders
72
+ const visibilityPlaceholders = visibility.map(() => "?").join(", ");
73
+
74
+ // Query FTS5 table and join with posts using raw D1 query
75
+ const stmt = d1.prepare(`
76
+ SELECT
77
+ posts.*,
78
+ posts_fts.rank AS rank,
79
+ snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) AS snippet
80
+ FROM posts_fts
81
+ JOIN posts ON posts.id = posts_fts.rowid
82
+ WHERE posts_fts MATCH ?
83
+ AND posts.deleted_at IS NULL
84
+ AND posts.visibility IN (${visibilityPlaceholders})
85
+ ORDER BY posts_fts.rank
86
+ LIMIT ? OFFSET ?
87
+ `);
88
+
89
+ const { results } = await stmt
90
+ .bind(ftsQuery, ...visibility, limit, offset)
91
+ .all<RawSearchRow>();
92
+
93
+ return (results || []).map((row) => ({
94
+ post: {
95
+ id: row.id,
96
+ type: row.type as Post["type"],
97
+ visibility: row.visibility as Post["visibility"],
98
+ title: row.title,
99
+ path: row.path,
100
+ content: row.content,
101
+ contentHtml: row.content_html,
102
+ sourceUrl: row.source_url,
103
+ sourceName: row.source_name,
104
+ sourceDomain: row.source_domain,
105
+ replyToId: row.reply_to_id,
106
+ threadId: row.thread_id,
107
+ deletedAt: row.deleted_at,
108
+ publishedAt: row.published_at,
109
+ createdAt: row.created_at,
110
+ updatedAt: row.updated_at,
111
+ },
112
+ rank: row.rank,
113
+ snippet: row.snippet,
114
+ }));
115
+ },
116
+ };
117
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Settings Service
3
+ *
4
+ * Key-value store for site configuration
5
+ */
6
+
7
+ import { eq } from "drizzle-orm";
8
+ import type { Database } from "../db/index.js";
9
+ import { settings } from "../db/schema.js";
10
+ import { now } from "../lib/time.js";
11
+ import { SETTINGS_KEYS, ONBOARDING_STATUS, type SettingsKey } from "../lib/constants.js";
12
+
13
+ export interface SettingsService {
14
+ get(key: SettingsKey): Promise<string | null>;
15
+ getAll(): Promise<Record<string, string>>;
16
+ set(key: SettingsKey, value: string): Promise<void>;
17
+ setMany(entries: Partial<Record<SettingsKey, string>>): Promise<void>;
18
+ isOnboardingComplete(): Promise<boolean>;
19
+ completeOnboarding(): Promise<void>;
20
+ }
21
+
22
+ export function createSettingsService(db: Database): SettingsService {
23
+ return {
24
+ async get(key) {
25
+ const result = await db.select().from(settings).where(eq(settings.key, key)).limit(1);
26
+ return result[0]?.value ?? null;
27
+ },
28
+
29
+ async getAll() {
30
+ const rows = await db.select().from(settings);
31
+ const result: Record<string, string> = {};
32
+ for (const row of rows) {
33
+ result[row.key] = row.value;
34
+ }
35
+ return result;
36
+ },
37
+
38
+ async set(key, value) {
39
+ const timestamp = now();
40
+ await db
41
+ .insert(settings)
42
+ .values({ key, value, updatedAt: timestamp })
43
+ .onConflictDoUpdate({
44
+ target: settings.key,
45
+ set: { value, updatedAt: timestamp },
46
+ });
47
+ },
48
+
49
+ async setMany(entries) {
50
+ const timestamp = now();
51
+ const keys = Object.keys(entries) as SettingsKey[];
52
+
53
+ for (const key of keys) {
54
+ const value = entries[key];
55
+ if (value !== undefined) {
56
+ await db
57
+ .insert(settings)
58
+ .values({ key, value, updatedAt: timestamp })
59
+ .onConflictDoUpdate({
60
+ target: settings.key,
61
+ set: { value, updatedAt: timestamp },
62
+ });
63
+ }
64
+ }
65
+ },
66
+
67
+ async isOnboardingComplete() {
68
+ const status = await this.get(SETTINGS_KEYS.ONBOARDING_STATUS);
69
+ return status === ONBOARDING_STATUS.COMPLETED;
70
+ },
71
+
72
+ async completeOnboarding() {
73
+ await this.set(SETTINGS_KEYS.ONBOARDING_STATUS, ONBOARDING_STATUS.COMPLETED);
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Action Buttons Component
3
+ *
4
+ * Provides consistent Edit/View/Delete button group for list and detail pages
5
+ */
6
+
7
+ import type { FC } from "hono/jsx";
8
+ import { useLingui } from "../../i18n/index.js";
9
+
10
+ export interface ActionButtonsProps {
11
+ /**
12
+ * URL for the edit action
13
+ */
14
+ editHref?: string;
15
+
16
+ /**
17
+ * URL for the view action (opens in new tab)
18
+ */
19
+ viewHref?: string;
20
+
21
+ /**
22
+ * Delete button form action
23
+ */
24
+ deleteAction?: string;
25
+
26
+ /**
27
+ * Delete confirmation message
28
+ */
29
+ deleteConfirm?: string;
30
+
31
+ /**
32
+ * Button size variant
33
+ * @default "sm"
34
+ */
35
+ size?: "sm" | "md";
36
+
37
+ /**
38
+ * Custom edit button label (overrides default translation)
39
+ */
40
+ editLabel?: string;
41
+
42
+ /**
43
+ * Custom view button label (overrides default translation)
44
+ */
45
+ viewLabel?: string;
46
+
47
+ /**
48
+ * Custom delete button label (overrides default translation)
49
+ */
50
+ deleteLabel?: string;
51
+ }
52
+
53
+ export const ActionButtons: FC<ActionButtonsProps> = ({
54
+ editHref,
55
+ viewHref,
56
+ deleteAction,
57
+ deleteConfirm,
58
+ size = "sm",
59
+ editLabel,
60
+ viewLabel,
61
+ deleteLabel,
62
+ }) => {
63
+ const { t } = useLingui();
64
+
65
+ const editClass = size === "sm" ? "btn-sm-outline" : "btn-outline";
66
+ const viewClass = size === "sm" ? "btn-sm-ghost" : "btn-ghost";
67
+ const deleteClass = size === "sm" ? "btn-sm-ghost text-destructive" : "btn-ghost text-destructive";
68
+
69
+ const defaultEditLabel = t({ message: "Edit", comment: "@context: Button to edit item" });
70
+ const defaultViewLabel = t({ message: "View", comment: "@context: Button to view item on public site" });
71
+ const defaultDeleteLabel = t({ message: "Delete", comment: "@context: Button to delete item" });
72
+
73
+ return (
74
+ <>
75
+ {editHref && (
76
+ <a href={editHref} class={editClass}>
77
+ {editLabel || defaultEditLabel}
78
+ </a>
79
+ )}
80
+ {viewHref && (
81
+ <a href={viewHref} class={viewClass} target="_blank">
82
+ {viewLabel || defaultViewLabel}
83
+ </a>
84
+ )}
85
+ {deleteAction && (
86
+ <form method="post" action={deleteAction} style="display: inline">
87
+ <button
88
+ type="submit"
89
+ class={deleteClass}
90
+ onclick={deleteConfirm ? `return confirm('${deleteConfirm}')` : undefined}
91
+ >
92
+ {deleteLabel || defaultDeleteLabel}
93
+ </button>
94
+ </form>
95
+ )}
96
+ </>
97
+ );
98
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * CRUD Page Header Component
3
+ *
4
+ * Provides consistent header layout for dashboard CRUD list pages
5
+ * with title and primary action button
6
+ */
7
+
8
+ import type { FC, PropsWithChildren } from "hono/jsx";
9
+
10
+ export interface CrudPageHeaderProps extends PropsWithChildren {
11
+ /**
12
+ * Page title to display
13
+ */
14
+ title: string;
15
+
16
+ /**
17
+ * Primary action button text (e.g., "New Post")
18
+ */
19
+ ctaLabel?: string;
20
+
21
+ /**
22
+ * Primary action button href
23
+ */
24
+ ctaHref?: string;
25
+
26
+ // children is already defined in PropsWithChildren
27
+ // Optional children to render in place of default CTA button (useful for custom actions like upload buttons)
28
+ }
29
+
30
+ export const CrudPageHeader: FC<CrudPageHeaderProps> = ({
31
+ title,
32
+ ctaLabel,
33
+ ctaHref,
34
+ children,
35
+ }) => {
36
+ return (
37
+ <div class="flex items-center justify-between mb-6">
38
+ <h1 class="text-2xl font-semibold">{title}</h1>
39
+ {children || (
40
+ ctaLabel && ctaHref && (
41
+ <a href={ctaHref} class="btn">
42
+ {ctaLabel}
43
+ </a>
44
+ )
45
+ )}
46
+ </div>
47
+ );
48
+ };