@jant/core 0.3.6 → 0.3.8
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 -21
- package/dist/client.js +1 -0
- package/dist/db/schema.js +15 -0
- 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/index.js +1 -1
- package/dist/lib/image.js +3 -3
- package/dist/lib/media-helpers.js +43 -0
- package/dist/lib/nav-reorder.js +27 -0
- package/dist/lib/navigation.js +35 -0
- package/dist/lib/schemas.js +32 -2
- package/dist/lib/sse.js +7 -8
- package/dist/lib/theme-components.js +49 -0
- package/dist/routes/api/posts.js +101 -5
- package/dist/routes/api/timeline.js +115 -0
- package/dist/routes/api/upload.js +9 -5
- package/dist/routes/dash/media.js +38 -0
- package/dist/routes/dash/navigation.js +274 -0
- package/dist/routes/dash/posts.js +45 -6
- package/dist/routes/feed/rss.js +10 -1
- package/dist/routes/pages/archive.js +14 -27
- package/dist/routes/pages/collection.js +10 -19
- package/dist/routes/pages/home.js +88 -98
- package/dist/routes/pages/page.js +19 -38
- package/dist/routes/pages/post.js +61 -48
- package/dist/routes/pages/search.js +13 -26
- package/dist/services/collection.js +13 -0
- package/dist/services/index.js +3 -1
- package/dist/services/media.js +55 -2
- package/dist/services/navigation.js +115 -0
- package/dist/services/post.js +26 -1
- package/dist/theme/components/MediaGallery.js +107 -0
- package/dist/theme/components/PostForm.js +158 -2
- package/dist/theme/components/PostList.js +5 -0
- package/dist/theme/components/index.js +3 -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 +27 -0
- package/package.json +3 -2
- package/src/__tests__/helpers/app.ts +6 -1
- package/src/__tests__/helpers/db.ts +20 -0
- package/src/app.tsx +11 -25
- package/src/client.ts +1 -0
- package/src/db/migrations/0002_add_media_attachments.sql +3 -0
- package/src/db/migrations/0003_add_navigation_links.sql +8 -0
- package/src/db/migrations/meta/0003_snapshot.json +821 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +15 -0
- package/src/i18n/locales/en.po +170 -58
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +162 -71
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +162 -71
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +13 -1
- package/src/lib/__tests__/schemas.test.ts +89 -1
- package/src/lib/__tests__/sse.test.ts +13 -1
- package/src/lib/__tests__/theme-components.test.ts +107 -0
- package/src/lib/image.ts +3 -3
- package/src/lib/media-helpers.ts +54 -0
- package/src/lib/nav-reorder.ts +26 -0
- package/src/lib/navigation.ts +46 -0
- package/src/lib/schemas.ts +47 -1
- package/src/lib/sse.ts +10 -11
- package/src/lib/theme-components.ts +76 -0
- package/src/routes/api/__tests__/posts.test.ts +239 -0
- package/src/routes/api/__tests__/timeline.test.ts +242 -0
- package/src/routes/api/posts.ts +134 -5
- package/src/routes/api/timeline.tsx +145 -0
- package/src/routes/api/upload.ts +9 -5
- package/src/routes/dash/media.tsx +50 -0
- package/src/routes/dash/navigation.tsx +306 -0
- package/src/routes/dash/posts.tsx +79 -7
- package/src/routes/feed/rss.ts +14 -1
- package/src/routes/pages/archive.tsx +15 -23
- package/src/routes/pages/collection.tsx +8 -15
- package/src/routes/pages/home.tsx +121 -88
- package/src/routes/pages/page.tsx +17 -30
- package/src/routes/pages/post.tsx +64 -40
- package/src/routes/pages/search.tsx +18 -22
- package/src/services/__tests__/collection.test.ts +102 -0
- package/src/services/__tests__/media.test.ts +282 -7
- package/src/services/__tests__/navigation.test.ts +213 -0
- package/src/services/__tests__/post-timeline.test.ts +220 -0
- package/src/services/collection.ts +19 -0
- package/src/services/index.ts +7 -0
- package/src/services/media.ts +78 -2
- 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/MediaGallery.tsx +128 -0
- package/src/theme/components/PostForm.tsx +170 -2
- package/src/theme/components/PostList.tsx +7 -0
- package/src/theme/components/index.ts +13 -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 +97 -0
- 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 -1507
- 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 -113
- 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 -31
- 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 -27
- 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/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 -11
- 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 -13
- 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 -213
- package/dist/types.d.ts.map +0 -1
|
@@ -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;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media Gallery Component
|
|
3
|
+
*
|
|
4
|
+
* Renders media attachments on public post pages.
|
|
5
|
+
* Layout adapts based on the number of images.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { FC } from "hono/jsx";
|
|
9
|
+
import type { MediaAttachment } from "../../types.js";
|
|
10
|
+
|
|
11
|
+
export interface MediaGalleryProps {
|
|
12
|
+
attachments: MediaAttachment[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const MediaGallery: FC<MediaGalleryProps> = ({ attachments }) => {
|
|
16
|
+
const images = attachments.filter((a) => a.mimeType.startsWith("image/"));
|
|
17
|
+
if (images.length === 0) return null;
|
|
18
|
+
|
|
19
|
+
if (images.length === 1) {
|
|
20
|
+
const [img] = images;
|
|
21
|
+
if (!img) return null;
|
|
22
|
+
return (
|
|
23
|
+
<div class="mt-3">
|
|
24
|
+
<a href={img.url} target="_blank" rel="noopener noreferrer">
|
|
25
|
+
<img
|
|
26
|
+
src={img.previewUrl}
|
|
27
|
+
alt={img.alt || ""}
|
|
28
|
+
width={img.width ?? undefined}
|
|
29
|
+
height={img.height ?? undefined}
|
|
30
|
+
class="rounded-lg max-w-full h-auto"
|
|
31
|
+
loading="lazy"
|
|
32
|
+
/>
|
|
33
|
+
</a>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (images.length === 2) {
|
|
39
|
+
return (
|
|
40
|
+
<div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
|
41
|
+
{images.map((img) => (
|
|
42
|
+
<a
|
|
43
|
+
key={img.id}
|
|
44
|
+
href={img.url}
|
|
45
|
+
target="_blank"
|
|
46
|
+
rel="noopener noreferrer"
|
|
47
|
+
class="aspect-square"
|
|
48
|
+
>
|
|
49
|
+
<img
|
|
50
|
+
src={img.previewUrl}
|
|
51
|
+
alt={img.alt || ""}
|
|
52
|
+
class="w-full h-full object-cover"
|
|
53
|
+
loading="lazy"
|
|
54
|
+
/>
|
|
55
|
+
</a>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (images.length === 3) {
|
|
62
|
+
const [first, ...rest] = images;
|
|
63
|
+
if (!first) return null;
|
|
64
|
+
return (
|
|
65
|
+
<div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
|
66
|
+
<a
|
|
67
|
+
href={first.url}
|
|
68
|
+
target="_blank"
|
|
69
|
+
rel="noopener noreferrer"
|
|
70
|
+
class="row-span-2"
|
|
71
|
+
>
|
|
72
|
+
<img
|
|
73
|
+
src={first.previewUrl}
|
|
74
|
+
alt={first.alt || ""}
|
|
75
|
+
class="w-full h-full object-cover"
|
|
76
|
+
loading="lazy"
|
|
77
|
+
/>
|
|
78
|
+
</a>
|
|
79
|
+
{rest.map((img) => (
|
|
80
|
+
<a
|
|
81
|
+
key={img.id}
|
|
82
|
+
href={img.url}
|
|
83
|
+
target="_blank"
|
|
84
|
+
rel="noopener noreferrer"
|
|
85
|
+
class="aspect-square"
|
|
86
|
+
>
|
|
87
|
+
<img
|
|
88
|
+
src={img.previewUrl}
|
|
89
|
+
alt={img.alt || ""}
|
|
90
|
+
class="w-full h-full object-cover"
|
|
91
|
+
loading="lazy"
|
|
92
|
+
/>
|
|
93
|
+
</a>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 4+ images: 2-column grid, show first 4 with remaining count
|
|
100
|
+
const shown = images.slice(0, 4);
|
|
101
|
+
const remaining = images.length - 4;
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div class="mt-3 grid grid-cols-2 gap-1 rounded-lg overflow-hidden">
|
|
105
|
+
{shown.map((img, i) => (
|
|
106
|
+
<a
|
|
107
|
+
key={img.id}
|
|
108
|
+
href={img.url}
|
|
109
|
+
target="_blank"
|
|
110
|
+
rel="noopener noreferrer"
|
|
111
|
+
class="relative aspect-square"
|
|
112
|
+
>
|
|
113
|
+
<img
|
|
114
|
+
src={img.previewUrl}
|
|
115
|
+
alt={img.alt || ""}
|
|
116
|
+
class="w-full h-full object-cover"
|
|
117
|
+
loading="lazy"
|
|
118
|
+
/>
|
|
119
|
+
{i === 3 && remaining > 0 && (
|
|
120
|
+
<div class="absolute inset-0 bg-black/50 flex items-center justify-center text-white text-xl font-semibold">
|
|
121
|
+
+{remaining}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</a>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
@@ -3,25 +3,44 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { FC } from "hono/jsx";
|
|
6
|
-
import type { Post } from "../../types.js";
|
|
6
|
+
import type { Post, Media, Collection } from "../../types.js";
|
|
7
7
|
import { useLingui } from "@lingui/react/macro";
|
|
8
|
+
import { getMediaUrl, getImageUrl } from "../../lib/image.js";
|
|
8
9
|
|
|
9
10
|
export interface PostFormProps {
|
|
10
11
|
post?: Post;
|
|
11
12
|
action: string;
|
|
13
|
+
mediaAttachments?: Media[];
|
|
14
|
+
r2PublicUrl?: string;
|
|
15
|
+
imageTransformUrl?: string;
|
|
16
|
+
collections?: Collection[];
|
|
17
|
+
postCollectionIds?: number[];
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export const PostForm: FC<PostFormProps> = ({
|
|
20
|
+
export const PostForm: FC<PostFormProps> = ({
|
|
21
|
+
post,
|
|
22
|
+
action,
|
|
23
|
+
mediaAttachments,
|
|
24
|
+
r2PublicUrl,
|
|
25
|
+
imageTransformUrl,
|
|
26
|
+
collections,
|
|
27
|
+
postCollectionIds,
|
|
28
|
+
}) => {
|
|
15
29
|
const { t } = useLingui();
|
|
16
30
|
const isEdit = !!post;
|
|
17
31
|
|
|
32
|
+
const existingMediaIds = (mediaAttachments ?? []).map((m) => m.id);
|
|
33
|
+
|
|
18
34
|
const signals = JSON.stringify({
|
|
19
35
|
type: post?.type ?? "note",
|
|
20
36
|
title: post?.title ?? "",
|
|
21
37
|
content: post?.content ?? "",
|
|
22
38
|
sourceUrl: post?.sourceUrl ?? "",
|
|
39
|
+
sourceName: post?.sourceName ?? "",
|
|
23
40
|
visibility: post?.visibility ?? "quiet",
|
|
24
41
|
path: post?.path ?? "",
|
|
42
|
+
mediaIds: existingMediaIds,
|
|
43
|
+
collectionIds: postCollectionIds ?? [],
|
|
25
44
|
}).replace(/</g, "\\u003c");
|
|
26
45
|
|
|
27
46
|
return (
|
|
@@ -96,6 +115,73 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
|
|
|
96
115
|
</textarea>
|
|
97
116
|
</div>
|
|
98
117
|
|
|
118
|
+
{/* Media attachments */}
|
|
119
|
+
<div class="field" data-show="$type !== 'page'">
|
|
120
|
+
<label class="label">
|
|
121
|
+
{t({
|
|
122
|
+
message: "Media",
|
|
123
|
+
comment: "@context: Post form field - media attachments",
|
|
124
|
+
})}
|
|
125
|
+
</label>
|
|
126
|
+
<p
|
|
127
|
+
class="text-xs text-muted-foreground mb-2"
|
|
128
|
+
data-show="$type === 'image'"
|
|
129
|
+
>
|
|
130
|
+
{t({
|
|
131
|
+
message: "At least 1 image required for image posts.",
|
|
132
|
+
comment: "@context: Hint for image post type media requirement",
|
|
133
|
+
})}
|
|
134
|
+
</p>
|
|
135
|
+
{mediaAttachments && mediaAttachments.length > 0 && (
|
|
136
|
+
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mb-2">
|
|
137
|
+
{mediaAttachments.map((m) => {
|
|
138
|
+
const url = getMediaUrl(m.id, m.r2Key, r2PublicUrl);
|
|
139
|
+
const thumbUrl = getImageUrl(url, imageTransformUrl, {
|
|
140
|
+
width: 150,
|
|
141
|
+
quality: 80,
|
|
142
|
+
format: "auto",
|
|
143
|
+
fit: "cover",
|
|
144
|
+
});
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
key={m.id}
|
|
148
|
+
class="relative group aspect-square"
|
|
149
|
+
data-show={`$mediaIds.includes('${m.id}')`}
|
|
150
|
+
>
|
|
151
|
+
<img
|
|
152
|
+
src={thumbUrl}
|
|
153
|
+
alt={m.alt || m.originalName}
|
|
154
|
+
class="w-full h-full object-cover rounded-lg border"
|
|
155
|
+
loading="lazy"
|
|
156
|
+
/>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
class="absolute top-1 right-1 w-5 h-5 flex items-center justify-center bg-black/60 text-white rounded-full text-xs opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
|
160
|
+
data-on:click={`$mediaIds = $mediaIds.filter(id => id !== '${m.id}')`}
|
|
161
|
+
title={t({
|
|
162
|
+
message: "Remove",
|
|
163
|
+
comment: "@context: Remove media attachment button",
|
|
164
|
+
})}
|
|
165
|
+
>
|
|
166
|
+
×
|
|
167
|
+
</button>
|
|
168
|
+
</div>
|
|
169
|
+
);
|
|
170
|
+
})}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
class="btn-outline text-sm"
|
|
176
|
+
data-on:click="document.getElementById('media-picker-dialog').showModal(); fetch('/dash/media/picker').then(r => r.text()).then(html => document.getElementById('media-picker-grid').innerHTML = html)"
|
|
177
|
+
>
|
|
178
|
+
{t({
|
|
179
|
+
message: "Add Media",
|
|
180
|
+
comment: "@context: Button to open media picker",
|
|
181
|
+
})}
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
|
|
99
185
|
{/* Source URL (for link/quote types) */}
|
|
100
186
|
<div class="field">
|
|
101
187
|
<label class="label">
|
|
@@ -112,6 +198,26 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
|
|
|
112
198
|
/>
|
|
113
199
|
</div>
|
|
114
200
|
|
|
201
|
+
{/* Source Name (for link/quote types) */}
|
|
202
|
+
<div class="field">
|
|
203
|
+
<label class="label">
|
|
204
|
+
{t({
|
|
205
|
+
message: "Source Name (optional)",
|
|
206
|
+
comment:
|
|
207
|
+
"@context: Post form field - name of the source website or author",
|
|
208
|
+
})}
|
|
209
|
+
</label>
|
|
210
|
+
<input
|
|
211
|
+
type="text"
|
|
212
|
+
data-bind="sourceName"
|
|
213
|
+
class="input"
|
|
214
|
+
placeholder={t({
|
|
215
|
+
message: "e.g. The Verge, John Doe",
|
|
216
|
+
comment: "@context: Source name placeholder",
|
|
217
|
+
})}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
115
221
|
{/* Visibility */}
|
|
116
222
|
<div class="field">
|
|
117
223
|
<label class="label">
|
|
@@ -148,6 +254,31 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
|
|
|
148
254
|
</select>
|
|
149
255
|
</div>
|
|
150
256
|
|
|
257
|
+
{/* Collections */}
|
|
258
|
+
{collections && collections.length > 0 && (
|
|
259
|
+
<fieldset class="field">
|
|
260
|
+
<legend class="label">
|
|
261
|
+
{t({
|
|
262
|
+
message: "Collections (optional)",
|
|
263
|
+
comment: "@context: Post form field - assign to collections",
|
|
264
|
+
})}
|
|
265
|
+
</legend>
|
|
266
|
+
<div class="flex flex-col gap-1">
|
|
267
|
+
{collections.map((col) => (
|
|
268
|
+
<label key={col.id} class="flex items-center gap-2 text-sm">
|
|
269
|
+
<input
|
|
270
|
+
type="checkbox"
|
|
271
|
+
class="checkbox"
|
|
272
|
+
data-attr:checked={`$collectionIds.includes(${col.id})`}
|
|
273
|
+
data-on:change={`$collectionIds.includes(${col.id}) ? $collectionIds = $collectionIds.filter(id => id !== ${col.id}) : $collectionIds = [...$collectionIds, ${col.id}]`}
|
|
274
|
+
/>
|
|
275
|
+
{col.title}
|
|
276
|
+
</label>
|
|
277
|
+
))}
|
|
278
|
+
</div>
|
|
279
|
+
</fieldset>
|
|
280
|
+
)}
|
|
281
|
+
|
|
151
282
|
{/* Custom path (optional) */}
|
|
152
283
|
<div class="field">
|
|
153
284
|
<label class="label">
|
|
@@ -181,6 +312,43 @@ export const PostForm: FC<PostFormProps> = ({ post, action }) => {
|
|
|
181
312
|
{t({ message: "Cancel", comment: "@context: Button to cancel form" })}
|
|
182
313
|
</a>
|
|
183
314
|
</div>
|
|
315
|
+
|
|
316
|
+
{/* Media picker dialog */}
|
|
317
|
+
<dialog
|
|
318
|
+
id="media-picker-dialog"
|
|
319
|
+
class="p-6 rounded-lg max-w-2xl w-full backdrop:bg-black/50"
|
|
320
|
+
onclick="event.target === this && this.close()"
|
|
321
|
+
>
|
|
322
|
+
<div class="flex items-center justify-between mb-4">
|
|
323
|
+
<h2 class="text-lg font-semibold">
|
|
324
|
+
{t({
|
|
325
|
+
message: "Select Media",
|
|
326
|
+
comment: "@context: Media picker dialog title",
|
|
327
|
+
})}
|
|
328
|
+
</h2>
|
|
329
|
+
<button
|
|
330
|
+
type="button"
|
|
331
|
+
class="btn-outline text-sm"
|
|
332
|
+
onclick="this.closest('dialog').close()"
|
|
333
|
+
>
|
|
334
|
+
{t({
|
|
335
|
+
message: "Done",
|
|
336
|
+
comment: "@context: Close media picker button",
|
|
337
|
+
})}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
<div
|
|
341
|
+
id="media-picker-grid"
|
|
342
|
+
class="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto"
|
|
343
|
+
>
|
|
344
|
+
<p class="text-muted-foreground text-sm col-span-4">
|
|
345
|
+
{t({
|
|
346
|
+
message: "Loading...",
|
|
347
|
+
comment: "@context: Loading state for media picker",
|
|
348
|
+
})}
|
|
349
|
+
</p>
|
|
350
|
+
</div>
|
|
351
|
+
</dialog>
|
|
184
352
|
</form>
|
|
185
353
|
);
|
|
186
354
|
};
|
|
@@ -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
|
>
|
|
@@ -3,6 +3,7 @@ export { CrudPageHeader, type CrudPageHeaderProps } from "./CrudPageHeader.js";
|
|
|
3
3
|
export { DangerZone, type DangerZoneProps } from "./DangerZone.js";
|
|
4
4
|
export { EmptyState, type EmptyStateProps } from "./EmptyState.js";
|
|
5
5
|
export { ListItemRow, type ListItemRowProps } from "./ListItemRow.js";
|
|
6
|
+
export { MediaGallery, type MediaGalleryProps } from "./MediaGallery.js";
|
|
6
7
|
export { PageForm, type PageFormProps } from "./PageForm.js";
|
|
7
8
|
export {
|
|
8
9
|
Pagination,
|
|
@@ -20,3 +21,15 @@ export {
|
|
|
20
21
|
VisibilityBadge,
|
|
21
22
|
type VisibilityBadgeProps,
|
|
22
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";
|