@jant/core 0.3.24 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/dist/app.js +50 -25
  2. package/dist/db/schema.js +1 -1
  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 +3 -9
  7. package/dist/lib/constants.js +1 -0
  8. package/dist/lib/nav-reorder.js +1 -1
  9. package/dist/lib/navigation.js +26 -1
  10. package/dist/lib/pagination.js +44 -0
  11. package/dist/lib/render.js +7 -11
  12. package/dist/lib/schemas.js +3 -3
  13. package/dist/lib/theme.js +4 -4
  14. package/dist/lib/timeline.js +24 -48
  15. package/dist/lib/view.js +2 -2
  16. package/dist/routes/api/collections.js +124 -0
  17. package/dist/routes/api/nav-items.js +104 -0
  18. package/dist/routes/api/pages.js +91 -0
  19. package/dist/routes/api/posts.js +2 -2
  20. package/dist/routes/api/search.js +2 -2
  21. package/dist/routes/api/settings.js +68 -0
  22. package/dist/routes/compose.js +48 -0
  23. package/dist/routes/dash/collections.js +2 -2
  24. package/dist/routes/dash/index.js +1 -1
  25. package/dist/routes/dash/media.js +2 -2
  26. package/dist/routes/dash/pages.js +411 -62
  27. package/dist/routes/dash/posts.js +3 -5
  28. package/dist/routes/dash/redirects.js +2 -2
  29. package/dist/routes/dash/settings.js +79 -5
  30. package/dist/routes/feed/rss.js +2 -2
  31. package/dist/routes/feed/sitemap.js +1 -1
  32. package/dist/routes/pages/archive.js +3 -6
  33. package/dist/routes/pages/collection.js +3 -6
  34. package/dist/routes/pages/collections.js +28 -0
  35. package/dist/routes/pages/featured.js +32 -0
  36. package/dist/routes/pages/home.js +9 -50
  37. package/dist/routes/pages/page.js +29 -32
  38. package/dist/routes/pages/post.js +3 -6
  39. package/dist/routes/pages/search.js +3 -6
  40. package/dist/services/page.js +5 -1
  41. package/dist/services/post.js +40 -6
  42. package/dist/services/search.js +1 -1
  43. package/dist/ui/compose/ComposeDialog.js +452 -0
  44. package/dist/ui/compose/ComposePrompt.js +55 -0
  45. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  46. package/dist/{theme/components → ui/dash}/PostForm.js +0 -27
  47. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  48. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  49. package/dist/{theme/components → ui/dash}/index.js +3 -6
  50. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  51. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  52. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  53. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  54. package/dist/ui/feed/TimelineFeed.js +41 -0
  55. package/dist/ui/feed/TimelineItem.js +27 -0
  56. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  57. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  58. package/dist/ui/layouts/SiteLayout.js +141 -0
  59. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  60. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  61. package/dist/ui/pages/CollectionsPage.js +76 -0
  62. package/dist/ui/pages/FeaturedPage.js +24 -0
  63. package/dist/ui/pages/HomePage.js +24 -0
  64. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  65. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  66. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  67. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  68. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  69. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  70. package/dist/ui/shared/index.js +5 -0
  71. package/package.json +1 -9
  72. package/src/__tests__/helpers/db.ts +3 -0
  73. package/src/app.tsx +57 -27
  74. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  75. package/src/db/migrations/meta/_journal.json +7 -0
  76. package/src/db/schema.ts +1 -1
  77. package/src/i18n/locales/en.po +332 -181
  78. package/src/i18n/locales/en.ts +1 -1
  79. package/src/i18n/locales/zh-Hans.po +332 -181
  80. package/src/i18n/locales/zh-Hans.ts +1 -1
  81. package/src/i18n/locales/zh-Hant.po +332 -181
  82. package/src/i18n/locales/zh-Hant.ts +1 -1
  83. package/src/index.ts +7 -36
  84. package/src/lib/__tests__/schemas.test.ts +60 -19
  85. package/src/lib/__tests__/timeline.test.ts +45 -81
  86. package/src/lib/__tests__/view.test.ts +13 -7
  87. package/src/lib/constants.ts +1 -0
  88. package/src/lib/nav-reorder.ts +1 -1
  89. package/src/lib/navigation.ts +40 -2
  90. package/src/lib/pagination.ts +50 -0
  91. package/src/lib/render.tsx +7 -14
  92. package/src/lib/schemas.ts +8 -6
  93. package/src/lib/theme.ts +5 -5
  94. package/src/lib/timeline.ts +28 -57
  95. package/src/lib/view.ts +2 -2
  96. package/src/preset.css +2 -1
  97. package/src/routes/__tests__/compose.test.ts +199 -0
  98. package/src/routes/api/__tests__/collections.test.ts +249 -0
  99. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  100. package/src/routes/api/__tests__/pages.test.ts +218 -0
  101. package/src/routes/api/__tests__/settings.test.ts +132 -0
  102. package/src/routes/api/collections.ts +143 -0
  103. package/src/routes/api/nav-items.ts +115 -0
  104. package/src/routes/api/pages.ts +101 -0
  105. package/src/routes/api/posts.ts +2 -2
  106. package/src/routes/api/search.ts +2 -2
  107. package/src/routes/api/settings.ts +91 -0
  108. package/src/routes/compose.ts +63 -0
  109. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  110. package/src/routes/dash/collections.tsx +2 -2
  111. package/src/routes/dash/index.tsx +1 -1
  112. package/src/routes/dash/media.tsx +2 -2
  113. package/src/routes/dash/pages.tsx +443 -70
  114. package/src/routes/dash/posts.tsx +3 -7
  115. package/src/routes/dash/redirects.tsx +2 -2
  116. package/src/routes/dash/settings.tsx +83 -5
  117. package/src/routes/feed/rss.ts +2 -2
  118. package/src/routes/feed/sitemap.ts +1 -1
  119. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  120. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  121. package/src/routes/pages/archive.tsx +2 -6
  122. package/src/routes/pages/collection.tsx +2 -6
  123. package/src/routes/pages/collections.tsx +36 -0
  124. package/src/routes/pages/featured.tsx +38 -0
  125. package/src/routes/pages/home.tsx +9 -55
  126. package/src/routes/pages/page.tsx +28 -30
  127. package/src/routes/pages/post.tsx +2 -5
  128. package/src/routes/pages/search.tsx +2 -6
  129. package/src/services/__tests__/page.test.ts +106 -0
  130. package/src/services/__tests__/post.test.ts +114 -15
  131. package/src/services/page.ts +13 -1
  132. package/src/services/post.ts +57 -7
  133. package/src/services/search.ts +2 -2
  134. package/src/styles/tokens.css +47 -0
  135. package/src/styles/ui.css +491 -0
  136. package/src/types.ts +29 -159
  137. package/src/ui/compose/ComposeDialog.tsx +395 -0
  138. package/src/ui/compose/ComposePrompt.tsx +55 -0
  139. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  140. package/src/{theme/components → ui/dash}/PostForm.tsx +0 -25
  141. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  142. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  143. package/src/ui/dash/index.ts +10 -0
  144. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  145. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  146. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  147. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  148. package/src/ui/feed/TimelineFeed.tsx +49 -0
  149. package/src/ui/feed/TimelineItem.tsx +45 -0
  150. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  151. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  152. package/src/ui/layouts/SiteLayout.tsx +150 -0
  153. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  154. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  155. package/src/ui/pages/CollectionsPage.tsx +73 -0
  156. package/src/ui/pages/FeaturedPage.tsx +31 -0
  157. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  158. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  159. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  160. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  161. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  162. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  163. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  164. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  165. package/src/ui/shared/index.ts +12 -0
  166. package/bin/jant.js +0 -185
  167. package/dist/lib/theme-components.js +0 -46
  168. package/dist/routes/dash/navigation.js +0 -289
  169. package/dist/theme/index.js +0 -18
  170. package/dist/theme/layouts/index.js +0 -2
  171. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  172. package/dist/themes/threads/index.js +0 -81
  173. package/dist/themes/threads/pages/HomePage.js +0 -25
  174. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  175. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  176. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  177. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  178. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  179. package/src/lib/__tests__/theme-components.test.ts +0 -105
  180. package/src/lib/theme-components.ts +0 -65
  181. package/src/routes/dash/navigation.tsx +0 -317
  182. package/src/theme/components/index.ts +0 -23
  183. package/src/theme/index.ts +0 -22
  184. package/src/theme/layouts/index.ts +0 -7
  185. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  186. package/src/themes/threads/index.ts +0 -100
  187. package/src/themes/threads/style.css +0 -336
  188. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  189. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  190. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  191. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  192. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  193. /package/dist/{theme → ui}/color-themes.js +0 -0
  194. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  195. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  196. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  197. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  198. /package/dist/{theme/components → ui/dash}/PageForm.js +0 -0
  199. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  200. /package/src/{theme → ui}/color-themes.ts +0 -0
  201. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  202. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  203. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  204. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  205. /package/src/{theme/components → ui/dash}/PageForm.tsx +0 -0
  206. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -51,19 +51,20 @@ export const RedirectTypeSchema = z.enum(["301", "302"]);
