@jant/core 0.3.24 → 0.3.26
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 +101 -571
- package/dist/client.js +1 -0
- 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/avatar-upload.js +134 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/constants.js +10 -9
- package/dist/lib/favicon.js +102 -0
- package/dist/lib/image.js +13 -17
- package/dist/lib/media-helpers.js +2 -2
- package/dist/lib/nav-reorder.js +1 -1
- package/dist/lib/navigation.js +48 -3
- package/dist/lib/pagination.js +44 -0
- package/dist/lib/render.js +16 -11
- package/dist/lib/schemas.js +34 -3
- package/dist/lib/theme.js +4 -4
- package/dist/lib/timeline.js +24 -48
- package/dist/lib/timezones.js +388 -0
- package/dist/lib/view.js +3 -3
- 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 +3 -3
- package/dist/routes/api/search.js +2 -2
- package/dist/routes/api/settings.js +68 -0
- package/dist/routes/api/upload.js +3 -3
- package/dist/routes/auth/reset.js +221 -0
- package/dist/routes/auth/setup.js +194 -0
- package/dist/routes/auth/signin.js +176 -0
- package/dist/routes/compose.js +48 -0
- package/dist/routes/dash/collections.js +24 -416
- package/dist/routes/dash/index.js +1 -1
- package/dist/routes/dash/media.js +13 -393
- package/dist/routes/dash/pages.js +112 -86
- package/dist/routes/dash/posts.js +3 -5
- package/dist/routes/dash/redirects.js +20 -14
- package/dist/routes/dash/settings.js +213 -518
- package/dist/routes/feed/rss.js +4 -3
- package/dist/routes/feed/sitemap.js +5 -3
- 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 +36 -0
- package/dist/routes/pages/home.js +33 -49
- package/dist/routes/pages/latest.js +45 -0
- 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 +45 -31
- package/dist/services/search.js +1 -1
- package/dist/types/bindings.js +3 -0
- package/dist/types/config.js +147 -0
- package/dist/types/constants.js +27 -0
- package/dist/types/entities.js +3 -0
- package/dist/types/operations.js +3 -0
- package/dist/types/props.js +3 -0
- package/dist/types/views.js +5 -0
- package/dist/types.js +8 -111
- package/dist/{theme → ui}/color-themes.js +33 -33
- package/dist/ui/compose/ComposeDialog.js +467 -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}/PageForm.js +21 -15
- package/dist/{theme/components → ui/dash}/PostForm.js +22 -43
- package/dist/{theme/components → ui/dash}/PostList.js +6 -6
- package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
- package/dist/ui/dash/collections/CollectionForm.js +152 -0
- package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
- package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
- package/dist/{theme/components → ui/dash}/index.js +3 -6
- package/dist/ui/dash/media/MediaListContent.js +166 -0
- package/dist/ui/dash/media/ViewMediaContent.js +212 -0
- package/dist/ui/dash/pages/LinkFormContent.js +130 -0
- package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
- package/dist/ui/dash/settings/AccountContent.js +209 -0
- package/dist/ui/dash/settings/AppearanceContent.js +259 -0
- package/dist/ui/dash/settings/GeneralContent.js +536 -0
- package/dist/ui/dash/settings/SettingsNav.js +41 -0
- 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/ui/font-themes.js +36 -0
- package/dist/{theme → ui}/layouts/BaseLayout.js +34 -2
- package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
- package/dist/ui/layouts/SiteLayout.js +169 -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 +131 -561
- package/src/client.ts +1 -0
- 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 +477 -261
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +477 -261
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +477 -261
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/index.ts +7 -36
- package/src/lib/__tests__/config.test.ts +192 -0
- package/src/lib/__tests__/favicon.test.ts +151 -0
- package/src/lib/__tests__/image.test.ts +2 -6
- package/src/lib/__tests__/schemas.test.ts +60 -19
- package/src/lib/__tests__/timeline.test.ts +45 -81
- package/src/lib/__tests__/timezones.test.ts +61 -0
- package/src/lib/__tests__/view.test.ts +15 -9
- package/src/lib/avatar-upload.ts +165 -0
- package/src/lib/config.ts +47 -0
- package/src/lib/constants.ts +19 -10
- package/src/lib/favicon.ts +115 -0
- package/src/lib/image.ts +13 -21
- package/src/lib/media-helpers.ts +2 -2
- package/src/lib/nav-reorder.ts +1 -1
- package/src/lib/navigation.ts +73 -4
- package/src/lib/pagination.ts +50 -0
- package/src/lib/render.tsx +22 -15
- package/src/lib/schemas.ts +47 -6
- package/src/lib/theme.ts +5 -5
- package/src/lib/timeline.ts +28 -57
- package/src/lib/timezones.ts +325 -0
- package/src/lib/view.ts +3 -3
- 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 +3 -3
- package/src/routes/api/search.ts +2 -2
- package/src/routes/api/settings.ts +91 -0
- package/src/routes/api/upload.ts +2 -3
- package/src/routes/auth/reset.tsx +239 -0
- package/src/routes/auth/setup.tsx +189 -0
- package/src/routes/auth/signin.tsx +163 -0
- package/src/routes/compose.ts +63 -0
- package/src/routes/dash/__tests__/pages.test.ts +225 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
- package/src/routes/dash/collections.tsx +18 -367
- package/src/routes/dash/index.tsx +1 -1
- package/src/routes/dash/media.tsx +13 -415
- package/src/routes/dash/pages.tsx +131 -98
- package/src/routes/dash/posts.tsx +3 -7
- package/src/routes/dash/redirects.tsx +22 -16
- package/src/routes/dash/settings.tsx +265 -478
- package/src/routes/feed/__tests__/rss.test.ts +141 -0
- package/src/routes/feed/rss.ts +5 -3
- package/src/routes/feed/sitemap.ts +5 -3
- 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 +44 -0
- package/src/routes/pages/home.tsx +30 -53
- package/src/routes/pages/latest.tsx +59 -0
- 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 +58 -40
- package/src/services/search.ts +2 -2
- package/src/styles/components.css +0 -65
- package/src/styles/tokens.css +47 -0
- package/src/styles/ui.css +475 -0
- package/src/types/bindings.ts +30 -0
- package/src/types/config.ts +183 -0
- package/src/types/constants.ts +26 -0
- package/src/types/entities.ts +109 -0
- package/src/types/operations.ts +88 -0
- package/src/types/props.ts +115 -0
- package/src/types/views.ts +172 -0
- package/src/types.ts +8 -774
- package/src/ui/__tests__/font-themes.test.ts +34 -0
- package/src/{theme → ui}/color-themes.ts +34 -34
- package/src/ui/compose/ComposeDialog.tsx +414 -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}/PageForm.tsx +25 -19
- package/src/{theme/components → ui/dash}/PostForm.tsx +26 -45
- 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/collections/CollectionForm.tsx +153 -0
- package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
- package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
- package/src/ui/dash/index.ts +10 -0
- package/src/ui/dash/media/MediaListContent.tsx +201 -0
- package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
- package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
- package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
- package/src/ui/dash/settings/AccountContent.tsx +176 -0
- package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
- package/src/ui/dash/settings/GeneralContent.tsx +533 -0
- package/src/ui/dash/settings/SettingsNav.tsx +56 -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/ui/font-themes.ts +54 -0
- package/src/{theme → ui}/layouts/BaseLayout.tsx +28 -1
- package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
- package/src/ui/layouts/SiteLayout.tsx +164 -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/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/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
|
@@ -12,9 +12,8 @@ import { createTestDatabase } from "../../__tests__/helpers/db.js";
|
|
|
12
12
|
import { createPostService } from "../../services/post.js";
|
|
13
13
|
import { createMediaService } from "../../services/media.js";
|
|
14
14
|
import { buildMediaMap } from "../media-helpers.js";
|
|
15
|
-
import { groupByDate } from "../timeline.js";
|
|
16
15
|
import type { Database } from "../../db/index.js";
|
|
17
|
-
import type { PostWithMedia
|
|
16
|
+
import type { PostWithMedia } from "../../types.js";
|
|
18
17
|
|
|
19
18
|
describe("Timeline data assembly", () => {
|
|
20
19
|
let db: Database;
|
|
@@ -184,101 +183,66 @@ describe("Timeline data assembly", () => {
|
|
|
184
183
|
expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
|
|
185
184
|
});
|
|
186
185
|
|
|
187
|
-
it("
|
|
188
|
-
for (let i = 0; i <
|
|
186
|
+
it("supports offset-based pagination for page navigation", async () => {
|
|
187
|
+
for (let i = 0; i < 5; i++) {
|
|
189
188
|
await postService.create({
|
|
190
189
|
format: "note",
|
|
191
190
|
body: `Post ${i}`,
|
|
191
|
+
publishedAt: 1000 + i,
|
|
192
192
|
});
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
// Request limit + 1 to check for more
|
|
196
195
|
const pageSize = 2;
|
|
197
|
-
|
|
196
|
+
|
|
197
|
+
// Page 1
|
|
198
|
+
const page1 = await postService.list({
|
|
198
199
|
status: "published",
|
|
199
200
|
excludeReplies: true,
|
|
200
|
-
limit: pageSize
|
|
201
|
+
limit: pageSize,
|
|
202
|
+
offset: 0,
|
|
201
203
|
});
|
|
204
|
+
expect(page1).toHaveLength(2);
|
|
205
|
+
expect(page1[0]?.body).toBe("Post 4");
|
|
206
|
+
expect(page1[1]?.body).toBe("Post 3");
|
|
202
207
|
|
|
203
|
-
|
|
204
|
-
|
|
208
|
+
// Page 2
|
|
209
|
+
const page2 = await postService.list({
|
|
210
|
+
status: "published",
|
|
211
|
+
excludeReplies: true,
|
|
212
|
+
limit: pageSize,
|
|
213
|
+
offset: 2,
|
|
214
|
+
});
|
|
215
|
+
expect(page2).toHaveLength(2);
|
|
216
|
+
expect(page2[0]?.body).toBe("Post 2");
|
|
217
|
+
expect(page2[1]?.body).toBe("Post 1");
|
|
205
218
|
|
|
206
|
-
|
|
207
|
-
|
|
219
|
+
// Page 3 (partial)
|
|
220
|
+
const page3 = await postService.list({
|
|
221
|
+
status: "published",
|
|
222
|
+
excludeReplies: true,
|
|
223
|
+
limit: pageSize,
|
|
224
|
+
offset: 4,
|
|
225
|
+
});
|
|
226
|
+
expect(page3).toHaveLength(1);
|
|
227
|
+
expect(page3[0]?.body).toBe("Post 0");
|
|
208
228
|
});
|
|
209
|
-
});
|
|
210
229
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
post: {
|
|
215
|
-
id: 1,
|
|
216
|
-
permalink: "/p/1",
|
|
230
|
+
it("computes total pages from count", async () => {
|
|
231
|
+
for (let i = 0; i < 5; i++) {
|
|
232
|
+
await postService.create({
|
|
217
233
|
format: "note",
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
publishedAt: `${dateStr}T12:00:00.000Z`,
|
|
222
|
-
publishedAtFormatted: formatted,
|
|
223
|
-
publishedAtTime: "12:00",
|
|
224
|
-
publishedAtRelative: "1d",
|
|
225
|
-
updatedAt: `${dateStr}T12:00:00.000Z`,
|
|
226
|
-
media: [],
|
|
227
|
-
},
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
it("returns empty array for empty input", () => {
|
|
232
|
-
expect(groupByDate([])).toEqual([]);
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("groups items by YYYY-MM-DD date key", () => {
|
|
236
|
-
const items = [
|
|
237
|
-
makeItem("2024-02-01", "Feb 1, 2024"),
|
|
238
|
-
makeItem("2024-02-01", "Feb 1, 2024"),
|
|
239
|
-
makeItem("2024-02-02", "Feb 2, 2024"),
|
|
240
|
-
];
|
|
241
|
-
|
|
242
|
-
const groups = groupByDate(items);
|
|
243
|
-
expect(groups).toHaveLength(2);
|
|
244
|
-
expect(groups[0]?.dateKey).toBe("2024-02-01");
|
|
245
|
-
expect(groups[0]?.label).toBe("Feb 1, 2024");
|
|
246
|
-
expect(groups[0]?.items).toHaveLength(2);
|
|
247
|
-
expect(groups[1]?.dateKey).toBe("2024-02-02");
|
|
248
|
-
expect(groups[1]?.items).toHaveLength(1);
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it("creates separate groups for non-contiguous same dates", () => {
|
|
252
|
-
const items = [
|
|
253
|
-
makeItem("2024-02-01", "Feb 1, 2024"),
|
|
254
|
-
makeItem("2024-02-02", "Feb 2, 2024"),
|
|
255
|
-
makeItem("2024-02-01", "Feb 1, 2024"),
|
|
256
|
-
];
|
|
257
|
-
|
|
258
|
-
const groups = groupByDate(items);
|
|
259
|
-
expect(groups).toHaveLength(3);
|
|
260
|
-
expect(groups[0]?.dateKey).toBe("2024-02-01");
|
|
261
|
-
expect(groups[1]?.dateKey).toBe("2024-02-02");
|
|
262
|
-
expect(groups[2]?.dateKey).toBe("2024-02-01");
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
it("handles a single item", () => {
|
|
266
|
-
const items = [makeItem("2024-06-15", "Jun 15, 2024")];
|
|
267
|
-
const groups = groupByDate(items);
|
|
268
|
-
expect(groups).toHaveLength(1);
|
|
269
|
-
expect(groups[0]?.dateKey).toBe("2024-06-15");
|
|
270
|
-
expect(groups[0]?.items).toHaveLength(1);
|
|
271
|
-
});
|
|
234
|
+
body: `Post ${i}`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
272
237
|
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
238
|
+
const pageSize = 2;
|
|
239
|
+
const totalCount = await postService.count({
|
|
240
|
+
status: "published",
|
|
241
|
+
excludeReplies: true,
|
|
242
|
+
});
|
|
278
243
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
expect(groups[0]?.label).toBe("Mar 10, 2024");
|
|
244
|
+
expect(totalCount).toBe(5);
|
|
245
|
+
const totalPages = Math.ceil(totalCount / pageSize);
|
|
246
|
+
expect(totalPages).toBe(3);
|
|
283
247
|
});
|
|
284
248
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { TIMEZONES, mapIanaToTimezone } from "../timezones.js";
|
|
3
|
+
|
|
4
|
+
describe("TIMEZONES", () => {
|
|
5
|
+
it("contains expected timezone entries", () => {
|
|
6
|
+
expect(TIMEZONES.length).toBeGreaterThan(30);
|
|
7
|
+
const utc = TIMEZONES.find((tz) => tz.value === "UTC");
|
|
8
|
+
expect(utc).toBeDefined();
|
|
9
|
+
expect(utc!.offset).toBe("+00:00");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("each entry has required fields", () => {
|
|
13
|
+
for (const tz of TIMEZONES) {
|
|
14
|
+
expect(tz.value).toBeTruthy();
|
|
15
|
+
expect(tz.label).toBeTruthy();
|
|
16
|
+
expect(tz.offset).toBeTruthy();
|
|
17
|
+
expect(tz.iana.length).toBeGreaterThan(0);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("has no duplicate values", () => {
|
|
22
|
+
const values = TIMEZONES.map((tz) => tz.value);
|
|
23
|
+
expect(new Set(values).size).toBe(values.length);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("mapIanaToTimezone", () => {
|
|
28
|
+
it("maps Asia/Shanghai to Beijing", () => {
|
|
29
|
+
expect(mapIanaToTimezone("Asia/Shanghai")).toBe("Beijing");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("maps America/New_York to Eastern Time", () => {
|
|
33
|
+
expect(mapIanaToTimezone("America/New_York")).toBe(
|
|
34
|
+
"Eastern Time (US & Canada)",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("maps Europe/London to London", () => {
|
|
39
|
+
expect(mapIanaToTimezone("Europe/London")).toBe("London");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("maps Asia/Tokyo to Tokyo", () => {
|
|
43
|
+
expect(mapIanaToTimezone("Asia/Tokyo")).toBe("Tokyo");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns UTC for unknown timezone", () => {
|
|
47
|
+
expect(mapIanaToTimezone("Unknown/Zone")).toBe("UTC");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns UTC for empty string", () => {
|
|
51
|
+
expect(mapIanaToTimezone("")).toBe("UTC");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("maps Pacific/Honolulu to Hawaii", () => {
|
|
55
|
+
expect(mapIanaToTimezone("Pacific/Honolulu")).toBe("Hawaii");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("maps Australia/Sydney to Sydney", () => {
|
|
59
|
+
expect(mapIanaToTimezone("Australia/Sydney")).toBe("Sydney");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -34,7 +34,7 @@ function makePost(overrides: Partial<Post> = {}): Post {
|
|
|
34
34
|
status: "published",
|
|
35
35
|
featured: 0,
|
|
36
36
|
pinned: 0,
|
|
37
|
-
|
|
37
|
+
path: null,
|
|
38
38
|
title: null,
|
|
39
39
|
url: null,
|
|
40
40
|
body: "Hello world",
|
|
@@ -100,19 +100,25 @@ function makeNavItem(overrides: Partial<NavItem> = {}): NavItem {
|
|
|
100
100
|
// =============================================================================
|
|
101
101
|
|
|
102
102
|
describe("toPostView", () => {
|
|
103
|
-
it("generates permalink from post id when no
|
|
104
|
-
const post = makePostWithMedia({ id: 123,
|
|
103
|
+
it("generates permalink from post id when no path", () => {
|
|
104
|
+
const post = makePostWithMedia({ id: 123, path: null });
|
|
105
105
|
const view = toPostView(post, EMPTY_CTX);
|
|
106
106
|
expect(view.permalink).toMatch(/^\/p\/.+$/);
|
|
107
107
|
expect(view.permalink.length).toBeGreaterThan(3);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it("generates permalink from
|
|
111
|
-
const post = makePostWithMedia({ id: 123,
|
|
110
|
+
it("generates permalink from path when path is set", () => {
|
|
111
|
+
const post = makePostWithMedia({ id: 123, path: "my-post" });
|
|
112
112
|
const view = toPostView(post, EMPTY_CTX);
|
|
113
113
|
expect(view.permalink).toBe("/my-post");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
it("generates permalink from multi-level path", () => {
|
|
117
|
+
const post = makePostWithMedia({ id: 123, path: "2024/01/my-post" });
|
|
118
|
+
const view = toPostView(post, EMPTY_CTX);
|
|
119
|
+
expect(view.permalink).toBe("/2024/01/my-post");
|
|
120
|
+
});
|
|
121
|
+
|
|
116
122
|
it("formats dates correctly", () => {
|
|
117
123
|
const post = makePostWithMedia({ publishedAt: 1706745600 });
|
|
118
124
|
const view = toPostView(post, EMPTY_CTX);
|
|
@@ -202,7 +208,7 @@ describe("toPostView", () => {
|
|
|
202
208
|
it("converts null fields to undefined", () => {
|
|
203
209
|
const view = toPostView(makePostWithMedia(), EMPTY_CTX);
|
|
204
210
|
expect(view.title).toBeUndefined();
|
|
205
|
-
expect(view.
|
|
211
|
+
expect(view.path).toBeUndefined();
|
|
206
212
|
expect(view.url).toBeUndefined();
|
|
207
213
|
expect(view.quoteText).toBeUndefined();
|
|
208
214
|
expect(view.rating).toBeUndefined();
|
|
@@ -322,8 +328,8 @@ describe("toMediaView", () => {
|
|
|
322
328
|
it("generates local proxy URL without public URL", () => {
|
|
323
329
|
const media = makeMedia();
|
|
324
330
|
const view = toMediaView(media, EMPTY_CTX);
|
|
325
|
-
expect(view.url).toBe("/media/01902a9f-1a2b-7c3d.webp");
|
|
326
|
-
expect(view.thumbnailUrl).toBe("/media/01902a9f-1a2b-7c3d.webp");
|
|
331
|
+
expect(view.url).toBe("/media/2025/01/01902a9f-1a2b-7c3d.webp");
|
|
332
|
+
expect(view.thumbnailUrl).toBe("/media/2025/01/01902a9f-1a2b-7c3d.webp");
|
|
327
333
|
});
|
|
328
334
|
|
|
329
335
|
it("generates CDN URL with public URL", () => {
|
|
@@ -465,7 +471,7 @@ describe("toSearchResultView", () => {
|
|
|
465
471
|
featured: 1,
|
|
466
472
|
pinned: 0,
|
|
467
473
|
url: "https://example.com",
|
|
468
|
-
|
|
474
|
+
path: "my-link",
|
|
469
475
|
}),
|
|
470
476
|
rank: 0.8,
|
|
471
477
|
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side Avatar Upload Handler
|
|
3
|
+
*
|
|
4
|
+
* Intercepts avatar file selection to generate favicon variants
|
|
5
|
+
* before uploading. Generates:
|
|
6
|
+
* - favicon.ico (ICO containing 16x16 + 32x32 PNGs)
|
|
7
|
+
* - apple-touch-icon.png (180x180 PNG)
|
|
8
|
+
*
|
|
9
|
+
* Uses the `[data-avatar-upload]` attribute on file inputs.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { encodeIco } from "./favicon.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load an image from a File object
|
|
16
|
+
*/
|
|
17
|
+
function loadImage(file: File): Promise<HTMLImageElement> {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const img = new Image();
|
|
20
|
+
img.onload = () => {
|
|
21
|
+
URL.revokeObjectURL(img.src);
|
|
22
|
+
resolve(img);
|
|
23
|
+
};
|
|
24
|
+
img.onerror = () => reject(new Error("Failed to load image"));
|
|
25
|
+
img.src = URL.createObjectURL(file);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resize image to a square PNG using center crop.
|
|
31
|
+
*
|
|
32
|
+
* @param img - Source HTMLImageElement
|
|
33
|
+
* @param size - Target width and height in pixels
|
|
34
|
+
* @returns PNG Blob at the target size
|
|
35
|
+
*/
|
|
36
|
+
function resizeToSquarePng(img: HTMLImageElement, size: number): Promise<Blob> {
|
|
37
|
+
const canvas = document.createElement("canvas");
|
|
38
|
+
canvas.width = size;
|
|
39
|
+
canvas.height = size;
|
|
40
|
+
|
|
41
|
+
const ctx = canvas.getContext("2d");
|
|
42
|
+
if (!ctx) throw new Error("Failed to get canvas context");
|
|
43
|
+
|
|
44
|
+
// Cover crop: scale to fill square, crop center
|
|
45
|
+
const scale = Math.max(size / img.width, size / img.height);
|
|
46
|
+
const sw = size / scale;
|
|
47
|
+
const sh = size / scale;
|
|
48
|
+
const sx = (img.width - sw) / 2;
|
|
49
|
+
const sy = (img.height - sh) / 2;
|
|
50
|
+
|
|
51
|
+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, size, size);
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
canvas.toBlob(
|
|
55
|
+
(blob) => {
|
|
56
|
+
if (blob) resolve(blob);
|
|
57
|
+
else reject(new Error("Failed to create PNG blob"));
|
|
58
|
+
},
|
|
59
|
+
"image/png",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Process avatar file and upload with favicon variants.
|
|
66
|
+
*
|
|
67
|
+
* @param input - The file input element with `data-avatar-upload` attribute
|
|
68
|
+
* @param file - The selected file
|
|
69
|
+
*/
|
|
70
|
+
async function handleAvatarUpload(
|
|
71
|
+
input: HTMLInputElement,
|
|
72
|
+
file: File,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
// Find the parent form for the loading button
|
|
75
|
+
const form = input.closest("form");
|
|
76
|
+
const label = form?.querySelector("label");
|
|
77
|
+
const originalText = label?.textContent ?? "";
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
// Show processing state
|
|
81
|
+
if (label)
|
|
82
|
+
label.textContent = input.dataset.textProcessing || "Processing...";
|
|
83
|
+
|
|
84
|
+
// Load the image
|
|
85
|
+
const img = await loadImage(file);
|
|
86
|
+
|
|
87
|
+
// Generate variants in parallel
|
|
88
|
+
const [png16, png32, png180] = await Promise.all([
|
|
89
|
+
resizeToSquarePng(img, 16),
|
|
90
|
+
resizeToSquarePng(img, 32),
|
|
91
|
+
resizeToSquarePng(img, 180),
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// Encode ICO with 16x16 and 32x32
|
|
95
|
+
const [png16Buf, png32Buf] = await Promise.all([
|
|
96
|
+
png16.arrayBuffer(),
|
|
97
|
+
png32.arrayBuffer(),
|
|
98
|
+
]);
|
|
99
|
+
const icoBlob = encodeIco([
|
|
100
|
+
{ size: 16, png: png16Buf },
|
|
101
|
+
{ size: 32, png: png32Buf },
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// Show uploading state
|
|
105
|
+
if (label)
|
|
106
|
+
label.textContent = input.dataset.textUploading || "Uploading...";
|
|
107
|
+
|
|
108
|
+
// Build FormData with original + variants
|
|
109
|
+
const formData = new FormData();
|
|
110
|
+
formData.append("file", file);
|
|
111
|
+
formData.append("favicon", icoBlob, "favicon.ico");
|
|
112
|
+
formData.append("appleTouch", png180, "apple-touch-icon.png");
|
|
113
|
+
|
|
114
|
+
// Upload
|
|
115
|
+
const response = await fetch("/dash/settings/avatar", {
|
|
116
|
+
method: "POST",
|
|
117
|
+
body: formData,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
throw new Error("Upload failed");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Redirect on success
|
|
125
|
+
window.location.href = "/dash/settings?saved";
|
|
126
|
+
} catch {
|
|
127
|
+
// Restore button text on error
|
|
128
|
+
if (label) label.textContent = originalText;
|
|
129
|
+
// Show error toast
|
|
130
|
+
const errorMsg =
|
|
131
|
+
input.dataset.textError || "Upload failed. Please try again.";
|
|
132
|
+
const container = document.getElementById("toast-container");
|
|
133
|
+
if (container) {
|
|
134
|
+
const toast = document.createElement("div");
|
|
135
|
+
toast.className = "toast toast-error";
|
|
136
|
+
toast.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg><span>${errorMsg}</span>`;
|
|
137
|
+
container.appendChild(toast);
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
toast.classList.add("toast-out");
|
|
140
|
+
toast.addEventListener("animationend", () => toast.remove());
|
|
141
|
+
}, 3000);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Reset file input so the same file can be re-selected
|
|
146
|
+
input.value = "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Initialize avatar upload via event delegation
|
|
151
|
+
*/
|
|
152
|
+
function initAvatarUpload(): void {
|
|
153
|
+
document.addEventListener("change", (e) => {
|
|
154
|
+
const input = (e.target as HTMLElement).closest(
|
|
155
|
+
"[data-avatar-upload]",
|
|
156
|
+
) as HTMLInputElement | null;
|
|
157
|
+
if (!input?.files?.[0]) return;
|
|
158
|
+
|
|
159
|
+
// Prevent default form submission (Datastar data-on:change)
|
|
160
|
+
e.stopPropagation();
|
|
161
|
+
handleAvatarUpload(input, input.files[0]);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
initAvatarUpload();
|
package/src/lib/config.ts
CHANGED
|
@@ -118,3 +118,50 @@ export async function getSiteDescription(c: Context): Promise<string> {
|
|
|
118
118
|
export async function getSiteLanguage(c: Context): Promise<string> {
|
|
119
119
|
return getConfig(c, "SITE_LANGUAGE");
|
|
120
120
|
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get home default view with fallback chain: DB > ENV > Default
|
|
124
|
+
*
|
|
125
|
+
* @param c - Hono context
|
|
126
|
+
* @returns Home default view ("latest" or "featured")
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```typescript
|
|
130
|
+
* const view = await getHomeDefaultView(c);
|
|
131
|
+
* // Returns: (DB: HOME_DEFAULT_VIEW) ?? "latest"
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export async function getHomeDefaultView(c: Context): Promise<string> {
|
|
135
|
+
return getConfig(c, "HOME_DEFAULT_VIEW");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get timezone with fallback chain: DB > ENV > Default
|
|
140
|
+
*
|
|
141
|
+
* @param c - Hono context
|
|
142
|
+
* @returns Timezone string (e.g. "Beijing", "UTC")
|
|
143
|
+
*/
|
|
144
|
+
export async function getTimeZone(c: Context): Promise<string> {
|
|
145
|
+
return getConfig(c, "TIME_ZONE");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get site footer markdown with fallback chain: DB > ENV > Default
|
|
150
|
+
*
|
|
151
|
+
* @param c - Hono context
|
|
152
|
+
* @returns Footer markdown string (empty string if not set)
|
|
153
|
+
*/
|
|
154
|
+
export async function getSiteFooter(c: Context): Promise<string> {
|
|
155
|
+
return getConfig(c, "SITE_FOOTER");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check if search engine indexing is disabled
|
|
160
|
+
*
|
|
161
|
+
* @param c - Hono context
|
|
162
|
+
* @returns true if NOINDEX is set to "true"
|
|
163
|
+
*/
|
|
164
|
+
export async function isNoIndex(c: Context): Promise<boolean> {
|
|
165
|
+
const value = await getConfig(c, "NOINDEX");
|
|
166
|
+
return value === "true";
|
|
167
|
+
}
|
package/src/lib/constants.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export const RESERVED_PATHS = [
|
|
9
9
|
"featured",
|
|
10
|
+
"latest",
|
|
10
11
|
"collections",
|
|
11
12
|
"signin",
|
|
12
13
|
"signout",
|
|
@@ -42,18 +43,26 @@ export function isReservedPath(path: string): boolean {
|
|
|
42
43
|
export const DEFAULT_PAGE_SIZE = 100;
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
* Settings keys (
|
|
46
|
+
* Settings keys - derived from CONFIG_FIELDS (Single Source of Truth)
|
|
47
|
+
*
|
|
48
|
+
* Only non-envOnly fields and internal fields are stored in DB settings.
|
|
49
|
+
* Environment-only fields (SITE_URL, AUTH_SECRET, etc.) are never in the DB.
|
|
46
50
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
import { CONFIG_FIELDS, type ConfigKey } from "../types.js";
|
|
52
|
+
|
|
53
|
+
type SettingsFieldKey = {
|
|
54
|
+
[K in ConfigKey]: (typeof CONFIG_FIELDS)[K] extends { envOnly: false }
|
|
55
|
+
? K
|
|
56
|
+
: never;
|
|
57
|
+
}[ConfigKey];
|
|
58
|
+
|
|
59
|
+
export const SETTINGS_KEYS = Object.fromEntries(
|
|
60
|
+
Object.entries(CONFIG_FIELDS)
|
|
61
|
+
.filter(([, field]) => !field.envOnly || "internal" in field)
|
|
62
|
+
.map(([key]) => [key, key]),
|
|
63
|
+
) as { [K in SettingsFieldKey]: K };
|
|
55
64
|
|
|
56
|
-
export type SettingsKey =
|
|
65
|
+
export type SettingsKey = SettingsFieldKey;
|
|
57
66
|
|
|
58
67
|
/**
|
|
59
68
|
* Onboarding status values
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Favicon Utilities
|
|
3
|
+
*
|
|
4
|
+
* Sizes and ICO encoding for generated favicon variants.
|
|
5
|
+
* Favicon data is stored as base64 in the settings table (not R2)
|
|
6
|
+
* since the files are tiny and accessed on every page load.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Favicon variant sizes (width x height in pixels)
|
|
11
|
+
*/
|
|
12
|
+
export const FAVICON_SIZES = {
|
|
13
|
+
ICO_16: 16,
|
|
14
|
+
ICO_32: 32,
|
|
15
|
+
APPLE_TOUCH: 180,
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Encode PNG images into an ICO file.
|
|
20
|
+
*
|
|
21
|
+
* ICO format (with PNG payloads):
|
|
22
|
+
* - Header: 6 bytes (reserved=0, type=1, count=N)
|
|
23
|
+
* - Directory: 16 bytes per entry (width, height, colors, reserved, planes, bpp, size, offset)
|
|
24
|
+
* - Data: raw PNG bytes for each entry
|
|
25
|
+
*
|
|
26
|
+
* @param entries - Array of { size, png } where png is an ArrayBuffer of PNG data
|
|
27
|
+
* @returns ICO file as a Blob
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const ico = encodeIco([
|
|
32
|
+
* { size: 16, png: png16ArrayBuffer },
|
|
33
|
+
* { size: 32, png: png32ArrayBuffer },
|
|
34
|
+
* ]);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function encodeIco(
|
|
38
|
+
entries: { size: number; png: ArrayBuffer }[],
|
|
39
|
+
): Blob {
|
|
40
|
+
const headerSize = 6;
|
|
41
|
+
const dirEntrySize = 16;
|
|
42
|
+
const dirSize = entries.length * dirEntrySize;
|
|
43
|
+
|
|
44
|
+
let dataOffset = headerSize + dirSize;
|
|
45
|
+
|
|
46
|
+
// Build header + directory
|
|
47
|
+
const header = new ArrayBuffer(headerSize + dirSize);
|
|
48
|
+
const view = new DataView(header);
|
|
49
|
+
|
|
50
|
+
// ICO header
|
|
51
|
+
view.setUint16(0, 0, true); // reserved
|
|
52
|
+
view.setUint16(2, 1, true); // type = icon
|
|
53
|
+
view.setUint16(4, entries.length, true); // count
|
|
54
|
+
|
|
55
|
+
const pngBuffers: ArrayBuffer[] = [];
|
|
56
|
+
for (let i = 0; i < entries.length; i++) {
|
|
57
|
+
const entry = entries[i]!;
|
|
58
|
+
const offset = headerSize + i * dirEntrySize;
|
|
59
|
+
|
|
60
|
+
// Width/height: 0 means 256
|
|
61
|
+
view.setUint8(offset + 0, entry.size < 256 ? entry.size : 0);
|
|
62
|
+
view.setUint8(offset + 1, entry.size < 256 ? entry.size : 0);
|
|
63
|
+
view.setUint8(offset + 2, 0); // color count (0 for >256 colors)
|
|
64
|
+
view.setUint8(offset + 3, 0); // reserved
|
|
65
|
+
view.setUint16(offset + 4, 1, true); // color planes
|
|
66
|
+
view.setUint16(offset + 6, 32, true); // bits per pixel
|
|
67
|
+
view.setUint32(offset + 8, entry.png.byteLength, true); // image size
|
|
68
|
+
view.setUint32(offset + 12, dataOffset, true); // image offset
|
|
69
|
+
|
|
70
|
+
dataOffset += entry.png.byteLength;
|
|
71
|
+
pngBuffers.push(entry.png);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return new Blob([header, ...pngBuffers], { type: "image/x-icon" });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Convert an ArrayBuffer to a base64 string.
|
|
79
|
+
*
|
|
80
|
+
* @param buffer - The ArrayBuffer to encode
|
|
81
|
+
* @returns base64-encoded string
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const b64 = arrayBufferToBase64(await blob.arrayBuffer());
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
|
89
|
+
const bytes = new Uint8Array(buffer);
|
|
90
|
+
let binary = "";
|
|
91
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
92
|
+
binary += String.fromCharCode(bytes[i]!);
|
|
93
|
+
}
|
|
94
|
+
return btoa(binary);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Convert a base64 string to a Uint8Array.
|
|
99
|
+
*
|
|
100
|
+
* @param base64 - The base64 string to decode
|
|
101
|
+
* @returns decoded Uint8Array
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* const bytes = base64ToUint8Array(storedBase64);
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function base64ToUint8Array(base64: string): Uint8Array {
|
|
109
|
+
const binary = atob(base64);
|
|
110
|
+
const bytes = new Uint8Array(binary.length);
|
|
111
|
+
for (let i = 0; i < binary.length; i++) {
|
|
112
|
+
bytes[i] = binary.charCodeAt(i);
|
|
113
|
+
}
|
|
114
|
+
return bytes;
|
|
115
|
+
}
|