@jant/core 0.3.39 → 0.3.40

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 (39) hide show
  1. package/README.md +1 -0
  2. package/dist/{app-BfoG98VD.js → app-CAtsuLLh.js} +306 -72
  3. package/dist/client/_assets/client-auth.js +77 -68
  4. package/dist/client/_assets/client.css +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/node.js +2 -2
  7. package/package.json +1 -1
  8. package/src/app.tsx +5 -0
  9. package/src/client/components/__tests__/jant-collection-sidebar.test.ts +56 -0
  10. package/src/client/components/jant-collection-form.ts +2 -0
  11. package/src/client/components/jant-collection-sidebar.ts +19 -6
  12. package/src/i18n/locales/en.po +14 -0
  13. package/src/i18n/locales/en.ts +1 -1
  14. package/src/i18n/locales/zh-Hans.po +14 -0
  15. package/src/i18n/locales/zh-Hans.ts +1 -1
  16. package/src/i18n/locales/zh-Hant.po +14 -0
  17. package/src/i18n/locales/zh-Hant.ts +1 -1
  18. package/src/lib/__tests__/collection-groups.test.ts +63 -0
  19. package/src/lib/__tests__/constants.test.ts +23 -1
  20. package/src/lib/__tests__/schemas.test.ts +18 -0
  21. package/src/lib/__tests__/slug-format.test.ts +8 -0
  22. package/src/lib/__tests__/timeline.test.ts +84 -2
  23. package/src/lib/collection-groups.ts +69 -0
  24. package/src/lib/constants.ts +20 -0
  25. package/src/lib/schemas.ts +8 -1
  26. package/src/lib/slug-format.ts +8 -0
  27. package/src/lib/timeline.ts +30 -23
  28. package/src/routes/pages/collection.tsx +89 -37
  29. package/src/runtime/__tests__/readiness.test.ts +89 -0
  30. package/src/runtime/readiness.ts +129 -0
  31. package/src/services/__tests__/collection.test.ts +45 -0
  32. package/src/services/__tests__/post-timeline.test.ts +58 -0
  33. package/src/services/__tests__/post.test.ts +35 -1
  34. package/src/services/collection.ts +55 -0
  35. package/src/services/post.ts +121 -12
  36. package/src/styles/ui.css +17 -0
  37. package/src/types/props.ts +2 -1
  38. package/src/ui/pages/CollectionPage.tsx +84 -17
  39. package/src/ui/shared/CollectionDirectory.tsx +18 -4