51
51
  export const RatingSchema = z.coerce
52
52
  .number()
53
53
  .int()
54
- .min(1)
54
+ .min(0)
55
55
  .max(5)
56
56
  .optional()
57
- .or(z.literal("").transform(() => undefined));
57
+ .or(z.literal("").transform(() => undefined))
58
+ .transform((v) => (v === 0 ? undefined : v));
58
59
 
59
60
  /**
60
61
  * API request body schema for creating a post
61
62
  */
62
63
  export const CreatePostSchema = z.object({
63
64
  format: FormatSchema,
64
- slug: z
65
+ path: z
65
66
  .string()
66
- .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/)
67
+ .regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)
67
68
  .optional()
68
69
  .or(z.literal("").transform(() => undefined)),
69
70
  title: z.string().optional(),
@@ -81,9 +82,10 @@ export const CreatePostSchema = z.object({
81
82
  collectionId: z.coerce
82
83
  .number()
83
84
  .int()
84
- .positive()
85
+ .min(0)
85
86
  .optional()
86
- .or(z.literal("").transform(() => undefined)),
87
+ .or(z.literal("").transform(() => undefined))
88
+ .transform((v) => (v === 0 ? undefined : v)),
87
89
  replyToId: z.string().optional(), // Sqid format
88
90
  publishedAt: z.number().int().positive().optional(),
89
91
  mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
package/src/lib/theme.ts CHANGED
@@ -4,14 +4,14 @@
4
4
  * Resolves the active color theme and builds CSS for injection into `<head>`.
5
5
  */
6
6
 
7
- import type { ColorTheme } from "../theme/color-themes.js";
8
- import { BUILTIN_COLOR_THEMES } from "../theme/color-themes.js";
7
+ import type { ColorTheme } from "../ui/color-themes.js";
8
+ import { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
9
9
  import type { JantConfig } from "../types.js";
10
10
 
11
11
  /**
12
12
  * Get the list of available color themes.
13
13
  *
14
- * Returns `config.theme.colorThemes` if provided, otherwise the built-in list.
14
+ * Returns `config.colorThemes` if provided, otherwise the built-in list.
15
15
  *
16
16
  * @param config - The Jant configuration
17
17
  * @returns Array of available color themes
@@ -22,7 +22,7 @@ import type { JantConfig } from "../types.js";
22
22
  * ```
23
23
  */
24
24
  export function getAvailableThemes(config: JantConfig): ColorTheme[] {
25
- return config.theme?.colorThemes ?? BUILTIN_COLOR_THEMES;
25
+ return config.colorThemes ?? BUILTIN_COLOR_THEMES;
26
26
  }
27
27
 
28
28
  /**
@@ -32,7 +32,7 @@ export function getAvailableThemes(config: JantConfig): ColorTheme[] {
32
32
  * BaseCoat defaults → selected theme → cssVariables
33
33
  *
34
34
  * @param theme - The active color theme (undefined = no theme overrides)
35
- * @param cssVariables - Extra CSS variable overrides from `createApp({ theme: { cssVariables } })`
35
+ * @param cssVariables - Extra CSS variable overrides from `createApp({ cssVariables })`
36
36
  * @returns CSS string to inject in `<head>`, or empty string if nothing to inject
37
37
  *
38
38
  * Uses `:root:root` and `:root.dark` selectors for higher specificity than
@@ -2,11 +2,11 @@
2
2
  * Timeline Data Assembly
3
3
  *
4
4
  * Shared helper for assembling timeline items with media and thread previews.
5
- * Used by both full-page rendering and load-more SSE responses.
5
+ * Used by page rendering with page-based pagination.
6
6
  */
7
7
 
8
8
  import type { Context } from "hono";
9
- import type { Bindings, TimelineItemView, DateGroup } from "../types.js";
9
+ import type { Bindings, TimelineItemView } from "../types.js";
10
10
  import type { AppVariables } from "../app.js";
11
11
  import { buildMediaMap } from "./media-helpers.js";
12
12
  import { createMediaContext, toPostView, toPostViews } from "./view.js";
@@ -20,51 +20,58 @@ const DEFAULT_PAGE_SIZE = 20;
20
20
  */
21
21
  export interface TimelineResult {
22
22
  items: TimelineItemView[];
23
- hasMore: boolean;
24
- nextCursor?: number;
23
+ currentPage: number;
24
+ totalPages: number;
25
25
  }
26
26
 
27
27
  /**
28
28
  * Assembles a page of timeline items with media attachments and thread previews.
29
29
  *
30
- * Fetches posts, batch-loads media, identifies threads, and returns
31
- * render-ready `TimelineItemView[]` with pagination info.
30
+ * Fetches posts using offset-based pagination, batch-loads media, identifies
31
+ * threads, and returns render-ready `TimelineItemView[]` with page info.
32
32
  *
33
33
  * @param c - Hono context (provides services + env)
34
- * @param options - Optional cursor for pagination
34
+ * @param options - Optional page number (1-indexed, defaults to 1)
35
35
  * @returns Assembled timeline items with pagination info
36
36
  *
37
37
  * @example
38
38
  * ```ts
39
- * const { items, hasMore, nextCursor } = await assembleTimeline(c);
40
- * const { items, hasMore, nextCursor } = await assembleTimeline(c, { cursor: 42 });
39
+ * const { items, currentPage, totalPages } = await assembleTimeline(c);
40
+ * const { items, currentPage, totalPages } = await assembleTimeline(c, { page: 2 });
41
41
  * ```
42
42
  */
43
43
  export async function assembleTimeline(
44
44
  c: Context<Env>,
45
- options?: { cursor?: number },
45
+ options?: { page?: number },
46
46
  ): Promise<TimelineResult> {
47
47
  const pageSize =
48
48
  parseInt(c.env.PAGE_SIZE ?? String(DEFAULT_PAGE_SIZE), 10) ||
49
49
  DEFAULT_PAGE_SIZE;
50
50
 
51
- // Fetch one extra to determine if there are more
52
- const posts = await c.var.services.posts.list({
51
+ const page = Math.max(1, options?.page ?? 1);
52
+ const offset = (page - 1) * pageSize;
53
+
54
+ // Get total count for pagination
55
+ const totalCount = await c.var.services.posts.count({
53
56
  status: "published",
54
57
  excludeReplies: true,
55
- limit: pageSize + 1,
56
- cursor: options?.cursor,
57
58
  });
59
+ const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
58
60
 
59
- const hasMore = posts.length > pageSize;
60
- const displayPosts = hasMore ? posts.slice(0, pageSize) : posts;
61
+ // Fetch posts for the current page
62
+ const posts = await c.var.services.posts.list({
63
+ status: "published",
64
+ excludeReplies: true,
65
+ limit: pageSize,
66
+ offset,
67
+ });
61
68
 
62
- if (displayPosts.length === 0) {
63
- return { items: [], hasMore: false };
69
+ if (posts.length === 0) {
70
+ return { items: [], currentPage: page, totalPages };
64
71
  }
65
72
 
66
73
  // Batch load media attachments
67
- const postIds = displayPosts.map((p) => p.id);
74
+ const postIds = posts.map((p) => p.id);
68
75
  const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
69
76
  const mediaCtx = createMediaContext(c);
70
77
  const mediaMap = buildMediaMap(
@@ -102,7 +109,7 @@ export async function assembleTimeline(
102
109
  : new Map();
103
110
 
104
111
  // Assemble timeline items with View Models
105
- const items: TimelineItemView[] = displayPosts.map((post) => {
112
+ const items: TimelineItemView[] = posts.map((post) => {
106
113
  const postView = toPostView(
107
114
  { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
108
115
  mediaCtx,
@@ -130,41 +137,5 @@ export async function assembleTimeline(
130
137
  return { post: postView };
131
138
  });
132
139
 
133
- // Determine next cursor
134
- const lastPost = displayPosts[displayPosts.length - 1];
135
- const nextCursor = hasMore && lastPost ? lastPost.id : undefined;
136
-
137
- return { items, hasMore, nextCursor };
138
- }
139
-
140
- /**
141
- * Groups timeline items by their publication date (YYYY-MM-DD).
142
- *
143
- * @param items - Timeline items to group
144
- * @returns Array of date groups, each containing items published on the same day
145
- *
146
- * @example
147
- * ```ts
148
- * const groups = groupByDate(items);
149
- * // [{ dateKey: "2024-02-01", label: "Feb 1, 2024", items: [...] }, ...]
150
- * ```
151
- */
152
- export function groupByDate(items: TimelineItemView[]): DateGroup[] {
153
- const groups: DateGroup[] = [];
154
- let current: DateGroup | null = null;
155
-
156
- for (const item of items) {
157
- const dateKey = item.post.publishedAt.slice(0, 10);
158
- if (!current || current.dateKey !== dateKey) {
159
- current = {
160
- dateKey,
161
- label: item.post.publishedAtFormatted,
162
- items: [],
163
- };
164
- groups.push(current);
165
- }
166
- current.items.push(item);
167
- }
168
-
169
- return groups;
140
+ return { items, currentPage: page, totalPages };
170
141
  }
package/src/lib/view.ts CHANGED
@@ -109,7 +109,7 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
109
109
  * @returns Render-ready PostView with pre-computed fields
110
110
  */
111
111
  export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
112
- const permalink = post.slug ? `/${post.slug}` : `/p/${encode(post.id)}`;
112
+ const permalink = post.path ? `/${post.path}` : `/p/${encode(post.id)}`;
113
113
 
114
114
  // Pre-compute excerpt from raw body
115
115
  let excerpt: string | undefined;
@@ -141,7 +141,7 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
141
141
  return {
142
142
  id: post.id,
143
143
  permalink,
144
- slug: post.slug ?? undefined,
144
+ path: post.path ?? undefined,
145
145
  title: post.title ?? undefined,
146
146
  bodyHtml: post.bodyHtml ?? undefined,
147
147
  excerpt,
package/src/preset.css CHANGED
@@ -10,7 +10,8 @@
10
10
  @import "basecoat-css";
11
11
  @plugin "@tailwindcss/typography";
12
12
  @import "./styles/components.css";
13
- @import "./themes/threads/style.css";
13
+ @import "./styles/tokens.css";
14
+ @import "./styles/ui.css";
14
15
 
15
16
  @theme {
16
17
  --radius-default: 0.5rem;
@@ -0,0 +1,199 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestApp } from "../../__tests__/helpers/app.js";
3
+ import { composeRoutes } from "../compose.js";
4
+
5
+ describe("Compose Routes", () => {
6
+ describe("POST /compose", () => {
7
+ it("redirects to signin when not authenticated", async () => {
8
+ const { app } = createTestApp({ authenticated: false });
9
+ app.route("/compose", composeRoutes);
10
+
11
+ const res = await app.request("/compose", {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify({ format: "note", body: "Hello" }),
15
+ });
16
+
17
+ expect(res.status).toBe(302);
18
+ expect(res.headers.get("Location")).toBe("/signin");
19
+ });
20
+
21
+ it("creates a note post and returns redirect", async () => {
22
+ const { app, services } = createTestApp({ authenticated: true });
23
+ app.route("/compose", composeRoutes);
24
+
25
+ const res = await app.request("/compose", {
26
+ method: "POST",
27
+ headers: { "Content-Type": "application/json" },
28
+ body: JSON.stringify({ format: "note", body: "Hello world" }),
29
+ });
30
+
31
+ expect(res.status).toBe(200);
32
+ expect(res.headers.get("Content-Type")).toBe("text/html");
33
+
34
+ // Verify post was created
35
+ const posts = await services.posts.list();
36
+ expect(posts).toHaveLength(1);
37
+ expect(posts[0].format).toBe("note");
38
+ expect(posts[0].body).toBe("Hello world");
39
+ expect(posts[0].status).toBe("published");
40
+ });
41
+
42
+ it("creates a link post", async () => {
43
+ const { app, services } = createTestApp({ authenticated: true });
44
+ app.route("/compose", composeRoutes);
45
+
46
+ const res = await app.request("/compose", {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({
50
+ format: "link",
51
+ body: "Check this out",
52
+ url: "https://example.com",
53
+ }),
54
+ });
55
+
56
+ expect(res.status).toBe(200);
57
+
58
+ const posts = await services.posts.list();
59
+ expect(posts).toHaveLength(1);
60
+ expect(posts[0].format).toBe("link");
61
+ expect(posts[0].url).toBe("https://example.com");
62
+ });
63
+
64
+ it("creates a quote post", async () => {
65
+ const { app, services } = createTestApp({ authenticated: true });
66
+ app.route("/compose", composeRoutes);
67
+
68
+ const res = await app.request("/compose", {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({
72
+ format: "quote",
73
+ body: "Great insight",
74
+ quoteText: "The original quote",
75
+ url: "https://example.com/source",
76
+ }),
77
+ });
78
+
79
+ expect(res.status).toBe(200);
80
+
81
+ const posts = await services.posts.list();
82
+ expect(posts).toHaveLength(1);
83
+ expect(posts[0].format).toBe("quote");
84
+ expect(posts[0].quoteText).toBe("The original quote");
85
+ });
86
+
87
+ it("creates a draft when status is draft", async () => {
88
+ const { app, services } = createTestApp({ authenticated: true });
89
+ app.route("/compose", composeRoutes);
90
+
91
+ const res = await app.request("/compose", {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/json" },
94
+ body: JSON.stringify({
95
+ format: "note",
96
+ body: "Draft content",
97
+ status: "draft",
98
+ }),
99
+ });
100
+
101
+ expect(res.status).toBe(200);
102
+
103
+ const posts = await services.posts.list({ includeDrafts: true });
104
+ expect(posts).toHaveLength(1);
105
+ expect(posts[0].status).toBe("draft");
106
+ });
107
+
108
+ it("returns error for invalid format", async () => {
109
+ const { app } = createTestApp({ authenticated: true });
110
+ app.route("/compose", composeRoutes);
111
+
112
+ const res = await app.request("/compose", {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ format: "invalid", body: "Hello" }),
116
+ });
117
+
118
+ expect(res.status).toBe(200);
119
+ expect(res.headers.get("Content-Type")).toBe("text/html");
120
+ // Returns a toast error (text/html with error message)
121
+ const text = await res.text();
122
+ expect(text).toContain("toast-error");
123
+ });
124
+
125
+ it("attaches media IDs when provided", async () => {
126
+ const { app, services } = createTestApp({ authenticated: true });
127
+ app.route("/compose", composeRoutes);
128
+
129
+ // Create media first
130
+ const media = await services.media.create({
131
+ filename: "test.jpg",
132
+ originalName: "test.jpg",
133
+ mimeType: "image/jpeg",
134
+ size: 1024,
135
+ storageKey: "media/2025/01/test.jpg",
136
+ width: 800,
137
+ height: 600,
138
+ });
139
+
140
+ const res = await app.request("/compose", {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({
144
+ format: "note",
145
+ body: "Post with media",
146
+ mediaIds: [media.id],
147
+ }),
148
+ });
149
+
150
+ expect(res.status).toBe(200);
151
+
152
+ const posts = await services.posts.list();
153
+ expect(posts).toHaveLength(1);
154
+
155
+ // Verify media is attached
156
+ const attachments = await services.media.getByPostId(posts[0].id);
157
+ expect(attachments).toHaveLength(1);
158
+ expect(attachments[0].id).toBe(media.id);
159
+ });
160
+
161
+ it("sets featured and pinned flags", async () => {
162
+ const { app, services } = createTestApp({ authenticated: true });
163
+ app.route("/compose", composeRoutes);
164
+
165
+ const res = await app.request("/compose", {
166
+ method: "POST",
167
+ headers: { "Content-Type": "application/json" },
168
+ body: JSON.stringify({
169
+ format: "note",
170
+ body: "Featured and pinned",
171
+ featured: true,
172
+ pinned: true,
173
+ }),
174
+ });
175
+
176
+ expect(res.status).toBe(200);
177
+
178
+ const posts = await services.posts.list();
179
+ expect(posts).toHaveLength(1);
180
+ expect(posts[0].featured).toBe(1);
181
+ expect(posts[0].pinned).toBe(1);
182
+ });
183
+
184
+ it("returns error when format is missing", async () => {
185
+ const { app } = createTestApp({ authenticated: true });
186
+ app.route("/compose", composeRoutes);
187
+
188
+ const res = await app.request("/compose", {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ body: JSON.stringify({ body: "No format" }),
192
+ });
193
+
194
+ expect(res.status).toBe(200);
195
+ const text = await res.text();
196
+ expect(text).toContain("toast-error");
197
+ });
198
+ });
199
+ });
@@ -0,0 +1,249 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createTestApp } from "../../../__tests__/helpers/app.js";
3
+ import { collectionsApiRoutes } from "../collections.js";
4
+
5
+ describe("Collections API Routes", () => {
6
+ describe("GET /api/collections", () => {
7
+ it("returns empty list when no collections exist", async () => {
8
+ const { app } = createTestApp();
9
+ app.route("/api/collections", collectionsApiRoutes);
10
+
11
+ const res = await app.request("/api/collections");
12
+ expect(res.status).toBe(200);
13
+
14
+ const body = await res.json();
15
+ expect(body.collections).toEqual([]);
16
+ });
17
+
18
+ it("returns collections with post counts", async () => {
19
+ const { app, services } = createTestApp();
20
+ app.route("/api/collections", collectionsApiRoutes);
21
+
22
+ const col = await services.collections.create({
23
+ slug: "tech",
24
+ title: "Tech",
25
+ });
26
+ await services.posts.create({
27
+ format: "note",
28
+ body: "tech post",
29
+ collectionId: col.id,
30
+ });
31
+
32
+ const res = await app.request("/api/collections");
33
+ const body = await res.json();
34
+
35
+ expect(body.collections).toHaveLength(1);
36
+ expect(body.collections[0].slug).toBe("tech");
37
+ expect(body.collections[0].postCount).toBe(1);
38
+ });
39
+ });
40
+
41
+ describe("GET /api/collections/:id", () => {
42
+ it("returns a collection by id", async () => {
43
+ const { app, services } = createTestApp();
44
+ app.route("/api/collections", collectionsApiRoutes);
45
+
46
+ const col = await services.collections.create({
47
+ slug: "tech",
48
+ title: "Tech Articles",
49
+ });
50
+
51
+ const res = await app.request(`/api/collections/${col.id}`);
52
+ expect(res.status).toBe(200);
53
+
54
+ const body = await res.json();
55
+ expect(body.title).toBe("Tech Articles");
56
+ expect(body.slug).toBe("tech");
57
+ });
58
+
59
+ it("returns 400 for invalid id", async () => {
60
+ const { app } = createTestApp();
61
+ app.route("/api/collections", collectionsApiRoutes);
62
+
63
+ const res = await app.request("/api/collections/abc");
64
+ expect(res.status).toBe(400);
65
+ });
66
+
67
+ it("returns 404 for non-existent collection", async () => {
68
+ const { app } = createTestApp();
69
+ app.route("/api/collections", collectionsApiRoutes);
70
+
71
+ const res = await app.request("/api/collections/9999");
72
+ expect(res.status).toBe(404);
73
+ });
74
+ });
75
+
76
+ describe("POST /api/collections", () => {
77
+ it("returns 401 when not authenticated", async () => {
78
+ const { app } = createTestApp({ authenticated: false });
79
+ app.route("/api/collections", collectionsApiRoutes);
80
+
81
+ const res = await app.request("/api/collections", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ slug: "tech", title: "Tech" }),
85
+ });
86
+
87
+ expect(res.status).toBe(401);
88
+ });
89
+
90
+ it("creates a collection when authenticated", async () => {
91
+ const { app } = createTestApp({ authenticated: true });
92
+ app.route("/api/collections", collectionsApiRoutes);
93
+
94
+ const res = await app.request("/api/collections", {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({
98
+ slug: "tech",
99
+ title: "Tech",
100
+ description: "Tech articles",
101
+ }),
102
+ });
103
+
104
+ expect(res.status).toBe(201);
105
+ const body = await res.json();
106
+ expect(body.slug).toBe("tech");
107
+ expect(body.title).toBe("Tech");
108
+ expect(body.description).toBe("Tech articles");
109
+ });
110
+
111
+ it("returns 400 for missing required fields", async () => {
112
+ const { app } = createTestApp({ authenticated: true });
113
+ app.route("/api/collections", collectionsApiRoutes);
114
+
115
+ const res = await app.request("/api/collections", {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ slug: "tech" }),
119
+ });
120
+
121
+ expect(res.status).toBe(400);
122
+ });
123
+ });
124
+
125
+ describe("PUT /api/collections/reorder", () => {
126
+ it("returns 401 when not authenticated", async () => {
127
+ const { app } = createTestApp({ authenticated: false });
128
+ app.route("/api/collections", collectionsApiRoutes);
129
+
130
+ const res = await app.request("/api/collections/reorder", {
131
+ method: "PUT",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({ ids: [1, 2] }),
134
+ });
135
+
136
+ expect(res.status).toBe(401);
137
+ });
138
+
139
+ it("reorders collections when authenticated", async () => {
140
+ const { app, services } = createTestApp({ authenticated: true });
141
+ app.route("/api/collections", collectionsApiRoutes);
142
+
143
+ const col1 = await services.collections.create({
144
+ slug: "first",
145
+ title: "First",
146
+ });
147
+ const col2 = await services.collections.create({
148
+ slug: "second",
149
+ title: "Second",
150
+ });
151
+
152
+ const res = await app.request("/api/collections/reorder", {
153
+ method: "PUT",
154
+ headers: { "Content-Type": "application/json" },
155
+ body: JSON.stringify({ ids: [col2.id, col1.id] }),
156
+ });
157
+
158
+ expect(res.status).toBe(200);
159
+ const body = await res.json();
160
+ expect(body.collections[0].slug).toBe("second");
161
+ expect(body.collections[1].slug).toBe("first");
162
+ });
163
+ });
164
+
165
+ describe("PUT /api/collections/:id", () => {
166
+ it("updates a collection when authenticated", async () => {
167
+ const { app, services } = createTestApp({ authenticated: true });
168
+ app.route("/api/collections", collectionsApiRoutes);
169
+
170
+ const col = await services.collections.create({
171
+ slug: "tech",
172
+ title: "Tech",
173
+ });
174
+
175
+ const res = await app.request(`/api/collections/${col.id}`, {
176
+ method: "PUT",
177
+ headers: { "Content-Type": "application/json" },
178
+ body: JSON.stringify({ title: "Technology" }),
179
+ });
180
+
181
+ expect(res.status).toBe(200);
182
+ const body = await res.json();
183
+ expect(body.title).toBe("Technology");
184
+ });
185
+
186
+ it("returns 404 for non-existent collection", async () => {
187
+ const { app } = createTestApp({ authenticated: true });
188
+ app.route("/api/collections", collectionsApiRoutes);
189
+
190
+ const res = await app.request("/api/collections/9999", {
191
+ method: "PUT",
192
+ headers: { "Content-Type": "application/json" },
193
+ body: JSON.stringify({ title: "test" }),
194
+ });
195
+
196
+ expect(res.status).toBe(404);
197
+ });
198
+ });
199
+
200
+ describe("DELETE /api/collections/:id", () => {
201
+ it("returns 401 when not authenticated", async () => {
202
+ const { app, services } = createTestApp({ authenticated: false });
203
+ app.route("/api/collections", collectionsApiRoutes);
204
+
205
+ const col = await services.collections.create({
206
+ slug: "tech",
207
+ title: "Tech",
208
+ });
209
+
210
+ const res = await app.request(`/api/collections/${col.id}`, {
211
+ method: "DELETE",
212
+ });
213
+
214
+ expect(res.status).toBe(401);
215
+ });
216
+
217
+ it("deletes a collection when authenticated", async () => {
218
+ const { app, services } = createTestApp({ authenticated: true });
219
+ app.route("/api/collections", collectionsApiRoutes);
220
+
221
+ const col = await services.collections.create({
222
+ slug: "tech",
223
+ title: "Tech",
224
+ });
225
+
226
+ const res = await app.request(`/api/collections/${col.id}`, {
227
+ method: "DELETE",
228
+ });
229
+
230
+ expect(res.status).toBe(200);
231
+ const body = await res.json();
232
+ expect(body.success).toBe(true);
233
+
234
+ const found = await services.collections.getById(col.id);
235
+ expect(found).toBeNull();
236
+ });
237
+
238
+ it("returns 404 for non-existent collection", async () => {
239
+ const { app } = createTestApp({ authenticated: true });
240
+ app.route("/api/collections", collectionsApiRoutes);
241
+
242
+ const res = await app.request("/api/collections/9999", {
243
+ method: "DELETE",
244
+ });
245
+
246
+ expect(res.status).toBe(404);
247
+ });
248
+ });
249
+ });