@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.
- package/README.md +1 -0
- package/dist/{app-BfoG98VD.js → app-CAtsuLLh.js} +306 -72
- package/dist/client/_assets/client-auth.js +77 -68
- package/dist/client/_assets/client.css +1 -1
- package/dist/index.js +1 -1
- package/dist/node.js +2 -2
- package/package.json +1 -1
- package/src/app.tsx +5 -0
- package/src/client/components/__tests__/jant-collection-sidebar.test.ts +56 -0
- package/src/client/components/jant-collection-form.ts +2 -0
- package/src/client/components/jant-collection-sidebar.ts +19 -6
- package/src/i18n/locales/en.po +14 -0
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +14 -0
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +14 -0
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/collection-groups.test.ts +63 -0
- package/src/lib/__tests__/constants.test.ts +23 -1
- package/src/lib/__tests__/schemas.test.ts +18 -0
- package/src/lib/__tests__/slug-format.test.ts +8 -0
- package/src/lib/__tests__/timeline.test.ts +84 -2
- package/src/lib/collection-groups.ts +69 -0
- package/src/lib/constants.ts +20 -0
- package/src/lib/schemas.ts +8 -1
- package/src/lib/slug-format.ts +8 -0
- package/src/lib/timeline.ts +30 -23
- package/src/routes/pages/collection.tsx +89 -37
- package/src/runtime/__tests__/readiness.test.ts +89 -0
- package/src/runtime/readiness.ts +129 -0
- package/src/services/__tests__/collection.test.ts +45 -0
- package/src/services/__tests__/post-timeline.test.ts +58 -0
- package/src/services/__tests__/post.test.ts +35 -1
- package/src/services/collection.ts +55 -0
- package/src/services/post.ts +121 -12
- package/src/styles/ui.css +17 -0
- package/src/types/props.ts +2 -1
- package/src/ui/pages/CollectionPage.tsx +84 -17
- 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()
|
package/src/services/post.ts
CHANGED
|
@@ -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.
|
|
507
|
-
// Filter by collection via junction table
|
|
566
|
+
if (filters.collectionIds !== undefined) {
|
|
508
567
|
conditions.push(
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/types/props.ts
CHANGED
|
@@ -87,11 +87,12 @@ export interface SearchPageProps {
|
|
|
87
87
|
|
|
88
88
|
/** Props for the single collection page component */
|
|
89
89
|
export interface CollectionPageProps {
|
|
90
|
-
|
|
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;
|