@jant/core 0.3.24 → 0.3.25
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 +50 -25
- package/dist/db/schema.js +1 -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/index.js +3 -9
- package/dist/lib/constants.js +1 -0
- package/dist/lib/nav-reorder.js +1 -1
- package/dist/lib/navigation.js +26 -1
- package/dist/lib/pagination.js +44 -0
- package/dist/lib/render.js +7 -11
- package/dist/lib/schemas.js +3 -3
- package/dist/lib/theme.js +4 -4
- package/dist/lib/timeline.js +24 -48
- package/dist/lib/view.js +2 -2
- package/dist/routes/api/collections.js +124 -0
- package/dist/routes/api/nav-items.js +104 -0
- package/dist/routes/api/pages.js +91 -0
- package/dist/routes/api/posts.js +2 -2
- package/dist/routes/api/search.js +2 -2
- package/dist/routes/api/settings.js +68 -0
- package/dist/routes/compose.js +48 -0
- package/dist/routes/dash/collections.js +2 -2
- package/dist/routes/dash/index.js +1 -1
- package/dist/routes/dash/media.js +2 -2
- package/dist/routes/dash/pages.js +411 -62
- package/dist/routes/dash/posts.js +3 -5
- package/dist/routes/dash/redirects.js +2 -2
- package/dist/routes/dash/settings.js +79 -5
- package/dist/routes/feed/rss.js +2 -2
- package/dist/routes/feed/sitemap.js +1 -1
- package/dist/routes/pages/archive.js +3 -6
- package/dist/routes/pages/collection.js +3 -6
- package/dist/routes/pages/collections.js +28 -0
- package/dist/routes/pages/featured.js +32 -0
- package/dist/routes/pages/home.js +9 -50
- package/dist/routes/pages/page.js +29 -32
- package/dist/routes/pages/post.js +3 -6
- package/dist/routes/pages/search.js +3 -6
- package/dist/services/page.js +5 -1
- package/dist/services/post.js +40 -6
- package/dist/services/search.js +1 -1
- package/dist/ui/compose/ComposeDialog.js +452 -0
- package/dist/ui/compose/ComposePrompt.js +55 -0
- package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
- package/dist/{theme/components → ui/dash}/PostForm.js +0 -27
- package/dist/{theme/components → ui/dash}/PostList.js +6 -6
- package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
- package/dist/{theme/components → ui/dash}/index.js +3 -6
- package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
- package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
- package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
- package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
- package/dist/ui/feed/TimelineFeed.js +41 -0
- package/dist/ui/feed/TimelineItem.js +27 -0
- package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
- package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
- package/dist/ui/layouts/SiteLayout.js +141 -0
- package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
- package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
- package/dist/ui/pages/CollectionsPage.js +76 -0
- package/dist/ui/pages/FeaturedPage.js +24 -0
- package/dist/ui/pages/HomePage.js +24 -0
- package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
- package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
- package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
- package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
- package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
- package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
- package/dist/ui/shared/index.js +5 -0
- package/package.json +1 -9
- package/src/__tests__/helpers/db.ts +3 -0
- package/src/app.tsx +57 -27
- package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/schema.ts +1 -1
- package/src/i18n/locales/en.po +332 -181
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +332 -181
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +332 -181
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +7 -36
- package/src/lib/__tests__/schemas.test.ts +60 -19
- package/src/lib/__tests__/timeline.test.ts +45 -81
- package/src/lib/__tests__/view.test.ts +13 -7
- package/src/lib/constants.ts +1 -0
- package/src/lib/nav-reorder.ts +1 -1
- package/src/lib/navigation.ts +40 -2
- package/src/lib/pagination.ts +50 -0
- package/src/lib/render.tsx +7 -14
- package/src/lib/schemas.ts +8 -6
- package/src/lib/theme.ts +5 -5
- package/src/lib/timeline.ts +28 -57
- package/src/lib/view.ts +2 -2
- package/src/preset.css +2 -1
- package/src/routes/__tests__/compose.test.ts +199 -0
- package/src/routes/api/__tests__/collections.test.ts +249 -0
- package/src/routes/api/__tests__/nav-items.test.ts +222 -0
- package/src/routes/api/__tests__/pages.test.ts +218 -0
- package/src/routes/api/__tests__/settings.test.ts +132 -0
- package/src/routes/api/collections.ts +143 -0
- package/src/routes/api/nav-items.ts +115 -0
- package/src/routes/api/pages.ts +101 -0
- package/src/routes/api/posts.ts +2 -2
- package/src/routes/api/search.ts +2 -2
- package/src/routes/api/settings.ts +91 -0
- package/src/routes/compose.ts +63 -0
- package/src/routes/dash/__tests__/pages.test.ts +225 -0
- package/src/routes/dash/collections.tsx +2 -2
- package/src/routes/dash/index.tsx +1 -1
- package/src/routes/dash/media.tsx +2 -2
- package/src/routes/dash/pages.tsx +443 -70
- package/src/routes/dash/posts.tsx +3 -7
- package/src/routes/dash/redirects.tsx +2 -2
- package/src/routes/dash/settings.tsx +83 -5
- package/src/routes/feed/rss.ts +2 -2
- package/src/routes/feed/sitemap.ts +1 -1
- package/src/routes/pages/__tests__/collections.test.ts +94 -0
- package/src/routes/pages/__tests__/featured.test.ts +94 -0
- package/src/routes/pages/archive.tsx +2 -6
- package/src/routes/pages/collection.tsx +2 -6
- package/src/routes/pages/collections.tsx +36 -0
- package/src/routes/pages/featured.tsx +38 -0
- package/src/routes/pages/home.tsx +9 -55
- package/src/routes/pages/page.tsx +28 -30
- package/src/routes/pages/post.tsx +2 -5
- package/src/routes/pages/search.tsx +2 -6
- package/src/services/__tests__/page.test.ts +106 -0
- package/src/services/__tests__/post.test.ts +114 -15
- package/src/services/page.ts +13 -1
- package/src/services/post.ts +57 -7
- package/src/services/search.ts +2 -2
- package/src/styles/tokens.css +47 -0
- package/src/styles/ui.css +491 -0
- package/src/types.ts +29 -159
- package/src/ui/compose/ComposeDialog.tsx +395 -0
- package/src/ui/compose/ComposePrompt.tsx +55 -0
- package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
- package/src/{theme/components → ui/dash}/PostForm.tsx +0 -25
- package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
- package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
- package/src/ui/dash/index.ts +10 -0
- package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
- package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
- package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
- package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
- package/src/ui/feed/TimelineFeed.tsx +49 -0
- package/src/ui/feed/TimelineItem.tsx +45 -0
- package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
- package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
- package/src/ui/layouts/SiteLayout.tsx +150 -0
- package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
- package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
- package/src/ui/pages/CollectionsPage.tsx +73 -0
- package/src/ui/pages/FeaturedPage.tsx +31 -0
- package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
- package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
- package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
- package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
- package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
- package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
- package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
- package/src/ui/shared/__tests__/pagination.test.ts +46 -0
- package/src/ui/shared/index.ts +12 -0
- package/bin/jant.js +0 -185
- package/dist/lib/theme-components.js +0 -46
- package/dist/routes/dash/navigation.js +0 -289
- package/dist/theme/index.js +0 -18
- package/dist/theme/layouts/index.js +0 -2
- package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
- package/dist/themes/threads/index.js +0 -81
- package/dist/themes/threads/pages/HomePage.js +0 -25
- package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
- package/dist/themes/threads/timeline/TimelineItem.js +0 -36
- package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
- package/dist/themes/threads/timeline/groupByDate.js +0 -22
- package/dist/themes/threads/timeline/timelineMore.js +0 -107
- package/src/lib/__tests__/theme-components.test.ts +0 -105
- package/src/lib/theme-components.ts +0 -65
- package/src/routes/dash/navigation.tsx +0 -317
- package/src/theme/components/index.ts +0 -23
- package/src/theme/index.ts +0 -22
- package/src/theme/layouts/index.ts +0 -7
- package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
- package/src/themes/threads/index.ts +0 -100
- package/src/themes/threads/style.css +0 -336
- package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
- package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
- package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
- package/src/themes/threads/timeline/groupByDate.ts +0 -30
- package/src/themes/threads/timeline/timelineMore.tsx +0 -130
- /package/dist/{theme → ui}/color-themes.js +0 -0
- /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
- /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
- /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
- /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
- /package/dist/{theme/components → ui/dash}/PageForm.js +0 -0
- /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
- /package/src/{theme → ui}/color-themes.ts +0 -0
- /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
- /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
- /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
- /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
- /package/src/{theme/components → ui/dash}/PageForm.tsx +0 -0
- /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
|
@@ -8,7 +8,7 @@ import { Hono } from "hono";
|
|
|
8
8
|
import { useLingui } from "@lingui/react/macro";
|
|
9
9
|
import type { Bindings } from "../../types.js";
|
|
10
10
|
import type { AppVariables } from "../../app.js";
|
|
11
|
-
import { DashLayout } from "../../
|
|
11
|
+
import { DashLayout } from "../../ui/layouts/DashLayout.js";
|
|
12
12
|
import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
|
|
13
13
|
import {
|
|
14
14
|
getSiteLanguage,
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "../../lib/config.js";
|
|
18
18
|
import { SETTINGS_KEYS } from "../../lib/constants.js";
|
|
19
19
|
import { getAvailableThemes } from "../../lib/theme.js";
|
|
20
|
-
import type { ColorTheme } from "../../
|
|
20
|
+
import type { ColorTheme } from "../../ui/color-themes.js";
|
|
21
21
|
|
|
22
22
|
/** Escape HTML special characters for safe insertion into HTML strings */
|
|
23
23
|
function escapeHtml(str: string): string {
|
|
@@ -283,17 +283,21 @@ function ThemeCard({
|
|
|
283
283
|
function AppearanceContent({
|
|
284
284
|
themes,
|
|
285
285
|
currentThemeId,
|
|
286
|
+
customCSS,
|
|
286
287
|
}: {
|
|
287
288
|
themes: ColorTheme[];
|
|
288
289
|
currentThemeId: string;
|
|
290
|
+
customCSS: string;
|
|
289
291
|
}) {
|
|
290
292
|
const { t } = useLingui();
|
|
291
293
|
|
|
292
|
-
const
|
|
294
|
+
const themeSignals = JSON.stringify({ theme: currentThemeId }).replace(
|
|
293
295
|
/</g,
|
|
294
296
|
"\\u003c",
|
|
295
297
|
);
|
|
296
298
|
|
|
299
|
+
const cssSignals = JSON.stringify({ customCSS }).replace(/</g, "\\u003c");
|
|
300
|
+
|
|
297
301
|
return (
|
|
298
302
|
<>
|
|
299
303
|
<h1 class="text-2xl font-semibold mb-2">
|
|
@@ -302,7 +306,7 @@ function AppearanceContent({
|
|
|
302
306
|
<SettingsNav currentTab="appearance" />
|
|
303
307
|
|
|
304
308
|
<div
|
|
305
|
-
data-signals={
|
|
309
|
+
data-signals={themeSignals}
|
|
306
310
|
data-on:change="@post('/dash/settings/appearance')"
|
|
307
311
|
class="max-w-3xl"
|
|
308
312
|
>
|
|
@@ -332,6 +336,59 @@ function AppearanceContent({
|
|
|
332
336
|
</div>
|
|
333
337
|
</fieldset>
|
|
334
338
|
</div>
|
|
339
|
+
|
|
340
|
+
<form
|
|
341
|
+
data-signals={cssSignals}
|
|
342
|
+
data-on:submit__prevent="@post('/dash/settings/custom-css')"
|
|
343
|
+
data-indicator="_cssLoading"
|
|
344
|
+
class="max-w-3xl mt-8"
|
|
345
|
+
>
|
|
346
|
+
<fieldset>
|
|
347
|
+
<legend class="text-lg font-semibold">
|
|
348
|
+
{t({
|
|
349
|
+
message: "Custom CSS",
|
|
350
|
+
comment: "@context: Appearance settings heading for custom CSS",
|
|
351
|
+
})}
|
|
352
|
+
</legend>
|
|
353
|
+
<p class="text-sm text-muted-foreground mb-4">
|
|
354
|
+
{t({
|
|
355
|
+
message:
|
|
356
|
+
"Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.",
|
|
357
|
+
comment: "@context: Custom CSS settings description",
|
|
358
|
+
})}
|
|
359
|
+
</p>
|
|
360
|
+
<textarea
|
|
361
|
+
data-bind="customCSS"
|
|
362
|
+
class="textarea font-mono text-sm min-h-32"
|
|
363
|
+
rows={8}
|
|
364
|
+
placeholder={t({
|
|
365
|
+
message: "/* Your custom CSS here */",
|
|
366
|
+
comment: "@context: Custom CSS textarea placeholder",
|
|
367
|
+
})}
|
|
368
|
+
>
|
|
369
|
+
{customCSS}
|
|
370
|
+
</textarea>
|
|
371
|
+
</fieldset>
|
|
372
|
+
<button
|
|
373
|
+
type="submit"
|
|
374
|
+
class="btn mt-4"
|
|
375
|
+
data-attr-disabled="$_cssLoading"
|
|
376
|
+
>
|
|
377
|
+
<span data-show="!$_cssLoading">
|
|
378
|
+
{t({
|
|
379
|
+
message: "Save CSS",
|
|
380
|
+
comment: "@context: Button to save custom CSS",
|
|
381
|
+
})}
|
|
382
|
+
</span>
|
|
383
|
+
<span data-show="$_cssLoading">
|
|
384
|
+
{t({
|
|
385
|
+
message: "Processing...",
|
|
386
|
+
comment:
|
|
387
|
+
"@context: Loading text shown on submit button while request is in progress",
|
|
388
|
+
})}
|
|
389
|
+
</span>
|
|
390
|
+
</button>
|
|
391
|
+
</form>
|
|
335
392
|
</>
|
|
336
393
|
);
|
|
337
394
|
}
|
|
@@ -585,6 +642,7 @@ settingsRoutes.get("/appearance", async (c) => {
|
|
|
585
642
|
const { settings } = c.var.services;
|
|
586
643
|
const siteName = await getSiteName(c);
|
|
587
644
|
const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
|
|
645
|
+
const customCSS = (await settings.get(SETTINGS_KEYS.CUSTOM_CSS)) ?? "";
|
|
588
646
|
const themes = getAvailableThemes(c.var.config);
|
|
589
647
|
const saved = c.req.query("saved") !== undefined;
|
|
590
648
|
|
|
@@ -596,7 +654,11 @@ settingsRoutes.get("/appearance", async (c) => {
|
|
|
596
654
|
currentPath="/dash/settings"
|
|
597
655
|
toast={saved ? { message: "Theme saved successfully." } : undefined}
|
|
598
656
|
>
|
|
599
|
-
<AppearanceContent
|
|
657
|
+
<AppearanceContent
|
|
658
|
+
themes={themes}
|
|
659
|
+
currentThemeId={currentThemeId}
|
|
660
|
+
customCSS={customCSS}
|
|
661
|
+
/>
|
|
600
662
|
</DashLayout>,
|
|
601
663
|
);
|
|
602
664
|
});
|
|
@@ -621,6 +683,22 @@ settingsRoutes.post("/appearance", async (c) => {
|
|
|
621
683
|
return dsRedirect("/dash/settings/appearance?saved");
|
|
622
684
|
});
|
|
623
685
|
|
|
686
|
+
// Save custom CSS
|
|
687
|
+
settingsRoutes.post("/custom-css", async (c) => {
|
|
688
|
+
const body = await c.req.json<{ customCSS: string }>();
|
|
689
|
+
const { settings } = c.var.services;
|
|
690
|
+
|
|
691
|
+
const css = body.customCSS?.trim() ?? "";
|
|
692
|
+
|
|
693
|
+
if (css) {
|
|
694
|
+
await settings.set(SETTINGS_KEYS.CUSTOM_CSS, css);
|
|
695
|
+
} else {
|
|
696
|
+
await settings.remove(SETTINGS_KEYS.CUSTOM_CSS);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return dsToast("Custom CSS saved successfully.");
|
|
700
|
+
});
|
|
701
|
+
|
|
624
702
|
// Account page
|
|
625
703
|
settingsRoutes.get("/account", async (c) => {
|
|
626
704
|
const siteName = await getSiteName(c);
|
package/src/routes/feed/rss.ts
CHANGED
|
@@ -64,7 +64,7 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
|
|
|
64
64
|
rssRoutes.get("/", async (c) => {
|
|
65
65
|
const feedData = await buildFeedData(c);
|
|
66
66
|
|
|
67
|
-
const renderer = c.var.config.
|
|
67
|
+
const renderer = c.var.config.feed?.rss ?? defaultRssRenderer;
|
|
68
68
|
const xml = renderer(feedData);
|
|
69
69
|
|
|
70
70
|
return new Response(xml, {
|
|
@@ -78,7 +78,7 @@ rssRoutes.get("/", async (c) => {
|
|
|
78
78
|
rssRoutes.get("/atom.xml", async (c) => {
|
|
79
79
|
const feedData = await buildFeedData(c);
|
|
80
80
|
|
|
81
|
-
const renderer = c.var.config.
|
|
81
|
+
const renderer = c.var.config.feed?.atom ?? defaultAtomRenderer;
|
|
82
82
|
const xml = renderer(feedData);
|
|
83
83
|
|
|
84
84
|
return new Response(xml, {
|
|
@@ -35,7 +35,7 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
|
|
|
35
35
|
const postViews = toPostViewsFromPosts(posts, mediaCtx);
|
|
36
36
|
const pageViews = publishedPages.map(toPageView);
|
|
37
37
|
|
|
38
|
-
const renderer = c.var.config.
|
|
38
|
+
const renderer = c.var.config.feed?.sitemap ?? defaultSitemapRenderer;
|
|
39
39
|
const xml = renderer({ siteUrl, posts: postViews, pages: pageViews });
|
|
40
40
|
|
|
41
41
|
return new Response(xml, {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the collections listing page data logic.
|
|
3
|
+
*
|
|
4
|
+
* Note: Route handler tests that import JSX components with @lingui/react/macro
|
|
5
|
+
* cannot run in vitest (requires SWC plugin). These tests verify the service
|
|
6
|
+
* layer operations that the collections route orchestrates.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
10
|
+
import { createTestDatabase } from "../../../__tests__/helpers/db.js";
|
|
11
|
+
import { createCollectionService } from "../../../services/collection.js";
|
|
12
|
+
import { createPostService } from "../../../services/post.js";
|
|
13
|
+
import type { Database } from "../../../db/index.js";
|
|
14
|
+
|
|
15
|
+
describe("Collections Listing Page - Data Logic", () => {
|
|
16
|
+
let db: Database;
|
|
17
|
+
let collectionService: ReturnType<typeof createCollectionService>;
|
|
18
|
+
let postService: ReturnType<typeof createPostService>;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
const testDb = createTestDatabase();
|
|
22
|
+
db = testDb.db as unknown as Database;
|
|
23
|
+
collectionService = createCollectionService(db);
|
|
24
|
+
postService = createPostService(db);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("returns collections with post counts", async () => {
|
|
28
|
+
const recipes = await collectionService.create({
|
|
29
|
+
slug: "recipes",
|
|
30
|
+
title: "Recipes",
|
|
31
|
+
});
|
|
32
|
+
await collectionService.create({
|
|
33
|
+
slug: "travel",
|
|
34
|
+
title: "Travel",
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Add posts to recipes collection
|
|
38
|
+
await postService.create({
|
|
39
|
+
format: "note",
|
|
40
|
+
body: "Recipe 1",
|
|
41
|
+
collectionId: recipes.id,
|
|
42
|
+
});
|
|
43
|
+
await postService.create({
|
|
44
|
+
format: "note",
|
|
45
|
+
body: "Recipe 2",
|
|
46
|
+
collectionId: recipes.id,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Simulate route handler logic
|
|
50
|
+
const [allCollections, postCounts] = await Promise.all([
|
|
51
|
+
collectionService.list(),
|
|
52
|
+
collectionService.getPostCounts(),
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const collections = allCollections.map((col) => ({
|
|
56
|
+
...col,
|
|
57
|
+
postCount: postCounts.get(col.id) ?? 0,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
expect(collections).toHaveLength(2);
|
|
61
|
+
const recipesResult = collections.find((c) => c.slug === "recipes");
|
|
62
|
+
const travelResult = collections.find((c) => c.slug === "travel");
|
|
63
|
+
expect(recipesResult?.postCount).toBe(2);
|
|
64
|
+
expect(travelResult?.postCount).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns empty list when no collections exist", async () => {
|
|
68
|
+
const allCollections = await collectionService.list();
|
|
69
|
+
expect(allCollections).toHaveLength(0);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("does not count soft-deleted posts", async () => {
|
|
73
|
+
const col = await collectionService.create({
|
|
74
|
+
slug: "test",
|
|
75
|
+
title: "Test",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const post = await postService.create({
|
|
79
|
+
format: "note",
|
|
80
|
+
body: "Will be deleted",
|
|
81
|
+
collectionId: col.id,
|
|
82
|
+
});
|
|
83
|
+
await postService.create({
|
|
84
|
+
format: "note",
|
|
85
|
+
body: "Will remain",
|
|
86
|
+
collectionId: col.id,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await postService.delete(post.id);
|
|
90
|
+
|
|
91
|
+
const postCounts = await collectionService.getPostCounts();
|
|
92
|
+
expect(postCounts.get(col.id)).toBe(1);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the featured page data logic.
|
|
3
|
+
*
|
|
4
|
+
* Note: Route handler tests that import JSX components with @lingui/react/macro
|
|
5
|
+
* cannot run in vitest (requires SWC plugin). These tests verify the service
|
|
6
|
+
* layer operations that the featured route orchestrates.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
10
|
+
import { createTestDatabase } from "../../../__tests__/helpers/db.js";
|
|
11
|
+
import { createPostService } from "../../../services/post.js";
|
|
12
|
+
import type { Database } from "../../../db/index.js";
|
|
13
|
+
|
|
14
|
+
describe("Featured Page - Data Logic", () => {
|
|
15
|
+
let db: Database;
|
|
16
|
+
let postService: ReturnType<typeof createPostService>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
const testDb = createTestDatabase();
|
|
20
|
+
db = testDb.db as unknown as Database;
|
|
21
|
+
postService = createPostService(db);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns only featured published posts", async () => {
|
|
25
|
+
await postService.create({
|
|
26
|
+
format: "note",
|
|
27
|
+
body: "Featured post",
|
|
28
|
+
featured: true,
|
|
29
|
+
status: "published",
|
|
30
|
+
});
|
|
31
|
+
await postService.create({
|
|
32
|
+
format: "note",
|
|
33
|
+
body: "Normal post",
|
|
34
|
+
featured: false,
|
|
35
|
+
status: "published",
|
|
36
|
+
});
|
|
37
|
+
await postService.create({
|
|
38
|
+
format: "note",
|
|
39
|
+
body: "Draft featured",
|
|
40
|
+
featured: true,
|
|
41
|
+
status: "draft",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const posts = await postService.list({
|
|
45
|
+
featured: true,
|
|
46
|
+
status: "published",
|
|
47
|
+
excludeReplies: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(posts).toHaveLength(1);
|
|
51
|
+
expect(posts[0]?.body).toBe("Featured post");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns empty list when no featured posts exist", async () => {
|
|
55
|
+
await postService.create({
|
|
56
|
+
format: "note",
|
|
57
|
+
body: "Normal post",
|
|
58
|
+
status: "published",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const posts = await postService.list({
|
|
62
|
+
featured: true,
|
|
63
|
+
status: "published",
|
|
64
|
+
excludeReplies: true,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(posts).toHaveLength(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("excludes replies from featured posts", async () => {
|
|
71
|
+
const root = await postService.create({
|
|
72
|
+
format: "note",
|
|
73
|
+
body: "Featured root",
|
|
74
|
+
featured: true,
|
|
75
|
+
status: "published",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Reply inherits featured from root
|
|
79
|
+
await postService.create({
|
|
80
|
+
format: "note",
|
|
81
|
+
body: "Reply to featured",
|
|
82
|
+
replyToId: root.id,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const posts = await postService.list({
|
|
86
|
+
featured: true,
|
|
87
|
+
status: "published",
|
|
88
|
+
excludeReplies: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(posts).toHaveLength(1);
|
|
92
|
+
expect(posts[0]?.body).toBe("Featured root");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -8,7 +8,7 @@ import { Hono } from "hono";
|
|
|
8
8
|
import type { Bindings, Format } from "../../types.js";
|
|
9
9
|
import type { AppVariables } from "../../app.js";
|
|
10
10
|
import { FORMATS } from "../../types.js";
|
|
11
|
-
import { ArchivePage
|
|
11
|
+
import { ArchivePage } from "../../ui/pages/ArchivePage.js";
|
|
12
12
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
13
13
|
import { renderPublicPage } from "../../lib/render.js";
|
|
14
14
|
import { createMediaContext, toArchiveGroups } from "../../lib/view.js";
|
|
@@ -69,20 +69,16 @@ archiveRoutes.get("/", async (c) => {
|
|
|
69
69
|
const mediaCtx = createMediaContext(c);
|
|
70
70
|
const groups = toArchiveGroups(grouped, mediaCtx);
|
|
71
71
|
|
|
72
|
-
const components = c.var.config.theme?.components;
|
|
73
|
-
const Page = components?.ArchivePage ?? DefaultArchivePage;
|
|
74
|
-
|
|
75
72
|
return renderPublicPage(c, {
|
|
76
73
|
title: `Archive - ${navData.siteName}`,
|
|
77
74
|
navData,
|
|
78
75
|
content: (
|
|
79
|
-
<
|
|
76
|
+
<ArchivePage
|
|
80
77
|
groups={groups}
|
|
81
78
|
hasMore={hasMore}
|
|
82
79
|
nextCursor={nextCursor}
|
|
83
80
|
format={format}
|
|
84
81
|
featured={featured}
|
|
85
|
-
theme={components}
|
|
86
82
|
/>
|
|
87
83
|
),
|
|
88
84
|
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import type { Bindings } from "../../types.js";
|
|
7
7
|
import type { AppVariables } from "../../app.js";
|
|
8
|
-
import { CollectionPage
|
|
8
|
+
import { CollectionPage } from "../../ui/pages/CollectionPage.js";
|
|
9
9
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
10
10
|
import { renderPublicPage } from "../../lib/render.js";
|
|
11
11
|
import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
|
|
@@ -33,19 +33,15 @@ collectionRoutes.get("/:slug", async (c) => {
|
|
|
33
33
|
const mediaCtx = createMediaContext(c);
|
|
34
34
|
const postViews = toPostViewsFromPosts(posts, mediaCtx);
|
|
35
35
|
|
|
36
|
-
const components = c.var.config.theme?.components;
|
|
37
|
-
const Page = components?.CollectionPage ?? DefaultCollectionPage;
|
|
38
|
-
|
|
39
36
|
return renderPublicPage(c, {
|
|
40
37
|
title: `${collection.title} - ${navData.siteName}`,
|
|
41
38
|
description: collection.description ?? undefined,
|
|
42
39
|
navData,
|
|
43
40
|
content: (
|
|
44
|
-
<
|
|
41
|
+
<CollectionPage
|
|
45
42
|
collection={collection}
|
|
46
43
|
posts={postViews}
|
|
47
44
|
hasMore={false}
|
|
48
|
-
theme={components}
|
|
49
45
|
/>
|
|
50
46
|
),
|
|
51
47
|
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections Listing Page Route
|
|
3
|
+
*
|
|
4
|
+
* Lists all collections with their post counts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { Bindings } from "../../types.js";
|
|
9
|
+
import type { AppVariables } from "../../app.js";
|
|
10
|
+
import { getNavigationData } from "../../lib/navigation.js";
|
|
11
|
+
import { renderPublicPage } from "../../lib/render.js";
|
|
12
|
+
import { CollectionsPage } from "../../ui/pages/CollectionsPage.js";
|
|
13
|
+
|
|
14
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
15
|
+
|
|
16
|
+
export const collectionsPageRoutes = new Hono<Env>();
|
|
17
|
+
|
|
18
|
+
collectionsPageRoutes.get("/", async (c) => {
|
|
19
|
+
const [allCollections, postCounts] = await Promise.all([
|
|
20
|
+
c.var.services.collections.list(),
|
|
21
|
+
c.var.services.collections.getPostCounts(),
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const collections = allCollections.map((col) => ({
|
|
25
|
+
...col,
|
|
26
|
+
postCount: postCounts.get(col.id) ?? 0,
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const navData = await getNavigationData(c);
|
|
30
|
+
|
|
31
|
+
return renderPublicPage(c, {
|
|
32
|
+
title: `Collections - ${navData.siteName}`,
|
|
33
|
+
navData,
|
|
34
|
+
content: <CollectionsPage collections={collections} />,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Featured Page Route
|
|
3
|
+
*
|
|
4
|
+
* Shows featured posts as a timeline feed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { Bindings } from "../../types.js";
|
|
9
|
+
import type { AppVariables } from "../../app.js";
|
|
10
|
+
import { getNavigationData } from "../../lib/navigation.js";
|
|
11
|
+
import { renderPublicPage } from "../../lib/render.js";
|
|
12
|
+
import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
|
|
13
|
+
import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
|
|
14
|
+
|
|
15
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
16
|
+
|
|
17
|
+
export const featuredRoutes = new Hono<Env>();
|
|
18
|
+
|
|
19
|
+
featuredRoutes.get("/", async (c) => {
|
|
20
|
+
const posts = await c.var.services.posts.list({
|
|
21
|
+
featured: true,
|
|
22
|
+
status: "published",
|
|
23
|
+
excludeReplies: true,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const navData = await getNavigationData(c);
|
|
27
|
+
const mediaCtx = createMediaContext(c);
|
|
28
|
+
const postViews = toPostViewsFromPosts(posts, mediaCtx);
|
|
29
|
+
|
|
30
|
+
// Convert to timeline items (simple — no thread previews)
|
|
31
|
+
const items = postViews.map((post) => ({ post }));
|
|
32
|
+
|
|
33
|
+
return renderPublicPage(c, {
|
|
34
|
+
title: `Featured - ${navData.siteName}`,
|
|
35
|
+
navData,
|
|
36
|
+
content: <FeaturedPage items={items} />,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Home Page Route
|
|
3
3
|
*
|
|
4
4
|
* Timeline feed with per-type card components and thread previews.
|
|
5
|
-
*
|
|
5
|
+
* Uses page-based pagination.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { Hono } from "hono";
|
|
@@ -11,63 +11,21 @@ import type { AppVariables } from "../../app.js";
|
|
|
11
11
|
import { getNavigationData } from "../../lib/navigation.js";
|
|
12
12
|
import { renderPublicPage } from "../../lib/render.js";
|
|
13
13
|
import { assembleTimeline } from "../../lib/timeline.js";
|
|
14
|
-
import { sse } from "../../lib/sse.js";
|
|
15
14
|
import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
|
|
16
|
-
import { HomePage
|
|
15
|
+
import { HomePage } from "../../ui/pages/HomePage.js";
|
|
17
16
|
|
|
18
17
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
19
18
|
|
|
20
19
|
export const homeRoutes = new Hono<Env>();
|
|
21
20
|
|
|
22
21
|
homeRoutes.get("/", async (c) => {
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const lastDate = c.req.query("lastDate");
|
|
22
|
+
const pageParam = c.req.query("page");
|
|
23
|
+
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1;
|
|
26
24
|
|
|
27
|
-
const { items,
|
|
28
|
-
|
|
25
|
+
const { items, currentPage, totalPages } = await assembleTimeline(c, {
|
|
26
|
+
page,
|
|
29
27
|
});
|
|
30
28
|
|
|
31
|
-
// SSE load-more response
|
|
32
|
-
if (cursor && !isNaN(cursor)) {
|
|
33
|
-
if (items.length === 0) {
|
|
34
|
-
return sse(c, async (stream) => {
|
|
35
|
-
stream.remove("#load-more-container");
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const themeConfig = c.var.config.theme;
|
|
40
|
-
const renderMore = themeConfig?.timelineMore;
|
|
41
|
-
if (!renderMore) {
|
|
42
|
-
// Should never happen — default theme always provides timelineMore
|
|
43
|
-
return sse(c, async (stream) => {
|
|
44
|
-
stream.remove("#load-more-container");
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const patches = renderMore({
|
|
49
|
-
items,
|
|
50
|
-
lastDate: lastDate ?? undefined,
|
|
51
|
-
hasMore,
|
|
52
|
-
nextCursor,
|
|
53
|
-
theme: themeConfig?.components,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
return sse(c, async (stream) => {
|
|
57
|
-
for (const patch of patches) {
|
|
58
|
-
if (patch.mode === "remove") {
|
|
59
|
-
stream.remove(patch.selector);
|
|
60
|
-
} else {
|
|
61
|
-
stream.patchElements(patch.content, {
|
|
62
|
-
mode: patch.mode,
|
|
63
|
-
selector: patch.selector,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Full page render
|
|
71
29
|
const navData = await getNavigationData(c);
|
|
72
30
|
|
|
73
31
|
// Fetch pinned posts
|
|
@@ -79,19 +37,15 @@ homeRoutes.get("/", async (c) => {
|
|
|
79
37
|
const mediaCtx = createMediaContext(c);
|
|
80
38
|
const pinnedItems = toPostViewsFromPosts(pinnedPosts, mediaCtx);
|
|
81
39
|
|
|
82
|
-
const components = c.var.config.theme?.components;
|
|
83
|
-
const Page = components?.HomePage ?? DefaultHomePage;
|
|
84
|
-
|
|
85
40
|
return renderPublicPage(c, {
|
|
86
41
|
title: navData.siteName,
|
|
87
42
|
navData,
|
|
88
43
|
content: (
|
|
89
|
-
<
|
|
44
|
+
<HomePage
|
|
90
45
|
items={items}
|
|
91
46
|
pinnedItems={pinnedItems}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
theme={components}
|
|
47
|
+
currentPage={currentPage}
|
|
48
|
+
totalPages={totalPages}
|
|
95
49
|
/>
|
|
96
50
|
),
|
|
97
51
|
});
|