@jant/core 0.3.23 → 0.3.25

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 (248) hide show
  1. package/dist/app.js +50 -26
  2. package/dist/db/schema.js +72 -47
  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/index.js +5 -11
  7. package/dist/lib/constants.js +2 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/nav-reorder.js +1 -1
  11. package/dist/lib/navigation.js +30 -6
  12. package/dist/lib/pagination.js +44 -0
  13. package/dist/lib/render.js +7 -11
  14. package/dist/lib/schemas.js +80 -38
  15. package/dist/lib/theme.js +4 -4
  16. package/dist/lib/time.js +56 -1
  17. package/dist/lib/timeline.js +95 -0
  18. package/dist/lib/view.js +61 -72
  19. package/dist/routes/api/collections.js +124 -0
  20. package/dist/routes/api/nav-items.js +104 -0
  21. package/dist/routes/api/pages.js +91 -0
  22. package/dist/routes/api/posts.js +27 -33
  23. package/dist/routes/api/search.js +4 -5
  24. package/dist/routes/api/settings.js +68 -0
  25. package/dist/routes/api/upload.js +13 -13
  26. package/dist/routes/compose.js +48 -0
  27. package/dist/routes/dash/collections.js +24 -42
  28. package/dist/routes/dash/index.js +3 -3
  29. package/dist/routes/dash/media.js +2 -2
  30. package/dist/routes/dash/pages.js +440 -106
  31. package/dist/routes/dash/posts.js +27 -37
  32. package/dist/routes/dash/redirects.js +2 -2
  33. package/dist/routes/dash/settings.js +79 -5
  34. package/dist/routes/feed/rss.js +4 -6
  35. package/dist/routes/feed/sitemap.js +11 -8
  36. package/dist/routes/pages/archive.js +13 -15
  37. package/dist/routes/pages/collection.js +12 -9
  38. package/dist/routes/pages/collections.js +28 -0
  39. package/dist/routes/pages/featured.js +32 -0
  40. package/dist/routes/pages/home.js +19 -68
  41. package/dist/routes/pages/page.js +57 -29
  42. package/dist/routes/pages/post.js +7 -17
  43. package/dist/routes/pages/search.js +5 -9
  44. package/dist/services/collection.js +52 -64
  45. package/dist/services/index.js +5 -3
  46. package/dist/services/navigation.js +29 -53
  47. package/dist/services/page.js +84 -0
  48. package/dist/services/post.js +102 -69
  49. package/dist/services/search.js +24 -18
  50. package/dist/types.js +24 -40
  51. package/dist/ui/compose/ComposeDialog.js +452 -0
  52. package/dist/ui/compose/ComposePrompt.js +55 -0
  53. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
  54. package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
  55. package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
  56. package/dist/{theme/components → ui/dash}/PostList.js +18 -13
  57. package/dist/ui/dash/StatusBadge.js +46 -0
  58. package/dist/{theme/components → ui/dash}/index.js +3 -6
  59. package/dist/ui/feed/LinkCard.js +72 -0
  60. package/dist/ui/feed/NoteCard.js +58 -0
  61. package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
  62. package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
  63. package/dist/ui/feed/TimelineFeed.js +41 -0
  64. package/dist/ui/feed/TimelineItem.js +27 -0
  65. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  66. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  67. package/dist/ui/layouts/SiteLayout.js +141 -0
  68. package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
  69. package/dist/ui/pages/CollectionPage.js +70 -0
  70. package/dist/ui/pages/CollectionsPage.js +76 -0
  71. package/dist/ui/pages/FeaturedPage.js +24 -0
  72. package/dist/ui/pages/HomePage.js +24 -0
  73. package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
  74. package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
  75. package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
  76. package/dist/ui/shared/MediaGallery.js +35 -0
  77. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  78. package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
  79. package/dist/ui/shared/index.js +5 -0
  80. package/package.json +2 -9
  81. package/src/__tests__/helpers/app.ts +4 -0
  82. package/src/__tests__/helpers/db.ts +53 -73
  83. package/src/app.tsx +56 -28
  84. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  85. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  86. package/src/db/migrations/meta/_journal.json +14 -0
  87. package/src/db/schema.ts +63 -46
  88. package/src/i18n/locales/en.po +443 -240
  89. package/src/i18n/locales/en.ts +1 -1
  90. package/src/i18n/locales/zh-Hans.po +443 -240
  91. package/src/i18n/locales/zh-Hans.ts +1 -1
  92. package/src/i18n/locales/zh-Hant.po +443 -240
  93. package/src/i18n/locales/zh-Hant.ts +1 -1
  94. package/src/index.ts +29 -42
  95. package/src/lib/__tests__/excerpt.test.ts +125 -0
  96. package/src/lib/__tests__/schemas.test.ts +201 -99
  97. package/src/lib/__tests__/time.test.ts +62 -0
  98. package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
  99. package/src/lib/__tests__/view.test.ts +204 -50
  100. package/src/lib/constants.ts +2 -4
  101. package/src/lib/excerpt.ts +87 -0
  102. package/src/lib/feed.ts +22 -7
  103. package/src/lib/nav-reorder.ts +1 -1
  104. package/src/lib/navigation.ts +45 -8
  105. package/src/lib/pagination.ts +50 -0
  106. package/src/lib/render.tsx +7 -14
  107. package/src/lib/schemas.ts +119 -51
  108. package/src/lib/theme.ts +5 -5
  109. package/src/lib/time.ts +64 -0
  110. package/src/lib/timeline.ts +141 -0
  111. package/src/lib/view.ts +80 -82
  112. package/src/preset.css +46 -0
  113. package/src/routes/__tests__/compose.test.ts +199 -0
  114. package/src/routes/api/__tests__/collections.test.ts +249 -0
  115. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  116. package/src/routes/api/__tests__/pages.test.ts +218 -0
  117. package/src/routes/api/__tests__/posts.test.ts +50 -108
  118. package/src/routes/api/__tests__/search.test.ts +2 -3
  119. package/src/routes/api/__tests__/settings.test.ts +132 -0
  120. package/src/routes/api/collections.ts +143 -0
  121. package/src/routes/api/nav-items.ts +115 -0
  122. package/src/routes/api/pages.ts +101 -0
  123. package/src/routes/api/posts.ts +28 -28
  124. package/src/routes/api/search.ts +3 -3
  125. package/src/routes/api/settings.ts +91 -0
  126. package/src/routes/api/upload.ts +16 -6
  127. package/src/routes/compose.ts +63 -0
  128. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  129. package/src/routes/dash/collections.tsx +20 -42
  130. package/src/routes/dash/index.tsx +3 -3
  131. package/src/routes/dash/media.tsx +2 -2
  132. package/src/routes/dash/pages.tsx +480 -122
  133. package/src/routes/dash/posts.tsx +42 -54
  134. package/src/routes/dash/redirects.tsx +2 -2
  135. package/src/routes/dash/settings.tsx +83 -5
  136. package/src/routes/feed/rss.ts +4 -3
  137. package/src/routes/feed/sitemap.ts +15 -5
  138. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  139. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  140. package/src/routes/pages/archive.tsx +15 -15
  141. package/src/routes/pages/collection.tsx +16 -9
  142. package/src/routes/pages/collections.tsx +36 -0
  143. package/src/routes/pages/featured.tsx +38 -0
  144. package/src/routes/pages/home.tsx +21 -92
  145. package/src/routes/pages/page.tsx +62 -27
  146. package/src/routes/pages/post.tsx +6 -18
  147. package/src/routes/pages/search.tsx +3 -7
  148. package/src/services/__tests__/collection.test.ts +257 -158
  149. package/src/services/__tests__/media.test.ts +18 -18
  150. package/src/services/__tests__/navigation.test.ts +161 -87
  151. package/src/services/__tests__/page.test.ts +106 -0
  152. package/src/services/__tests__/post-timeline.test.ts +92 -88
  153. package/src/services/__tests__/post.test.ts +432 -197
  154. package/src/services/__tests__/search.test.ts +19 -25
  155. package/src/services/collection.ts +71 -113
  156. package/src/services/index.ts +9 -8
  157. package/src/services/navigation.ts +38 -71
  158. package/src/services/page.ts +136 -0
  159. package/src/services/post.ts +141 -101
  160. package/src/services/search.ts +38 -27
  161. package/src/styles/tokens.css +47 -0
  162. package/src/styles/ui.css +491 -0
  163. package/src/types.ts +212 -198
  164. package/src/ui/compose/ComposeDialog.tsx +395 -0
  165. package/src/ui/compose/ComposePrompt.tsx +55 -0
  166. package/src/ui/dash/FormatBadge.tsx +28 -0
  167. package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
  168. package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
  169. package/src/ui/dash/PostList.tsx +101 -0
  170. package/src/ui/dash/StatusBadge.tsx +61 -0
  171. package/src/ui/dash/index.ts +10 -0
  172. package/src/ui/feed/LinkCard.tsx +72 -0
  173. package/src/ui/feed/NoteCard.tsx +63 -0
  174. package/src/ui/feed/QuoteCard.tsx +68 -0
  175. package/src/ui/feed/ThreadPreview.tsx +48 -0
  176. package/src/ui/feed/TimelineFeed.tsx +49 -0
  177. package/src/ui/feed/TimelineItem.tsx +45 -0
  178. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  179. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  180. package/src/ui/layouts/SiteLayout.tsx +150 -0
  181. package/src/ui/pages/ArchivePage.tsx +162 -0
  182. package/src/ui/pages/CollectionPage.tsx +70 -0
  183. package/src/ui/pages/CollectionsPage.tsx +73 -0
  184. package/src/ui/pages/FeaturedPage.tsx +31 -0
  185. package/src/ui/pages/HomePage.tsx +37 -0
  186. package/src/ui/pages/PostPage.tsx +56 -0
  187. package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
  188. package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
  189. package/src/ui/shared/MediaGallery.tsx +59 -0
  190. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  191. package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
  192. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  193. package/src/ui/shared/index.ts +12 -0
  194. package/bin/jant.js +0 -185
  195. package/dist/lib/theme-components.js +0 -49
  196. package/dist/routes/api/timeline.js +0 -120
  197. package/dist/routes/dash/navigation.js +0 -288
  198. package/dist/theme/components/MediaGallery.js +0 -107
  199. package/dist/theme/components/VisibilityBadge.js +0 -37
  200. package/dist/theme/index.js +0 -18
  201. package/dist/theme/layouts/index.js +0 -2
  202. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  203. package/dist/themes/minimal/index.js +0 -65
  204. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  205. package/dist/themes/minimal/pages/HomePage.js +0 -25
  206. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  207. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  208. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  209. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  210. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  211. package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
  212. package/src/lib/__tests__/theme-components.test.ts +0 -126
  213. package/src/lib/theme-components.ts +0 -68
  214. package/src/routes/api/timeline.tsx +0 -159
  215. package/src/routes/dash/navigation.tsx +0 -316
  216. package/src/theme/components/MediaGallery.tsx +0 -128
  217. package/src/theme/components/PostList.tsx +0 -92
  218. package/src/theme/components/TypeBadge.tsx +0 -37
  219. package/src/theme/components/VisibilityBadge.tsx +0 -45
  220. package/src/theme/components/index.ts +0 -23
  221. package/src/theme/index.ts +0 -22
  222. package/src/theme/layouts/index.ts +0 -7
  223. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  224. package/src/themes/minimal/index.ts +0 -83
  225. package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
  226. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  227. package/src/themes/minimal/pages/HomePage.tsx +0 -41
  228. package/src/themes/minimal/pages/PostPage.tsx +0 -43
  229. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  230. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  231. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  232. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  233. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  234. package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
  235. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
  236. package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
  237. /package/dist/{theme → ui}/color-themes.js +0 -0
  238. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  239. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  240. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  241. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  242. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  243. /package/src/{theme → ui}/color-themes.ts +0 -0
  244. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  245. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  246. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  247. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  248. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -19,7 +19,6 @@ export interface PostFormProps {
19
19
  imageTransformUrl?: string;
20
20
  s3PublicUrl?: string;
21
21
  collections?: Collection[];
22
- postCollectionIds?: number[];
23
22
  }
