@jant/core 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +11 -4
- package/dist/client.js +1 -0
- package/dist/db/schema.js +15 -1
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/image.js +39 -15
- package/dist/lib/media-helpers.js +49 -0
- package/dist/lib/nav-reorder.js +27 -0
- package/dist/lib/navigation.js +35 -0
- package/dist/lib/storage.js +164 -0
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/posts.js +12 -7
- package/dist/routes/api/timeline.js +116 -0
- package/dist/routes/api/upload.js +35 -24
- package/dist/routes/dash/media.js +24 -14
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/dash/posts.js +4 -1
- package/dist/routes/feed/rss.js +3 -2
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +84 -126
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +47 -56
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +8 -6
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/PostForm.js +4 -3
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +2 -0
- package/dist/theme/components/timeline/ArticleCard.js +50 -0
- package/dist/theme/components/timeline/ImageCard.js +86 -0
- package/dist/theme/components/timeline/LinkCard.js +62 -0
- package/dist/theme/components/timeline/NoteCard.js +37 -0
- package/dist/theme/components/timeline/QuoteCard.js +51 -0
- package/dist/theme/components/timeline/ThreadPreview.js +52 -0
- package/dist/theme/components/timeline/TimelineFeed.js +43 -0
- package/dist/theme/components/timeline/TimelineItem.js +25 -0
- package/dist/theme/components/timeline/index.js +8 -0
- package/dist/theme/layouts/DashLayout.js +8 -0
- package/dist/theme/layouts/SiteLayout.js +160 -0
- package/dist/theme/layouts/index.js +1 -0
- package/dist/types/sortablejs.d.js +5 -0
- package/dist/types.js +32 -0
- package/package.json +4 -2
- package/src/__tests__/helpers/app.ts +1 -0
- package/src/__tests__/helpers/db.ts +20 -0
- package/src/app.tsx +12 -7
- package/src/client.ts +1 -0
- package/src/db/migrations/0003_add_navigation_links.sql +8 -0
- package/src/db/migrations/0004_add_storage_provider.sql +3 -0
- package/src/db/migrations/meta/0003_snapshot.json +821 -0
- package/src/db/migrations/meta/_journal.json +21 -0
- package/src/db/schema.ts +15 -1
- package/src/i18n/locales/en.po +148 -80
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +150 -103
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +150 -103
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +5 -0
- package/src/lib/__tests__/image.test.ts +96 -0
- package/src/lib/__tests__/storage.test.ts +162 -0
- package/src/lib/__tests__/theme-components.test.ts +107 -0
- package/src/lib/image.ts +46 -16
- package/src/lib/media-helpers.ts +65 -0
- package/src/lib/nav-reorder.ts +26 -0
- package/src/lib/navigation.ts +46 -0
- package/src/lib/storage.ts +236 -0
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +8 -8
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/posts.ts +20 -6
- package/src/routes/api/timeline.tsx +152 -0
- package/src/routes/api/upload.ts +52 -25
- package/src/routes/dash/media.tsx +40 -8
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/dash/posts.tsx +5 -0
- package/src/routes/feed/rss.ts +3 -2
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +118 -122
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +63 -60
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/media.test.ts +73 -28
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +12 -8
- package/src/services/navigation.ts +165 -0
- package/src/services/post.ts +48 -1
- package/src/styles/components.css +59 -0
- package/src/theme/components/PostForm.tsx +13 -2
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +12 -0
- package/src/theme/components/timeline/ArticleCard.tsx +57 -0
- package/src/theme/components/timeline/ImageCard.tsx +80 -0
- package/src/theme/components/timeline/LinkCard.tsx +66 -0
- package/src/theme/components/timeline/NoteCard.tsx +41 -0
- package/src/theme/components/timeline/QuoteCard.tsx +55 -0
- package/src/theme/components/timeline/ThreadPreview.tsx +49 -0
- package/src/theme/components/timeline/TimelineFeed.tsx +52 -0
- package/src/theme/components/timeline/TimelineItem.tsx +39 -0
- package/src/theme/components/timeline/index.ts +8 -0
- package/src/theme/layouts/DashLayout.tsx +10 -0
- package/src/theme/layouts/SiteLayout.tsx +184 -0
- package/src/theme/layouts/index.ts +1 -0
- package/src/types/sortablejs.d.ts +23 -0
- package/src/types.ts +102 -1
- package/dist/app.d.ts +0 -38
- package/dist/app.d.ts.map +0 -1
- package/dist/auth.d.ts +0 -25
- package/dist/auth.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -10
- package/dist/db/index.d.ts.map +0 -1
- package/dist/db/schema.d.ts +0 -1543
- package/dist/db/schema.d.ts.map +0 -1
- package/dist/i18n/Trans.d.ts +0 -25
- package/dist/i18n/Trans.d.ts.map +0 -1
- package/dist/i18n/context.d.ts +0 -69
- package/dist/i18n/context.d.ts.map +0 -1
- package/dist/i18n/detect.d.ts +0 -20
- package/dist/i18n/detect.d.ts.map +0 -1
- package/dist/i18n/i18n.d.ts +0 -32
- package/dist/i18n/i18n.d.ts.map +0 -1
- package/dist/i18n/index.d.ts +0 -41
- package/dist/i18n/index.d.ts.map +0 -1
- package/dist/i18n/locales/en.d.ts +0 -3
- package/dist/i18n/locales/en.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hans.d.ts +0 -3
- package/dist/i18n/locales/zh-Hans.d.ts.map +0 -1
- package/dist/i18n/locales/zh-Hant.d.ts +0 -3
- package/dist/i18n/locales/zh-Hant.d.ts.map +0 -1
- package/dist/i18n/locales.d.ts +0 -11
- package/dist/i18n/locales.d.ts.map +0 -1
- package/dist/i18n/middleware.d.ts +0 -21
- package/dist/i18n/middleware.d.ts.map +0 -1
- package/dist/index.d.ts +0 -16
- package/dist/index.d.ts.map +0 -1
- package/dist/lib/config.d.ts +0 -83
- package/dist/lib/config.d.ts.map +0 -1
- package/dist/lib/constants.d.ts +0 -37
- package/dist/lib/constants.d.ts.map +0 -1
- package/dist/lib/image.d.ts +0 -73
- package/dist/lib/image.d.ts.map +0 -1
- package/dist/lib/index.d.ts +0 -9
- package/dist/lib/index.d.ts.map +0 -1
- package/dist/lib/markdown.d.ts +0 -60
- package/dist/lib/markdown.d.ts.map +0 -1
- package/dist/lib/schemas.d.ts +0 -130
- package/dist/lib/schemas.d.ts.map +0 -1
- package/dist/lib/sqid.d.ts +0 -60
- package/dist/lib/sqid.d.ts.map +0 -1
- package/dist/lib/sse.d.ts +0 -192
- package/dist/lib/sse.d.ts.map +0 -1
- package/dist/lib/theme.d.ts +0 -44
- package/dist/lib/theme.d.ts.map +0 -1
- package/dist/lib/time.d.ts +0 -90
- package/dist/lib/time.d.ts.map +0 -1
- package/dist/lib/url.d.ts +0 -82
- package/dist/lib/url.d.ts.map +0 -1
- package/dist/middleware/auth.d.ts +0 -24
- package/dist/middleware/auth.d.ts.map +0 -1
- package/dist/middleware/onboarding.d.ts +0 -26
- package/dist/middleware/onboarding.d.ts.map +0 -1
- package/dist/routes/api/posts.d.ts +0 -13
- package/dist/routes/api/posts.d.ts.map +0 -1
- package/dist/routes/api/search.d.ts +0 -13
- package/dist/routes/api/search.d.ts.map +0 -1
- package/dist/routes/api/upload.d.ts +0 -16
- package/dist/routes/api/upload.d.ts.map +0 -1
- package/dist/routes/dash/collections.d.ts +0 -13
- package/dist/routes/dash/collections.d.ts.map +0 -1
- package/dist/routes/dash/index.d.ts +0 -15
- package/dist/routes/dash/index.d.ts.map +0 -1
- package/dist/routes/dash/media.d.ts +0 -16
- package/dist/routes/dash/media.d.ts.map +0 -1
- package/dist/routes/dash/pages.d.ts +0 -15
- package/dist/routes/dash/pages.d.ts.map +0 -1
- package/dist/routes/dash/posts.d.ts +0 -13
- package/dist/routes/dash/posts.d.ts.map +0 -1
- package/dist/routes/dash/redirects.d.ts +0 -13
- package/dist/routes/dash/redirects.d.ts.map +0 -1
- package/dist/routes/dash/settings.d.ts +0 -15
- package/dist/routes/dash/settings.d.ts.map +0 -1
- package/dist/routes/feed/rss.d.ts +0 -13
- package/dist/routes/feed/rss.d.ts.map +0 -1
- package/dist/routes/feed/sitemap.d.ts +0 -13
- package/dist/routes/feed/sitemap.d.ts.map +0 -1
- package/dist/routes/pages/archive.d.ts +0 -15
- package/dist/routes/pages/archive.d.ts.map +0 -1
- package/dist/routes/pages/collection.d.ts +0 -13
- package/dist/routes/pages/collection.d.ts.map +0 -1
- package/dist/routes/pages/home.d.ts +0 -13
- package/dist/routes/pages/home.d.ts.map +0 -1
- package/dist/routes/pages/page.d.ts +0 -15
- package/dist/routes/pages/page.d.ts.map +0 -1
- package/dist/routes/pages/post.d.ts +0 -13
- package/dist/routes/pages/post.d.ts.map +0 -1
- package/dist/routes/pages/search.d.ts +0 -13
- package/dist/routes/pages/search.d.ts.map +0 -1
- package/dist/services/collection.d.ts +0 -32
- package/dist/services/collection.d.ts.map +0 -1
- package/dist/services/index.d.ts +0 -28
- package/dist/services/index.d.ts.map +0 -1
- package/dist/services/media.d.ts +0 -34
- package/dist/services/media.d.ts.map +0 -1
- package/dist/services/post.d.ts +0 -31
- package/dist/services/post.d.ts.map +0 -1
- package/dist/services/redirect.d.ts +0 -15
- package/dist/services/redirect.d.ts.map +0 -1
- package/dist/services/search.d.ts +0 -26
- package/dist/services/search.d.ts.map +0 -1
- package/dist/services/settings.d.ts +0 -18
- package/dist/services/settings.d.ts.map +0 -1
- package/dist/theme/color-themes.d.ts +0 -30
- package/dist/theme/color-themes.d.ts.map +0 -1
- package/dist/theme/components/ActionButtons.d.ts +0 -43
- package/dist/theme/components/ActionButtons.d.ts.map +0 -1
- package/dist/theme/components/CrudPageHeader.d.ts +0 -23
- package/dist/theme/components/CrudPageHeader.d.ts.map +0 -1
- package/dist/theme/components/DangerZone.d.ts +0 -36
- package/dist/theme/components/DangerZone.d.ts.map +0 -1
- package/dist/theme/components/EmptyState.d.ts +0 -27
- package/dist/theme/components/EmptyState.d.ts.map +0 -1
- package/dist/theme/components/ListItemRow.d.ts +0 -15
- package/dist/theme/components/ListItemRow.d.ts.map +0 -1
- package/dist/theme/components/MediaGallery.d.ts +0 -13
- package/dist/theme/components/MediaGallery.d.ts.map +0 -1
- package/dist/theme/components/PageForm.d.ts +0 -14
- package/dist/theme/components/PageForm.d.ts.map +0 -1
- package/dist/theme/components/Pagination.d.ts +0 -46
- package/dist/theme/components/Pagination.d.ts.map +0 -1
- package/dist/theme/components/PostForm.d.ts +0 -16
- package/dist/theme/components/PostForm.d.ts.map +0 -1
- package/dist/theme/components/PostList.d.ts +0 -10
- package/dist/theme/components/PostList.d.ts.map +0 -1
- package/dist/theme/components/ThreadView.d.ts +0 -15
- package/dist/theme/components/ThreadView.d.ts.map +0 -1
- package/dist/theme/components/TypeBadge.d.ts +0 -12
- package/dist/theme/components/TypeBadge.d.ts.map +0 -1
- package/dist/theme/components/VisibilityBadge.d.ts +0 -12
- package/dist/theme/components/VisibilityBadge.d.ts.map +0 -1
- package/dist/theme/components/index.d.ts +0 -14
- package/dist/theme/components/index.d.ts.map +0 -1
- package/dist/theme/index.d.ts +0 -21
- package/dist/theme/index.d.ts.map +0 -1
- package/dist/theme/layouts/BaseLayout.d.ts +0 -23
- package/dist/theme/layouts/BaseLayout.d.ts.map +0 -1
- package/dist/theme/layouts/DashLayout.d.ts +0 -17
- package/dist/theme/layouts/DashLayout.d.ts.map +0 -1
- package/dist/theme/layouts/index.d.ts +0 -3
- package/dist/theme/layouts/index.d.ts.map +0 -1
- package/dist/types.d.ts +0 -237
- package/dist/types.d.ts.map +0 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage Driver Abstraction
|
|
3
|
+
*
|
|
4
|
+
* Provides a common interface for file storage with R2 and S3-compatible backends.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Bindings } from "../types.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Common interface for storage operations.
|
|
11
|
+
*
|
|
12
|
+
* Both R2 and S3-compatible drivers implement this interface,
|
|
13
|
+
* allowing the rest of the application to be storage-agnostic.
|
|
14
|
+
*/
|
|
15
|
+
export interface StorageDriver {
|
|
16
|
+
/** Upload a file to storage */
|
|
17
|
+
put(
|
|
18
|
+
key: string,
|
|
19
|
+
body: ReadableStream | Uint8Array,
|
|
20
|
+
opts?: { contentType?: string },
|
|
21
|
+
): Promise<void>;
|
|
22
|
+
|
|
23
|
+
/** Retrieve a file from storage. Returns null if not found. */
|
|
24
|
+
get(
|
|
25
|
+
key: string,
|
|
26
|
+
): Promise<{ body: ReadableStream; contentType?: string } | null>;
|
|
27
|
+
|
|
28
|
+
/** Delete a file from storage */
|
|
29
|
+
delete(key: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Creates an R2 storage driver that delegates to a Cloudflare R2 bucket binding.
|
|
34
|
+
*
|
|
35
|
+
* @param r2 - The R2 bucket binding from the Cloudflare Workers environment
|
|
36
|
+
* @returns A StorageDriver backed by R2
|
|
37
|
+
*/
|
|
38
|
+
export function createR2Driver(r2: R2Bucket): StorageDriver {
|
|
39
|
+
return {
|
|
40
|
+
async put(key, body, opts) {
|
|
41
|
+
await r2.put(key, body, {
|
|
42
|
+
httpMetadata: opts?.contentType
|
|
43
|
+
? { contentType: opts.contentType }
|
|
44
|
+
: undefined,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async get(key) {
|
|
49
|
+
const object = await r2.get(key);
|
|
50
|
+
if (!object) return null;
|
|
51
|
+
return {
|
|
52
|
+
body: object.body,
|
|
53
|
+
contentType: object.httpMetadata?.contentType ?? undefined,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
async delete(key) {
|
|
58
|
+
await r2.delete(key);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Configuration for the S3-compatible storage driver.
|
|
65
|
+
*/
|
|
66
|
+
export interface S3DriverConfig {
|
|
67
|
+
endpoint: string;
|
|
68
|
+
bucket: string;
|
|
69
|
+
accessKeyId: string;
|
|
70
|
+
secretAccessKey: string;
|
|
71
|
+
region: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates an S3-compatible storage driver using the AWS SDK.
|
|
76
|
+
*
|
|
77
|
+
* Supports any S3-compatible service: AWS S3, Backblaze B2, MinIO, etc.
|
|
78
|
+
* Uses path-style addressing for non-AWS endpoints.
|
|
79
|
+
*
|
|
80
|
+
* @param config - S3 connection configuration
|
|
81
|
+
* @returns A StorageDriver backed by S3
|
|
82
|
+
*/
|
|
83
|
+
export function createS3Driver(config: S3DriverConfig): StorageDriver {
|
|
84
|
+
// Lazy-load the AWS SDK to avoid bundling it when using R2
|
|
85
|
+
let clientPromise: Promise<{
|
|
86
|
+
send: (command: unknown) => Promise<unknown>;
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
|
|
88
|
+
S3Client: any;
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
|
|
90
|
+
PutObjectCommand: any;
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
|
|
92
|
+
GetObjectCommand: any;
|
|
93
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Dynamic import type
|
|
94
|
+
DeleteObjectCommand: any;
|
|
95
|
+
bucket: string;
|
|
96
|
+
}> | null = null;
|
|
97
|
+
|
|
98
|
+
function getClient() {
|
|
99
|
+
if (!clientPromise) {
|
|
100
|
+
clientPromise = import("@aws-sdk/client-s3").then((sdk) => {
|
|
101
|
+
const forcePathStyle = !config.endpoint.includes("amazonaws.com");
|
|
102
|
+
const client = new sdk.S3Client({
|
|
103
|
+
endpoint: config.endpoint,
|
|
104
|
+
region: config.region,
|
|
105
|
+
credentials: {
|
|
106
|
+
accessKeyId: config.accessKeyId,
|
|
107
|
+
secretAccessKey: config.secretAccessKey,
|
|
108
|
+
},
|
|
109
|
+
forcePathStyle,
|
|
110
|
+
});
|
|
111
|
+
return {
|
|
112
|
+
send: (cmd: unknown) => client.send(cmd as never),
|
|
113
|
+
S3Client: sdk.S3Client,
|
|
114
|
+
PutObjectCommand: sdk.PutObjectCommand,
|
|
115
|
+
GetObjectCommand: sdk.GetObjectCommand,
|
|
116
|
+
DeleteObjectCommand: sdk.DeleteObjectCommand,
|
|
117
|
+
bucket: config.bucket,
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return clientPromise;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
async put(key, body, opts) {
|
|
126
|
+
const s3 = await getClient();
|
|
127
|
+
|
|
128
|
+
// Buffer the stream to Uint8Array for the S3 SDK
|
|
129
|
+
let bodyBytes: Uint8Array;
|
|
130
|
+
if (body instanceof Uint8Array) {
|
|
131
|
+
bodyBytes = body;
|
|
132
|
+
} else {
|
|
133
|
+
const reader = body.getReader();
|
|
134
|
+
const chunks: Uint8Array[] = [];
|
|
135
|
+
for (;;) {
|
|
136
|
+
const { done, value } = await reader.read();
|
|
137
|
+
if (done) break;
|
|
138
|
+
chunks.push(value);
|
|
139
|
+
}
|
|
140
|
+
let totalLength = 0;
|
|
141
|
+
for (const chunk of chunks) totalLength += chunk.length;
|
|
142
|
+
bodyBytes = new Uint8Array(totalLength);
|
|
143
|
+
let offset = 0;
|
|
144
|
+
for (const chunk of chunks) {
|
|
145
|
+
bodyBytes.set(chunk, offset);
|
|
146
|
+
offset += chunk.length;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const command = new s3.PutObjectCommand({
|
|
151
|
+
Bucket: s3.bucket,
|
|
152
|
+
Key: key,
|
|
153
|
+
Body: bodyBytes,
|
|
154
|
+
ContentType: opts?.contentType,
|
|
155
|
+
});
|
|
156
|
+
await s3.send(command);
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
async get(key) {
|
|
160
|
+
const s3 = await getClient();
|
|
161
|
+
try {
|
|
162
|
+
const command = new s3.GetObjectCommand({
|
|
163
|
+
Bucket: s3.bucket,
|
|
164
|
+
Key: key,
|
|
165
|
+
});
|
|
166
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AWS SDK response type
|
|
167
|
+
const response = (await s3.send(command)) as any;
|
|
168
|
+
if (!response.Body) return null;
|
|
169
|
+
return {
|
|
170
|
+
body: response.Body.transformToWebStream() as ReadableStream,
|
|
171
|
+
contentType: response.ContentType ?? undefined,
|
|
172
|
+
};
|
|
173
|
+
} catch (err: unknown) {
|
|
174
|
+
// NoSuchKey → return null instead of throwing
|
|
175
|
+
if (
|
|
176
|
+
err instanceof Error &&
|
|
177
|
+
(err.name === "NoSuchKey" || err.name === "NotFound")
|
|
178
|
+
) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
throw err;
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async delete(key) {
|
|
186
|
+
const s3 = await getClient();
|
|
187
|
+
const command = new s3.DeleteObjectCommand({
|
|
188
|
+
Bucket: s3.bucket,
|
|
189
|
+
Key: key,
|
|
190
|
+
});
|
|
191
|
+
await s3.send(command);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Creates the appropriate storage driver based on environment configuration.
|
|
198
|
+
*
|
|
199
|
+
* Returns `null` if no storage is configured (no R2 binding and no S3 config).
|
|
200
|
+
*
|
|
201
|
+
* @param env - The Cloudflare Workers environment bindings
|
|
202
|
+
* @returns A StorageDriver instance or null
|
|
203
|
+
*
|
|
204
|
+
* @example
|
|
205
|
+
* ```ts
|
|
206
|
+
* const storage = createStorageDriver(c.env);
|
|
207
|
+
* if (storage) {
|
|
208
|
+
* await storage.put("media/file.jpg", stream, { contentType: "image/jpeg" });
|
|
209
|
+
* }
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
export function createStorageDriver(env: Bindings): StorageDriver | null {
|
|
213
|
+
const driver = env.STORAGE_DRIVER || "r2";
|
|
214
|
+
|
|
215
|
+
if (driver === "s3") {
|
|
216
|
+
if (
|
|
217
|
+
!env.S3_ENDPOINT ||
|
|
218
|
+
!env.S3_BUCKET ||
|
|
219
|
+
!env.S3_ACCESS_KEY_ID ||
|
|
220
|
+
!env.S3_SECRET_ACCESS_KEY
|
|
221
|
+
) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
return createS3Driver({
|
|
225
|
+
endpoint: env.S3_ENDPOINT,
|
|
226
|
+
bucket: env.S3_BUCKET,
|
|
227
|
+
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
228
|
+
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
229
|
+
region: env.S3_REGION || "auto",
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Default: R2
|
|
234
|
+
if (!env.R2) return null;
|
|
235
|
+
return createR2Driver(env.R2);
|
|
236
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme Component Resolution
|
|
3
|
+
*
|
|
4
|
+
* Resolves theme-overridable components, falling back to defaults.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FC } from "hono/jsx";
|
|
8
|
+
import type {
|
|
9
|
+
PostType,
|
|
10
|
+
ThemeComponents,
|
|
11
|
+
TimelineCardProps,
|
|
12
|
+
ThreadPreviewProps,
|
|
13
|
+
TimelineFeedProps,
|
|
14
|
+
} from "../types.js";
|
|
15
|
+
|
|
16
|
+
const THEME_KEY_MAP: Record<PostType, keyof ThemeComponents> = {
|
|
17
|
+
note: "NoteCard",
|
|
18
|
+
article: "ArticleCard",
|
|
19
|
+
link: "LinkCard",
|
|
20
|
+
quote: "QuoteCard",
|
|
21
|
+
image: "ImageCard",
|
|
22
|
+
page: "NoteCard",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves the card component for a given post type.
|
|
27
|
+
*
|
|
28
|
+
* Checks theme overrides first, then falls back to the provided default card component.
|
|
29
|
+
*
|
|
30
|
+
* @param type - The post type to resolve a card for
|
|
31
|
+
* @param defaults - Map of post type to default card component
|
|
32
|
+
* @param themeComponents - Optional theme component overrides
|
|
33
|
+
* @returns The resolved card component
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const Card = resolveCardComponent("article", DEFAULT_CARD_MAP, c.var.config.theme?.components);
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function resolveCardComponent(
|
|
41
|
+
type: PostType,
|
|
42
|
+
defaults: Record<PostType, FC<TimelineCardProps>>,
|
|
43
|
+
themeComponents?: ThemeComponents,
|
|
44
|
+
): FC<TimelineCardProps> {
|
|
45
|
+
const key = THEME_KEY_MAP[type];
|
|
46
|
+
const override = themeComponents?.[key] as FC<TimelineCardProps> | undefined;
|
|
47
|
+
return override ?? defaults[type];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the ThreadPreview component.
|
|
52
|
+
*
|
|
53
|
+
* @param defaultComponent - The default ThreadPreview component
|
|
54
|
+
* @param themeComponents - Optional theme component overrides
|
|
55
|
+
* @returns The resolved ThreadPreview component
|
|
56
|
+
*/
|
|
57
|
+
export function resolveThreadPreview(
|
|
58
|
+
defaultComponent: FC<ThreadPreviewProps>,
|
|
59
|
+
themeComponents?: ThemeComponents,
|
|
60
|
+
): FC<ThreadPreviewProps> {
|
|
61
|
+
return themeComponents?.ThreadPreview ?? defaultComponent;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolves the TimelineFeed component.
|
|
66
|
+
*
|
|
67
|
+
* @param defaultComponent - The default TimelineFeed component
|
|
68
|
+
* @param themeComponents - Optional theme component overrides
|
|
69
|
+
* @returns The resolved TimelineFeed component
|
|
70
|
+
*/
|
|
71
|
+
export function resolveTimelineFeed(
|
|
72
|
+
defaultComponent: FC<TimelineFeedProps>,
|
|
73
|
+
themeComponents?: ThemeComponents,
|
|
74
|
+
): FC<TimelineFeedProps> {
|
|
75
|
+
return themeComponents?.TimelineFeed ?? defaultComponent;
|
|
76
|
+
}
|
|
@@ -50,7 +50,7 @@ describe("Posts API Routes", () => {
|
|
|
50
50
|
originalName: "test.jpg",
|
|
51
51
|
mimeType: "image/jpeg",
|
|
52
52
|
size: 1024,
|
|
53
|
-
|
|
53
|
+
storageKey: "media/2025/01/test.jpg",
|
|
54
54
|
width: 800,
|
|
55
55
|
height: 600,
|
|
56
56
|
});
|
|
@@ -145,7 +145,7 @@ describe("Posts API Routes", () => {
|
|
|
145
145
|
originalName: "test.jpg",
|
|
146
146
|
mimeType: "image/jpeg",
|
|
147
147
|
size: 1024,
|
|
148
|
-
|
|
148
|
+
storageKey: "media/2025/01/test.jpg",
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
await services.media.attachToPost(post.id, [media.id]);
|
|
@@ -223,14 +223,14 @@ describe("Posts API Routes", () => {
|
|
|
223
223
|
originalName: "a.jpg",
|
|
224
224
|
mimeType: "image/jpeg",
|
|
225
225
|
size: 1024,
|
|
226
|
-
|
|
226
|
+
storageKey: "media/2025/01/a.jpg",
|
|
227
227
|
});
|
|
228
228
|
const m2 = await services.media.create({
|
|
229
229
|
filename: "b.jpg",
|
|
230
230
|
originalName: "b.jpg",
|
|
231
231
|
mimeType: "image/jpeg",
|
|
232
232
|
size: 2048,
|
|
233
|
-
|
|
233
|
+
storageKey: "media/2025/01/b.jpg",
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
const res = await app.request("/api/posts", {
|
|
@@ -282,7 +282,7 @@ describe("Posts API Routes", () => {
|
|
|
282
282
|
originalName: "a.jpg",
|
|
283
283
|
mimeType: "image/jpeg",
|
|
284
284
|
size: 1024,
|
|
285
|
-
|
|
285
|
+
storageKey: "media/2025/01/a.jpg",
|
|
286
286
|
});
|
|
287
287
|
|
|
288
288
|
const res = await app.request("/api/posts", {
|
|
@@ -404,7 +404,7 @@ describe("Posts API Routes", () => {
|
|
|
404
404
|
originalName: "a.jpg",
|
|
405
405
|
mimeType: "image/jpeg",
|
|
406
406
|
size: 1024,
|
|
407
|
-
|
|
407
|
+
storageKey: "media/2025/01/a.jpg",
|
|
408
408
|
});
|
|
409
409
|
|
|
410
410
|
await services.media.attachToPost(post.id, [m1.id]);
|
|
@@ -414,7 +414,7 @@ describe("Posts API Routes", () => {
|
|
|
414
414
|
originalName: "b.jpg",
|
|
415
415
|
mimeType: "image/jpeg",
|
|
416
416
|
size: 2048,
|
|
417
|
-
|
|
417
|
+
storageKey: "media/2025/01/b.jpg",
|
|
418
418
|
});
|
|
419
419
|
|
|
420
420
|
const res = await app.request(`/api/posts/${sqid.encode(post.id)}`, {
|
|
@@ -443,7 +443,7 @@ describe("Posts API Routes", () => {
|
|
|
443
443
|
originalName: "a.jpg",
|
|
444
444
|
mimeType: "image/jpeg",
|
|
445
445
|
size: 1024,
|
|
446
|
-
|
|
446
|
+
storageKey: "media/2025/01/a.jpg",
|
|
447
447
|
});
|
|
448
448
|
|
|
449
449
|
await services.media.attachToPost(post.id, [m1.id]);
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline API Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests the timeline data assembly logic via the service layer.
|
|
5
|
+
* The actual route handler renders JSX components which require the Lingui SWC
|
|
6
|
+
* plugin (not available in vitest). We test the underlying service operations
|
|
7
|
+
* that power the timeline API instead.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
11
|
+
import { createTestDatabase } from "../../../__tests__/helpers/db.js";
|
|
12
|
+
import { createPostService } from "../../../services/post.js";
|
|
13
|
+
import { createMediaService } from "../../../services/media.js";
|
|
14
|
+
import { buildMediaMap } from "../../../lib/media-helpers.js";
|
|
15
|
+
import type { Database } from "../../../db/index.js";
|
|
16
|
+
import type { PostWithMedia, TimelineItemData } from "../../../types.js";
|
|
17
|
+
|
|
18
|
+
describe("Timeline data assembly", () => {
|
|
19
|
+
let db: Database;
|
|
20
|
+
let postService: ReturnType<typeof createPostService>;
|
|
21
|
+
let mediaService: ReturnType<typeof createMediaService>;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
const testDb = createTestDatabase();
|
|
25
|
+
db = testDb.db as unknown as Database;
|
|
26
|
+
postService = createPostService(db);
|
|
27
|
+
mediaService = createMediaService(db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("assembles timeline items with media attachments", async () => {
|
|
31
|
+
const post = await postService.create({
|
|
32
|
+
type: "note",
|
|
33
|
+
content: "Hello",
|
|
34
|
+
visibility: "featured",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const posts = await postService.list({
|
|
38
|
+
visibility: ["featured", "quiet"],
|
|
39
|
+
excludeReplies: true,
|
|
40
|
+
excludeTypes: ["page"],
|
|
41
|
+
limit: 21,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(posts).toHaveLength(1);
|
|
45
|
+
expect(posts[0]?.id).toBe(post.id);
|
|
46
|
+
|
|
47
|
+
// Build media map
|
|
48
|
+
const postIds = posts.map((p) => p.id);
|
|
49
|
+
const rawMediaMap = await mediaService.getByPostIds(postIds);
|
|
50
|
+
const mediaMap = buildMediaMap(rawMediaMap);
|
|
51
|
+
|
|
52
|
+
// Assemble items
|
|
53
|
+
const items: TimelineItemData[] = posts.map((p) => ({
|
|
54
|
+
post: { ...p, mediaAttachments: mediaMap.get(p.id) ?? [] },
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
expect(items).toHaveLength(1);
|
|
58
|
+
expect(items[0]?.post.mediaAttachments).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("identifies thread roots and builds thread previews", async () => {
|
|
62
|
+
const root = await postService.create({
|
|
63
|
+
type: "note",
|
|
64
|
+
content: "Thread root",
|
|
65
|
+
visibility: "featured",
|
|
66
|
+
});
|
|
67
|
+
await postService.create({
|
|
68
|
+
type: "note",
|
|
69
|
+
content: "Reply 1",
|
|
70
|
+
replyToId: root.id,
|
|
71
|
+
});
|
|
72
|
+
await postService.create({
|
|
73
|
+
type: "note",
|
|
74
|
+
content: "Reply 2",
|
|
75
|
+
replyToId: root.id,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const posts = await postService.list({
|
|
79
|
+
visibility: ["featured", "quiet"],
|
|
80
|
+
excludeReplies: true,
|
|
81
|
+
excludeTypes: ["page"],
|
|
82
|
+
limit: 21,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(posts).toHaveLength(1);
|
|
86
|
+
|
|
87
|
+
const postIds = posts.map((p) => p.id);
|
|
88
|
+
const replyCounts = await postService.getReplyCounts(postIds);
|
|
89
|
+
const threadRootIds = postIds.filter(
|
|
90
|
+
(id) => (replyCounts.get(id) ?? 0) > 0,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(threadRootIds).toEqual([root.id]);
|
|
94
|
+
expect(replyCounts.get(root.id)).toBe(2);
|
|
95
|
+
|
|
96
|
+
const threadPreviews = await postService.getThreadPreviews(threadRootIds);
|
|
97
|
+
const replies = threadPreviews.get(root.id);
|
|
98
|
+
expect(replies).toHaveLength(2);
|
|
99
|
+
expect(replies?.[0]?.content).toBe("Reply 1");
|
|
100
|
+
|
|
101
|
+
// Assemble items
|
|
102
|
+
const rawMediaMap = await mediaService.getByPostIds(postIds);
|
|
103
|
+
const mediaMap = buildMediaMap(rawMediaMap);
|
|
104
|
+
|
|
105
|
+
const items: TimelineItemData[] = posts.map((post) => {
|
|
106
|
+
const postWithMedia: PostWithMedia = {
|
|
107
|
+
...post,
|
|
108
|
+
mediaAttachments: mediaMap.get(post.id) ?? [],
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const replyCount = replyCounts.get(post.id) ?? 0;
|
|
112
|
+
const previewReplies = threadPreviews.get(post.id);
|
|
113
|
+
|
|
114
|
+
if (replyCount > 0 && previewReplies) {
|
|
115
|
+
return {
|
|
116
|
+
post: postWithMedia,
|
|
117
|
+
threadPreview: {
|
|
118
|
+
replies: previewReplies.map((r) => ({
|
|
119
|
+
...r,
|
|
120
|
+
mediaAttachments: [],
|
|
121
|
+
})),
|
|
122
|
+
totalReplyCount: replyCount,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return { post: postWithMedia };
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(items).toHaveLength(1);
|
|
131
|
+
expect(items[0]?.threadPreview).toBeDefined();
|
|
132
|
+
expect(items[0]?.threadPreview?.replies).toHaveLength(2);
|
|
133
|
+
expect(items[0]?.threadPreview?.totalReplyCount).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("excludes pages from timeline", async () => {
|
|
137
|
+
await postService.create({
|
|
138
|
+
type: "note",
|
|
139
|
+
content: "A note",
|
|
140
|
+
visibility: "quiet",
|
|
141
|
+
});
|
|
142
|
+
await postService.create({
|
|
143
|
+
type: "page",
|
|
144
|
+
content: "A page",
|
|
145
|
+
visibility: "quiet",
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const posts = await postService.list({
|
|
149
|
+
visibility: ["featured", "quiet"],
|
|
150
|
+
excludeReplies: true,
|
|
151
|
+
excludeTypes: ["page"],
|
|
152
|
+
limit: 21,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(posts).toHaveLength(1);
|
|
156
|
+
expect(posts[0]?.type).toBe("note");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("excludes replies from top-level list", async () => {
|
|
160
|
+
const root = await postService.create({
|
|
161
|
+
type: "note",
|
|
162
|
+
content: "Root",
|
|
163
|
+
visibility: "quiet",
|
|
164
|
+
});
|
|
165
|
+
await postService.create({
|
|
166
|
+
type: "note",
|
|
167
|
+
content: "Reply",
|
|
168
|
+
replyToId: root.id,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const posts = await postService.list({
|
|
172
|
+
visibility: ["featured", "quiet"],
|
|
173
|
+
excludeReplies: true,
|
|
174
|
+
excludeTypes: ["page"],
|
|
175
|
+
limit: 21,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(posts).toHaveLength(1);
|
|
179
|
+
expect(posts[0]?.content).toBe("Root");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("supports cursor pagination for load more", async () => {
|
|
183
|
+
const posts = [];
|
|
184
|
+
for (let i = 0; i < 5; i++) {
|
|
185
|
+
posts.push(
|
|
186
|
+
await postService.create({
|
|
187
|
+
type: "note",
|
|
188
|
+
content: `Post ${i}`,
|
|
189
|
+
visibility: "quiet",
|
|
190
|
+
publishedAt: 1000 + i,
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// First page
|
|
196
|
+
const page1 = await postService.list({
|
|
197
|
+
visibility: ["featured", "quiet"],
|
|
198
|
+
excludeReplies: true,
|
|
199
|
+
excludeTypes: ["page"],
|
|
200
|
+
limit: 3,
|
|
201
|
+
});
|
|
202
|
+
expect(page1).toHaveLength(3);
|
|
203
|
+
|
|
204
|
+
// Second page using cursor
|
|
205
|
+
const lastPost = page1[page1.length - 1];
|
|
206
|
+
expect(lastPost).toBeDefined();
|
|
207
|
+
const page2 = await postService.list({
|
|
208
|
+
visibility: ["featured", "quiet"],
|
|
209
|
+
excludeReplies: true,
|
|
210
|
+
excludeTypes: ["page"],
|
|
211
|
+
limit: 3,
|
|
212
|
+
cursor: lastPost?.id,
|
|
213
|
+
});
|
|
214
|
+
expect(page2).toHaveLength(2);
|
|
215
|
+
expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("correctly determines hasMore flag", async () => {
|
|
219
|
+
for (let i = 0; i < 3; i++) {
|
|
220
|
+
await postService.create({
|
|
221
|
+
type: "note",
|
|
222
|
+
content: `Post ${i}`,
|
|
223
|
+
visibility: "quiet",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Request limit + 1 to check for more
|
|
228
|
+
const pageSize = 2;
|
|
229
|
+
const posts = await postService.list({
|
|
230
|
+
visibility: ["featured", "quiet"],
|
|
231
|
+
excludeReplies: true,
|
|
232
|
+
excludeTypes: ["page"],
|
|
233
|
+
limit: pageSize + 1,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const hasMore = posts.length > pageSize;
|
|
237
|
+
expect(hasMore).toBe(true);
|
|
238
|
+
|
|
239
|
+
const displayPosts = posts.slice(0, pageSize);
|
|
240
|
+
expect(displayPosts).toHaveLength(2);
|
|
241
|
+
});
|
|
242
|
+
});
|