@jant/core 0.3.0 → 0.3.2

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/src/auth.ts CHANGED
@@ -31,7 +31,7 @@ export function createAuth(
31
31
  minPasswordLength: 8,
32
32
  },
33
33
  session: {
34
- expiresIn: 3600 * 24 * 365 * 10, // 10 years
34
+ expiresIn: 3600 * 24 * 366, // 366 days
35
35
  cookieCache: {
36
36
  enabled: true,
37
37
  maxAge: 60 * 5, // 5 minutes
@@ -0,0 +1,34 @@
1
+ -- FTS5 virtual table for full-text search (trigram tokenizer for CJK support)
2
+ CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
3
+ title,
4
+ content,
5
+ content='posts',
6
+ content_rowid='id',
7
+ tokenize='trigram'
8
+ );
9
+
10
+ -- Populate FTS with existing posts
11
+ INSERT INTO posts_fts(rowid, title, content)
12
+ SELECT id, COALESCE(title, ''), COALESCE(content, '')
13
+ FROM posts WHERE deleted_at IS NULL;
14
+
15
+ -- Trigger: sync FTS on INSERT
16
+ CREATE TRIGGER posts_fts_insert AFTER INSERT ON posts
17
+ WHEN NEW.deleted_at IS NULL
18
+ BEGIN
19
+ INSERT INTO posts_fts(rowid, title, content)
20
+ VALUES (NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, ''));
21
+ END;
22
+
23
+ -- Trigger: sync FTS on UPDATE
24
+ CREATE TRIGGER posts_fts_update AFTER UPDATE ON posts BEGIN
25
+ DELETE FROM posts_fts WHERE rowid = OLD.id;
26
+ INSERT INTO posts_fts(rowid, title, content)
27
+ SELECT NEW.id, COALESCE(NEW.title, ''), COALESCE(NEW.content, '')
28
+ WHERE NEW.deleted_at IS NULL;
29
+ END;
30
+
31
+ -- Trigger: sync FTS on DELETE
32
+ CREATE TRIGGER posts_fts_delete AFTER DELETE ON posts BEGIN
33
+ DELETE FROM posts_fts WHERE rowid = OLD.id;
34
+ END;
@@ -8,6 +8,13 @@
8
8
  "when": 1770564499811,
9
9
  "tag": "0000_square_wallflower",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "6",
15
+ "when": 1770564499812,
16
+ "tag": "0001_add_search_fts",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
@@ -27,7 +27,6 @@ export const RESERVED_PATHS = [
27
27
  "static",
28
28
  "assets",
29
29
  "health",
30
- "appearance",
31
30
  ] as const;
32
31
 
33
32
  export type ReservedPath = (typeof RESERVED_PATHS)[number];
@@ -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 { getSiteLanguage, getConfigFallback } from "../../lib/config.js";
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
- function SettingsContent({
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-6">
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
- // Settings page
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
- <SettingsContent
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
- // Update settings
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<{
@@ -135,15 +135,6 @@ function DashLayoutContent({
135
135
  comment: "@context: Dashboard navigation - site settings",
136
136
  })}
137
137
  </a>
138
- <a
139
- href="/dash/appearance"
140
- class={navClass("/dash/appearance", /^\/dash\/appearance/)}
141
- >
142
- {t({
143
- message: "Appearance",
144
- comment: "@context: Dashboard navigation - appearance settings",
145
- })}
146
- </a>
147
138
  </nav>
148
139
  </aside>
149
140
 
@@ -1,13 +0,0 @@
1
- /**
2
- * Dashboard Appearance Routes
3
- */
4
- import { Hono } from "hono";
5
- import type { Bindings } from "../../types.js";
6
- import type { AppVariables } from "../../app.js";
7
- type Env = {
8
- Bindings: Bindings;
9
- Variables: AppVariables;
10
- };
11
- export declare const appearanceRoutes: Hono<Env, import("hono/types").BlankSchema, "/">;
12
- export {};
13
- //# sourceMappingURL=appearance.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"appearance.d.ts","sourceRoot":"","sources":["../../../src/routes/dash/appearance.tsx"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAQjD,KAAK,GAAG,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CAAC;AAE3D,eAAO,MAAM,gBAAgB,kDAAkB,CAAC"}