@jant/core 0.3.25 → 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 (131) hide show
  1. package/dist/app.js +67 -562
  2. package/dist/client.js +1 -0
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/lib/avatar-upload.js +134 -0
  7. package/dist/lib/config.js +39 -0
  8. package/dist/lib/constants.js +10 -10
  9. package/dist/lib/favicon.js +102 -0
  10. package/dist/lib/image.js +13 -17
  11. package/dist/lib/media-helpers.js +2 -2
  12. package/dist/lib/navigation.js +23 -3
  13. package/dist/lib/render.js +10 -1
  14. package/dist/lib/schemas.js +31 -0
  15. package/dist/lib/timezones.js +388 -0
  16. package/dist/lib/view.js +1 -1
  17. package/dist/routes/api/posts.js +1 -1
  18. package/dist/routes/api/upload.js +3 -3
  19. package/dist/routes/auth/reset.js +221 -0
  20. package/dist/routes/auth/setup.js +194 -0
  21. package/dist/routes/auth/signin.js +176 -0
  22. package/dist/routes/dash/collections.js +23 -415
  23. package/dist/routes/dash/media.js +12 -392
  24. package/dist/routes/dash/pages.js +7 -330
  25. package/dist/routes/dash/redirects.js +18 -12
  26. package/dist/routes/dash/settings.js +198 -577
  27. package/dist/routes/feed/rss.js +2 -1
  28. package/dist/routes/feed/sitemap.js +4 -2
  29. package/dist/routes/pages/featured.js +5 -1
  30. package/dist/routes/pages/home.js +26 -1
  31. package/dist/routes/pages/latest.js +45 -0
  32. package/dist/services/post.js +30 -50
  33. package/dist/types/bindings.js +3 -0
  34. package/dist/types/config.js +147 -0
  35. package/dist/types/constants.js +27 -0
  36. package/dist/types/entities.js +3 -0
  37. package/dist/types/operations.js +3 -0
  38. package/dist/types/props.js +3 -0
  39. package/dist/types/views.js +5 -0
  40. package/dist/types.js +8 -111
  41. package/dist/ui/color-themes.js +33 -33
  42. package/dist/ui/compose/ComposeDialog.js +36 -21
  43. package/dist/ui/dash/PageForm.js +21 -15
  44. package/dist/ui/dash/PostForm.js +22 -16
  45. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  46. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  47. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  48. package/dist/ui/dash/media/MediaListContent.js +166 -0
  49. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  50. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  51. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  52. package/dist/ui/dash/settings/AccountContent.js +209 -0
  53. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  54. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  55. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  56. package/dist/ui/font-themes.js +36 -0
  57. package/dist/ui/layouts/BaseLayout.js +24 -2
  58. package/dist/ui/layouts/SiteLayout.js +47 -19
  59. package/package.json +1 -1
  60. package/src/app.tsx +93 -553
  61. package/src/client.ts +1 -0
  62. package/src/i18n/locales/en.po +240 -175
  63. package/src/i18n/locales/en.ts +1 -1
  64. package/src/i18n/locales/zh-Hans.po +240 -175
  65. package/src/i18n/locales/zh-Hans.ts +1 -1
  66. package/src/i18n/locales/zh-Hant.po +240 -175
  67. package/src/i18n/locales/zh-Hant.ts +1 -1
  68. package/src/lib/__tests__/config.test.ts +192 -0
  69. package/src/lib/__tests__/favicon.test.ts +151 -0
  70. package/src/lib/__tests__/image.test.ts +2 -6
  71. package/src/lib/__tests__/timezones.test.ts +61 -0
  72. package/src/lib/__tests__/view.test.ts +2 -2
  73. package/src/lib/avatar-upload.ts +165 -0
  74. package/src/lib/config.ts +47 -0
  75. package/src/lib/constants.ts +19 -11
  76. package/src/lib/favicon.ts +115 -0
  77. package/src/lib/image.ts +13 -21
  78. package/src/lib/media-helpers.ts +2 -2
  79. package/src/lib/navigation.ts +33 -2
  80. package/src/lib/render.tsx +15 -1
  81. package/src/lib/schemas.ts +39 -0
  82. package/src/lib/timezones.ts +325 -0
  83. package/src/lib/view.ts +1 -1
  84. package/src/routes/api/posts.ts +1 -1
  85. package/src/routes/api/upload.ts +2 -3
  86. package/src/routes/auth/reset.tsx +239 -0
  87. package/src/routes/auth/setup.tsx +189 -0
  88. package/src/routes/auth/signin.tsx +163 -0
  89. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  90. package/src/routes/dash/collections.tsx +17 -366
  91. package/src/routes/dash/media.tsx +12 -414
  92. package/src/routes/dash/pages.tsx +8 -348
  93. package/src/routes/dash/redirects.tsx +20 -14
  94. package/src/routes/dash/settings.tsx +243 -534
  95. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  96. package/src/routes/feed/rss.ts +3 -1
  97. package/src/routes/feed/sitemap.ts +4 -2
  98. package/src/routes/pages/featured.tsx +7 -1
  99. package/src/routes/pages/home.tsx +25 -2
  100. package/src/routes/pages/latest.tsx +59 -0
  101. package/src/services/post.ts +34 -66
  102. package/src/styles/components.css +0 -65
  103. package/src/styles/tokens.css +1 -1
  104. package/src/styles/ui.css +24 -40
  105. package/src/types/bindings.ts +30 -0
  106. package/src/types/config.ts +183 -0
  107. package/src/types/constants.ts +26 -0
  108. package/src/types/entities.ts +109 -0
  109. package/src/types/operations.ts +88 -0
  110. package/src/types/props.ts +115 -0
  111. package/src/types/views.ts +172 -0
  112. package/src/types.ts +8 -644
  113. package/src/ui/__tests__/font-themes.test.ts +34 -0
  114. package/src/ui/color-themes.ts +34 -34
  115. package/src/ui/compose/ComposeDialog.tsx +40 -21
  116. package/src/ui/dash/PageForm.tsx +25 -19
  117. package/src/ui/dash/PostForm.tsx +26 -20
  118. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  119. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  120. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  121. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  122. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  123. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  124. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  125. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  126. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  127. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  128. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  129. package/src/ui/font-themes.ts +54 -0
  130. package/src/ui/layouts/BaseLayout.tsx +17 -0
  131. package/src/ui/layouts/SiteLayout.tsx +45 -31
