@jant/core 0.0.1

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 (93) hide show
  1. package/bin/jant.js +188 -0
  2. package/drizzle.config.ts +10 -0
  3. package/lingui.config.ts +16 -0
  4. package/package.json +116 -0
  5. package/src/app.tsx +377 -0
  6. package/src/assets/datastar.min.js +8 -0
  7. package/src/auth.ts +38 -0
  8. package/src/client.ts +6 -0
  9. package/src/db/index.ts +14 -0
  10. package/src/db/migrations/0000_solid_moon_knight.sql +118 -0
  11. package/src/db/migrations/0001_add_search_fts.sql +40 -0
  12. package/src/db/migrations/0002_collection_path.sql +2 -0
  13. package/src/db/migrations/0003_collection_path_nullable.sql +21 -0
  14. package/src/db/migrations/0004_media_uuid.sql +35 -0
  15. package/src/db/migrations/meta/0000_snapshot.json +784 -0
  16. package/src/db/migrations/meta/_journal.json +41 -0
  17. package/src/db/schema.ts +159 -0
  18. package/src/i18n/EXAMPLES.md +235 -0
  19. package/src/i18n/README.md +296 -0
  20. package/src/i18n/Trans.tsx +31 -0
  21. package/src/i18n/context.tsx +101 -0
  22. package/src/i18n/detect.ts +100 -0
  23. package/src/i18n/i18n.ts +62 -0
  24. package/src/i18n/index.ts +65 -0
  25. package/src/i18n/locales/en.po +875 -0
  26. package/src/i18n/locales/en.ts +1 -0
  27. package/src/i18n/locales/zh-Hans.po +875 -0
  28. package/src/i18n/locales/zh-Hans.ts +1 -0
  29. package/src/i18n/locales/zh-Hant.po +875 -0
  30. package/src/i18n/locales/zh-Hant.ts +1 -0
  31. package/src/i18n/locales.ts +14 -0
  32. package/src/i18n/middleware.ts +59 -0
  33. package/src/index.ts +42 -0
  34. package/src/lib/assets.ts +47 -0
  35. package/src/lib/constants.ts +67 -0
  36. package/src/lib/image.ts +107 -0
  37. package/src/lib/index.ts +9 -0
  38. package/src/lib/markdown.ts +93 -0
  39. package/src/lib/schemas.ts +92 -0
  40. package/src/lib/sqid.ts +79 -0
  41. package/src/lib/sse.ts +152 -0
  42. package/src/lib/time.ts +117 -0
  43. package/src/lib/url.ts +107 -0
  44. package/src/middleware/auth.ts +59 -0
  45. package/src/routes/api/posts.ts +127 -0
  46. package/src/routes/api/search.ts +53 -0
  47. package/src/routes/api/upload.ts +240 -0
  48. package/src/routes/dash/collections.tsx +341 -0
  49. package/src/routes/dash/index.tsx +89 -0
  50. package/src/routes/dash/media.tsx +551 -0
  51. package/src/routes/dash/pages.tsx +245 -0
  52. package/src/routes/dash/posts.tsx +202 -0
  53. package/src/routes/dash/redirects.tsx +155 -0
  54. package/src/routes/dash/settings.tsx +93 -0
  55. package/src/routes/feed/rss.ts +119 -0
  56. package/src/routes/feed/sitemap.ts +75 -0
  57. package/src/routes/pages/archive.tsx +223 -0
  58. package/src/routes/pages/collection.tsx +79 -0
  59. package/src/routes/pages/home.tsx +93 -0
  60. package/src/routes/pages/page.tsx +64 -0
  61. package/src/routes/pages/post.tsx +81 -0
  62. package/src/routes/pages/search.tsx +162 -0
  63. package/src/services/collection.ts +180 -0
  64. package/src/services/index.ts +40 -0
  65. package/src/services/media.ts +97 -0
  66. package/src/services/post.ts +279 -0
  67. package/src/services/redirect.ts +74 -0
  68. package/src/services/search.ts +117 -0
  69. package/src/services/settings.ts +76 -0
  70. package/src/theme/components/ActionButtons.tsx +98 -0
  71. package/src/theme/components/CrudPageHeader.tsx +48 -0
  72. package/src/theme/components/DangerZone.tsx +77 -0
  73. package/src/theme/components/EmptyState.tsx +56 -0
  74. package/src/theme/components/ListItemRow.tsx +24 -0
  75. package/src/theme/components/PageForm.tsx +114 -0
  76. package/src/theme/components/Pagination.tsx +196 -0
  77. package/src/theme/components/PostForm.tsx +122 -0
  78. package/src/theme/components/PostList.tsx +68 -0
  79. package/src/theme/components/ThreadView.tsx +118 -0
  80. package/src/theme/components/TypeBadge.tsx +28 -0
  81. package/src/theme/components/VisibilityBadge.tsx +33 -0
  82. package/src/theme/components/index.ts +12 -0
  83. package/src/theme/index.ts +24 -0
  84. package/src/theme/layouts/BaseLayout.tsx +49 -0
  85. package/src/theme/layouts/DashLayout.tsx +108 -0
  86. package/src/theme/layouts/index.ts +2 -0
  87. package/src/theme/styles/main.css +52 -0
  88. package/src/types.ts +222 -0
  89. package/static/assets/datastar.min.js +7 -0
  90. package/static/assets/image-processor.js +234 -0
  91. package/tsconfig.json +16 -0
  92. package/vite.config.ts +82 -0
  93. package/wrangler.toml +21 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Single Post Page Route
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "../../i18n/index.js";
7
+ import type { Bindings, Post } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import { BaseLayout } from "../../theme/layouts/index.js";
10
+ import * as sqid from "../../lib/sqid.js";
11
+ import * as time from "../../lib/time.js";
12
+
13
+ type Env = { Bindings: Bindings; Variables: AppVariables };
14
+
15
+ export const postRoutes = new Hono<Env>();
16
+
17
+ function PostContent({ post }: { post: Post }) {
18
+ const { t } = useLingui();
19
+
20
+ return (
21
+ <div class="container py-8">
22
+ <article class="h-entry">
23
+ {post.title && <h1 class="p-name text-2xl font-semibold mb-4">{post.title}</h1>}
24
+
25
+ <div
26
+ class="e-content prose"
27
+ dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
28
+ />
29
+
30
+ <footer class="mt-6 pt-4 border-t text-sm text-muted-foreground">
31
+ <time class="dt-published" datetime={time.toISOString(post.publishedAt)}>
32
+ {time.formatDate(post.publishedAt)}
33
+ </time>
34
+ <a href={`/p/${sqid.encode(post.id)}`} class="u-url ml-4">
35
+ {t({ message: "Permalink", comment: "@context: Link to permanent URL of post" })}
36
+ </a>
37
+ </footer>
38
+ </article>
39
+
40
+ <nav class="mt-8">
41
+ <a href="/" class="text-sm hover:underline">
42
+ {t({ message: "← Back to home", comment: "@context: Navigation link" })}
43
+ </a>
44
+ </nav>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ postRoutes.get("/:id", async (c) => {
50
+ const paramId = c.req.param("id");
51
+
52
+ // Try to decode as sqid first
53
+ let id = sqid.decode(paramId);
54
+
55
+ // If not a valid sqid, try to find by path
56
+ if (!id) {
57
+ const post = await c.var.services.posts.getByPath(paramId);
58
+ if (post) {
59
+ id = post.id;
60
+ }
61
+ }
62
+
63
+ if (!id) return c.notFound();
64
+
65
+ const post = await c.var.services.posts.getById(id);
66
+ if (!post) return c.notFound();
67
+
68
+ // Don't show drafts on public site
69
+ if (post.visibility === "draft") {
70
+ return c.notFound();
71
+ }
72
+
73
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
74
+ const title = post.title || siteName;
75
+
76
+ return c.html(
77
+ <BaseLayout title={title} description={post.content?.slice(0, 160)} c={c}>
78
+ <PostContent post={post} />
79
+ </BaseLayout>
80
+ );
81
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Search Page Route
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { useLingui } from "../../i18n/index.js";
7
+ import type { Bindings } from "../../types.js";
8
+ import type { AppVariables } from "../../app.js";
9
+ import type { SearchResult } from "../../services/search.js";
10
+ import { BaseLayout } from "../../theme/layouts/index.js";
11
+ import { PagePagination } from "../../theme/components/index.js";
12
+ import * as sqid from "../../lib/sqid.js";
13
+ import * as time from "../../lib/time.js";
14
+
15
+ type Env = { Bindings: Bindings; Variables: AppVariables };
16
+
17
+ const PAGE_SIZE = 10;
18
+
19
+ export const searchRoutes = new Hono<Env>();
20
+
21
+ function SearchContent({
22
+ query,
23
+ results,
24
+ error,
25
+ hasMore,
26
+ page,
27
+ }: {
28
+ query: string;
29
+ results: SearchResult[];
30
+ error: string | null;
31
+ hasMore: boolean;
32
+ page: number;
33
+ }) {
34
+ const { t } = useLingui();
35
+ const searchTitle = t({ message: "Search", comment: "@context: Search page title" });
36
+
37
+ return (
38
+ <div class="container py-8 max-w-2xl">
39
+ <h1 class="text-2xl font-semibold mb-6">{searchTitle}</h1>
40
+
41
+ {/* Search form */}
42
+ <form method="get" action="/search" class="mb-8">
43
+ <div class="flex gap-2">
44
+ <input
45
+ type="search"
46
+ name="q"
47
+ class="input flex-1"
48
+ placeholder={t({ message: "Search posts...", comment: "@context: Search input placeholder" })}
49
+ value={query}
50
+ autofocus
51
+ />
52
+ <button type="submit" class="btn">
53
+ {t({ message: "Search", comment: "@context: Search submit button" })}
54
+ </button>
55
+ </div>
56
+ </form>
57
+
58
+ {/* Error */}
59
+ {error && (
60
+ <div class="p-4 rounded-lg bg-destructive/10 text-destructive mb-6">
61
+ {error}
62
+ </div>
63
+ )}
64
+
65
+ {/* Results */}
66
+ {query && !error && (
67
+ <div>
68
+ <p class="text-sm text-muted-foreground mb-4">
69
+ {results.length === 0
70
+ ? t({ message: "No results found.", comment: "@context: Search empty results" })
71
+ : results.length === 1
72
+ ? t({ message: "Found 1 result", comment: "@context: Search results count - single" })
73
+ : t({ message: "Found {count} results", comment: "@context: Search results count - multiple", values: { count: String(results.length) } })}
74
+ </p>
75
+
76
+ {results.length > 0 && (
77
+ <>
78
+ <div class="flex flex-col gap-4">
79
+ {results.map((result) => (
80
+ <article key={result.post.id} class="p-4 rounded-lg border hover:border-primary">
81
+ <a href={`/p/${sqid.encode(result.post.id)}`} class="block">
82
+ <h2 class="font-medium hover:underline">
83
+ {result.post.title ||
84
+ result.post.content?.slice(0, 60) ||
85
+ `Post #${result.post.id}`}
86
+ </h2>
87
+
88
+ {result.snippet && (
89
+ <p
90
+ class="text-sm text-muted-foreground mt-2 line-clamp-2"
91
+ dangerouslySetInnerHTML={{ __html: result.snippet }}
92
+ />
93
+ )}
94
+
95
+ <footer class="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
96
+ <span class="badge-outline">{result.post.type}</span>
97
+ <time datetime={time.toISOString(result.post.publishedAt)}>
98
+ {time.formatDate(result.post.publishedAt)}
99
+ </time>
100
+ </footer>
101
+ </a>
102
+ </article>
103
+ ))}
104
+ </div>
105
+
106
+ <PagePagination
107
+ baseUrl={`/search?q=${encodeURIComponent(query)}`}
108
+ currentPage={page}
109
+ hasMore={hasMore}
110
+ />
111
+ </>
112
+ )}
113
+ </div>
114
+ )}
115
+
116
+ <nav class="mt-8 pt-6 border-t">
117
+ <a href="/" class="text-sm hover:underline">
118
+ ← {t({ message: "Back to home", comment: "@context: Navigation link back to home page" })}
119
+ </a>
120
+ </nav>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ searchRoutes.get("/", async (c) => {
126
+ const query = c.req.query("q") || "";
127
+ const pageParam = c.req.query("page");
128
+ const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
129
+
130
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
131
+
132
+ // Only search if there's a query
133
+ let results: Awaited<ReturnType<typeof c.var.services.search.search>> = [];
134
+ let error: string | null = null;
135
+ let hasMore = false;
136
+
137
+ if (query.trim()) {
138
+ try {
139
+ // Fetch one extra to check for more
140
+ results = await c.var.services.search.search(query, {
141
+ limit: PAGE_SIZE + 1,
142
+ offset: (page - 1) * PAGE_SIZE,
143
+ visibility: ["featured", "quiet"],
144
+ });
145
+
146
+ hasMore = results.length > PAGE_SIZE;
147
+ if (hasMore) {
148
+ results = results.slice(0, PAGE_SIZE);
149
+ }
150
+ } catch (err) {
151
+ // eslint-disable-next-line no-console -- Error logging is intentional
152
+ console.error("Search error:", err);
153
+ error = "Search failed. Please try again.";
154
+ }
155
+ }
156
+
157
+ return c.html(
158
+ <BaseLayout title={query ? `Search: ${query} - ${siteName}` : `Search - ${siteName}`} c={c}>
159
+ <SearchContent query={query} results={results} error={error} hasMore={hasMore} page={page} />
160
+ </BaseLayout>
161
+ );
162
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Collection Service
3
+ *
4
+ * Manages collections and post-collection relationships
5
+ */
6
+
7
+ import { eq, desc, and } from "drizzle-orm";
8
+ import type { Database } from "../db/index.js";
9
+ import { collections, postCollections, posts } from "../db/schema.js";
10
+ import { now } from "../lib/time.js";
11
+ import type { Collection, Post } from "../types.js";
12
+
13
+ export interface CollectionService {
14
+ getById(id: number): Promise<Collection | null>;
15
+ getByPath(path: string): Promise<Collection | null>;
16
+ list(): Promise<Collection[]>;
17
+ create(data: CreateCollectionData): Promise<Collection>;
18
+ update(id: number, data: UpdateCollectionData): Promise<Collection | null>;
19
+ delete(id: number): Promise<boolean>;
20
+ addPost(collectionId: number, postId: number): Promise<void>;
21
+ removePost(collectionId: number, postId: number): Promise<void>;
22
+ getPosts(collectionId: number): Promise<Post[]>;
23
+ getCollectionsForPost(postId: number): Promise<Collection[]>;
24
+ }
25
+
26
+ export interface CreateCollectionData {
27
+ title: string;
28
+ path?: string;
29
+ description?: string;
30
+ }
31
+
32
+ export interface UpdateCollectionData {
33
+ title?: string;
34
+ path?: string | null;
35
+ description?: string;
36
+ }
37
+
38
+ export function createCollectionService(db: Database): CollectionService {
39
+ function toCollection(row: typeof collections.$inferSelect): Collection {
40
+ return {
41
+ id: row.id,
42
+ title: row.title,
43
+ path: row.path,
44
+ description: row.description,
45
+ createdAt: row.createdAt,
46
+ updatedAt: row.updatedAt,
47
+ };
48
+ }
49
+
50
+ function toPost(row: typeof posts.$inferSelect): Post {
51
+ return {
52
+ id: row.id,
53
+ type: row.type as Post["type"],
54
+ visibility: row.visibility as Post["visibility"],
55
+ title: row.title,
56
+ path: row.path,
57
+ content: row.content,
58
+ contentHtml: row.contentHtml,
59
+ sourceUrl: row.sourceUrl,
60
+ sourceName: row.sourceName,
61
+ sourceDomain: row.sourceDomain,
62
+ replyToId: row.replyToId,
63
+ threadId: row.threadId,
64
+ deletedAt: row.deletedAt,
65
+ publishedAt: row.publishedAt,
66
+ createdAt: row.createdAt,
67
+ updatedAt: row.updatedAt,
68
+ };
69
+ }
70
+
71
+ return {
72
+ async getById(id) {
73
+ const result = await db.select().from(collections).where(eq(collections.id, id)).limit(1);
74
+ return result[0] ? toCollection(result[0]) : null;
75
+ },
76
+
77
+ async getByPath(path) {
78
+ const result = await db
79
+ .select()
80
+ .from(collections)
81
+ .where(eq(collections.path, path))
82
+ .limit(1);
83
+ return result[0] ? toCollection(result[0]) : null;
84
+ },
85
+
86
+ async list() {
87
+ const rows = await db.select().from(collections).orderBy(desc(collections.createdAt));
88
+ return rows.map(toCollection);
89
+ },
90
+
91
+ async create(data) {
92
+ const timestamp = now();
93
+
94
+ const result = await db
95
+ .insert(collections)
96
+ .values({
97
+ title: data.title,
98
+ path: data.path || null,
99
+ description: data.description ?? null,
100
+ createdAt: timestamp,
101
+ updatedAt: timestamp,
102
+ })
103
+ .returning();
104
+
105
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
106
+ return toCollection(result[0]!);
107
+ },
108
+
109
+ async update(id, data) {
110
+ const existing = await this.getById(id);
111
+ if (!existing) return null;
112
+
113
+ const timestamp = now();
114
+ const updates: Partial<typeof collections.$inferInsert> = { updatedAt: timestamp };
115
+
116
+ if (data.title !== undefined) updates.title = data.title;
117
+ if (data.path !== undefined) updates.path = data.path;
118
+ if (data.description !== undefined) updates.description = data.description;
119
+
120
+ const result = await db
121
+ .update(collections)
122
+ .set(updates)
123
+ .where(eq(collections.id, id))
124
+ .returning();
125
+
126
+ return result[0] ? toCollection(result[0]) : null;
127
+ },
128
+
129
+ async delete(id) {
130
+ // Delete all post-collection relationships first
131
+ await db.delete(postCollections).where(eq(postCollections.collectionId, id));
132
+
133
+ const result = await db.delete(collections).where(eq(collections.id, id)).returning();
134
+ return result.length > 0;
135
+ },
136
+
137
+ async addPost(collectionId, postId) {
138
+ const timestamp = now();
139
+
140
+ // Upsert the relationship
141
+ await db
142
+ .insert(postCollections)
143
+ .values({
144
+ postId,
145
+ collectionId,
146
+ addedAt: timestamp,
147
+ })
148
+ .onConflictDoNothing();
149
+ },
150
+
151
+ async removePost(collectionId, postId) {
152
+ await db
153
+ .delete(postCollections)
154
+ .where(
155
+ and(eq(postCollections.collectionId, collectionId), eq(postCollections.postId, postId))
156
+ );
157
+ },
158
+
159
+ async getPosts(collectionId) {
160
+ const rows = await db
161
+ .select({ post: posts })
162
+ .from(postCollections)
163
+ .innerJoin(posts, eq(postCollections.postId, posts.id))
164
+ .where(eq(postCollections.collectionId, collectionId))
165
+ .orderBy(desc(postCollections.addedAt));
166
+
167
+ return rows.map((r) => toPost(r.post));
168
+ },
169
+
170
+ async getCollectionsForPost(postId) {
171
+ const rows = await db
172
+ .select({ collection: collections })
173
+ .from(postCollections)
174
+ .innerJoin(collections, eq(postCollections.collectionId, collections.id))
175
+ .where(eq(postCollections.postId, postId));
176
+
177
+ return rows.map((r) => toCollection(r.collection));
178
+ },
179
+ };
180
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Services
3
+ *
4
+ * Business logic layer
5
+ */
6
+
7
+ import type { Database } from "../db/index.js";
8
+ import { createSettingsService, type SettingsService } from "./settings.js";
9
+ import { createPostService, type PostService } from "./post.js";
10
+ import { createRedirectService, type RedirectService } from "./redirect.js";
11
+ import { createMediaService, type MediaService } from "./media.js";
12
+ import { createCollectionService, type CollectionService } from "./collection.js";
13
+ import { createSearchService, type SearchService } from "./search.js";
14
+
15
+ export interface Services {
16
+ settings: SettingsService;
17
+ posts: PostService;
18
+ redirects: RedirectService;
19
+ media: MediaService;
20
+ collections: CollectionService;
21
+ search: SearchService;
22
+ }
23
+
24
+ export function createServices(db: Database, d1: D1Database): Services {
25
+ return {
26
+ settings: createSettingsService(db),
27
+ posts: createPostService(db),
28
+ redirects: createRedirectService(db),
29
+ media: createMediaService(db),
30
+ collections: createCollectionService(db),
31
+ search: createSearchService(d1),
32
+ };
33
+ }
34
+
35
+ export type { SettingsService } from "./settings.js";
36
+ export type { PostService, PostFilters } from "./post.js";
37
+ export type { RedirectService } from "./redirect.js";
38
+ export type { MediaService } from "./media.js";
39
+ export type { CollectionService } from "./collection.js";
40
+ export type { SearchService, SearchResult, SearchOptions } from "./search.js";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Media Service
3
+ *
4
+ * Handles media upload and management with R2 storage
5
+ */
6
+
7
+ import { eq, desc } from "drizzle-orm";
8
+ import { uuidv7 } from "uuidv7";
9
+ import type { Database } from "../db/index.js";
10
+ import { media } from "../db/schema.js";
11
+ import { now } from "../lib/time.js";
12
+ import type { Media } from "../types.js";
13
+
14
+ export interface MediaService {
15
+ getById(id: string): Promise<Media | null>;
16
+ list(limit?: number): Promise<Media[]>;
17
+ create(data: CreateMediaData): Promise<Media>;
18
+ delete(id: string): Promise<boolean>;
19
+ getByR2Key(r2Key: string): Promise<Media | null>;
20
+ }
21
+
22
+ export interface CreateMediaData {
23
+ postId?: number;
24
+ filename: string;
25
+ originalName: string;
26
+ mimeType: string;
27
+ size: number;
28
+ r2Key: string;
29
+ width?: number;
30
+ height?: number;
31
+ alt?: string;
32
+ }
33
+
34
+ export function createMediaService(db: Database): MediaService {
35
+ function toMedia(row: typeof media.$inferSelect): Media {
36
+ return {
37
+ id: row.id,
38
+ postId: row.postId,
39
+ filename: row.filename,
40
+ originalName: row.originalName,
41
+ mimeType: row.mimeType,
42
+ size: row.size,
43
+ r2Key: row.r2Key,
44
+ width: row.width,
45
+ height: row.height,
46
+ alt: row.alt,
47
+ createdAt: row.createdAt,
48
+ };
49
+ }
50
+
51
+ return {
52
+ async getById(id) {
53
+ const result = await db.select().from(media).where(eq(media.id, id)).limit(1);
54
+ return result[0] ? toMedia(result[0]) : null;
55
+ },
56
+
57
+ async getByR2Key(r2Key) {
58
+ const result = await db.select().from(media).where(eq(media.r2Key, r2Key)).limit(1);
59
+ return result[0] ? toMedia(result[0]) : null;
60
+ },
61
+
62
+ async list(limit = 100) {
63
+ const rows = await db.select().from(media).orderBy(desc(media.createdAt)).limit(limit);
64
+ return rows.map(toMedia);
65
+ },
66
+
67
+ async create(data) {
68
+ const id = uuidv7();
69
+ const timestamp = now();
70
+
71
+ const result = await db
72
+ .insert(media)
73
+ .values({
74
+ id,
75
+ postId: data.postId ?? null,
76
+ filename: data.filename,
77
+ originalName: data.originalName,
78
+ mimeType: data.mimeType,
79
+ size: data.size,
80
+ r2Key: data.r2Key,
81
+ width: data.width ?? null,
82
+ height: data.height ?? null,
83
+ alt: data.alt ?? null,
84
+ createdAt: timestamp,
85
+ })
86
+ .returning();
87
+
88
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
89
+ return toMedia(result[0]!);
90
+ },
91
+
92
+ async delete(id) {
93
+ const result = await db.delete(media).where(eq(media.id, id)).returning();
94
+ return result.length > 0;
95
+ },
96
+ };
97
+ }