@jant/core 0.3.24 → 0.3.26

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 (277) hide show
  1. package/dist/app.js +101 -571
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +1 -1
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/index.js +3 -9
  8. package/dist/lib/avatar-upload.js +134 -0
  9. package/dist/lib/config.js +39 -0
  10. package/dist/lib/constants.js +10 -9
  11. package/dist/lib/favicon.js +102 -0
  12. package/dist/lib/image.js +13 -17
  13. package/dist/lib/media-helpers.js +2 -2
  14. package/dist/lib/nav-reorder.js +1 -1
  15. package/dist/lib/navigation.js +48 -3
  16. package/dist/lib/pagination.js +44 -0
  17. package/dist/lib/render.js +16 -11
  18. package/dist/lib/schemas.js +34 -3
  19. package/dist/lib/theme.js +4 -4
  20. package/dist/lib/timeline.js +24 -48
  21. package/dist/lib/timezones.js +388 -0
  22. package/dist/lib/view.js +3 -3
  23. package/dist/routes/api/collections.js +124 -0
  24. package/dist/routes/api/nav-items.js +104 -0
  25. package/dist/routes/api/pages.js +91 -0
  26. package/dist/routes/api/posts.js +3 -3
  27. package/dist/routes/api/search.js +2 -2
  28. package/dist/routes/api/settings.js +68 -0
  29. package/dist/routes/api/upload.js +3 -3
  30. package/dist/routes/auth/reset.js +221 -0
  31. package/dist/routes/auth/setup.js +194 -0
  32. package/dist/routes/auth/signin.js +176 -0
  33. package/dist/routes/compose.js +48 -0
  34. package/dist/routes/dash/collections.js +24 -416
  35. package/dist/routes/dash/index.js +1 -1
  36. package/dist/routes/dash/media.js +13 -393
  37. package/dist/routes/dash/pages.js +112 -86
  38. package/dist/routes/dash/posts.js +3 -5
  39. package/dist/routes/dash/redirects.js +20 -14
  40. package/dist/routes/dash/settings.js +213 -518
  41. package/dist/routes/feed/rss.js +4 -3
  42. package/dist/routes/feed/sitemap.js +5 -3
  43. package/dist/routes/pages/archive.js +3 -6
  44. package/dist/routes/pages/collection.js +3 -6
  45. package/dist/routes/pages/collections.js +28 -0
  46. package/dist/routes/pages/featured.js +36 -0
  47. package/dist/routes/pages/home.js +33 -49
  48. package/dist/routes/pages/latest.js +45 -0
  49. package/dist/routes/pages/page.js +29 -32
  50. package/dist/routes/pages/post.js +3 -6
  51. package/dist/routes/pages/search.js +3 -6
  52. package/dist/services/page.js +5 -1
  53. package/dist/services/post.js +45 -31
  54. package/dist/services/search.js +1 -1
  55. package/dist/types/bindings.js +3 -0
  56. package/dist/types/config.js +147 -0
  57. package/dist/types/constants.js +27 -0
  58. package/dist/types/entities.js +3 -0
  59. package/dist/types/operations.js +3 -0
  60. package/dist/types/props.js +3 -0
  61. package/dist/types/views.js +5 -0
  62. package/dist/types.js +8 -111
  63. package/dist/{theme → ui}/color-themes.js +33 -33
  64. package/dist/ui/compose/ComposeDialog.js +467 -0
  65. package/dist/ui/compose/ComposePrompt.js +55 -0
  66. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  67. package/dist/{theme/components → ui/dash}/PageForm.js +21 -15
  68. package/dist/{theme/components → ui/dash}/PostForm.js +22 -43
  69. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  70. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  71. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  72. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  73. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  74. package/dist/{theme/components → ui/dash}/index.js +3 -6
  75. package/dist/ui/dash/media/MediaListContent.js +166 -0
  76. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  77. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  78. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  79. package/dist/ui/dash/settings/AccountContent.js +209 -0
  80. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  81. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  82. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  83. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  84. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  85. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  86. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  87. package/dist/ui/feed/TimelineFeed.js +41 -0
  88. package/dist/ui/feed/TimelineItem.js +27 -0
  89. package/dist/ui/font-themes.js +36 -0
  90. package/dist/{theme → ui}/layouts/BaseLayout.js +34 -2
  91. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  92. package/dist/ui/layouts/SiteLayout.js +169 -0
  93. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  94. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  95. package/dist/ui/pages/CollectionsPage.js +76 -0
  96. package/dist/ui/pages/FeaturedPage.js +24 -0
  97. package/dist/ui/pages/HomePage.js +24 -0
  98. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  99. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  100. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  101. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  102. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  103. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  104. package/dist/ui/shared/index.js +5 -0
  105. package/package.json +1 -9
  106. package/src/__tests__/helpers/db.ts +3 -0
  107. package/src/app.tsx +131 -561
  108. package/src/client.ts +1 -0
  109. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  110. package/src/db/migrations/meta/_journal.json +7 -0
  111. package/src/db/schema.ts +1 -1
  112. package/src/i18n/locales/en.po +477 -261
  113. package/src/i18n/locales/en.ts +1 -1
  114. package/src/i18n/locales/zh-Hans.po +477 -261
  115. package/src/i18n/locales/zh-Hans.ts +1 -1
  116. package/src/i18n/locales/zh-Hant.po +477 -261
  117. package/src/i18n/locales/zh-Hant.ts +1 -1
  118. package/src/index.ts +7 -36
  119. package/src/lib/__tests__/config.test.ts +192 -0
  120. package/src/lib/__tests__/favicon.test.ts +151 -0
  121. package/src/lib/__tests__/image.test.ts +2 -6
  122. package/src/lib/__tests__/schemas.test.ts +60 -19
  123. package/src/lib/__tests__/timeline.test.ts +45 -81
  124. package/src/lib/__tests__/timezones.test.ts +61 -0
  125. package/src/lib/__tests__/view.test.ts +15 -9
  126. package/src/lib/avatar-upload.ts +165 -0
  127. package/src/lib/config.ts +47 -0
  128. package/src/lib/constants.ts +19 -10
  129. package/src/lib/favicon.ts +115 -0
  130. package/src/lib/image.ts +13 -21
  131. package/src/lib/media-helpers.ts +2 -2
  132. package/src/lib/nav-reorder.ts +1 -1
  133. package/src/lib/navigation.ts +73 -4
  134. package/src/lib/pagination.ts +50 -0
  135. package/src/lib/render.tsx +22 -15
  136. package/src/lib/schemas.ts +47 -6
  137. package/src/lib/theme.ts +5 -5
  138. package/src/lib/timeline.ts +28 -57
  139. package/src/lib/timezones.ts +325 -0
  140. package/src/lib/view.ts +3 -3
  141. package/src/preset.css +2 -1
  142. package/src/routes/__tests__/compose.test.ts +199 -0
  143. package/src/routes/api/__tests__/collections.test.ts +249 -0
  144. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  145. package/src/routes/api/__tests__/pages.test.ts +218 -0
  146. package/src/routes/api/__tests__/settings.test.ts +132 -0
  147. package/src/routes/api/collections.ts +143 -0
  148. package/src/routes/api/nav-items.ts +115 -0
  149. package/src/routes/api/pages.ts +101 -0
  150. package/src/routes/api/posts.ts +3 -3
  151. package/src/routes/api/search.ts +2 -2
  152. package/src/routes/api/settings.ts +91 -0
  153. package/src/routes/api/upload.ts +2 -3
  154. package/src/routes/auth/reset.tsx +239 -0
  155. package/src/routes/auth/setup.tsx +189 -0
  156. package/src/routes/auth/signin.tsx +163 -0
  157. package/src/routes/compose.ts +63 -0
  158. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  159. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  160. package/src/routes/dash/collections.tsx +18 -367
  161. package/src/routes/dash/index.tsx +1 -1
  162. package/src/routes/dash/media.tsx +13 -415
  163. package/src/routes/dash/pages.tsx +131 -98
  164. package/src/routes/dash/posts.tsx +3 -7
  165. package/src/routes/dash/redirects.tsx +22 -16
  166. package/src/routes/dash/settings.tsx +265 -478
  167. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  168. package/src/routes/feed/rss.ts +5 -3
  169. package/src/routes/feed/sitemap.ts +5 -3
  170. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  171. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  172. package/src/routes/pages/archive.tsx +2 -6
  173. package/src/routes/pages/collection.tsx +2 -6
  174. package/src/routes/pages/collections.tsx +36 -0
  175. package/src/routes/pages/featured.tsx +44 -0
  176. package/src/routes/pages/home.tsx +30 -53
  177. package/src/routes/pages/latest.tsx +59 -0
  178. package/src/routes/pages/page.tsx +28 -30
  179. package/src/routes/pages/post.tsx +2 -5
  180. package/src/routes/pages/search.tsx +2 -6
  181. package/src/services/__tests__/page.test.ts +106 -0
  182. package/src/services/__tests__/post.test.ts +114 -15
  183. package/src/services/page.ts +13 -1
  184. package/src/services/post.ts +58 -40
  185. package/src/services/search.ts +2 -2
  186. package/src/styles/components.css +0 -65
  187. package/src/styles/tokens.css +47 -0
  188. package/src/styles/ui.css +475 -0
  189. package/src/types/bindings.ts +30 -0
  190. package/src/types/config.ts +183 -0
  191. package/src/types/constants.ts +26 -0
  192. package/src/types/entities.ts +109 -0
  193. package/src/types/operations.ts +88 -0
  194. package/src/types/props.ts +115 -0
  195. package/src/types/views.ts +172 -0
  196. package/src/types.ts +8 -774
  197. package/src/ui/__tests__/font-themes.test.ts +34 -0
  198. package/src/{theme → ui}/color-themes.ts +34 -34
  199. package/src/ui/compose/ComposeDialog.tsx +414 -0
  200. package/src/ui/compose/ComposePrompt.tsx +55 -0
  201. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  202. package/src/{theme/components → ui/dash}/PageForm.tsx +25 -19
  203. package/src/{theme/components → ui/dash}/PostForm.tsx +26 -45
  204. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  205. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  206. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  207. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  208. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  209. package/src/ui/dash/index.ts +10 -0
  210. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  211. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  212. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  213. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  214. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  215. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  216. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  217. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  218. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  219. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  220. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  221. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  222. package/src/ui/feed/TimelineFeed.tsx +49 -0
  223. package/src/ui/feed/TimelineItem.tsx +45 -0
  224. package/src/ui/font-themes.ts +54 -0
  225. package/src/{theme → ui}/layouts/BaseLayout.tsx +28 -1
  226. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  227. package/src/ui/layouts/SiteLayout.tsx +164 -0
  228. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  229. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  230. package/src/ui/pages/CollectionsPage.tsx +73 -0
  231. package/src/ui/pages/FeaturedPage.tsx +31 -0
  232. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  233. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  234. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  235. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  236. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  237. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  238. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  239. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  240. package/src/ui/shared/index.ts +12 -0
  241. package/bin/jant.js +0 -185
  242. package/dist/lib/theme-components.js +0 -46
  243. package/dist/routes/dash/navigation.js +0 -289
  244. package/dist/theme/index.js +0 -18
  245. package/dist/theme/layouts/index.js +0 -2
  246. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  247. package/dist/themes/threads/index.js +0 -81
  248. package/dist/themes/threads/pages/HomePage.js +0 -25
  249. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  250. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  251. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  252. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  253. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  254. package/src/lib/__tests__/theme-components.test.ts +0 -105
  255. package/src/lib/theme-components.ts +0 -65
  256. package/src/routes/dash/navigation.tsx +0 -317
  257. package/src/theme/components/index.ts +0 -23
  258. package/src/theme/index.ts +0 -22
  259. package/src/theme/layouts/index.ts +0 -7
  260. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  261. package/src/themes/threads/index.ts +0 -100
  262. package/src/themes/threads/style.css +0 -336
  263. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  264. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  265. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  266. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  267. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  268. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  269. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  270. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  271. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  272. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  273. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  274. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  275. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  276. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  277. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -41,7 +41,6 @@ export const PostForm: FC<PostFormProps> = ({
41
41
  body: post?.body ?? "",
42
42
  url: post?.url ?? "",
43
43
  quoteText: post?.quoteText ?? "",
44
- slug: post?.slug ?? "",
45
44
  status: post?.status ?? "published",
46
45
  featured: post?.featured === 1,
47
46
  pinned: post?.pinned === 1,
@@ -169,7 +168,7 @@ export const PostForm: FC<PostFormProps> = ({
169
168
  r2PublicUrl,
170
169
  s3PublicUrl,
171
170
  );
172
- const mUrl = getMediaUrl(m.id, m.storageKey, pUrl);
171
+ const mUrl = getMediaUrl(m.storageKey, pUrl);
173
172
  const thumbUrl = getImageUrl(mUrl, imageTransformUrl, {
174
173
  width: 150,
175
174
  quality: 80,
@@ -287,51 +286,33 @@ export const PostForm: FC<PostFormProps> = ({
287
286
  </div>
288
287
  )}
289
288
 
290
- {/* Custom slug (optional) */}
291
- <div class="field">
292
- <label class="label">
293
- {t({
294
- message: "Custom Slug (optional)",
295
- comment: "@context: Post form field",
296
- })}
297
- </label>
298
- <input
299
- type="text"
300
- data-bind="slug"
301
- class="input"
302
- placeholder="my-custom-url"
303
- pattern="[a-z0-9-]*"
304
- />
305
- <p class="text-xs text-muted-foreground mt-1">
306
- {t({
307
- message:
308
- "Custom URL path. Leave empty to use default /p/ID format.",
309
- comment: "@context: Slug help text",
310
- })}
311
- </p>
312
- </div>
313
-
314
289
  {/* Submit */}
315
290
  <div class="flex gap-2">
316
- <button type="submit" class="btn" data-attr-disabled="$_loading">
317
- <span data-show="!$_loading">
318
- {isEdit
319
- ? t({
320
- message: "Update",
321
- comment: "@context: Button to update existing post",
322
- })
323
- : t({
324
- message: "Publish",
325
- comment: "@context: Button to publish new post",
326
- })}
327
- </span>
328
- <span data-show="$_loading">
329
- {t({
330
- message: "Processing...",
331
- comment:
332
- "@context: Loading text shown on submit button while request is in progress",
333
- })}
334
- </span>
291
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
292
+ <svg
293
+ data-show="$_loading"
294
+ style="display:none"
295
+ class="animate-spin size-4"
296
+ xmlns="http://www.w3.org/2000/svg"
297
+ viewBox="0 0 24 24"
298
+ fill="none"
299
+ stroke="currentColor"
300
+ stroke-width="2"
301
+ stroke-linecap="round"
302
+ stroke-linejoin="round"
303
+ role="status"
304
+ >
305
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
306
+ </svg>
307
+ {isEdit
308
+ ? t({
309
+ message: "Update",
310
+ comment: "@context: Button to update existing post",
311
+ })
312
+ : t({
313
+ message: "Publish",
314
+ comment: "@context: Button to publish new post",
315
+ })}
335
316
  </button>
336
317
  <a href="/dash/posts" class="btn-outline">
337
318
  {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
@@ -7,9 +7,9 @@ import { useLingui } from "@lingui/react/macro";
7
7
  import type { Post } from "../../types.js";
8
8
  import * as sqid from "../../lib/sqid.js";
9
9
  import * as time from "../../lib/time.js";
10
- import { VisibilityBadge } from "./VisibilityBadge.js";
11
- import { TypeBadge } from "./TypeBadge.js";
12
- import { EmptyState } from "./EmptyState.js";
10
+ import { StatusBadge } from "./StatusBadge.js";
11
+ import { FormatBadge } from "./FormatBadge.js";
12
+ import { EmptyState } from "../shared/EmptyState.js";
13
13
  import { ListItemRow } from "./ListItemRow.js";
14
14
  import { ActionButtons } from "./ActionButtons.js";
15
15
 
@@ -38,8 +38,8 @@ export const PostList: FC<PostListProps> = ({ posts }) => {
38
38
  return (
39
39
  <div class="flex flex-col divide-y">
40
40
  {posts.map((post) => {
41
- const permalink = post.slug
42
- ? `/${post.slug}`
41
+ const permalink = post.path
42
+ ? `/${post.path}`
43
43
  : `/p/${sqid.encode(post.id)}`;
44
44
  return (
45
45
  <ListItemRow
@@ -67,8 +67,8 @@ export const PostList: FC<PostListProps> = ({ posts }) => {
67
67
  }
68
68
  >
69
69
  <div class="flex items-center gap-2 mb-1">
70
- <TypeBadge type={post.format} />
71
- <VisibilityBadge
70
+ <FormatBadge type={post.format} />
71
+ <StatusBadge
72
72
  status={post.status}
73
73
  featured={post.featured === 1}
74
74
  pinned={post.pinned === 1}
@@ -2,20 +2,19 @@
2
2
  * Status Badge Component
3
3
  *
4
4
  * Displays badges for post status, featured, and pinned state.
5
- * Named VisibilityBadge for backward compatibility with theme overrides.
6
5
  */
7
6
 
8
7
  import type { FC } from "hono/jsx";
9
8
  import { useLingui } from "@lingui/react/macro";
10
9
  import type { Status } from "../../types.js";
11
10
 
12
- export interface VisibilityBadgeProps {
11
+ export interface StatusBadgeProps {
13
12
  status: Status;
14
13
  featured?: boolean;
15
14
  pinned?: boolean;
16
15
  }
17
16
 
18
- export const VisibilityBadge: FC<VisibilityBadgeProps> = ({
17
+ export const StatusBadge: FC<StatusBadgeProps> = ({
19
18
  status,
20
19
  featured,
21
20
  pinned,
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Shared collection form (new + edit)
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { Collection } from "../../../types.js";
7
+
8
+ export function CollectionForm({
9
+ collection,
10
+ isEdit,
11
+ }: {
12
+ collection?: Collection;
13
+ isEdit?: boolean;
14
+ }) {
15
+ const { t } = useLingui();
16
+
17
+ const signals = JSON.stringify({
18
+ title: collection?.title ?? "",
19
+ slug: collection?.slug ?? "",
20
+ description: collection?.description ?? "",
21
+ }).replace(/</g, "\\u003c");
22
+
23
+ const action = isEdit
24
+ ? `/dash/collections/${collection?.id}`
25
+ : "/dash/collections";
26
+
27
+ const heading = isEdit
28
+ ? t({ message: "Edit Collection", comment: "@context: Page heading" })
29
+ : t({ message: "New Collection", comment: "@context: Page heading" });
30
+
31
+ const submitLabel = isEdit
32
+ ? t({
33
+ message: "Update Collection",
34
+ comment: "@context: Button to save collection changes",
35
+ })
36
+ : t({
37
+ message: "Create Collection",
38
+ comment: "@context: Button to save new collection",
39
+ });
40
+
41
+ const cancelHref = isEdit
42
+ ? `/dash/collections/${collection?.id}`
43
+ : "/dash/collections";
44
+
45
+ return (
46
+ <>
47
+ <h1 class="text-2xl font-semibold mb-6">{heading}</h1>
48
+
49
+ <form
50
+ data-signals={signals}
51
+ data-on:submit__prevent={`@post('${action}')`}
52
+ data-indicator="_loading"
53
+ class="flex flex-col gap-4 max-w-lg"
54
+ >
55
+ <div class="field">
56
+ <label class="label">
57
+ {t({
58
+ message: "Title",
59
+ comment: "@context: Collection form field",
60
+ })}
61
+ </label>
62
+ <input
63
+ type="text"
64
+ data-bind="title"
65
+ class="input"
66
+ required
67
+ placeholder={
68
+ isEdit
69
+ ? undefined
70
+ : t({
71
+ message: "My Collection",
72
+ comment: "@context: Collection title placeholder",
73
+ })
74
+ }
75
+ />
76
+ </div>
77
+
78
+ <div class="field">
79
+ <label class="label">
80
+ {t({ message: "Slug", comment: "@context: Collection form field" })}
81
+ </label>
82
+ <input
83
+ type="text"
84
+ data-bind="slug"
85
+ class="input"
86
+ required
87
+ pattern="[a-z0-9-]+"
88
+ placeholder={isEdit ? undefined : "my-collection"}
89
+ />
90
+ {!isEdit && (
91
+ <p class="text-xs text-muted-foreground mt-1">
92
+ {t({
93
+ message: "URL-safe identifier (lowercase, numbers, hyphens)",
94
+ comment: "@context: Collection path help text",
95
+ })}
96
+ </p>
97
+ )}
98
+ </div>
99
+
100
+ <div class="field">
101
+ <label class="label">
102
+ {t({
103
+ message: "Description (optional)",
104
+ comment: "@context: Collection form field",
105
+ })}
106
+ </label>
107
+ <textarea
108
+ data-bind="description"
109
+ class="textarea"
110
+ rows={3}
111
+ placeholder={
112
+ isEdit
113
+ ? undefined
114
+ : t({
115
+ message: "What's this collection about?",
116
+ comment: "@context: Collection description placeholder",
117
+ })
118
+ }
119
+ >
120
+ {collection?.description ?? ""}
121
+ </textarea>
122
+ </div>
123
+
124
+ <div class="flex gap-2">
125
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
126
+ <svg
127
+ data-show="$_loading"
128
+ style="display:none"
129
+ class="animate-spin size-4"
130
+ xmlns="http://www.w3.org/2000/svg"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ stroke-width="2"
135
+ stroke-linecap="round"
136
+ stroke-linejoin="round"
137
+ role="status"
138
+ >
139
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
140
+ </svg>
141
+ {submitLabel}
142
+ </button>
143
+ <a href={cancelHref} class="btn-outline">
144
+ {t({
145
+ message: "Cancel",
146
+ comment: "@context: Button to cancel form",
147
+ })}
148
+ </a>
149
+ </div>
150
+ </form>
151
+ </>
152
+ );
153
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Collections list view
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { Collection } from "../../../types.js";
7
+ import {
8
+ EmptyState,
9
+ ListItemRow,
10
+ ActionButtons,
11
+ CrudPageHeader,
12
+ } from "../index.js";
13
+
14
+ export function CollectionsListContent({
15
+ collections,
16
+ }: {
17
+ collections: Collection[];
18
+ }) {
19
+ const { t } = useLingui();
20
+
21
+ return (
22
+ <>
23
+ <CrudPageHeader
24
+ title={t({
25
+ message: "Collections",
26
+ comment: "@context: Dashboard heading",
27
+ })}
28
+ ctaLabel={t({
29
+ message: "New Collection",
30
+ comment: "@context: Button to create new collection",
31
+ })}
32
+ ctaHref="/dash/collections/new"
33
+ />
34
+
35
+ {collections.length === 0 ? (
36
+ <EmptyState
37
+ message={t({
38
+ message: "No collections yet.",
39
+ comment: "@context: Empty state message",
40
+ })}
41
+ ctaText={t({
42
+ message: "New Collection",
43
+ comment: "@context: Button to create new collection",
44
+ })}
45
+ ctaHref="/dash/collections/new"
46
+ />
47
+ ) : (
48
+ <div class="flex flex-col divide-y">
49
+ {collections.map((col) => (
50
+ <ListItemRow
51
+ key={col.id}
52
+ actions={
53
+ <ActionButtons
54
+ editHref={`/dash/collections/${col.id}/edit`}
55
+ editLabel={t({
56
+ message: "Edit",
57
+ comment: "@context: Button to edit collection",
58
+ })}
59
+ viewHref={`/c/${col.slug}`}
60
+ viewLabel={t({
61
+ message: "View",
62
+ comment: "@context: Button to view collection",
63
+ })}
64
+ />
65
+ }
66
+ >
67
+ <a
68
+ href={`/dash/collections/${col.id}`}
69
+ class="font-medium hover:underline"
70
+ >
71
+ {col.title}
72
+ </a>
73
+ <p class="text-sm text-muted-foreground">/{col.slug}</p>
74
+ {col.description && (
75
+ <p class="text-sm text-muted-foreground mt-1">
76
+ {col.description}
77
+ </p>
78
+ )}
79
+ </ListItemRow>
80
+ ))}
81
+ </div>
82
+ )}
83
+ </>
84
+ );
85
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Single collection detail view
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { Collection, PostView } from "../../../types.js";
7
+ import { ActionButtons } from "../index.js";
8
+ import { encode } from "../../../lib/sqid.js";
9
+
10
+ export function ViewCollectionContent({
11
+ collection,
12
+ posts,
13
+ }: {
14
+ collection: Collection;
15
+ posts: PostView[];
16
+ }) {
17
+ const { t } = useLingui();
18
+ const postsHeader = t({
19
+ message: "Posts in Collection ({count})",
20
+ comment: "@context: Collection posts section heading",
21
+ values: { count: String(posts.length) },
22
+ });
23
+
24
+ return (
25
+ <>
26
+ <div class="flex items-center justify-between mb-6">
27
+ <div>
28
+ <h1 class="text-2xl font-semibold">{collection.title}</h1>
29
+ <p class="text-sm text-muted-foreground">/{collection.slug}</p>
30
+ </div>
31
+ <ActionButtons
32
+ editHref={`/dash/collections/${collection.id}/edit`}
33
+ editLabel={t({
34
+ message: "Edit",
35
+ comment: "@context: Button to edit collection",
36
+ })}
37
+ viewHref={`/c/${collection.slug}`}
38
+ viewLabel={t({
39
+ message: "View",
40
+ comment: "@context: Button to view collection",
41
+ })}
42
+ />
43
+ </div>
44
+
45
+ {collection.description && (
46
+ <p class="text-muted-foreground mb-6">{collection.description}</p>
47
+ )}
48
+
49
+ <div class="card">
50
+ <header>
51
+ <h2>{postsHeader}</h2>
52
+ </header>
53
+ <section>
54
+ {posts.length === 0 ? (
55
+ <p class="text-muted-foreground">
56
+ {t({
57
+ message: "No posts in this collection.",
58
+ comment: "@context: Empty state message",
59
+ })}
60
+ </p>
61
+ ) : (
62
+ <div class="flex flex-col divide-y">
63
+ {posts.map((post) => (
64
+ <div key={post.id} class="py-3 flex items-center gap-4">
65
+ <div class="flex-1 min-w-0">
66
+ <a
67
+ href={`/dash/posts/${encode(post.id)}`}
68
+ class="font-medium hover:underline"
69
+ >
70
+ {post.title ||
71
+ post.excerpt?.slice(0, 50) ||
72
+ `Post #${post.id}`}
73
+ </a>
74
+ </div>
75
+ </div>
76
+ ))}
77
+ </div>
78
+ )}
79
+ </section>
80
+ </div>
81
+
82
+ <div class="mt-6">
83
+ <a href="/dash/collections" class="text-sm hover:underline">
84
+ {t({
85
+ message: "\u2190 Back to Collections",
86
+ comment: "@context: Navigation link",
87
+ })}
88
+ </a>
89
+ </div>
90
+ </>
91
+ );
92
+ }
@@ -0,0 +1,10 @@
1
+ export { ActionButtons, type ActionButtonsProps } from "./ActionButtons.js";
2
+ export { CrudPageHeader, type CrudPageHeaderProps } from "./CrudPageHeader.js";
3
+ export { DangerZone, type DangerZoneProps } from "./DangerZone.js";
4
+ export { EmptyState, type EmptyStateProps } from "../shared/EmptyState.js";
5
+ export { FormatBadge, type FormatBadgeProps } from "./FormatBadge.js";
6
+ export { ListItemRow, type ListItemRowProps } from "./ListItemRow.js";
7
+ export { PageForm, type PageFormProps } from "./PageForm.js";
8
+ export { PostForm, type PostFormProps } from "./PostForm.js";
9
+ export { PostList, type PostListProps } from "./PostList.js";
10
+ export { StatusBadge, type StatusBadgeProps } from "./StatusBadge.js";
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Media grid list with upload UI
3
+ */
4
+
5
+ import { useLingui } from "@lingui/react/macro";
6
+ import type { Media } from "../../../types.js";
7
+ import { EmptyState } from "../index.js";
8
+ import {
9
+ getMediaUrl,
10
+ getImageUrl,
11
+ getPublicUrlForProvider,
12
+ } from "../../../lib/image.js";
13
+
14
+ function formatSize(bytes: number): string {
15
+ if (bytes < 1024) return `${bytes} B`;
16
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
17
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
18
+ }
19
+
20
+ function MediaCard({
21
+ media,
22
+ r2PublicUrl,
23
+ imageTransformUrl,
24
+ s3PublicUrl,
25
+ }: {
26
+ media: Media;
27
+ r2PublicUrl?: string;
28
+ imageTransformUrl?: string;
29
+ s3PublicUrl?: string;
30
+ }) {
31
+ const publicUrl = getPublicUrlForProvider(
32
+ media.provider,
33
+ r2PublicUrl,
34
+ s3PublicUrl,
35
+ );
36
+ const fullUrl = getMediaUrl(media.storageKey, publicUrl);
37
+ const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
38
+ width: 300,
39
+ quality: 80,
40
+ format: "auto",
41
+ fit: "cover",
42
+ });
43
+ const isImage = media.mimeType.startsWith("image/");
44
+
45
+ return (
46
+ <div class="group relative" data-media-id={media.id}>
47
+ {isImage ? (
48
+ <button
49
+ type="button"
50
+ class="block w-full aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary cursor-pointer"
51
+ onclick={`document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()`}
52
+ >
53
+ <img
54
+ src={thumbnailUrl}
55
+ alt={media.alt || media.originalName}
56
+ class="w-full h-full object-cover"
57
+ loading="lazy"
58
+ />
59
+ </button>
60
+ ) : (
61
+ <a
62
+ href={`/dash/media/${media.id}`}
63
+ class="block aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary"
64
+ >
65
+ <div class="w-full h-full flex items-center justify-center text-muted-foreground">
66
+ <span class="text-xs">{media.mimeType}</span>
67
+ </div>
68
+ </a>
69
+ )}
70
+ <a
71
+ href={`/dash/media/${media.id}`}
72
+ class="block mt-2 text-xs truncate hover:underline"
73
+ title={media.originalName}
74
+ >
75
+ {media.originalName}
76
+ </a>
77
+ <div class="text-xs text-muted-foreground">{formatSize(media.size)}</div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ export function MediaListContent({
83
+ mediaList,
84
+ r2PublicUrl,
85
+ imageTransformUrl,
86
+ s3PublicUrl,
87
+ }: {
88
+ mediaList: Media[];
89
+ r2PublicUrl?: string;
90
+ imageTransformUrl?: string;
91
+ s3PublicUrl?: string;
92
+ }) {
93
+ const { t } = useLingui();
94
+
95
+ const processingText = t({
96
+ message: "Processing...",
97
+ comment: "@context: Upload status - processing",
98
+ });
99
+ const uploadingText = t({
100
+ message: "Uploading...",
101
+ comment: "@context: Upload status - uploading",
102
+ });
103
+ const uploadText = t({
104
+ message: "Upload",
105
+ comment: "@context: Button to upload media file",
106
+ });
107
+ const errorText = t({
108
+ message: "Upload failed. Please try again.",
109
+ comment: "@context: Upload error message",
110
+ });
111
+
112
+ return (
113
+ <>
114
+ {/* Hidden form for Datastar-driven upload */}
115
+ <form
116
+ id="upload-form"
117
+ class="hidden"
118
+ enctype="multipart/form-data"
119
+ data-on:submit__prevent="@post('/api/upload', {contentType: 'form'})"
120
+ >
121
+ <input id="upload-file-input" type="file" name="file" />
122
+ </form>
123
+
124
+ {/* Header */}
125
+ <div class="flex items-center justify-between mb-6">
126
+ <h1 class="text-2xl font-semibold">
127
+ {t({ message: "Media", comment: "@context: Media main heading" })}
128
+ </h1>
129
+ <label class="btn cursor-pointer">
130
+ <span>{uploadText}</span>
131
+ <input
132
+ type="file"
133
+ class="hidden"
134
+ accept="image/*"
135
+ data-media-upload
136
+ data-text-processing={processingText}
137
+ data-text-uploading={uploadingText}
138
+ data-text-error={errorText}
139
+ />
140
+ </label>
141
+ </div>
142
+
143
+ {/* Upload instructions */}
144
+ <div class="card mb-6">
145
+ <section class="text-sm text-muted-foreground">
146
+ <p>
147
+ {t({
148
+ message:
149
+ "Images are automatically optimized: resized to max 1920px, converted to WebP, and metadata stripped.",
150
+ comment:
151
+ "@context: Media upload instructions - auto optimization",
152
+ })}
153
+ </p>
154
+ </section>
155
+ </div>
156
+
157
+ {/* Media grid or empty state */}
158
+ <div id="media-content">
159
+ {mediaList.length === 0 ? (
160
+ <div id="empty-state">
161
+ <EmptyState
162
+ message={t({
163
+ message: "No media uploaded yet.",
164
+ comment: "@context: Empty state message when no media exists",
165
+ })}
166
+ />
167
+ </div>
168
+ ) : (
169
+ <div
170
+ id="media-grid"
171
+ class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
172
+ >
173
+ {mediaList.map((m) => (
174
+ <MediaCard
175
+ key={m.id}
176
+ media={m}
177
+ r2PublicUrl={r2PublicUrl}
178
+ imageTransformUrl={imageTransformUrl}
179
+ s3PublicUrl={s3PublicUrl}
180
+ />
181
+ ))}
182
+ </div>
183
+ )}
184
+ </div>
185
+
186
+ {/* Lightbox */}
187
+ <dialog
188
+ id="lightbox"
189
+ class="p-0 m-auto bg-transparent backdrop:bg-black/80"
190
+ onclick="event.target === this && this.close()"
191
+ >
192
+ <img
193
+ id="lightbox-img"
194
+ src=""
195
+ alt=""
196
+ class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
197
+ />
198
+ </dialog>
199
+ </>
200
+ );
201
+ }