theme rework
Massets/default.css
| @@ -1,5 +1,5 @@ | |||
|---|---|---|---|
| 1 | 1 | @import "normalize.css"; | |
| 2 | - | @import "sakura-vader.css"; | |
| 2 | + | @import "theme.css"; | |
| 3 | 3 | ||
| 4 | 4 | body { | |
| 5 | 5 | display: flex; | |
| @@ -10,6 +10,10 @@ body { | |||
|---|---|---|---|
| 10 | 10 | padding: 3rem; | |
| 11 | 11 | box-sizing: border-box; | |
| 12 | 12 | 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; | |
| 13 | 17 | } | |
| 14 | 18 | ||
| 15 | 19 | a { | |
| @@ -77,7 +81,7 @@ img { | |||
|---|---|---|---|
| 77 | 81 | .help-icon { | |
| 78 | 82 | width: 2rem; | |
| 79 | 83 | height: 2rem; | |
| 80 | - | filter: invert(); | |
| 84 | + | filter: var(--help-icon-filter); | |
| 81 | 85 | margin-left: 0.5rem; | |
| 82 | 86 | } | |
| 83 | 87 | ||
| @@ -94,9 +98,9 @@ input[type="reset"] { | |||
|---|---|---|---|
| 94 | 98 | } | |
| 95 | 99 | ||
| 96 | 100 | 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); | |
| 100 | 104 | ||
| 101 | 105 | form { | |
| 102 | 106 | 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 @@ | |||
|---|---|---|---|
| 1 | 1 | @import "normalize.css"; | |
| 2 | - | @import "sakura-vader.css"; | |
| 3 | - | @import "highlight.css"; | |
| 2 | + | @import "theme.css"; | |
| 4 | 3 | ||
| 5 | 4 | hr { | |
| 6 | 5 | width: 100%; | |
| @@ -45,7 +44,7 @@ body { | |||
|---|---|---|---|
| 45 | 44 | flex-grow: 1; | |
| 46 | 45 | height: 100%; | |
| 47 | 46 | overflow: auto; | |
| 48 | - | border: 1px solid #40363a; | |
| 47 | + | border: 1px solid var(--app-border); | |
| 49 | 48 | border-right: unset; | |
| 50 | 49 | display: flex; | |
| 51 | 50 | flex-direction: column; | |
| @@ -76,7 +75,7 @@ body { | |||
|---|---|---|---|
| 76 | 75 | padding: 1rem; | |
| 77 | 76 | width: 30rem; | |
| 78 | 77 | height: 100%; | |
| 79 | - | border: 1px solid #40363a; | |
| 78 | + | border: 1px solid var(--app-border); | |
| 80 | 79 | display: flex; | |
| 81 | 80 | flex-direction: column; | |
| 82 | 81 | ||
| @@ -100,7 +99,7 @@ body { | |||
|---|---|---|---|
| 100 | 99 | #content { | |
| 101 | 100 | /* The shared divider is now the bottom edge; restore the right border | |
| 102 | 101 | the row layout dropped and drop the bottom one. */ | |
| 103 | - | border-right: 1px solid #40363a; | |
| 102 | + | border-right: 1px solid var(--app-border); | |
| 104 | 103 | border-bottom: unset; | |
| 105 | 104 | } | |
| 106 | 105 | ||
| @@ -133,8 +132,8 @@ video { | |||
|---|---|---|---|
| 133 | 132 | flex-direction: column; | |
| 134 | 133 | align-items: center; | |
| 135 | 134 | justify-content: center; | |
| 136 | - | background-color: #120c0e; | |
| 137 | - | color: #d9d8dc; | |
| 135 | + | background-color: var(--app-bg); | |
| 136 | + | color: var(--app-fg); | |
| 138 | 137 | text-align: center; | |
| 139 | 138 | ||
| 140 | 139 | 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 @@ | |||
|---|---|---|---|
| 12 | 12 | "*.json", | |
| 13 | 13 | "!bun.lock", | |
| 14 | 14 | "assets/show.css", | |
| 15 | - | "assets/default.css" | |
| 15 | + | "assets/default.css", | |
| 16 | + | "assets/theme.css" | |
| 16 | 17 | ] | |
| 17 | 18 | }, | |
| 18 | 19 | "formatter": { | |
Mbun.lock
| @@ -8,14 +8,15 @@ | |||
|---|---|---|---|
| 8 | 8 | "@elysiajs/cron": "^1.2.0", | |
| 9 | 9 | "@elysiajs/html": "^1.2.0", | |
| 10 | 10 | "@elysiajs/static": "^1.2.0", | |
| 11 | - | "@types/busboy": "^1.5.4", | |
| 12 | 11 | "busboy": "^1.6.0", | |
| 13 | 12 | "elysia": "^1.2.0", | |
| 14 | 13 | "file-type": "^20.0.1", | |
| 15 | 14 | "highlight.js": "^11.11.1", | |
| 15 | + | "sakura.css": "^1.5.1", | |
| 16 | 16 | }, | |
| 17 | 17 | "devDependencies": { | |
| 18 | 18 | "@biomejs/biome": "2.4.16", | |
| 19 | + | "@types/busboy": "^1.5.4", | |
| 19 | 20 | "bun-types": "latest", | |
| 20 | 21 | }, | |
| 21 | 22 | }, | |
| @@ -109,6 +110,8 @@ | |||
|---|---|---|---|
| 109 | 110 | ||
| 110 | 111 | "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], | |
| 111 | 112 | ||
| 113 | + | "sakura.css": ["sakura.css@1.5.1", "", {}, "sha512-MAL13eVLmTuiF6a/SjrzK5PItWQwMrsIIldLq7kVwuJjxoxVyzwGJbP+JNSwLt34VF8Eg9yj3q17GG/h8jhvMA=="], | |
| 114 | + | ||
| 112 | 115 | "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], | |
| 113 | 116 | ||
| 114 | 117 | "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 @@ | |||
|---|---|---|---|
| 2 | 2 | "name": "zbin", | |
| 3 | 3 | "version": "1.0.0", | |
| 4 | 4 | "scripts": { | |
| 5 | - | "build": "bun build src/client*.ts --outdir assets/dist", | |
| 5 | + | "build": "bun build src/client*.ts src/styles/*.css --outdir assets/dist", | |
| 6 | 6 | "dev": "bun run build && bun run --watch src/index.ts", | |
| 7 | 7 | "prod": "bun run build && bun run src/index.ts", | |
| 8 | 8 | "format": "biome format --write", | |
| @@ -17,7 +17,8 @@ | |||
|---|---|---|---|
| 17 | 17 | "busboy": "^1.6.0", | |
| 18 | 18 | "elysia": "^1.2.0", | |
| 19 | 19 | "file-type": "^20.0.1", | |
| 20 | - | "highlight.js": "^11.11.1" | |
| 20 | + | "highlight.js": "^11.11.1", | |
| 21 | + | "sakura.css": "^1.5.1" | |
| 21 | 22 | }, | |
| 22 | 23 | "devDependencies": { | |
| 23 | 24 | "@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)", () => { | |||
|---|---|---|---|
| 12 | 12 | deleteAt: null, | |
| 13 | 13 | size: 2, | |
| 14 | 14 | preview: { kind: "text", html: "hi" }, | |
| 15 | + | theme: "auto", | |
| 15 | 16 | }); | |
| 16 | 17 | expect(html).not.toContain(evil); | |
| 17 | 18 | expect(html).toContain("<img"); | |
| @@ -26,6 +27,7 @@ test("escapes filename in the encrypted overlay (no stored XSS)", () => { | |||
|---|---|---|---|
| 26 | 27 | deleteAt: null, | |
| 27 | 28 | size: null, | |
| 28 | 29 | preview: { kind: "await" }, | |
| 30 | + | theme: "auto", | |
| 29 | 31 | }); | |
| 30 | 32 | expect(html).not.toContain(evil); | |
| 31 | 33 | expect(html).toContain("<script>"); | |
Msrc/components.tsx
| @@ -4,34 +4,90 @@ import type { PropsWithChildren } from "@kitajs/html"; | |||
|---|---|---|---|
| 4 | 4 | import { escapeHTML } from "bun"; | |
| 5 | 5 | import hljs from "highlight.js"; | |
| 6 | 6 | import { config } from "./config"; | |
| 7 | - | import { humanFileSize, isValidUTF8 } from "./shared"; | |
| 7 | + | import { humanFileSize, isValidUTF8, type Theme, themeIcon } from "./shared"; | |
| 8 | 8 | ||
| 9 | 9 | export const filetypes = ["none", "blob"].concat(hljs.listLanguages().sort()); | |
| 10 | 10 | ||
| 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"; | |
| 12 | 38 | return ( | |
| 13 | 39 | <> | |
| 14 | 40 | {"<!doctype html>"} | |
| 15 | - | <html lang="en"> | |
| 41 | + | <html lang="en" data-theme={props.theme}> | |
| 16 | 42 | <head> | |
| 17 | 43 | <title>⚡ZBin⚡</title> | |
| 18 | 44 | <meta charset="UTF-8" /> | |
| 19 | 45 | <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 | + | /> | |
| 20 | 72 | <link rel="stylesheet" href={props.css} /> | |
| 21 | 73 | <link | |
| 22 | 74 | rel="icon" | |
| 23 | 75 | 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>" | |
| 24 | 76 | /> | |
| 77 | + | <script src="/dist/client-theme.js" defer /> | |
| 25 | 78 | </head> | |
| 26 | - | <body>{props.children}</body> | |
| 79 | + | <body> | |
| 80 | + | <ThemeToggle theme={props.theme} /> | |
| 81 | + | {props.children} | |
| 82 | + | </body> | |
| 27 | 83 | </html> | |
| 28 | 84 | </> | |
| 29 | 85 | ); | |
| 30 | 86 | } | |
| 31 | 87 | ||
| 32 | - | export function NotFound() { | |
| 88 | + | export function NotFound(theme: Theme) { | |
| 33 | 89 | return ( | |
| 34 | - | <Template css="/default.css"> | |
| 90 | + | <Template css="/default.css" theme={theme}> | |
| 35 | 91 | <h1>404</h1> | |
| 36 | 92 | <p>File not found</p> | |
| 37 | 93 | </Template> | |
| @@ -95,9 +151,9 @@ function ServerLimits() { | |||
|---|---|---|---|
| 95 | 151 | ); | |
| 96 | 152 | } | |
| 97 | 153 | ||
| 98 | - | export function Index(hostname: string) { | |
| 154 | + | export function Index(hostname: string, theme: Theme) { | |
| 99 | 155 | return ( | |
| 100 | - | <Template css="/default.css"> | |
| 156 | + | <Template css="/default.css" theme={theme}> | |
| 101 | 157 | <h1>⚡ZBin⚡</h1> | |
| 102 | 158 | <ServerLimits /> | |
| 103 | 159 | <dialog id="upload-dialog">File is being uploaded, please wait</dialog> | |
| @@ -237,9 +293,9 @@ export function Index(hostname: string) { | |||
|---|---|---|---|
| 237 | 293 | ); | |
| 238 | 294 | } | |
| 239 | 295 | ||
| 240 | - | export function WrongPassword() { | |
| 296 | + | export function WrongPassword(theme: Theme) { | |
| 241 | 297 | return ( | |
| 242 | - | <Template css="/default.css"> | |
| 298 | + | <Template css="/default.css" theme={theme}> | |
| 243 | 299 | <h1>Incorrect password</h1> | |
| 244 | 300 | <p>Reload page to try again</p> | |
| 245 | 301 | </Template> | |
| @@ -295,8 +351,9 @@ export function ShowFile(opts: { | |||
|---|---|---|---|
| 295 | 351 | deleteAt: number | null; | |
| 296 | 352 | size: number | null; | |
| 297 | 353 | preview: Preview; | |
| 354 | + | theme: Theme; | |
| 298 | 355 | }) { | |
| 299 | - | const { filename, uuid, filetype, deleteAt, size, preview } = opts; | |
| 356 | + | const { filename, uuid, filetype, deleteAt, size, preview, theme } = opts; | |
| 300 | 357 | const awaiting = preview.kind === "await"; | |
| 301 | 358 | const rawUrl = `/raw/${uuid}`; | |
| 302 | 359 | ||
| @@ -319,7 +376,7 @@ export function ShowFile(opts: { | |||
|---|---|---|---|
| 319 | 376 | } | |
| 320 | 377 | ||
| 321 | 378 | return ( | |
| 322 | - | <Template css="/show.css"> | |
| 379 | + | <Template css="/show.css" theme={theme}> | |
| 323 | 380 | {/* Sits above the decrypt overlay (see #logo z-index) so it stays a way | |
| 324 | 381 | back home even before the file is unlocked. */} | |
| 325 | 382 | <a id="logo" href="/"> | |
| @@ -381,7 +438,9 @@ export function ShowFile(opts: { | |||
|---|---|---|---|
| 381 | 438 | {deleteAt ? ( | |
| 382 | 439 | <p> | |
| 383 | 440 | 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 | + | )} | |
| 385 | 444 | ) | |
| 386 | 445 | </p> | |
| 387 | 446 | ) : ( | |
| @@ -397,9 +456,9 @@ export function ShowFile(opts: { | |||
|---|---|---|---|
| 397 | 456 | ); | |
| 398 | 457 | } | |
| 399 | 458 | ||
| 400 | - | export function SetCookie(uuid: string) { | |
| 459 | + | export function SetCookie(uuid: string, theme: Theme) { | |
| 401 | 460 | return ( | |
| 402 | - | <Template css="/default.css"> | |
| 461 | + | <Template css="/default.css" theme={theme}> | |
| 403 | 462 | Redirecting you back to{" "} | |
| 404 | 463 | <a safe href={`/show/${uuid}`}> | |
| 405 | 464 | /show/{uuid} | |
Msrc/index.ts
| @@ -21,6 +21,7 @@ import { | |||
|---|---|---|---|
| 21 | 21 | } from "./components"; | |
| 22 | 22 | import { config } from "./config"; | |
| 23 | 23 | import { decryptToStream, encryptStream } from "./crypto"; | |
| 24 | + | import { getTheme } from "./shared"; | |
| 24 | 25 | ||
| 25 | 26 | // Paths default to ./db; overridable via env so tests can point at a throwaway | |
| 26 | 27 | // directory instead of the real database/blobs. | |
| @@ -191,7 +192,29 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 191 | 192 | }, | |
| 192 | 193 | }), | |
| 193 | 194 | ) | |
| 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 | + | }) | |
| 195 | 218 | .post( | |
| 196 | 219 | "/upload", | |
| 197 | 220 | async ({ set, server, request, headers }) => { | |
| @@ -445,6 +468,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 445 | 468 | .get( | |
| 446 | 469 | "/show/:uuid", | |
| 447 | 470 | async ({ set, params, cookie, server, request, headers }) => { | |
| 471 | + | const theme = getTheme(headers.cookie); | |
| 448 | 472 | const row = | |
| 449 | 473 | (db | |
| 450 | 474 | .prepare( | |
| @@ -460,7 +484,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 460 | 484 | }) || null; | |
| 461 | 485 | if (!row) { | |
| 462 | 486 | set.status = StatusMap["Not Found"]; | |
| 463 | - | return NotFound(); | |
| 487 | + | return NotFound(theme); | |
| 464 | 488 | } | |
| 465 | 489 | ||
| 466 | 490 | const path = blobPath(params.uuid); | |
| @@ -468,7 +492,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 468 | 492 | // Row without its blob (e.g. the file was removed out of band): treat | |
| 469 | 493 | // as not found rather than failing later while reading/streaming it. | |
| 470 | 494 | set.status = StatusMap["Not Found"]; | |
| 471 | - | return NotFound(); | |
| 495 | + | return NotFound(theme); | |
| 472 | 496 | } | |
| 473 | 497 | let preview: Preview; | |
| 474 | 498 | let shownSize: number | null = row.size; | |
| @@ -513,7 +537,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 513 | 537 | `password=; Path=/show/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, | |
| 514 | 538 | `password=; Path=/raw/${params.uuid}; SameSite=lax; HttpOnly${secure}; Expires=Thu, 01 Jan 1970 00:00:00 GMT`, | |
| 515 | 539 | ]; | |
| 516 | - | return WrongPassword(); | |
| 540 | + | return WrongPassword(theme); | |
| 517 | 541 | } | |
| 518 | 542 | } | |
| 519 | 543 | } else if (row.media_mime && isInlineMedia(row.media_mime)) { | |
| @@ -544,6 +568,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 544 | 568 | deleteAt: row.delete_at, | |
| 545 | 569 | size: shownSize, | |
| 546 | 570 | preview, | |
| 571 | + | theme, | |
| 547 | 572 | }); | |
| 548 | 573 | }, | |
| 549 | 574 | { | |
| @@ -555,7 +580,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 555 | 580 | ) | |
| 556 | 581 | .post( | |
| 557 | 582 | "/set-cookie/:uuid", | |
| 558 | - | ({ set, body, params }) => { | |
| 583 | + | ({ set, body, params, headers }) => { | |
| 559 | 584 | // encodeURIComponent keeps ';', CR/LF and other separators out of the | |
| 560 | 585 | // cookie value; Elysia URL-decodes the value again when it reads it back. | |
| 561 | 586 | const value = encodeURIComponent(body.password); | |
| @@ -566,7 +591,7 @@ const app = new Elysia({ | |||
|---|---|---|---|
| 566 | 591 | ]; | |
| 567 | 592 | set.headers.location = `/show/${params.uuid}`; | |
| 568 | 593 | set.status = StatusMap["See Other"]; | |
| 569 | - | return SetCookie(params.uuid); | |
| 594 | + | return SetCookie(params.uuid, getTheme(headers.cookie)); | |
| 570 | 595 | }, | |
| 571 | 596 | { | |
| 572 | 597 | body: t.Object({ | |
Msrc/routes.test.ts
| @@ -22,9 +22,9 @@ const text = new TextDecoder(); | |||
|---|---|---|---|
| 22 | 22 | ||
| 23 | 23 | // A 1x1 PNG header — enough magic for file-type to detect image/png. | |
| 24 | 24 | 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, | |
| 28 | 28 | ]); | |
| 29 | 29 | ||
| 30 | 30 | function upload( | |
| @@ -72,7 +72,11 @@ describe("home page", () => { | |||
|---|---|---|---|
| 72 | 72 | ||
| 73 | 73 | describe("plaintext round-trip", () => { | |
| 74 | 74 | 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 | + | ); | |
| 76 | 80 | ||
| 77 | 81 | const show = await get(`/show/${uuid}`); | |
| 78 | 82 | expect(show.status).toBe(200); | |
| @@ -193,7 +197,9 @@ describe("already-encrypted (client-side) uploads", () => { | |||
|---|---|---|---|
| 193 | 197 | ); | |
| 194 | 198 | ||
| 195 | 199 | // 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 | + | ); | |
| 197 | 203 | ||
| 198 | 204 | const raw = await get(`/raw/${uuid}?ignore_password=true`); | |
| 199 | 205 | const stored = new Uint8Array(await raw.arrayBuffer()); | |
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"; | |