@@ -0,0 +1,129 @@
1
+ import { createDatabase, createNodeDatabase } from "../db/index.js";
2
+ import { sqliteSchemaBundle } from "../db/schema-bundle.js";
3
+ import { getAuthSecret } from "../lib/env.js";
4
+ import { getHostBasedStartupConfigurationIssues } from "../lib/startup-config.js";
5
+ import { createSiteService } from "../services/site.js";
6
+ import type { Bindings } from "../types/bindings.js";
7
+
8
+ export interface ReadinessCheckStatus {
9
+ ok: boolean;
10
+ error?: string;
11
+ }
12
+
13
+ export interface InstanceReadinessResult {
14
+ status: "ok" | "error";
15
+ checks: {
16
+ startupConfig: ReadinessCheckStatus;
17
+ database: ReadinessCheckStatus;
18
+ };
19
+ }
20
+
21
+ function getStartupConfigurationReadiness(
22
+ env: Pick<
23
+ Bindings,
24
+ | "AUTH_SECRET"
25
+ | "HOSTED_CONTROL_PLANE_BASE_URL"
26
+ | "HOSTED_CONTROL_PLANE_DOMAIN_CHECK_SECRET"
27
+ | "HOSTED_CONTROL_PLANE_INTERNAL_BASE_URL"
28
+ | "HOSTED_CONTROL_PLANE_INTERNAL_TOKEN"
29
+ | "HOSTED_CONTROL_PLANE_SSO_SECRET"
30
+ | "INTERNAL_ADMIN_TOKEN"
31
+ | "SITE_RESOLUTION_MODE"
32
+ >,
33
+ ): ReadinessCheckStatus {
34
+ const errors: string[] = [];
35
+
36
+ if (!getAuthSecret(env)) {
37
+ errors.push("AUTH_SECRET must be set before Jant can accept traffic.");
38
+ }
39
+
40
+ for (const issue of getHostBasedStartupConfigurationIssues(env)) {
41
+ errors.push(`${issue.variable}: ${issue.message}`);
42
+ }
43
+
44
+ return errors.length > 0
45
+ ? {
46
+ ok: false,
47
+ error: errors.join(" "),
48
+ }
49
+ : { ok: true };
50
+ }
51
+
52
+ async function getDatabaseReadiness(
53
+ env: Pick<Bindings, "DB" | "NODE_DATABASE" | "NODE_SQLITE">,
54
+ ): Promise<ReadinessCheckStatus> {
55
+ try {
56
+ if (env.NODE_DATABASE?.db) {
57
+ const siteService = createSiteService(
58
+ env.NODE_DATABASE.db,
59
+ env.NODE_DATABASE.schema,
60
+ );
61
+ await siteService.getById("sit_readiness_probe");
62
+ return { ok: true };
63
+ }
64
+
65
+ if (env.NODE_SQLITE) {
66
+ const siteService = createSiteService(
67
+ createNodeDatabase(env.NODE_SQLITE),
68
+ );
69
+ await siteService.getById("sit_readiness_probe");
70
+ return { ok: true };
71
+ }
72
+
73
+ if (env.DB) {
74
+ // Use a D1 session to mirror the normal Cloudflare runtime path.
75
+ const session = env.DB.withSession();
76
+ const siteService = createSiteService(
77
+ createDatabase(session as unknown as D1Database),
78
+ sqliteSchemaBundle,
79
+ );
80
+ await siteService.getById("sit_readiness_probe");
81
+ return { ok: true };
82
+ }
83
+
84
+ return {
85
+ ok: false,
86
+ error: "No database binding is configured for this runtime.",
87
+ };
88
+ } catch (error) {
89
+ return {
90
+ ok: false,
91
+ error: error instanceof Error ? error.message : String(error),
92
+ };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Perform instance-scoped readiness checks that bypass site resolution.
98
+ *
99
+ * This is intentionally stricter than `/health`: it verifies startup
100
+ * configuration and performs a lightweight database/schema query against the
101
+ * shared `site` table through the service layer.
102
+ */
103
+ export async function getInstanceReadiness(
104
+ env: Pick<
105
+ Bindings,
106
+ | "AUTH_SECRET"
107
+ | "DB"
108
+ | "HOSTED_CONTROL_PLANE_BASE_URL"
109
+ | "HOSTED_CONTROL_PLANE_DOMAIN_CHECK_SECRET"
110
+ | "HOSTED_CONTROL_PLANE_INTERNAL_BASE_URL"
111
+ | "HOSTED_CONTROL_PLANE_INTERNAL_TOKEN"
112
+ | "HOSTED_CONTROL_PLANE_SSO_SECRET"
113
+ | "INTERNAL_ADMIN_TOKEN"
114
+ | "NODE_DATABASE"
115
+ | "NODE_SQLITE"
116
+ | "SITE_RESOLUTION_MODE"
117
+ >,
118
+ ): Promise<InstanceReadinessResult> {
119
+ const startupConfig = getStartupConfigurationReadiness(env);
120
+ const database = await getDatabaseReadiness(env);
121
+
122
+ return {
123
+ status: startupConfig.ok && database.ok ? "ok" : "error",
124
+ checks: {
125
+ startupConfig,
126
+ database,
127
+ },
128
+ };
129
+ }
@@ -117,6 +117,24 @@ describe("CollectionService", () => {
117
117
  }),
118
118
  ).rejects.toThrow();
119
119
  });
