shared.ts
| 1 | export function humanFileSize(size: number) { |
| 2 | if (size < 1024) return `${size} B`; |
| 3 | const i = Math.min(4, Math.floor(Math.log(size) / Math.log(1024))); |
| 4 | return `${(size / 1024 ** i).toFixed(2)} ${["B", "KiB", "MiB", "GiB", "TiB"][i]}`; |
| 5 | } |
| 6 | |
| 7 | // TextDecoder-based UTF-8 check that works in both Bun and the browser. |
| 8 | export function isValidUTF8(buf: ArrayBuffer | ArrayBufferView): boolean { |
| 9 | try { |
| 10 | new TextDecoder("utf-8", { fatal: true }).decode(buf); |
| 11 | return true; |
| 12 | } catch (_e) { |
| 13 | return false; |
| 14 | } |
| 15 | } |
| 16 | |
| 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 | |
| 43 | const MODE_STORAGE_KEY = "zbin-mode"; |
| 44 | |
| 45 | // Enables the (HTML-disabled) client-side radio now that JS is running, applies |
| 46 | // the saved client/server preference (defaulting to client since JS is here), |
| 47 | // and persists changes. Shared by the index (encrypt_mode) and show (decrypt_mode) pages. |
| 48 | export function setupModeRadios(name: string) { |
| 49 | const radios = document.querySelectorAll<HTMLInputElement>( |
| 50 | `input[name="${name}"]`, |
| 51 | ); |
| 52 | if (radios.length === 0) return; |
| 53 | const saved = localStorage.getItem(MODE_STORAGE_KEY) ?? "client"; |
| 54 | for (const radio of radios) { |
| 55 | radio.disabled = false; |
| 56 | radio.checked = radio.value === saved; |
| 57 | radio.addEventListener("change", () => { |
| 58 | if (radio.checked) localStorage.setItem(MODE_STORAGE_KEY, radio.value); |
| 59 | }); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | export function getMode(name: string): string { |
| 64 | const checked = document.querySelector<HTMLInputElement>( |
| 65 | `input[name="${name}"]:checked`, |
| 66 | ); |
| 67 | return checked?.value ?? "server"; |
| 68 | } |
| 69 |