@jant/core 0.3.22 → 0.3.24

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 (178) hide show
  1. package/dist/app.js +23 -5
  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 -6
  7. package/dist/lib/constants.js +1 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/navigation.js +4 -5
  11. package/dist/lib/render.js +1 -1
  12. package/dist/lib/schemas.js +80 -38
  13. package/dist/lib/theme-components.js +8 -11
  14. package/dist/lib/time.js +56 -1
  15. package/dist/lib/timeline.js +119 -0
  16. package/dist/lib/view.js +62 -73
  17. package/dist/routes/api/posts.js +29 -35
  18. package/dist/routes/api/search.js +5 -6
  19. package/dist/routes/api/upload.js +13 -13
  20. package/dist/routes/dash/collections.js +22 -40
  21. package/dist/routes/dash/index.js +2 -2
  22. package/dist/routes/dash/navigation.js +25 -24
  23. package/dist/routes/dash/pages.js +42 -57
  24. package/dist/routes/dash/posts.js +27 -35
  25. package/dist/routes/feed/rss.js +2 -4
  26. package/dist/routes/feed/sitemap.js +10 -7
  27. package/dist/routes/pages/archive.js +12 -11
  28. package/dist/routes/pages/collection.js +11 -5
  29. package/dist/routes/pages/home.js +53 -61
  30. package/dist/routes/pages/page.js +60 -29
  31. package/dist/routes/pages/post.js +5 -12
  32. package/dist/routes/pages/search.js +3 -4
  33. package/dist/services/collection.js +52 -64
  34. package/dist/services/index.js +5 -3
  35. package/dist/services/navigation.js +29 -53
  36. package/dist/services/page.js +80 -0
  37. package/dist/services/post.js +68 -69
  38. package/dist/services/search.js +24 -18
  39. package/dist/theme/components/MediaGallery.js +19 -91
  40. package/dist/theme/components/PageForm.js +15 -15
  41. package/dist/theme/components/PostForm.js +136 -129
  42. package/dist/theme/components/PostList.js +13 -8
  43. package/dist/theme/components/ThreadView.js +3 -3
  44. package/dist/theme/components/TypeBadge.js +3 -14
  45. package/dist/theme/components/VisibilityBadge.js +33 -23
  46. package/dist/theme/components/index.js +0 -2
  47. package/dist/theme/index.js +10 -16
  48. package/dist/theme/layouts/index.js +0 -1
  49. package/dist/themes/threads/ThreadsSiteLayout.js +172 -0
  50. package/dist/themes/threads/index.js +81 -0
  51. package/dist/{theme → themes/threads}/pages/ArchivePage.js +31 -47
  52. package/dist/themes/threads/pages/CollectionPage.js +65 -0
  53. package/dist/{theme → themes/threads}/pages/HomePage.js +4 -5
  54. package/dist/{theme → themes/threads}/pages/PostPage.js +10 -8
  55. package/dist/{theme → themes/threads}/pages/SearchPage.js +8 -8
  56. package/dist/{theme → themes/threads}/pages/SinglePage.js +5 -6
  57. package/dist/{theme/components → themes/threads}/timeline/LinkCard.js +20 -11
  58. package/dist/themes/threads/timeline/NoteCard.js +53 -0
  59. package/dist/themes/threads/timeline/QuoteCard.js +59 -0
  60. package/dist/{theme/components → themes/threads}/timeline/ThreadPreview.js +5 -6
  61. package/dist/themes/threads/timeline/TimelineFeed.js +58 -0
  62. package/dist/{theme/components → themes/threads}/timeline/TimelineItem.js +8 -17
  63. package/dist/themes/threads/timeline/TimelineLoadMore.js +23 -0
  64. package/dist/themes/threads/timeline/groupByDate.js +22 -0
  65. package/dist/themes/threads/timeline/timelineMore.js +107 -0
  66. package/dist/types.js +24 -40
  67. package/package.json +2 -1
  68. package/src/__tests__/helpers/app.ts +4 -0
  69. package/src/__tests__/helpers/db.ts +51 -74
  70. package/src/app.tsx +27 -6
  71. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  72. package/src/db/migrations/meta/_journal.json +7 -0
  73. package/src/db/schema.ts +63 -46
  74. package/src/i18n/locales/en.po +216 -164
  75. package/src/i18n/locales/en.ts +1 -1
  76. package/src/i18n/locales/zh-Hans.po +216 -164
  77. package/src/i18n/locales/zh-Hans.ts +1 -1
  78. package/src/i18n/locales/zh-Hant.po +216 -164
  79. package/src/i18n/locales/zh-Hant.ts +1 -1
  80. package/src/index.ts +30 -15
  81. package/src/lib/__tests__/excerpt.test.ts +125 -0
  82. package/src/lib/__tests__/schemas.test.ts +166 -105
  83. package/src/lib/__tests__/theme-components.test.ts +4 -25
  84. package/src/lib/__tests__/time.test.ts +62 -0
  85. package/src/{routes/api → lib}/__tests__/timeline.test.ts +108 -66
  86. package/src/lib/__tests__/view.test.ts +217 -67
  87. package/src/lib/constants.ts +1 -4
  88. package/src/lib/excerpt.ts +87 -0
  89. package/src/lib/feed.ts +22 -7
  90. package/src/lib/navigation.ts +6 -7
  91. package/src/lib/render.tsx +1 -1
  92. package/src/lib/schemas.ts +118 -52
  93. package/src/lib/theme-components.ts +10 -13
  94. package/src/lib/time.ts +64 -0
  95. package/src/lib/timeline.ts +170 -0
  96. package/src/lib/view.ts +81 -83
  97. package/src/preset.css +45 -0
  98. package/src/routes/api/__tests__/posts.test.ts +50 -108
  99. package/src/routes/api/__tests__/search.test.ts +2 -3
  100. package/src/routes/api/posts.ts +30 -30
  101. package/src/routes/api/search.ts +4 -4
  102. package/src/routes/api/upload.ts +16 -6
  103. package/src/routes/dash/collections.tsx +18 -40
  104. package/src/routes/dash/index.tsx +2 -2
  105. package/src/routes/dash/navigation.tsx +27 -26
  106. package/src/routes/dash/pages.tsx +45 -60
  107. package/src/routes/dash/posts.tsx +44 -52
  108. package/src/routes/feed/rss.ts +2 -1
  109. package/src/routes/feed/sitemap.ts +14 -4
  110. package/src/routes/pages/archive.tsx +14 -10
  111. package/src/routes/pages/collection.tsx +17 -6
  112. package/src/routes/pages/home.tsx +56 -81
  113. package/src/routes/pages/page.tsx +64 -27
  114. package/src/routes/pages/post.tsx +5 -14
  115. package/src/routes/pages/search.tsx +2 -2
  116. package/src/services/__tests__/collection.test.ts +257 -158
  117. package/src/services/__tests__/media.test.ts +18 -18
  118. package/src/services/__tests__/navigation.test.ts +161 -87
  119. package/src/services/__tests__/post-timeline.test.ts +92 -88
  120. package/src/services/__tests__/post.test.ts +342 -206
  121. package/src/services/__tests__/search.test.ts +19 -25
  122. package/src/services/collection.ts +71 -113
  123. package/src/services/index.ts +9 -8
  124. package/src/services/navigation.ts +38 -71
  125. package/src/services/page.ts +124 -0
  126. package/src/services/post.ts +93 -103
  127. package/src/services/search.ts +38 -27
  128. package/src/styles/components.css +0 -54
  129. package/src/theme/components/MediaGallery.tsx +27 -96
  130. package/src/theme/components/PageForm.tsx +21 -21
  131. package/src/theme/components/PostForm.tsx +122 -118
  132. package/src/theme/components/PostList.tsx +58 -49
  133. package/src/theme/components/ThreadView.tsx +6 -3
  134. package/src/theme/components/TypeBadge.tsx +9 -17
  135. package/src/theme/components/VisibilityBadge.tsx +40 -23
  136. package/src/theme/components/index.ts +0 -13
  137. package/src/theme/index.ts +10 -16
  138. package/src/theme/layouts/index.ts +0 -1
  139. package/src/themes/threads/ThreadsSiteLayout.tsx +194 -0
  140. package/src/themes/threads/index.ts +100 -0
  141. package/src/{theme → themes/threads}/pages/ArchivePage.tsx +52 -55
  142. package/src/themes/threads/pages/CollectionPage.tsx +61 -0
  143. package/src/{theme → themes/threads}/pages/HomePage.tsx +5 -6
  144. package/src/{theme → themes/threads}/pages/PostPage.tsx +11 -8
  145. package/src/{theme → themes/threads}/pages/SearchPage.tsx +9 -13
  146. package/src/themes/threads/pages/SinglePage.tsx +23 -0
  147. package/src/themes/threads/style.css +336 -0
  148. package/src/{theme/components → themes/threads}/timeline/LinkCard.tsx +21 -13
  149. package/src/themes/threads/timeline/NoteCard.tsx +58 -0
  150. package/src/themes/threads/timeline/QuoteCard.tsx +63 -0
  151. package/src/{theme/components → themes/threads}/timeline/ThreadPreview.tsx +6 -6
  152. package/src/themes/threads/timeline/TimelineFeed.tsx +62 -0
  153. package/src/{theme/components → themes/threads}/timeline/TimelineItem.tsx +9 -20
  154. package/src/themes/threads/timeline/TimelineLoadMore.tsx +35 -0
  155. package/src/themes/threads/timeline/groupByDate.ts +30 -0
  156. package/src/themes/threads/timeline/timelineMore.tsx +130 -0
  157. package/src/types.ts +242 -98
  158. package/dist/routes/api/timeline.js +0 -120
  159. package/dist/theme/components/timeline/ArticleCard.js +0 -46
  160. package/dist/theme/components/timeline/ImageCard.js +0 -83
  161. package/dist/theme/components/timeline/NoteCard.js +0 -34
  162. package/dist/theme/components/timeline/QuoteCard.js +0 -48
  163. package/dist/theme/components/timeline/TimelineFeed.js +0 -46
  164. package/dist/theme/components/timeline/index.js +0 -8
  165. package/dist/theme/layouts/SiteLayout.js +0 -131
  166. package/dist/theme/pages/CollectionPage.js +0 -63
  167. package/dist/theme/pages/index.js +0 -11
  168. package/src/routes/api/timeline.tsx +0 -159
  169. package/src/theme/components/timeline/ArticleCard.tsx +0 -45
  170. package/src/theme/components/timeline/ImageCard.tsx +0 -70
  171. package/src/theme/components/timeline/NoteCard.tsx +0 -34
  172. package/src/theme/components/timeline/QuoteCard.tsx +0 -48
  173. package/src/theme/components/timeline/TimelineFeed.tsx +0 -56
  174. package/src/theme/components/timeline/index.ts +0 -8
  175. package/src/theme/layouts/SiteLayout.tsx +0 -132
  176. package/src/theme/pages/CollectionPage.tsx +0 -60
  177. package/src/theme/pages/SinglePage.tsx +0 -24
  178. package/src/theme/pages/index.ts +0 -13
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Media Gallery Component
3
3
  *