@@ -118,9 +118,40 @@ function defineTheme(opts: {
118
118
  }
119
119
 
120
120
  export const BUILTIN_COLOR_THEMES: ColorTheme[] = [
121
+ defineTheme({
122
+ id: "halloween",
123
+ name: "Halloween",
124
+ preview: {
125
+ lightBg: "#f9f2e3",
126
+ lightText: "#352200",
127
+ lightLink: "#b84400",
128
+ darkBg: "#1e1000",
129
+ darkText: "#dfc390",
130
+ darkLink: "#ff8c00",
131
+ },
132
+ light: {
133
+ bg: "oklch(0.97 0.015 75)",
134
+ fg: "oklch(0.25 0.04 55)",
135
+ primary: "oklch(0.47 0.17 50)",
136
+ primaryFg: "oklch(0.98 0.01 75)",
137
+ muted: "oklch(0.93 0.02 75)",
138
+ mutedFg: "oklch(0.5 0.025 55)",
139
+ border: "oklch(0.88 0.025 75)",
140
+ },
141
+ dark: {
142
+ bg: "oklch(0.16 0.03 50)",
143
+ fg: "oklch(0.85 0.025 75)",
144
+ primary: "oklch(0.72 0.19 55)",
145
+ primaryFg: "oklch(0.14 0.03 50)",
146
+ muted: "oklch(0.22 0.025 50)",
147
+ mutedFg: "oklch(0.62 0.02 75)",
148
+ border: "oklch(0.28 0.025 50)",
149
+ },
150
+ }),
151
+
121
152
  {
122
153
  id: "default",
123
- name: "Default",
154
+ name: "Panda",
124
155
  light: {},
125
156
  dark: {},
126
157
  preview: {
@@ -226,37 +257,6 @@ export const BUILTIN_COLOR_THEMES: ColorTheme[] = [
226
257
  },
227
258
  }),
228
259
 
229
- defineTheme({
230
- id: "halloween",
231
- name: "Halloween",
232
- preview: {
233
- lightBg: "#f9f2e3",
234
- lightText: "#352200",
235
- lightLink: "#cc5500",
236
- darkBg: "#1e1000",
237
- darkText: "#dfc390",
238
- darkLink: "#ff8c00",
239
- },
240
- light: {
241
- bg: "oklch(0.97 0.015 75)",
242
- fg: "oklch(0.25 0.04 55)",
243
- primary: "oklch(0.6 0.2 50)",
244
- primaryFg: "oklch(0.98 0.01 75)",
245
- muted: "oklch(0.93 0.02 75)",
246
- mutedFg: "oklch(0.5 0.025 55)",
247
- border: "oklch(0.88 0.025 75)",
248
- },
249
- dark: {
250
- bg: "oklch(0.16 0.03 50)",
251
- fg: "oklch(0.85 0.025 75)",
252
- primary: "oklch(0.72 0.19 55)",
253
- primaryFg: "oklch(0.14 0.03 50)",
254
- muted: "oklch(0.22 0.025 50)",
255
- mutedFg: "oklch(0.62 0.02 75)",
256
- border: "oklch(0.28 0.025 50)",
257
- },
258
- }),
259
-
260
260
  defineTheme({
261
261
  id: "notepad",
262
262
  name: "Notepad",
@@ -294,7 +294,7 @@ export const BUILTIN_COLOR_THEMES: ColorTheme[] = [
294
294
  preview: {
295
295
  lightBg: "#f7eef5",
296
296
  lightText: "#2e1e2c",
297
- lightLink: "#9845c8",
297
+ lightLink: "#7a30a8",
298
298
  darkBg: "#1d1428",
299
299
  darkText: "#d4c2d0",
300
300
  darkLink: "#c080fc",
@@ -302,7 +302,7 @@ export const BUILTIN_COLOR_THEMES: ColorTheme[] = [
302
302
  light: {
303
303
  bg: "oklch(0.97 0.012 325)",
304
304
  fg: "oklch(0.25 0.02 310)",
305
- primary: "oklch(0.55 0.2 300)",
305
+ primary: "oklch(0.45 0.2 300)",
306
306
  primaryFg: "oklch(0.98 0.008 325)",
307
307
  muted: "oklch(0.93 0.016 325)",
308
308
  mutedFg: "oklch(0.52 0.015 310)",
@@ -319,34 +319,53 @@ export const ComposeDialog: FC<ComposeDialogProps> = ({ collections }) => {
319
319
  <button
320
320
  type="button"
321
321
  class="btn-outline text-sm"
322
- data-attr-disabled="$_composeLoading"
322
+ data-attr:disabled="$_composeLoading"
323
323
  data-on:click="$status = 'draft'; document.querySelector('#compose-dialog form').requestSubmit()"
324
324
  >
325
- <span data-show="!$_composeLoading">
326
- {t({
327
- message: "Draft",
328
- comment: "@context: Compose button - save as draft",
329
- })}
330
- </span>
331
- <span data-show="$_composeLoading">...</span>
325
+ <svg
326
+ data-show="$_composeLoading"
327
+ style="display:none"
328
+ class="animate-spin size-4"
329
+ xmlns="http://www.w3.org/2000/svg"
330
+ viewBox="0 0 24 24"
331
+ fill="none"
332
+ stroke="currentColor"
333
+ stroke-width="2"
334
+ stroke-linecap="round"
335
+ stroke-linejoin="round"
336
+ role="status"
337
+ >
338
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
339
+ </svg>
340
+ {t({
341
+ message: "Draft",
342
+ comment: "@context: Compose button - save as draft",
343
+ })}
332
344
  </button>
333
345
  <button
334
346
  type="submit"
335
347
  class="btn text-sm"
336
- data-attr-disabled="$_composeLoading"
348
+ data-attr:disabled="$_composeLoading"
337
349
  >
338
- <span data-show="!$_composeLoading">
339
- {t({
340
- message: "Post",
341
- comment: "@context: Compose button - publish post",
342
- })}
343
- </span>
344
- <span data-show="$_composeLoading">
345
- {t({
346
- message: "Posting...",
347
- comment: "@context: Compose loading text while posting",
348
- })}
349
- </span>
350
+ <svg
351
+ data-show="$_composeLoading"
352
+ style="display:none"
353
+ class="animate-spin size-4"
354
+ xmlns="http://www.w3.org/2000/svg"
355
+ viewBox="0 0 24 24"
356
+ fill="none"
357
+ stroke="currentColor"
358
+ stroke-width="2"
359
+ stroke-linecap="round"
360
+ stroke-linejoin="round"
361
+ role="status"
362
+ >
363
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
364
+ </svg>
365
+ {t({
366
+ message: "Post",
367
+ comment: "@context: Compose button - publish post",
368
+ })}
350
369
  </button>
351
370
  </div>
352
371
  </div>
@@ -147,25 +147,31 @@ export const PageForm: FC<PageFormProps> = ({
147
147
 
148
148
  {/* Submit */}
149
149
  <div class="flex gap-2">
150
- <button type="submit" class="btn" data-attr-disabled="$_loading">
151
- <span data-show="!$_loading">
152
- {isEdit
153
- ? t({
154
- message: "Update Page",
155
- comment: "@context: Button to update existing page",
156
- })
157
- : t({
158
- message: "Create Page",
159
- comment: "@context: Button to create new page",
160
- })}
161
- </span>
162
- <span data-show="$_loading">
163
- {t({
164
- message: "Processing...",
165
- comment:
166
- "@context: Loading text shown on submit button while request is in progress",
167
- })}
168
- </span>
150
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
151
+ <svg
152
+ data-show="$_loading"
153
+ style="display:none"
154
+ class="animate-spin size-4"
155
+ xmlns="http://www.w3.org/2000/svg"
156
+ viewBox="0 0 24 24"
157
+ fill="none"
158
+ stroke="currentColor"
159
+ stroke-width="2"
160
+ stroke-linecap="round"
161
+ stroke-linejoin="round"
162
+ role="status"
163
+ >
164
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
165
+ </svg>
166
+ {isEdit
167
+ ? t({
168
+ message: "Update Page",
169
+ comment: "@context: Button to update existing page",
170
+ })
171
+ : t({
172
+ message: "Create Page",
173
+ comment: "@context: Button to create new page",
174
+ })}
169
175
  </button>
170
176
  <a href={cancelUrl} class="btn-outline">
171
177
  {t({
@@ -168,7 +168,7 @@ export const PostForm: FC<PostFormProps> = ({
168
168
  r2PublicUrl,
169
169
  s3PublicUrl,
170
170
  );
171
- const mUrl = getMediaUrl(m.id, m.storageKey, pUrl);
171
+ const mUrl = getMediaUrl(m.storageKey, pUrl);
172
172
  const thumbUrl = getImageUrl(mUrl, imageTransformUrl, {
173
173
  width: 150,
174
174
  quality: 80,
@@ -288,25 +288,31 @@ export const PostForm: FC<PostFormProps> = ({
288
288
 
289
289
  {/* Submit */}
290
290
  <div class="flex gap-2">
291
- <button type="submit" class="btn" data-attr-disabled="$_loading">
292
- <span data-show="!$_loading">
293
- {isEdit
294
- ? t({
295
- message: "Update",
296
- comment: "@context: Button to update existing post",
297
- })
298
- : t({
299
- message: "Publish",
300
- comment: "@context: Button to publish new post",
301
- })}
302
- </span>
303
- <span data-show="$_loading">
304
- {t({
305
- message: "Processing...",
306
- comment:
307
- "@context: Loading text shown on submit button while request is in progress",
308
- })}
309
- </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
+ })}
310
316
  </button>
311
317
  <a href="/dash/posts" class="btn-outline">
312
318
  {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
@@ -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
+ }