@jant/core 0.6.8 → 0.6.10

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 (77) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-9P4rVCe2.js → app-CGHkOdme.js} +3450 -3121
  3. package/dist/app-D24n0DoH.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/{client-CXnEhyyv.js → client-DYrWuaIk.js} +1 -1
  6. package/dist/client/_assets/{client-auth-CSItbyU8.js → client-auth-B5Re0uCd.js} +187 -167
  7. package/dist/client/_assets/client-xWDl78yi.css +2 -0
  8. package/dist/{export-Be082J0n.js → export-DY1v5Iqu.js} +2 -2
  9. package/dist/{github-sync-D1Cw8mOY.js → github-sync-2_T7nbOv.js} +1 -1
  10. package/dist/{github-sync-_kPWM4m9.js → github-sync-LefaslGJ.js} +2 -2
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/client/components/__tests__/jant-settings-avatar.test.ts +8 -2
  15. package/src/client/components/__tests__/jant-settings-general.test.ts +64 -12
  16. package/src/client/components/jant-compose-dialog.ts +12 -0
  17. package/src/client/components/jant-settings-general.ts +74 -21
  18. package/src/client/components/settings-types.ts +13 -0
  19. package/src/client/settings-bridge.ts +3 -0
  20. package/src/client/tiptap/__tests__/link-toolbar.test.ts +41 -0
  21. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  22. package/src/client/tiptap/bubble-menu.ts +37 -4
  23. package/src/client/tiptap/link-toolbar.ts +63 -1
  24. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  25. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  26. package/src/db/migrations/meta/_journal.json +7 -0
  27. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  28. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  29. package/src/db/migrations/pg/meta/_journal.json +7 -0
  30. package/src/db/pg/schema.ts +36 -0
  31. package/src/db/schema.ts +36 -0
  32. package/src/i18n/__tests__/middleware.test.ts +46 -0
  33. package/src/i18n/locales/settings/en.po +282 -27
  34. package/src/i18n/locales/settings/en.ts +1 -1
  35. package/src/i18n/locales/settings/zh-Hans.po +282 -27
  36. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  37. package/src/i18n/locales/settings/zh-Hant.po +282 -27
  38. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  39. package/src/i18n/middleware.ts +17 -8
  40. package/src/i18n/supported-locales.ts +5 -4
  41. package/src/lib/__tests__/feed.test.ts +5 -1
  42. package/src/lib/feed.ts +6 -3
  43. package/src/lib/ids.ts +1 -0
  44. package/src/lib/resolve-config.ts +1 -0
  45. package/src/lib/upload.ts +14 -0
  46. package/src/routes/api/__tests__/settings.test.ts +1 -4
  47. package/src/routes/api/__tests__/upload.test.ts +2 -0
  48. package/src/routes/api/internal/__tests__/uploads.test.ts +19 -1
  49. package/src/routes/api/settings.ts +2 -1
  50. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  51. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  52. package/src/routes/dash/settings.tsx +22 -4
  53. package/src/routes/feed/__tests__/feed.test.ts +58 -19
  54. package/src/routes/feed/feed.ts +37 -28
  55. package/src/routes/pages/featured.tsx +17 -0
  56. package/src/routes/pages/latest.tsx +25 -0
  57. package/src/services/__tests__/media.test.ts +191 -30
  58. package/src/services/__tests__/settings.test.ts +55 -0
  59. package/src/services/bootstrap.ts +7 -0
  60. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  61. package/src/services/media.ts +169 -42
  62. package/src/services/post.ts +1 -1
  63. package/src/services/settings.ts +49 -15
  64. package/src/services/upload-session.ts +13 -3
  65. package/src/styles/tokens.css +21 -4
  66. package/src/styles/ui.css +44 -1
  67. package/src/types/bindings.ts +1 -0
  68. package/src/types/config.ts +13 -0
  69. package/src/ui/__tests__/color-themes.test.ts +2 -2
  70. package/src/ui/color-themes.ts +32 -0
  71. package/src/ui/dash/appearance/ColorThemeContent.tsx +264 -29
  72. package/src/ui/dash/settings/GeneralContent.tsx +54 -4
  73. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +3 -2
  74. package/src/ui/layouts/BaseLayout.tsx +3 -2
  75. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +17 -4
  76. package/dist/app-DaxS_Cz-.js +0 -6
  77. package/dist/client/_assets/client-C6peCkkD.css +0 -2
@@ -2483,7 +2483,7 @@ export function createPostService(
2483
2483
  .select()
2484
2484
  .from(posts)
2485
2485
  .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)))