4
- * Renders media attachments on public post pages.
5
- * Layout adapts based on the number of images.
4
+ * Renders media attachments in a horizontal scrollable row,
5
+ * similar to Threads.net's image carousel.
6
6
  */
7
7
 
8
8
  import type { FC } from "hono/jsx";
@@ -16,113 +16,44 @@ export const MediaGallery: FC<MediaGalleryProps> = ({ attachments }) => {
16
16
  const images = attachments.filter((a) => a.mimeType.startsWith("image/"));
17
17
  if (images.length === 0) return null;
18
18
 
19
- if (images.length === 1) {
20
- const [img] = images;
21
- if (!img) return null;
22
- return (
23
- <div class="mt-3">
24
- <a href={img.url} target="_blank" rel="noopener noreferrer">
25
- <img
26
- src={img.thumbnailUrl}
27
- alt={img.altText || ""}
28
- width={img.width ?? undefined}
29
- height={img.height ?? undefined}
30
- class="rounded-lg max-w-full h-auto"
31
- loading="lazy"
32
- />
33
- </a>
34
- </div>
35
- );
36
- }
19
+ const single = images.length === 1;
37
20
 
38
- if (images.length === 2) {
39
- return (
40
- <div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
41
- {images.map((img) => (
42
- <a
43
- key={img.id}
44
- href={img.url}
45
- target="_blank"
46
- rel="noopener noreferrer"
47
- class="aspect-square"
48
- >
49
- <img
50
- src={img.thumbnailUrl}
51
- alt={img.altText || ""}
52
- class="w-full h-full object-cover"
53
- loading="lazy"
54
- />
55
- </a>
56
- ))}
57
- </div>
58
- );
59
- }
60
-
61
- if (images.length === 3) {
62
- const [first, ...rest] = images;
63
- if (!first) return null;
64
- return (
65
- <div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
66
- <a
67
- href={first.url}
68
- target="_blank"
69
- rel="noopener noreferrer"
70
- class="row-span-2"
71
- >
72
- <img
73
- src={first.thumbnailUrl}
74
- alt={first.altText || ""}
75
- class="w-full h-full object-cover"
76
- loading="lazy"
77
- />
78
- </a>
79
- {rest.map((img) => (
21
+ return (
22
+ <div
23
+ class={`mt-3 flex gap-2 ${single ? "" : "overflow-x-auto scroll-smooth snap-x snap-mandatory"}`}
24
+ style={
25
+ single ? undefined : "scrollbar-width: none; -ms-overflow-style: none;"
26
+ }
27
+ >
28
+ {images.map((img) => {
29
+ const aspectRatio =
30
+ img.width && img.height ? img.width / img.height : 4 / 3;
31
+ const itemWidth = single
32
+ ? undefined
33
+ : `${Math.round(320 * Math.min(Math.max(aspectRatio, 0.6), 1.6))}px`;
34
+
35
+ return (
80
36
  <a
81
37
  key={img.id}
82
38
  href={img.url}
83
39
  target="_blank"
84
40
  rel="noopener noreferrer"
85
- class="aspect-square"
41
+ class={`${single ? "" : "shrink-0 snap-start"} block rounded-lg overflow-hidden`}
42
+ style={single ? undefined : { width: itemWidth, maxWidth: "85%" }}
86
43
  >
87
44
  <img
88
45
  src={img.thumbnailUrl}
89
46
  alt={img.altText || ""}
90
- class="w-full h-full object-cover"
47
+ class={
48
+ single
49
+ ? "rounded-lg max-w-full max-h-96 h-auto object-contain"
50
+ : "h-80 w-full object-cover"
51
+ }
91
52
  loading="lazy"
92
53
  />
93
54
  </a>
94
- ))}
95
- </div>
96
- );
97
- }
98
-
99
- // 4+ images: 2-column grid, show first 4 with remaining count
100
- const shown = images.slice(0, 4);
101
- const remaining = images.length - 4;
102
-
103
- return (
104
- <div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
105
- {shown.map((img, i) => (
106
- <a
107
- key={img.id}
108
- href={img.url}
109
- target="_blank"
110
- rel="noopener noreferrer"
111
- class="relative aspect-square"
112
- >
113
- <img
114
- src={img.thumbnailUrl}
115
- alt={img.altText || ""}
116
- class="w-full h-full object-cover"
117
- loading="lazy"
118
- />
119
- {i === 3 && remaining > 0 && (
120
- <div class="absolute inset-0 bg-black/50 flex items-center justify-center text-white text-xl font-semibold">
121
- +{remaining}
122
- </div>
123
- )}
124
- </a>
125
- ))}
55
+ );
56
+ })}
126
57
  </div>
127
58
  );
128
59
  };
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Page Creation/Edit Form
3
3
  *
4
- * For managing custom pages (posts with type="page")
4
+ * For managing standalone pages (about, now, etc.)
5
5
  */
6
6
 
7
7
  import type { FC } from "hono/jsx";
8
- import type { Post } from "../../types.js";
8
+ import type { Page } from "../../types.js";
9
9
  import { useLingui } from "@lingui/react/macro";
10
10
 
11
11
  export interface PageFormProps {
12
- page?: Post;
12
+ page?: Page;
13
13
  action: string;
14
14
  cancelUrl?: string;
15
15
  }
@@ -24,9 +24,9 @@ export const PageForm: FC<PageFormProps> = ({
24
24
 
25
25
  const signals = JSON.stringify({
26
26
  title: page?.title ?? "",
27
- path: page?.path ?? "",
28
- content: page?.content ?? "",
29
- visibility: page?.visibility ?? "unlisted",
27
+ slug: page?.slug ?? "",
28
+ body: page?.body ?? "",
29
+ status: page?.status ?? "published",
30
30
  }).replace(/</g, "\\u003c");
31
31
 
32
32
  return (
@@ -58,25 +58,25 @@ export const PageForm: FC<PageFormProps> = ({
58
58
  />
59
59
  </div>
60
60
 
61
- {/* Path */}
61
+ {/* Slug */}
62
62
  <div class="field">
63
63
  <label class="label">
64
64
  {t({
65
- message: "Path",
66
- comment: "@context: Page form field label - URL path",
65
+ message: "Slug",
66
+ comment: "@context: Page form field label - URL slug",
67
67
  })}
68
68
  </label>
69
69
  <div class="flex items-center gap-2">
70
70
  <span class="text-muted-foreground">/</span>
71
71
  <input
72
72
  type="text"
73
- data-bind="path"
73
+ data-bind="slug"
74
74
  class="input flex-1"
75
75
  placeholder="about"
76
76
  pattern="[a-z0-9\-]+"
77
77
  title={t({
78
78
  message: "Lowercase letters, numbers, and hyphens only",
79
- comment: "@context: Page path validation message",
79
+ comment: "@context: Page slug validation message",
80
80
  })}
81
81
  required
82
82
  />
@@ -85,12 +85,12 @@ export const PageForm: FC<PageFormProps> = ({
85
85
  {t({
86
86
  message:
87
87
  "The URL path for this page. Use lowercase letters, numbers, and hyphens.",
88
- comment: "@context: Page path helper text",
88
+ comment: "@context: Page slug helper text",
89
89
  })}
90
90
  </p>
91
91
  </div>
92
92
 
93
- {/* Content */}
93
+ {/* Body */}
94
94
  <div class="field">
95
95
  <label class="label">
96
96
  {t({
@@ -99,7 +99,7 @@ export const PageForm: FC<PageFormProps> = ({
99
99
  })}
100
100
  </label>
101
101
  <textarea
102
- data-bind="content"
102
+ data-bind="body"
103
103
  class="textarea min-h-48"
104
104
  placeholder={t({
105
105
  message: "Page content (Markdown supported)...",
@@ -107,11 +107,11 @@ export const PageForm: FC<PageFormProps> = ({
107
107
  })}
108
108
  required
109
109
  >
110
- {page?.content ?? ""}
110
+ {page?.body ?? ""}
111
111
  </textarea>
112
112
  </div>
113
113
 
114
- {/* Visibility */}
114
+ {/* Status */}
115
115
  <div class="field">
116
116
  <label class="label">
117
117
  {t({
@@ -119,17 +119,17 @@ export const PageForm: FC<PageFormProps> = ({
119
119
  comment: "@context: Page form field label - publish status",
120
120
  })}
121
121
  </label>
122
- <select data-bind="visibility" class="select">
122
+ <select data-bind="status" class="select">
123
123
  <option
124
- value="unlisted"
125
- selected={page?.visibility === "unlisted" || !page}
124
+ value="published"
125
+ selected={page?.status === "published" || !page}
126
126
  >
127
127
  {t({
128
128
  message: "Published",
129
129
  comment: "@context: Page status option - published",
130
130
  })}
131
131
  </option>
132
- <option value="draft" selected={page?.visibility === "draft"}>
132
+ <option value="draft" selected={page?.status === "draft"}>
133
133
  {t({
134
134
  message: "Draft",
135
135
  comment: "@context: Page status option - draft",
@@ -139,7 +139,7 @@ export const PageForm: FC<PageFormProps> = ({
139
139
  <p class="text-xs text-muted-foreground mt-1">
140
140
  {t({
141
141
  message:
142
- "Published pages are accessible via their path. Drafts are not visible.",
142
+ "Published pages are accessible via their slug. Drafts are not visible.",
143
143
  comment: "@context: Page status helper text",
144
144
  })}
145
145
  </p>
@@ -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,18 @@ 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
+ slug: post?.slug ?? "",
45
+ status: post?.status ?? "published",
46
+ featured: post?.featured === 1,
47
+ pinned: post?.pinned === 1,
48
+ rating: post?.rating ?? 0,
49
+ collectionId: post?.collectionId ?? 0,
48
50
  mediaIds: existingMediaIds,
49
- collectionIds: postCollectionIds ?? [],
50
51
  }).replace(/</g, "\\u003c");
51
52
 
52
53
  return (
@@ -58,29 +59,23 @@ export const PostForm: FC<PostFormProps> = ({
58
59
  >
59
60
  <div id="post-form-message"></div>
60
61
 
61
- {/* Type selector */}
62
+ {/* Format selector */}
62
63
  <div class="field">
63
64
  <label class="label">
64
65
  {t({
65
- message: "Type",
66
- comment: "@context: Post form field - post type",
66
+ message: "Format",
67
+ comment: "@context: Post form field - post format",
67
68
  })}
68
69
  </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" })}
70
+ <select data-bind="format" class="select" required>
71
+ <option value="note" selected={post?.format === "note" || !post}>
72
+ {t({ message: "Note", comment: "@context: Post format option" })}
72
73
  </option>
73
- <option value="article" selected={post?.type === "article"}>
74
- {t({ message: "Article", comment: "@context: Post type option" })}
74
+ <option value="link" selected={post?.format === "link"}>
75
+ {t({ message: "Link", comment: "@context: Post format option" })}
75
76
  </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" })}
77
+ <option value="quote" selected={post?.format === "quote"}>
78
+ {t({ message: "Quote", comment: "@context: Post format option" })}
84
79
  </option>
85
80
  </select>
86
81
  </div>
@@ -104,41 +99,68 @@ export const PostForm: FC<PostFormProps> = ({
104
99
  />
105
100
  </div>
106
101
 
107
- {/* Content */}
102
+ {/* Body */}
108
103
  <div class="field">
109
104
  <label class="label">
110
105
  {t({ message: "Content", comment: "@context: Post form field" })}
111
106
  </label>
112
107
  <textarea
113
- data-bind="content"
108
+ data-bind="body"
114
109
  class="textarea min-h-32"
115
110
  placeholder={t({
116
111
  message: "What's on your mind?",
117
112
  comment: "@context: Post content placeholder",
118
113
  })}
119
- required
120
114
  >
121
- {post?.content ?? ""}
115
+ {post?.body ?? ""}
122
116
  </textarea>
123
117
  </div>
124
118
 
125
- {/* Media attachments */}
126
- <div class="field" data-show="$type !== 'page'">
119
+ {/* URL (for link/quote formats) */}
120
+ <div class="field">
127
121
  <label class="label">
128
122
  {t({
129
- message: "Media",
130
- comment: "@context: Post form field - media attachments",
123
+ message: "URL (optional)",
124
+ comment: "@context: Post form field - source URL",
131
125
  })}
132
126
  </label>
133
- <p
134
- class="text-xs text-muted-foreground mb-2"
135
- data-show="$type === 'image'"
127
+ <input
128
+ type="url"
129
+ data-bind="url"
130
+ class="input"
131
+ placeholder="https://..."
132
+ />
133
+ </div>
134
+
135
+ {/* Quote Text (for quote format) */}
136
+ <div class="field" data-show="$format === 'quote'">
137
+ <label class="label">
138
+ {t({
139
+ message: "Quote Text",
140
+ comment: "@context: Post form field - quoted text",
141
+ })}
142
+ </label>
143
+ <textarea
144
+ data-bind="quoteText"
145
+ class="textarea"
146
+ placeholder={t({
147
+ message: "The text being quoted...",
148
+ comment: "@context: Quote text placeholder",
149
+ })}
150
+ rows={3}
136
151
  >
152
+ {post?.quoteText ?? ""}
153
+ </textarea>
154
+ </div>
155
+
156
+ {/* Media attachments */}
157
+ <div class="field">
158
+ <label class="label">
137
159
  {t({
138
- message: "At least 1 image required for image posts.",
139
- comment: "@context: Hint for image post type media requirement",
160
+ message: "Media",
161
+ comment: "@context: Post form field - media attachments",
140
162
  })}
141
- </p>
163
+ </label>
142
164
  {mediaAttachments && mediaAttachments.length > 0 && (
143
165
  <div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2">
144
166
  {mediaAttachments.map((m) => {
@@ -147,8 +169,8 @@ export const PostForm: FC<PostFormProps> = ({
147
169
  r2PublicUrl,
148
170
  s3PublicUrl,
149
171
  );
150
- const url = getMediaUrl(m.id, m.storageKey, pUrl);
151
- const thumbUrl = getImageUrl(url, imageTransformUrl, {
172
+ const mUrl = getMediaUrl(m.id, m.storageKey, pUrl);
173
+ const thumbUrl = getImageUrl(mUrl, imageTransformUrl, {
152
174
  width: 150,
153
175
  quality: 80,
154
176
  format: "auto",
@@ -194,117 +216,99 @@ export const PostForm: FC<PostFormProps> = ({
194
216
  </button>
195
217
  </div>
196
218
 
197
- {/* Source URL (for link/quote types) */}
219
+ {/* Status */}
198
220
  <div class="field">
199
221
  <label class="label">
200
- {t({
201
- message: "Source URL (optional)",
202
- comment: "@context: Post form field",
203
- })}
222
+ {t({ message: "Status", comment: "@context: Post form field" })}
204
223
  </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">
224
+ <select data-bind="status" class="select">
239
225
  <option
240
- value="quiet"
241
- selected={post?.visibility === "quiet" || !post}
226
+ value="published"
227
+ selected={post?.status === "published" || !post}
242
228
  >
243
229
  {t({
244
- message: "Quiet (normal)",
245
- comment: "@context: Post visibility option",
230
+ message: "Published",
231
+ comment: "@context: Post status option",
246
232
  })}
247
233
  </option>
248
- <option value="featured" selected={post?.visibility === "featured"}>
249
- {t({
250
- message: "Featured",
251
- comment: "@context: Post visibility option",
252
- })}
253
- </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"}>
234
+ <option value="draft" selected={post?.status === "draft"}>
261
235
  {t({
262
236
  message: "Draft",
263
- comment: "@context: Post visibility option",
237
+ comment: "@context: Post status option",
264
238
  })}
265
239
  </option>
266
240
  </select>
267
241
  </div>
268
242
 
269
- {/* Collections */}
243
+ {/* Featured & Pinned */}
244
+ <div class="flex gap-4">
245
+ <label class="flex items-center gap-2 text-sm">
246
+ <input type="checkbox" class="checkbox" data-bind="featured" />
247
+ {t({
248
+ message: "Featured",
249
+ comment: "@context: Post form checkbox - mark as featured",
250
+ })}
251
+ </label>
252
+ <label class="flex items-center gap-2 text-sm">
253
+ <input type="checkbox" class="checkbox" data-bind="pinned" />
254
+ {t({
255
+ message: "Pinned",
256
+ comment: "@context: Post form checkbox - pin to top",
257
+ })}
258
+ </label>
259
+ </div>
260
+
261
+ {/* Collection */}
270
262
  {collections && collections.length > 0 && (
271
- <fieldset class="field">
272
- <legend class="label">
263
+ <div class="field">
264
+ <label class="label">
273
265
  {t({
274
- message: "Collections (optional)",
275
- comment: "@context: Post form field - assign to collections",
266
+ message: "Collection (optional)",
267
+ comment: "@context: Post form field - assign to collection",
276
268
  })}
277
- </legend>
278
- <div class="flex flex-col gap-1">
269
+ </label>
270
+ <select data-bind="collectionId" class="select">
271
+ <option value="0">
272
+ {t({
273
+ message: "None",
274
+ comment: "@context: No collection selected",
275
+ })}
276
+ </option>
279
277
  {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
- />
278
+ <option
279
+ key={col.id}
280
+ value={col.id}
281
+ selected={post?.collectionId === col.id}
282
+ >
287
283
  {col.title}
288
- </label>
284
+ </option>
289
285
  ))}
290
- </div>
291
- </fieldset>
286
+ </select>
287
+ </div>
292
288
  )}
293
289
 
294
- {/* Custom path (optional) */}
290
+ {/* Custom slug (optional) */}
295
291
  <div class="field">
296
292
  <label class="label">
297
293
  {t({
298
- message: "Custom Path (optional)",
294
+ message: "Custom Slug (optional)",
299
295
  comment: "@context: Post form field",
300
296
  })}
301
297
  </label>
302
298
  <input
303
299
  type="text"
304
- data-bind="path"
300
+ data-bind="slug"
305
301
  class="input"
306
302
  placeholder="my-custom-url"
303
+ pattern="[a-z0-9-]*"
307
304
  />
305
+ <p class="text-xs text-muted-foreground mt-1">
306
+ {t({
307
+ message:
308
+ "Custom URL path. Leave empty to use default /p/ID format.",
309
+ comment: "@context: Slug help text",
310
+ })}
311
+ </p>
308
312
  </div>
309
313
 
310
314
  {/* Submit */}