120
+
121
+ it("rejects aggregate syntax in collection slugs", async () => {
122
+ await expect(
123
+ collectionService.create({
124
+ slug: "smart+movies",
125
+ title: "Smart + Movies",
126
+ }),
127
+ ).rejects.toThrow("Use lowercase letters, numbers, and hyphens only.");
128
+ });
129
+
130
+ it("rejects slugs reserved by the collection namespace", async () => {
131
+ await expect(
132
+ collectionService.create({
133
+ slug: "new",
134
+ title: "New",
135
+ }),
136
+ ).rejects.toThrow("This link is reserved. Choose something else.");
137
+ });
120
138
  });
121
139
 
122
140
  describe("getById", () => {
@@ -156,6 +174,33 @@ describe("CollectionService", () => {
156
174
  });
157
175
  });
158
176
 
177
+ describe("resolveSelection", () => {
178
+ it("resolves, dedupes, and preserves slug order", async () => {
179
+ await collectionService.create({ slug: "smart", title: "Smart" });
180
+ await collectionService.create({ slug: "movies", title: "Movies" });
181
+
182
+ const selection =
183
+ await collectionService.resolveSelection("smart+movies+smart");
184
+
185
+ expect(selection?.slugs).toEqual(["smart", "movies"]);
186
+ expect(selection?.slugExpression).toBe("smart+movies");
187
+ expect(
188
+ selection?.collections.map((collection) => collection.slug),
189
+ ).toEqual(["smart", "movies"]);
190
+ });
191
+
192
+ it("returns null when any slug is missing or the expression is malformed", async () => {
193
+ await collectionService.create({ slug: "smart", title: "Smart" });
194
+
195
+ await expect(
196
+ collectionService.resolveSelection("smart++movies"),
197
+ ).resolves.toBeNull();
198
+ await expect(
199
+ collectionService.resolveSelection("smart+missing"),
200
+ ).resolves.toBeNull();
201
+ });
202
+ });
203
+
159
204
  describe("list", () => {
160
205
  it("returns empty array when no collections exist", async () => {
161
206
  const list = await collectionService.list();
@@ -503,5 +503,63 @@ describe("PostService - Timeline features", () => {
503
503
  expect(entries[1]?.post.id).toBe(firstRoot.id);
504
504
  expect(entries[1]?.collectedAt).toBe(100);
505
505
  });
506
+
507
+ it("dedupes shared threads across multiple collections", async () => {
508
+ const smart = await collectionService.create({
509
+ slug: "smart",
510
+ title: "Smart",
511
+ });
512
+ const movies = await collectionService.create({
513
+ slug: "movies",
514
+ title: "Movies",
515
+ });
516
+ const sharedRoot = await postService.create({
517
+ format: "note",
518
+ bodyMarkdown: "Shared root",
519
+ });
520
+ const sharedReply = await postService.create({
521
+ format: "note",
522
+ bodyMarkdown: "Shared reply",
523
+ replyToId: sharedRoot.id,
524
+ });
525
+ const secondRoot = await postService.create({
526
+ format: "note",
527
+ bodyMarkdown: "Second root",
528
+ });
529
+
530
+ await db.insert(postCollections).values([
531
+ {
532
+ siteId: DEFAULT_TEST_SITE_ID,
533
+ postId: sharedRoot.id,
534
+ collectionId: smart.id,
535
+ createdAt: 100,
536
+ },
537
+ {
538
+ siteId: DEFAULT_TEST_SITE_ID,
539
+ postId: sharedReply.id,
540
+ collectionId: movies.id,
541
+ createdAt: 300,
542
+ },
543
+ {
544
+ siteId: DEFAULT_TEST_SITE_ID,
545
+ postId: secondRoot.id,
546
+ collectionId: movies.id,
547
+ createdAt: 200,
548
+ },
549
+ ]);
550
+
551
+ const entries = await postService.listCollectionFeedEntriesForCollections(
552
+ [smart.id, movies.id],
553
+ {
554
+ status: "published",
555
+ },
556
+ );
557
+
558
+ expect(entries).toHaveLength(2);
559
+ expect(entries[0]?.post.id).toBe(sharedRoot.id);
560
+ expect(entries[0]?.collectedAt).toBe(300);
561
+ expect(entries[1]?.post.id).toBe(secondRoot.id);
562
+ expect(entries[1]?.collectedAt).toBe(200);
563
+ });
506
564
  });
507
565
  });
