@jant/core 0.3.6 → 0.3.7

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 (81) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +7 -21
  3. package/dist/db/schema.d.ts +36 -0
  4. package/dist/db/schema.d.ts.map +1 -1
  5. package/dist/db/schema.js +2 -0
  6. package/dist/i18n/locales/en.d.ts.map +1 -1
  7. package/dist/i18n/locales/en.js +1 -1
  8. package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
  9. package/dist/i18n/locales/zh-Hans.js +1 -1
  10. package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
  11. package/dist/i18n/locales/zh-Hant.js +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/lib/schemas.d.ts +17 -0
  16. package/dist/lib/schemas.d.ts.map +1 -1
  17. package/dist/lib/schemas.js +32 -2
  18. package/dist/lib/sse.d.ts +3 -3
  19. package/dist/lib/sse.d.ts.map +1 -1
  20. package/dist/lib/sse.js +7 -8
  21. package/dist/routes/api/posts.d.ts.map +1 -1
  22. package/dist/routes/api/posts.js +101 -5
  23. package/dist/routes/dash/media.js +38 -0
  24. package/dist/routes/dash/posts.d.ts.map +1 -1
  25. package/dist/routes/dash/posts.js +45 -6
  26. package/dist/routes/feed/rss.d.ts.map +1 -1
  27. package/dist/routes/feed/rss.js +10 -1
  28. package/dist/routes/pages/home.d.ts.map +1 -1
  29. package/dist/routes/pages/home.js +37 -4
  30. package/dist/routes/pages/post.d.ts.map +1 -1
  31. package/dist/routes/pages/post.js +28 -2
  32. package/dist/services/collection.d.ts +1 -0
  33. package/dist/services/collection.d.ts.map +1 -1
  34. package/dist/services/collection.js +13 -0
  35. package/dist/services/media.d.ts +7 -0
  36. package/dist/services/media.d.ts.map +1 -1
  37. package/dist/services/media.js +54 -1
  38. package/dist/theme/components/MediaGallery.d.ts +13 -0
  39. package/dist/theme/components/MediaGallery.d.ts.map +1 -0
  40. package/dist/theme/components/MediaGallery.js +107 -0
  41. package/dist/theme/components/PostForm.d.ts +6 -1
  42. package/dist/theme/components/PostForm.d.ts.map +1 -1
  43. package/dist/theme/components/PostForm.js +158 -2
  44. package/dist/theme/components/index.d.ts +1 -0
  45. package/dist/theme/components/index.d.ts.map +1 -1
  46. package/dist/theme/components/index.js +1 -0
  47. package/dist/types.d.ts +24 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/types.js +27 -0
  50. package/package.json +1 -1
  51. package/src/__tests__/helpers/app.ts +6 -1
  52. package/src/__tests__/helpers/db.ts +10 -0
  53. package/src/app.tsx +7 -25
  54. package/src/db/migrations/0002_add_media_attachments.sql +3 -0
  55. package/src/db/schema.ts +2 -0
  56. package/src/i18n/locales/en.po +81 -37
  57. package/src/i18n/locales/en.ts +1 -1
  58. package/src/i18n/locales/zh-Hans.po +81 -37
  59. package/src/i18n/locales/zh-Hans.ts +1 -1
  60. package/src/i18n/locales/zh-Hant.po +81 -37
  61. package/src/i18n/locales/zh-Hant.ts +1 -1
  62. package/src/index.ts +8 -1
  63. package/src/lib/__tests__/schemas.test.ts +89 -1
  64. package/src/lib/__tests__/sse.test.ts +13 -1
  65. package/src/lib/schemas.ts +47 -1
  66. package/src/lib/sse.ts +10 -11
  67. package/src/routes/api/__tests__/posts.test.ts +239 -0
  68. package/src/routes/api/posts.ts +134 -5
  69. package/src/routes/dash/media.tsx +50 -0
  70. package/src/routes/dash/posts.tsx +79 -7
  71. package/src/routes/feed/rss.ts +14 -1
  72. package/src/routes/pages/home.tsx +80 -36
  73. package/src/routes/pages/post.tsx +36 -3
  74. package/src/services/__tests__/collection.test.ts +102 -0
  75. package/src/services/__tests__/media.test.ts +248 -0
  76. package/src/services/collection.ts +19 -0
  77. package/src/services/media.ts +76 -1
  78. package/src/theme/components/MediaGallery.tsx +128 -0
  79. package/src/theme/components/PostForm.tsx +170 -2
  80. package/src/theme/components/index.ts +1 -0
  81. package/src/types.ts +36 -0
@@ -3,25 +3,44 @@
3
3
  */
4
4
 
5
5
  import type { FC } from "hono/jsx";
6
- import type { Post } from "../../types.js";
6
+ import type { Post, Media, Collection } from "../../types.js";
7
7
  import { useLingui } from "@lingui/react/macro";
8
+ import { getMediaUrl, getImageUrl } from "../../lib/image.js";
8
9
 
