@jant/core 0.3.35 → 0.3.36

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 (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3026 -2778
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +7 -7
  93. package/src/routes/feed/rss.ts +8 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /package/src/{lib → client}/toast.ts +0 -0
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * CRUD operations for posts with Thread support.
5
5
  * Posts have format (note/link/quote), status (draft/published),
6
- * featured flag, and pinned flag.
6
+ * visibility (listed/featured/unlisted), and pinned flag.
7
7
  */
8
8
 
9
9
  import { eq, and, isNull, desc, or, inArray, sql } from "drizzle-orm";
@@ -11,10 +11,18 @@ import type { BatchItem } from "drizzle-orm/batch";
11
11
  import type { Database } from "../db/index.js";
12
12
  import { posts, postCollections } from "../db/schema.js";
13
13
  import { now } from "../lib/time.js";
14
- import { render as renderMarkdown } from "../lib/markdown.js";
14
+ import { renderTiptapJson } from "../lib/tiptap-render.js";
15
+ import { extractSummary } from "../lib/summary.js";
15
16
  import type { StorageDriver } from "../lib/storage.js";
16
17
  import type { MediaService } from "./media.js";
17
- import type { Format, Status, Post, CreatePost, UpdatePost } from "../types.js";
18
+ import type {
19
+ Format,
20
+ Status,
21
+ Visibility,
22
+ Post,
23
+ CreatePost,
24
+ UpdatePost,
25
+ } from "../types.js";
18
26
  import type { PathRegistryService } from "./path-registry.js";
19
27
  import { ConflictError } from "../lib/errors.js";
20
28
 
@@ -27,11 +35,13 @@ export interface PostDeleteDeps {
27
35
  export interface PostFilters {
28
36
  format?: Format;
29
37
  status?: Status;
30
- featured?: boolean;
38
+ visibility?: Visibility;
31
39
  pinned?: boolean;
32
40
  collectionId?: number;
33
41
  /** Exclude posts that are replies (have threadId set) */
34
42
  excludeReplies?: boolean;
43
+ /** Exclude unlisted posts from results */
44
+ excludeUnlisted?: boolean;
35
45
  includeDeleted?: boolean;
36
46
  threadId?: number;
37
47
  limit?: number;
@@ -39,14 +49,24 @@ export interface PostFilters {
39
49
  offset?: number; // offset for page-based pagination
40
50
  }
41
51
 
52
+ /** Config for automatic summary extraction */
53
+ export interface SummaryConfig {
54
+ maxParagraphs: number;
55
+ maxChars: number;
56
+ }
57
+
42
58
  export interface PostService {
43
59
  getById(id: number): Promise<Post | null>;
44
60
  getByPath(path: string): Promise<Post | null>;
45
61
  list(filters?: PostFilters): Promise<Post[]>;
46
62
  /** Count posts matching filters (ignores cursor, offset, limit) */
47
63
  count(filters?: PostFilters): Promise<number>;
48
- create(data: CreatePost): Promise<Post>;
49
- update(id: number, data: UpdatePost): Promise<Post | null>;
64
+ create(data: CreatePost, summaryConfig?: SummaryConfig): Promise<Post>;
65
+ update(
66
+ id: number,
67
+ data: UpdatePost,
68
+ summaryConfig?: SummaryConfig,
69
+ ): Promise<Post | null>;
50
70
  /**
51
71
  * Soft-delete a post and clean up its media (storage files + DB records).
52
72
  * Thread roots cascade to all replies.
@@ -56,10 +76,10 @@ export interface PostService {
56
76
  */
57
77
  delete(id: number, deps?: PostDeleteDeps): Promise<boolean>;
58
78
  getThread(rootId: number): Promise<Post[]>;
59
- updateThreadStatusAndFeatured(
79
+ updateThreadStatusAndVisibility(
60
80
  rootId: number,
61
81
  status: Status,
62
- featured: boolean,
82
+ visibility: Visibility,
63
83
  ): Promise<void>;
64
84
  /** Get reply counts for multiple posts */
65
85
  getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
@@ -70,10 +90,23 @@ export interface PostService {
70
90
  ): Promise<Map<number, Post[]>>;
71
91
  }
72
92
 
73
- /** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
93
+ /** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
74
94
  function isUniqueConstraintError(err: unknown): boolean {
75
- const msg = String(err);
76
- return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
95
+ let current: unknown = err;
96
+ while (current) {
97
+ const msg = String(current);
98
+ if (
99
+ msg.includes("UNIQUE constraint") ||
100
+ msg.includes("SQLITE_CONSTRAINT")
101
+ ) {
102
+ return true;
103
+ }
104
+ current =
105
+ current instanceof Error && current.cause !== current
106
+ ? current.cause
107
+ : undefined;
108
+ }
109
+ return false;
77
110
  }
78
111
 
79
112
  export function createPostService(
@@ -87,8 +120,11 @@ export function createPostService(
87
120
  if (filters.status) {
88
121
  conditions.push(eq(posts.status, filters.status));
89
122
  }
90
- if (filters.featured !== undefined) {
91
- conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
123
+ if (filters.visibility !== undefined) {
124
+ conditions.push(eq(posts.visibility, filters.visibility));
125
+ }
126
+ if (filters.excludeUnlisted) {
127
+ conditions.push(sql`${posts.visibility} != 'unlisted'`);
92
128
  }
93
129
  if (filters.pinned !== undefined) {
94
130
  conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
@@ -120,7 +156,7 @@ export function createPostService(
120
156
  id: row.id,
121
157
  format: row.format as Format,
122
158
  status: row.status as Status,
123
- featured: row.featured,
159
+ visibility: row.visibility as Visibility,
124
160
  pinned: row.pinned,
125
161
  path: row.path,
126
162
  title: row.title,
@@ -128,6 +164,7 @@ export function createPostService(
128
164
  body: row.body,
129
165
  bodyHtml: row.bodyHtml,
130
166
  quoteText: row.quoteText,
167
+ summary: row.summary,
131
168
  rating: row.rating,
132
169
  replyToId: row.replyToId,
133
170
  threadId: row.threadId,
@@ -190,27 +227,37 @@ export function createPostService(
190
227
  return result[0]?.count ?? 0;
191
228
  },
192
229
 
193
- async create(data) {
230
+ async create(data, summaryConfig) {
194
231
  const timestamp = now();
195
232
 
196
- const bodyHtml = data.body ? renderMarkdown(data.body) : null;
233
+ const bodyHtml = data.body ? renderTiptapJson(data.body) : null;
234
+
235
+ // Generate summary for titled notes with body content
236
+ let summary: string | null = null;
237
+ if (data.format === "note" && data.title && data.body && summaryConfig) {
238
+ summary = extractSummary(
239
+ data.body,
240
+ summaryConfig.maxParagraphs,
241
+ summaryConfig.maxChars,
242
+ );
243
+ }
197
244
 
198
245
  // Handle thread relationship
199
246
  let threadId: number | null = null;
200
247
  let status: Status = data.status ?? "published";
201
- let featured = data.featured ?? false;
248
+ let visibility: Visibility = data.visibility ?? "listed";
202
249
 
203
250
  if (data.replyToId) {
204
251
  const parent = await this.getById(data.replyToId);
205
252
  if (parent) {
206
253
  threadId = parent.threadId ?? parent.id;
207
- // Inherit status and featured from root
254
+ // Inherit status and visibility from root
208
255
  const root = parent.threadId
209
256
  ? await this.getById(parent.threadId)
210
257
  : parent;
211
258
  if (root) {
212
259
  status = root.status as Status;
213
- featured = root.featured === 1;
260
+ visibility = root.visibility as Visibility;
214
261
  }
215
262
  }
216
263
  }
@@ -229,7 +276,7 @@ export function createPostService(
229
276
  .values({
230
277
  format: data.format,
231
278
  status,
232
- featured: featured ? 1 : 0,
279
+ visibility,
233
280
  pinned: data.pinned ? 1 : 0,
234
281
  path: data.path ?? null,
235
282
  title: data.title ?? null,
@@ -237,6 +284,7 @@ export function createPostService(
237
284
  body: data.body ?? null,
238
285
  bodyHtml,
239
286
  quoteText: data.quoteText ?? null,
287
+ summary,
240
288
  rating: data.rating ?? null,
241
289
  replyToId: data.replyToId ?? null,
242
290
  threadId,
@@ -275,7 +323,7 @@ export function createPostService(
275
323
  return post;
276
324
  },
277
325
 
278
- async update(id, data) {
326
+ async update(id, data, summaryConfig) {
279
327
  const existing = await this.getById(id);
280
328
  if (!existing) return null;
281
329
 
@@ -310,22 +358,38 @@ export function createPostService(
310
358
 
311
359
  if (data.body !== undefined) {
312
360
  updates.body = data.body;
313
- updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
361
+ updates.bodyHtml = data.body ? renderTiptapJson(data.body) : null;
362
+ }
363
+
364
+ // Recompute summary when body, title, or format change
365
+ if (summaryConfig) {
366
+ const format = data.format ?? (existing.format as Format);
367
+ const title = data.title !== undefined ? data.title : existing.title;
368
+ const body = data.body !== undefined ? data.body : existing.body;
369
+ if (format === "note" && title && body) {
370
+ updates.summary = extractSummary(
371
+ body,
372
+ summaryConfig.maxParagraphs,
373
+ summaryConfig.maxChars,
374
+ );
375
+ } else {
376
+ updates.summary = null;
377
+ }
314
378
  }
315
379
 
316
- // Handle status/featured change - cascade to thread if this is root
380
+ // Handle status/visibility change - cascade to thread if this is root
317
381
  const statusChanged =
318
382
  data.status !== undefined && data.status !== existing.status;
319
- const featuredChanged =
320
- data.featured !== undefined &&
321
- (data.featured ? 1 : 0) !== existing.featured;
383
+ const visibilityChanged =
384
+ data.visibility !== undefined &&
385
+ data.visibility !== existing.visibility;
322
386
 
323
387
  if (statusChanged) updates.status = data.status;
324
- if (featuredChanged) updates.featured = data.featured ? 1 : 0;
388
+ if (visibilityChanged) updates.visibility = data.visibility;
325
389
 
326
390
  // Build all write queries for atomic execution via D1 batch
327
391
  const needsCascade =
328
- (statusChanged || featuredChanged) && !existing.threadId;
392
+ (statusChanged || visibilityChanged) && !existing.threadId;
329
393
  const needsCollectionSync = data.collectionIds !== undefined;
330
394
  const hasExtraWrites = needsCascade || needsCollectionSync;
331
395
 
@@ -348,13 +412,8 @@ export function createPostService(
348
412
  .update(posts)
349
413
  .set({
350
414
  status: data.status ?? (existing.status as Status),
351
- featured: (
352
- data.featured !== undefined
353
- ? data.featured
354
- : existing.featured === 1
355
- )
356
- ? 1
357
- : 0,
415
+ visibility:
416
+ data.visibility ?? (existing.visibility as Visibility),
358
417
  updatedAt: timestamp,
359
418
  })
360
419
  .where(eq(posts.threadId, id)),
@@ -467,11 +526,11 @@ export function createPostService(
467
526
  return rows.map(toPost);
468
527
  },
469
528
 
470
- async updateThreadStatusAndFeatured(rootId, status, featured) {
529
+ async updateThreadStatusAndVisibility(rootId, status, visibility) {
471
530
  const timestamp = now();
472
531
  await db
473
532
  .update(posts)
474
- .set({ status, featured: featured ? 1 : 0, updatedAt: timestamp })
533
+ .set({ status, visibility, updatedAt: timestamp })
475
534
  .where(eq(posts.threadId, rootId));
476
535
  },
477
536
 
@@ -27,7 +27,7 @@ interface RawSearchRow {
27
27
  id: number;
28
28
  format: string;
29
29
  status: string;
30
- featured: number;
30
+ visibility: string;
31
31
  pinned: number;
32
32
  path: string | null;
33
33
  title: string | null;
@@ -35,6 +35,7 @@ interface RawSearchRow {
35
35
  body: string | null;
36
36
  body_html: string | null;
37
37
  quote_text: string | null;
38
+ summary: string | null;
38
39
  rating: number | null;
39
40
  collection_id: number | null;
40
41
  reply_to_id: number | null;
@@ -97,7 +98,7 @@ export function createSearchService(d1: D1Database): SearchService {
97
98
  id: row.id,
98
99
  format: row.format as Post["format"],
99
100
  status: row.status as Post["status"],
100
- featured: row.featured,
101
+ visibility: row.visibility as Post["visibility"],
101
102
  pinned: row.pinned,
102
103
  path: row.path,
103
104
  title: row.title,
@@ -105,6 +106,7 @@ export function createSearchService(d1: D1Database): SearchService {
105
106
  body: row.body,
106
107
  bodyHtml: row.body_html,
107
108
  quoteText: row.quote_text,
109
+ summary: row.summary,
108
110
  rating: row.rating,
109
111
  replyToId: row.reply_to_id,
110
112
  threadId: row.thread_id,
@@ -44,6 +44,7 @@ export interface AvatarUploadDeps {
44
44
  media: MediaService;
45
45
  storage: StorageDriver;
46
46
  storageProvider: string;
47
+ maxFileSizeMB: number;
47
48
  }
48
49
 
49
50
  export interface SettingsService {
@@ -194,7 +195,7 @@ export function createSettingsService(db: Database): SettingsService {
194
195
  // Header nav max visible: only update if provided (may be managed separately)
195
196
  if (data.headerNavMaxVisible !== undefined) {
196
197
  const navMax = parseInt(String(data.headerNavMaxVisible), 10);
197
- if (!isNaN(navMax) && navMax !== 3) {
198
+ if (!isNaN(navMax) && navMax !== 2) {
198
199
  await this.set("HEADER_NAV_MAX_VISIBLE", String(navMax));
199
200
  } else {
200
201
  await this.remove("HEADER_NAV_MAX_VISIBLE");
@@ -215,7 +216,10 @@ export function createSettingsService(db: Database): SettingsService {
215
216
  },
216
217
 
217
218
  async uploadAvatar(data, deps) {
218
- const uploadError = validateUploadFile(data.file as unknown as File);
219
+ const uploadError = validateUploadFile(data.file as unknown as File, {
220
+ imagesOnly: true,
221
+ maxFileSizeMB: deps.maxFileSizeMB,
222
+ });
219
223
  if (uploadError) {
220
224
  throw new ValidationError(uploadError);
221
225
  }
@@ -23,16 +23,6 @@ svg[stroke-width].icon-fine {
23
23
  padding-left: var(--site-padding);
24
24
  padding-right: var(--site-padding);
25
25
  }
26
-
27
- .container-sidebar {
28
- max-width: calc(
29
- var(--sidebar-width) + var(--sidebar-gap) + var(--site-width)
30
- );
31
- margin-left: auto;
32
- margin-right: auto;
33
- padding-left: var(--site-padding);
34
- padding-right: var(--site-padding);
35
- }
36
26
  }
37
27
 
38
28
  /* Toast notifications */
@@ -121,29 +111,50 @@ svg[stroke-width].icon-fine {
121
111
  padding-bottom: 12px;
122
112
  }
123
113
 
124
- .dash-header-left {
114
+ .dash-header-avatar-link {
115
+ flex-shrink: 0;
116
+ line-height: 0;
117
+ }
118
+
119
+ .dash-header-avatar {
120
+ width: 22px;
121
+ height: 22px;
122
+ border-radius: 5px;
123
+ }
124
+
125
+ .dash-header-avatar:is(img) {
126
+ object-fit: cover;
127
+ }
128
+
129
+ .dash-header-avatar-fallback {
125
130
  display: flex;
126
131
  align-items: center;
127
- gap: 6px;
132
+ justify-content: center;
133
+ color: white;
134
+ font-size: 0.625rem;
135
+ font-weight: 600;
136
+ line-height: 1;
128
137
  }
129
138
 
130
139
  .dash-header-nav {
131
140
  display: flex;
132
141
  align-items: center;
133
- justify-content: center;
134
- gap: 4px;
135
- flex: 1;
142
+ gap: 16px;
136
143
  }
137
144
 
138
- .dash-header-logo {
139
- font-size: 1.125rem;
140
- font-weight: 800;
141
- line-height: 1;
142
- color: var(--color-foreground);
145
+ .dash-header-nav-sep {
146
+ display: none;
143
147
  }
144
148
 
145
- .dash-header-site-link {
146
- @apply flex items-center justify-center;
149
+ .dash-header-right {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 8px;
153
+ margin-left: auto;
154
+ }
155
+
156
+ .dash-header-link {
157
+ font-size: 0.8125rem;
147
158
  color: var(--color-muted-foreground);
148
159
  transition: color 0.15s;
149
160
 
@@ -152,33 +163,41 @@ svg[stroke-width].icon-fine {
152
163
  }
153
164
  }
154
165
 
155
- .dash-header-link {
156
- position: relative;
166
+ .dash-header-link-active {
167
+ color: var(--color-foreground);
168
+ font-weight: 500;
169
+ }
170
+
171
+ .dash-header-visit {
172
+ @apply flex items-center gap-3;
157
173
  font-size: 0.875rem;
158
- padding: 6px 10px;
159
- border-radius: var(--radius);
160
174
  color: var(--color-muted-foreground);
161
- transition:
162
- color 0.15s,
163
- background-color 0.15s;
175
+ transition: color 0.15s;
164
176
 
165
177
  &:hover {
166
178
  color: var(--color-foreground);
167
- background-color: var(--color-accent);
168
179
  }
169
180
  }
170
181
 
171
- .dash-header-link-active {
172
- color: var(--color-foreground);
173
- font-weight: 600;
174
- background-color: var(--color-accent);
182
+ .dash-header-visit-text {
183
+ display: none;
184
+ }
185
+
186
+ @media (min-width: 700px) {
187
+ .dash-header-visit-text {
188
+ display: inline;
189
+ }
190
+
191
+ .dash-header-visit-icon {
192
+ display: none;
193
+ }
175
194
  }
176
195
 
177
196
  .dash-header-menu-btn {
178
197
  @apply flex items-center justify-center;
179
198
  width: 32px;
180
199
  height: 32px;
181
- border-radius: 999px;
200
+ border-radius: var(--radius);
182
201
  border: none;
183
202
  background: transparent;
184
203
  color: var(--color-muted-foreground);
@@ -198,33 +217,194 @@ svg[stroke-width].icon-fine {
198
217
  }
199
218
  }
200
219
 
201
- /* Sub-navigationunderline style for secondary tabs (Settings, Appearance) */
220
+ /* Breadcrumbsecond row below header on settings sub-pages */
221
+ @layer components {
222
+ .dash-breadcrumb {
223
+ display: flex;
224
+ align-items: center;
225
+ gap: 8px;
226
+ padding: 8px 0;
227
+ font-size: 0.8125rem;
228
+ }
229
+
230
+ .dash-breadcrumb-parent {
231
+ color: var(--color-muted-foreground);
232
+ transition: color 0.15s;
233
+
234
+ &:hover {
235
+ color: var(--color-foreground);
236
+ }
237
+ }
238
+
239
+ .dash-breadcrumb-sep {
240
+ color: var(--color-muted-foreground);
241
+ opacity: 0.5;
242
+ }
243
+
244
+ .dash-breadcrumb-current {
245
+ color: var(--color-foreground);
246
+ font-weight: 500;
247
+ }
248
+ }
249
+
250
+ /* Settings root — iOS-style grouped list */
202
251
  @layer components {
203
- .dash-subnav {
204
- @apply flex gap-4 mb-6;
205
-
206
- > a {
207
- position: relative;
208
- font-size: 0.8125rem;
209
- padding-bottom: 8px;
210
- color: var(--color-muted-foreground);
211
- transition: color 0.15s;
212
-
213
- &:hover {
214
- color: var(--color-foreground);
215
- }
216
-
217
- &.active {
218
- color: var(--color-foreground);
219
- font-weight: 500;
220
-
221
- &::after {
222
- content: "";
223
- @apply absolute inset-x-0 bottom-0 h-0.5 rounded-full;
224
- background-color: var(--color-foreground);
225
- }
226
- }
252
+ .settings-root {
253
+ @apply flex flex-col gap-6;
254
+ }
255
+
256
+ .settings-group-label {
257
+ @apply text-xs font-medium uppercase tracking-wider mb-2 px-1;
258
+ color: var(--color-muted-foreground);
259
+ }
260
+
261
+ .settings-group {
262
+ @apply border rounded-xl overflow-hidden;
263
+ border-radius: var(--dash-card-radius);
264
+ }
265
+
266
+ .settings-item {
267
+ @apply flex items-center gap-3 px-4 py-3;
268
+ transition: background-color 0.15s;
269
+ border-bottom: 1px solid var(--color-border);
270
+
271
+ &:last-child {
272
+ border-bottom: none;
227
273
  }
274
+
275
+ &:hover {
276
+ background-color: var(--color-accent);
277
+ }
278
+ }
279
+
280
+ .settings-item-icon {
281
+ @apply flex items-center justify-center shrink-0;
282
+ width: 28px;
283
+ height: 28px;
284
+ border-radius: 6px;
285
+ color: white;
286
+ }
287
+
288
+ .settings-item-text {
289
+ @apply flex flex-col flex-1 min-w-0;
290
+ }
291
+
292
+ .settings-item-name {
293
+ @apply text-sm font-medium;
294
+ color: var(--color-foreground);
295
+ }
296
+
297
+ .settings-item-desc {
298
+ @apply text-xs;
299
+ color: var(--color-muted-foreground);
300
+ }
301
+
302
+ .settings-item-chevron {
303
+ @apply shrink-0;
304
+ color: var(--color-muted-foreground);
305
+ opacity: 0.5;
306
+ }
307
+ }
308
+
309
+ /* Dashboard scoped font rules */
310
+ @layer components {
311
+ .dash-heading {
312
+ font-family: Georgia, "Times New Roman", serif;
313
+ }
314
+ }
315
+
316
+ /* Navigation preview — browser-chrome frame for nav preview */
317
+ @layer components {
318
+ .nav-preview {
319
+ @apply border rounded-lg overflow-hidden;
320
+ background-color: var(--color-card);
321
+ }
322
+
323
+ .nav-preview-chrome {
324
+ @apply flex items-center gap-3 px-4 py-2.5 border-b;
325
+ background-color: var(--color-muted);
326
+ }
327
+
328
+ .nav-preview-dots {
329
+ @apply flex gap-1.5;
330
+
331
+ > span {
332
+ @apply block size-2.5 rounded-full;
333
+ background-color: var(--color-muted-foreground);
334
+ opacity: 0.3;
335
+ }
336
+ }
337
+
338
+ .nav-preview-label {
339
+ @apply text-xs;
340
+ color: var(--color-muted-foreground);
341
+ }
342
+
343
+ .nav-preview-content {
344
+ @apply px-5 py-3;
345
+ }
346
+ }
347
+
348
+ /* Navigation items list — card-style draggable nav items */
349
+ @layer components {
350
+ .nav-items-list {
351
+ @apply flex flex-col gap-2;
352
+ }
353
+
354
+ .nav-item {
355
+ @apply border rounded-lg transition-shadow;
356
+
357
+ &:hover {
358
+ @apply shadow-xs;
359
+ }
360
+ }
361
+
362
+ .nav-item-editing {
363
+ @apply ring-1 ring-ring;
364
+ }
365
+
366
+ .nav-item-row {
367
+ @apply flex items-center gap-1 px-1 py-1;
368
+ }
369
+
370
+ .nav-item-handle {
371
+ @apply flex items-center justify-center w-8 h-8 shrink-0 cursor-grab rounded;
372
+ transition: background-color 0.15s;
373
+
374
+ &:hover {
375
+ background-color: var(--color-accent);
376
+ }
377
+
378
+ &:active {
379
+ cursor: grabbing;
380
+ }
381
+ }
382
+
383
+ .nav-item-info {
384
+ @apply flex flex-col flex-1 min-w-0 py-1.5 px-1.5 rounded cursor-pointer;
385
+ transition: background-color 0.15s;
386
+
387
+ &:hover {
388
+ background-color: var(--color-accent);
389
+ }
390
+ }
391
+
392
+ .nav-item-toggle {
393
+ @apply flex items-center justify-center w-8 h-8 shrink-0 rounded border-0 bg-transparent cursor-pointer;
394
+ color: var(--color-muted-foreground);
395
+ transition:
396
+ color 0.15s,
397
+ background-color 0.15s;
398
+
399
+ &:hover {
400
+ color: var(--color-foreground);
401
+ background-color: var(--color-accent);
402
+ }
403
+ }
404
+
405
+ .nav-item-edit {
406
+ @apply flex flex-col gap-3 px-4 pb-3 pt-3 border-t mx-1 mb-1;
407
+ border-color: var(--color-border);
228
408
  }
229
409
  }
230
410