@jant/core 0.3.1 → 0.3.3
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/dist/app.d.ts.map +1 -1
- package/dist/app.js +0 -2
- package/dist/lib/constants.d.ts +1 -1
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +1 -2
- package/dist/routes/api/posts.js +1 -1
- package/dist/routes/dash/settings.d.ts +2 -0
- package/dist/routes/dash/settings.d.ts.map +1 -1
- package/dist/routes/dash/settings.js +413 -93
- package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
- package/dist/theme/layouts/DashLayout.js +0 -8
- package/package.json +10 -5
- package/src/__tests__/helpers/app.ts +97 -0
- package/src/__tests__/helpers/db.ts +85 -0
- package/src/app.tsx +0 -3
- package/src/db/migrations/0001_add_search_fts.sql +34 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/lib/__tests__/constants.test.ts +44 -0
- package/src/lib/__tests__/markdown.test.ts +133 -0
- package/src/lib/__tests__/schemas.test.ts +220 -0
- package/src/lib/__tests__/sqid.test.ts +65 -0
- package/src/lib/__tests__/sse.test.ts +86 -0
- package/src/lib/__tests__/time.test.ts +112 -0
- package/src/lib/__tests__/url.test.ts +138 -0
- package/src/lib/constants.ts +0 -1
- package/src/middleware/__tests__/auth.test.ts +139 -0
- package/src/routes/api/__tests__/posts.test.ts +306 -0
- package/src/routes/api/__tests__/search.test.ts +77 -0
- package/src/routes/api/posts.ts +3 -1
- package/src/routes/dash/settings.tsx +350 -16
- package/src/services/__tests__/collection.test.ts +226 -0
- package/src/services/__tests__/media.test.ts +134 -0
- package/src/services/__tests__/post.test.ts +636 -0
- package/src/services/__tests__/redirect.test.ts +110 -0
- package/src/services/__tests__/search.test.ts +143 -0
- package/src/services/__tests__/settings.test.ts +110 -0
- package/src/theme/layouts/DashLayout.tsx +0 -9
- package/dist/routes/dash/appearance.d.ts +0 -13
- package/dist/routes/dash/appearance.d.ts.map +0 -1
- package/dist/routes/dash/appearance.js +0 -160
- package/src/routes/dash/appearance.tsx +0 -176
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Dashboard Settings Routes
|
|
3
|
+
*
|
|
4
|
+
* Sub-pages: General, Appearance, Account
|
|
3
5
|
*/
|
|
4
6
|
|
|
5
7
|
import { Hono } from "hono";
|
|
@@ -7,8 +9,15 @@ import { useLingui } from "@lingui/react/macro";
|
|
|
7
9
|
import type { Bindings } from "../../types.js";
|
|
8
10
|
import type { AppVariables } from "../../app.js";
|
|
9
11
|
import { DashLayout } from "../../theme/layouts/index.js";
|
|
10
|
-
import { sse, dsToast } from "../../lib/sse.js";
|
|
11
|
-
import {
|
|
12
|
+
import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
|
|
13
|
+
import {
|
|
14
|
+
getSiteLanguage,
|
|
15
|
+
getSiteName,
|
|
16
|
+
getConfigFallback,
|
|
17
|
+
} from "../../lib/config.js";
|
|
18
|
+
import { SETTINGS_KEYS } from "../../lib/constants.js";
|
|
19
|
+
import { getAvailableThemes } from "../../lib/theme.js";
|
|
20
|
+
import type { ColorTheme } from "../../theme/color-themes.js";
|
|
12
21
|
|
|
13
22
|
/** Escape HTML special characters for safe insertion into HTML strings */
|
|
14
23
|
function escapeHtml(str: string): string {
|
|
@@ -23,7 +32,66 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
23
32
|
|
|
24
33
|
export const settingsRoutes = new Hono<Env>();
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Shared sub-navigation
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
type SettingsTab = "general" | "appearance" | "account";
|
|
40
|
+
|
|
41
|
+
function SettingsNav({ currentTab }: { currentTab: SettingsTab }) {
|
|
42
|
+
const { t } = useLingui();
|
|
43
|
+
|
|
44
|
+
const tabs: { id: SettingsTab; label: string; href: string }[] = [
|
|
45
|
+
{
|
|
46
|
+
id: "general",
|
|
47
|
+
label: t({
|
|
48
|
+
message: "General",
|
|
49
|
+
comment: "@context: Settings sub-navigation tab",
|
|
50
|
+
}),
|
|
51
|
+
href: "/dash/settings",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "appearance",
|
|
55
|
+
label: t({
|
|
56
|
+
message: "Appearance",
|
|
57
|
+
comment: "@context: Settings sub-navigation tab",
|
|
58
|
+
}),
|
|
59
|
+
href: "/dash/settings/appearance",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "account",
|
|
63
|
+
label: t({
|
|
64
|
+
message: "Account",
|
|
65
|
+
comment: "@context: Settings sub-navigation tab",
|
|
66
|
+
}),
|
|
67
|
+
href: "/dash/settings/account",
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<nav class="flex gap-1 mb-6">
|
|
73
|
+
{tabs.map((tab) => (
|
|
74
|
+
<a
|
|
75
|
+
key={tab.id}
|
|
76
|
+
href={tab.href}
|
|
77
|
+
class={`px-3 py-2 text-sm rounded-md ${
|
|
78
|
+
tab.id === currentTab
|
|
79
|
+
? "bg-accent text-accent-foreground font-medium"
|
|
80
|
+
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
81
|
+
}`}
|
|
82
|
+
>
|
|
83
|
+
{tab.label}
|
|
84
|
+
</a>
|
|
85
|
+
))}
|
|
86
|
+
</nav>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// General tab
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
function GeneralContent({
|
|
27
95
|
siteName,
|
|
28
96
|
siteDescription,
|
|
29
97
|
siteLanguage,
|
|
@@ -46,9 +114,10 @@ function SettingsContent({
|
|
|
46
114
|
|
|
47
115
|
return (
|
|
48
116
|
<>
|
|
49
|
-
<h1 class="text-2xl font-semibold mb-
|
|
117
|
+
<h1 class="text-2xl font-semibold mb-2">
|
|
50
118
|
{t({ message: "Settings", comment: "@context: Dashboard heading" })}
|
|
51
119
|
</h1>
|
|
120
|
+
<SettingsNav currentTab="general" />
|
|
52
121
|
|
|
53
122
|
<div class="flex flex-col gap-6 max-w-lg">
|
|
54
123
|
<form
|
|
@@ -126,6 +195,192 @@ function SettingsContent({
|
|
|
126
195
|
})}
|
|
127
196
|
</button>
|
|
128
197
|
</form>
|
|
198
|
+
</div>
|
|
199
|
+
</>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Appearance tab
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
function ThemeCard({
|
|
208
|
+
theme,
|
|
209
|
+
selected,
|
|
210
|
+
}: {
|
|
211
|
+
theme: ColorTheme;
|
|
212
|
+
selected: boolean;
|
|
213
|
+
}) {
|
|
214
|
+
const expr = `$theme === '${theme.id}'`;
|
|
215
|
+
const { preview } = theme;
|
|
216
|
+
|
|
217
|
+
return (
|
|
218
|
+
<label
|
|
219
|
+
class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
|
|
220
|
+
data-class:border-primary={expr}
|
|
221
|
+
data-class:border-border={`$theme !== '${theme.id}'`}
|
|
222
|
+
>
|
|
223
|
+
<div class="grid grid-cols-2">
|
|
224
|
+
<div
|
|
225
|
+
class="p-5"
|
|
226
|
+
style={`background-color:${preview.lightBg};color:${preview.lightText}`}
|
|
227
|
+
>
|
|
228
|
+
<input
|
|
229
|
+
type="radio"
|
|
230
|
+
name="theme"
|
|
231
|
+
value={theme.id}
|
|
232
|
+
data-bind="theme"
|
|
233
|
+
checked={selected || undefined}
|
|
234
|
+
class="mb-1"
|
|
235
|
+
/>
|
|
236
|
+
<h3 class="font-bold text-lg">{theme.name}</h3>
|
|
237
|
+
<p class="text-sm mt-2 leading-relaxed">
|
|
238
|
+
This is the {theme.name} theme in light mode. Links{" "}
|
|
239
|
+
<a
|
|
240
|
+
tabIndex={-1}
|
|
241
|
+
class="underline"
|
|
242
|
+
style={`color:${preview.lightLink}`}
|
|
243
|
+
>
|
|
244
|
+
look like this
|
|
245
|
+
</a>
|
|
246
|
+
. We'll show the correct light or dark mode based on your visitor's
|
|
247
|
+
settings.
|
|
248
|
+
</p>
|
|
249
|
+
</div>
|
|
250
|
+
<div
|
|
251
|
+
class="p-5"
|
|
252
|
+
style={`background-color:${preview.darkBg};color:${preview.darkText}`}
|
|
253
|
+
>
|
|
254
|
+
<h3 class="font-bold text-lg">{theme.name}</h3>
|
|
255
|
+
<p class="text-sm mt-2 leading-relaxed">
|
|
256
|
+
This is the {theme.name} theme in dark mode. Links{" "}
|
|
257
|
+
<a
|
|
258
|
+
tabIndex={-1}
|
|
259
|
+
class="underline"
|
|
260
|
+
style={`color:${preview.darkLink}`}
|
|
261
|
+
>
|
|
262
|
+
look like this
|
|
263
|
+
</a>
|
|
264
|
+
. We'll show the correct light or dark mode based on your visitor's
|
|
265
|
+
settings.
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</label>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function AppearanceContent({
|
|
274
|
+
themes,
|
|
275
|
+
currentThemeId,
|
|
276
|
+
}: {
|
|
277
|
+
themes: ColorTheme[];
|
|
278
|
+
currentThemeId: string;
|
|
279
|
+
}) {
|
|
280
|
+
const { t } = useLingui();
|
|
281
|
+
|
|
282
|
+
const signals = JSON.stringify({ theme: currentThemeId }).replace(
|
|
283
|
+
/</g,
|
|
284
|
+
"\\u003c",
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<>
|
|
289
|
+
<h1 class="text-2xl font-semibold mb-2">
|
|
290
|
+
{t({ message: "Settings", comment: "@context: Dashboard heading" })}
|
|
291
|
+
</h1>
|
|
292
|
+
<SettingsNav currentTab="appearance" />
|
|
293
|
+
|
|
294
|
+
<div
|
|
295
|
+
data-signals={signals}
|
|
296
|
+
data-on:change="@post('/dash/settings/appearance')"
|
|
297
|
+
class="max-w-3xl"
|
|
298
|
+
>
|
|
299
|
+
<fieldset>
|
|
300
|
+
<legend class="text-lg font-semibold">
|
|
301
|
+
{t({
|
|
302
|
+
message: "Color theme",
|
|
303
|
+
comment: "@context: Appearance settings heading",
|
|
304
|
+
})}
|
|
305
|
+
</legend>
|
|
306
|
+
<p class="text-sm text-muted-foreground mb-4">
|
|
307
|
+
{t({
|
|
308
|
+
message:
|
|
309
|
+
"This will theme both your site and your dashboard. All color themes support dark mode.",
|
|
310
|
+
comment: "@context: Appearance settings description",
|
|
311
|
+
})}
|
|
312
|
+
</p>
|
|
313
|
+
|
|
314
|
+
<div class="flex flex-col gap-4">
|
|
315
|
+
{themes.map((theme) => (
|
|
316
|
+
<ThemeCard
|
|
317
|
+
key={theme.id}
|
|
318
|
+
theme={theme}
|
|
319
|
+
selected={theme.id === currentThemeId}
|
|
320
|
+
/>
|
|
321
|
+
))}
|
|
322
|
+
</div>
|
|
323
|
+
</fieldset>
|
|
324
|
+
</div>
|
|
325
|
+
</>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Account tab
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
function AccountContent({ userName }: { userName: string }) {
|
|
334
|
+
const { t } = useLingui();
|
|
335
|
+
|
|
336
|
+
const profileSignals = JSON.stringify({ userName }).replace(/</g, "\\u003c");
|
|
337
|
+
|
|
338
|
+
return (
|
|
339
|
+
<>
|
|
340
|
+
<h1 class="text-2xl font-semibold mb-2">
|
|
341
|
+
{t({ message: "Settings", comment: "@context: Dashboard heading" })}
|
|
342
|
+
</h1>
|
|
343
|
+
<SettingsNav currentTab="account" />
|
|
344
|
+
|
|
345
|
+
<div class="flex flex-col gap-6 max-w-lg">
|
|
346
|
+
<form
|
|
347
|
+
data-signals={profileSignals}
|
|
348
|
+
data-on:submit__prevent="@post('/dash/settings/account')"
|
|
349
|
+
>
|
|
350
|
+
<div class="card">
|
|
351
|
+
<header>
|
|
352
|
+
<h2>
|
|
353
|
+
{t({
|
|
354
|
+
message: "Profile",
|
|
355
|
+
comment: "@context: Account settings section heading",
|
|
356
|
+
})}
|
|
357
|
+
</h2>
|
|
358
|
+
</header>
|
|
359
|
+
<section class="flex flex-col gap-4">
|
|
360
|
+
<div class="field">
|
|
361
|
+
<label class="label">
|
|
362
|
+
{t({
|
|
363
|
+
message: "Name",
|
|
364
|
+
comment: "@context: Account settings form field",
|
|
365
|
+
})}
|
|
366
|
+
</label>
|
|
367
|
+
<input
|
|
368
|
+
type="text"
|
|
369
|
+
data-bind="userName"
|
|
370
|
+
class="input"
|
|
371
|
+
required
|
|
372
|
+
/>
|
|
373
|
+
</div>
|
|
374
|
+
</section>
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
<button type="submit" class="btn mt-4">
|
|
378
|
+
{t({
|
|
379
|
+
message: "Save Profile",
|
|
380
|
+
comment: "@context: Button to save profile",
|
|
381
|
+
})}
|
|
382
|
+
</button>
|
|
383
|
+
</form>
|
|
129
384
|
|
|
130
385
|
<form
|
|
131
386
|
data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
|
|
@@ -205,16 +460,18 @@ function SettingsContent({
|
|
|
205
460
|
);
|
|
206
461
|
}
|
|
207
462
|
|
|
208
|
-
//
|
|
463
|
+
// ===========================================================================
|
|
464
|
+
// Route handlers
|
|
465
|
+
// ===========================================================================
|
|
466
|
+
|
|
467
|
+
// General settings page
|
|
209
468
|
settingsRoutes.get("/", async (c) => {
|
|
210
469
|
const { settings } = c.var.services;
|
|
211
470
|
|
|
212
|
-
// Fetch raw DB values (null if not set)
|
|
213
471
|
const dbSiteName = await settings.get("SITE_NAME");
|
|
214
472
|
const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
|
|
215
473
|
const siteLanguage = await getSiteLanguage(c);
|
|
216
474
|
|
|
217
|
-
// Fallback values (ENV > Default) for placeholders
|
|
218
475
|
const siteNameFallback = getConfigFallback(c, "SITE_NAME");
|
|
219
476
|
const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
|
|
220
477
|
|
|
@@ -228,7 +485,7 @@ settingsRoutes.get("/", async (c) => {
|
|
|
228
485
|
currentPath="/dash/settings"
|
|
229
486
|
toast={saved ? { message: "Settings saved successfully." } : undefined}
|
|
230
487
|
>
|
|
231
|
-
<
|
|
488
|
+
<GeneralContent
|
|
232
489
|
siteName={dbSiteName || ""}
|
|
233
490
|
siteDescription={dbSiteDescription || ""}
|
|
234
491
|
siteLanguage={siteLanguage}
|
|
@@ -239,7 +496,7 @@ settingsRoutes.get("/", async (c) => {
|
|
|
239
496
|
);
|
|
240
497
|
});
|
|
241
498
|
|
|
242
|
-
//
|
|
499
|
+
// Save general settings
|
|
243
500
|
settingsRoutes.post("/", async (c) => {
|
|
244
501
|
const body = await c.req.json<{
|
|
245
502
|
siteName: string;
|
|
@@ -251,7 +508,6 @@ settingsRoutes.post("/", async (c) => {
|
|
|
251
508
|
|
|
252
509
|
const oldLanguage = (await settings.get("SITE_LANGUAGE")) ?? "en";
|
|
253
510
|
|
|
254
|
-
// For text fields: empty = remove from DB (fall back to ENV > Default)
|
|
255
511
|
if (body.siteName.trim()) {
|
|
256
512
|
await settings.set("SITE_NAME", body.siteName.trim());
|
|
257
513
|
} else {
|
|
@@ -264,25 +520,19 @@ settingsRoutes.post("/", async (c) => {
|
|
|
264
520
|
await settings.remove("SITE_DESCRIPTION");
|
|
265
521
|
}
|
|
266
522
|
|
|
267
|
-
// Language always has a value from the select
|
|
268
523
|
await settings.set("SITE_LANGUAGE", body.siteLanguage);
|
|
269
524
|
|
|
270
525
|
const languageChanged = oldLanguage !== body.siteLanguage;
|
|
271
|
-
|
|
272
|
-
// Determine the effective display name after save
|
|
273
526
|
const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
|
|
274
527
|
|
|
275
528
|
return sse(c, async (stream) => {
|
|
276
529
|
if (languageChanged) {
|
|
277
|
-
// Language changed - full reload needed to update all UI text
|
|
278
530
|
await stream.redirect("/dash/settings?saved");
|
|
279
531
|
} else {
|
|
280
532
|
const escaped = escapeHtml(displayName);
|
|
281
|
-
// Update header site name
|
|
282
533
|
await stream.patchElements(
|
|
283
534
|
`<a id="site-name" href="/dash" class="font-semibold">${escaped}</a>`,
|
|
284
535
|
);
|
|
285
|
-
// Update page title
|
|
286
536
|
await stream.patchElements(`Settings - ${escaped}`, {
|
|
287
537
|
mode: "inner",
|
|
288
538
|
selector: "title",
|
|
@@ -292,6 +542,90 @@ settingsRoutes.post("/", async (c) => {
|
|
|
292
542
|
});
|
|
293
543
|
});
|
|
294
544
|
|
|
545
|
+
// Appearance page
|
|
546
|
+
settingsRoutes.get("/appearance", async (c) => {
|
|
547
|
+
const { settings } = c.var.services;
|
|
548
|
+
const siteName = await getSiteName(c);
|
|
549
|
+
const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
|
|
550
|
+
const themes = getAvailableThemes(c.var.config);
|
|
551
|
+
const saved = c.req.query("saved") !== undefined;
|
|
552
|
+
|
|
553
|
+
return c.html(
|
|
554
|
+
<DashLayout
|
|
555
|
+
c={c}
|
|
556
|
+
title="Settings"
|
|
557
|
+
siteName={siteName}
|
|
558
|
+
currentPath="/dash/settings"
|
|
559
|
+
toast={saved ? { message: "Theme saved successfully." } : undefined}
|
|
560
|
+
>
|
|
561
|
+
<AppearanceContent themes={themes} currentThemeId={currentThemeId} />
|
|
562
|
+
</DashLayout>,
|
|
563
|
+
);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// Save theme
|
|
567
|
+
settingsRoutes.post("/appearance", async (c) => {
|
|
568
|
+
const body = await c.req.json<{ theme: string }>();
|
|
569
|
+
const { settings } = c.var.services;
|
|
570
|
+
const themes = getAvailableThemes(c.var.config);
|
|
571
|
+
|
|
572
|
+
const validTheme = themes.find((t) => t.id === body.theme);
|
|
573
|
+
if (!validTheme) {
|
|
574
|
+
return dsToast("Invalid theme selected.", "error");
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (validTheme.id === "default") {
|
|
578
|
+
await settings.remove(SETTINGS_KEYS.THEME);
|
|
579
|
+
} else {
|
|
580
|
+
await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
return dsRedirect("/dash/settings/appearance?saved");
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// Account page
|
|
587
|
+
settingsRoutes.get("/account", async (c) => {
|
|
588
|
+
const siteName = await getSiteName(c);
|
|
589
|
+
const session = await c.var.auth.api.getSession({
|
|
590
|
+
headers: c.req.raw.headers,
|
|
591
|
+
});
|
|
592
|
+
const userName = session?.user?.name ?? "";
|
|
593
|
+
const saved = c.req.query("saved") !== undefined;
|
|
594
|
+
|
|
595
|
+
return c.html(
|
|
596
|
+
<DashLayout
|
|
597
|
+
c={c}
|
|
598
|
+
title="Settings"
|
|
599
|
+
siteName={siteName}
|
|
600
|
+
currentPath="/dash/settings"
|
|
601
|
+
toast={saved ? { message: "Profile saved successfully." } : undefined}
|
|
602
|
+
>
|
|
603
|
+
<AccountContent userName={userName} />
|
|
604
|
+
</DashLayout>,
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// Save account profile
|
|
609
|
+
settingsRoutes.post("/account", async (c) => {
|
|
610
|
+
const body = await c.req.json<{ userName: string }>();
|
|
611
|
+
const name = body.userName?.trim();
|
|
612
|
+
|
|
613
|
+
if (!name) {
|
|
614
|
+
return dsToast("Name is required.", "error");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
await c.var.auth.api.updateUser({
|
|
619
|
+
body: { name },
|
|
620
|
+
headers: c.req.raw.headers,
|
|
621
|
+
});
|
|
622
|
+
} catch {
|
|
623
|
+
return dsToast("Failed to update profile.", "error");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return dsToast("Profile saved successfully.");
|
|
627
|
+
});
|
|
628
|
+
|
|
295
629
|
// Change password
|
|
296
630
|
settingsRoutes.post("/password", async (c) => {
|
|
297
631
|
const body = await c.req.json<{
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
3
|
+
import { createCollectionService } from "../collection.js";
|
|
4
|
+
import { createPostService } from "../post.js";
|
|
5
|
+
import type { Database } from "../../db/index.js";
|
|
6
|
+
|
|
7
|
+
describe("CollectionService", () => {
|
|
8
|
+
let db: Database;
|
|
9
|
+
let collectionService: ReturnType<typeof createCollectionService>;
|
|
10
|
+
let postService: ReturnType<typeof createPostService>;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
const testDb = createTestDatabase();
|
|
14
|
+
db = testDb.db as unknown as Database;
|
|
15
|
+
collectionService = createCollectionService(db);
|
|
16
|
+
postService = createPostService(db);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("create", () => {
|
|
20
|
+
it("creates a collection with required fields", async () => {
|
|
21
|
+
const collection = await collectionService.create({
|
|
22
|
+
title: "My Collection",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(collection.id).toBe(1);
|
|
26
|
+
expect(collection.title).toBe("My Collection");
|
|
27
|
+
expect(collection.path).toBeNull();
|
|
28
|
+
expect(collection.description).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("creates a collection with all fields", async () => {
|
|
32
|
+
const collection = await collectionService.create({
|
|
33
|
+
title: "Tech Posts",
|
|
34
|
+
path: "tech",
|
|
35
|
+
description: "Posts about technology",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(collection.title).toBe("Tech Posts");
|
|
39
|
+
expect(collection.path).toBe("tech");
|
|
40
|
+
expect(collection.description).toBe("Posts about technology");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("sets timestamps", async () => {
|
|
44
|
+
const collection = await collectionService.create({
|
|
45
|
+
title: "Test",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(collection.createdAt).toBeGreaterThan(0);
|
|
49
|
+
expect(collection.updatedAt).toBeGreaterThan(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("getById", () => {
|
|
54
|
+
it("returns a collection by ID", async () => {
|
|
55
|
+
const created = await collectionService.create({ title: "Test" });
|
|
56
|
+
|
|
57
|
+
const found = await collectionService.getById(created.id);
|
|
58
|
+
expect(found).not.toBeNull();
|
|
59
|
+
expect(found?.title).toBe("Test");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns null for non-existent ID", async () => {
|
|
63
|
+
const found = await collectionService.getById(9999);
|
|
64
|
+
expect(found).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("getByPath", () => {
|
|
69
|
+
it("returns a collection by path", async () => {
|
|
70
|
+
await collectionService.create({ title: "Tech", path: "tech" });
|
|
71
|
+
|
|
72
|
+
const found = await collectionService.getByPath("tech");
|
|
73
|
+
expect(found).not.toBeNull();
|
|
74
|
+
expect(found?.title).toBe("Tech");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns null for non-existent path", async () => {
|
|
78
|
+
const found = await collectionService.getByPath("nonexistent");
|
|
79
|
+
expect(found).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("list", () => {
|
|
84
|
+
it("returns empty array when no collections exist", async () => {
|
|
85
|
+
const list = await collectionService.list();
|
|
86
|
+
expect(list).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns all collections", async () => {
|
|
90
|
+
await collectionService.create({ title: "First" });
|
|
91
|
+
await collectionService.create({ title: "Second" });
|
|
92
|
+
await collectionService.create({ title: "Third" });
|
|
93
|
+
|
|
94
|
+
const list = await collectionService.list();
|
|
95
|
+
expect(list).toHaveLength(3);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("update", () => {
|
|
100
|
+
it("updates collection title", async () => {
|
|
101
|
+
const collection = await collectionService.create({ title: "Old" });
|
|
102
|
+
|
|
103
|
+
const updated = await collectionService.update(collection.id, {
|
|
104
|
+
title: "New",
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(updated?.title).toBe("New");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("updates collection path", async () => {
|
|
111
|
+
const collection = await collectionService.create({
|
|
112
|
+
title: "Test",
|
|
113
|
+
path: "old-path",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const updated = await collectionService.update(collection.id, {
|
|
117
|
+
path: "new-path",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(updated?.path).toBe("new-path");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns null for non-existent collection", async () => {
|
|
124
|
+
const result = await collectionService.update(9999, { title: "X" });
|
|
125
|
+
expect(result).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("delete", () => {
|
|
130
|
+
it("deletes a collection", async () => {
|
|
131
|
+
const collection = await collectionService.create({ title: "Test" });
|
|
132
|
+
|
|
133
|
+
const result = await collectionService.delete(collection.id);
|
|
134
|
+
expect(result).toBe(true);
|
|
135
|
+
|
|
136
|
+
const found = await collectionService.getById(collection.id);
|
|
137
|
+
expect(found).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("deletes associated post-collection relationships", async () => {
|
|
141
|
+
const collection = await collectionService.create({ title: "Test" });
|
|
142
|
+
const post = await postService.create({
|
|
143
|
+
type: "note",
|
|
144
|
+
content: "test",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await collectionService.addPost(collection.id, post.id);
|
|
148
|
+
await collectionService.delete(collection.id);
|
|
149
|
+
|
|
150
|
+
// Post itself should still exist
|
|
151
|
+
expect(await postService.getById(post.id)).not.toBeNull();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns false for non-existent collection", async () => {
|
|
155
|
+
const result = await collectionService.delete(9999);
|
|
156
|
+
expect(result).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("post relationships", () => {
|
|
161
|
+
it("adds a post to a collection", async () => {
|
|
162
|
+
const collection = await collectionService.create({ title: "Test" });
|
|
163
|
+
const post = await postService.create({
|
|
164
|
+
type: "note",
|
|
165
|
+
content: "test",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await collectionService.addPost(collection.id, post.id);
|
|
169
|
+
|
|
170
|
+
const posts = await collectionService.getPosts(collection.id);
|
|
171
|
+
expect(posts).toHaveLength(1);
|
|
172
|
+
expect(posts[0]?.id).toBe(post.id);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("adding same post twice is idempotent", async () => {
|
|
176
|
+
const collection = await collectionService.create({ title: "Test" });
|
|
177
|
+
const post = await postService.create({
|
|
178
|
+
type: "note",
|
|
179
|
+
content: "test",
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await collectionService.addPost(collection.id, post.id);
|
|
183
|
+
await collectionService.addPost(collection.id, post.id);
|
|
184
|
+
|
|
185
|
+
const posts = await collectionService.getPosts(collection.id);
|
|
186
|
+
expect(posts).toHaveLength(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("removes a post from a collection", async () => {
|
|
190
|
+
const collection = await collectionService.create({ title: "Test" });
|
|
191
|
+
const post = await postService.create({
|
|
192
|
+
type: "note",
|
|
193
|
+
content: "test",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await collectionService.addPost(collection.id, post.id);
|
|
197
|
+
await collectionService.removePost(collection.id, post.id);
|
|
198
|
+
|
|
199
|
+
const posts = await collectionService.getPosts(collection.id);
|
|
200
|
+
expect(posts).toHaveLength(0);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns collections for a post", async () => {
|
|
204
|
+
const col1 = await collectionService.create({ title: "Col 1" });
|
|
205
|
+
const col2 = await collectionService.create({ title: "Col 2" });
|
|
206
|
+
const post = await postService.create({
|
|
207
|
+
type: "note",
|
|
208
|
+
content: "test",
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await collectionService.addPost(col1.id, post.id);
|
|
212
|
+
await collectionService.addPost(col2.id, post.id);
|
|
213
|
+
|
|
214
|
+
const collections = await collectionService.getCollectionsForPost(
|
|
215
|
+
post.id,
|
|
216
|
+
);
|
|
217
|
+
expect(collections).toHaveLength(2);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("getPosts returns empty array for empty collection", async () => {
|
|
221
|
+
const collection = await collectionService.create({ title: "Empty" });
|
|
222
|
+
const posts = await collectionService.getPosts(collection.id);
|
|
223
|
+
expect(posts).toEqual([]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|