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