@jant/core 0.3.23 → 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 -26
- package/dist/db/schema.js +72 -47
- 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 +5 -11
- package/dist/lib/constants.js +2 -4
- package/dist/lib/excerpt.js +76 -0
- package/dist/lib/feed.js +18 -7
- package/dist/lib/nav-reorder.js +1 -1
- package/dist/lib/navigation.js +30 -6
- package/dist/lib/pagination.js +44 -0
- package/dist/lib/render.js +7 -11
- package/dist/lib/schemas.js +80 -38
- package/dist/lib/theme.js +4 -4
- package/dist/lib/time.js +56 -1
- package/dist/lib/timeline.js +95 -0
- package/dist/lib/view.js +61 -72
- 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 +27 -33
- package/dist/routes/api/search.js +4 -5
- package/dist/routes/api/settings.js +68 -0
- package/dist/routes/api/upload.js +13 -13
- package/dist/routes/compose.js +48 -0
- package/dist/routes/dash/collections.js +24 -42
- package/dist/routes/dash/index.js +3 -3
- package/dist/routes/dash/media.js +2 -2
- package/dist/routes/dash/pages.js +440 -106
- package/dist/routes/dash/posts.js +27 -37
- package/dist/routes/dash/redirects.js +2 -2
- package/dist/routes/dash/settings.js +79 -5
- package/dist/routes/feed/rss.js +4 -6
- package/dist/routes/feed/sitemap.js +11 -8
- package/dist/routes/pages/archive.js +13 -15
- package/dist/routes/pages/collection.js +12 -9
- package/dist/routes/pages/collections.js +28 -0
- package/dist/routes/pages/featured.js +32 -0
- package/dist/routes/pages/home.js +19 -68
- package/dist/routes/pages/page.js +57 -29
- package/dist/routes/pages/post.js +7 -17
- package/dist/routes/pages/search.js +5 -9
- package/dist/services/collection.js +52 -64
- package/dist/services/index.js +5 -3
- package/dist/services/navigation.js +29 -53
- package/dist/services/page.js +84 -0
- package/dist/services/post.js +102 -69
- package/dist/services/search.js +24 -18
- package/dist/types.js +24 -40
- 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} +3 -15
- package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
- package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
- package/dist/{theme/components → ui/dash}/PostList.js +18 -13
- package/dist/ui/dash/StatusBadge.js +46 -0
- package/dist/{theme/components → ui/dash}/index.js +3 -6
- package/dist/ui/feed/LinkCard.js +72 -0
- package/dist/ui/feed/NoteCard.js +58 -0
- package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
- package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
- 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/minimal → ui}/pages/ArchivePage.js +37 -50
- package/dist/ui/pages/CollectionPage.js +70 -0
- 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/minimal → ui}/pages/PostPage.js +20 -12
- package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
- package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
- package/dist/ui/shared/MediaGallery.js +35 -0
- package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
- package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
- package/dist/ui/shared/index.js +5 -0
- package/package.json +2 -9
- package/src/__tests__/helpers/app.ts +4 -0
- package/src/__tests__/helpers/db.ts +53 -73
- package/src/app.tsx +56 -28
- package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
- package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
- package/src/db/migrations/meta/_journal.json +14 -0
- package/src/db/schema.ts +63 -46
- package/src/i18n/locales/en.po +443 -240
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +443 -240
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +443 -240
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +29 -42
- package/src/lib/__tests__/excerpt.test.ts +125 -0
- package/src/lib/__tests__/schemas.test.ts +201 -99
- package/src/lib/__tests__/time.test.ts +62 -0
- package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
- package/src/lib/__tests__/view.test.ts +204 -50
- package/src/lib/constants.ts +2 -4
- package/src/lib/excerpt.ts +87 -0
- package/src/lib/feed.ts +22 -7
- package/src/lib/nav-reorder.ts +1 -1
- package/src/lib/navigation.ts +45 -8
- package/src/lib/pagination.ts +50 -0
- package/src/lib/render.tsx +7 -14
- package/src/lib/schemas.ts +119 -51
- package/src/lib/theme.ts +5 -5
- package/src/lib/time.ts +64 -0
- package/src/lib/timeline.ts +141 -0
- package/src/lib/view.ts +80 -82
- package/src/preset.css +46 -0
- 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__/posts.test.ts +50 -108
- package/src/routes/api/__tests__/search.test.ts +2 -3
- 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 +28 -28
- package/src/routes/api/search.ts +3 -3
- package/src/routes/api/settings.ts +91 -0
- package/src/routes/api/upload.ts +16 -6
- package/src/routes/compose.ts +63 -0
- package/src/routes/dash/__tests__/pages.test.ts +225 -0
- package/src/routes/dash/collections.tsx +20 -42
- package/src/routes/dash/index.tsx +3 -3
- package/src/routes/dash/media.tsx +2 -2
- package/src/routes/dash/pages.tsx +480 -122
- package/src/routes/dash/posts.tsx +42 -54
- package/src/routes/dash/redirects.tsx +2 -2
- package/src/routes/dash/settings.tsx +83 -5
- package/src/routes/feed/rss.ts +4 -3
- package/src/routes/feed/sitemap.ts +15 -5
- 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 +15 -15
- package/src/routes/pages/collection.tsx +16 -9
- package/src/routes/pages/collections.tsx +36 -0
- package/src/routes/pages/featured.tsx +38 -0
- package/src/routes/pages/home.tsx +21 -92
- package/src/routes/pages/page.tsx +62 -27
- package/src/routes/pages/post.tsx +6 -18
- package/src/routes/pages/search.tsx +3 -7
- package/src/services/__tests__/collection.test.ts +257 -158
- package/src/services/__tests__/media.test.ts +18 -18
- package/src/services/__tests__/navigation.test.ts +161 -87
- package/src/services/__tests__/page.test.ts +106 -0
- package/src/services/__tests__/post-timeline.test.ts +92 -88
- package/src/services/__tests__/post.test.ts +432 -197
- package/src/services/__tests__/search.test.ts +19 -25
- package/src/services/collection.ts +71 -113
- package/src/services/index.ts +9 -8
- package/src/services/navigation.ts +38 -71
- package/src/services/page.ts +136 -0
- package/src/services/post.ts +141 -101
- package/src/services/search.ts +38 -27
- package/src/styles/tokens.css +47 -0
- package/src/styles/ui.css +491 -0
- package/src/types.ts +212 -198
- package/src/ui/compose/ComposeDialog.tsx +395 -0
- package/src/ui/compose/ComposePrompt.tsx +55 -0
- package/src/ui/dash/FormatBadge.tsx +28 -0
- package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
- package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
- package/src/ui/dash/PostList.tsx +101 -0
- package/src/ui/dash/StatusBadge.tsx +61 -0
- package/src/ui/dash/index.ts +10 -0
- package/src/ui/feed/LinkCard.tsx +72 -0
- package/src/ui/feed/NoteCard.tsx +63 -0
- package/src/ui/feed/QuoteCard.tsx +68 -0
- package/src/ui/feed/ThreadPreview.tsx +48 -0
- 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/ui/pages/ArchivePage.tsx +162 -0
- package/src/ui/pages/CollectionPage.tsx +70 -0
- package/src/ui/pages/CollectionsPage.tsx +73 -0
- package/src/ui/pages/FeaturedPage.tsx +31 -0
- package/src/ui/pages/HomePage.tsx +37 -0
- package/src/ui/pages/PostPage.tsx +56 -0
- package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
- package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
- package/src/ui/shared/MediaGallery.tsx +59 -0
- package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
- package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
- 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 -49
- package/dist/routes/api/timeline.js +0 -120
- package/dist/routes/dash/navigation.js +0 -288
- package/dist/theme/components/MediaGallery.js +0 -107
- package/dist/theme/components/VisibilityBadge.js +0 -37
- package/dist/theme/index.js +0 -18
- package/dist/theme/layouts/index.js +0 -2
- package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
- package/dist/themes/minimal/index.js +0 -65
- package/dist/themes/minimal/pages/CollectionPage.js +0 -65
- package/dist/themes/minimal/pages/HomePage.js +0 -25
- package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
- package/dist/themes/minimal/timeline/ImageCard.js +0 -67
- package/dist/themes/minimal/timeline/LinkCard.js +0 -47
- package/dist/themes/minimal/timeline/NoteCard.js +0 -34
- package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
- package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
- package/src/lib/__tests__/theme-components.test.ts +0 -126
- package/src/lib/theme-components.ts +0 -68
- package/src/routes/api/timeline.tsx +0 -159
- package/src/routes/dash/navigation.tsx +0 -316
- package/src/theme/components/MediaGallery.tsx +0 -128
- package/src/theme/components/PostList.tsx +0 -92
- package/src/theme/components/TypeBadge.tsx +0 -37
- package/src/theme/components/VisibilityBadge.tsx +0 -45
- 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/minimal/MinimalSiteLayout.tsx +0 -100
- package/src/themes/minimal/index.ts +0 -83
- package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
- package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
- package/src/themes/minimal/pages/HomePage.tsx +0 -41
- package/src/themes/minimal/pages/PostPage.tsx +0 -43
- package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
- package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
- package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
- package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
- package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
- package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
- package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
- package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
- /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/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/shared}/EmptyState.tsx +0 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML Excerpt Utilities
|
|
3
|
+
*
|
|
4
|
+
* Generates paragraph-aware excerpts from HTML content for article
|
|
5
|
+
* previews in timelines. Breaks only at paragraph boundaries.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strips HTML tags from a string, returning plain text.
|
|
10
|
+
*
|
|
11
|
+
* @param html - HTML string to strip
|
|
12
|
+
* @returns Plain text without HTML tags
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* stripHtml("<p>Hello <strong>world</strong></p>") // "Hello world"
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export function stripHtml(html: string): string {
|
|
20
|
+
return html.replace(/<[^>]*>/g, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Result of extracting an HTML excerpt.
|
|
25
|
+
*/
|
|
26
|
+
export interface HtmlExcerpt {
|
|
27
|
+
/** HTML excerpt (complete paragraphs only) */
|
|
28
|
+
excerpt: string;
|
|
29
|
+
/** Whether the original content has more text beyond the excerpt */
|
|
30
|
+
hasMore: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extracts a paragraph-aware HTML excerpt from body HTML.
|
|
35
|
+
*
|
|
36
|
+
* Uses a greedy algorithm: accumulates paragraphs until the total
|
|
37
|
+
* plain-text length exceeds 500 characters, then stops. At least
|
|
38
|
+
* one paragraph is always included.
|
|
39
|
+
*
|
|
40
|
+
* If the content contains a `<!--more-->` marker, the content before
|
|
41
|
+
* the marker is used as the excerpt instead.
|
|
42
|
+
*
|
|
43
|
+
* @param bodyHtml - Full HTML body content
|
|
44
|
+
* @returns Excerpt HTML and whether there is more content
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```typescript
|
|
48
|
+
* // Short content — returned as-is with hasMore = false
|
|
49
|
+
* getHtmlExcerpt("<p>Short post.</p>")
|
|
50
|
+
* // { excerpt: "<p>Short post.</p>", hasMore: false }
|
|
51
|
+
*
|
|
52
|
+
* // Long content — truncated at paragraph boundary
|
|
53
|
+
* getHtmlExcerpt("<p>" + "A".repeat(300) + "</p><p>" + "B".repeat(300) + "</p>")
|
|
54
|
+
* // { excerpt: "<p>AAA...</p>", hasMore: true }
|
|
55
|
+
*
|
|
56
|
+
* // Manual break with <!--more-->
|
|
57
|
+
* getHtmlExcerpt("<p>Intro</p><!--more--><p>Rest</p>")
|
|
58
|
+
* // { excerpt: "<p>Intro</p>", hasMore: true }
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function getHtmlExcerpt(bodyHtml: string): HtmlExcerpt {
|
|
62
|
+
// Honor manual <!--more--> marker
|
|
63
|
+
if (bodyHtml.includes("<!--more-->")) {
|
|
64
|
+
const excerpt = bodyHtml.split("<!--more-->")[0]!;
|
|
65
|
+
return { excerpt, hasMore: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const paragraphs = bodyHtml.match(/<p>[\s\S]*?<\/p>/g) || [];
|
|
69
|
+
|
|
70
|
+
// No paragraphs found — return full content
|
|
71
|
+
if (paragraphs.length === 0) {
|
|
72
|
+
return { excerpt: bodyHtml, hasMore: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let excerpt = "";
|
|
76
|
+
let charCount = 0;
|
|
77
|
+
|
|
78
|
+
for (const p of paragraphs) {
|
|
79
|
+
const textLen = stripHtml(p).length;
|
|
80
|
+
if (charCount + textLen > 500 && excerpt) break;
|
|
81
|
+
excerpt += p;
|
|
82
|
+
charCount += textLen;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const hasMore = excerpt.length < bodyHtml.length;
|
|
86
|
+
return { excerpt, hasMore };
|
|
87
|
+
}
|
package/src/lib/feed.ts
CHANGED
|
@@ -51,7 +51,7 @@ export function defaultRssRenderer(data: FeedData): string {
|
|
|
51
51
|
<link>${link}</link>
|
|
52
52
|
<guid isPermaLink="true">${link}</guid>
|
|
53
53
|
<pubDate>${pubDate}</pubDate>
|
|
54
|
-
<description><![CDATA[${post.
|
|
54
|
+
<description><![CDATA[${post.bodyHtml || ""}]]></description>${enclosure}
|
|
55
55
|
</item>`;
|
|
56
56
|
})
|
|
57
57
|
.join("");
|
|
@@ -90,7 +90,7 @@ export function defaultAtomRenderer(data: FeedData): string {
|
|
|
90
90
|
<id>${link}</id>
|
|
91
91
|
<published>${post.publishedAt}</published>
|
|
92
92
|
<updated>${post.updatedAt}</updated>
|
|
93
|
-
<content type="html"><![CDATA[${post.
|
|
93
|
+
<content type="html"><![CDATA[${post.bodyHtml || ""}]]></content>
|
|
94
94
|
</entry>`;
|
|
95
95
|
})
|
|
96
96
|
.join("");
|
|
@@ -112,17 +112,17 @@ export function defaultAtomRenderer(data: FeedData): string {
|
|
|
112
112
|
/**
|
|
113
113
|
* Default Sitemap renderer.
|
|
114
114
|
*
|
|
115
|
-
* @param data - Sitemap data with PostView[]
|
|
115
|
+
* @param data - Sitemap data with PostView[] and PageView[]
|
|
116
116
|
* @returns Sitemap XML string
|
|
117
117
|
*/
|
|
118
118
|
export function defaultSitemapRenderer(data: SitemapData): string {
|
|
119
|
-
const { siteUrl, posts } = data;
|
|
119
|
+
const { siteUrl, posts, pages } = data;
|
|
120
120
|
|
|
121
|
-
const
|
|
121
|
+
const postUrls = posts
|
|
122
122
|
.map((post) => {
|
|
123
123
|
const loc = `${siteUrl}${post.permalink}`;
|
|
124
124
|
const lastmod = post.updatedAt.split("T")[0];
|
|
125
|
-
const priority = post.
|
|
125
|
+
const priority = post.featured ? "0.8" : "0.6";
|
|
126
126
|
|
|
127
127
|
return `
|
|
128
128
|
<url>
|
|
@@ -133,6 +133,20 @@ export function defaultSitemapRenderer(data: SitemapData): string {
|
|
|
133
133
|
})
|
|
134
134
|
.join("");
|
|
135
135
|
|
|
136
|
+
const pageUrls = pages
|
|
137
|
+
.map((page) => {
|
|
138
|
+
const loc = `${siteUrl}/${page.slug}`;
|
|
139
|
+
const lastmod = page.updatedAt.split("T")[0];
|
|
140
|
+
|
|
141
|
+
return `
|
|
142
|
+
<url>
|
|
143
|
+
<loc>${loc}</loc>
|
|
144
|
+
<lastmod>${lastmod}</lastmod>
|
|
145
|
+
<priority>0.7</priority>
|
|
146
|
+
</url>`;
|
|
147
|
+
})
|
|
148
|
+
.join("");
|
|
149
|
+
|
|
136
150
|
const homepageUrl = `
|
|
137
151
|
<url>
|
|
138
152
|
<loc>${siteUrl}/</loc>
|
|
@@ -143,6 +157,7 @@ export function defaultSitemapRenderer(data: SitemapData): string {
|
|
|
143
157
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
144
158
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
145
159
|
${homepageUrl}
|
|
146
|
-
${
|
|
160
|
+
${postUrls}
|
|
161
|
+
${pageUrls}
|
|
147
162
|
</urlset>`;
|
|
148
163
|
}
|
package/src/lib/nav-reorder.ts
CHANGED
|
@@ -16,7 +16,7 @@ if (list) {
|
|
|
16
16
|
const ids = [...list.querySelectorAll<HTMLElement>("[data-id]")].map(
|
|
17
17
|
(el) => Number(el.dataset.id),
|
|
18
18
|
);
|
|
19
|
-
fetch("/dash/
|
|
19
|
+
fetch("/dash/pages/reorder", {
|
|
20
20
|
method: "POST",
|
|
21
21
|
headers: { "Content-Type": "application/json" },
|
|
22
22
|
body: JSON.stringify({ ids }),
|
package/src/lib/navigation.ts
CHANGED
|
@@ -6,23 +6,26 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Context } from "hono";
|
|
8
8
|
import { getSiteName } from "./config.js";
|
|
9
|
-
import type {
|
|
10
|
-
import {
|
|
9
|
+
import type { Collection, NavItemView } from "../types.js";
|
|
10
|
+
import { toNavItemViews } from "./view.js";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Navigation data needed by SiteLayout
|
|
14
14
|
*/
|
|
15
15
|
export interface NavigationData {
|
|
16
|
-
links:
|
|
16
|
+
links: NavItemView[];
|
|
17
17
|
currentPath: string;
|
|
18
18
|
siteName: string;
|
|
19
|
+
siteDescription: string;
|
|
20
|
+
isAuthenticated: boolean;
|
|
21
|
+
collections: Collection[];
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* Fetch navigation data for public pages.
|
|
23
26
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
27
|
+
* Returns NavItemView[] with pre-computed isActive/isExternal state.
|
|
28
|
+
* Also checks authentication status and loads collections for authenticated users.
|
|
26
29
|
*
|
|
27
30
|
* @param c - Hono context
|
|
28
31
|
* @returns Navigation data for SiteLayout
|
|
@@ -38,9 +41,43 @@ export interface NavigationData {
|
|
|
38
41
|
* ```
|
|
39
42
|
*/
|
|
40
43
|
export async function getNavigationData(c: Context): Promise<NavigationData> {
|
|
41
|
-
const
|
|
44
|
+
const items = await c.var.services.navItems.list();
|
|
42
45
|
const currentPath = new URL(c.req.url).pathname;
|
|
43
46
|
const siteName = await getSiteName(c);
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
|
|
48
|
+
// Only include description if explicitly set (DB or env), not the default
|
|
49
|
+
const dbDescription = await c.var.services.settings.get("SITE_DESCRIPTION");
|
|
50
|
+
const envDescription = c.env.SITE_DESCRIPTION;
|
|
51
|
+
const siteDescription =
|
|
52
|
+
dbDescription || (typeof envDescription === "string" ? envDescription : "");
|
|
53
|
+
|
|
54
|
+
const links = toNavItemViews(items, currentPath);
|
|
55
|
+
|
|
56
|
+
// Check auth status for compose button
|
|
57
|
+
let isAuthenticated = false;
|
|
58
|
+
let collections: Collection[] = [];
|
|
59
|
+
if (c.var.auth) {
|
|
60
|
+
try {
|
|
61
|
+
const session = await c.var.auth.api.getSession({
|
|
62
|
+
headers: c.req.raw.headers,
|
|
63
|
+
});
|
|
64
|
+
isAuthenticated = !!session?.user;
|
|
65
|
+
} catch {
|
|
66
|
+
// Not authenticated
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Only load collections when authenticated (for compose dialog)
|
|
71
|
+
if (isAuthenticated) {
|
|
72
|
+
collections = await c.var.services.collections.list();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
links,
|
|
77
|
+
currentPath,
|
|
78
|
+
siteName,
|
|
79
|
+
siteDescription,
|
|
80
|
+
isAuthenticated,
|
|
81
|
+
collections,
|
|
82
|
+
};
|
|
46
83
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination Utilities
|
|
3
|
+
*
|
|
4
|
+
* Pure utility functions for page-based pagination.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Computes which page numbers to display in a numbered pagination control.
|
|
9
|
+
* Always includes: first page, last page, current page, and 1 page on each side of current.
|
|
10
|
+
* Gaps between non-consecutive pages are represented by 0 (ellipsis marker).
|
|
11
|
+
*
|
|
12
|
+
* @param currentPage - The current active page (1-indexed)
|
|
13
|
+
* @param totalPages - Total number of pages
|
|
14
|
+
* @returns Array of page numbers, with 0 representing ellipsis gaps
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* getPageNumbers(1, 5) // [1, 2, 3, 4, 5]
|
|
19
|
+
* getPageNumbers(1, 20) // [1, 2, 0, 20]
|
|
20
|
+
* getPageNumbers(10, 20) // [1, 0, 9, 10, 11, 0, 20]
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function getPageNumbers(
|
|
24
|
+
currentPage: number,
|
|
25
|
+
totalPages: number,
|
|
26
|
+
): number[] {
|
|
27
|
+
if (totalPages <= 7) {
|
|
28
|
+
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pages = new Set<number>();
|
|
32
|
+
pages.add(1);
|
|
33
|
+
pages.add(totalPages);
|
|
34
|
+
pages.add(currentPage);
|
|
35
|
+
if (currentPage > 1) pages.add(currentPage - 1);
|
|
36
|
+
if (currentPage < totalPages) pages.add(currentPage + 1);
|
|
37
|
+
|
|
38
|
+
const sorted = [...pages].sort((a, b) => a - b);
|
|
39
|
+
|
|
40
|
+
// Insert 0 for gaps
|
|
41
|
+
const result: number[] = [];
|
|
42
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
43
|
+
if (i > 0 && sorted[i]! - sorted[i - 1]! > 1) {
|
|
44
|
+
result.push(0); // ellipsis marker
|
|
45
|
+
}
|
|
46
|
+
result.push(sorted[i]!);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
}
|
package/src/lib/render.tsx
CHANGED
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides a single entry point for rendering public pages with the
|
|
5
5
|
* correct layout stack: BaseLayout > SiteLayout > content.
|
|
6
|
-
*
|
|
7
|
-
* BaseLayout is always the built-in implementation (handles Vite assets,
|
|
8
|
-
* I18nProvider, toast). SiteLayout is resolved from theme components.
|
|
9
6
|
*/
|
|
10
7
|
|
|
11
8
|
import type { Context } from "hono";
|
|
12
9
|
import type { Child } from "hono/jsx";
|
|
13
|
-
import type {
|
|
14
|
-
import { BaseLayout } from "../
|
|
15
|
-
import { SiteLayout
|
|
10
|
+
import type { SiteLayoutProps } from "../types.js";
|
|
11
|
+
import { BaseLayout } from "../ui/layouts/BaseLayout.js";
|
|
12
|
+
import { SiteLayout } from "../ui/layouts/SiteLayout.js";
|
|
16
13
|
import type { NavigationData } from "./navigation.js";
|
|
17
14
|
|
|
18
15
|
export interface RenderPublicPageOptions {
|
|
@@ -29,8 +26,6 @@ export interface RenderPublicPageOptions {
|
|
|
29
26
|
/**
|
|
30
27
|
* Render a public page with the standard layout stack.
|
|
31
28
|
*
|
|
32
|
-
* Always uses the built-in BaseLayout, resolves SiteLayout from theme config.
|
|
33
|
-
*
|
|
34
29
|
* @param c - Hono context
|
|
35
30
|
* @param options - Page rendering options
|
|
36
31
|
* @returns Hono HTML response
|
|
@@ -48,20 +43,18 @@ export interface RenderPublicPageOptions {
|
|
|
48
43
|
export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
|
|
49
44
|
const { title, description, navData, content } = options;
|
|
50
45
|
|
|
51
|
-
const components = c.var.config?.theme?.components as
|
|
52
|
-
| ThemeComponents
|
|
53
|
-
| undefined;
|
|
54
|
-
const Layout = components?.SiteLayout ?? DefaultSiteLayout;
|
|
55
|
-
|
|
56
46
|
const layoutProps: SiteLayoutProps = {
|
|
57
47
|
siteName: navData.siteName,
|
|
48
|
+
siteDescription: navData.siteDescription,
|
|
58
49
|
links: navData.links,
|
|
59
50
|
currentPath: navData.currentPath,
|
|
51
|
+
isAuthenticated: navData.isAuthenticated,
|
|
52
|
+
collections: navData.collections,
|
|
60
53
|
};
|
|
61
54
|
|
|
62
55
|
return c.html(
|
|
63
56
|
<BaseLayout title={title} description={description} c={c}>
|
|
64
|
-
<
|
|
57
|
+
<SiteLayout {...layoutProps}>{content}</SiteLayout>
|
|
65
58
|
</BaseLayout>,
|
|
66
59
|
);
|
|
67
60
|
}
|
package/src/lib/schemas.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared Zod schemas for validation
|
|
2
|
+
* Shared Zod schemas for validation (v2)
|
|
3
3
|
*
|
|
4
4
|
* These schemas ensure type-safe validation of user input
|
|
5
5
|
* from forms, API requests, and other external sources.
|
|
@@ -10,24 +10,34 @@
|
|
|
10
10
|
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
import {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
FORMATS,
|
|
14
|
+
STATUSES,
|
|
15
|
+
SORT_ORDERS,
|
|
16
|
+
NAV_ITEM_TYPES,
|
|
15
17
|
MAX_MEDIA_ATTACHMENTS,
|
|
16
|
-
POST_TYPE_MEDIA_RULES,
|
|
17
18
|
} from "../types.js";
|
|
18
|
-
import type { PostType } from "../types.js";
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Post
|
|
22
|
-
* Based on
|
|
21
|
+
* Post format enum schema
|
|
22
|
+
* Based on FORMATS from types.ts
|
|
23
23
|
*/
|
|
24
|
-
export const
|
|
24
|
+
export const FormatSchema = z.enum(FORMATS);
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* Based on
|
|
27
|
+
* Post status enum schema
|
|
28
|
+
* Based on STATUSES from types.ts
|
|
29
29
|
*/
|
|
30
|
-
export const
|
|
30
|
+
export const StatusSchema = z.enum(STATUSES);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Collection sort order enum schema
|
|
34
|
+
*/
|
|
35
|
+
export const SortOrderSchema = z.enum(SORT_ORDERS);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Navigation item type enum schema
|
|
39
|
+
*/
|
|
40
|
+
export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
|
|
31
41
|
|
|
32
42
|
/**
|
|
33
43
|
* Redirect type enum schema
|
|
@@ -35,21 +45,47 @@ export const VisibilitySchema = z.enum(VISIBILITY_LEVELS);
|
|
|
35
45
|
*/
|
|
36
46
|
export const RedirectTypeSchema = z.enum(["301", "302"]);
|
|
37
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Rating schema (1-5 integer)
|
|
50
|
+
*/
|
|
51
|
+
export const RatingSchema = z.coerce
|
|
52
|
+
.number()
|
|
53
|
+
.int()
|
|
54
|
+
.min(0)
|
|
55
|
+
.max(5)
|
|
56
|
+
.optional()
|
|
57
|
+
.or(z.literal("").transform(() => undefined))
|
|
58
|
+
.transform((v) => (v === 0 ? undefined : v));
|
|
59
|
+
|
|
38
60
|
/**
|
|
39
61
|
* API request body schema for creating a post
|
|
40
62
|
*/
|
|
41
63
|
export const CreatePostSchema = z.object({
|
|
42
|
-
|
|
43
|
-
title: z.string().optional(),
|
|
44
|
-
content: z.string(),
|
|
45
|
-
visibility: VisibilitySchema,
|
|
46
|
-
sourceUrl: z.url().optional().or(z.literal("")),
|
|
47
|
-
sourceName: z.string().optional(),
|
|
64
|
+
format: FormatSchema,
|
|
48
65
|
path: z
|
|
49
66
|
.string()
|
|
50
|
-
.regex(/^[a-z0-9-]*$/)
|
|
67
|
+
.regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\/[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/)
|
|
68
|
+
.optional()
|
|
69
|
+
.or(z.literal("").transform(() => undefined)),
|
|
70
|
+
title: z.string().optional(),
|
|
71
|
+
body: z.string().optional(),
|
|
72
|
+
status: StatusSchema.optional(),
|
|
73
|
+
featured: z
|
|
74
|
+
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
75
|
+
.optional(),
|
|
76
|
+
pinned: z
|
|
77
|
+
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
78
|
+
.optional(),
|
|
79
|
+
url: z.url().optional().or(z.literal("")),
|
|
80
|
+
quoteText: z.string().optional(),
|
|
81
|
+
rating: RatingSchema,
|
|
82
|
+
collectionId: z.coerce
|
|
83
|
+
.number()
|
|
84
|
+
.int()
|
|
85
|
+
.min(0)
|
|
51
86
|
.optional()
|
|
52
|
-
.or(z.literal(""))
|
|
87
|
+
.or(z.literal("").transform(() => undefined))
|
|
88
|
+
.transform((v) => (v === 0 ? undefined : v)),
|
|
53
89
|
replyToId: z.string().optional(), // Sqid format
|
|
54
90
|
publishedAt: z.number().int().positive().optional(),
|
|
55
91
|
mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
|
|
@@ -60,13 +96,70 @@ export const CreatePostSchema = z.object({
|
|
|
60
96
|
*/
|
|
61
97
|
export const UpdatePostSchema = CreatePostSchema.partial();
|
|
62
98
|
|
|
99
|
+
/**
|
|
100
|
+
* API request body schema for creating a page
|
|
101
|
+
*/
|
|
102
|
+
export const CreatePageSchema = z.object({
|
|
103
|
+
slug: z
|
|
104
|
+
.string()
|
|
105
|
+
.min(1)
|
|
106
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
107
|
+
title: z.string().optional(),
|
|
108
|
+
body: z.string().optional(),
|
|
109
|
+
status: StatusSchema.optional(),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* API request body schema for updating a page
|
|
114
|
+
*/
|
|
115
|
+
export const UpdatePageSchema = CreatePageSchema.partial();
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* API request body schema for creating a navigation item
|
|
119
|
+
*/
|
|
120
|
+
export const CreateNavItemSchema = z.object({
|
|
121
|
+
type: NavItemTypeSchema,
|
|
122
|
+
label: z.string().min(1),
|
|
123
|
+
url: z.string().min(1),
|
|
124
|
+
pageId: z.coerce.number().int().positive().optional(),
|
|
125
|
+
position: z.coerce.number().int().min(0).optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* API request body schema for updating a navigation item
|
|
130
|
+
*/
|
|
131
|
+
export const UpdateNavItemSchema = CreateNavItemSchema.partial();
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* API request body schema for creating a collection
|
|
135
|
+
*/
|
|
136
|
+
export const CreateCollectionSchema = z.object({
|
|
137
|
+
slug: z
|
|
138
|
+
.string()
|
|
139
|
+
.min(1)
|
|
140
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
141
|
+
title: z.string().min(1),
|
|
142
|
+
description: z.string().optional(),
|
|
143
|
+
icon: z.string().optional(),
|
|
144
|
+
sortOrder: SortOrderSchema.optional(),
|
|
145
|
+
position: z.coerce.number().int().min(0).optional(),
|
|
146
|
+
showDivider: z
|
|
147
|
+
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
148
|
+
.optional(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* API request body schema for updating a collection
|
|
153
|
+
*/
|
|
154
|
+
export const UpdateCollectionSchema = CreateCollectionSchema.partial();
|
|
155
|
+
|
|
63
156
|
/**
|
|
64
157
|
* Form data helper: safely parse a FormData value with a schema
|
|
65
158
|
*
|
|
66
159
|
* @example
|
|
67
160
|
* ```ts
|
|
68
|
-
* const
|
|
69
|
-
* //
|
|
161
|
+
* const format = parseFormData(formData, "format", FormatSchema);
|
|
162
|
+
* // format is Format, throws if invalid
|
|
70
163
|
* ```
|
|
71
164
|
*/
|
|
72
165
|
export function parseFormData<T>(
|
|
@@ -103,40 +196,15 @@ export function parseFormDataOptional<T>(
|
|
|
103
196
|
}
|
|
104
197
|
|
|
105
198
|
/**
|
|
106
|
-
* Validates media attachment count
|
|
199
|
+
* Validates media attachment count for a post.
|
|
200
|
+
* All formats allow 0-20 media attachments.
|
|
107
201
|
*
|
|
108
|
-
* @param type - The post type to validate against
|
|
109
202
|
* @param mediaIds - Array of media IDs to attach
|
|
110
203
|
* @returns null if valid, error string if invalid
|
|
111
|
-
*
|
|
112
|
-
* @example
|
|
113
|
-
* ```ts
|
|
114
|
-
* const error = validateMediaForPostType("image", []);
|
|
115
|
-
* // Returns: "image posts require at least 1 media attachment"
|
|
116
|
-
* ```
|
|
117
204
|
*/
|
|
118
|
-
export function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
): string | null {
|
|
122
|
-
const rules = POST_TYPE_MEDIA_RULES[type];
|
|
123
|
-
|
|
124
|
-
if (rules === null) {
|
|
125
|
-
if (mediaIds.length > 0) {
|
|
126
|
-
return `${type} posts do not allow media attachments`;
|
|
127
|
-
}
|
|
128
|
-
return null;
|
|
205
|
+
export function validateMediaCount(mediaIds: string[]): string | null {
|
|
206
|
+
if (mediaIds.length > MAX_MEDIA_ATTACHMENTS) {
|
|
207
|
+
return `Posts allow at most ${MAX_MEDIA_ATTACHMENTS} media attachments`;
|
|
129
208
|
}
|
|
130
|
-
|
|
131
|
-
const [min, max] = rules;
|
|
132
|
-
|
|
133
|
-
if (mediaIds.length < min) {
|
|
134
|
-
return `${type} posts require at least ${min} media attachment${min !== 1 ? "s" : ""}`;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (mediaIds.length > max) {
|
|
138
|
-
return `${type} posts allow at most ${max} media attachment${max !== 1 ? "s" : ""}`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
209
|
return null;
|
|
142
210
|
}
|
package/src/lib/theme.ts
CHANGED
|
@@ -4,14 +4,14 @@
|
|
|
4
4
|
* Resolves the active color theme and builds CSS for injection into `<head>`.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ColorTheme } from "../
|
|
8
|
-
import { BUILTIN_COLOR_THEMES } from "../
|
|
7
|
+
import type { ColorTheme } from "../ui/color-themes.js";
|
|
8
|
+
import { BUILTIN_COLOR_THEMES } from "../ui/color-themes.js";
|
|
9
9
|
import type { JantConfig } from "../types.js";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Get the list of available color themes.
|
|
13
13
|
*
|
|
14
|
-
* Returns `config.
|
|
14
|
+
* Returns `config.colorThemes` if provided, otherwise the built-in list.
|
|
15
15
|
*
|
|
16
16
|
* @param config - The Jant configuration
|
|
17
17
|
* @returns Array of available color themes
|
|
@@ -22,7 +22,7 @@ import type { JantConfig } from "../types.js";
|
|
|
22
22
|
* ```
|
|
23
23
|
*/
|
|
24
24
|
export function getAvailableThemes(config: JantConfig): ColorTheme[] {
|
|
25
|
-
return config.
|
|
25
|
+
return config.colorThemes ?? BUILTIN_COLOR_THEMES;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -32,7 +32,7 @@ export function getAvailableThemes(config: JantConfig): ColorTheme[] {
|
|
|
32
32
|
* BaseCoat defaults → selected theme → cssVariables
|
|
33
33
|
*
|
|
34
34
|
* @param theme - The active color theme (undefined = no theme overrides)
|
|
35
|
-
* @param cssVariables - Extra CSS variable overrides from `createApp({
|
|
35
|
+
* @param cssVariables - Extra CSS variable overrides from `createApp({ cssVariables })`
|
|
36
36
|
* @returns CSS string to inject in `<head>`, or empty string if nothing to inject
|
|
37
37
|
*
|
|
38
38
|
* Uses `:root:root` and `:root.dark` selectors for higher specificity than
|
package/src/lib/time.ts
CHANGED
|
@@ -109,6 +109,70 @@ export function formatDate(timestamp: number): string {
|
|
|
109
109
|
* // Returns: "2024-02"
|
|
110
110
|
* ```
|
|
111
111
|
*/
|
|
112
|
+
/**
|
|
113
|
+
* Formats a Unix timestamp as a 24-hour time string (HH:MM).
|
|
114
|
+
*
|
|
115
|
+
* Converts a Unix timestamp (in seconds) to a zero-padded time string in
|
|
116
|
+
* 24-hour format. Always uses UTC timezone for consistency.
|
|
117
|
+
*
|
|
118
|
+
* @param timestamp - Unix timestamp in seconds to format
|
|
119
|
+
* @returns Formatted time string in "HH:MM" format
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* const time = formatTime(1706745600);
|
|
124
|
+
* // Returns: "00:00"
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export function formatTime(timestamp: number): string {
|
|
128
|
+
const date = new Date(timestamp * 1000);
|
|
129
|
+
const hours = String(date.getUTCHours()).padStart(2, "0");
|
|
130
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
|
|
131
|
+
return `${hours}:${minutes}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Formats a Unix timestamp as a short relative time string.
|
|
136
|
+
*
|
|
137
|
+
* Returns compact labels like "1m", "5h", "3d" for recent timestamps,
|
|
138
|
+
* and falls back to "MMM D" (e.g. "Feb 1") for anything older than 7 days.
|
|
139
|
+
*
|
|
140
|
+
* @param timestamp - Unix timestamp in seconds
|
|
141
|
+
* @returns Short relative time string
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```ts
|
|
145
|
+
* // Assuming current time is Feb 16, 2026
|
|
146
|
+
* formatRelativeTime(now() - 30); // "1m" (30 seconds → rounds up)
|
|
147
|
+
* formatRelativeTime(now() - 3600); // "1h"
|
|
148
|
+
* formatRelativeTime(now() - 86400); // "1d"
|
|
149
|
+
* formatRelativeTime(now() - 604800); // "7d"
|
|
150
|
+
* formatRelativeTime(now() - 864000); // "Feb 6"
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export function formatRelativeTime(timestamp: number): string {
|
|
154
|
+
const seconds = now() - timestamp;
|
|
155
|
+
|
|
156
|
+
if (seconds < 60) return "1m";
|
|
157
|
+
|
|
158
|
+
const minutes = Math.floor(seconds / 60);
|
|
159
|
+
if (minutes < 60) return `${minutes}m`;
|
|
160
|
+
|
|
161
|
+
const hours = Math.floor(seconds / 3600);
|
|
162
|
+
if (hours < 24) return `${hours}h`;
|
|
163
|
+
|
|
164
|
+
const days = Math.floor(seconds / 86400);
|
|
165
|
+
if (days <= 7) return `${days}d`;
|
|
166
|
+
|
|
167
|
+
// Older than 7 days: show "MMM D" (e.g. "Feb 1")
|
|
168
|
+
const date = new Date(timestamp * 1000);
|
|
169
|
+
return date.toLocaleDateString("en-US", {
|
|
170
|
+
month: "short",
|
|
171
|
+
day: "numeric",
|
|
172
|
+
timeZone: "UTC",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
112
176
|
export function formatYearMonth(timestamp: number): string {
|
|
113
177
|
const date = new Date(timestamp * 1000);
|
|
114
178
|
const year = date.getUTCFullYear();
|