@jant/core 0.3.6 → 0.3.8
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.js +11 -21
- package/dist/client.js +1 -0
- package/dist/db/schema.js +15 -0
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/image.js +3 -3
- package/dist/lib/media-helpers.js +43 -0
- package/dist/lib/nav-reorder.js +27 -0
- package/dist/lib/navigation.js +35 -0
- package/dist/lib/schemas.js +32 -2
- package/dist/lib/sse.js +7 -8
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/posts.js +101 -5
- package/dist/routes/api/timeline.js +115 -0
- package/dist/routes/api/upload.js +9 -5
- package/dist/routes/dash/media.js +38 -0
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/dash/posts.js +45 -6
- package/dist/routes/feed/rss.js +10 -1
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +88 -98
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +61 -48
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/collection.js +13 -0
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +55 -2
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/MediaGallery.js +107 -0
- package/dist/theme/components/PostForm.js +158 -2
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +3 -0
- package/dist/theme/components/timeline/ArticleCard.js +50 -0
- package/dist/theme/components/timeline/ImageCard.js +86 -0
- package/dist/theme/components/timeline/LinkCard.js +62 -0
- package/dist/theme/components/timeline/NoteCard.js +37 -0
- package/dist/theme/components/timeline/QuoteCard.js +51 -0
- package/dist/theme/components/timeline/ThreadPreview.js +52 -0
- package/dist/theme/components/timeline/TimelineFeed.js +43 -0
- package/dist/theme/components/timeline/TimelineItem.js +25 -0
- package/dist/theme/components/timeline/index.js +8 -0
- package/dist/theme/layouts/DashLayout.js +8 -0
- package/dist/theme/layouts/SiteLayout.js +160 -0
- package/dist/theme/layouts/index.js +1 -0
- package/dist/types/sortablejs.d.js +5 -0
- package/dist/types.js +27 -0
- package/package.json +3 -2
- package/src/__tests__/helpers/app.ts +6 -1
- package/src/__tests__/helpers/db.ts +20 -0
- package/src/app.tsx +11 -25
- package/src/client.ts +1 -0
- package/src/db/migrations/0002_add_media_attachments.sql +3 -0
- package/src/db/migrations/0003_add_navigation_links.sql +8 -0
- package/src/db/migrations/meta/0003_snapshot.json +821 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +15 -0
- package/src/i18n/locales/en.po +170 -58
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +162 -71
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +162 -71
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +13 -1
- package/src/lib/__tests__/schemas.test.ts +89 -1
- package/src/lib/__tests__/sse.test.ts +13 -1
- package/src/lib/__tests__/theme-components.test.ts +107 -0
- package/src/lib/image.ts +3 -3
- package/src/lib/media-helpers.ts +54 -0
- package/src/lib/nav-reorder.ts +26 -0
- package/src/lib/navigation.ts +46 -0
- package/src/lib/schemas.ts +47 -1
- package/src/lib/sse.ts +10 -11
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +239 -0
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/posts.ts +134 -5
- package/src/routes/api/timeline.tsx +145 -0
- package/src/routes/api/upload.ts +9 -5
- package/src/routes/dash/media.tsx +50 -0
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/dash/posts.tsx +79 -7
- package/src/routes/feed/rss.ts +14 -1
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +121 -88
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +64 -40
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/collection.test.ts +102 -0
- package/src/services/__tests__/media.test.ts +282 -7
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/collection.ts +19 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +78 -2
- package/src/services/navigation.ts +165 -0
- package/src/services/post.ts +48 -1
- package/src/styles/components.css +59 -0
- package/src/theme/components/MediaGallery.tsx +128 -0
- package/src/theme/components/PostForm.tsx +170 -2
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +13 -0
- package/src/theme/components/timeline/ArticleCard.tsx +57 -0
- package/src/theme/components/timeline/ImageCard.tsx +80 -0
- package/src/theme/components/timeline/LinkCard.tsx +66 -0
- package/src/theme/components/timeline/NoteCard.tsx +41 -0
- package/src/theme/components/timeline/QuoteCard.tsx +55 -0
- package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
- package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
- package/src/theme/components/timeline/TimelineItem.tsx +39 -0
- package/src/theme/components/timeline/index.ts +8 -0
- package/src/theme/layouts/DashLayout.tsx +10 -0
- package/src/theme/layouts/SiteLayout.tsx +184 -0
- package/src/theme/layouts/index.ts +1 -0
- package/src/types/sortablejs.d.ts +23 -0
- package/src/types.ts +97 -0
- package/dist/app.d.ts +0 -38
- package/dist/app.d.ts.map +0 -1
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -10
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/schema.d.ts +0 -1507
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/i18n/Trans.d.ts +0 -25
- package/dist/i18n/Trans.d.ts.map +0 -1
- package/dist/i18n/context.d.ts +0 -69
- package/dist/i18n/context.d.ts.map +0 -1
- package/dist/i18n/detect.d.ts +0 -20
- package/dist/i18n/detect.d.ts.map +0 -1
- package/dist/i18n/i18n.d.ts +0 -32
- package/dist/i18n/i18n.d.ts.map +0 -1
- package/dist/i18n/index.d.ts +0 -41
- package/dist/i18n/index.d.ts.map +0 -1
- package/dist/i18n/locales/en.d.ts +0 -3
- package/dist/i18n/locales/en.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hans.d.ts +0 -3
- package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hant.d.ts +0 -3
- package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
- package/dist/i18n/locales.d.ts +0 -11
- package/dist/i18n/locales.d.ts.map +0 -1
- package/dist/i18n/middleware.d.ts +0 -21
- package/dist/i18n/middleware.d.ts.map +0 -1
- package/dist/index.d.ts +0 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/config.d.ts +0 -83
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/constants.d.ts +0 -37
- package/dist/lib/constants.d.ts.map +0 -1
- package/dist/lib/image.d.ts +0 -73
- package/dist/lib/image.d.ts.map +0 -1
- package/dist/lib/index.d.ts +0 -9
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/lib/markdown.d.ts +0 -60
- package/dist/lib/markdown.d.ts.map +0 -1
- package/dist/lib/schemas.d.ts +0 -113
- package/dist/lib/schemas.d.ts.map +0 -1
- package/dist/lib/sqid.d.ts +0 -60
- package/dist/lib/sqid.d.ts.map +0 -1
- package/dist/lib/sse.d.ts +0 -192
- package/dist/lib/sse.d.ts.map +0 -1
- package/dist/lib/theme.d.ts +0 -44
- package/dist/lib/theme.d.ts.map +0 -1
- package/dist/lib/time.d.ts +0 -90
- package/dist/lib/time.d.ts.map +0 -1
- package/dist/lib/url.d.ts +0 -82
- package/dist/lib/url.d.ts.map +0 -1
- package/dist/middleware/auth.d.ts +0 -24
- package/dist/middleware/auth.d.ts.map +0 -1
- package/dist/middleware/onboarding.d.ts +0 -26
- package/dist/middleware/onboarding.d.ts.map +0 -1
- package/dist/routes/api/posts.d.ts +0 -13
- package/dist/routes/api/posts.d.ts.map +0 -1
- package/dist/routes/api/search.d.ts +0 -13
- package/dist/routes/api/search.d.ts.map +0 -1
- package/dist/routes/api/upload.d.ts +0 -16
- package/dist/routes/api/upload.d.ts.map +0 -1
- package/dist/routes/dash/collections.d.ts +0 -13
- package/dist/routes/dash/collections.d.ts.map +0 -1
- package/dist/routes/dash/index.d.ts +0 -15
- package/dist/routes/dash/index.d.ts.map +0 -1
- package/dist/routes/dash/media.d.ts +0 -16
- package/dist/routes/dash/media.d.ts.map +0 -1
- package/dist/routes/dash/pages.d.ts +0 -15
- package/dist/routes/dash/pages.d.ts.map +0 -1
- package/dist/routes/dash/posts.d.ts +0 -13
- package/dist/routes/dash/posts.d.ts.map +0 -1
- package/dist/routes/dash/redirects.d.ts +0 -13
- package/dist/routes/dash/redirects.d.ts.map +0 -1
- package/dist/routes/dash/settings.d.ts +0 -15
- package/dist/routes/dash/settings.d.ts.map +0 -1
- package/dist/routes/feed/rss.d.ts +0 -13
- package/dist/routes/feed/rss.d.ts.map +0 -1
- package/dist/routes/feed/sitemap.d.ts +0 -13
- package/dist/routes/feed/sitemap.d.ts.map +0 -1
- package/dist/routes/pages/archive.d.ts +0 -15
- package/dist/routes/pages/archive.d.ts.map +0 -1
- package/dist/routes/pages/collection.d.ts +0 -13
- package/dist/routes/pages/collection.d.ts.map +0 -1
- package/dist/routes/pages/home.d.ts +0 -13
- package/dist/routes/pages/home.d.ts.map +0 -1
- package/dist/routes/pages/page.d.ts +0 -15
- package/dist/routes/pages/page.d.ts.map +0 -1
- package/dist/routes/pages/post.d.ts +0 -13
- package/dist/routes/pages/post.d.ts.map +0 -1
- package/dist/routes/pages/search.d.ts +0 -13
- package/dist/routes/pages/search.d.ts.map +0 -1
- package/dist/services/collection.d.ts +0 -31
- package/dist/services/collection.d.ts.map +0 -1
- package/dist/services/index.d.ts +0 -28
- package/dist/services/index.d.ts.map +0 -1
- package/dist/services/media.d.ts +0 -27
- package/dist/services/media.d.ts.map +0 -1
- package/dist/services/post.d.ts +0 -31
- package/dist/services/post.d.ts.map +0 -1
- package/dist/services/redirect.d.ts +0 -15
- package/dist/services/redirect.d.ts.map +0 -1
- package/dist/services/search.d.ts +0 -26
- package/dist/services/search.d.ts.map +0 -1
- package/dist/services/settings.d.ts +0 -18
- package/dist/services/settings.d.ts.map +0 -1
- package/dist/theme/color-themes.d.ts +0 -30
- package/dist/theme/color-themes.d.ts.map +0 -1
- package/dist/theme/components/ActionButtons.d.ts +0 -43
- package/dist/theme/components/ActionButtons.d.ts.map +0 -1
- package/dist/theme/components/CrudPageHeader.d.ts +0 -23
- package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
- package/dist/theme/components/DangerZone.d.ts +0 -36
- package/dist/theme/components/DangerZone.d.ts.map +0 -1
- package/dist/theme/components/EmptyState.d.ts +0 -27
- package/dist/theme/components/EmptyState.d.ts.map +0 -1
- package/dist/theme/components/ListItemRow.d.ts +0 -15
- package/dist/theme/components/ListItemRow.d.ts.map +0 -1
- package/dist/theme/components/PageForm.d.ts +0 -14
- package/dist/theme/components/PageForm.d.ts.map +0 -1
- package/dist/theme/components/Pagination.d.ts +0 -46
- package/dist/theme/components/Pagination.d.ts.map +0 -1
- package/dist/theme/components/PostForm.d.ts +0 -11
- package/dist/theme/components/PostForm.d.ts.map +0 -1
- package/dist/theme/components/PostList.d.ts +0 -10
- package/dist/theme/components/PostList.d.ts.map +0 -1
- package/dist/theme/components/ThreadView.d.ts +0 -15
- package/dist/theme/components/ThreadView.d.ts.map +0 -1
- package/dist/theme/components/TypeBadge.d.ts +0 -12
- package/dist/theme/components/TypeBadge.d.ts.map +0 -1
- package/dist/theme/components/VisibilityBadge.d.ts +0 -12
- package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
- package/dist/theme/components/index.d.ts +0 -13
- package/dist/theme/components/index.d.ts.map +0 -1
- package/dist/theme/index.d.ts +0 -21
- package/dist/theme/index.d.ts.map +0 -1
- package/dist/theme/layouts/BaseLayout.d.ts +0 -23
- package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
- package/dist/theme/layouts/DashLayout.d.ts +0 -17
- package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
- package/dist/theme/layouts/index.d.ts +0 -3
- package/dist/theme/layouts/index.d.ts.map +0 -1
- package/dist/types.d.ts +0 -213
- package/dist/types.d.ts.map +0 -1
|
@@ -35,6 +35,39 @@ describe("Posts API Routes", () => {
|
|
|
35
35
|
expect(body.posts[0].sqid).toBeTruthy();
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
it("includes mediaAttachments in list response", async () => {
|
|
39
|
+
const { app, services } = createTestApp();
|
|
40
|
+
app.route("/api/posts", postsApiRoutes);
|
|
41
|
+
|
|
42
|
+
const post = await services.posts.create({
|
|
43
|
+
type: "note",
|
|
44
|
+
content: "with media",
|
|
45
|
+
visibility: "featured",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const media = await services.media.create({
|
|
49
|
+
filename: "test.jpg",
|
|
50
|
+
originalName: "test.jpg",
|
|
51
|
+
mimeType: "image/jpeg",
|
|
52
|
+
size: 1024,
|
|
53
|
+
r2Key: "media/2025/01/test.jpg",
|
|
54
|
+
width: 800,
|
|
55
|
+
height: 600,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await services.media.attachToPost(post.id, [media.id]);
|
|
59
|
+
|
|
60
|
+
const res = await app.request("/api/posts");
|
|
61
|
+
const body = await res.json();
|
|
62
|
+
|
|
63
|
+
expect(body.posts[0].mediaAttachments).toHaveLength(1);
|
|
64
|
+
expect(body.posts[0].mediaAttachments[0].id).toBe(media.id);
|
|
65
|
+
expect(body.posts[0].mediaAttachments[0].mimeType).toBe("image/jpeg");
|
|
66
|
+
expect(body.posts[0].mediaAttachments[0].url).toBeTruthy();
|
|
67
|
+
expect(body.posts[0].mediaAttachments[0].previewUrl).toBeTruthy();
|
|
68
|
+
expect(body.posts[0].mediaAttachments[0].position).toBe(0);
|
|
69
|
+
});
|
|
70
|
+
|
|
38
71
|
it("filters by visibility", async () => {
|
|
39
72
|
const { app, services } = createTestApp();
|
|
40
73
|
app.route("/api/posts", postsApiRoutes);
|
|
@@ -97,6 +130,33 @@ describe("Posts API Routes", () => {
|
|
|
97
130
|
expect(body.sqid).toBe(id);
|
|
98
131
|
});
|
|
99
132
|
|
|
133
|
+
it("includes mediaAttachments in single post response", async () => {
|
|
134
|
+
const { app, services } = createTestApp();
|
|
135
|
+
app.route("/api/posts", postsApiRoutes);
|
|
136
|
+
|
|
137
|
+
const post = await services.posts.create({
|
|
138
|
+
type: "note",
|
|
139
|
+
content: "with media",
|
|
140
|
+
visibility: "featured",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const media = await services.media.create({
|
|
144
|
+
filename: "test.jpg",
|
|
145
|
+
originalName: "test.jpg",
|
|
146
|
+
mimeType: "image/jpeg",
|
|
147
|
+
size: 1024,
|
|
148
|
+
r2Key: "media/2025/01/test.jpg",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await services.media.attachToPost(post.id, [media.id]);
|
|
152
|
+
|
|
153
|
+
const res = await app.request(`/api/posts/${sqid.encode(post.id)}`);
|
|
154
|
+
const body = await res.json();
|
|
155
|
+
|
|
156
|
+
expect(body.mediaAttachments).toHaveLength(1);
|
|
157
|
+
expect(body.mediaAttachments[0].id).toBe(media.id);
|
|
158
|
+
});
|
|
159
|
+
|
|
100
160
|
it("returns 400 for invalid sqid", async () => {
|
|
101
161
|
const { app } = createTestApp();
|
|
102
162
|
app.route("/api/posts", postsApiRoutes);
|
|
@@ -151,6 +211,114 @@ describe("Posts API Routes", () => {
|
|
|
151
211
|
const body = await res.json();
|
|
152
212
|
expect(body.content).toBe("Hello from API");
|
|
153
213
|
expect(body.sqid).toBeTruthy();
|
|
214
|
+
expect(body.mediaAttachments).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("creates a post with mediaIds and attaches them", async () => {
|
|
218
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
219
|
+
app.route("/api/posts", postsApiRoutes);
|
|
220
|
+
|
|
221
|
+
const m1 = await services.media.create({
|
|
222
|
+
filename: "a.jpg",
|
|
223
|
+
originalName: "a.jpg",
|
|
224
|
+
mimeType: "image/jpeg",
|
|
225
|
+
size: 1024,
|
|
226
|
+
r2Key: "media/2025/01/a.jpg",
|
|
227
|
+
});
|
|
228
|
+
const m2 = await services.media.create({
|
|
229
|
+
filename: "b.jpg",
|
|
230
|
+
originalName: "b.jpg",
|
|
231
|
+
mimeType: "image/jpeg",
|
|
232
|
+
size: 2048,
|
|
233
|
+
r2Key: "media/2025/01/b.jpg",
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const res = await app.request("/api/posts", {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers: { "Content-Type": "application/json" },
|
|
239
|
+
body: JSON.stringify({
|
|
240
|
+
type: "note",
|
|
241
|
+
content: "with images",
|
|
242
|
+
visibility: "quiet",
|
|
243
|
+
mediaIds: [m1.id, m2.id],
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(res.status).toBe(201);
|
|
248
|
+
const body = await res.json();
|
|
249
|
+
expect(body.mediaAttachments).toHaveLength(2);
|
|
250
|
+
expect(body.mediaAttachments[0].id).toBe(m1.id);
|
|
251
|
+
expect(body.mediaAttachments[0].position).toBe(0);
|
|
252
|
+
expect(body.mediaAttachments[1].id).toBe(m2.id);
|
|
253
|
+
expect(body.mediaAttachments[1].position).toBe(1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("returns 400 for image type without media", async () => {
|
|
257
|
+
const { app } = createTestApp({ authenticated: true });
|
|
258
|
+
app.route("/api/posts", postsApiRoutes);
|
|
259
|
+
|
|
260
|
+
const res = await app.request("/api/posts", {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: { "Content-Type": "application/json" },
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
type: "image",
|
|
265
|
+
content: "should fail",
|
|
266
|
+
visibility: "quiet",
|
|
267
|
+
mediaIds: [],
|
|
268
|
+
}),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(res.status).toBe(400);
|
|
272
|
+
const body = await res.json();
|
|
273
|
+
expect(body.error).toContain("image posts require at least 1");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("returns 400 for page type with media", async () => {
|
|
277
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
278
|
+
app.route("/api/posts", postsApiRoutes);
|
|
279
|
+
|
|
280
|
+
const m1 = await services.media.create({
|
|
281
|
+
filename: "a.jpg",
|
|
282
|
+
originalName: "a.jpg",
|
|
283
|
+
mimeType: "image/jpeg",
|
|
284
|
+
size: 1024,
|
|
285
|
+
r2Key: "media/2025/01/a.jpg",
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const res = await app.request("/api/posts", {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "Content-Type": "application/json" },
|
|
291
|
+
body: JSON.stringify({
|
|
292
|
+
type: "page",
|
|
293
|
+
content: "test",
|
|
294
|
+
visibility: "quiet",
|
|
295
|
+
mediaIds: [m1.id],
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
expect(res.status).toBe(400);
|
|
300
|
+
const body = await res.json();
|
|
301
|
+
expect(body.error).toContain("page posts do not allow");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("returns 400 for invalid media IDs", async () => {
|
|
305
|
+
const { app } = createTestApp({ authenticated: true });
|
|
306
|
+
app.route("/api/posts", postsApiRoutes);
|
|
307
|
+
|
|
308
|
+
const res = await app.request("/api/posts", {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "Content-Type": "application/json" },
|
|
311
|
+
body: JSON.stringify({
|
|
312
|
+
type: "note",
|
|
313
|
+
content: "test",
|
|
314
|
+
visibility: "quiet",
|
|
315
|
+
mediaIds: ["nonexistent-id"],
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
expect(res.status).toBe(400);
|
|
320
|
+
const body = await res.json();
|
|
321
|
+
expect(body.error).toContain("media IDs are invalid");
|
|
154
322
|
});
|
|
155
323
|
|
|
156
324
|
it("returns 400 for invalid body", async () => {
|
|
@@ -219,6 +387,77 @@ describe("Posts API Routes", () => {
|
|
|
219
387
|
expect(res.status).toBe(200);
|
|
220
388
|
const body = await res.json();
|
|
221
389
|
expect(body.content).toBe("updated");
|
|
390
|
+
expect(body.mediaAttachments).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("updates post with mediaIds to replace attachments", async () => {
|
|
394
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
395
|
+
app.route("/api/posts", postsApiRoutes);
|
|
396
|
+
|
|
397
|
+
const post = await services.posts.create({
|
|
398
|
+
type: "note",
|
|
399
|
+
content: "test",
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const m1 = await services.media.create({
|
|
403
|
+
filename: "a.jpg",
|
|
404
|
+
originalName: "a.jpg",
|
|
405
|
+
mimeType: "image/jpeg",
|
|
406
|
+
size: 1024,
|
|
407
|
+
r2Key: "media/2025/01/a.jpg",
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
await services.media.attachToPost(post.id, [m1.id]);
|
|
411
|
+
|
|
412
|
+
const m2 = await services.media.create({
|
|
413
|
+
filename: "b.jpg",
|
|
414
|
+
originalName: "b.jpg",
|
|
415
|
+
mimeType: "image/jpeg",
|
|
416
|
+
size: 2048,
|
|
417
|
+
r2Key: "media/2025/01/b.jpg",
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
|
|
421
|
+
method: "PUT",
|
|
422
|
+
headers: { "Content-Type": "application/json" },
|
|
423
|
+
body: JSON.stringify({ mediaIds: [m2.id] }),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
expect(res.status).toBe(200);
|
|
427
|
+
const body = await res.json();
|
|
428
|
+
expect(body.mediaAttachments).toHaveLength(1);
|
|
429
|
+
expect(body.mediaAttachments[0].id).toBe(m2.id);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("preserves existing attachments when mediaIds is omitted", async () => {
|
|
433
|
+
const { app, services } = createTestApp({ authenticated: true });
|
|
434
|
+
app.route("/api/posts", postsApiRoutes);
|
|
435
|
+
|
|
436
|
+
const post = await services.posts.create({
|
|
437
|
+
type: "note",
|
|
438
|
+
content: "test",
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const m1 = await services.media.create({
|
|
442
|
+
filename: "a.jpg",
|
|
443
|
+
originalName: "a.jpg",
|
|
444
|
+
mimeType: "image/jpeg",
|
|
445
|
+
size: 1024,
|
|
446
|
+
r2Key: "media/2025/01/a.jpg",
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
await services.media.attachToPost(post.id, [m1.id]);
|
|
450
|
+
|
|
451
|
+
const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
|
|
452
|
+
method: "PUT",
|
|
453
|
+
headers: { "Content-Type": "application/json" },
|
|
454
|
+
body: JSON.stringify({ content: "updated content" }),
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
expect(res.status).toBe(200);
|
|
458
|
+
const body = await res.json();
|
|
459
|
+
expect(body.mediaAttachments).toHaveLength(1);
|
|
460
|
+
expect(body.mediaAttachments[0].id).toBe(m1.id);
|
|
222
461
|
});
|
|
223
462
|
|
|
224
463
|
it("returns 404 for non-existent post", async () => {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline API Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the timeline data assembly logic via the service layer.
|
|
5
|
+
* The actual route handler renders JSX components which require the Lingui SWC
|
|
6
|
+
* plugin (not available in vitest). We test the underlying service operations
|
|
7
|
+
* that power the timeline API instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
11
|
+
import { createTestDatabase } from "../../../__tests__/helpers/db.js";
|
|
12
|
+
import { createPostService } from "../../../services/post.js";
|
|
13
|
+
import { createMediaService } from "../../../services/media.js";
|
|
14
|
+
import { buildMediaMap } from "../../../lib/media-helpers.js";
|
|
15
|
+
import type { Database } from "../../../db/index.js";
|
|
16
|
+
import type { PostWithMedia, TimelineItemData } from "../../../types.js";
|
|
17
|
+
|
|
18
|
+
describe("Timeline data assembly", () => {
|
|
19
|
+
let db: Database;
|
|
20
|
+
let postService: ReturnType<typeof createPostService>;
|
|
21
|
+
let mediaService: ReturnType<typeof createMediaService>;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
const testDb = createTestDatabase();
|
|
25
|
+
db = testDb.db as unknown as Database;
|
|
26
|
+
postService = createPostService(db);
|
|
27
|
+
mediaService = createMediaService(db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("assembles timeline items with media attachments", async () => {
|
|
31
|
+
const post = await postService.create({
|
|
32
|
+
type: "note",
|
|
33
|
+
content: "Hello",
|
|
34
|
+
visibility: "featured",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const posts = await postService.list({
|
|
38
|
+
visibility: ["featured", "quiet"],
|
|
39
|
+
excludeReplies: true,
|
|
40
|
+
excludeTypes: ["page"],
|
|
41
|
+
limit: 21,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(posts).toHaveLength(1);
|
|
45
|
+
expect(posts[0]?.id).toBe(post.id);
|
|
46
|
+
|
|
47
|
+
// Build media map
|
|
48
|
+
const postIds = posts.map((p) => p.id);
|
|
49
|
+
const rawMediaMap = await mediaService.getByPostIds(postIds);
|
|
50
|
+
const mediaMap = buildMediaMap(rawMediaMap);
|
|
51
|
+
|
|
52
|
+
// Assemble items
|
|
53
|
+
const items: TimelineItemData[] = posts.map((p) => ({
|
|
54
|
+
post: { ...p, mediaAttachments: mediaMap.get(p.id) ?? [] },
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
expect(items).toHaveLength(1);
|
|
58
|
+
expect(items[0]?.post.mediaAttachments).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("identifies thread roots and builds thread previews", async () => {
|
|
62
|
+
const root = await postService.create({
|
|
63
|
+
type: "note",
|
|
64
|
+
content: "Thread root",
|
|
65
|
+
visibility: "featured",
|
|
66
|
+
});
|
|
67
|
+
await postService.create({
|
|
68
|
+
type: "note",
|
|
69
|
+
content: "Reply 1",
|
|
70
|
+
replyToId: root.id,
|
|
71
|
+
});
|
|
72
|
+
await postService.create({
|
|
73
|
+
type: "note",
|
|
74
|
+
content: "Reply 2",
|
|
75
|
+
replyToId: root.id,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const posts = await postService.list({
|
|
79
|
+
visibility: ["featured", "quiet"],
|
|
80
|
+
excludeReplies: true,
|
|
81
|
+
excludeTypes: ["page"],
|
|
82
|
+
limit: 21,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(posts).toHaveLength(1);
|
|
86
|
+
|
|
87
|
+
const postIds = posts.map((p) => p.id);
|
|
88
|
+
const replyCounts = await postService.getReplyCounts(postIds);
|
|
89
|
+
const threadRootIds = postIds.filter(
|
|
90
|
+
(id) => (replyCounts.get(id) ?? 0) > 0,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(threadRootIds).toEqual([root.id]);
|
|
94
|
+
expect(replyCounts.get(root.id)).toBe(2);
|
|
95
|
+
|
|
96
|
+
const threadPreviews = await postService.getThreadPreviews(threadRootIds);
|
|
97
|
+
const replies = threadPreviews.get(root.id);
|
|
98
|
+
expect(replies).toHaveLength(2);
|
|
99
|
+
expect(replies?.[0]?.content).toBe("Reply 1");
|
|
100
|
+
|
|
101
|
+
// Assemble items
|
|
102
|
+
const rawMediaMap = await mediaService.getByPostIds(postIds);
|
|
103
|
+
const mediaMap = buildMediaMap(rawMediaMap);
|
|
104
|
+
|
|
105
|
+
const items: TimelineItemData[] = posts.map((post) => {
|
|
106
|
+
const postWithMedia: PostWithMedia = {
|
|
107
|
+
...post,
|
|
108
|
+
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
112
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
113
|
+
|
|
114
|
+
if (replyCount > 0 && previewReplies) {
|
|
115
|
+
return {
|
|
116
|
+
post: postWithMedia,
|
|
117
|
+
threadPreview: {
|
|
118
|
+
replies: previewReplies.map((r) => ({
|
|
119
|
+
...r,
|
|
120
|
+
mediaAttachments: [],
|
|
121
|
+
})),
|
|
122
|
+
totalReplyCount: replyCount,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { post: postWithMedia };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(items).toHaveLength(1);
|
|
131
|
+
expect(items[0]?.threadPreview).toBeDefined();
|
|
132
|
+
expect(items[0]?.threadPreview?.replies).toHaveLength(2);
|
|
133
|
+
expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("excludes pages from timeline", async () => {
|
|
137
|
+
await postService.create({
|
|
138
|
+
type: "note",
|
|
139
|
+
content: "A note",
|
|
140
|
+
visibility: "quiet",
|
|
141
|
+
});
|
|
142
|
+
await postService.create({
|
|
143
|
+
type: "page",
|
|
144
|
+
content: "A page",
|
|
145
|
+
visibility: "quiet",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const posts = await postService.list({
|
|
149
|
+
visibility: ["featured", "quiet"],
|
|
150
|
+
excludeReplies: true,
|
|
151
|
+
excludeTypes: ["page"],
|
|
152
|
+
limit: 21,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(posts).toHaveLength(1);
|
|
156
|
+
expect(posts[0]?.type).toBe("note");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("excludes replies from top-level list", async () => {
|
|
160
|
+
const root = await postService.create({
|
|
161
|
+
type: "note",
|
|
162
|
+
content: "Root",
|
|
163
|
+
visibility: "quiet",
|
|
164
|
+
});
|
|
165
|
+
await postService.create({
|
|
166
|
+
type: "note",
|
|
167
|
+
content: "Reply",
|
|
168
|
+
replyToId: root.id,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const posts = await postService.list({
|
|
172
|
+
visibility: ["featured", "quiet"],
|
|
173
|
+
excludeReplies: true,
|
|
174
|
+
excludeTypes: ["page"],
|
|
175
|
+
limit: 21,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(posts).toHaveLength(1);
|
|
179
|
+
expect(posts[0]?.content).toBe("Root");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("supports cursor pagination for load more", async () => {
|
|
183
|
+
const posts = [];
|
|
184
|
+
for (let i = 0; i < 5; i++) {
|
|
185
|
+
posts.push(
|
|
186
|
+
await postService.create({
|
|
187
|
+
type: "note",
|
|
188
|
+
content: `Post ${i}`,
|
|
189
|
+
visibility: "quiet",
|
|
190
|
+
publishedAt: 1000 + i,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// First page
|
|
196
|
+
const page1 = await postService.list({
|
|
197
|
+
visibility: ["featured", "quiet"],
|
|
198
|
+
excludeReplies: true,
|
|
199
|
+
excludeTypes: ["page"],
|
|
200
|
+
limit: 3,
|
|
201
|
+
});
|
|
202
|
+
expect(page1).toHaveLength(3);
|
|
203
|
+
|
|
204
|
+
// Second page using cursor
|
|
205
|
+
const lastPost = page1[page1.length - 1];
|
|
206
|
+
expect(lastPost).toBeDefined();
|
|
207
|
+
const page2 = await postService.list({
|
|
208
|
+
visibility: ["featured", "quiet"],
|
|
209
|
+
excludeReplies: true,
|
|
210
|
+
excludeTypes: ["page"],
|
|
211
|
+
limit: 3,
|
|
212
|
+
cursor: lastPost?.id,
|
|
213
|
+
});
|
|
214
|
+
expect(page2).toHaveLength(2);
|
|
215
|
+
expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("correctly determines hasMore flag", async () => {
|
|
219
|
+
for (let i = 0; i < 3; i++) {
|
|
220
|
+
await postService.create({
|
|
221
|
+
type: "note",
|
|
222
|
+
content: `Post ${i}`,
|
|
223
|
+
visibility: "quiet",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Request limit + 1 to check for more
|
|
228
|
+
const pageSize = 2;
|
|
229
|
+
const posts = await postService.list({
|
|
230
|
+
visibility: ["featured", "quiet"],
|
|
231
|
+
excludeReplies: true,
|
|
232
|
+
excludeTypes: ["page"],
|
|
233
|
+
limit: pageSize + 1,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const hasMore = posts.length > pageSize;
|
|
237
|
+
expect(hasMore).toBe(true);
|
|
238
|
+
|
|
239
|
+
const displayPosts = posts.slice(0, pageSize);
|
|
240
|
+
expect(displayPosts).toHaveLength(2);
|
|
241
|
+
});
|
|
242
|
+
});
|
package/src/routes/api/posts.ts
CHANGED
|
@@ -3,16 +3,50 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
|
-
import type { Bindings, PostType, Visibility } from "../../types.js";
|
|
6
|
+
import type { Bindings, PostType, Visibility, Media } from "../../types.js";
|
|
7
7
|
import type { AppVariables } from "../../app.js";
|
|
8
8
|
import * as sqid from "../../lib/sqid.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
CreatePostSchema,
|
|
11
|
+
UpdatePostSchema,
|
|
12
|
+
validateMediaForPostType,
|
|
13
|
+
} from "../../lib/schemas.js";
|
|
10
14
|
import { requireAuthApi } from "../../middleware/auth.js";
|
|
15
|
+
import { getMediaUrl, getImageUrl } from "../../lib/image.js";
|
|
11
16
|
|
|
12
17
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
13
18
|
|
|
14
19
|
export const postsApiRoutes = new Hono<Env>();
|
|
15
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Converts a Media record to a MediaAttachment API response shape.
|
|
23
|
+
*/
|
|
24
|
+
function toMediaAttachment(
|
|
25
|
+
m: Media,
|
|
26
|
+
r2PublicUrl?: string,
|
|
27
|
+
imageTransformUrl?: string,
|
|
28
|
+
) {
|
|
29
|
+
const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
|
|
30
|
+
const previewUrl = getImageUrl(url, imageTransformUrl, {
|
|
31
|
+
width: 400,
|
|
32
|
+
quality: 80,
|
|
33
|
+
format: "auto",
|
|
34
|
+
fit: "cover",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
id: m.id,
|
|
39
|
+
url,
|
|
40
|
+
previewUrl,
|
|
41
|
+
alt: m.alt,
|
|
42
|
+
blurhash: m.blurhash,
|
|
43
|
+
width: m.width,
|
|
44
|
+
height: m.height,
|
|
45
|
+
position: m.position,
|
|
46
|
+
mimeType: m.mimeType,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
16
50
|
// List posts
|
|
17
51
|
postsApiRoutes.get("/", async (c) => {
|
|
18
52
|
const type = c.req.query("type") as PostType | undefined;
|
|
@@ -27,10 +61,19 @@ postsApiRoutes.get("/", async (c) => {
|
|
|
27
61
|
limit,
|
|
28
62
|
});
|
|
29
63
|
|
|
64
|
+
// Batch load media for all posts
|
|
65
|
+
const postIds = posts.map((p) => p.id);
|
|
66
|
+
const mediaMap = await c.var.services.media.getByPostIds(postIds);
|
|
67
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
68
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
69
|
+
|
|
30
70
|
return c.json({
|
|
31
71
|
posts: posts.map((p) => ({
|
|
32
72
|
...p,
|
|
33
73
|
sqid: sqid.encode(p.id),
|
|
74
|
+
mediaAttachments: (mediaMap.get(p.id) ?? []).map((m) =>
|
|
75
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
76
|
+
),
|
|
34
77
|
})),
|
|
35
78
|
|
|
36
79
|
nextCursor:
|
|
@@ -48,7 +91,17 @@ postsApiRoutes.get("/:id", async (c) => {
|
|
|
48
91
|
const post = await c.var.services.posts.getById(id);
|
|
49
92
|
if (!post) return c.json({ error: "Not found" }, 404);
|
|
50
93
|
|
|
51
|
-
|
|
94
|
+
const mediaList = await c.var.services.media.getByPostId(post.id);
|
|
95
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
96
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
97
|
+
|
|
98
|
+
return c.json({
|
|
99
|
+
...post,
|
|
100
|
+
sqid: sqid.encode(post.id),
|
|
101
|
+
mediaAttachments: mediaList.map((m) =>
|
|
102
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
103
|
+
),
|
|
104
|
+
});
|
|
52
105
|
});
|
|
53
106
|
|
|
54
107
|
// Create post (requires auth)
|
|
@@ -66,6 +119,22 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
66
119
|
|
|
67
120
|
const body = parseResult.data;
|
|
68
121
|
|
|
122
|
+
// Validate media for post type
|
|
123
|
+
if (body.mediaIds) {
|
|
124
|
+
const mediaError = validateMediaForPostType(body.type, body.mediaIds);
|
|
125
|
+
if (mediaError) {
|
|
126
|
+
return c.json({ error: mediaError }, 400);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Verify all media IDs exist
|
|
130
|
+
if (body.mediaIds.length > 0) {
|
|
131
|
+
const existing = await c.var.services.media.getByIds(body.mediaIds);
|
|
132
|
+
if (existing.length !== body.mediaIds.length) {
|
|
133
|
+
return c.json({ error: "One or more media IDs are invalid" }, 400);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
69
138
|
const post = await c.var.services.posts.create({
|
|
70
139
|
type: body.type,
|
|
71
140
|
title: body.title,
|
|
@@ -80,7 +149,25 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
|
|
|
80
149
|
publishedAt: body.publishedAt,
|
|
81
150
|
});
|
|
82
151
|
|
|
83
|
-
|
|
152
|
+
// Attach media
|
|
153
|
+
if (body.mediaIds && body.mediaIds.length > 0) {
|
|
154
|
+
await c.var.services.media.attachToPost(post.id, body.mediaIds);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const mediaList = await c.var.services.media.getByPostId(post.id);
|
|
158
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
159
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
160
|
+
|
|
161
|
+
return c.json(
|
|
162
|
+
{
|
|
163
|
+
...post,
|
|
164
|
+
sqid: sqid.encode(post.id),
|
|
165
|
+
mediaAttachments: mediaList.map((m) =>
|
|
166
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
201,
|
|
170
|
+
);
|
|
84
171
|
});
|
|
85
172
|
|
|
86
173
|
// Update post (requires auth)
|
|
@@ -101,6 +188,30 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
101
188
|
|
|
102
189
|
const body = parseResult.data;
|
|
103
190
|
|
|
191
|
+
// Validate media for post type if mediaIds is provided
|
|
192
|
+
if (body.mediaIds !== undefined) {
|
|
193
|
+
// Need the post type — use the new type if provided, else fetch existing
|
|
194
|
+
let postType = body.type;
|
|
195
|
+
if (!postType) {
|
|
196
|
+
const existing = await c.var.services.posts.getById(id);
|
|
197
|
+
if (!existing) return c.json({ error: "Not found" }, 404);
|
|
198
|
+
postType = existing.type;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const mediaError = validateMediaForPostType(postType, body.mediaIds);
|
|
202
|
+
if (mediaError) {
|
|
203
|
+
return c.json({ error: mediaError }, 400);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Verify all media IDs exist
|
|
207
|
+
if (body.mediaIds.length > 0) {
|
|
208
|
+
const existing = await c.var.services.media.getByIds(body.mediaIds);
|
|
209
|
+
if (existing.length !== body.mediaIds.length) {
|
|
210
|
+
return c.json({ error: "One or more media IDs are invalid" }, 400);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
104
215
|
const post = await c.var.services.posts.update(id, {
|
|
105
216
|
type: body.type,
|
|
106
217
|
title: body.title,
|
|
@@ -114,7 +225,22 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
|
|
|
114
225
|
|
|
115
226
|
if (!post) return c.json({ error: "Not found" }, 404);
|
|
116
227
|
|
|
117
|
-
|
|
228
|
+
// Update media attachments if provided (including empty array to clear)
|
|
229
|
+
if (body.mediaIds !== undefined) {
|
|
230
|
+
await c.var.services.media.attachToPost(post.id, body.mediaIds);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const mediaList = await c.var.services.media.getByPostId(post.id);
|
|
234
|
+
const r2PublicUrl = c.env.R2_PUBLIC_URL;
|
|
235
|
+
const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
|
|
236
|
+
|
|
237
|
+
return c.json({
|
|
238
|
+
...post,
|
|
239
|
+
sqid: sqid.encode(post.id),
|
|
240
|
+
mediaAttachments: mediaList.map((m) =>
|
|
241
|
+
toMediaAttachment(m, r2PublicUrl, imageTransformUrl),
|
|
242
|
+
),
|
|
243
|
+
});
|
|
118
244
|
});
|
|
119
245
|
|
|
120
246
|
// Delete post (requires auth)
|
|
@@ -122,6 +248,9 @@ postsApiRoutes.delete("/:id", requireAuthApi(), async (c) => {
|
|
|
122
248
|
const id = sqid.decode(c.req.param("id"));
|
|
123
249
|
if (!id) return c.json({ error: "Invalid ID" }, 400);
|
|
124
250
|
|
|
251
|
+
// Detach media before deleting
|
|
252
|
+
await c.var.services.media.detachFromPost(id);
|
|
253
|
+
|
|
125
254
|
const success = await c.var.services.posts.delete(id);
|
|
126
255
|
if (!success) return c.json({ error: "Not found" }, 404);
|
|
127
256
|
|