@@ -4,7 +4,7 @@ import {
4
4
  createTestDatabase,
5
5
  DEFAULT_TEST_SITE_ID,
6
6
  } from "../../__tests__/helpers/db.js";
7
- import { posts } from "../../db/schema.js";
7
+ import { postCollections, posts } from "../../db/schema.js";
8
8
  import { createPostService } from "../post.js";
9
9
  import { createMediaService } from "../media.js";
10
10
  import { createCollectionService } from "../collection.js";
@@ -903,6 +903,40 @@ describe("PostService", () => {
903
903
  const count = await postService.count({ excludeReplies: true });
904
904
  expect(count).toBe(1);
905
905
  });
906
+
907
+ it("can stop counting after a small limit", async () => {
908
+ const collection = await collectionService.create({
909
+ slug: "rated",
910
+ title: "Rated",
911
+ });
912
+
913
+ for (let i = 0; i < 3; i++) {
914
+ const post = await postService.create({
915
+ format: "link",
916
+ title: `rated ${i}`,
917
+ url: `https://example.com/${i}`,
918
+ rating: i + 1,
919
+ });
920
+
921
+ await db.insert(postCollections).values({
922
+ siteId: DEFAULT_TEST_SITE_ID,
923
+ postId: post.id,
924
+ collectionId: collection.id,
925
+ createdAt: 100 + i,
926
+ });
927
+ }
928
+
929
+ const count = await postService.countUpTo(
930
+ {
931
+ collectionIds: [collection.id],
932
+ status: "published",
933
+ hasRating: true,
934
+ },
935
+ 2,
936
+ );
937
+
938
+ expect(count).toBe(2);
939
+ });
906
940
  });
907
941
 
