@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
|
@@ -5,19 +5,28 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { Hono } from "hono";
|
|
8
|
-
import { useLingui } from "@lingui/react/macro";
|
|
9
8
|
import type { Bindings } from "../../types.js";
|
|
10
9
|
import type { AppVariables } from "../../app.js";
|
|
11
|
-
import { DashLayout } from "../../
|
|
10
|
+
import { DashLayout } from "../../ui/layouts/DashLayout.js";
|
|
12
11
|
import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
|
|
12
|
+
import { arrayBufferToBase64 } from "../../lib/favicon.js";
|
|
13
13
|
import {
|
|
14
14
|
getSiteLanguage,
|
|
15
15
|
getSiteName,
|
|
16
|
+
getHomeDefaultView,
|
|
17
|
+
getTimeZone,
|
|
18
|
+
getSiteFooter,
|
|
19
|
+
isNoIndex,
|
|
16
20
|
getConfigFallback,
|
|
17
21
|
} from "../../lib/config.js";
|
|
18
22
|
import { SETTINGS_KEYS } from "../../lib/constants.js";
|
|
19
23
|
import { getAvailableThemes } from "../../lib/theme.js";
|
|
20
|
-
import
|
|
24
|
+
import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
|
|
25
|
+
import { TIMEZONES } from "../../lib/timezones.js";
|
|
26
|
+
import { BUILTIN_FONT_THEMES } from "../../ui/font-themes.js";
|
|
27
|
+
import { GeneralContent } from "../../ui/dash/settings/GeneralContent.js";
|
|
28
|
+
import { AppearanceContent } from "../../ui/dash/settings/AppearanceContent.js";
|
|
29
|
+
import { AccountContent } from "../../ui/dash/settings/AccountContent.js";
|
|
21
30
|
|
|
22
31
|
/** Escape HTML special characters for safe insertion into HTML strings */
|
|
23
32
|
function escapeHtml(str: string): string {
|
|
@@ -32,487 +41,46 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
|
32
41
|
|
|
33
42
|
export const settingsRoutes = new Hono<Env>();
|
|
34
43
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
type SettingsTab = "general" | "appearance" | "account";
|
|
40
|
-
|
|
41
|
-
function SettingsNav({ currentTab }: { currentTab: SettingsTab }) {
|
|
42
|
-
const { t } = useLingui();
|
|
43
|
-
|
|
44
|
-
const tabs: { id: SettingsTab; label: string; href: string }[] = [
|
|
45
|
-
{
|
|
46
|
-
id: "general",
|
|
47
|
-
label: t({
|
|
48
|
-
message: "General",
|
|
49
|
-
comment: "@context: Settings sub-navigation tab",
|
|
50
|
-
}),
|
|
51
|
-
href: "/dash/settings",
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
id: "appearance",
|
|
55
|
-
label: t({
|
|
56
|
-
message: "Appearance",
|
|
57
|
-
comment: "@context: Settings sub-navigation tab",
|
|
58
|
-
}),
|
|
59
|
-
href: "/dash/settings/appearance",
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
id: "account",
|
|
63
|
-
label: t({
|
|
64
|
-
message: "Account",
|
|
65
|
-
comment: "@context: Settings sub-navigation tab",
|
|
66
|
-
}),
|
|
67
|
-
href: "/dash/settings/account",
|
|
68
|
-
},
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
return (
|
|
72
|
-
<nav class="flex gap-1 mb-6">
|
|
73
|
-
{tabs.map((tab) => (
|
|
74
|
-
<a
|
|
75
|
-
key={tab.id}
|
|
76
|
-
href={tab.href}
|
|
77
|
-
class={`px-3 py-2 text-sm rounded-md ${
|
|
78
|
-
tab.id === currentTab
|
|
79
|
-
? "bg-accent text-accent-foreground font-medium"
|
|
80
|
-
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
|
81
|
-
}`}
|
|
82
|
-
>
|
|
83
|
-
{tab.label}
|
|
84
|
-
</a>
|
|
85
|
-
))}
|
|
86
|
-
</nav>
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ---------------------------------------------------------------------------
|
|
91
|
-
// General tab
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
93
|
-
|
|
94
|
-
function GeneralContent({
|
|
95
|
-
siteName,
|
|
96
|
-
siteDescription,
|
|
97
|
-
siteLanguage,
|
|
98
|
-
siteNameFallback,
|
|
99
|
-
siteDescriptionFallback,
|
|
100
|
-
}: {
|
|
101
|
-
siteName: string;
|
|
102
|
-
siteDescription: string;
|
|
103
|
-
siteLanguage: string;
|
|
104
|
-
siteNameFallback: string;
|
|
105
|
-
siteDescriptionFallback: string;
|
|
106
|
-
}) {
|
|
107
|
-
const { t } = useLingui();
|
|
108
|
-
|
|
109
|
-
const generalSignals = JSON.stringify({
|
|
110
|
-
siteName,
|
|
111
|
-
siteDescription,
|
|
112
|
-
siteLanguage,
|
|
113
|
-
}).replace(/</g, "\\u003c");
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<>
|
|
117
|
-
<h1 class="text-2xl font-semibold mb-2">
|
|
118
|
-
{t({ message: "Settings", comment: "@context: Dashboard heading" })}
|
|
119
|
-
</h1>
|
|
120
|
-
<SettingsNav currentTab="general" />
|
|
121
|
-
|
|
122
|
-
<div class="flex flex-col gap-6 max-w-lg">
|
|
123
|
-
<form
|
|
124
|
-
data-signals={generalSignals}
|
|
125
|
-
data-on:submit__prevent="@post('/dash/settings')"
|
|
126
|
-
data-indicator="_loading"
|
|
127
|
-
>
|
|
128
|
-
<div class="card">
|
|
129
|
-
<header>
|
|
130
|
-
<h2>
|
|
131
|
-
{t({
|
|
132
|
-
message: "General",
|
|
133
|
-
comment: "@context: Settings section heading",
|
|
134
|
-
})}
|
|
135
|
-
</h2>
|
|
136
|
-
</header>
|
|
137
|
-
<section class="flex flex-col gap-4">
|
|
138
|
-
<div class="field">
|
|
139
|
-
<label class="label">
|
|
140
|
-
{t({
|
|
141
|
-
message: "Site Name",
|
|
142
|
-
comment: "@context: Settings form field",
|
|
143
|
-
})}
|
|
144
|
-
</label>
|
|
145
|
-
<input
|
|
146
|
-
type="text"
|
|
147
|
-
data-bind="siteName"
|
|
148
|
-
class="input"
|
|
149
|
-
placeholder={siteNameFallback}
|
|
150
|
-
/>
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
<div class="field">
|
|
154
|
-
<label class="label">
|
|
155
|
-
{t({
|
|
156
|
-
message: "Site Description",
|
|
157
|
-
comment: "@context: Settings form field",
|
|
158
|
-
})}
|
|
159
|
-
</label>
|
|
160
|
-
<textarea
|
|
161
|
-
data-bind="siteDescription"
|
|
162
|
-
class="textarea"
|
|
163
|
-
rows={3}
|
|
164
|
-
placeholder={siteDescriptionFallback}
|
|
165
|
-
>
|
|
166
|
-
{siteDescription}
|
|
167
|
-
</textarea>
|
|
168
|
-
</div>
|
|
169
|
-
|
|
170
|
-
<div class="field">
|
|
171
|
-
<label class="label">
|
|
172
|
-
{t({
|
|
173
|
-
message: "Language",
|
|
174
|
-
comment: "@context: Settings form field",
|
|
175
|
-
})}
|
|
176
|
-
</label>
|
|
177
|
-
<select data-bind="siteLanguage" class="select">
|
|
178
|
-
<option value="en" selected={siteLanguage === "en"}>
|
|
179
|
-
English
|
|
180
|
-
</option>
|
|
181
|
-
<option value="zh-Hans" selected={siteLanguage === "zh-Hans"}>
|
|
182
|
-
简体中文
|
|
183
|
-
</option>
|
|
184
|
-
<option value="zh-Hant" selected={siteLanguage === "zh-Hant"}>
|
|
185
|
-
繁體中文
|
|
186
|
-
</option>
|
|
187
|
-
</select>
|
|
188
|
-
</div>
|
|
189
|
-
</section>
|
|
190
|
-
</div>
|
|
191
|
-
|
|
192
|
-
<button type="submit" class="btn mt-4" data-attr-disabled="$_loading">
|
|
193
|
-
<span data-show="!$_loading">
|
|
194
|
-
{t({
|
|
195
|
-
message: "Save Settings",
|
|
196
|
-
comment: "@context: Button to save settings",
|
|
197
|
-
})}
|
|
198
|
-
</span>
|
|
199
|
-
<span data-show="$_loading">
|
|
200
|
-
{t({
|
|
201
|
-
message: "Processing...",
|
|
202
|
-
comment:
|
|
203
|
-
"@context: Loading text shown on submit button while request is in progress",
|
|
204
|
-
})}
|
|
205
|
-
</span>
|
|
206
|
-
</button>
|
|
207
|
-
</form>
|
|
208
|
-
</div>
|
|
209
|
-
</>
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
// Appearance tab
|
|
215
|
-
// ---------------------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
function ThemeCard({
|
|
218
|
-
theme,
|
|
219
|
-
selected,
|
|
220
|
-
}: {
|
|
221
|
-
theme: ColorTheme;
|
|
222
|
-
selected: boolean;
|
|
223
|
-
}) {
|
|
224
|
-
const expr = `$theme === '${theme.id}'`;
|
|
225
|
-
const { preview } = theme;
|
|
226
|
-
|
|
227
|
-
return (
|
|
228
|
-
<label
|
|
229
|
-
class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
|
|
230
|
-
data-class:border-primary={expr}
|
|
231
|
-
data-class:border-border={`$theme !== '${theme.id}'`}
|
|
232
|
-
>
|
|
233
|
-
<div class="grid grid-cols-2">
|
|
234
|
-
<div
|
|
235
|
-
class="p-5"
|
|
236
|
-
style={`background-color:${preview.lightBg};color:${preview.lightText}`}
|
|
237
|
-
>
|
|
238
|
-
<input
|
|
239
|
-
type="radio"
|
|
240
|
-
name="theme"
|
|
241
|
-
value={theme.id}
|
|
242
|
-
data-bind="theme"
|
|
243
|
-
checked={selected || undefined}
|
|
244
|
-
class="mb-1"
|
|
245
|
-
/>
|
|
246
|
-
<h3 class="font-bold text-lg">{theme.name}</h3>
|
|
247
|
-
<p class="text-sm mt-2 leading-relaxed">
|
|
248
|
-
This is the {theme.name} theme in light mode. Links{" "}
|
|
249
|
-
<a
|
|
250
|
-
tabIndex={-1}
|
|
251
|
-
class="underline"
|
|
252
|
-
style={`color:${preview.lightLink}`}
|
|
253
|
-
>
|
|
254
|
-
look like this
|
|
255
|
-
</a>
|
|
256
|
-
. We'll show the correct light or dark mode based on your visitor's
|
|
257
|
-
settings.
|
|
258
|
-
</p>
|
|
259
|
-
</div>
|
|
260
|
-
<div
|
|
261
|
-
class="p-5"
|
|
262
|
-
style={`background-color:${preview.darkBg};color:${preview.darkText}`}
|
|
263
|
-
>
|
|
264
|
-
<h3 class="font-bold text-lg">{theme.name}</h3>
|
|
265
|
-
<p class="text-sm mt-2 leading-relaxed">
|
|
266
|
-
This is the {theme.name} theme in dark mode. Links{" "}
|
|
267
|
-
<a
|
|
268
|
-
tabIndex={-1}
|
|
269
|
-
class="underline"
|
|
270
|
-
style={`color:${preview.darkLink}`}
|
|
271
|
-
>
|
|
272
|
-
look like this
|
|
273
|
-
</a>
|
|
274
|
-
. We'll show the correct light or dark mode based on your visitor's
|
|
275
|
-
settings.
|
|
276
|
-
</p>
|
|
277
|
-
</div>
|
|
278
|
-
</div>
|
|
279
|
-
</label>
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function AppearanceContent({
|
|
284
|
-
themes,
|
|
285
|
-
currentThemeId,
|
|
286
|
-
}: {
|
|
287
|
-
themes: ColorTheme[];
|
|
288
|
-
currentThemeId: string;
|
|
289
|
-
}) {
|
|
290
|
-
const { t } = useLingui();
|
|
291
|
-
|
|
292
|
-
const signals = JSON.stringify({ theme: currentThemeId }).replace(
|
|
293
|
-
/</g,
|
|
294
|
-
"\\u003c",
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
return (
|
|
298
|
-
<>
|
|
299
|
-
<h1 class="text-2xl font-semibold mb-2">
|
|
300
|
-
{t({ message: "Settings", comment: "@context: Dashboard heading" })}
|
|
301
|
-
</h1>
|
|
302
|
-
<SettingsNav currentTab="appearance" />
|
|
303
|
-
|
|
304
|
-
<div
|
|
305
|
-
data-signals={signals}
|
|
306
|
-
data-on:change="@post('/dash/settings/appearance')"
|
|
307
|
-
class="max-w-3xl"
|
|
308
|
-
>
|
|
309
|
-
<fieldset>
|
|
310
|
-
<legend class="text-lg font-semibold">
|
|
311
|
-
{t({
|
|
312
|
-
message: "Color theme",
|
|
313
|
-
comment: "@context: Appearance settings heading",
|
|
314
|
-
})}
|
|
315
|
-
</legend>
|
|
316
|
-
<p class="text-sm text-muted-foreground mb-4">
|
|
317
|
-
{t({
|
|
318
|
-
message:
|
|
319
|
-
"This will theme both your site and your dashboard. All color themes support dark mode.",
|
|
320
|
-
comment: "@context: Appearance settings description",
|
|
321
|
-
})}
|
|
322
|
-
</p>
|
|
323
|
-
|
|
324
|
-
<div class="flex flex-col gap-4">
|
|
325
|
-
{themes.map((theme) => (
|
|
326
|
-
<ThemeCard
|
|
327
|
-
key={theme.id}
|
|
328
|
-
theme={theme}
|
|
329
|
-
selected={theme.id === currentThemeId}
|
|
330
|
-
/>
|
|
331
|
-
))}
|
|
332
|
-
</div>
|
|
333
|
-
</fieldset>
|
|
334
|
-
</div>
|
|
335
|
-
</>
|
|
336
|
-
);
|
|
337
|
-
}
|
|
44
|
+
// ===========================================================================
|
|
45
|
+
// General settings
|
|
46
|
+
// ===========================================================================
|
|
338
47
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
<h1 class="text-2xl font-semibold mb-2">
|
|
351
|
-
{t({ message: "Settings", comment: "@context: Dashboard heading" })}
|
|
352
|
-
</h1>
|
|
353
|
-
<SettingsNav currentTab="account" />
|
|
354
|
-
|
|
355
|
-
<div class="flex flex-col gap-6 max-w-lg">
|
|
356
|
-
<form
|
|
357
|
-
data-signals={profileSignals}
|
|
358
|
-
data-on:submit__prevent="@post('/dash/settings/account')"
|
|
359
|
-
data-indicator="_profileLoading"
|
|
360
|
-
>
|
|
361
|
-
<div class="card">
|
|
362
|
-
<header>
|
|
363
|
-
<h2>
|
|
364
|
-
{t({
|
|
365
|
-
message: "Profile",
|
|
366
|
-
comment: "@context: Account settings section heading",
|
|
367
|
-
})}
|
|
368
|
-
</h2>
|
|
369
|
-
</header>
|
|
370
|
-
<section class="flex flex-col gap-4">
|
|
371
|
-
<div class="field">
|
|
372
|
-
<label class="label">
|
|
373
|
-
{t({
|
|
374
|
-
message: "Name",
|
|
375
|
-
comment: "@context: Account settings form field",
|
|
376
|
-
})}
|
|
377
|
-
</label>
|
|
378
|
-
<input
|
|
379
|
-
type="text"
|
|
380
|
-
data-bind="userName"
|
|
381
|
-
class="input"
|
|
382
|
-
required
|
|
383
|
-
/>
|
|
384
|
-
</div>
|
|
385
|
-
</section>
|
|
386
|
-
</div>
|
|
387
|
-
|
|
388
|
-
<button
|
|
389
|
-
type="submit"
|
|
390
|
-
class="btn mt-4"
|
|
391
|
-
data-attr-disabled="$_profileLoading"
|
|
392
|
-
>
|
|
393
|
-
<span data-show="!$_profileLoading">
|
|
394
|
-
{t({
|
|
395
|
-
message: "Save Profile",
|
|
396
|
-
comment: "@context: Button to save profile",
|
|
397
|
-
})}
|
|
398
|
-
</span>
|
|
399
|
-
<span data-show="$_profileLoading">
|
|
400
|
-
{t({
|
|
401
|
-
message: "Processing...",
|
|
402
|
-
comment:
|
|
403
|
-
"@context: Loading text shown on submit button while request is in progress",
|
|
404
|
-
})}
|
|
405
|
-
</span>
|
|
406
|
-
</button>
|
|
407
|
-
</form>
|
|
408
|
-
|
|
409
|
-
<form
|
|
410
|
-
data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
|
|
411
|
-
data-on:submit__prevent="@post('/dash/settings/password')"
|
|
412
|
-
data-indicator="_passwordLoading"
|
|
413
|
-
>
|
|
414
|
-
<div class="card">
|
|
415
|
-
<header>
|
|
416
|
-
<h2>
|
|
417
|
-
{t({
|
|
418
|
-
message: "Change Password",
|
|
419
|
-
comment: "@context: Settings section heading",
|
|
420
|
-
})}
|
|
421
|
-
</h2>
|
|
422
|
-
</header>
|
|
423
|
-
<section class="flex flex-col gap-4">
|
|
424
|
-
<div class="field">
|
|
425
|
-
<label class="label">
|
|
426
|
-
{t({
|
|
427
|
-
message: "Current Password",
|
|
428
|
-
comment: "@context: Password form field",
|
|
429
|
-
})}
|
|
430
|
-
</label>
|
|
431
|
-
<input
|
|
432
|
-
type="password"
|
|
433
|
-
data-bind="currentPassword"
|
|
434
|
-
class="input"
|
|
435
|
-
required
|
|
436
|
-
autocomplete="current-password"
|
|
437
|
-
/>
|
|
438
|
-
</div>
|
|
439
|
-
|
|
440
|
-
<div class="field">
|
|
441
|
-
<label class="label">
|
|
442
|
-
{t({
|
|
443
|
-
message: "New Password",
|
|
444
|
-
comment: "@context: Password form field",
|
|
445
|
-
})}
|
|
446
|
-
</label>
|
|
447
|
-
<input
|
|
448
|
-
type="password"
|
|
449
|
-
data-bind="newPassword"
|
|
450
|
-
class="input"
|
|
451
|
-
required
|
|
452
|
-
minlength={8}
|
|
453
|
-
autocomplete="new-password"
|
|
454
|
-
/>
|
|
455
|
-
</div>
|
|
456
|
-
|
|
457
|
-
<div class="field">
|
|
458
|
-
<label class="label">
|
|
459
|
-
{t({
|
|
460
|
-
message: "Confirm New Password",
|
|
461
|
-
comment: "@context: Password form field",
|
|
462
|
-
})}
|
|
463
|
-
</label>
|
|
464
|
-
<input
|
|
465
|
-
type="password"
|
|
466
|
-
data-bind="confirmPassword"
|
|
467
|
-
class="input"
|
|
468
|
-
required
|
|
469
|
-
minlength={8}
|
|
470
|
-
autocomplete="new-password"
|
|
471
|
-
/>
|
|
472
|
-
</div>
|
|
473
|
-
</section>
|
|
474
|
-
</div>
|
|
475
|
-
|
|
476
|
-
<button
|
|
477
|
-
type="submit"
|
|
478
|
-
class="btn mt-4"
|
|
479
|
-
data-attr-disabled="$_passwordLoading"
|
|
480
|
-
>
|
|
481
|
-
<span data-show="!$_passwordLoading">
|
|
482
|
-
{t({
|
|
483
|
-
message: "Change Password",
|
|
484
|
-
comment: "@context: Button to change password",
|
|
485
|
-
})}
|
|
486
|
-
</span>
|
|
487
|
-
<span data-show="$_passwordLoading">
|
|
488
|
-
{t({
|
|
489
|
-
message: "Processing...",
|
|
490
|
-
comment:
|
|
491
|
-
"@context: Loading text shown on submit button while request is in progress",
|
|
492
|
-
})}
|
|
493
|
-
</span>
|
|
494
|
-
</button>
|
|
495
|
-
</form>
|
|
496
|
-
</div>
|
|
497
|
-
</>
|
|
48
|
+
/** Resolve the avatar storage key to a URL */
|
|
49
|
+
async function resolveAvatarUrl(c: {
|
|
50
|
+
var: { services: AppVariables["services"] };
|
|
51
|
+
env: Bindings;
|
|
52
|
+
}): Promise<string> {
|
|
53
|
+
const avatarKey = await c.var.services.settings.get("SITE_AVATAR");
|
|
54
|
+
if (!avatarKey) return "";
|
|
55
|
+
const publicUrl = getPublicUrlForProvider(
|
|
56
|
+
c.env.STORAGE_DRIVER || "r2",
|
|
57
|
+
c.env.R2_PUBLIC_URL,
|
|
58
|
+
c.env.S3_PUBLIC_URL,
|
|
498
59
|
);
|
|
60
|
+
return getMediaUrl(avatarKey, publicUrl);
|
|
499
61
|
}
|
|
500
62
|
|
|
501
|
-
// ===========================================================================
|
|
502
|
-
// Route handlers
|
|
503
|
-
// ===========================================================================
|
|
504
|
-
|
|
505
|
-
// General settings page
|
|
506
63
|
settingsRoutes.get("/", async (c) => {
|
|
507
64
|
const { settings } = c.var.services;
|
|
508
65
|
|
|
509
66
|
const dbSiteName = await settings.get("SITE_NAME");
|
|
510
67
|
const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
|
|
511
|
-
const siteLanguage
|
|
68
|
+
const [siteLanguage, homeDefaultView, timeZone, siteFooter, noindex] =
|
|
69
|
+
await Promise.all([
|
|
70
|
+
getSiteLanguage(c),
|
|
71
|
+
getHomeDefaultView(c),
|
|
72
|
+
getTimeZone(c),
|
|
73
|
+
getSiteFooter(c),
|
|
74
|
+
isNoIndex(c),
|
|
75
|
+
]);
|
|
512
76
|
|
|
513
77
|
const siteNameFallback = getConfigFallback(c, "SITE_NAME");
|
|
514
78
|
const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
|
|
515
79
|
|
|
80
|
+
const siteAvatarUrl = await resolveAvatarUrl(c);
|
|
81
|
+
const showHeaderAvatar =
|
|
82
|
+
(await settings.get("SHOW_HEADER_AVATAR")) === "true";
|
|
83
|
+
|
|
516
84
|
const saved = c.req.query("saved") !== undefined;
|
|
517
85
|
|
|
518
86
|
return c.html(
|
|
@@ -527,19 +95,27 @@ settingsRoutes.get("/", async (c) => {
|
|
|
527
95
|
siteName={dbSiteName || ""}
|
|
528
96
|
siteDescription={dbSiteDescription || ""}
|
|
529
97
|
siteLanguage={siteLanguage}
|
|
98
|
+
homeDefaultView={homeDefaultView}
|
|
530
99
|
siteNameFallback={siteNameFallback}
|
|
531
100
|
siteDescriptionFallback={siteDescriptionFallback}
|
|
101
|
+
siteAvatarUrl={siteAvatarUrl}
|
|
102
|
+
showHeaderAvatar={showHeaderAvatar}
|
|
103
|
+
timeZone={timeZone}
|
|
104
|
+
siteFooter={siteFooter}
|
|
105
|
+
noindex={noindex}
|
|
106
|
+
timezones={TIMEZONES}
|
|
532
107
|
/>
|
|
533
108
|
</DashLayout>,
|
|
534
109
|
);
|
|
535
110
|
});
|
|
536
111
|
|
|
537
|
-
// Save general settings
|
|
538
112
|
settingsRoutes.post("/", async (c) => {
|
|
539
113
|
const body = await c.req.json<{
|
|
540
114
|
siteName: string;
|
|
541
115
|
siteDescription: string;
|
|
542
116
|
siteLanguage: string;
|
|
117
|
+
homeDefaultView: string;
|
|
118
|
+
timeZone: string;
|
|
543
119
|
}>();
|
|
544
120
|
|
|
545
121
|
const { settings } = c.var.services;
|
|
@@ -560,6 +136,20 @@ settingsRoutes.post("/", async (c) => {
|
|
|
560
136
|
|
|
561
137
|
await settings.set("SITE_LANGUAGE", body.siteLanguage);
|
|
562
138
|
|
|
139
|
+
// Save homepage default view (only store if non-default)
|
|
140
|
+
if (body.homeDefaultView === "featured") {
|
|
141
|
+
await settings.set("HOME_DEFAULT_VIEW", body.homeDefaultView);
|
|
142
|
+
} else {
|
|
143
|
+
await settings.remove("HOME_DEFAULT_VIEW");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Timezone
|
|
147
|
+
if (body.timeZone && body.timeZone !== "UTC") {
|
|
148
|
+
await settings.set("TIME_ZONE", body.timeZone);
|
|
149
|
+
} else {
|
|
150
|
+
await settings.remove("TIME_ZONE");
|
|
151
|
+
}
|
|
152
|
+
|
|
563
153
|
const languageChanged = oldLanguage !== body.siteLanguage;
|
|
564
154
|
const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
|
|
565
155
|
|
|
@@ -576,15 +166,173 @@ settingsRoutes.post("/", async (c) => {
|
|
|
576
166
|
selector: "title",
|
|
577
167
|
});
|
|
578
168
|
await stream.toast("Settings saved successfully.");
|
|
169
|
+
await stream.patchSignals({
|
|
170
|
+
_orig_siteName: body.siteName,
|
|
171
|
+
_orig_siteDescription: body.siteDescription,
|
|
172
|
+
_orig_siteLanguage: body.siteLanguage,
|
|
173
|
+
_orig_homeDefaultView: body.homeDefaultView,
|
|
174
|
+
_orig_timeZone: body.timeZone,
|
|
175
|
+
_generalDirty: false,
|
|
176
|
+
});
|
|
579
177
|
}
|
|
580
178
|
});
|
|
581
179
|
});
|
|
582
180
|
|
|
583
|
-
|
|
181
|
+
settingsRoutes.post("/footer", async (c) => {
|
|
182
|
+
const body = await c.req.json<{ siteFooter: string }>();
|
|
183
|
+
const { settings } = c.var.services;
|
|
184
|
+
|
|
185
|
+
if (body.siteFooter?.trim()) {
|
|
186
|
+
await settings.set("SITE_FOOTER", body.siteFooter.trim());
|
|
187
|
+
} else {
|
|
188
|
+
await settings.remove("SITE_FOOTER");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return sse(c, async (stream) => {
|
|
192
|
+
await stream.toast("Footer saved successfully.");
|
|
193
|
+
await stream.patchSignals({
|
|
194
|
+
_orig_siteFooter: body.siteFooter,
|
|
195
|
+
_footerDirty: false,
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
settingsRoutes.post("/seo", async (c) => {
|
|
201
|
+
const body = await c.req.json<{ noindex: string }>();
|
|
202
|
+
const { settings } = c.var.services;
|
|
203
|
+
|
|
204
|
+
// Checkbox "noindex" is the allow-indexing signal:
|
|
205
|
+
// checked (value "true") = indexing allowed -> remove NOINDEX
|
|
206
|
+
// unchecked (value "") = indexing blocked -> set NOINDEX=true
|
|
207
|
+
if (body.noindex === "true") {
|
|
208
|
+
await settings.remove("NOINDEX");
|
|
209
|
+
} else {
|
|
210
|
+
await settings.set("NOINDEX", "true");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return sse(c, async (stream) => {
|
|
214
|
+
await stream.toast("SEO settings saved successfully.");
|
|
215
|
+
await stream.patchSignals({
|
|
216
|
+
_orig_noindex: body.noindex,
|
|
217
|
+
_seoDirty: false,
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// ===========================================================================
|
|
223
|
+
// Avatar upload & removal
|
|
224
|
+
// ===========================================================================
|
|
225
|
+
|
|
226
|
+
settingsRoutes.post("/avatar", async (c) => {
|
|
227
|
+
const storage = c.var.storage;
|
|
228
|
+
if (!storage) {
|
|
229
|
+
return dsToast("Storage not configured.", "error");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const formData = await c.req.formData();
|
|
233
|
+
const file = formData.get("file") as File | null;
|
|
234
|
+
if (!file) {
|
|
235
|
+
return dsToast("No file provided.", "error");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const allowedTypes = [
|
|
239
|
+
"image/jpeg",
|
|
240
|
+
"image/png",
|
|
241
|
+
"image/gif",
|
|
242
|
+
"image/webp",
|
|
243
|
+
"image/svg+xml",
|
|
244
|
+
];
|
|
245
|
+
if (!allowedTypes.includes(file.type)) {
|
|
246
|
+
return dsToast("File type not allowed.", "error");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const maxSize = 10 * 1024 * 1024;
|
|
250
|
+
if (file.size > maxSize) {
|
|
251
|
+
return dsToast("File too large (max 10MB).", "error");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const { uuidv7 } = await import("uuidv7");
|
|
255
|
+
const ext = file.name.split(".").pop() || "bin";
|
|
256
|
+
const id = uuidv7();
|
|
257
|
+
const date = new Date();
|
|
258
|
+
const year = date.getUTCFullYear();
|
|
259
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
260
|
+
const filename = `${id}.${ext}`;
|
|
261
|
+
const storageKey = `media/${year}/${month}/${filename}`;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await storage.put(storageKey, file.stream(), {
|
|
265
|
+
contentType: file.type,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
await c.var.services.media.create({
|
|
269
|
+
id,
|
|
270
|
+
filename,
|
|
271
|
+
originalName: file.name,
|
|
272
|
+
mimeType: file.type,
|
|
273
|
+
size: file.size,
|
|
274
|
+
storageKey,
|
|
275
|
+
provider: c.env.STORAGE_DRIVER || "r2",
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
await c.var.services.settings.set("SITE_AVATAR", storageKey);
|
|
279
|
+
|
|
280
|
+
// Store favicon variants as base64 in settings (small files, accessed every page load)
|
|
281
|
+
const faviconFile = formData.get("favicon") as File | null;
|
|
282
|
+
const appleTouchFile = formData.get("appleTouch") as File | null;
|
|
283
|
+
|
|
284
|
+
if (faviconFile) {
|
|
285
|
+
const b64 = arrayBufferToBase64(await faviconFile.arrayBuffer());
|
|
286
|
+
await c.var.services.settings.set("SITE_FAVICON_ICO", b64);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (appleTouchFile) {
|
|
290
|
+
const b64 = arrayBufferToBase64(await appleTouchFile.arrayBuffer());
|
|
291
|
+
await c.var.services.settings.set("SITE_FAVICON_APPLE_TOUCH", b64);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return dsRedirect("/dash/settings?saved");
|
|
295
|
+
} catch {
|
|
296
|
+
return dsToast("Upload failed. Please try again.", "error");
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
settingsRoutes.post("/avatar/remove", async (c) => {
|
|
301
|
+
await c.var.services.settings.remove("SITE_AVATAR");
|
|
302
|
+
await c.var.services.settings.remove("SITE_FAVICON_ICO");
|
|
303
|
+
await c.var.services.settings.remove("SITE_FAVICON_APPLE_TOUCH");
|
|
304
|
+
return dsRedirect("/dash/settings?saved");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
settingsRoutes.post("/avatar/display", async (c) => {
|
|
308
|
+
const body = await c.req.json<{ showHeaderAvatar: string }>();
|
|
309
|
+
const { settings } = c.var.services;
|
|
310
|
+
|
|
311
|
+
if (body.showHeaderAvatar === "true") {
|
|
312
|
+
await settings.set("SHOW_HEADER_AVATAR", "true");
|
|
313
|
+
} else {
|
|
314
|
+
await settings.remove("SHOW_HEADER_AVATAR");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return sse(c, async (stream) => {
|
|
318
|
+
await stream.toast("Avatar display setting saved successfully.");
|
|
319
|
+
await stream.patchSignals({
|
|
320
|
+
_orig_showHeaderAvatar: body.showHeaderAvatar,
|
|
321
|
+
_avatarDisplayDirty: false,
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// ===========================================================================
|
|
327
|
+
// Appearance
|
|
328
|
+
// ===========================================================================
|
|
329
|
+
|
|
584
330
|
settingsRoutes.get("/appearance", async (c) => {
|
|
585
331
|
const { settings } = c.var.services;
|
|
586
332
|
const siteName = await getSiteName(c);
|
|
587
333
|
const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
|
|
334
|
+
const currentFontThemeId = (await settings.get("FONT_THEME")) ?? "default";
|
|
335
|
+
const customCSS = (await settings.get(SETTINGS_KEYS.CUSTOM_CSS)) ?? "";
|
|
588
336
|
const themes = getAvailableThemes(c.var.config);
|
|
589
337
|
const saved = c.req.query("saved") !== undefined;
|
|
590
338
|
|
|
@@ -596,12 +344,17 @@ settingsRoutes.get("/appearance", async (c) => {
|
|
|
596
344
|
currentPath="/dash/settings"
|
|
597
345
|
toast={saved ? { message: "Theme saved successfully." } : undefined}
|
|
598
346
|
>
|
|
599
|
-
<AppearanceContent
|
|
347
|
+
<AppearanceContent
|
|
348
|
+
themes={themes}
|
|
349
|
+
currentThemeId={currentThemeId}
|
|
350
|
+
fontThemes={BUILTIN_FONT_THEMES}
|
|
351
|
+
currentFontThemeId={currentFontThemeId}
|
|
352
|
+
customCSS={customCSS}
|
|
353
|
+
/>
|
|
600
354
|
</DashLayout>,
|
|
601
355
|
);
|
|
602
356
|
});
|
|
603
357
|
|
|
604
|
-
// Save theme
|
|
605
358
|
settingsRoutes.post("/appearance", async (c) => {
|
|
606
359
|
const body = await c.req.json<{ theme: string }>();
|
|
607
360
|
const { settings } = c.var.services;
|
|
@@ -621,7 +374,43 @@ settingsRoutes.post("/appearance", async (c) => {
|
|
|
621
374
|
return dsRedirect("/dash/settings/appearance?saved");
|
|
622
375
|
});
|
|
623
376
|
|
|
624
|
-
|
|
377
|
+
settingsRoutes.post("/font-theme", async (c) => {
|
|
378
|
+
const body = await c.req.json<{ fontTheme: string }>();
|
|
379
|
+
const { settings } = c.var.services;
|
|
380
|
+
|
|
381
|
+
const validFont = BUILTIN_FONT_THEMES.find((f) => f.id === body.fontTheme);
|
|
382
|
+
if (!validFont) {
|
|
383
|
+
return dsToast("Invalid font theme selected.", "error");
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (validFont.id === "default") {
|
|
387
|
+
await settings.remove("FONT_THEME");
|
|
388
|
+
} else {
|
|
389
|
+
await settings.set("FONT_THEME", validFont.id);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return dsRedirect("/dash/settings/appearance?saved");
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
settingsRoutes.post("/custom-css", async (c) => {
|
|
396
|
+
const body = await c.req.json<{ customCSS: string }>();
|
|
397
|
+
const { settings } = c.var.services;
|
|
398
|
+
|
|
399
|
+
const css = body.customCSS?.trim() ?? "";
|
|
400
|
+
|
|
401
|
+
if (css) {
|
|
402
|
+
await settings.set(SETTINGS_KEYS.CUSTOM_CSS, css);
|
|
403
|
+
} else {
|
|
404
|
+
await settings.remove(SETTINGS_KEYS.CUSTOM_CSS);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return dsToast("Custom CSS saved successfully.");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ===========================================================================
|
|
411
|
+
// Account
|
|
412
|
+
// ===========================================================================
|
|
413
|
+
|
|
625
414
|
settingsRoutes.get("/account", async (c) => {
|
|
626
415
|
const siteName = await getSiteName(c);
|
|
627
416
|
const session = await c.var.auth.api.getSession({
|
|
@@ -643,7 +432,6 @@ settingsRoutes.get("/account", async (c) => {
|
|
|
643
432
|
);
|
|
644
433
|
});
|
|
645
434
|
|
|
646
|
-
// Save account profile
|
|
647
435
|
settingsRoutes.post("/account", async (c) => {
|
|
648
436
|
const body = await c.req.json<{ userName: string }>();
|
|
649
437
|
const name = body.userName?.trim();
|
|
@@ -664,7 +452,6 @@ settingsRoutes.post("/account", async (c) => {
|
|
|
664
452
|
return dsToast("Profile saved successfully.");
|
|
665
453
|
});
|
|
666
454
|
|
|
667
|
-
// Change password
|
|
668
455
|
settingsRoutes.post("/password", async (c) => {
|
|
669
456
|
const body = await c.req.json<{
|
|
670
457
|
currentPassword: string;
|