@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.
- package/bin/jant.js +188 -0
- package/drizzle.config.ts +10 -0
- package/lingui.config.ts +16 -0
- package/package.json +116 -0
- package/src/app.tsx +377 -0
- package/src/assets/datastar.min.js +8 -0
- package/src/auth.ts +38 -0
- package/src/client.ts +6 -0
- package/src/db/index.ts +14 -0
- package/src/db/migrations/0000_solid_moon_knight.sql +118 -0
- package/src/db/migrations/0001_add_search_fts.sql +40 -0
- package/src/db/migrations/0002_collection_path.sql +2 -0
- package/src/db/migrations/0003_collection_path_nullable.sql +21 -0
- package/src/db/migrations/0004_media_uuid.sql +35 -0
- package/src/db/migrations/meta/0000_snapshot.json +784 -0
- package/src/db/migrations/meta/_journal.json +41 -0
- package/src/db/schema.ts +159 -0
- package/src/i18n/EXAMPLES.md +235 -0
- package/src/i18n/README.md +296 -0
- package/src/i18n/Trans.tsx +31 -0
- package/src/i18n/context.tsx +101 -0
- package/src/i18n/detect.ts +100 -0
- package/src/i18n/i18n.ts +62 -0
- package/src/i18n/index.ts +65 -0
- package/src/i18n/locales/en.po +875 -0
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/zh-Hans.po +875 -0
- package/src/i18n/locales/zh-Hans.ts +1 -0
- package/src/i18n/locales/zh-Hant.po +875 -0
- package/src/i18n/locales/zh-Hant.ts +1 -0
- package/src/i18n/locales.ts +14 -0
- package/src/i18n/middleware.ts +59 -0
- package/src/index.ts +42 -0
- package/src/lib/assets.ts +47 -0
- package/src/lib/constants.ts +67 -0
- package/src/lib/image.ts +107 -0
- package/src/lib/index.ts +9 -0
- package/src/lib/markdown.ts +93 -0
- package/src/lib/schemas.ts +92 -0
- package/src/lib/sqid.ts +79 -0
- package/src/lib/sse.ts +152 -0
- package/src/lib/time.ts +117 -0
- package/src/lib/url.ts +107 -0
- package/src/middleware/auth.ts +59 -0
- package/src/routes/api/posts.ts +127 -0
- package/src/routes/api/search.ts +53 -0
- package/src/routes/api/upload.ts +240 -0
- package/src/routes/dash/collections.tsx +341 -0
- package/src/routes/dash/index.tsx +89 -0
- package/src/routes/dash/media.tsx +551 -0
- package/src/routes/dash/pages.tsx +245 -0
- package/src/routes/dash/posts.tsx +202 -0
- package/src/routes/dash/redirects.tsx +155 -0
- package/src/routes/dash/settings.tsx +93 -0
- package/src/routes/feed/rss.ts +119 -0
- package/src/routes/feed/sitemap.ts +75 -0
- package/src/routes/pages/archive.tsx +223 -0
- package/src/routes/pages/collection.tsx +79 -0
- package/src/routes/pages/home.tsx +93 -0
- package/src/routes/pages/page.tsx +64 -0
- package/src/routes/pages/post.tsx +81 -0
- package/src/routes/pages/search.tsx +162 -0
- package/src/services/collection.ts +180 -0
- package/src/services/index.ts +40 -0
- package/src/services/media.ts +97 -0
- package/src/services/post.ts +279 -0
- package/src/services/redirect.ts +74 -0
- package/src/services/search.ts +117 -0
- package/src/services/settings.ts +76 -0
- package/src/theme/components/ActionButtons.tsx +98 -0
- package/src/theme/components/CrudPageHeader.tsx +48 -0
- package/src/theme/components/DangerZone.tsx +77 -0
- package/src/theme/components/EmptyState.tsx +56 -0
- package/src/theme/components/ListItemRow.tsx +24 -0
- package/src/theme/components/PageForm.tsx +114 -0
- package/src/theme/components/Pagination.tsx +196 -0
- package/src/theme/components/PostForm.tsx +122 -0
- package/src/theme/components/PostList.tsx +68 -0
- package/src/theme/components/ThreadView.tsx +118 -0
- package/src/theme/components/TypeBadge.tsx +28 -0
- package/src/theme/components/VisibilityBadge.tsx +33 -0
- package/src/theme/components/index.ts +12 -0
- package/src/theme/index.ts +24 -0
- package/src/theme/layouts/BaseLayout.tsx +49 -0
- package/src/theme/layouts/DashLayout.tsx +108 -0
- package/src/theme/layouts/index.ts +2 -0
- package/src/theme/styles/main.css +52 -0
- package/src/types.ts +222 -0
- package/static/assets/datastar.min.js +7 -0
- package/static/assets/image-processor.js +234 -0
- package/tsconfig.json +16 -0
- package/vite.config.ts +82 -0
- 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
|
+
}
|