908
942
  describe("countByYearMonth", () => {
@@ -64,9 +64,39 @@ function isUniqueConstraintError(err: unknown): boolean {
64
64
  return false;
65
65
  }
66
66
 
67
+ export interface ResolvedCollectionSelection {
68
+ collections: Collection[];
69
+ slugs: string[];
70
+ slugExpression: string;
71
+ }
72
+
73
+ function parseCollectionSelectionSlugs(
74
+ slugExpression: string,
75
+ ): string[] | null {
76
+ const parts = slugExpression.split("+");
77
+ if (parts.length === 0) return null;
78
+
79
+ const seen = new Set<string>();
80
+ const slugs: string[] = [];
81
+
82
+ for (const part of parts) {
83
+ const slug = part.trim();
84
+ if (!slug) return null;
85
+ if (seen.has(slug)) continue;
86
+ seen.add(slug);
87
+ slugs.push(slug);
88
+ }
89
+
90
+ return slugs.length > 0 ? slugs : null;
91
+ }
92
+
67
93
  export interface CollectionService {
68
94
  getById(id: string): Promise<Collection | null>;
69
95
  getBySlug(slug: string): Promise<Collection | null>;
96
+ getBySlugs(slugs: string[]): Promise<Collection[]>;
97
+ resolveSelection(
98
+ slugExpression: string,
99
+ ): Promise<ResolvedCollectionSelection | null>;
70
100
  list(): Promise<Collection[]>;
71
101
  listDirectoryData(): Promise<CollectionsDirectoryData>;
72
102
  /** List collections sorted by most recent post addition (for compose dialog) */
@@ -430,6 +460,31 @@ export function createCollectionService(
430
460
  return this.getById(resolved.collectionId);
431
461
  },
432
462
 
463
+ async getBySlugs(slugs) {
464
+ if (slugs.length === 0) return [];
465
+
466
+ const collections = await Promise.all(
467
+ slugs.map((slug) => this.getBySlug(slug)),
468
+ );
469
+ return collections.filter(
470
+ (collection): collection is Collection => collection !== null,
471
+ );
472
+ },
473
+
474
+ async resolveSelection(slugExpression) {
475
+ const slugs = parseCollectionSelectionSlugs(slugExpression);
476
+ if (!slugs) return null;
477
+
478
+ const collections = await this.getBySlugs(slugs);
479
+ if (collections.length !== slugs.length) return null;
480
+
481
+ return {
482
+ collections,
483
+ slugs,
484
+ slugExpression: slugs.join("+"),
485
+ };
486
+ },
487
+
433
488
  async list() {
434
489
  const rows = await db
435
490
  .select()
@@ -83,6 +83,7 @@ export interface PostFilters {
83
83
  pinned?: boolean;
84
84
  featured?: boolean;
85
85
  collectionId?: string;
86
+ collectionIds?: string[];
86
87
  /** Exclude posts that are replies (have replyToId set) */
87
88
  excludeReplies?: boolean;
88
89
  /** Exclude posts hidden from Latest from results */
@@ -150,6 +151,8 @@ export interface PostService {
150
151
  list(filters?: PostFilters): Promise<Post[]>;
151
152
  /** Count posts matching filters (ignores cursor, offset, limit) */
152
153
  count(filters?: PostFilters): Promise<number>;
154
+ /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
155
+ countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
153
156
  /** Count posts grouped by published year-month (YYYY-MM) */
154
157
  countByYearMonth(
155
158
  filters?: PostFilters,
@@ -207,16 +210,31 @@ export interface PostService {
207
210
  collectionId: string,
208
211
  options?: ThreadRootPageOptions,
209
212
  ): Promise<number>;
213
+ /** Count distinct thread roots that contain published posts in any of the given collections */
214
+ countCollectionThreadRootsForCollections(
215
+ collectionIds: string[],
216
+ options?: ThreadRootPageOptions,
217
+ ): Promise<number>;
210
218
  /** List collection thread root IDs ordered by collected-at or rating semantics */
211
219
  listCollectionThreadRootIds(
212
220
  collectionId: string,
213
221
  options?: CollectionThreadRootPageOptions,
214
222
  ): Promise<string[]>;
223
+ /** List collection thread root IDs for a union of collections */
224
+ listCollectionThreadRootIdsForCollections(
225
+ collectionIds: string[],
226
+ options?: CollectionThreadRootPageOptions,
227
+ ): Promise<string[]>;
215
228
  /** List collection feed entries ordered by latest added-at timestamp */
216
229
  listCollectionFeedEntries(
217
230
  collectionId: string,
218
231
  options?: ThreadRootPageOptions,
219
232
  ): Promise<CollectionFeedEntry[]>;
233
+ /** List collection feed entries for a union of collections */
234
+ listCollectionFeedEntriesForCollections(
235
+ collectionIds: string[],
236
+ options?: ThreadRootPageOptions,
237
+ ): Promise<CollectionFeedEntry[]>;
220
238
  /** Fetch all published, non-deleted posts for each requested thread root */
221
239
  getPublishedThreads(rootIds: string[]): Promise<Map<string, Post[]>>;
222
240
  /** For each thread, return post IDs that belong to the given collection */
@@ -224,6 +242,11 @@ export interface PostService {
224
242
  collectionId: string,
225
243
  threadIds: string[],
226
244
  ): Promise<Map<string, string[]>>;
245
+ /** For each thread, return post IDs that belong to any of the given collections */
246
+ getCollectionPostIdsByThreadForCollections(
247
+ collectionIds: string[],
248
+ threadIds: string[],
249
+ ): Promise<Map<string, string[]>>;
227
250
  /** Get distinct years that have published posts */
228
251
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
229
252
  /** For each thread ID, return the ID of the last published, non-deleted post */
@@ -470,6 +493,43 @@ export function createPostService(
470
493
  .where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
471
494
  }
472
495
 
496
+ function normalizeCollectionIds(collectionIds: readonly string[]): string[] {
497
+ return [...new Set(collectionIds)];
498
+ }
499
+
500
+ function buildCollectionMembershipCondition(
501
+ collectionIds: readonly string[],
502
+ ): SQL<unknown> {
503
+ const uniqueCollectionIds = normalizeCollectionIds(collectionIds);
504
+ const firstCollectionId = uniqueCollectionIds[0];
505
+ if (!firstCollectionId) {
506
+ return sql`0 = 1`;
507
+ }
508
+
509
+ return uniqueCollectionIds.length === 1
510
+ ? eq(postCollections.collectionId, firstCollectionId)
511
+ : inArray(postCollections.collectionId, uniqueCollectionIds);
512
+ }
513
+
514
+ function buildPostCollectionSubqueryCondition(
515
+ collectionIds: readonly string[],
516
+ ): SQL<unknown> {
517
+ const uniqueCollectionIds = normalizeCollectionIds(collectionIds);
518
+ if (uniqueCollectionIds.length === 0) {
519
+ return sql`0 = 1`;
520
+ }
521
+
522
+ const placeholders = uniqueCollectionIds.map(
523
+ (collectionId) => sql`${collectionId}`,
524
+ );
525
+ return sql`${posts.id} IN (
526
+ SELECT post_id
527
+ FROM post_collection
528
+ WHERE site_id = ${siteId}
529
+ AND collection_id IN (${sql.join(placeholders, sql`, `)})
530
+ )`;
531
+ }
532
+
473
533
  /** Build WHERE conditions from filters (shared by list and count) */
474
534
  function buildFilterConditions(filters: PostFilters) {
475
535
  const conditions = [eq(posts.siteId, siteId)];
@@ -503,15 +563,13 @@ export function createPostService(
503
563
  if (filters.format) {
504
564
  conditions.push(eq(posts.format, filters.format));
505
565
  }
506
- if (filters.collectionId !== undefined) {
507
- // Filter by collection via junction table
566
+ if (filters.collectionIds !== undefined) {
508
567
  conditions.push(
509
- sql`${posts.id} IN (
510
- SELECT post_id
511
- FROM post_collection
512
- WHERE site_id = ${siteId}
513
- AND collection_id = ${filters.collectionId}
514
- )`,
568
+ buildPostCollectionSubqueryCondition(filters.collectionIds),
569
+ );
570
+ } else if (filters.collectionId !== undefined) {
571
+ conditions.push(
572
+ buildPostCollectionSubqueryCondition([filters.collectionId]),
515
573
  );
516
574
  }
517
575
  if (filters.threadId) {
@@ -1069,6 +1127,22 @@ export function createPostService(
1069
1127
  return result[0]?.count ?? 0;
1070
1128
  },
1071
1129
 
1130
+ async countUpTo(filters = {}, limit) {
1131
+ const normalizedLimit = Math.max(0, Math.trunc(limit));
1132
+ if (normalizedLimit === 0) {
1133
+ return 0;
1134
+ }
1135
+
1136
+ const conditions = buildFilterConditions(filters);
1137
+ const rows = await db
1138
+ .select({ id: posts.id })
1139
+ .from(posts)
1140
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
1141
+ .limit(normalizedLimit);
1142
+
1143
+ return rows.length;
1144
+ },
1145
+
1072
1146
  async countByYearMonth(filters = {}) {
1073
1147
  const conditions = [
1074
1148
  ...buildFilterConditions(filters),
@@ -2150,9 +2224,19 @@ export function createPostService(
2150
2224
  },
2151
2225
 
2152
2226
  async countCollectionThreadRoots(collectionId, options = {}) {
2227
+ return this.countCollectionThreadRootsForCollections(
2228
+ [collectionId],
2229
+ options,
2230
+ );
2231
+ },
2232
+
2233
+ async countCollectionThreadRootsForCollections(
2234
+ collectionIds,
2235
+ options = {},
2236
+ ) {
2153
2237
  const conditions = [
2154
2238
  ...buildThreadRootPageConditions(options),
2155
- eq(postCollections.collectionId, collectionId),
2239
+ buildCollectionMembershipCondition(collectionIds),
2156
2240
  ];
2157
2241
 
2158
2242
  const rows = await db
@@ -2173,9 +2257,19 @@ export function createPostService(
2173
2257
  },
2174
2258
 
2175
2259
  async listCollectionThreadRootIds(collectionId, options = {}) {
2260
+ return this.listCollectionThreadRootIdsForCollections(
2261
+ [collectionId],
2262
+ options,
2263
+ );
2264
+ },
2265
+
2266
+ async listCollectionThreadRootIdsForCollections(
2267
+ collectionIds,
2268
+ options = {},
2269
+ ) {
2176
2270
  const conditions = [
2177
2271
  ...buildThreadRootPageConditions(options),
2178
- eq(postCollections.collectionId, collectionId),
2272
+ buildCollectionMembershipCondition(collectionIds),
2179
2273
  ];
2180
2274
  const sortOrder = options.sortOrder ?? "newest";
2181
2275
  const collectedAt =
@@ -2234,9 +2328,16 @@ export function createPostService(
2234
2328
  },
2235
2329
 
2236
2330
  async listCollectionFeedEntries(collectionId, options = {}) {
2331
+ return this.listCollectionFeedEntriesForCollections(
2332
+ [collectionId],
2333
+ options,
2334
+ );
2335
+ },
2336
+
2337
+ async listCollectionFeedEntriesForCollections(collectionIds, options = {}) {
2237
2338
  const conditions = [
2238
2339
  ...buildThreadRootPageConditions(options),
2239
- eq(postCollections.collectionId, collectionId),
2340
+ buildCollectionMembershipCondition(collectionIds),
2240
2341
  ];
2241
2342
  const collectedAt = sql<number>`MAX(${postCollections.createdAt})`.as(
2242
2343
  "collected_at",
@@ -2306,6 +2407,13 @@ export function createPostService(
2306
2407
  },
2307
2408
 
2308
2409
  async getCollectionPostIdsByThread(collectionId, threadIds) {
2410
+ return this.getCollectionPostIdsByThreadForCollections(
2411
+ [collectionId],
2412
+ threadIds,
2413
+ );
2414
+ },
2415
+
2416
+ async getCollectionPostIdsByThreadForCollections(collectionIds, threadIds) {
2309
2417
  const result = new Map<string, string[]>();
2310
2418
  if (threadIds.length === 0) return result;
2311
2419
 
@@ -2327,12 +2435,13 @@ export function createPostService(
2327
2435
  .where(
2328
2436
  and(
2329
2437
  eq(posts.siteId, siteId),
2330
- eq(postCollections.collectionId, collectionId),
2438
+ buildCollectionMembershipCondition(collectionIds),
2331
2439
  inArray(posts.threadId, chunk),
2332
2440
  eq(posts.status, "published"),
2333
2441
  isNull(posts.deletedAt),
2334
2442
  ),
2335
2443
  )
2444
+ .groupBy(posts.threadId, posts.id)
2336
2445
  .orderBy(posts.threadId, posts.createdAt, posts.id),
2337
2446
  );
2338
2447
 
package/src/styles/ui.css CHANGED
@@ -754,6 +754,23 @@
754
754
  color: var(--site-text-secondary);
755
755
  }
756
756
 
757
+ .collection-directory-divider-link {
758
+ color: inherit;
759
+ text-decoration: none;
760
+ transition:
761
+ color 160ms ease,
762
+ text-decoration-color 160ms ease;
763
+ text-decoration-color: transparent;
764
+ }
765
+
766
+ .collection-directory-divider-link:hover,
767
+ .collection-directory-divider-link:focus-visible {
768
+ color: var(--foreground);
769
+ text-decoration: underline;
770
+ text-decoration-color: currentColor;
771
+ text-underline-offset: 0.18em;
772
+ }
773
+
757
774
  .collection-directory-divider-line {
758
775
  flex: 1;
759
776
  height: 1px;
@@ -87,11 +87,12 @@ export interface SearchPageProps {
87
87
 
88
88
  /** Props for the single collection page component */
89
89
  export interface CollectionPageProps {
90
- collection: Collection;
90
+ collections: Collection[];
91
91
  items: TimelineItemView[];
92
92
  totalThreadCount: number;
93
93
  currentPage: number;
94
94
  totalPages: number;
95
+ pagePath: string;
95
96
  baseUrl: string;
96
97
  currentSort: CollectionSortOrder;
97
98
  defaultSort: CollectionSortOrder;