@jant/core 0.3.36 → 0.3.37

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Session management: view active sessions and revoke them
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import { formatDate } from "../../../lib/time.js";
7
+
8
+ export interface SessionInfo {
9
+ token: string;
10
+ ipAddress: string | null;
11
+ userAgent: string | null;
12
+ createdAt: number;
13
+ isCurrent: boolean;
14
+ }
15
+
16
+ /**
17
+ * Parse a user-agent string into a human-readable device description.
18
+ *
19
+ * @param ua - Raw User-Agent header value
20
+ * @returns Short description like "Chrome on macOS" or "Safari on iPhone"
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * parseDevice("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/120")
25
+ * // "Chrome on macOS"
26
+ * ```
27
+ */
28
+ function parseDevice(ua: string | null): string | null {
29
+ if (!ua) return null;
30
+
31
+ let browser = "Unknown browser";
32
+ if (ua.includes("Firefox/")) browser = "Firefox";
33
+ else if (ua.includes("Edg/")) browser = "Edge";
34
+ else if (ua.includes("OPR/") || ua.includes("Opera/")) browser = "Opera";
35
+ else if (ua.includes("Chrome/") && ua.includes("Safari/")) browser = "Chrome";
36
+ else if (ua.includes("Safari/") && !ua.includes("Chrome/"))
37
+ browser = "Safari";
38
+ else if (ua.includes("curl/")) browser = "curl";
39
+
40
+ let os = "";
41
+ if (ua.includes("iPhone")) os = "iPhone";
42
+ else if (ua.includes("iPad")) os = "iPad";
43
+ else if (ua.includes("Android")) os = "Android";
44
+ else if (ua.includes("Macintosh") || ua.includes("Mac OS X")) os = "macOS";
45
+ else if (ua.includes("Windows")) os = "Windows";
46
+ else if (ua.includes("Linux")) os = "Linux";
47
+ else if (ua.includes("CrOS")) os = "ChromeOS";
48
+
49
+ return os ? `${browser} on ${os}` : browser;
50
+ }
51
+
52
+ /** Monitor icon for desktop sessions */
53
+ const ICON_DESKTOP = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg>`;
54
+
55
+ /** Smartphone icon for mobile sessions */
56
+ const ICON_MOBILE = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/></svg>`;
57
+
58
+ function isMobileUA(ua: string | null): boolean {
59
+ if (!ua) return false;
60
+ return /iPhone|iPad|iPod|Android|Mobile/i.test(ua);
61
+ }
62
+
63
+ function SessionRow({ session }: { session: SessionInfo }) {
64
+ const { t } = useLingui();
65
+ const device = parseDevice(session.userAgent);
66
+ const icon = isMobileUA(session.userAgent) ? ICON_MOBILE : ICON_DESKTOP;
67
+
68
+ return (
69
+ <div class="py-4 flex items-start gap-4 border-b border-border last:border-b-0">
70
+ <span
71
+ class="text-muted-foreground mt-0.5 shrink-0"
72
+ dangerouslySetInnerHTML={{ __html: icon }}
73
+ />
74
+ <div class="flex-1 min-w-0">
75
+ <div class="font-medium flex items-center gap-2">
76
+ {device ??
77
+ t({
78
+ message: "Unknown device",
79
+ comment:
80
+ "@context: Fallback label when session device can't be identified",
81
+ })}
82
+ {session.isCurrent && (
83
+ <span class="badge text-xs">
84
+ {t({
85
+ message: "Current",
86
+ comment:
87
+ "@context: Badge indicating the current active session",
88
+ })}
89
+ </span>
90
+ )}
91
+ </div>
92
+ <div class="text-sm text-muted-foreground mt-0.5">
93
+ {session.ipAddress && (
94
+ <>
95
+ <span>{session.ipAddress}</span>
96
+ <span class="mx-2">&middot;</span>
97
+ </>
98
+ )}
99
+ {t({
100
+ message: `Signed in ${formatDate(session.createdAt)}`,
101
+ comment: "@context: Session creation date",
102
+ })}
103
+ </div>
104
+ </div>
105
+ {!session.isCurrent && (
106
+ <button
107
+ type="button"
108
+ class="btn-sm-ghost text-destructive"
109
+ data-on:click__prevent={`@post('/settings/account/sessions/${session.token}/revoke')`}
110
+ >
111
+ {t({
112
+ message: "Revoke",
113
+ comment: "@context: Button to revoke a session",
114
+ })}
115
+ </button>
116
+ )}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ export function SessionsContent({ sessions }: { sessions: SessionInfo[] }) {
122
+ const { t } = useLingui();
123
+
124
+ return (
125
+ <div class="flex flex-col gap-6 max-w-2xl">
126
+ <div>
127
+ <h2 class="text-lg font-medium mb-1">
128
+ {t({
129
+ message: "Active Sessions",
130
+ comment: "@context: Settings section heading for active sessions",
131
+ })}
132
+ </h2>
133
+ <p class="text-sm text-muted-foreground mb-4">
134
+ {t({
135
+ message:
136
+ "These devices are currently signed in to your account. Revoke any session you don't recognize.",
137
+ comment: "@context: Description for session management",
138
+ })}
139
+ </p>
140
+ </div>
141
+
142
+ {sessions.length > 0 ? (
143
+ <div class="border border-border rounded-lg px-4">
144
+ {sessions.map((session) => (
145
+ <SessionRow key={session.token} session={session} />
146
+ ))}
147
+ </div>
148
+ ) : (
149
+ <p class="text-sm text-muted-foreground">
150
+ {t({
151
+ message: "No active sessions found.",
152
+ comment:
153
+ "@context: Empty state when no sessions exist (shouldn't normally appear)",
154
+ })}
155
+ </p>
156
+ )}
157
+ </div>
158
+ );
159
+ }
@@ -60,7 +60,9 @@ const ICONS = {
60
60
  type: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" x2="15" y1="20" y2="20"/><line x1="12" x2="12" y1="4" y2="20"/></svg>`,
61
61
  code: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`,
62
62
  arrowRightLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/></svg>`,
63
- user: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
63
+ lock: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
64
+ key: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/></svg>`,
65
+ shield: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>`,
64
66
  };
65
67
 
66
68
  // oklch-based colors for icon backgrounds
@@ -72,6 +74,7 @@ const COLORS = {
72
74
  pink: "oklch(0.6 0.2 350)",
73
75
  indigo: "oklch(0.5 0.2 275)",
74
76
  amber: "oklch(0.6 0.16 75)",
77
+ teal: "oklch(0.55 0.15 185)",
75
78
  gray: "oklch(0.55 0.01 250)",
76
79
  };
77
80
 
@@ -90,7 +93,7 @@ export function SettingsRootContent() {
90
93
  </div>
91
94
  <div class="settings-group">
92
95
  <SettingsItem
93
- href="/dash/settings/general"
96
+ href="/settings/general"
94
97
  icon={ICONS.settings}
95
98
  color={COLORS.blue}
96
99
  name={t({
@@ -115,7 +118,7 @@ export function SettingsRootContent() {
115
118
  </div>
116
119
  <div class="settings-group">
117
120
  <SettingsItem
118
- href="/dash/settings/avatar"
121
+ href="/settings/avatar"
119
122
  icon={ICONS.image}
120
123
  color={COLORS.purple}
121
124
  name={t({
@@ -128,7 +131,7 @@ export function SettingsRootContent() {
128
131
  })}
129
132
  />
130
133
  <SettingsItem
131
- href="/dash/settings/navigation"
134
+ href="/settings/navigation"
132
135
  icon={ICONS.menu}
133
136
  color={COLORS.green}
134
137
  name={t({
@@ -141,7 +144,7 @@ export function SettingsRootContent() {
141
144
  })}
142
145
  />
143
146
  <SettingsItem
144
- href="/dash/settings/color-theme"
147
+ href="/settings/color-theme"
145
148
  icon={ICONS.palette}
146
149
  color={COLORS.orange}
147
150
  name={t({
@@ -154,7 +157,7 @@ export function SettingsRootContent() {
154
157
  })}
155
158
  />
156
159
  <SettingsItem
157
- href="/dash/settings/font-theme"
160
+ href="/settings/font-theme"
158
161
  icon={ICONS.type}
159
162
  color={COLORS.pink}
160
163
  name={t({
@@ -167,7 +170,7 @@ export function SettingsRootContent() {
167
170
  })}
168
171
  />
169
172
  <SettingsItem
170
- href="/dash/settings/custom-css"
173
+ href="/settings/custom-css"
171
174
  icon={ICONS.code}
172
175
  color={COLORS.indigo}
173
176
  name={t({
@@ -192,16 +195,29 @@ export function SettingsRootContent() {
192
195
  </div>
193
196
  <div class="settings-group">
194
197
  <SettingsItem
195
- href="/dash/settings/redirects"
198
+ href="/settings/custom-urls"
196
199
  icon={ICONS.arrowRightLeft}
197
200
  color={COLORS.amber}
198
201
  name={t({
199
- message: "Redirects",
200
- comment: "@context: Settings item — redirects settings",
202
+ message: "Custom URLs",
203
+ comment: "@context: Settings item — custom URL settings",
201
204
  })}
202
205
  description={t({
203
- message: "URL redirects",
204
- comment: "@context: Settings item description for redirects",
206
+ message: "Redirects and custom paths",
207
+ comment: "@context: Settings item description for custom URLs",
208
+ })}
209
+ />
210
+ <SettingsItem
211
+ href="/settings/api-tokens"
212
+ icon={ICONS.key}
213
+ color={COLORS.teal}
214
+ name={t({
215
+ message: "API Tokens",
216
+ comment: "@context: Settings item — API token settings",
217
+ })}
218
+ description={t({
219
+ message: "Bearer tokens for scripts and automation",
220
+ comment: "@context: Settings item description for API tokens",
205
221
  })}
206
222
  />
207
223
  </div>
@@ -217,20 +233,34 @@ export function SettingsRootContent() {
217
233
  </div>
218
234
  <div class="settings-group">
219
235
  <SettingsItem
220
- href="/dash/settings/account"
221
- icon={ICONS.user}
236
+ href="/settings/account"
237
+ icon={ICONS.shield}
222
238
  color={COLORS.gray}
223
239
  name={t({
224
240
  message: "Account",
225
241
  comment: "@context: Settings item — account settings",
226
242
  })}
227
243
  description={t({
228
- message: "Profile, password",
244
+ message: "Sessions, password, export",
229
245
  comment: "@context: Settings item description for account",
230
246
  })}
231
247
  />
232
248
  </div>
233
249
  </div>
250
+
251
+ {/* Sign Out */}
252
+ <div class="pt-2 text-center">
253
+ <button
254
+ type="button"
255
+ data-on:click__prevent="@post('/signout')"
256
+ class="text-sm text-destructive hover:text-destructive/80 transition-colors"
257
+ >
258
+ {t({
259
+ message: "Sign Out",
260
+ comment: "@context: Settings link — sign out action",
261
+ })}
262
+ </button>
263
+ </div>
234
264
  </div>
235
265
  );
236
266
  }
@@ -6,13 +6,25 @@
6
6
 
7
7
  import type { FC } from "hono/jsx";
8
8
  import type { TimelineCardProps } from "../../types.js";
9
+ import { StarRating } from "../shared/StarRating.js";
10
+ import { PostFooter } from "../shared/PostFooter.js";
11
+ import { PostStatusBadges } from "./PostStatusBadges.js";
12
+ import { sanitizeUrl } from "../../lib/url.js";
9
13
 
10
- export const LinkCard: FC<TimelineCardProps> = ({ post, compact }) => {
11
- // Extract domain from URL for display
14
+ export const LinkCard: FC<TimelineCardProps> = ({
15
+ post,
16
+ mode = "feed",
17
+ display,
18
+ }) => {
19
+ const isCompact = mode === "compact";
20
+ const isDetail = mode === "detail";
21
+ const articleClass = `h-entry post-menu-target${isCompact ? " feed-compact" : isDetail ? " py-6" : " feed-card feed-card-link"}`;
22
+
23
+ const safeUrl = post.url ? sanitizeUrl(post.url) : "";
12
24
  let domain: string | undefined;
13
- if (post.url) {
25
+ if (safeUrl) {
14
26
  try {
15
- domain = new URL(post.url).hostname.replace(/^www\./, "");
27
+ domain = new URL(safeUrl).hostname.replace(/^www\./, "");
16
28
  } catch {
17
29
  // Invalid URL, skip domain display
18
30
  }
@@ -20,53 +32,90 @@ export const LinkCard: FC<TimelineCardProps> = ({ post, compact }) => {
20
32
 
21
33
  return (
22
34
  <article
23
- class={`h-entry${compact ? " feed-compact" : ""}`}
35
+ class={articleClass}
36
+ {...(isDetail ? { "data-page": "post" } : {})}
24
37
  data-post
25
38
  data-format="link"
39
+ data-post-id={post.id}
40
+ data-post-permalink={post.permalink}
41
+ {...(post.pinned ? { "data-post-pinned": "" } : {})}
42
+ {...(post.featured ? { "data-post-featured": "" } : {})}
43
+ data-post-visibility={post.visibility}
44
+ {...(!isDetail && post.threadRootId ? { "data-post-reply": "" } : {})}
26
45
  >
27
- {domain && (
28
- <div class="text-xs text-muted-foreground mb-1 flex items-center gap-1">
29
- <svg
30
- class="size-3"
31
- xmlns="http://www.w3.org/2000/svg"
32
- fill="none"
33
- viewBox="0 0 24 24"
34
- stroke-width="2"
35
- stroke="currentColor"
36
- >
37
- <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
38
- </svg>
39
- <span>{domain}</span>
40
- </div>
41
- )}
42
- {post.title && (
43
- <h2
44
- class={`p-name font-semibold ${compact ? "text-sm" : "text-base"} mb-1`}
45
- >
46
+ {!isCompact && !display?.hideStatusBadges && <PostStatusBadges />}
47
+ {domain &&
48
+ (safeUrl ? (
46
49
  <a
47
- href={post.url || post.permalink}
48
- class="u-url hover:underline"
49
- target={post.url ? "_blank" : undefined}
50
- rel={post.url ? "noopener noreferrer" : undefined}
50
+ href={safeUrl}
51
+ class="feed-link-domain"
52
+ target="_blank"
53
+ rel="noopener noreferrer"
51
54
  >
52
- {post.title}
55
+ <svg
56
+ class="feed-link-domain-icon"
57
+ xmlns="http://www.w3.org/2000/svg"
58
+ fill="none"
59
+ viewBox="0 0 24 24"
60
+ stroke-width="2"
61
+ stroke="currentColor"
62
+ >
63
+ <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
64
+ </svg>
65
+ <span>{domain}</span>
53
66
  </a>
54
- </h2>
55
- )}
56
- {!compact && post.bodyHtml && (
67
+ ) : (
68
+ <div class="feed-link-domain">
69
+ <svg
70
+ class="feed-link-domain-icon"
71
+ xmlns="http://www.w3.org/2000/svg"
72
+ fill="none"
73
+ viewBox="0 0 24 24"
74
+ stroke-width="2"
75
+ stroke="currentColor"
76
+ >
77
+ <path d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
78
+ </svg>
79
+ <span>{domain}</span>
80
+ </div>
81
+ ))}
82
+ {post.title &&
83
+ (isDetail ? (
84
+ <h1 class="p-name feed-link-title text-2xl font-semibold mb-4">
85
+ <a
86
+ href={safeUrl || post.permalink}
87
+ class="u-url feed-link-title-link"
88
+ target={safeUrl ? "_blank" : undefined}
89
+ rel={safeUrl ? "noopener noreferrer" : undefined}
90
+ >
91
+ {post.title}
92
+ </a>
93
+ </h1>
94
+ ) : (
95
+ <h2
96
+ class={`p-name feed-link-title font-semibold ${isCompact ? "text-sm" : ""} mb-1`}
97
+ >
98
+ <a
99
+ href={safeUrl || post.permalink}
100
+ class="u-url feed-link-title-link"
101
+ target={safeUrl ? "_blank" : undefined}
102
+ rel={safeUrl ? "noopener noreferrer" : undefined}
103
+ >
104
+ {post.title}
105
+ </a>
106
+ </h2>
107
+ ))}
108
+ {!isCompact && post.bodyHtml && (
57
109
  <div
58
- class="e-content prose text-muted-foreground"
110
+ class="e-content prose text-muted-foreground feed-link-summary"
59
111
  data-post-body
60
112
  dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
61
113
  />
62
114
  )}
63
- <footer class="mt-2 text-xs text-muted-foreground" data-post-meta>
64
- <a href={post.permalink} class="hover:underline">
65
- <time class="dt-published" datetime={post.publishedAt}>
66
- {post.publishedAtFormatted}
67
- </time>
68
- </a>
69
- </footer>
115
+ {!isCompact && !display?.hideRating && (
116
+ <StarRating rating={post.rating} />
117
+ )}
118
+ <PostFooter post={post} detail={isDetail} display={display?.footer} />
70
119
  </article>
71
120
  );
72
121
  };
@@ -8,39 +8,59 @@
8
8
  import type { FC } from "hono/jsx";
9
9
  import type { TimelineCardProps } from "../../types.js";
10
10
  import { MediaGallery } from "../shared/MediaGallery.js";
11
+ import { StarRating } from "../shared/StarRating.js";
12
+ import { PostFooter } from "../shared/PostFooter.js";
13
+ import { PostStatusBadges } from "./PostStatusBadges.js";
11
14
 
12
- export const NoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
15
+ export const NoteCard: FC<TimelineCardProps> = ({
16
+ post,
17
+ mode = "feed",
18
+ display,
19
+ }) => {
20
+ const isCompact = mode === "compact";
21
+ const isDetail = mode === "detail";
13
22
  const isArticle = !!post.title;
14
- const displayHtml = isArticle ? post.summaryHtml : post.bodyHtml;
23
+ const displayHtml = isDetail || !isArticle ? post.bodyHtml : post.summaryHtml;
15
24
 
16
25
  return (
17
26
  <article
18
- class={`h-entry${compact ? " feed-compact" : ""}`}
27
+ class={`h-entry post-menu-target${isCompact ? " feed-compact" : isDetail ? " py-6" : ""}`}
28
+ {...(isDetail ? { "data-page": "post" } : {})}
19
29
  data-post
20
30
  data-format="note"
31
+ data-post-id={post.id}
32
+ data-post-permalink={post.permalink}
33
+ {...(post.pinned ? { "data-post-pinned": "" } : {})}
34
+ {...(post.featured ? { "data-post-featured": "" } : {})}
35
+ data-post-visibility={post.visibility}
36
+ {...(!isDetail && post.threadRootId ? { "data-post-reply": "" } : {})}
21
37
  >
22
- {isArticle && (
23
- <h2
24
- class={`p-name font-semibold ${compact ? "text-sm" : "text-base"} mb-1`}
25
- >
26
- <a href={post.permalink} class="u-url hover:underline">
27
- {post.title}
28
- </a>
29
- </h2>
30
- )}
38
+ {!isCompact && !display?.hideStatusBadges && <PostStatusBadges />}
39
+ {isArticle &&
40
+ (isDetail ? (
41
+ <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>
42
+ ) : (
43
+ <h2
44
+ class={`p-name font-semibold ${isCompact ? "text-sm" : "text-lg"} mb-1`}
45
+ >
46
+ <a href={post.permalink} class="u-url hover:underline">
47
+ {post.title}
48
+ </a>
49
+ </h2>
50
+ ))}
31
51
  {displayHtml && (
32
52
  <div
33
- class={`e-content prose ${compact ? "prose-sm" : isArticle ? "text-muted-foreground" : ""}`}
53
+ class={`e-content prose ${isCompact ? "prose-sm" : isArticle && !isDetail ? "text-muted-foreground" : ""}`}
34
54
  data-post-body
35
55
  dangerouslySetInnerHTML={{ __html: displayHtml }}
36
56
  />
37
57
  )}
38
- {!compact && post.media.length > 0 && (
58
+ {!isCompact && post.media.length > 0 && (
39
59
  <div class="mt-3" data-post-media>
40
60
  <MediaGallery attachments={post.media} />
41
61
  </div>
42
62
  )}
43
- {!compact && isArticle && post.summaryHasMore && (
63
+ {!isDetail && !isCompact && isArticle && post.summaryHasMore && (
44
64
  <a
45
65
  href={`${post.permalink}#continue`}
46
66
  class="text-sm text-muted-foreground hover:underline mt-1 inline-block"
@@ -48,16 +68,10 @@ export const NoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
48
68
  Continue →
49
69
  </a>
50
70
  )}
51
- <footer class="mt-2" data-post-meta>
52
- <a
53
- href={post.permalink}
54
- class="u-url text-xs text-muted-foreground hover:underline"
55
- >
56
- <time class="dt-published" datetime={post.publishedAt}>
57
- {post.publishedAtFormatted}
58
- </time>
59
- </a>
60
- </footer>
71
+ {!isCompact && !display?.hideRating && (
72
+ <StarRating rating={post.rating} />
73
+ )}
74
+ <PostFooter post={post} detail={isDetail} display={display?.footer} />
61
75
  </article>
62
76
  );
63
77
  };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Post Status Badges
3
+ *
4
+ * Renders pinned / featured indicators at the top of a post card.
5
+ * All badges are always rendered in the DOM; visibility is driven by CSS
6
+ * selectors on the parent article's data attributes (data-post-pinned,
7
+ * data-post-featured). This lets the post menu toggle
8
+ * badges instantly without a page reload.
9
+ */
10
+
11
+ import type { FC } from "hono/jsx";
12
+
13
+ export const PostStatusBadges: FC = () => {
14
+ return (
15
+ <div class="post-status-badges">
16
+ <span class="post-status-badge post-status-pinned">
17
+ <svg
18
+ xmlns="http://www.w3.org/2000/svg"
19
+ viewBox="0 0 24 24"
20
+ fill="none"
21
+ stroke="currentColor"
22
+ stroke-width="1.75"
23
+ stroke-linecap="round"
24
+ stroke-linejoin="round"
25
+ >
26
+ <line x1="12" x2="12" y1="17" y2="22" />
27
+ <path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
28
+ </svg>
29
+ Pinned
30
+ </span>
31
+ <span class="post-status-separator" aria-hidden="true">
32
+ &middot;
33
+ </span>
34
+ <span class="post-status-badge post-status-featured">
35
+ <svg
36
+ xmlns="http://www.w3.org/2000/svg"
37
+ viewBox="0 0 24 24"
38
+ fill="none"
39
+ stroke="currentColor"
40
+ stroke-width="1.75"
41
+ stroke-linecap="round"
42
+ stroke-linejoin="round"
43
+ >
44
+ <path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" />
45
+ </svg>
46
+ Featured
47
+ </span>
48
+ <span class="post-status-badge post-status-private">
49
+ <svg
50
+ xmlns="http://www.w3.org/2000/svg"
51
+ viewBox="0 0 24 24"
52
+ fill="none"
53
+ stroke="currentColor"
54
+ stroke-width="1.75"
55
+ stroke-linecap="round"
56
+ stroke-linejoin="round"
57
+ >
58
+ <path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
59
+ <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
60
+ <path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
61
+ <path d="m2 2 20 20" />
62
+ </svg>
63
+ Private
64
+ </span>
65
+ </div>
66
+ );
67
+ };