@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.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +7 -21
- package/dist/db/schema.d.ts +36 -0
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +2 -0
- package/dist/i18n/locales/en.d.ts.map +1 -1
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.d.ts.map +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.d.ts.map +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/schemas.d.ts +17 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +32 -2
- package/dist/lib/sse.d.ts +3 -3
- package/dist/lib/sse.d.ts.map +1 -1
- package/dist/lib/sse.js +7 -8
- package/dist/routes/api/posts.d.ts.map +1 -1
- package/dist/routes/api/posts.js +101 -5
- package/dist/routes/dash/media.js +38 -0
- package/dist/routes/dash/posts.d.ts.map +1 -1
- package/dist/routes/dash/posts.js +45 -6
- package/dist/routes/feed/rss.d.ts.map +1 -1
- package/dist/routes/feed/rss.js +10 -1
- package/dist/routes/pages/home.d.ts.map +1 -1
- package/dist/routes/pages/home.js +37 -4
- package/dist/routes/pages/post.d.ts.map +1 -1
- package/dist/routes/pages/post.js +28 -2
- package/dist/services/collection.d.ts +1 -0
- package/dist/services/collection.d.ts.map +1 -1
- package/dist/services/collection.js +13 -0
- package/dist/services/media.d.ts +7 -0
- package/dist/services/media.d.ts.map +1 -1
- package/dist/services/media.js +54 -1
- package/dist/theme/components/MediaGallery.d.ts +13 -0
- package/dist/theme/components/MediaGallery.d.ts.map +1 -0
- package/dist/theme/components/MediaGallery.js +107 -0
- package/dist/theme/components/PostForm.d.ts +6 -1
- package/dist/theme/components/PostForm.d.ts.map +1 -1
- package/dist/theme/components/PostForm.js +158 -2
- package/dist/theme/components/index.d.ts +1 -0
- package/dist/theme/components/index.d.ts.map +1 -1
- package/dist/theme/components/index.js +1 -0
- package/dist/types.d.ts +24 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +27 -0
- package/package.json +1 -1
- package/src/__tests__/helpers/app.ts +6 -1
- package/src/__tests__/helpers/db.ts +10 -0
- package/src/app.tsx +7 -25
- package/src/db/migrations/0002_add_media_attachments.sql +3 -0
- package/src/db/schema.ts +2 -0
- package/src/i18n/locales/en.po +81 -37
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +81 -37
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +81 -37
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +8 -1
- package/src/lib/__tests__/schemas.test.ts +89 -1
- package/src/lib/__tests__/sse.test.ts +13 -1
- package/src/lib/schemas.ts +47 -1
- package/src/lib/sse.ts +10 -11
- package/src/routes/api/__tests__/posts.test.ts +239 -0
- package/src/routes/api/posts.ts +134 -5
- package/src/routes/dash/media.tsx +50 -0
- package/src/routes/dash/posts.tsx +79 -7
- package/src/routes/feed/rss.ts +14 -1
- package/src/routes/pages/home.tsx +80 -36
- package/src/routes/pages/post.tsx +36 -3
- package/src/services/__tests__/collection.test.ts +102 -0
- package/src/services/__tests__/media.test.ts +248 -0
- package/src/services/collection.ts +19 -0
- package/src/services/media.ts +76 -1
- package/src/theme/components/MediaGallery.tsx +128 -0
- package/src/theme/components/PostForm.tsx +170 -2
- package/src/theme/components/index.ts +1 -0
- 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> = ({
|
|
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
|
+
×
|
|
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
|
// =============================================================================
|