@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
package/src/services/index.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
type CollectionService,
|
|
15
15
|
} from "./collection.js";
|
|
16
16
|
import { createSearchService, type SearchService } from "./search.js";
|
|
17
|
+
import {
|
|
18
|
+
createNavigationLinkService,
|
|
19
|
+
type NavigationLinkService,
|
|
20
|
+
} from "./navigation.js";
|
|
17
21
|
|
|
18
22
|
export interface Services {
|
|
19
23
|
settings: SettingsService;
|
|
@@ -22,6 +26,7 @@ export interface Services {
|
|
|
22
26
|
media: MediaService;
|
|
23
27
|
collections: CollectionService;
|
|
24
28
|
search: SearchService;
|
|
29
|
+
navigationLinks: NavigationLinkService;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function createServices(db: Database, d1: D1Database): Services {
|
|
@@ -32,6 +37,7 @@ export function createServices(db: Database, d1: D1Database): Services {
|
|
|
32
37
|
media: createMediaService(db),
|
|
33
38
|
collections: createCollectionService(db),
|
|
34
39
|
search: createSearchService(d1),
|
|
40
|
+
navigationLinks: createNavigationLinkService(db),
|
|
35
41
|
};
|
|
36
42
|
}
|
|
37
43
|
|
|
@@ -41,3 +47,4 @@ export type { RedirectService } from "./redirect.js";
|
|
|
41
47
|
export type { MediaService } from "./media.js";
|
|
42
48
|
export type { CollectionService } from "./collection.js";
|
|
43
49
|
export type { SearchService, SearchResult, SearchOptions } from "./search.js";
|
|
50
|
+
export type { NavigationLinkService } from "./navigation.js";
|
package/src/services/media.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media Service
|
|
3
3
|
*
|
|
4
|
-
* Handles media upload and management with
|
|
4
|
+
* Handles media upload and management with pluggable storage backends.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { eq, desc, inArray, asc } from "drizzle-orm";
|
|
@@ -19,18 +19,20 @@ export interface MediaService {
|
|
|
19
19
|
list(limit?: number): Promise<Media[]>;
|
|
20
20
|
create(data: CreateMediaData): Promise<Media>;
|
|
21
21
|
delete(id: string): Promise<boolean>;
|
|
22
|
-
|
|
22
|
+
getByStorageKey(storageKey: string): Promise<Media | null>;
|
|
23
23
|
attachToPost(postId: number, mediaIds: string[]): Promise<void>;
|
|
24
24
|
detachFromPost(postId: number): Promise<void>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export interface CreateMediaData {
|
|
28
|
+
id?: string;
|
|
28
29
|
postId?: number;
|
|
29
30
|
filename: string;
|
|
30
31
|
originalName: string;
|
|
31
32
|
mimeType: string;
|
|
32
33
|
size: number;
|
|
33
|
-
|
|
34
|
+
storageKey: string;
|
|
35
|
+
provider?: string;
|
|
34
36
|
width?: number;
|
|
35
37
|
height?: number;
|
|
36
38
|
alt?: string;
|
|
@@ -47,7 +49,8 @@ export function createMediaService(db: Database): MediaService {
|
|
|
47
49
|
originalName: row.originalName,
|
|
48
50
|
mimeType: row.mimeType,
|
|
49
51
|
size: row.size,
|
|
50
|
-
|
|
52
|
+
storageKey: row.storageKey,
|
|
53
|
+
provider: row.provider,
|
|
51
54
|
width: row.width,
|
|
52
55
|
height: row.height,
|
|
53
56
|
alt: row.alt,
|
|
@@ -106,11 +109,11 @@ export function createMediaService(db: Database): MediaService {
|
|
|
106
109
|
return result;
|
|
107
110
|
},
|
|
108
111
|
|
|
109
|
-
async
|
|
112
|
+
async getByStorageKey(storageKey) {
|
|
110
113
|
const result = await db
|
|
111
114
|
.select()
|
|
112
115
|
.from(media)
|
|
113
|
-
.where(eq(media.
|
|
116
|
+
.where(eq(media.storageKey, storageKey))
|
|
114
117
|
.limit(1);
|
|
115
118
|
return result[0] ? toMedia(result[0]) : null;
|
|
116
119
|
},
|
|
@@ -125,7 +128,7 @@ export function createMediaService(db: Database): MediaService {
|
|
|
125
128
|
},
|
|
126
129
|
|
|
127
130
|
async create(data) {
|
|
128
|
-
const id = uuidv7();
|
|
131
|
+
const id = data.id ?? uuidv7();
|
|
129
132
|
const timestamp = now();
|
|
130
133
|
|
|
131
134
|
const result = await db
|
|
@@ -137,7 +140,8 @@ export function createMediaService(db: Database): MediaService {
|
|
|
137
140
|
originalName: data.originalName,
|
|
138
141
|
mimeType: data.mimeType,
|
|
139
142
|
size: data.size,
|
|
140
|
-
|
|
143
|
+
storageKey: data.storageKey,
|
|
144
|
+
provider: data.provider ?? "r2",
|
|
141
145
|
width: data.width ?? null,
|
|
142
146
|
height: data.height ?? null,
|
|
143
147
|
alt: data.alt ?? null,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Link Service
|
|
3
|
+
*
|
|
4
|
+
* Manages navigation links displayed on public pages
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { eq, asc, sql } from "drizzle-orm";
|
|
8
|
+
import type { Database } from "../db/index.js";
|
|
9
|
+
import { navigationLinks } from "../db/schema.js";
|
|
10
|
+
import { now } from "../lib/time.js";
|
|
11
|
+
import type {
|
|
12
|
+
NavigationLink,
|
|
13
|
+
CreateNavigationLink,
|
|
14
|
+
UpdateNavigationLink,
|
|
15
|
+
} from "../types.js";
|
|
16
|
+
|
|
17
|
+
export interface NavigationLinkService {
|
|
18
|
+
list(): Promise<NavigationLink[]>;
|
|
19
|
+
getById(id: number): Promise<NavigationLink | null>;
|
|
20
|
+
create(data: CreateNavigationLink): Promise<NavigationLink>;
|
|
21
|
+
update(
|
|
22
|
+
id: number,
|
|
23
|
+
data: UpdateNavigationLink,
|
|
24
|
+
): Promise<NavigationLink | null>;
|
|
25
|
+
delete(id: number): Promise<boolean>;
|
|
26
|
+
reorder(ids: number[]): Promise<void>;
|
|
27
|
+
ensureDefaults(): Promise<NavigationLink[]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createNavigationLinkService(
|
|
31
|
+
db: Database,
|
|
32
|
+
): NavigationLinkService {
|
|
33
|
+
function toNavigationLink(
|
|
34
|
+
row: typeof navigationLinks.$inferSelect,
|
|
35
|
+
): NavigationLink {
|
|
36
|
+
return {
|
|
37
|
+
id: row.id,
|
|
38
|
+
label: row.label,
|
|
39
|
+
url: row.url,
|
|
40
|
+
position: row.position,
|
|
41
|
+
createdAt: row.createdAt,
|
|
42
|
+
updatedAt: row.updatedAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
async list() {
|
|
48
|
+
const rows = await db
|
|
49
|
+
.select()
|
|
50
|
+
.from(navigationLinks)
|
|
51
|
+
.orderBy(asc(navigationLinks.position));
|
|
52
|
+
return rows.map(toNavigationLink);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async getById(id) {
|
|
56
|
+
const result = await db
|
|
57
|
+
.select()
|
|
58
|
+
.from(navigationLinks)
|
|
59
|
+
.where(eq(navigationLinks.id, id))
|
|
60
|
+
.limit(1);
|
|
61
|
+
return result[0] ? toNavigationLink(result[0]) : null;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async create(data) {
|
|
65
|
+
const timestamp = now();
|
|
66
|
+
|
|
67
|
+
let position = data.position;
|
|
68
|
+
if (position === undefined) {
|
|
69
|
+
const maxResult = await db
|
|
70
|
+
.select({ maxPos: sql<number>`COALESCE(MAX(position), -1)` })
|
|
71
|
+
.from(navigationLinks);
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- aggregate always returns one row
|
|
73
|
+
position = maxResult[0]!.maxPos + 1;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await db
|
|
77
|
+
.insert(navigationLinks)
|
|
78
|
+
.values({
|
|
79
|
+
label: data.label,
|
|
80
|
+
url: data.url,
|
|
81
|
+
position,
|
|
82
|
+
createdAt: timestamp,
|
|
83
|
+
updatedAt: timestamp,
|
|
84
|
+
})
|
|
85
|
+
.returning();
|
|
86
|
+
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
88
|
+
return toNavigationLink(result[0]!);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async update(id, data) {
|
|
92
|
+
const existing = await db
|
|
93
|
+
.select()
|
|
94
|
+
.from(navigationLinks)
|
|
95
|
+
.where(eq(navigationLinks.id, id))
|
|
96
|
+
.limit(1);
|
|
97
|
+
if (!existing[0]) return null;
|
|
98
|
+
|
|
99
|
+
const timestamp = now();
|
|
100
|
+
const result = await db
|
|
101
|
+
.update(navigationLinks)
|
|
102
|
+
.set({
|
|
103
|
+
...(data.label !== undefined && { label: data.label }),
|
|
104
|
+
...(data.url !== undefined && { url: data.url }),
|
|
105
|
+
...(data.position !== undefined && { position: data.position }),
|
|
106
|
+
updatedAt: timestamp,
|
|
107
|
+
})
|
|
108
|
+
.where(eq(navigationLinks.id, id))
|
|
109
|
+
.returning();
|
|
110
|
+
|
|
111
|
+
return result[0] ? toNavigationLink(result[0]) : null;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async delete(id) {
|
|
115
|
+
const result = await db
|
|
116
|
+
.delete(navigationLinks)
|
|
117
|
+
.where(eq(navigationLinks.id, id))
|
|
118
|
+
.returning();
|
|
119
|
+
return result.length > 0;
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
async reorder(ids) {
|
|
123
|
+
const timestamp = now();
|
|
124
|
+
for (let i = 0; i < ids.length; i++) {
|
|
125
|
+
await db
|
|
126
|
+
.update(navigationLinks)
|
|
127
|
+
.set({ position: i, updatedAt: timestamp })
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loop index guarantees element exists
|
|
129
|
+
.where(eq(navigationLinks.id, ids[i]!));
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async ensureDefaults() {
|
|
134
|
+
const existing = await db.select().from(navigationLinks).limit(1);
|
|
135
|
+
if (existing.length > 0) {
|
|
136
|
+
const rows = await db
|
|
137
|
+
.select()
|
|
138
|
+
.from(navigationLinks)
|
|
139
|
+
.orderBy(asc(navigationLinks.position));
|
|
140
|
+
return rows.map(toNavigationLink);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const timestamp = now();
|
|
144
|
+
const defaults = [
|
|
145
|
+
{ label: "Home", url: "/", position: 0 },
|
|
146
|
+
{ label: "Archive", url: "/archive", position: 1 },
|
|
147
|
+
{ label: "RSS", url: "/feed", position: 2 },
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
for (const link of defaults) {
|
|
151
|
+
await db.insert(navigationLinks).values({
|
|
152
|
+
...link,
|
|
153
|
+
createdAt: timestamp,
|
|
154
|
+
updatedAt: timestamp,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const rows = await db
|
|
159
|
+
.select()
|
|
160
|
+
.from(navigationLinks)
|
|
161
|
+
.orderBy(asc(navigationLinks.position));
|
|
162
|
+
return rows.map(toNavigationLink);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
package/src/services/post.ts
CHANGED
|
@@ -4,7 +4,16 @@
|
|
|
4
4
|
* CRUD operations for posts with Thread support
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
eq,
|
|
9
|
+
and,
|
|
10
|
+
isNull,
|
|
11
|
+
desc,
|
|
12
|
+
or,
|
|
13
|
+
inArray,
|
|
14
|
+
notInArray,
|
|
15
|
+
sql,
|
|
16
|
+
} from "drizzle-orm";
|
|
8
17
|
import type { Database } from "../db/index.js";
|
|
9
18
|
import { posts } from "../db/schema.js";
|
|
10
19
|
import { now } from "../lib/time.js";
|
|
@@ -20,6 +29,8 @@ import type {
|
|
|
20
29
|
|
|
21
30
|
export interface PostFilters {
|
|
22
31
|
type?: PostType;
|
|
32
|
+
/** Exclude specific post types (e.g. ["page"]) */
|
|
33
|
+
excludeTypes?: PostType[];
|
|
23
34
|
visibility?: Visibility | Visibility[];
|
|
24
35
|
includeDeleted?: boolean;
|
|
25
36
|
threadId?: number;
|
|
@@ -40,6 +51,11 @@ export interface PostService {
|
|
|
40
51
|
updateThreadVisibility(rootId: number, visibility: Visibility): Promise<void>;
|
|
41
52
|
/** Get reply counts for multiple posts */
|
|
42
53
|
getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
|
|
54
|
+
/** Get preview replies for multiple thread roots */
|
|
55
|
+
getThreadPreviews(
|
|
56
|
+
rootIds: number[],
|
|
57
|
+
previewCount?: number,
|
|
58
|
+
): Promise<Map<number, Post[]>>;
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
export function createPostService(db: Database): PostService {
|
|
@@ -101,6 +117,11 @@ export function createPostService(db: Database): PostService {
|
|
|
101
117
|
conditions.push(eq(posts.type, filters.type));
|
|
102
118
|
}
|
|
103
119
|
|
|
120
|
+
// Exclude types filter
|
|
121
|
+
if (filters.excludeTypes && filters.excludeTypes.length > 0) {
|
|
122
|
+
conditions.push(notInArray(posts.type, filters.excludeTypes));
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
// Thread filter
|
|
105
126
|
if (filters.threadId) {
|
|
106
127
|
conditions.push(eq(posts.threadId, filters.threadId));
|
|
@@ -302,5 +323,31 @@ export function createPostService(db: Database): PostService {
|
|
|
302
323
|
}
|
|
303
324
|
return counts;
|
|
304
325
|
},
|
|
326
|
+
|
|
327
|
+
async getThreadPreviews(rootIds, previewCount = 3) {
|
|
328
|
+
if (rootIds.length === 0) return new Map();
|
|
329
|
+
|
|
330
|
+
const rows = await db
|
|
331
|
+
.select()
|
|
332
|
+
.from(posts)
|
|
333
|
+
.where(and(inArray(posts.threadId, rootIds), isNull(posts.deletedAt)))
|
|
334
|
+
.orderBy(posts.threadId, posts.createdAt);
|
|
335
|
+
|
|
336
|
+
// Partition by threadId, take first previewCount per thread
|
|
337
|
+
const result = new Map<number, Post[]>();
|
|
338
|
+
for (const row of rows) {
|
|
339
|
+
const post = toPost(row);
|
|
340
|
+
if (post.threadId === null) continue;
|
|
341
|
+
const list = result.get(post.threadId);
|
|
342
|
+
if (list) {
|
|
343
|
+
if (list.length < previewCount) {
|
|
344
|
+
list.push(post);
|
|
345
|
+
}
|
|
346
|
+
} else {
|
|
347
|
+
result.set(post.threadId, [post]);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return result;
|
|
351
|
+
},
|
|
305
352
|
};
|
|
306
353
|
}
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
.container {
|
|
11
11
|
@apply mx-auto max-w-2xl px-4;
|
|
12
12
|
}
|
|
13
|
+
|
|
14
|
+
.container-timeline {
|
|
15
|
+
@apply mx-auto px-4;
|
|
16
|
+
max-width: 600px;
|
|
17
|
+
}
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
/* Alert variants */
|
|
@@ -119,6 +124,60 @@
|
|
|
119
124
|
}
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
/* Timeline cards */
|
|
128
|
+
@layer components {
|
|
129
|
+
.timeline-card {
|
|
130
|
+
@apply rounded-lg border p-4;
|
|
131
|
+
border-color: var(--color-border);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.timeline-card-link {
|
|
135
|
+
border-left-width: 4px;
|
|
136
|
+
border-left-color: var(--color-primary);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.timeline-card-quote {
|
|
140
|
+
border-left-width: 4px;
|
|
141
|
+
border-left-color: var(--color-muted-foreground);
|
|
142
|
+
background-color: var(--color-muted);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.timeline-card-image {
|
|
146
|
+
@apply p-0 overflow-hidden;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.timeline-card-image-gallery {
|
|
150
|
+
/* Remove default margin from MediaGallery inside image cards */
|
|
151
|
+
> div {
|
|
152
|
+
@apply mt-0;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.timeline-card-compact {
|
|
157
|
+
@apply p-3 text-sm;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.timeline-thread-replies {
|
|
161
|
+
@apply relative ml-5;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.timeline-thread-reply {
|
|
165
|
+
@apply relative pl-5 mt-3;
|
|
166
|
+
|
|
167
|
+
&::after {
|
|
168
|
+
content: "";
|
|
169
|
+
@apply absolute left-0 top-0 bottom-0 w-px;
|
|
170
|
+
background-color: var(--color-border);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
&::before {
|
|
174
|
+
content: "";
|
|
175
|
+
@apply absolute left-0 top-4 h-px w-4;
|
|
176
|
+
background-color: var(--color-border);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
122
181
|
@keyframes toast-in {
|
|
123
182
|
from {
|
|
124
183
|
opacity: 0;
|
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
import type { FC } from "hono/jsx";
|
|
6
6
|
import type { Post, Media, Collection } from "../../types.js";
|
|
7
7
|
import { useLingui } from "@lingui/react/macro";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
getMediaUrl,
|
|
10
|
+
getImageUrl,
|
|
11
|
+
getPublicUrlForProvider,
|
|
12
|
+
} from "../../lib/image.js";
|
|
9
13
|
|
|
10
14
|
export interface PostFormProps {
|
|
11
15
|
post?: Post;
|
|
@@ -13,6 +17,7 @@ export interface PostFormProps {
|
|
|
13
17
|
mediaAttachments?: Media[];
|
|
14
18
|
r2PublicUrl?: string;
|
|
15
19
|
imageTransformUrl?: string;
|
|
20
|
+
s3PublicUrl?: string;
|
|
16
21
|
collections?: Collection[];
|
|
17
22
|
postCollectionIds?: number[];
|
|
18
23
|
}
|
|
@@ -23,6 +28,7 @@ export const PostForm: FC<PostFormProps> = ({
|
|
|
23
28
|
mediaAttachments,
|
|
24
29
|
r2PublicUrl,
|
|
25
30
|
imageTransformUrl,
|
|
31
|
+
s3PublicUrl,
|
|
26
32
|
collections,
|
|
27
33
|
postCollectionIds,
|
|
28
34
|
}) => {
|
|
@@ -135,7 +141,12 @@ export const PostForm: FC<PostFormProps> = ({
|
|
|
135
141
|
{mediaAttachments && mediaAttachments.length > 0 && (
|
|
136
142
|
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2">
|
|
137
143
|
{mediaAttachments.map((m) => {
|
|
138
|
-
const
|
|
144
|
+
const pUrl = getPublicUrlForProvider(
|
|
145
|
+
m.provider,
|
|
146
|
+
r2PublicUrl,
|
|
147
|
+
s3PublicUrl,
|
|
148
|
+
);
|
|
149
|
+
const url = getMediaUrl(m.id, m.storageKey, pUrl);
|
|
139
150
|
const thumbUrl = getImageUrl(url, imageTransformUrl, {
|
|
140
151
|
width: 150,
|
|
141
152
|
quality: 80,
|
|
@@ -52,6 +52,13 @@ export const PostList: FC<PostListProps> = ({ posts }) => {
|
|
|
52
52
|
message: "View",
|
|
53
53
|
comment: "@context: Button to view post on public site",
|
|
54
54
|
})}
|
|
55
|
+
deleteAction={`/dash/posts/${sqid.encode(post.id)}/delete`}
|
|
56
|
+
deleteConfirm={t({
|
|
57
|
+
message:
|
|
58
|
+
"Are you sure you want to delete this post? This cannot be undone.",
|
|
59
|
+
comment:
|
|
60
|
+
"@context: Confirmation dialog when deleting a post from the list",
|
|
61
|
+
})}
|
|
55
62
|
/>
|
|
56
63
|
}
|
|
57
64
|
>
|
|
@@ -21,3 +21,15 @@ export {
|
|
|
21
21
|
VisibilityBadge,
|
|
22
22
|
type VisibilityBadgeProps,
|
|
23
23
|
} from "./VisibilityBadge.js";
|
|
24
|
+
|
|
25
|
+
// Timeline components
|
|
26
|
+
export {
|
|
27
|
+
NoteCard,
|
|
28
|
+
ArticleCard,
|
|
29
|
+
LinkCard,
|
|
30
|
+
QuoteCard,
|
|
31
|
+
ImageCard,
|
|
32
|
+
ThreadPreview,
|
|
33
|
+
TimelineItem,
|
|
34
|
+
TimelineFeed,
|
|
35
|
+
} from "./timeline/index.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Article Card Component
|
|
3
|
+
*
|
|
4
|
+
* Prominent title + excerpt for type="article" posts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FC } from "hono/jsx";
|
|
8
|
+
import type { TimelineCardProps } from "../../../types.js";
|
|
9
|
+
import * as sqid from "../../../lib/sqid.js";
|
|
10
|
+
import * as time from "../../../lib/time.js";
|
|
11
|
+
|
|
12
|
+
export const ArticleCard: FC<TimelineCardProps> = ({ post, compact }) => {
|
|
13
|
+
const permalink = `/p/${sqid.encode(post.id)}`;
|
|
14
|
+
const excerpt = post.content
|
|
15
|
+
? post.content.length > 160
|
|
16
|
+
? post.content.slice(0, 160) + "..."
|
|
17
|
+
: post.content
|
|
18
|
+
: null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<article
|
|
22
|
+
class={`h-entry timeline-card${compact ? " timeline-card-compact" : ""}`}
|
|
23
|
+
>
|
|
24
|
+
{post.title && (
|
|
25
|
+
<h2
|
|
26
|
+
class={`p-name font-semibold ${compact ? "text-sm" : "text-lg"} mb-1`}
|
|
27
|
+
>
|
|
28
|
+
<a href={permalink} class="u-url hover:underline">
|
|
29
|
+
{post.title}
|
|
30
|
+
</a>
|
|
31
|
+
</h2>
|
|
32
|
+
)}
|
|
33
|
+
{!compact && excerpt && (
|
|
34
|
+
<p class="e-content text-sm text-muted-foreground line-clamp-3">
|
|
35
|
+
{excerpt}
|
|
36
|
+
</p>
|
|
37
|
+
)}
|
|
38
|
+
<footer class="mt-2 text-xs text-muted-foreground">
|
|
39
|
+
<a href={permalink} class="u-url hover:underline">
|
|
40
|
+
<time
|
|
41
|
+
class="dt-published"
|
|
42
|
+
datetime={time.toISOString(post.publishedAt)}
|
|
43
|
+
>
|
|
44
|
+
{time.formatDate(post.publishedAt)}
|
|
45
|
+
</time>
|
|
46
|
+
</a>
|
|
47
|
+
{!compact && (
|
|
48
|
+
<span class="ml-2">
|
|
49
|
+
<a href={permalink} class="hover:underline">
|
|
50
|
+
Read more →
|
|
51
|
+
</a>
|
|
52
|
+
</span>
|
|
53
|
+
)}
|
|
54
|
+
</footer>
|
|
55
|
+
</article>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Card Component
|
|
3
|
+
*
|
|
4
|
+
* Image-first layout for type="image" posts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FC } from "hono/jsx";
|
|
8
|
+
import type { TimelineCardProps } from "../../../types.js";
|
|
9
|
+
import { MediaGallery } from "../MediaGallery.js";
|
|
10
|
+
import * as sqid from "../../../lib/sqid.js";
|
|
11
|
+
import * as time from "../../../lib/time.js";
|
|
12
|
+
|
|
13
|
+
export const ImageCard: FC<TimelineCardProps> = ({ post, compact }) => {
|
|
14
|
+
const permalink = `/p/${sqid.encode(post.id)}`;
|
|
15
|
+
|
|
16
|
+
if (compact) {
|
|
17
|
+
return (
|
|
18
|
+
<article class="h-entry timeline-card timeline-card-compact">
|
|
19
|
+
{post.title && (
|
|
20
|
+
<h2 class="p-name text-sm font-medium mb-1">
|
|
21
|
+
<a href={permalink} class="u-url hover:underline">
|
|
22
|
+
{post.title}
|
|
23
|
+
</a>
|
|
24
|
+
</h2>
|
|
25
|
+
)}
|
|
26
|
+
{post.contentHtml && (
|
|
27
|
+
<div
|
|
28
|
+
class="e-content prose prose-sm text-muted-foreground"
|
|
29
|
+
dangerouslySetInnerHTML={{ __html: post.contentHtml }}
|
|
30
|
+
/>
|
|
31
|
+
)}
|
|
32
|
+
<footer class="mt-1 text-xs text-muted-foreground">
|
|
33
|
+
<a href={permalink} class="u-url hover:underline">
|
|
34
|
+
<time
|
|
35
|
+
class="dt-published"
|
|
36
|
+
datetime={time.toISOString(post.publishedAt)}
|
|
37
|
+
>
|
|
38
|
+
{time.formatDate(post.publishedAt)}
|
|
39
|
+
</time>
|
|
40
|
+
</a>
|
|
41
|
+
</footer>
|
|
42
|
+
</article>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<article class="h-entry timeline-card timeline-card-image">
|
|
48
|
+
{post.mediaAttachments.length > 0 && (
|
|
49
|
+
<div class="timeline-card-image-gallery">
|
|
50
|
+
<MediaGallery attachments={post.mediaAttachments} />
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
<div class="p-4">
|
|
54
|
+
{post.title && (
|
|
55
|
+
<h2 class="p-name font-medium mb-1">
|
|
56
|
+
<a href={permalink} class="u-url hover:underline">
|
|
57
|
+
{post.title}
|
|
58
|
+
</a>
|
|
59
|
+
</h2>
|
|
60
|
+
)}
|
|
61
|
+
{post.contentHtml && (
|
|
62
|
+
<div
|
|
63
|
+
class="e-content prose prose-sm"
|
|
64
|
+
dangerouslySetInnerHTML={{ __html: post.contentHtml }}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
<footer class="mt-2 text-xs text-muted-foreground">
|
|
68
|
+
<a href={permalink} class="u-url hover:underline">
|
|
69
|
+
<time
|
|
70
|
+
class="dt-published"
|
|
71
|
+
datetime={time.toISOString(post.publishedAt)}
|
|
72
|
+
>
|
|
73
|
+
{time.formatDate(post.publishedAt)}
|
|
74
|
+
</time>
|
|
75
|
+
</a>
|
|
76
|
+
</footer>
|
|
77
|
+
</div>
|
|
78
|
+
</article>
|
|
79
|
+
);
|
|
80
|
+
};
|