24
23
 
25
24
  export const PostForm: FC<PostFormProps> = ({
@@ -30,7 +29,6 @@ export const PostForm: FC<PostFormProps> = ({
30
29
  imageTransformUrl,
31
30
  s3PublicUrl,
32
31
  collections,
33
- postCollectionIds,
34
32
  }) => {
35
33
  const { t } = useLingui();
36
34
  const isEdit = !!post;
@@ -38,15 +36,17 @@ export const PostForm: FC<PostFormProps> = ({
38
36
  const existingMediaIds = (mediaAttachments ?? []).map((m) => m.id);
39
37
 
40
38
  const signals = JSON.stringify({
41
- type: post?.type ?? "note",
39
+ format: post?.format ?? "note",
42
40
  title: post?.title ?? "",
43
- content: post?.content ?? "",
44
- sourceUrl: post?.sourceUrl ?? "",
45
- sourceName: post?.sourceName ?? "",
46
- visibility: post?.visibility ?? "quiet",
47
- path: post?.path ?? "",
41
+ body: post?.body ?? "",
42
+ url: post?.url ?? "",
43
+ quoteText: post?.quoteText ?? "",
44
+ status: post?.status ?? "published",
45
+ featured: post?.featured === 1,
46
+ pinned: post?.pinned === 1,
47
+ rating: post?.rating ?? 0,
48
+ collectionId: post?.collectionId ?? 0,
48
49
  mediaIds: existingMediaIds,
49
- collectionIds: postCollectionIds ?? [],
50
50
  }).replace(/</g, "\\u003c");
51
51
 
52
52
  return (
@@ -58,29 +58,23 @@ export const PostForm: FC<PostFormProps> = ({
58
58
  >
59
59
  <div id="post-form-message"></div>
60
60
 
61
- {/* Type selector */}
61
+ {/* Format selector */}
62
62
  <div class="field">
63
63
  <label class="label">
64
64
  {t({
65
- message: "Type",
66
- comment: "@context: Post form field - post type",
65
+ message: "Format",
66
+ comment: "@context: Post form field - post format",
67
67
  })}
68
68
  </label>
69
- <select data-bind="type" class="select" required>
70
- <option value="note" selected={post?.type === "note"}>
71
- {t({ message: "Note", comment: "@context: Post type option" })}
69
+ <select data-bind="format" class="select" required>
70
+ <option value="note" selected={post?.format === "note" || !post}>
71
+ {t({ message: "Note", comment: "@context: Post format option" })}
72
72
  </option>
73
- <option value="article" selected={post?.type === "article"}>
74
- {t({ message: "Article", comment: "@context: Post type option" })}
73
+ <option value="link" selected={post?.format === "link"}>
74
+ {t({ message: "Link", comment: "@context: Post format option" })}
75
75
  </option>
76
- <option value="link" selected={post?.type === "link"}>
77
- {t({ message: "Link", comment: "@context: Post type option" })}
78
- </option>
79
- <option value="quote" selected={post?.type === "quote"}>
80
- {t({ message: "Quote", comment: "@context: Post type option" })}
81
- </option>
82
- <option value="image" selected={post?.type === "image"}>
83
- {t({ message: "Image", comment: "@context: Post type option" })}
76
+ <option value="quote" selected={post?.format === "quote"}>
77
+ {t({ message: "Quote", comment: "@context: Post format option" })}
84
78
  </option>
85
79
  </select>
86
80
  </div>
@@ -104,41 +98,68 @@ export const PostForm: FC<PostFormProps> = ({
104
98
  />
105
99
  </div>
106
100
 
107
- {/* Content */}
101
+ {/* Body */}
108
102
  <div class="field">
109
103
  <label class="label">
110
104
  {t({ message: "Content", comment: "@context: Post form field" })}
111
105
  </label>
112
106
  <textarea
113
- data-bind="content"
107
+ data-bind="body"
114
108
  class="textarea min-h-32"
115
109
  placeholder={t({
116
110
  message: "What's on your mind?",
117
111
  comment: "@context: Post content placeholder",
118
112
  })}
119
- required
120
113
  >
121
- {post?.content ?? ""}
114
+ {post?.body ?? ""}
122
115
  </textarea>
123
116
  </div>
124
117
 
125
- {/* Media attachments */}
126
- <div class="field" data-show="$type !== 'page'">
118
+ {/* URL (for link/quote formats) */}
119
+ <div class="field">
127
120
  <label class="label">
128
121
  {t({
129
- message: "Media",
130
- comment: "@context: Post form field - media attachments",
122
+ message: "URL (optional)",
123
+ comment: "@context: Post form field - source URL",
131
124
  })}
132
125
  </label>
133
- <p
134
- class="text-xs text-muted-foreground mb-2"
135
- data-show="$type === 'image'"
126
+ <input
127
+ type="url"
128
+ data-bind="url"
129
+ class="input"
130
+ placeholder="https://..."
131
+ />
132
+ </div>
133
+
134
+ {/* Quote Text (for quote format) */}
135
+ <div class="field" data-show="$format === 'quote'">
136
+ <label class="label">
137
+ {t({
138
+ message: "Quote Text",
139
+ comment: "@context: Post form field - quoted text",
140
+ })}
141
+ </label>
142
+ <textarea
143
+ data-bind="quoteText"
144
+ class="textarea"
145
+ placeholder={t({
146
+ message: "The text being quoted...",
147
+ comment: "@context: Quote text placeholder",
148
+ })}
149
+ rows={3}
136
150
  >
151
+ {post?.quoteText ?? ""}
152
+ </textarea>
153
+ </div>
154
+
155
+ {/* Media attachments */}
156
+ <div class="field">
157
+ <label class="label">
137
158
  {t({
138
- message: "At least 1 image required for image posts.",
139
- comment: "@context: Hint for image post type media requirement",
159
+ message: "Media",
160
+ comment: "@context: Post form field - media attachments",
140
161
  })}
141
- </p>
162
+ </label>
142
163
  {mediaAttachments && mediaAttachments.length > 0 && (
143
164
  <div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2">
144
165
  {mediaAttachments.map((m) => {
@@ -147,8 +168,8 @@ export const PostForm: FC<PostFormProps> = ({
147
168
  r2PublicUrl,
148
169
  s3PublicUrl,
149
170
  );
150
- const url = getMediaUrl(m.id, m.storageKey, pUrl);
151
- const thumbUrl = getImageUrl(url, imageTransformUrl, {
171
+ const mUrl = getMediaUrl(m.id, m.storageKey, pUrl);
172
+ const thumbUrl = getImageUrl(mUrl, imageTransformUrl, {
152
173
  width: 150,
153
174
  quality: 80,
154
175
  format: "auto",
@@ -194,119 +215,77 @@ export const PostForm: FC<PostFormProps> = ({
194
215
  </button>
195
216
  </div>
196
217
 
197
- {/* Source URL (for link/quote types) */}
218
+ {/* Status */}
198
219
  <div class="field">
199
220
  <label class="label">
200
- {t({
201
- message: "Source URL (optional)",
202
- comment: "@context: Post form field",
203
- })}
221
+ {t({ message: "Status", comment: "@context: Post form field" })}
204
222
  </label>
205
- <input
206
- type="url"
207
- data-bind="sourceUrl"
208
- class="input"
209
- placeholder="https://..."
210
- />
211
- </div>
212
-
213
- {/* Source Name (for link/quote types) */}
214
- <div class="field">
215
- <label class="label">
216
- {t({
217
- message: "Source Name (optional)",
218
- comment:
219
- "@context: Post form field - name of the source website or author",
220
- })}
221
- </label>
222
- <input
223
- type="text"
224
- data-bind="sourceName"
225
- class="input"
226
- placeholder={t({
227
- message: "e.g. The Verge, John Doe",
228
- comment: "@context: Source name placeholder",
229
- })}
230
- />
231
- </div>
232
-
233
- {/* Visibility */}
234
- <div class="field">
235
- <label class="label">
236
- {t({ message: "Visibility", comment: "@context: Post form field" })}
237
- </label>
238
- <select data-bind="visibility" class="select">
223
+ <select data-bind="status" class="select">
239
224
  <option
240
- value="quiet"
241
- selected={post?.visibility === "quiet" || !post}
225
+ value="published"
226
+ selected={post?.status === "published" || !post}
242
227
  >
243
228
  {t({
244
- message: "Quiet (normal)",
245
- comment: "@context: Post visibility option",
246
- })}
247
- </option>
248
- <option value="featured" selected={post?.visibility === "featured"}>
249
- {t({
250
- message: "Featured",
251
- comment: "@context: Post visibility option",
229
+ message: "Published",
230
+ comment: "@context: Post status option",
252
231
  })}
253
232
  </option>
254
- <option value="unlisted" selected={post?.visibility === "unlisted"}>
255
- {t({
256
- message: "Unlisted",
257
- comment: "@context: Post visibility option",
258
- })}
259
- </option>
260
- <option value="draft" selected={post?.visibility === "draft"}>
233
+ <option value="draft" selected={post?.status === "draft"}>
261
234
  {t({
262
235
  message: "Draft",
263
- comment: "@context: Post visibility option",
236
+ comment: "@context: Post status option",
264
237
  })}
265
238
  </option>
266
239
  </select>
267
240
  </div>
268
241
 
269
- {/* Collections */}
242
+ {/* Featured & Pinned */}
243
+ <div class="flex gap-4">
244
+ <label class="flex items-center gap-2 text-sm">
245
+ <input type="checkbox" class="checkbox" data-bind="featured" />
246
+ {t({
247
+ message: "Featured",
248
+ comment: "@context: Post form checkbox - mark as featured",
249
+ })}
250
+ </label>
251
+ <label class="flex items-center gap-2 text-sm">
252
+ <input type="checkbox" class="checkbox" data-bind="pinned" />
253
+ {t({
254
+ message: "Pinned",
255
+ comment: "@context: Post form checkbox - pin to top",
256
+ })}
257
+ </label>
258
+ </div>
259
+
260
+ {/* Collection */}
270
261
  {collections && collections.length > 0 && (
271
- <fieldset class="field">
272
- <legend class="label">
262
+ <div class="field">
263
+ <label class="label">
273
264
  {t({
274
- message: "Collections (optional)",
275
- comment: "@context: Post form field - assign to collections",
265
+ message: "Collection (optional)",
266
+ comment: "@context: Post form field - assign to collection",
276
267
  })}
277
- </legend>
278
- <div class="flex flex-col gap-1">
268
+ </label>
269
+ <select data-bind="collectionId" class="select">
270
+ <option value="0">
271
+ {t({
272
+ message: "None",
273
+ comment: "@context: No collection selected",
274
+ })}
275
+ </option>
279
276
  {collections.map((col) => (
280
- <label key={col.id} class="flex items-center gap-2 text-sm">
281
- <input
282
- type="checkbox"
283
- class="checkbox"
284
- data-attr:checked={`$collectionIds.includes(${col.id})`}
285
- data-on:change={`$collectionIds.includes(${col.id}) ? $collectionIds = $collectionIds.filter(id => id !== ${col.id}) : $collectionIds = [...$collectionIds, ${col.id}]`}
286
- />
277
+ <option
278
+ key={col.id}
279
+ value={col.id}
280
+ selected={post?.collectionId === col.id}
281
+ >
287
282
  {col.title}
288
- </label>
283
+ </option>
289
284
  ))}
290
- </div>
291
- </fieldset>
285
+ </select>
286
+ </div>
292
287
  )}
293
288
 
294
- {/* Custom path (optional) */}
295
- <div class="field">
296
- <label class="label">
297
- {t({
298
- message: "Custom Path (optional)",
299
- comment: "@context: Post form field",
300
- })}
301
- </label>
302
- <input
303
- type="text"
304
- data-bind="path"
305
- class="input"
306
- placeholder="my-custom-url"
307
- />
308
- </div>
309
-
310
289
  {/* Submit */}
311
290
  <div class="flex gap-2">
312
291
  <button type="submit" class="btn" data-attr-disabled="$_loading">
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Post List Component
3
+ */
4
+
5
+ import type { FC } from "hono/jsx";
6
+ import { useLingui } from "@lingui/react/macro";
7
+ import type { Post } from "../../types.js";
8
+ import * as sqid from "../../lib/sqid.js";
9
+ import * as time from "../../lib/time.js";
10
+ import { StatusBadge } from "./StatusBadge.js";
11
+ import { FormatBadge } from "./FormatBadge.js";
12
+ import { EmptyState } from "../shared/EmptyState.js";
13
+ import { ListItemRow } from "./ListItemRow.js";
14
+ import { ActionButtons } from "./ActionButtons.js";
15
+
16
+ export interface PostListProps {
17
+ posts: Post[];
18
+ }
19
+
20
+ export const PostList: FC<PostListProps> = ({ posts }) => {
21
+ const { t } = useLingui();
22
+ if (posts.length === 0) {
23
+ return (
24
+ <EmptyState
25
+ message={t({
26
+ message: "No posts yet.",
27
+ comment: "@context: Empty state message when no posts exist",
28
+ })}
29
+ ctaText={t({
30
+ message: "Create your first post",
31
+ comment: "@context: Button in empty state to create first post",
32
+ })}
33
+ ctaHref="/dash/posts/new"
34
+ />
35
+ );
36
+ }
37
+
38
+ return (
39
+ <div class="flex flex-col divide-y">
40
+ {posts.map((post) => {
41
+ const permalink = post.path
42
+ ? `/${post.path}`
43
+ : `/p/${sqid.encode(post.id)}`;
44
+ return (
45
+ <ListItemRow
46
+ key={post.id}
47
+ actions={
48
+ <ActionButtons
49
+ editHref={`/dash/posts/${sqid.encode(post.id)}/edit`}
50
+ editLabel={t({
51
+ message: "Edit",
52
+ comment: "@context: Button to edit post",
53
+ })}
54
+ viewHref={permalink}
55
+ viewLabel={t({
56
+ message: "View",
57
+ comment: "@context: Button to view post on public site",
58
+ })}
59
+ deleteAction={`/dash/posts/${sqid.encode(post.id)}/delete`}
60
+ deleteConfirm={t({
61
+ message:
62
+ "Are you sure you want to delete this post? This cannot be undone.",
63
+ comment:
64
+ "@context: Confirmation dialog when deleting a post from the list",
65
+ })}
66
+ />
67
+ }
68
+ >
69
+ <div class="flex items-center gap-2 mb-1">
70
+ <FormatBadge type={post.format} />
71
+ <StatusBadge
72
+ status={post.status}
73
+ featured={post.featured === 1}
74
+ pinned={post.pinned === 1}
75
+ />
76
+ <span class="text-xs text-muted-foreground">
77
+ {time.formatDate(post.publishedAt)}
78
+ </span>
79
+ </div>
80
+ <a
81
+ href={`/dash/posts/${sqid.encode(post.id)}`}
82
+ class="font-medium hover:underline"
83
+ >
84
+ {post.title ||
85
+ post.body?.slice(0, 60) ||
86
+ t({
87
+ message: "Untitled",
88
+ comment: "@context: Default title for untitled post",
89
+ })}
90
+ </a>
91
+ {post.body && !post.title && (
92
+ <p class="text-sm text-muted-foreground mt-1 line-clamp-2">
93
+ {post.body.slice(0, 120)}
94
+ </p>
95
+ )}
96
+ </ListItemRow>
97
+ );
98
+ })}
99
+ </div>
100
+ );
101
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Status Badge Component
3
+ *
4
+ * Displays badges for post status, featured, and pinned state.
5
+ */
6
+
7
+ import type { FC } from "hono/jsx";
8
+ import { useLingui } from "@lingui/react/macro";
9
+ import type { Status } from "../../types.js";
10
+
11
+ export interface StatusBadgeProps {
12
+ status: Status;
13
+ featured?: boolean;
14
+ pinned?: boolean;
15
+ }
16
+
17
+ export const StatusBadge: FC<StatusBadgeProps> = ({
18
+ status,
19
+ featured,
20
+ pinned,
21
+ }) => {
22
+ const { t } = useLingui();
23
+
24
+ const statusVariants: Record<Status, string> = {
25
+ published: "badge-secondary",
26
+ draft: "badge-outline",
27
+ };
28
+
29
+ const statusLabels: Record<Status, string> = {
30
+ published: t({
31
+ message: "Published",
32
+ comment: "@context: Post status badge - published",
33
+ }),
34
+ draft: t({
35
+ message: "Draft",
36
+ comment: "@context: Post status badge - draft",
37
+ }),
38
+ };
39
+
40
+ return (
41
+ <span class="flex items-center gap-1">
42
+ <span class={statusVariants[status]}>{statusLabels[status]}</span>
43
+ {featured && (
44
+ <span class="badge-primary">
45
+ {t({
46
+ message: "Featured",
47
+ comment: "@context: Post badge - featured",
48
+ })}
49
+ </span>
50
+ )}
51
+ {pinned && (
52
+ <span class="badge-outline">
53
+ {t({
54
+ message: "Pinned",
55
+ comment: "@context: Post badge - pinned",
56
+ })}
57
+ </span>
58
+ )}
59
+ </span>
60
+ );
61
+ };
@@ -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,72 @@
1
+ /**
2
+ * Link Card
3
+ *
4
+ * Compact link preview box — date is shown at the feed level as a group header.
5
+ */
6
+
7
+ import type { FC } from "hono/jsx";
8
+ import type { TimelineCardProps } from "../../types.js";
9
+
10
+ export const LinkCard: FC<TimelineCardProps> = ({ post, compact }) => {
11
+ // Extract domain from URL for display
12
+ let domain: string | undefined;
13
+ if (post.url) {
14
+ try {
15
+ domain = new URL(post.url).hostname.replace(/^www\./, "");
16
+ } catch {
17
+ // Invalid URL, skip domain display
18
+ }
19
+ }
20
+
21
+ return (
22
+ <article
23
+ class={`h-entry${compact ? " feed-compact" : ""}`}
24
+ data-post
25
+ data-format="link"
26
+ >
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
+ <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}
51
+ >
52
+ {post.title}
53
+ </a>
54
+ </h2>
55
+ )}
56
+ {!compact && post.bodyHtml && (
57
+ <div
58
+ class="e-content prose text-muted-foreground"
59
+ data-post-body
60
+ dangerouslySetInnerHTML={{ __html: post.bodyHtml }}
61
+ />
62
+ )}
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>
70
+ </article>
71
+ );
72
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Note Card
3
+ *
4
+ * Without title: plain text note with full date in footer.
5
+ * With title: article-style rendering with summary excerpt and "Read more" link.
6
+ */
7
+
8
+ import type { FC } from "hono/jsx";
9
+ import type { TimelineCardProps } from "../../types.js";
10
+ import { MediaGallery } from "../shared/MediaGallery.js";
11
+
12
+ export const NoteCard: FC<TimelineCardProps> = ({ post, compact }) => {
13
+ const isArticle = !!post.title;
14
+ const displayHtml = isArticle ? post.summaryHtml : post.bodyHtml;
15
+
16
+ return (
17
+ <article
18
+ class={`h-entry${compact ? " feed-compact" : ""}`}
19
+ data-post
20
+ data-format="note"
21
+ >
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
+ )}
31
+ {displayHtml && (
32
+ <div
33
+ class={`e-content prose ${compact ? "prose-sm" : isArticle ? "text-muted-foreground" : ""}`}
34
+ data-post-body
35
+ dangerouslySetInnerHTML={{ __html: displayHtml }}
36
+ />
37
+ )}
38
+ {!compact && post.media.length > 0 && (
39
+ <div class="mt-3" data-post-media>
40
+ <MediaGallery attachments={post.media} />
41
+ </div>
42
+ )}
43
+ {!compact && isArticle && post.summaryHasMore && (
44
+ <a
45
+ href={post.permalink}
46
+ class="text-sm text-muted-foreground hover:underline mt-1 inline-block"
47
+ >
48
+ Read more →
49
+ </a>
50
+ )}
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>
61
+ </article>
62
+ );
63
+ };