2486
- .orderBy(posts.createdAt);
2486
+ .orderBy(posts.createdAt, posts.id);
2487
2487
 
2488
2488
  return hydratePosts(rows);
2489
2489
  },
@@ -19,6 +19,7 @@ import {
19
19
  } from "../lib/constants.js";
20
20
  import {
21
21
  baseLocale,
22
+ isLocale,
22
23
  isValidContentLanguage,
23
24
  normalizeContentLanguage,
24
25
  } from "../i18n/locales.js";
@@ -41,6 +42,8 @@ export interface GeneralSettingsData {
41
42
  siteDescription: string;
42
43
  siteFooter: string;
43
44
  siteLanguage: string;
45
+ /** Admin UI locale; empty string follows the content language. */
46
+ dashboardLanguage?: string;
44
47
  cjkSerifFont: string;
45
48
  showJantBrandingOnHome: boolean;
46
49
  homeDefaultView?: FeedKind;
@@ -61,6 +64,11 @@ export interface SiteSettingsResult {
61
64
 
62
65
  export interface LocaleSettingsData {
63
66
  siteLanguage: string;
67
+ /**
68
+ * Admin dashboard UI locale. Empty string clears the explicit setting so the
69
+ * dashboard follows the content language. When set, must be a catalog locale.
70
+ */
71
+ dashboardLanguage?: string;
64
72
  cjkSerifFont: string;
65
73
  timeZone: string;
66
74
  }
@@ -97,7 +105,11 @@ export interface SettingsService {
97
105
  ): Promise<SiteSettingsResult>;
98
106
  updateLocaleSettings(
99
107
  data: LocaleSettingsData,
100
- opts: { oldLanguage: string; oldCjkSerifFont?: string },
108
+ opts: {
109
+ oldLanguage: string;
110
+ oldCjkSerifFont?: string;
111
+ oldDashboardLanguage?: string;
112
+ },
101
113
  ): Promise<{ languageChanged: boolean }>;
102
114
  updateFeedSettings(data: { mainRssFeed?: FeedKind }): Promise<void>;
103
115
  updateHomeBranding(showJantBrandingOnHome: boolean): Promise<void>;
@@ -131,15 +143,16 @@ export interface SettingsService {
131
143
  */
132
144
  uploadAvatar(data: AvatarUploadData, deps: AvatarUploadDeps): Promise<void>;
133
145
  /**
134
- * Remove avatar and all favicon-related settings.
135
- * Deletes the apple-touch-icon from storage if it exists.
136
- *
137
- * @param storage - Optional storage driver for deleting the apple-touch-icon file
146
+ * Remove avatar and all favicon-related settings. The apple-touch-icon's
147
+ * media row is deleted and its storage object is retired (moved to the
148
+ * recycle bin, or deleted) via the media service when supplied — so removal
149
+ * is recoverable rather than erasing the bytes with no trace.
138
150
  */
139
- removeAvatar(
140
- storage?: StorageDriver | null,
141
- deps?: { media?: MediaService; storageProvider?: string },
142
- ): Promise<void>;
151
+ removeAvatar(deps?: {
152
+ storage?: StorageDriver | null;
153
+ media?: MediaService;
154
+ storageProvider?: string;
155
+ }): Promise<void>;
143
156
  }
144
157
 
145
158
  export function createSettingsService(
@@ -301,6 +314,26 @@ export function createSettingsService(
301
314
  normalizeContentLanguage(trimmedLanguage),
302
315
  );
303
316
 
317
+ // Dashboard UI locale. undefined = leave untouched; "" = clear so the
318
+ // dashboard follows the content language; otherwise it must be one of the
319
+ // translated catalog locales.
320
+ let dashboardChanged = false;
321
+ if (data.dashboardLanguage !== undefined) {
322
+ const dashboardLanguage = data.dashboardLanguage.trim();
323
+ if (dashboardLanguage && !isLocale(dashboardLanguage)) {
324
+ throw new ValidationError(
325
+ "Choose a dashboard language Jant is translated into.",
326
+ );
327
+ }
328
+ if (dashboardLanguage) {
329
+ await this.set("DASHBOARD_LANGUAGE", dashboardLanguage);
330
+ } else {
331
+ await this.remove("DASHBOARD_LANGUAGE");
332
+ }
333
+ dashboardChanged =
334
+ (opts.oldDashboardLanguage ?? "") !== dashboardLanguage;
335
+ }
336
+
304
337
  // CJK serif font setting
305
338
  const cjkFont = data.cjkSerifFont?.trim() ?? "";
306
339
  if (cjkFont && isCjkSerifFont(cjkFont) && cjkFont !== "off") {
@@ -329,7 +362,8 @@ export function createSettingsService(
329
362
  return {
330
363
  languageChanged:
331
364
  opts.oldLanguage !== trimmedLanguage ||
332
- (opts.oldCjkSerifFont ?? "off") !== effectiveCjkFont,
365
+ (opts.oldCjkSerifFont ?? "off") !== effectiveCjkFont ||
366
+ dashboardChanged,
333
367
  };
334
368
  },
335
369
 
@@ -469,19 +503,19 @@ export function createSettingsService(
469
503
  await this.set("SITE_FAVICON_VERSION", version);
470
504
  },
471
505
 
472
- async removeAvatar(storage, deps) {
506
+ async removeAvatar(deps) {
473
507
  const appleTouchKey = await this.get("SITE_FAVICON_APPLE_TOUCH");
474
- if (storage && appleTouchKey) {
475
- await storage.delete(appleTouchKey);
476
- }
477
508
 
509
+ // Retire the apple-touch-icon through the media service so its object goes
510
+ // to the recycle bin (recoverable) rather than being erased now. Also
511
+ // removes its media row.
478
512
  if (deps?.media && deps.storageProvider && appleTouchKey) {
479
513
  const existing = await deps.media.getByStorageKey(
480
514
  appleTouchKey,
481
515
  deps.storageProvider,
482
516
  );
483
517
  if (existing) {
484
- await deps.media.delete(existing.id);
518
+ await deps.media.delete(existing.id, deps.storage);
485
519
  }
486
520
  }
487
521
 
@@ -148,6 +148,7 @@ export interface UploadSessionService {
148
148
  abortedMultipartUploads: number;
149
149
  deletedSessions: number;
150
150
  deletedOrphanMedia: number;
151
+ purgedStorageObjects: number;
151
152
  }>;
152
153
  abort(id: string, deps: { storage: StorageDriver }): Promise<void>;
153
154
  }
@@ -766,9 +767,10 @@ export function createUploadSessionService(
766
767
  }
767
768
 
768
769
  // Reap finalized media that was uploaded during compose but never
769
- // attached to a post (`postId IS NULL`) past the grace window. Deletes
770
- // the S3/R2 objects and DB rows (best-effort storage delete). Bounded by
771
- // the same per-run `limit`; any backlog drains over subsequent runs.
770
+ // attached to a post (`postId IS NULL`) past the grace window. This now
771
+ // soft-deletes via the media service: the DB row is removed and the
772
+ // storage object is enqueued for deferred deletion. Bounded by the same
773
+ // per-run `limit`; any backlog drains over subsequent runs.
772
774
  const orphanIds = await media.listOrphanedMediaIds({
773
775
  before: now() - ORPHAN_MEDIA_GRACE_SECONDS,
774
776
  limit,
@@ -777,10 +779,18 @@ export function createUploadSessionService(
777
779
  await media.deleteByIds(orphanIds, deps.storage);
778
780
  }
779
781
 
782
+ // Physically delete storage objects whose recycle window has elapsed.
783
+ // Skips any object a live media row still references (re-uploads).
784
+ const purgedStorageObjects = await media.purgeDueStorageObjects(
785
+ { before: now(), limit, provider: deps.storageDriver },
786
+ deps.storage,
787
+ );
788
+
780
789
  return {
781
790
  abortedMultipartUploads,
782
791
  deletedSessions,
783
792
  deletedOrphanMedia: orphanIds.length,
793
+ purgedStorageObjects,
784
794
  };
785
795
  },
786
796
  };
@@ -381,6 +381,21 @@
381
381
  @media (max-width: 760px) {
382
382
  :root {
383
383
  --site-padding: 1.875rem;
384
+ }
385
+ }
386
+
387
+ /* Tufte two-column → single-column collapse.
388
+ *
389
+ * Below this width the post column is already narrowed to `min(100%, 35rem)`
390
+ * (preset.css) and there is no room for a 45% sidenote gutter, so all content
391
+ * goes full-width. Single images (MediaGallery getSingleVisualWidth) and link
392
+ * previews (.link-preview) read this token directly, so they collapse here too.
393
+ *
394
+ * Keep this breakpoint in sync with the post-column collapse (preset.css /
395
+ * ui.css feed-divider, both `max-width: 1024px`) and the sidenote float→inline
396
+ * collapse (ui.css). They are one layout switch; do not let them drift apart. */
397
+ @media (max-width: 1024px) {
398
+ :root {
384
399
  --layout-content-width: 100%;
385
400
  }
386
401
  }
@@ -467,13 +482,15 @@
467
482
 
468
483
  @media (max-width: 760px), (hover: none) and (pointer: coarse) {
469
484
  :root {
470
- /* Content layer — tighter scale for mobile reading
471
- Ratio ≈ 1.9 : 1.4 : 1.15 : 1 (display:title:subtitle:body)
472
- 13px base 31.9 / 24 / 19.5 / 16.9 */
485
+ /* Content layer — tighter scale for mobile reading.
486
+ Ratio ≈ 1.88 : 1.42 : 1.15 : 1 (display:title:subtitle:body).
487
+ At 15px root × 0.78 content-scale: 28.7 / 21.6 / 17.6 / 15.2px.
488
+ Body is floored to a 16px minimum below (max()) for readability;
489
+ the floor still yields to calc() when the user zooms the root up. */
473
490
  --type-content-display: calc(2.45rem * var(--type-content-scale));
474
491
  --type-content-title: calc(1.85rem * var(--type-content-scale));
475
492
  --type-content-subtitle: calc(1.5rem * var(--type-content-scale));
476
- --type-content-body: calc(1.3rem * var(--type-content-scale));
493
+ --type-content-body: max(16px, calc(1.3rem * var(--type-content-scale)));
477
494
 
478
495
  /* UI layer — pixel floors for touch targets */
479
496
  --type-ui-title: max(16px, var(--type-secondary));
package/src/styles/ui.css CHANGED
@@ -2433,7 +2433,12 @@
2433
2433
  display: none;
2434
2434
  }
2435
2435
 
2436
- @media (max-width: 760px) {
2436
+ /* Collapse sidenotes from floated margin notes to inline tap-to-toggle at the
2437
+ same width the post column drops to `min(100%, 35rem)` (preset.css) and the
2438
+ Tufte gutter disappears — see --layout-content-width in tokens.css. Below
2439
+ 1024px a floated `width: 50%; margin-right: -60%` note has no gutter to land
2440
+ in and overflows the right edge, so it must inline instead. */
2441
+ @media (max-width: 1024px) {
2437
2442
  label.margin-toggle:not(.sidenote-number) {
2438
2443
  display: inline;
2439
2444
  cursor: pointer;
@@ -11110,6 +11115,24 @@
11110
11115
  line-height: 1.28;
11111
11116
  }
11112
11117
 
11118
+ .theme-preview-accent {
11119
+ display: inline-flex;
11120
+ align-items: center;
11121
+ gap: 0.3rem;
11122
+ white-space: nowrap;
11123
+ color: var(--preview-muted-light);
11124
+ }
11125
+
11126
+ .theme-preview-swatch {
11127
+ flex: none;
11128
+ width: 0.62rem;
11129
+ height: 0.62rem;
11130
+ border-radius: 3px;
11131
+ background: var(--preview-primary-light);
11132
+ box-shadow: 0 0 0 1px
11133
+ color-mix(in srgb, var(--preview-fg-light) 18%, transparent);
11134
+ }
11135
+
11113
11136
  .theme-preview-meta {
11114
11137
  color: var(--preview-muted-light);
11115
11138
  line-height: 1.55;
@@ -11143,6 +11166,16 @@
11143
11166
  color: var(--preview-muted-dark);
11144
11167
  }
11145
11168
 
11169
+ .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-accent {
11170
+ color: var(--preview-muted-dark);
11171
+ }
11172
+
11173
+ .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-swatch {
11174
+ background: var(--preview-primary-dark);
11175
+ box-shadow: 0 0 0 1px
11176
+ color-mix(in srgb, var(--preview-fg-dark) 18%, transparent);
11177
+ }
11178
+
11146
11179
  .theme-preview-panel[data-theme-preview-mode="dark"] .theme-preview-divider {
11147
11180
  border-color: var(--preview-border-dark);
11148
11181
  }
@@ -11671,6 +11704,16 @@
11671
11704
  color: var(--preview-muted-dark);
11672
11705
  }
11673
11706
 
11707
+ .theme-preview-panel[data-theme-preview-mode="auto"] .theme-preview-accent {
11708
+ color: var(--preview-muted-dark);
11709
+ }
11710
+
11711
+ .theme-preview-panel[data-theme-preview-mode="auto"] .theme-preview-swatch {
11712
+ background: var(--preview-primary-dark);
11713
+ box-shadow: 0 0 0 1px
11714
+ color-mix(in srgb, var(--preview-fg-dark) 18%, transparent);
11715
+ }
11716
+
11674
11717
  .theme-preview-panel[data-theme-preview-mode="auto"]
11675
11718
  .theme-preview-divider {
11676
11719
  border-color: var(--preview-border-dark);
@@ -35,6 +35,7 @@ export interface Bindings {
35
35
  SITE_NAME?: EnvBindingValue;
36
36
  SITE_DESCRIPTION?: EnvBindingValue;
37
37
  SITE_LANGUAGE?: EnvBindingValue;
38
+ DASHBOARD_LANGUAGE?: EnvBindingValue;
38
39
  HOME_DEFAULT_VIEW?: EnvBindingValue;
39
40
  MAIN_RSS_FEED?: EnvBindingValue;
40
41
  TIME_ZONE?: EnvBindingValue;
@@ -41,6 +41,13 @@ export const CONFIG_FIELDS = {
41
41
  envOnly: false,
42
42
  envKeys: ["SITE_LANGUAGE"],
43
43
  },
44
+ // Admin dashboard UI locale. Empty means "follow the content language"
45
+ // (resolved through the catalog fallback chain, i.e. today's behaviour).
46
+ DASHBOARD_LANGUAGE: {
47
+ defaultValue: "",
48
+ envOnly: false,
49
+ envKeys: ["DASHBOARD_LANGUAGE"],
50
+ },
44
51
  CJK_SERIF_FONT: {
45
52
  defaultValue: "off",
46
53
  envOnly: false,
@@ -455,6 +462,12 @@ export interface AppConfig {
455
462
  /** true only when description is set in DB or ENV (not just the default) */
456
463
  siteDescriptionExplicit: boolean;
457
464
  siteLanguage: string;
465
+ /**
466
+ * Admin dashboard UI locale. Empty string means "follow the content
467
+ * language" (derived via the catalog fallback chain). When set, it is one of
468
+ * the translated catalog locales ("en", "zh-Hans", "zh-Hant").
469
+ */
470
+ dashboardLanguage: string;
458
471
  /** CJK serif font locale: "off", "zh-Hans", "zh-Hant", "ja", or "ko" */
459
472
  cjkSerifFont: string;
460
473
  homeDefaultView: string;
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
2
2
  import { BUILTIN_COLOR_THEMES } from "../color-themes.js";
3
3
 
4
4
  describe("BUILTIN_COLOR_THEMES", () => {
5
- it("contains 13 themes", () => {
6
- expect(BUILTIN_COLOR_THEMES).toHaveLength(14);
5
+ it("contains 15 themes", () => {
6
+ expect(BUILTIN_COLOR_THEMES).toHaveLength(15);
7
7
  });
8
8
 
9
9
  it("keeps Tufte as the first theme", () => {
@@ -624,6 +624,38 @@ export const BUILTIN_COLOR_THEMES: ColorTheme[] = [
624
624
  },
625
625
  }),
626
626
 
627
+ // Pure white with neutral grays — zero tint, clean and clinical
628
+ defineTheme({
629
+ id: "snow",
630
+ name: "Snow",
631
+ light: {
632
+ bg: "oklch(1 0 0)",
633
+ fg: "oklch(0.205 0 0)",
634
+ primary: "oklch(0.25 0 0)",
635
+ primaryFg: "oklch(0.99 0 0)",
636
+ siteAccent: "oklch(0.37 0 0)",
637
+ muted: "oklch(0.965 0 0)",
638
+ mutedFg: "oklch(0.5 0 0)",
639
+ border: "oklch(0.91 0 0)",
640
+ readingTitle: "oklch(0.17 0 0)",
641
+ readingHeading: "oklch(0.205 0 0)",
642
+ readingBody: "oklch(0.24 0 0)",
643
+ readingQuote: "oklch(0.4 0 0)",
644
+ dashBg: "oklch(0.975 0 0)",
645
+ },
646
+ dark: {
647
+ bg: "oklch(0.17 0 0)",
648
+ fg: "oklch(0.92 0 0)",
649
+ primary: "oklch(0.85 0 0)",
650
+ primaryFg: "oklch(0.17 0 0)",
651
+ siteAccent: "oklch(0.78 0 0)",
652
+ muted: "oklch(0.235 0 0)",
653
+ mutedFg: "oklch(0.65 0 0)",
654
+ border: "oklch(0.3 0 0)",
655
+ dashBg: "oklch(0.15 0 0)",
656
+ },
657
+ }),
658
+
627
659
  // Deep coffee brown — rich and grounded
628
660
  defineTheme({
629
661
  id: "espresso",