theme rework

AuthorKonata <konata@posteo.jp>
Date
Commit995c84a33197740a4cdf26bbade19e1665777eb6
Parentaece819
18 files changed, 280 insertions(+), 270 deletions(-)
Massets/default.css
@@ -1,5 +1,5 @@
11 @import "normalize.css";
2-@import "sakura-vader.css";
2+@import "theme.css";
33
44 body {
55 display: flex;
@@ -10,6 +10,10 @@ body {
1010 padding: 3rem;
1111 box-sizing: border-box;
1212 max-width: 80rem;
13+ /* Center the page. Sakura's body sets `margin: auto`, but it now loads in a
14+ separate <link> before this file's `@import "normalize.css"` (which resets
15+ `margin: 0`), so we re-assert it here rather than depend on sheet order. */
16+ margin-inline: auto;
1317 }
1418
1519 a {
@@ -77,7 +81,7 @@ img {
7781 .help-icon {
7882 width: 2rem;
7983 height: 2rem;
80- filter: invert();
84+ filter: var(--help-icon-filter);
8185 margin-left: 0.5rem;
8286 }
8387
@@ -94,9 +98,9 @@ input[type="reset"] {
9498 }
9599
96100 dialog {
97- background-color: #120c0e;
98- color: #d9d8dc;
99- border-color: #eb99a1;
101+ background-color: var(--app-bg);
102+ color: var(--app-fg);
103+ border-color: var(--app-accent);
100104
101105 form {
102106 display: flex;
Dassets/highlight.css
-1
@@ -1 +0,0 @@
1-pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#1d1f21;color:#c5c8c6}.hljs span::selection,.hljs::selection{background:#373b41}.hljs span::-moz-selection,.hljs::-moz-selection{background:#373b41}.hljs-name,.hljs-title{color:#f0c674}.hljs-comment,.hljs-meta,.hljs-meta .hljs-keyword{color:#707880}.hljs-deletion,.hljs-link,.hljs-literal,.hljs-number,.hljs-symbol{color:#c66}.hljs-addition,.hljs-doctag,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-string{color:#b5bd68}.hljs-attribute,.hljs-code,.hljs-selector-id{color:#b294bb}.hljs-bullet,.hljs-keyword,.hljs-selector-tag,.hljs-tag{color:#81a2be}.hljs-subst,.hljs-template-tag,.hljs-template-variable,.hljs-variable{color:#8abeb7}.hljs-built_in,.hljs-quote,.hljs-section,.hljs-selector-class,.hljs-type{color:#de935f}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
Dassets/sakura-vader.css
-227
@@ -1,227 +0,0 @@
1-/* $color-text: #dedce5; */
2-/* Sakura.css v1.5.0
3- * ================
4- * Minimal css theme.
5- * Project: https://github.com/oxalorg/sakura/
6- */
7-/* Body */
8-html {
9- font-size: 62.5%;
10- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
11-}
12-
13-body {
14- font-size: 1.8rem;
15- line-height: 1.618;
16- max-width: 38em;
17- margin: auto;
18- color: #d9d8dc;
19- background-color: #120c0e;
20- padding: 13px;
21-}
22-
23-@media (max-width: 684px) {
24- body {
25- font-size: 1.53rem;
26- }
27-}
28-@media (max-width: 382px) {
29- body {
30- font-size: 1.35rem;
31- }
32-}
33-h1, h2, h3, h4, h5, h6 {
34- line-height: 1.1;
35- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
36- font-weight: 700;
37- margin-top: 3rem;
38- margin-bottom: 1.5rem;
39- overflow-wrap: break-word;
40- word-wrap: break-word;
41- -ms-word-break: break-all;
42- word-break: break-word;
43-}
44-
45-h1 {
46- font-size: 2.35em;
47-}
48-
49-h2 {
50- font-size: 2em;
51-}
52-
53-h3 {
54- font-size: 1.75em;
55-}
56-
57-h4 {
58- font-size: 1.5em;
59-}
60-
61-h5 {
62- font-size: 1.25em;
63-}
64-
65-h6 {
66- font-size: 1em;
67-}
68-
69-p {
70- margin-top: 0px;
71- margin-bottom: 2.5rem;
72-}
73-
74-small, sub, sup {
75- font-size: 75%;
76-}
77-
78-hr {
79- border-color: #eb99a1;
80-}
81-
82-a {
83- text-decoration: none;
84- color: #eb99a1;
85-}
86-a:visited {
87- color: #e26f7a;
88-}
89-a:hover {
90- color: #DA4453;
91- border-bottom: 2px solid #d9d8dc;
92-}
93-
94-ul {
95- padding-left: 1.4em;
96- margin-top: 0px;
97- margin-bottom: 2.5rem;
98-}
99-
100-li {
101- margin-bottom: 0.4em;
102-}
103-
104-blockquote {
105- margin-left: 0px;
106- margin-right: 0px;
107- padding-left: 1em;
108- padding-top: 0.8em;
109- padding-bottom: 0.8em;
110- padding-right: 0.8em;
111- border-left: 5px solid #eb99a1;
112- margin-bottom: 2.5rem;
113- background-color: #40363a;
114-}
115-
116-blockquote p {
117- margin-bottom: 0;
118-}
119-
120-img, video {
121- height: auto;
122- max-width: 100%;
123- margin-top: 0px;
124- margin-bottom: 2.5rem;
125-}
126-
127-/* Pre and Code */
128-pre {
129- background-color: #40363a;
130- display: block;
131- padding: 1em;
132- overflow-x: auto;
133- margin-top: 0px;
134- margin-bottom: 2.5rem;
135- font-size: 0.9em;
136-}
137-
138-code, kbd, samp {
139- font-size: 0.9em;
140- padding: 0 0.5em;
141- background-color: #40363a;
142- white-space: pre-wrap;
143-}
144-
145-pre > code {
146- padding: 0;
147- background-color: transparent;
148- white-space: pre;
149- font-size: 1em;
150-}
151-
152-/* Tables */
153-table {
154- text-align: justify;
155- width: 100%;
156- border-collapse: collapse;
157- margin-bottom: 2rem;
158-}
159-
160-td, th {
161- padding: 0.5em;
162- border-bottom: 1px solid #40363a;
163-}
164-
165-/* Buttons, forms and input */
166-input, textarea {
167- border: 1px solid #d9d8dc;
168-}
169-input:focus, textarea:focus {
170- border: 1px solid #eb99a1;
171-}
172-
173-textarea {
174- width: 100%;
175-}
176-
177-.button, button, input[type=submit], input[type=reset], input[type=button], input[type=file]::file-selector-button {
178- display: inline-block;
179- padding: 5px 10px;
180- text-align: center;
181- text-decoration: none;
182- white-space: nowrap;
183- background-color: #eb99a1;
184- color: #120c0e;
185- border-radius: 1px;
186- border: 1px solid #eb99a1;
187- cursor: pointer;
188- box-sizing: border-box;
189-}
190-.button[disabled], button[disabled], input[type=submit][disabled], input[type=reset][disabled], input[type=button][disabled], input[type=file]::file-selector-button[disabled] {
191- cursor: default;
192- opacity: 0.5;
193-}
194-.button:hover, button:hover, input[type=submit]:hover, input[type=reset]:hover, input[type=button]:hover, input[type=file]::file-selector-button:hover {
195- background-color: #DA4453;
196- color: #120c0e;
197- outline: 0;
198-}
199-.button:focus-visible, button:focus-visible, input[type=submit]:focus-visible, input[type=reset]:focus-visible, input[type=button]:focus-visible, input[type=file]::file-selector-button:focus-visible {
200- outline-style: solid;
201- outline-width: 2px;
202-}
203-
204-textarea, select, input {
205- color: #d9d8dc;
206- padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
207- margin-bottom: 10px;
208- background-color: #40363a;
209- border: 1px solid #40363a;
210- border-radius: 4px;
211- box-shadow: none;
212- box-sizing: border-box;
213-}
214-textarea:focus, select:focus, input:focus {
215- border: 1px solid #eb99a1;
216- outline: 0;
217-}
218-
219-input[type=checkbox]:focus {
220- outline: 1px dotted #eb99a1;
221-}
222-
223-label, legend, fieldset {
224- display: block;
225- margin-bottom: 0.5rem;
226- font-weight: 600;
227-}
Massets/show.css
@@ -1,6 +1,5 @@
11 @import "normalize.css";
2-@import "sakura-vader.css";
3-@import "highlight.css";
2+@import "theme.css";
43
54 hr {
65 width: 100%;
@@ -45,7 +44,7 @@ body {
4544 flex-grow: 1;
4645 height: 100%;
4746 overflow: auto;
48- border: 1px solid #40363a;
47+ border: 1px solid var(--app-border);
4948 border-right: unset;
5049 display: flex;
5150 flex-direction: column;
@@ -76,7 +75,7 @@ body {
7675 padding: 1rem;
7776 width: 30rem;
7877 height: 100%;
79- border: 1px solid #40363a;
78+ border: 1px solid var(--app-border);
8079 display: flex;
8180 flex-direction: column;
8281
@@ -100,7 +99,7 @@ body {
10099 #content {
101100 /* The shared divider is now the bottom edge; restore the right border
102101 the row layout dropped and drop the bottom one. */
103- border-right: 1px solid #40363a;
102+ border-right: 1px solid var(--app-border);
104103 border-bottom: unset;
105104 }
106105
@@ -133,8 +132,8 @@ video {
133132 flex-direction: column;
134133 align-items: center;
135134 justify-content: center;
136- background-color: #120c0e;
137- color: #d9d8dc;
135+ background-color: var(--app-bg);
136+ color: var(--app-fg);
138137 text-align: center;
139138
140139 h1 {
Aassets/theme.css
@@ -0,0 +1,70 @@
1+/* Theme tokens for the app's own (non-sakura) colors. The server always renders
2+ an explicit data-theme (auto/light/dark) on <html>, so the selectors key off
3+ it. Dark values are the exact "vader" palette, so dark mode is unchanged. */
4+:root,
5+:root[data-theme="light"],
6+:root[data-theme="auto"] {
7+ --app-bg: #f9f9f9;
8+ --app-fg: #4a4a4a;
9+ --app-accent: #1d7484;
10+ --app-surface: #f1f1f1;
11+ --app-border: #d1d1d1;
12+ --help-icon-filter: none;
13+}
14+
15+:root[data-theme="dark"] {
16+ --app-bg: #120c0e;
17+ --app-fg: #d9d8dc;
18+ --app-accent: #eb99a1;
19+ --app-surface: #40363a;
20+ --app-border: #40363a;
21+ --help-icon-filter: invert();
22+}
23+
24+/* auto + OS prefers dark: mirror the forced-dark palette */
25+@media (prefers-color-scheme: dark) {
26+ :root[data-theme="auto"] {
27+ --app-bg: #120c0e;
28+ --app-fg: #d9d8dc;
29+ --app-accent: #eb99a1;
30+ --app-surface: #40363a;
31+ --app-border: #40363a;
32+ --help-icon-filter: invert();
33+ }
34+}
35+
36+/* The theme toggle: a small icon button pinned top-right on every page. Resets
37+ sakura's accent button skin to a plain bordered icon (id specificity wins). */
38+/* Reset the page's generic `form` styling (default.css makes forms full-width
39+ flex columns) so the toggle stays a small fixed corner control. */
40+#theme-form {
41+ position: fixed;
42+ top: 0.75rem;
43+ right: 0.75rem;
44+ z-index: 100;
45+ margin: 0;
46+ display: block;
47+ width: auto;
48+ min-width: 0;
49+}
50+
51+#theme-toggle {
52+ background: transparent;
53+ border: none;
54+ color: var(--app-fg);
55+ padding: 0.3rem;
56+ cursor: pointer;
57+ display: flex;
58+ align-items: center;
59+}
60+
61+#theme-toggle:hover {
62+ background: transparent;
63+ color: var(--app-accent);
64+}
65+
66+#theme-toggle svg {
67+ width: 1.9rem;
68+ height: 1.9rem;
69+ display: block;
70+}
Mbiome.json
@@ -12,7 +12,8 @@
1212 "*.json",
1313 "!bun.lock",
1414 "assets/show.css",
15- "assets/default.css"
15+ "assets/default.css",
16+ "assets/theme.css"
1617 ]
1718 },
1819 "formatter": {
Mbun.lock
@@ -8,14 +8,15 @@
88 "@elysiajs/cron": "^1.2.0",
99 "@elysiajs/html": "^1.2.0",
1010 "@elysiajs/static": "^1.2.0",
11- "@types/busboy": "^1.5.4",
1211 "busboy": "^1.6.0",
1312 "elysia": "^1.2.0",
1413 "file-type": "^20.0.1",
1514 "highlight.js": "^11.11.1",
15+ "sakura.css": "^1.5.1",
1616 },
1717 "devDependencies": {
1818 "@biomejs/biome": "2.4.16",
19+ "@types/busboy": "^1.5.4",
1920 "bun-types": "latest",
2021 },
2122 },
@@ -109,6 +110,8 @@
109110
110111 "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
111112
113+ "sakura.css": ["sakura.css@1.5.1", "", {}, "sha512-MAL13eVLmTuiF6a/SjrzK5PItWQwMrsIIldLq7kVwuJjxoxVyzwGJbP+JNSwLt34VF8Eg9yj3q17GG/h8jhvMA=="],
114+
112115 "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
113116
114117 "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
Mpackage.json
@@ -2,7 +2,7 @@
22 "name": "zbin",
33 "version": "1.0.0",
44 "scripts": {
5- "build": "bun build src/client*.ts --outdir assets/dist",
5+ "build": "bun build src/client*.ts src/styles/*.css --outdir assets/dist",
66 "dev": "bun run build && bun run --watch src/index.ts",
77 "prod": "bun run build && bun run src/index.ts",
88 "format": "biome format --write",
@@ -17,7 +17,8 @@
1717 "busboy": "^1.6.0",
1818 "elysia": "^1.2.0",
1919 "file-type": "^20.0.1",
20- "highlight.js": "^11.11.1"
20+ "highlight.js": "^11.11.1",
21+ "sakura.css": "^1.5.1"
2122 },
2223 "devDependencies": {
2324 "@biomejs/biome": "2.4.16",
Asrc/client-theme.ts
@@ -0,0 +1,38 @@
1+import { type Theme, themeIcon } from "./shared";
2+
3+// Progressive enhancement for the theme toggle. The server already rendered the
4+// correct theme (data-theme + the dark sheet's media), so there's nothing to do
5+// on load — we only intercept the toggle form to switch instantly without the
6+// full-page reload the no-JS POST would cause, and keep the cookie in sync.
7+
8+const order: Theme[] = ["auto", "light", "dark"];
9+
10+function current(): Theme {
11+ const value = document.documentElement.dataset.theme;
12+ return value === "light" || value === "dark" ? value : "auto";
13+}
14+
15+function apply(theme: Theme) {
16+ document.documentElement.dataset.theme = theme;
17+ const darkMedia =
18+ theme === "auto"
19+ ? "(prefers-color-scheme: dark)"
20+ : theme === "dark"
21+ ? "all"
22+ : "not all";
23+ // Swap both dark sheets in lockstep: sakura (page chrome) and highlight.js
24+ // (code syntax). Each is null on pages that don't include it.
25+ for (const id of ["sakura-dark", "highlight-dark"]) {
26+ const sheet = document.getElementById(id) as HTMLLinkElement | null;
27+ if (sheet) sheet.media = darkMedia;
28+ }
29+ const button = document.getElementById("theme-toggle");
30+ if (button) button.innerHTML = themeIcon(theme);
31+ // biome-ignore lint/suspicious/noDocumentCookie: a single non-HttpOnly cookie write; the async Cookie Store API isn't worth the complexity (or its weaker browser support) here
32+ document.cookie = `zbin-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
33+}
34+
35+document.getElementById("theme-form")?.addEventListener("submit", (e) => {
36+ e.preventDefault();
37+ apply(order[(order.indexOf(current()) + 1) % order.length]);
38+});
Msrc/components.test.ts
@@ -12,6 +12,7 @@ test("escapes filename in the file view (no stored XSS)", () => {
1212 deleteAt: null,
1313 size: 2,
1414 preview: { kind: "text", html: "hi" },
15+ theme: "auto",
1516 });
1617 expect(html).not.toContain(evil);
1718 expect(html).toContain("&lt;img");
@@ -26,6 +27,7 @@ test("escapes filename in the encrypted overlay (no stored XSS)", () => {
2627 deleteAt: null,
2728 size: null,
2829 preview: { kind: "await" },
30+ theme: "auto",
2931 });
3032 expect(html).not.toContain(evil);
3133 expect(html).toContain("&lt;script&gt;");
Msrc/components.tsx
@@ -4,34 +4,90 @@ import type { PropsWithChildren } from "@kitajs/html";
44 import { escapeHTML } from "bun";
55 import hljs from "highlight.js";
66 import { config } from "./config";
7-import { humanFileSize, isValidUTF8 } from "./shared";
7+import { humanFileSize, isValidUTF8, type Theme, themeIcon } from "./shared";
88
99 export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
1010
11-function Template(props: PropsWithChildren<{ css: string }>) {
11+// The auto/light/dark toggle. A plain form so it works without JS (it POSTs to
12+// /theme/cycle, which flips the cookie and redirects back); client-theme.js
13+// upgrades it to switch instantly without a reload.
14+function ThemeToggle(props: { theme: Theme }) {
15+ return (
16+ <form id="theme-form" method="post" action="/theme/cycle">
17+ <button
18+ id="theme-toggle"
19+ type="submit"
20+ aria-label={`Switch color theme (current: ${props.theme})`}
21+ >
22+ {themeIcon(props.theme)}
23+ </button>
24+ </form>
25+ );
26+}
27+
28+function Template(props: PropsWithChildren<{ css: string; theme: Theme }>) {
29+ // Server-render the active theme so it's correct before first paint (no flash)
30+ // and works with JS off. The dark sakura sheet is gated by `media`: in auto it
31+ // follows the OS, otherwise it's forced fully on/off.
32+ const darkMedia =
33+ props.theme === "auto"
34+ ? "(prefers-color-scheme: dark)"
35+ : props.theme === "dark"
36+ ? "all"
37+ : "not all";
1238 return (
1339 <>
1440 {"<!doctype html>"}
15- <html lang="en">
41+ <html lang="en" data-theme={props.theme}>
1642 <head>
1743 <title>⚡ZBin⚡</title>
1844 <meta charset="UTF-8" />
1945 <meta name="viewport" content="width=device-width, initial-scale=1" />
46+ <link
47+ id="sakura-light"
48+ rel="stylesheet"
49+ href="/dist/styles/sakura-light.css"
50+ />
51+ <link
52+ id="sakura-dark"
53+ rel="stylesheet"
54+ href="/dist/styles/sakura-dark.css"
55+ media={darkMedia}
56+ />
57+ {/* highlight.js syntax themes, gated like sakura's: light is the
58+ base, the dark sheet's `media` follows the active theme. Only
59+ the show page renders `.hljs` markup, but loading them sitewide
60+ keeps the head (and the client toggle) uniform. */}
61+ <link
62+ id="highlight-light"
63+ rel="stylesheet"
64+ href="/dist/styles/highlight-light.css"
65+ />
66+ <link
67+ id="highlight-dark"
68+ rel="stylesheet"
69+ href="/dist/styles/highlight-dark.css"
70+ media={darkMedia}
71+ />
2072 <link rel="stylesheet" href={props.css} />
2173 <link
2274 rel="icon"
2375 href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📂</text></svg>"
2476 />
77+ <script src="/dist/client-theme.js" defer />
2578 </head>
26- <body>{props.children}</body>
79+ <body>
80+ <ThemeToggle theme={props.theme} />
81+ {props.children}
82+ </body>
2783 </html>
2884 </>
2985 );
3086 }
3187
32-export function NotFound() {
88+export function NotFound(theme: Theme) {
3389 return (
34- <Template css="/default.css">
90+ <Template css="/default.css" theme={theme}>
3591 <h1>404</h1>
3692 <p>File not found</p>
3793 </Template>
@@ -95,9 +151,9 @@ function ServerLimits() {
95151 );
96152 }
97153
98-export function Index(hostname: string) {
154+export function Index(hostname: string, theme: Theme) {
99155 return (
100- <Template css="/default.css">
156+ <Template css="/default.css" theme={theme}>
101157 <h1>⚡ZBin⚡</h1>
102158 <ServerLimits />
103159 <dialog id="upload-dialog">File is being uploaded, please wait</dialog>
@@ -237,9 +293,9 @@ export function Index(hostname: string) {
237293 );
238294 }
239295
240-export function WrongPassword() {
296+export function WrongPassword(theme: Theme) {
241297 return (
242- <Template css="/default.css">
298+ <Template css="/default.css" theme={theme}>
243299 <h1>Incorrect password</h1>
244300 <p>Reload page to try again</p>
245301 </Template>
@@ -295,8 +351,9 @@ export function ShowFile(opts: {
295351 deleteAt: number | null;
296352 size: number | null;
297353 preview: Preview;
354+ theme: Theme;
298355 }) {
299- const { filename, uuid, filetype, deleteAt, size, preview } = opts;
356+ const { filename, uuid, filetype, deleteAt, size, preview, theme } = opts;
300357 const awaiting = preview.kind === "await";
301358 const rawUrl = `/raw/${uuid}`;
302359
@@ -319,7 +376,7 @@ export function ShowFile(opts: {
319376 }
320377
321378 return (
322- <Template css="/show.css">
379+ <Template css="/show.css" theme={theme}>
323380 {/* Sits above the decrypt overlay (see #logo z-index) so it stays a way
324381 back home even before the file is unlocked. */}
325382 <a id="logo" href="/">
@@ -381,7 +438,9 @@ export function ShowFile(opts: {
381438 {deleteAt ? (
382439 <p>
383440 delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
384- {humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
441+ {humanReadableTime(
442+ Math.floor((deleteAt - Date.now() / 1000) / 60),
443+ )}
385444 )
386445 </p>
387446 ) : (
@@ -397,9 +456,9 @@ export function ShowFile(opts: {
397456 );
398457 }
399458
400-export function SetCookie(uuid: string) {
459+export function SetCookie(uuid: string, theme: Theme) {
401460 return (
402- <Template css="/default.css">
461+ <Template css="/default.css" theme={theme}>
403462 Redirecting you back to{" "}
404463 <a safe href={`/show/${uuid}`}>
405464 /show/{uuid}
Msrc/index.ts
@@ -21,6 +21,7 @@ import {
2121 } from "./components";
2222 import { config } from "./config";
2323 import { decryptToStream, encryptStream } from "./crypto";
24+import { getTheme } from "./shared";
2425
2526 // Paths default to ./db; overridable via env so tests can point at a throwaway
2627 // directory instead of the real database/blobs.
@@ -191,7 +192,29 @@ const app = new Elysia({
191192 },
192193 }),
193194 )
194- .get("/", ({ server }) => Index(server?.url.toString() ?? ""))
195+ .get("/", ({ server, headers }) =>
196+ Index(server?.url.toString() ?? "", getTheme(headers.cookie)),
197+ )
198+ // Cycles the theme preference (auto → light → dark) for clients without JS:
199+ // the toggle is a form that POSTs here, we flip the cookie and redirect back.
200+ // JS clients intercept the submit and never hit this route.
201+ .post("/theme/cycle", ({ set, headers }) => {
202+ const order = ["auto", "light", "dark"] as const;
203+ const next =
204+ order[(order.indexOf(getTheme(headers.cookie)) + 1) % order.length];
205+ const secure = config.behindProxy ? "; Secure" : "";
206+ set.headers["set-cookie"] =
207+ `zbin-theme=${next}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
208+ // Redirect back to the page the toggle was on, using only the path so the
209+ // Referer can't be turned into an open redirect to another host.
210+ let back = "/";
211+ try {
212+ back = new URL(headers.referer ?? "").pathname || "/";
213+ } catch {}
214+ set.headers.location = back;
215+ set.status = StatusMap["See Other"];
216+ return "";
217+ })
195218 .post(
196219 "/upload",
197220 async ({ set, server, request, headers }) => {
@@ -445,6 +468,7 @@ const app = new Elysia({
445468 .get(
446469 "/show/:uuid",
447470 async ({ set, params, cookie, server, request, headers }) => {
471+ const theme = getTheme(headers.cookie);
448472 const row =
449473 (db
450474 .prepare(
@@ -460,7 +484,7 @@ const app = new Elysia({
460484 }) || null;
461485 if (!row) {
462486 set.status = StatusMap["Not Found"];
463- return NotFound();
487+ return NotFound(theme);
464488 }
465489
466490 const path = blobPath(params.uuid);
@@ -468,7 +492,7 @@ const app = new Elysia({
468492 // Row without its blob (e.g. the file was removed out of band): treat
469493 // as not found rather than failing later while reading/streaming it.
470494 set.status = StatusMap["Not Found"];
471- return NotFound();
495+ return NotFound(theme);
472496 }
473497 let preview: Preview;
474498 let shownSize: number | null = row.size;
@@ -513,7 +537,7 @@ const app = new Elysia({
513537 `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
514538 `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
515539 ];
516- return WrongPassword();
540+ return WrongPassword(theme);
517541 }
518542 }
519543 } else if (row.media_mime && isInlineMedia(row.media_mime)) {
@@ -544,6 +568,7 @@ const app = new Elysia({
544568 deleteAt: row.delete_at,
545569 size: shownSize,
546570 preview,
571+ theme,
547572 });
548573 },
549574 {
@@ -555,7 +580,7 @@ const app = new Elysia({
555580 )
556581 .post(
557582 "/set-cookie/:uuid",
558- ({ set, body, params }) => {
583+ ({ set, body, params, headers }) => {
559584 // encodeURIComponent keeps ';', CR/LF and other separators out of the
560585 // cookie value; Elysia URL-decodes the value again when it reads it back.
561586 const value = encodeURIComponent(body.password);
@@ -566,7 +591,7 @@ const app = new Elysia({
566591 ];
567592 set.headers.location = `/show/${params.uuid}`;
568593 set.status = StatusMap["See Other"];
569- return SetCookie(params.uuid);
594+ return SetCookie(params.uuid, getTheme(headers.cookie));
570595 },
571596 {
572597 body: t.Object({
Msrc/routes.test.ts
@@ -22,9 +22,9 @@ const text = new TextDecoder();
2222
2323 // A 1x1 PNG header — enough magic for file-type to detect image/png.
2424 const PNG = new Uint8Array([
25- 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
26- 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
27- 0x08, 0x06, 0x00, 0x00, 0x00,
25+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
26+ 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
27+ 0x00, 0x00, 0x00,
2828 ]);
2929
3030 function upload(
@@ -72,7 +72,11 @@ describe("home page", () => {
7272
7373 describe("plaintext round-trip", () => {
7474 test("upload → show renders the content, raw returns the exact bytes", async () => {
75- const uuid = await uploadId({ filetype: "none" }, "hello.txt", "hello world");
75+ const uuid = await uploadId(
76+ { filetype: "none" },
77+ "hello.txt",
78+ "hello world",
79+ );
7680
7781 const show = await get(`/show/${uuid}`);
7882 expect(show.status).toBe(200);
@@ -193,7 +197,9 @@ describe("already-encrypted (client-side) uploads", () => {
193197 );
194198
195199 // No password is known to the server, so /show defers to the client flow.
196- expect(await (await get(`/show/${uuid}`)).text()).toContain("decrypt-overlay");
200+ expect(await (await get(`/show/${uuid}`)).text()).toContain(
201+ "decrypt-overlay",
202+ );
197203
198204 const raw = await get(`/raw/${uuid}?ignore_password=true`);
199205 const stored = new Uint8Array(await raw.arrayBuffer());
Msrc/shared.ts
@@ -14,6 +14,32 @@ export function isValidUTF8(buf: ArrayBuffer | ArrayBufferView): boolean {
1414 }
1515 }
1616
17+export type Theme = "auto" | "light" | "dark";
18+
19+// Reads the persisted theme choice from a raw Cookie request header, defaulting
20+// to "auto" (follow the OS) when unset or unrecognized. Values are plain
21+// (auto/light/dark), so no decoding is needed.
22+export function getTheme(cookieHeader: string | undefined): Theme {
23+ const value = cookieHeader?.match(/(?:^|;\s*)zbin-theme=([^;]+)/)?.[1];
24+ return value === "light" || value === "dark" ? value : "auto";
25+}
26+
27+// Inline SVG for the toggle's current state, drawn in `currentColor` so it picks
28+// up the button's text color. Shared by the server (initial render) and
29+// client-theme.js (on toggle): light = sun, dark = crescent, auto = a half-filled
30+// disc suggesting "follows the system". Injected as raw markup (not escaped).
31+export function themeIcon(theme: Theme): string {
32+ const open =
33+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">';
34+ if (theme === "light") {
35+ return `${open}<circle cx="12" cy="12" r="4.5"/><path d="M12 1.5v3M12 19.5v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1.5 12h3M19.5 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/></svg>`;
36+ }
37+ if (theme === "dark") {
38+ return `${open}<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>`;
39+ }
40+ return `${open}<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18z" fill="currentColor" stroke="none"/></svg>`;
41+}
42+
1743 const MODE_STORAGE_KEY = "zbin-mode";
1844
1945 // Enables the (HTML-disabled) client-side radio now that JS is running, applies
Asrc/styles/highlight-dark.css
@@ -0,0 +1 @@
1+@import "highlight.js/styles/base16/tomorrow-night.css";
Asrc/styles/highlight-light.css
@@ -0,0 +1 @@
1+@import "highlight.js/styles/base16/tomorrow.css";
Asrc/styles/sakura-dark.css
@@ -0,0 +1 @@
1+@import "sakura.css/css/sakura-vader.css";
Asrc/styles/sakura-light.css
@@ -0,0 +1 @@
1+@import "sakura.css/css/sakura.css";