theme rework
Massets/default.css
@@ -1,5 +1,5 @@
@import "normalize.css";
@import "sakura-vader.css";
@import "theme.css";
body {
display: flex;
@@ -10,6 +10,10 @@ body {
padding: 3rem;
box-sizing: border-box;
max-width: 80rem;
/* Center the page. Sakura's body sets `margin: auto`, but it now loads in a
separate <link> before this file's `@import "normalize.css"` (which resets
`margin: 0`), so we re-assert it here rather than depend on sheet order. */
margin-inline: auto;
}
a {
@@ -77,7 +81,7 @@ img {
.help-icon {
width: 2rem;
height: 2rem;
filter: invert();
filter: var(--help-icon-filter);
margin-left: 0.5rem;
}
@@ -94,9 +98,9 @@ input[type="reset"] {
}
dialog {
background-color: #120c0e;
color: #d9d8dc;
border-color: #eb99a1;
background-color: var(--app-bg);
color: var(--app-fg);
border-color: var(--app-accent);
form {
display: flex;
Dassets/highlight.css-1
@@ -1 +0,0 @@
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 @@
/* $color-text: #dedce5; */
/* Sakura.css v1.5.0
* ================
* Minimal css theme.
* Project: https://github.com/oxalorg/sakura/
*/
/* Body */
html {
font-size: 62.5%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
}
body {
font-size: 1.8rem;
line-height: 1.618;
max-width: 38em;
margin: auto;
color: #d9d8dc;
background-color: #120c0e;
padding: 13px;
}
@media (max-width: 684px) {
body {
font-size: 1.53rem;
}
}
@media (max-width: 382px) {
body {
font-size: 1.35rem;
}
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.1;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
font-weight: 700;
margin-top: 3rem;
margin-bottom: 1.5rem;
overflow-wrap: break-word;
word-wrap: break-word;
-ms-word-break: break-all;
word-break: break-word;
}
h1 {
font-size: 2.35em;
}
h2 {
font-size: 2em;
}
h3 {
font-size: 1.75em;
}
h4 {
font-size: 1.5em;
}
h5 {
font-size: 1.25em;
}
h6 {
font-size: 1em;
}
p {
margin-top: 0px;
margin-bottom: 2.5rem;
}
small, sub, sup {
font-size: 75%;
}
hr {
border-color: #eb99a1;
}
a {
text-decoration: none;
color: #eb99a1;
}
a:visited {
color: #e26f7a;
}
a:hover {
color: #DA4453;
border-bottom: 2px solid #d9d8dc;
}
ul {
padding-left: 1.4em;
margin-top: 0px;
margin-bottom: 2.5rem;
}
li {
margin-bottom: 0.4em;
}
blockquote {
margin-left: 0px;
margin-right: 0px;
padding-left: 1em;
padding-top: 0.8em;
padding-bottom: 0.8em;
padding-right: 0.8em;
border-left: 5px solid #eb99a1;
margin-bottom: 2.5rem;
background-color: #40363a;
}
blockquote p {
margin-bottom: 0;
}
img, video {
height: auto;
max-width: 100%;
margin-top: 0px;
margin-bottom: 2.5rem;
}
/* Pre and Code */
pre {
background-color: #40363a;
display: block;
padding: 1em;
overflow-x: auto;
margin-top: 0px;
margin-bottom: 2.5rem;
font-size: 0.9em;
}
code, kbd, samp {
font-size: 0.9em;
padding: 0 0.5em;
background-color: #40363a;
white-space: pre-wrap;
}
pre > code {
padding: 0;
background-color: transparent;
white-space: pre;
font-size: 1em;
}
/* Tables */
table {
text-align: justify;
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
}
td, th {
padding: 0.5em;
border-bottom: 1px solid #40363a;
}
/* Buttons, forms and input */
input, textarea {
border: 1px solid #d9d8dc;
}
input:focus, textarea:focus {
border: 1px solid #eb99a1;
}
textarea {
width: 100%;
}
.button, button, input[type=submit], input[type=reset], input[type=button], input[type=file]::file-selector-button {
display: inline-block;
padding: 5px 10px;
text-align: center;
text-decoration: none;
white-space: nowrap;
background-color: #eb99a1;
color: #120c0e;
border-radius: 1px;
border: 1px solid #eb99a1;
cursor: pointer;
box-sizing: border-box;
}
.button[disabled], button[disabled], input[type=submit][disabled], input[type=reset][disabled], input[type=button][disabled], input[type=file]::file-selector-button[disabled] {
cursor: default;
opacity: 0.5;
}
.button:hover, button:hover, input[type=submit]:hover, input[type=reset]:hover, input[type=button]:hover, input[type=file]::file-selector-button:hover {
background-color: #DA4453;
color: #120c0e;
outline: 0;
}
.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 {
outline-style: solid;
outline-width: 2px;
}
textarea, select, input {
color: #d9d8dc;
padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
margin-bottom: 10px;
background-color: #40363a;
border: 1px solid #40363a;
border-radius: 4px;
box-shadow: none;
box-sizing: border-box;
}
textarea:focus, select:focus, input:focus {
border: 1px solid #eb99a1;
outline: 0;
}
input[type=checkbox]:focus {
outline: 1px dotted #eb99a1;
}
label, legend, fieldset {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
Massets/show.css
@@ -1,6 +1,5 @@
@import "normalize.css";
@import "sakura-vader.css";
@import "highlight.css";
@import "theme.css";
hr {
width: 100%;
@@ -45,7 +44,7 @@ body {
flex-grow: 1;
height: 100%;
overflow: auto;
border: 1px solid #40363a;
border: 1px solid var(--app-border);
border-right: unset;
display: flex;
flex-direction: column;
@@ -76,7 +75,7 @@ body {
padding: 1rem;
width: 30rem;
height: 100%;
border: 1px solid #40363a;
border: 1px solid var(--app-border);
display: flex;
flex-direction: column;
@@ -100,7 +99,7 @@ body {
#content {
/* The shared divider is now the bottom edge; restore the right border
the row layout dropped and drop the bottom one. */
border-right: 1px solid #40363a;
border-right: 1px solid var(--app-border);
border-bottom: unset;
}
@@ -133,8 +132,8 @@ video {
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #120c0e;
color: #d9d8dc;
background-color: var(--app-bg);
color: var(--app-fg);
text-align: center;
h1 {
Aassets/theme.css
@@ -0,0 +1,70 @@
/* Theme tokens for the app's own (non-sakura) colors. The server always renders
an explicit data-theme (auto/light/dark) on <html>, so the selectors key off
it. Dark values are the exact "vader" palette, so dark mode is unchanged. */
:root,
:root[data-theme="light"],
:root[data-theme="auto"] {
--app-bg: #f9f9f9;
--app-fg: #4a4a4a;
--app-accent: #1d7484;
--app-surface: #f1f1f1;
--app-border: #d1d1d1;
--help-icon-filter: none;
}
:root[data-theme="dark"] {
--app-bg: #120c0e;
--app-fg: #d9d8dc;
--app-accent: #eb99a1;
--app-surface: #40363a;
--app-border: #40363a;
--help-icon-filter: invert();
}
/* auto + OS prefers dark: mirror the forced-dark palette */
@media (prefers-color-scheme: dark) {
:root[data-theme="auto"] {
--app-bg: #120c0e;
--app-fg: #d9d8dc;
--app-accent: #eb99a1;
--app-surface: #40363a;
--app-border: #40363a;
--help-icon-filter: invert();
}
}
/* The theme toggle: a small icon button pinned top-right on every page. Resets
sakura's accent button skin to a plain bordered icon (id specificity wins). */
/* Reset the page's generic `form` styling (default.css makes forms full-width
flex columns) so the toggle stays a small fixed corner control. */
#theme-form {
position: fixed;
top: 0.75rem;
right: 0.75rem;
z-index: 100;
margin: 0;
display: block;
width: auto;
min-width: 0;
}
#theme-toggle {
background: transparent;
border: none;
color: var(--app-fg);
padding: 0.3rem;
cursor: pointer;
display: flex;
align-items: center;
}
#theme-toggle:hover {
background: transparent;
color: var(--app-accent);
}
#theme-toggle svg {
width: 1.9rem;
height: 1.9rem;
display: block;
}
Mbiome.json
@@ -12,7 +12,8 @@
"*.json",
"!bun.lock",
"assets/show.css",
"assets/default.css"
"assets/default.css",
"assets/theme.css"
]
},
"formatter": {
Mbun.lock
@@ -8,14 +8,15 @@
"@elysiajs/cron": "^1.2.0",
"@elysiajs/html": "^1.2.0",
"@elysiajs/static": "^1.2.0",
"@types/busboy": "^1.5.4",
"busboy": "^1.6.0",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"highlight.js": "^11.11.1",
"sakura.css": "^1.5.1",
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@types/busboy": "^1.5.4",
"bun-types": "latest",
},
},
@@ -109,6 +110,8 @@
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"sakura.css": ["sakura.css@1.5.1", "", {}, "sha512-MAL13eVLmTuiF6a/SjrzK5PItWQwMrsIIldLq7kVwuJjxoxVyzwGJbP+JNSwLt34VF8Eg9yj3q17GG/h8jhvMA=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"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 @@
"name": "zbin",
"version": "1.0.0",
"scripts": {
"build": "bun build src/client*.ts --outdir assets/dist",
"build": "bun build src/client*.ts src/styles/*.css --outdir assets/dist",
"dev": "bun run build && bun run --watch src/index.ts",
"prod": "bun run build && bun run src/index.ts",
"format": "biome format --write",
@@ -17,7 +17,8 @@
"busboy": "^1.6.0",
"elysia": "^1.2.0",
"file-type": "^20.0.1",
"highlight.js": "^11.11.1"
"highlight.js": "^11.11.1",
"sakura.css": "^1.5.1"
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
Asrc/client-theme.ts
@@ -0,0 +1,38 @@
import { type Theme, themeIcon } from "./shared";
// Progressive enhancement for the theme toggle. The server already rendered the
// correct theme (data-theme + the dark sheet's media), so there's nothing to do
// on load — we only intercept the toggle form to switch instantly without the
// full-page reload the no-JS POST would cause, and keep the cookie in sync.
const order: Theme[] = ["auto", "light", "dark"];
function current(): Theme {
const value = document.documentElement.dataset.theme;
return value === "light" || value === "dark" ? value : "auto";
}
function apply(theme: Theme) {
document.documentElement.dataset.theme = theme;
const darkMedia =
theme === "auto"
? "(prefers-color-scheme: dark)"
: theme === "dark"
? "all"
: "not all";
// Swap both dark sheets in lockstep: sakura (page chrome) and highlight.js
// (code syntax). Each is null on pages that don't include it.
for (const id of ["sakura-dark", "highlight-dark"]) {
const sheet = document.getElementById(id) as HTMLLinkElement | null;
if (sheet) sheet.media = darkMedia;
}
const button = document.getElementById("theme-toggle");
if (button) button.innerHTML = themeIcon(theme);
// 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
document.cookie = `zbin-theme=${theme}; path=/; max-age=31536000; samesite=lax`;
}
document.getElementById("theme-form")?.addEventListener("submit", (e) => {
e.preventDefault();
apply(order[(order.indexOf(current()) + 1) % order.length]);
});
Msrc/components.test.ts
@@ -12,6 +12,7 @@ test("escapes filename in the file view (no stored XSS)", () => {
deleteAt: null,
size: 2,
preview: { kind: "text", html: "hi" },
theme: "auto",
});
expect(html).not.toContain(evil);
expect(html).toContain("<img");
@@ -26,6 +27,7 @@ test("escapes filename in the encrypted overlay (no stored XSS)", () => {
deleteAt: null,
size: null,
preview: { kind: "await" },
theme: "auto",
});
expect(html).not.toContain(evil);
expect(html).toContain("<script>");
Msrc/components.tsx
@@ -4,34 +4,90 @@ import type { PropsWithChildren } from "@kitajs/html";
import { escapeHTML } from "bun";
import hljs from "highlight.js";
import { config } from "./config";
import { humanFileSize, isValidUTF8 } from "./shared";
import { humanFileSize, isValidUTF8, type Theme, themeIcon } from "./shared";
export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort());
function Template(props: PropsWithChildren<{ css: string }>) {
// The auto/light/dark toggle. A plain form so it works without JS (it POSTs to
// /theme/cycle, which flips the cookie and redirects back); client-theme.js
// upgrades it to switch instantly without a reload.
function ThemeToggle(props: { theme: Theme }) {
return (
<form id="theme-form" method="post" action="/theme/cycle">
<button
id="theme-toggle"
type="submit"
aria-label={`Switch color theme (current: ${props.theme})`}
>
{themeIcon(props.theme)}
</button>
</form>
);
}
function Template(props: PropsWithChildren<{ css: string; theme: Theme }>) {
// Server-render the active theme so it's correct before first paint (no flash)
// and works with JS off. The dark sakura sheet is gated by `media`: in auto it
// follows the OS, otherwise it's forced fully on/off.
const darkMedia =
props.theme === "auto"
? "(prefers-color-scheme: dark)"
: props.theme === "dark"
? "all"
: "not all";
return (
<>
{"<!doctype html>"}
<html lang="en">
<html lang="en" data-theme={props.theme}>
<head>
<title>⚡ZBin⚡</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
id="sakura-light"
rel="stylesheet"
href="/dist/styles/sakura-light.css"
/>
<link
id="sakura-dark"
rel="stylesheet"
href="/dist/styles/sakura-dark.css"
media={darkMedia}
/>
{/* highlight.js syntax themes, gated like sakura's: light is the
base, the dark sheet's `media` follows the active theme. Only
the show page renders `.hljs` markup, but loading them sitewide
keeps the head (and the client toggle) uniform. */}
<link
id="highlight-light"
rel="stylesheet"
href="/dist/styles/highlight-light.css"
/>
<link
id="highlight-dark"
rel="stylesheet"
href="/dist/styles/highlight-dark.css"
media={darkMedia}
/>
<link rel="stylesheet" href={props.css} />
<link
rel="icon"
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>"
/>
<script src="/dist/client-theme.js" defer />
</head>
<body>{props.children}</body>
<body>
<ThemeToggle theme={props.theme} />
{props.children}
</body>
</html>
</>
);
}
export function NotFound() {
export function NotFound(theme: Theme) {
return (
<Template css="/default.css">
<Template css="/default.css" theme={theme}>
<h1>404</h1>
<p>File not found</p>
</Template>
@@ -95,9 +151,9 @@ function ServerLimits() {
);
}
export function Index(hostname: string) {
export function Index(hostname: string, theme: Theme) {
return (
<Template css="/default.css">
<Template css="/default.css" theme={theme}>
<h1>⚡ZBin⚡</h1>
<ServerLimits />
<dialog id="upload-dialog">File is being uploaded, please wait</dialog>
@@ -237,9 +293,9 @@ export function Index(hostname: string) {
);
}
export function WrongPassword() {
export function WrongPassword(theme: Theme) {
return (
<Template css="/default.css">
<Template css="/default.css" theme={theme}>
<h1>Incorrect password</h1>
<p>Reload page to try again</p>
</Template>
@@ -295,8 +351,9 @@ export function ShowFile(opts: {
deleteAt: number | null;
size: number | null;
preview: Preview;
theme: Theme;
}) {
const { filename, uuid, filetype, deleteAt, size, preview } = opts;
const { filename, uuid, filetype, deleteAt, size, preview, theme } = opts;
const awaiting = preview.kind === "await";
const rawUrl = `/raw/${uuid}`;
@@ -319,7 +376,7 @@ export function ShowFile(opts: {
}
return (
<Template css="/show.css">
<Template css="/show.css" theme={theme}>
{/* Sits above the decrypt overlay (see #logo z-index) so it stays a way
back home even before the file is unlocked. */}
<a id="logo" href="/">
@@ -381,7 +438,9 @@ export function ShowFile(opts: {
{deleteAt ? (
<p>
delete at: {new Date(deleteAt * 1000).toISOString()} (in{" "}
{humanReadableTime(Math.floor((deleteAt - Date.now() / 1000) / 60))}
{humanReadableTime(
Math.floor((deleteAt - Date.now() / 1000) / 60),
)}
)
</p>
) : (
@@ -397,9 +456,9 @@ export function ShowFile(opts: {
);
}
export function SetCookie(uuid: string) {
export function SetCookie(uuid: string, theme: Theme) {
return (
<Template css="/default.css">
<Template css="/default.css" theme={theme}>
Redirecting you back to{" "}
<a safe href={`/show/${uuid}`}>
/show/{uuid}
Msrc/index.ts
@@ -21,6 +21,7 @@ import {
} from "./components";
import { config } from "./config";
import { decryptToStream, encryptStream } from "./crypto";
import { getTheme } from "./shared";
// Paths default to ./db; overridable via env so tests can point at a throwaway
// directory instead of the real database/blobs.
@@ -191,7 +192,29 @@ const app = new Elysia({
},
}),
)
.get("/", ({ server }) => Index(server?.url.toString() ?? ""))
.get("/", ({ server, headers }) =>
Index(server?.url.toString() ?? "", getTheme(headers.cookie)),
)
// Cycles the theme preference (auto → light → dark) for clients without JS:
// the toggle is a form that POSTs here, we flip the cookie and redirect back.
// JS clients intercept the submit and never hit this route.
.post("/theme/cycle", ({ set, headers }) => {
const order = ["auto", "light", "dark"] as const;
const next =
order[(order.indexOf(getTheme(headers.cookie)) + 1) % order.length];
const secure = config.behindProxy ? "; Secure" : "";
set.headers["set-cookie"] =
`zbin-theme=${next}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
// Redirect back to the page the toggle was on, using only the path so the
// Referer can't be turned into an open redirect to another host.
let back = "/";
try {
back = new URL(headers.referer ?? "").pathname || "/";
} catch {}
set.headers.location = back;
set.status = StatusMap["See Other"];
return "";
})
.post(
"/upload",
async ({ set, server, request, headers }) => {
@@ -445,6 +468,7 @@ const app = new Elysia({
.get(
"/show/:uuid",
async ({ set, params, cookie, server, request, headers }) => {
const theme = getTheme(headers.cookie);
const row =
(db
.prepare(
@@ -460,7 +484,7 @@ const app = new Elysia({
}) || null;
if (!row) {
set.status = StatusMap["Not Found"];
return NotFound();
return NotFound(theme);
}
const path = blobPath(params.uuid);
@@ -468,7 +492,7 @@ const app = new Elysia({
// Row without its blob (e.g. the file was removed out of band): treat
// as not found rather than failing later while reading/streaming it.
set.status = StatusMap["Not Found"];
return NotFound();
return NotFound(theme);
}
let preview: Preview;
let shownSize: number | null = row.size;
@@ -513,7 +537,7 @@ const app = new Elysia({
`password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`,
];
return WrongPassword();
return WrongPassword(theme);
}
}
} else if (row.media_mime && isInlineMedia(row.media_mime)) {
@@ -544,6 +568,7 @@ const app = new Elysia({
deleteAt: row.delete_at,
size: shownSize,
preview,
theme,
});
},
{
@@ -555,7 +580,7 @@ const app = new Elysia({
)
.post(
"/set-cookie/:uuid",
({ set, body, params }) => {
({ set, body, params, headers }) => {
// encodeURIComponent keeps ';', CR/LF and other separators out of the
// cookie value; Elysia URL-decodes the value again when it reads it back.
const value = encodeURIComponent(body.password);
@@ -566,7 +591,7 @@ const app = new Elysia({
];
set.headers.location = `/show/${params.uuid}`;
set.status = StatusMap["See Other"];
return SetCookie(params.uuid);
return SetCookie(params.uuid, getTheme(headers.cookie));
},
{
body: t.Object({
Msrc/routes.test.ts
@@ -22,9 +22,9 @@ const text = new TextDecoder();
// A 1x1 PNG header — enough magic for file-type to detect image/png.
const PNG = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00,
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49,
0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06,
0x00, 0x00, 0x00,
]);
function upload(
@@ -72,7 +72,11 @@ describe("home page", () => {
describe("plaintext round-trip", () => {
test("upload → show renders the content, raw returns the exact bytes", async () => {
const uuid = await uploadId({ filetype: "none" }, "hello.txt", "hello world");
const uuid = await uploadId(
{ filetype: "none" },
"hello.txt",
"hello world",
);
const show = await get(`/show/${uuid}`);
expect(show.status).toBe(200);
@@ -193,7 +197,9 @@ describe("already-encrypted (client-side) uploads", () => {
);
// No password is known to the server, so /show defers to the client flow.
expect(await (await get(`/show/${uuid}`)).text()).toContain("decrypt-overlay");
expect(await (await get(`/show/${uuid}`)).text()).toContain(
"decrypt-overlay",
);
const raw = await get(`/raw/${uuid}?ignore_password=true`);
const stored = new Uint8Array(await raw.arrayBuffer());
Asrc/styles/highlight-dark.css
@@ -0,0 +1 @@
@import "highlight.js/styles/base16/tomorrow-night.css";
Asrc/styles/highlight-light.css
@@ -0,0 +1 @@
@import "highlight.js/styles/base16/tomorrow.css";
Asrc/styles/sakura-dark.css
@@ -0,0 +1 @@
@import "sakura.css/css/sakura-vader.css";