9
10
  export interface PostFormProps {
10
11
  post?: Post;
11
12
  action: string;
13
+ mediaAttachments?: Media[];
14
+ r2PublicUrl?: string;
15
+ imageTransformUrl?: string;
16
+ collections?: Collection[];
17
+ postCollectionIds?: number[];
12
18
  }
13
19
 
14
- export const PostForm: FC<PostFormProps> = ({ post, action }) => {
20
+ export const PostForm: FC<PostFormProps> = ({
21
+ post,
22
+ action,
23
+ mediaAttachments,
24
+ r2PublicUrl,
25
+ imageTransformUrl,
26
+ collections,
27
+ postCollectionIds,
28
+ }) => {
15
29
  const { t } = useLingui();
16
30
  const isEdit = !!post;
17
31
 
32
+ const existingMediaIds = (mediaAttachments ?? []).map((m) => m.id);
33
+
18
34
  const signals = JSON.stringify({
19
35
  type: post?.type ?? "note",
20
36
  title: post?.title ?? "",
21
37
  content: post?.content ?? "",
22
38
  sourceUrl: post?.sourceUrl ?? "",
39
+ sourceName: post?.sourceName ?? "",
23
40
  visibility: post?.visibility ?? "quiet",
24
41
  path: post?.path ?? "",
42
+ mediaIds: existingMediaIds,
43
+ collectionIds: postCollectionIds ?? [],
25
44
  }).replace(/</g, "\\u003c");
26
45
 
27
46
  return (
@@ -96,6 +115,73 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
96
115
  </textarea>
97
116
  </div>
98
117
 
118
+ {/* Media attachments */}
119
+ <div class="field" data-show="$type !== 'page'">
120
+ <label class="label">
121
+ {t({
122
+ message: "Media",
123
+ comment: "@context: Post form field - media attachments",
124
+ })}
125
+ </label>
126
+ <p
127
+ class="text-xs text-muted-foreground mb-2"
128
+ data-show="$type === 'image'"
129
+ >
130
+ {t({
131
+ message: "At least 1 image required for image posts.",
132
+ comment: "@context: Hint for image post type media requirement",
133
+ })}
134
+ </p>
135
+ {mediaAttachments && mediaAttachments.length > 0 && (
136
+ <div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2">
137
+ {mediaAttachments.map((m) => {
138
+ const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
139
+ const thumbUrl = getImageUrl(url, imageTransformUrl, {
140
+ width: 150,
141
+ quality: 80,
142
+ format: "auto",
143
+ fit: "cover",
144
+ });
145
+ return (
146
+ <div
147
+ key={m.id}
148
+ class="relative group aspect-square"
149
+ data-show={`$mediaIds.includes('${m.id}')`}
150
+ >
151
+ <img
152
+ src={thumbUrl}
153
+ alt={m.alt || m.originalName}
154
+ class="w-full h-full object-cover rounded-lg border"
155
+ loading="lazy"
156
+ />
157
+ <button
158
+ type="button"
159
+ class="absolute top-1 right-1 w-5 h-5 flex items-center justify-center bg-black/60 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
160
+ data-on:click={`$mediaIds = $mediaIds.filter(id => id !== '${m.id}')`}
161
+ title={t({
162
+ message: "Remove",
163
+ comment: "@context: Remove media attachment button",
164
+ })}
165
+ >
166
+ &times;
167
+ </button>
168
+ </div>
169
+ );
170
+ })}
171
+ </div>
172
+ )}
173
+ <button
174
+ type="button"
175
+ class="btn-outline text-sm"
176
+ data-on:click="document.getElementById('media-picker-dialog').showModal(); fetch('/dash/media/picker').then(r => r.text()).then(html => document.getElementById('media-picker-grid').innerHTML = html)"
177
+ >
178
+ {t({
179
+ message: "Add Media",
180
+ comment: "@context: Button to open media picker",
181
+ })}
182
+ </button>
183
+ </div>
184
+
99
185
  {/* Source URL (for link/quote types) */}
100
186
  <div class="field">
101
187
  <label class="label">
@@ -112,6 +198,26 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
112
198
  />
113
199
  </div>
114
200
 
201
+ {/* Source Name (for link/quote types) */}
202
+ <div class="field">
203
+ <label class="label">
204
+ {t({
205
+ message: "Source Name (optional)",
206
+ comment:
207
+ "@context: Post form field - name of the source website or author",
208
+ })}
209
+ </label>
210
+ <input
211
+ type="text"
212
+ data-bind="sourceName"
213
+ class="input"
214
+ placeholder={t({
215
+ message: "e.g. The Verge, John Doe",
216
+ comment: "@context: Source name placeholder",
217
+ })}
218
+ />
219
+ </div>
220
+
115
221
  {/* Visibility */}
116
222
  <div class="field">
117
223
  <label class="label">
@@ -148,6 +254,31 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
148
254
  </select>
149
255
  </div>
150
256
 
257
+ {/* Collections */}
258
+ {collections && collections.length > 0 && (
259
+ <fieldset class="field">
260
+ <legend class="label">
261
+ {t({
262
+ message: "Collections (optional)",
263
+ comment: "@context: Post form field - assign to collections",
264
+ })}
265
+ </legend>
266
+ <div class="flex flex-col gap-1">
267
+ {collections.map((col) => (
268
+ <label key={col.id} class="flex items-center gap-2 text-sm">
269
+ <input
270
+ type="checkbox"
271
+ class="checkbox"
272
+ data-attr:checked={`$collectionIds.includes(${col.id})`}
273
+ data-on:change={`$collectionIds.includes(${col.id}) ? $collectionIds = $collectionIds.filter(id => id !== ${col.id}) : $collectionIds = [...$collectionIds, ${col.id}]`}
274
+ />
275
+ {col.title}
276
+ </label>
277
+ ))}
278
+ </div>
279
+ </fieldset>
280
+ )}
281
+
151
282
  {/* Custom path (optional) */}
152
283
  <div class="field">
153
284
  <label class="label">
@@ -181,6 +312,43 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
181
312
  {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
182
313
  </a>
183
314
  </div>
315
+
316
+ {/* Media picker dialog */}
317
+ <dialog
318
+ id="media-picker-dialog"
319
+ class="p-6 rounded-lg max-w-2xl w-full backdrop:bg-black/50"
320
+ onclick="event.target === this && this.close()"
321
+ >
322
+ <div class="flex items-center justify-between mb-4">
323
+ <h2 class="text-lg font-semibold">
324
+ {t({
325
+ message: "Select Media",
326
+ comment: "@context: Media picker dialog title",
327
+ })}
328
+ </h2>
329
+ <button
330
+ type="button"
331
+ class="btn-outline text-sm"
332
+ onclick="this.closest('dialog').close()"
333
+ >
334
+ {t({
335
+ message: "Done",
336
+ comment: "@context: Close media picker button",
337
+ })}
338
+ </button>
339
+ </div>
340
+ <div
341
+ id="media-picker-grid"
342
+ class="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto"
343
+ >
344
+ <p class="text-muted-foreground text-sm col-span-4">
345
+ {t({
346
+ message: "Loading...",
347
+ comment: "@context: Loading state for media picker",
348
+ })}
349
+ </p>
350
+ </div>
351
+ </dialog>
184
352
  </form>
185
353
  );
186
354
  };
@@ -3,6 +3,7 @@ export { CrudPageHeader, type CrudPageHeaderProps } from "./CrudPageHeader.js";
3
3
  export { DangerZone, type DangerZoneProps } from "./DangerZone.js";
4
4
  export { EmptyState, type EmptyStateProps } from "./EmptyState.js";
5
5
  export { ListItemRow, type ListItemRowProps } from "./ListItemRow.js";
6
+ export { MediaGallery, type MediaGalleryProps } from "./MediaGallery.js";
6
7
  export { PageForm, type PageFormProps } from "./PageForm.js";
7
8
  export {
8
9
  Pagination,
package/src/types.ts CHANGED
@@ -16,6 +16,22 @@ export const POST_TYPES = [
16
16
  ] as const;
17
17
  export type PostType = (typeof POST_TYPES)[number];
18
18
 
19
+ export const MAX_MEDIA_ATTACHMENTS = 20;
20
+
21
+ /**
22
+ * Media attachment rules per post type.
23
+ * Each entry is [min, max] or null (no media allowed).
24
+ */
25
+ export const POST_TYPE_MEDIA_RULES: Record<PostType, [number, number] | null> =
26
+ {
27
+ note: [0, 20],
28
+ article: [0, 20],
29
+ image: [1, 20],
30
+ link: [0, 1],
31
+ quote: [0, 20],
32
+ page: null,
33
+ };
34
+
19
35
  export const VISIBILITY_LEVELS = [
20
36
  "featured",
21
37
  "quiet",
@@ -135,9 +151,27 @@ export interface Media {
135
151
  width: number | null;
136
152
  height: number | null;
137
153
  alt: string | null;
154
+ position: number;
155
+ blurhash: string | null;
138
156
  createdAt: number;
139
157
  }
140
158
 
159
+ export interface MediaAttachment {
160
+ id: string;
161
+ url: string;
162
+ previewUrl: string;
163
+ alt: string | null;
164
+ blurhash: string | null;
165
+ width: number | null;
166
+ height: number | null;
167
+ position: number;
168
+ mimeType: string;
169
+ }
170
+
171
+ export interface PostWithMedia extends Post {
172
+ mediaAttachments: MediaAttachment[];
173
+ }
174
+
141
175
  export interface Collection {
142
176
  id: number;
143
177
  title: string;
@@ -181,6 +215,7 @@ export interface CreatePost {
181
215
  sourceName?: string;
182
216
  replyToId?: number;
183
217
  publishedAt?: number;
218
+ mediaIds?: string[];
184
219
  }
185
220
 
186
221
  export interface UpdatePost {
@@ -192,6 +227,7 @@ export interface UpdatePost {
192
227
  sourceUrl?: string | null;
193
228
  sourceName?: string | null;
194
229
  publishedAt?: number;
230
+ mediaIds?: string[];
195
231
  }
196
232
 
197
233
  // =============================================================================