@jant/core 0.3.25 → 0.3.27
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 +70 -563
- package/dist/auth.js +3 -0
- package/dist/client.js +1 -0
- package/dist/i18n/locales/en.js +1 -1
- package/dist/i18n/locales/zh-Hans.js +1 -1
- package/dist/i18n/locales/zh-Hant.js +1 -1
- package/dist/lib/avatar-upload.js +134 -0
- package/dist/lib/config.js +39 -0
- package/dist/lib/constants.js +10 -10
- 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/navigation.js +23 -3
- package/dist/lib/render.js +10 -1
- package/dist/lib/schemas.js +31 -0
- package/dist/lib/timezones.js +388 -0
- package/dist/lib/view.js +1 -1
- package/dist/routes/api/posts.js +1 -1
- 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/dash/collections.js +23 -415
- package/dist/routes/dash/media.js +12 -392
- package/dist/routes/dash/pages.js +7 -330
- package/dist/routes/dash/redirects.js +18 -12
- package/dist/routes/dash/settings.js +198 -577
- package/dist/routes/feed/rss.js +2 -1
- package/dist/routes/feed/sitemap.js +4 -2
- package/dist/routes/pages/featured.js +5 -1
- package/dist/routes/pages/home.js +26 -1
- package/dist/routes/pages/latest.js +45 -0
- package/dist/services/post.js +30 -50
- 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/ui/color-themes.js +33 -33
- package/dist/ui/compose/ComposeDialog.js +36 -21
- package/dist/ui/dash/PageForm.js +21 -15
- package/dist/ui/dash/PostForm.js +22 -16
- 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/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/ui/font-themes.js +36 -0
- package/dist/ui/layouts/BaseLayout.js +24 -2
- package/dist/ui/layouts/SiteLayout.js +47 -19
- package/package.json +1 -1
- package/src/app.tsx +95 -553
- package/src/auth.ts +4 -1
- package/src/client.ts +1 -0
- package/src/i18n/locales/en.po +240 -175
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +240 -175
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +240 -175
- package/src/i18n/locales/zh-Hant.ts +1 -1
- 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__/timezones.test.ts +61 -0
- package/src/lib/__tests__/view.test.ts +2 -2
- package/src/lib/avatar-upload.ts +165 -0
- package/src/lib/config.ts +47 -0
- package/src/lib/constants.ts +19 -11
- 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/navigation.ts +33 -2
- package/src/lib/render.tsx +15 -1
- package/src/lib/schemas.ts +39 -0
- package/src/lib/timezones.ts +325 -0
- package/src/lib/view.ts +1 -1
- package/src/routes/api/posts.ts +1 -1
- 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/dash/__tests__/settings-avatar.test.ts +89 -0
- package/src/routes/dash/collections.tsx +17 -366
- package/src/routes/dash/media.tsx +12 -414
- package/src/routes/dash/pages.tsx +8 -348
- package/src/routes/dash/redirects.tsx +20 -14
- package/src/routes/dash/settings.tsx +243 -534
- package/src/routes/feed/__tests__/rss.test.ts +141 -0
- package/src/routes/feed/rss.ts +3 -1
- package/src/routes/feed/sitemap.ts +4 -2
- package/src/routes/pages/featured.tsx +7 -1
- package/src/routes/pages/home.tsx +25 -2
- package/src/routes/pages/latest.tsx +59 -0
- package/src/services/post.ts +34 -66
- package/src/styles/components.css +0 -65
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +24 -40
- 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 -644
- package/src/ui/__tests__/font-themes.test.ts +34 -0
- package/src/ui/color-themes.ts +34 -34
- package/src/ui/compose/ComposeDialog.tsx +40 -21
- package/src/ui/dash/PageForm.tsx +25 -19
- package/src/ui/dash/PostForm.tsx +26 -20
- 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/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/ui/font-themes.ts +54 -0
- package/src/ui/layouts/BaseLayout.tsx +17 -0
- package/src/ui/layouts/SiteLayout.tsx +45 -31
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup Routes
|
|
3
|
+
*
|
|
4
|
+
* Initial admin account creation during first-time setup.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import type { FC } from "hono/jsx";
|
|
9
|
+
import { useLingui } from "@lingui/react/macro";
|
|
10
|
+
import type { Bindings } from "../../types.js";
|
|
11
|
+
import type { AppVariables } from "../../app.js";
|
|
12
|
+
import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
|
|
13
|
+
import { dsRedirect, dsToast } from "../../lib/sse.js";
|
|
14
|
+
import { SetupSchema } from "../../lib/schemas.js";
|
|
15
|
+
import { mapIanaToTimezone } from "../../lib/timezones.js";
|
|
16
|
+
|
|
17
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
18
|
+
|
|
19
|
+
const SetupContent: FC = () => {
|
|
20
|
+
const { t } = useLingui();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div class="min-h-screen flex items-center justify-center">
|
|
24
|
+
<div class="card max-w-md w-full">
|
|
25
|
+
<header>
|
|
26
|
+
<h2>
|
|
27
|
+
{t({
|
|
28
|
+
message: "Welcome to Jant",
|
|
29
|
+
comment: "@context: Setup page welcome heading",
|
|
30
|
+
})}
|
|
31
|
+
</h2>
|
|
32
|
+
<p>
|
|
33
|
+
{t({
|
|
34
|
+
message: "Create your admin account.",
|
|
35
|
+
comment: "@context: Setup page description",
|
|
36
|
+
})}
|
|
37
|
+
</p>
|
|
38
|
+
</header>
|
|
39
|
+
<section>
|
|
40
|
+
<form
|
|
41
|
+
data-signals="{name: '', email: '', password: '', _timezone: ''}"
|
|
42
|
+
data-init="$_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''"
|
|
43
|
+
data-on:submit__prevent="@post('/setup')"
|
|
44
|
+
data-indicator="_loading"
|
|
45
|
+
class="flex flex-col gap-4"
|
|
46
|
+
>
|
|
47
|
+
<div class="field">
|
|
48
|
+
<label class="label">
|
|
49
|
+
{t({
|
|
50
|
+
message: "Your Name",
|
|
51
|
+
comment: "@context: Setup form field - user name",
|
|
52
|
+
})}
|
|
53
|
+
</label>
|
|
54
|
+
<input
|
|
55
|
+
type="text"
|
|
56
|
+
data-bind="name"
|
|
57
|
+
class="input"
|
|
58
|
+
required
|
|
59
|
+
placeholder="John Doe"
|
|
60
|
+
/>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="field">
|
|
63
|
+
<label class="label">
|
|
64
|
+
{t({
|
|
65
|
+
message: "Email",
|
|
66
|
+
comment: "@context: Setup/signin form field - email",
|
|
67
|
+
})}
|
|
68
|
+
</label>
|
|
69
|
+
<input
|
|
70
|
+
type="email"
|
|
71
|
+
data-bind="email"
|
|
72
|
+
class="input"
|
|
73
|
+
required
|
|
74
|
+
placeholder="you@example.com"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="field">
|
|
78
|
+
<label class="label">
|
|
79
|
+
{t({
|
|
80
|
+
message: "Password",
|
|
81
|
+
comment: "@context: Setup/signin form field - password",
|
|
82
|
+
})}
|
|
83
|
+
</label>
|
|
84
|
+
<input
|
|
85
|
+
type="password"
|
|
86
|
+
data-bind="password"
|
|
87
|
+
class="input"
|
|
88
|
+
required
|
|
89
|
+
minLength={8}
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
<button type="submit" class="btn" data-attr:disabled="$_loading">
|
|
93
|
+
<svg
|
|
94
|
+
data-show="$_loading"
|
|
95
|
+
style="display:none"
|
|
96
|
+
class="animate-spin size-4"
|
|
97
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
98
|
+
viewBox="0 0 24 24"
|
|
99
|
+
fill="none"
|
|
100
|
+
stroke="currentColor"
|
|
101
|
+
stroke-width="2"
|
|
102
|
+
stroke-linecap="round"
|
|
103
|
+
stroke-linejoin="round"
|
|
104
|
+
role="status"
|
|
105
|
+
>
|
|
106
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
107
|
+
</svg>
|
|
108
|
+
{t({
|
|
109
|
+
message: "Complete Setup",
|
|
110
|
+
comment: "@context: Setup form submit button",
|
|
111
|
+
})}
|
|
112
|
+
</button>
|
|
113
|
+
</form>
|
|
114
|
+
</section>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export const setupRoutes = new Hono<Env>();
|
|
121
|
+
|
|
122
|
+
setupRoutes.get("/setup", async (c) => {
|
|
123
|
+
const isComplete = await c.var.services.settings.isOnboardingComplete();
|
|
124
|
+
if (isComplete) return c.redirect("/");
|
|
125
|
+
|
|
126
|
+
return c.html(
|
|
127
|
+
<BaseLayout title="Setup - Jant" c={c}>
|
|
128
|
+
<SetupContent />
|
|
129
|
+
</BaseLayout>,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
setupRoutes.post("/setup", async (c) => {
|
|
134
|
+
const isComplete = await c.var.services.settings.isOnboardingComplete();
|
|
135
|
+
if (isComplete) return c.redirect("/");
|
|
136
|
+
|
|
137
|
+
const body = await c.req.json<Record<string, string>>();
|
|
138
|
+
const parsed = SetupSchema.safeParse(body);
|
|
139
|
+
const browserTimezone = body._timezone;
|
|
140
|
+
|
|
141
|
+
if (!parsed.success) {
|
|
142
|
+
const msg = parsed.error.errors[0]?.message ?? "Invalid input";
|
|
143
|
+
return dsToast(msg, "error");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const { name, email, password } = parsed.data;
|
|
147
|
+
|
|
148
|
+
if (!c.var.auth) {
|
|
149
|
+
return dsToast("AUTH_SECRET not configured", "error");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const signUpResponse = await c.var.auth.api.signUpEmail({
|
|
154
|
+
body: { name, email, password },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!signUpResponse || "error" in signUpResponse) {
|
|
158
|
+
return dsToast("Failed to create account", "error");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await c.var.services.settings.completeOnboarding();
|
|
162
|
+
|
|
163
|
+
// Save auto-detected timezone
|
|
164
|
+
if (browserTimezone) {
|
|
165
|
+
const tz = mapIanaToTimezone(browserTimezone);
|
|
166
|
+
if (tz !== "UTC") {
|
|
167
|
+
await c.var.services.settings.set("TIME_ZONE", tz);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Seed default navigation items
|
|
172
|
+
await c.var.services.navItems.create({
|
|
173
|
+
type: "link",
|
|
174
|
+
label: "Featured",
|
|
175
|
+
url: "/featured",
|
|
176
|
+
});
|
|
177
|
+
await c.var.services.navItems.create({
|
|
178
|
+
type: "link",
|
|
179
|
+
label: "Collections",
|
|
180
|
+
url: "/collections",
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return dsRedirect("/signin?setup");
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// eslint-disable-next-line no-console -- Error logging is intentional
|
|
186
|
+
console.error("Setup error:", err);
|
|
187
|
+
return dsToast("Failed to create account", "error");
|
|
188
|
+
}
|
|
189
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in / Sign-out Routes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Hono } from "hono";
|
|
6
|
+
import type { FC } from "hono/jsx";
|
|
7
|
+
import { useLingui } from "@lingui/react/macro";
|
|
8
|
+
import type { Bindings } from "../../types.js";
|
|
9
|
+
import type { AppVariables } from "../../app.js";
|
|
10
|
+
import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
|
|
11
|
+
import { dsRedirect, dsToast } from "../../lib/sse.js";
|
|
12
|
+
import { SigninSchema } from "../../lib/schemas.js";
|
|
13
|
+
|
|
14
|
+
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
15
|
+
|
|
16
|
+
const SigninContent: FC<{
|
|
17
|
+
demoEmail?: string;
|
|
18
|
+
demoPassword?: string;
|
|
19
|
+
}> = ({ demoEmail, demoPassword }) => {
|
|
20
|
+
const { t } = useLingui();
|
|
21
|
+
const signals = JSON.stringify({
|
|
22
|
+
email: demoEmail || "",
|
|
23
|
+
password: demoPassword || "",
|
|
24
|
+
}).replace(/</g, "\\u003c");
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div class="min-h-screen flex items-center justify-center">
|
|
28
|
+
<div class="card max-w-md w-full">
|
|
29
|
+
<header>
|
|
30
|
+
<h2>
|
|
31
|
+
{t({
|
|
32
|
+
message: "Sign In",
|
|
33
|
+
comment: "@context: Sign in page heading",
|
|
34
|
+
})}
|
|
35
|
+
</h2>
|
|
36
|
+
</header>
|
|
37
|
+
<section>
|
|
38
|
+
{demoEmail && demoPassword && (
|
|
39
|
+
<p class="text-muted-foreground text-sm mb-4">
|
|
40
|
+
{t({
|
|
41
|
+
message: "Demo account pre-filled. Just click Sign In.",
|
|
42
|
+
comment:
|
|
43
|
+
"@context: Hint shown on signin page when demo credentials are pre-filled",
|
|
44
|
+
})}
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
<form
|
|
48
|
+
data-signals={signals}
|
|
49
|
+
data-on:submit__prevent="@post('/signin')"
|
|
50
|
+
data-indicator="_loading"
|
|
51
|
+
class="flex flex-col gap-4"
|
|
52
|
+
>
|
|
53
|
+
<div class="field">
|
|
54
|
+
<label class="label">
|
|
55
|
+
{t({
|
|
56
|
+
message: "Email",
|
|
57
|
+
comment: "@context: Setup/signin form field - email",
|
|
58
|
+
})}
|
|
59
|
+
</label>
|
|
60
|
+
<input type="email" data-bind="email" class="input" required />
|
|
61
|
+
</div>
|
|
62
|
+
<div class="field">
|
|
63
|
+
<label class="label">
|
|
64
|
+
{t({
|
|
65
|
+
message: "Password",
|
|
66
|
+
comment: "@context: Setup/signin form field - password",
|
|
67
|
+
})}
|
|
68
|
+
</label>
|
|
69
|
+
<input
|
|
70
|
+
type="password"
|
|
71
|
+
data-bind="password"
|
|
72
|
+
class="input"
|
|
73
|
+
required
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<button type="submit" class="btn" data-attr:disabled="$_loading">
|
|
77
|
+
<svg
|
|
78
|
+
data-show="$_loading"
|
|
79
|
+
style="display:none"
|
|
80
|
+
class="animate-spin size-4"
|
|
81
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
82
|
+
viewBox="0 0 24 24"
|
|
83
|
+
fill="none"
|
|
84
|
+
stroke="currentColor"
|
|
85
|
+
stroke-width="2"
|
|
86
|
+
stroke-linecap="round"
|
|
87
|
+
stroke-linejoin="round"
|
|
88
|
+
role="status"
|
|
89
|
+
>
|
|
90
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
91
|
+
</svg>
|
|
92
|
+
{t({
|
|
93
|
+
message: "Sign In",
|
|
94
|
+
comment: "@context: Sign in form submit button",
|
|
95
|
+
})}
|
|
96
|
+
</button>
|
|
97
|
+
</form>
|
|
98
|
+
</section>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const signinRoutes = new Hono<Env>();
|
|
105
|
+
|
|
106
|
+
signinRoutes.get("/signin", async (c) => {
|
|
107
|
+
const isSetup = c.req.query("setup") !== undefined;
|
|
108
|
+
const isReset = c.req.query("reset") !== undefined;
|
|
109
|
+
let toast: { message: string } | undefined;
|
|
110
|
+
if (isSetup) {
|
|
111
|
+
toast = { message: "Account created successfully. Please sign in." };
|
|
112
|
+
} else if (isReset) {
|
|
113
|
+
toast = { message: "Password reset successfully. Please sign in." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return c.html(
|
|
117
|
+
<BaseLayout title="Sign In - Jant" c={c} toast={toast}>
|
|
118
|
+
<SigninContent
|
|
119
|
+
demoEmail={c.env.DEMO_EMAIL}
|
|
120
|
+
demoPassword={c.env.DEMO_PASSWORD}
|
|
121
|
+
/>
|
|
122
|
+
</BaseLayout>,
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
signinRoutes.post("/signin", async (c) => {
|
|
127
|
+
if (!c.var.auth) {
|
|
128
|
+
return dsToast("Auth not configured", "error");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const body = await c.req.json();
|
|
132
|
+
const parsed = SigninSchema.safeParse(body);
|
|
133
|
+
|
|
134
|
+
if (!parsed.success) {
|
|
135
|
+
const msg = parsed.error.errors[0]?.message ?? "Invalid input";
|
|
136
|
+
return dsToast(msg, "error");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { email, password } = parsed.data;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const { headers } = await c.var.auth.api.signInEmail({
|
|
143
|
+
returnHeaders: true,
|
|
144
|
+
body: { email, password },
|
|
145
|
+
headers: c.req.raw.headers,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return dsRedirect("/dash", { headers });
|
|
149
|
+
} catch {
|
|
150
|
+
return dsToast("Invalid email or password", "error");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
signinRoutes.get("/signout", async (c) => {
|
|
155
|
+
if (c.var.auth) {
|
|
156
|
+
try {
|
|
157
|
+
await c.var.auth.api.signOut({ headers: c.req.raw.headers });
|
|
158
|
+
} catch {
|
|
159
|
+
// Ignore signout errors
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return c.redirect("/");
|
|
163
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for avatar upload with favicon variant storage in DB settings.
|
|
3
|
+
*
|
|
4
|
+
* Note: Route handlers that import JSX components with @lingui/react/macro
|
|
5
|
+
* cannot run in vitest (requires SWC plugin). These tests verify the
|
|
6
|
+
* service-layer and storage operations that the routes orchestrate.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
10
|
+
import { createTestDatabase } from "../../../__tests__/helpers/db.js";
|
|
11
|
+
import { createSettingsService } from "../../../services/settings.js";
|
|
12
|
+
import { createMediaService } from "../../../services/media.js";
|
|
13
|
+
import {
|
|
14
|
+
arrayBufferToBase64,
|
|
15
|
+
base64ToUint8Array,
|
|
16
|
+
} from "../../../lib/favicon.js";
|
|
17
|
+
import type { Database } from "../../../db/index.js";
|
|
18
|
+
|
|
19
|
+
describe("Dashboard Settings - Avatar Upload Logic", () => {
|
|
20
|
+
let db: Database;
|
|
21
|
+
let settingsService: ReturnType<typeof createSettingsService>;
|
|
22
|
+
let mediaService: ReturnType<typeof createMediaService>;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
const testDb = createTestDatabase();
|
|
26
|
+
db = testDb.db as unknown as Database;
|
|
27
|
+
settingsService = createSettingsService(db);
|
|
28
|
+
mediaService = createMediaService(db);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("avatar upload with favicon variants in DB", () => {
|
|
32
|
+
it("stores avatar media and sets SITE_AVATAR to storageKey", async () => {
|
|
33
|
+
const storageKey = "media/2026/02/test-avatar-id.png";
|
|
34
|
+
await mediaService.create({
|
|
35
|
+
id: "test-avatar-id",
|
|
36
|
+
filename: "test-avatar-id.png",
|
|
37
|
+
originalName: "logo.png",
|
|
38
|
+
mimeType: "image/png",
|
|
39
|
+
size: 5000,
|
|
40
|
+
storageKey,
|
|
41
|
+
provider: "r2",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
await settingsService.set("SITE_AVATAR", storageKey);
|
|
45
|
+
|
|
46
|
+
const avatarKey = await settingsService.get("SITE_AVATAR");
|
|
47
|
+
expect(avatarKey).toBe(storageKey);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("stores favicon ICO as base64 in settings", async () => {
|
|
51
|
+
const fakeIcoData = new Uint8Array([0, 0, 1, 0, 1, 0, 32, 32]);
|
|
52
|
+
const b64 = arrayBufferToBase64(fakeIcoData.buffer);
|
|
53
|
+
await settingsService.set("SITE_FAVICON_ICO", b64);
|
|
54
|
+
|
|
55
|
+
const stored = await settingsService.get("SITE_FAVICON_ICO");
|
|
56
|
+
expect(stored).not.toBeNull();
|
|
57
|
+
const decoded = base64ToUint8Array(stored!);
|
|
58
|
+
expect(Array.from(decoded)).toEqual(Array.from(fakeIcoData));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("stores apple-touch-icon as base64 in settings", async () => {
|
|
62
|
+
const fakePng = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
|
|
63
|
+
const b64 = arrayBufferToBase64(fakePng.buffer);
|
|
64
|
+
await settingsService.set("SITE_FAVICON_APPLE_TOUCH", b64);
|
|
65
|
+
|
|
66
|
+
const stored = await settingsService.get("SITE_FAVICON_APPLE_TOUCH");
|
|
67
|
+
expect(stored).not.toBeNull();
|
|
68
|
+
const decoded = base64ToUint8Array(stored!);
|
|
69
|
+
expect(Array.from(decoded)).toEqual(Array.from(fakePng));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("avatar removal cleans up favicon settings", () => {
|
|
74
|
+
it("removes all favicon-related settings", async () => {
|
|
75
|
+
await settingsService.set("SITE_AVATAR", "media/2026/02/some-id.png");
|
|
76
|
+
await settingsService.set("SITE_FAVICON_ICO", "base64data");
|
|
77
|
+
await settingsService.set("SITE_FAVICON_APPLE_TOUCH", "base64data");
|
|
78
|
+
|
|
79
|
+
// Simulate avatar removal
|
|
80
|
+
await settingsService.remove("SITE_AVATAR");
|
|
81
|
+
await settingsService.remove("SITE_FAVICON_ICO");
|
|
82
|
+
await settingsService.remove("SITE_FAVICON_APPLE_TOUCH");
|
|
83
|
+
|
|
84
|
+
expect(await settingsService.get("SITE_AVATAR")).toBeNull();
|
|
85
|
+
expect(await settingsService.get("SITE_FAVICON_ICO")).toBeNull();
|
|
86
|
+
expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|