Compare commits
11
Commits
v0.3.0
..
3ac575a2dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac575a2dc | ||
|
|
19cb8d1d45 | ||
|
|
dcf2d66a82 | ||
|
|
f1b5dd0364 | ||
|
|
8eabeb4839 | ||
|
|
7ed50087a6 | ||
|
|
8104cdba4c | ||
|
|
be3ff81e5c | ||
|
|
1e317d11da | ||
|
|
2ce50a6393 | ||
|
|
70c284b15d |
@@ -63,6 +63,7 @@ go build -o palette ./cmd/palette
|
|||||||
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
|
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
|
||||||
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
|
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
|
||||||
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
|
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `<db-dir>/admin-key` (0600) |
|
||||||
|
| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
|
||||||
|
|
||||||
An `/admin` page exists for runtime settings, protected by a key set at
|
An `/admin` page exists for runtime settings, protected by a key set at
|
||||||
install (`PALETTE_ADMIN_KEY` env var) and resettable locally — see
|
install (`PALETTE_ADMIN_KEY` env var) and resettable locally — see
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
web.SetDefaultDark(os.Getenv("PALETTE_DEFAULT_DARK"))
|
||||||
srv := api.NewServer(st, cfg, ui, ss, adminKey)
|
srv := api.NewServer(st, cfg, ui, ss, adminKey)
|
||||||
log.Printf("palette listening on %s", cfg.Addr)
|
log.Printf("palette listening on %s", cfg.Addr)
|
||||||
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
|
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
|
||||||
|
|||||||
@@ -40,6 +40,47 @@
|
|||||||
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
|
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
|
||||||
--on-accent: #274a6b;
|
--on-accent: #274a6b;
|
||||||
}
|
}
|
||||||
|
/* matching light/dark counterparts: same hue family, flipped lightness roles */
|
||||||
|
/* Midnight Light: light counterpart of midnight (violet family) */
|
||||||
|
[data-preset="midnight-light"] {
|
||||||
|
--bg: #F3EFF9; --surface: #FAF8FD; --surface-2: #E1D9EF;
|
||||||
|
--muted: #A99CC9; --muted-fg: #5F5390; --fg: #2B2140;
|
||||||
|
--accent: #7B5FC0; --border: #D8CEEC;
|
||||||
|
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
|
||||||
|
--on-accent: #FAF8FD;
|
||||||
|
}
|
||||||
|
/* Smooth Dark: dark counterpart of smooth (muted slate-violet family) */
|
||||||
|
[data-preset="smooth-dark"] {
|
||||||
|
--bg: #23222E; --surface: #2C2B3A; --surface-2: #3B3A4D;
|
||||||
|
--muted: #7A7796; --muted-fg: #B5B1C9; --fg: #E8E7F0;
|
||||||
|
--accent: #A9A6C4; --border: #45435A;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #23222E;
|
||||||
|
}
|
||||||
|
/* Pastel Lavender Dark: dark counterpart (lavender family) */
|
||||||
|
[data-preset="pastel-lavender-dark"] {
|
||||||
|
--bg: #2A2340; --surface: #352C50; --surface-2: #4A3E6B;
|
||||||
|
--muted: #8F7FB8; --muted-fg: #CBBEE8; --fg: #EFEAF8;
|
||||||
|
--accent: #B39DE4; --border: #4E4272;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #2A2340;
|
||||||
|
}
|
||||||
|
/* Pastel Peach Dark: dark counterpart (peach family) */
|
||||||
|
[data-preset="pastel-peach-dark"] {
|
||||||
|
--bg: #3A241C; --surface: #4A2F24; --surface-2: #654234;
|
||||||
|
--muted: #B07A64; --muted-fg: #F0C4B0; --fg: #FBEBE2;
|
||||||
|
--accent: #F9826C; --border: #75503F;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #3A241C;
|
||||||
|
}
|
||||||
|
/* Pastel Cloud Dark: dark counterpart (pink cloud family, blue accent) */
|
||||||
|
[data-preset="pastel-cloud-dark"] {
|
||||||
|
--bg: #33222E; --surface: #402C3A; --surface-2: #583D50;
|
||||||
|
--muted: #A87E97; --muted-fg: #E5C2D6; --fg: #FAEEF5;
|
||||||
|
--accent: #A2D2FF; --border: #644759;
|
||||||
|
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
|
||||||
|
--on-accent: #33222E;
|
||||||
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
body {
|
body {
|
||||||
@@ -162,6 +203,22 @@ body {
|
|||||||
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 5px 12px; font: inherit; font-size: 21.6px; cursor: pointer; text-decoration: none; }
|
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 5px 12px; font: inherit; font-size: 21.6px; cursor: pointer; text-decoration: none; }
|
||||||
.iconbtn.gear { display: inline-flex; align-items: center; padding: 5px 9px; }
|
.iconbtn.gear { display: inline-flex; align-items: center; padding: 5px 9px; }
|
||||||
.iconbtn.gear svg { width: 22px; height: 22px; }
|
.iconbtn.gear svg { width: 22px; height: 22px; }
|
||||||
|
/* #127: gear highlights like selected nav tabs; dark toggle button */
|
||||||
|
.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
|
.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 22px; height: 22px; }
|
||||||
|
.iconbtn.dark-toggle .icon-moon { display: none; }
|
||||||
|
.iconbtn.dark-toggle .icon-sun { display: inline-block; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-sun { display: none; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; }
|
||||||
|
/* #127: gear highlights like selected nav tabs; dark toggle button */
|
||||||
|
.iconbtn.gear.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
|
.iconbtn.dark-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 22px; height: 22px; }
|
||||||
|
.iconbtn.dark-toggle .icon-moon { display: none; }
|
||||||
|
.iconbtn.dark-toggle .icon-sun { display: inline-block; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-sun { display: none; }
|
||||||
|
.dark .iconbtn.dark-toggle .icon-moon { display: inline-block; }
|
||||||
.settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); }
|
.settings-head { padding: 12px 18px; border-bottom: 1px solid var(--border); }
|
||||||
.settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
|
.settings-head h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
|
||||||
.settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; }
|
.settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; }
|
||||||
@@ -439,6 +496,12 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
|
|||||||
.topbar nav a { padding: 5px 8px; font-size: 15px; }
|
.topbar nav a { padding: 5px 8px; font-size: 15px; }
|
||||||
.iconbtn.gear { padding: 4px 7px; flex-shrink: 0; }
|
.iconbtn.gear { padding: 4px 7px; flex-shrink: 0; }
|
||||||
.iconbtn.gear svg { width: 18px; height: 18px; }
|
.iconbtn.gear svg { width: 18px; height: 18px; }
|
||||||
|
.iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 18px; height: 18px; }
|
||||||
|
.iconbtn.dark-toggle span { display: none; }
|
||||||
|
.iconbtn.dark-toggle { padding: 4px 7px; flex-shrink: 0; }
|
||||||
|
.iconbtn.dark-toggle svg { width: 18px; height: 18px; }
|
||||||
|
.iconbtn.dark-toggle span { display: none; }
|
||||||
|
|
||||||
/* new paste: stack editor above sidebar, natural page height */
|
/* new paste: stack editor above sidebar, natural page height */
|
||||||
.deck {
|
.deck {
|
||||||
@@ -503,3 +566,133 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; }
|
|||||||
.can-item-row { margin-top: 8px; }
|
.can-item-row { margin-top: 8px; }
|
||||||
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
|
.can-item-row .can-item-title { width: 100%; margin-bottom: 6px; }
|
||||||
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
|
.can-item-row .can-item-content { width: 100%; font-family: var(--mono, monospace); resize: vertical; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Styled controls (#123) — buttons, selects, checkboxes, radios
|
||||||
|
match the custom URL box house pattern: 1px var(--border) on
|
||||||
|
var(--bg), var(--radius) corners, accent border on focus, no
|
||||||
|
default browser chrome (appearance: none).
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* selects: replace native chrome with a house-styled pill */
|
||||||
|
select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg) url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 10px center;
|
||||||
|
color: var(--fg);
|
||||||
|
font: inherit;
|
||||||
|
padding: 4px 30px 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .12s ease, background-color .12s ease;
|
||||||
|
}
|
||||||
|
select:hover { border-color: var(--muted-fg); background-color: var(--surface-2); }
|
||||||
|
select:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.editor-head select { padding: 4px 30px 4px 10px; }
|
||||||
|
|
||||||
|
/* checkboxes and radios: custom drawn, accent when checked */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px; height: 16px; margin: 0; flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color .12s ease, background-color .12s ease;
|
||||||
|
}
|
||||||
|
input[type="checkbox"] { border-radius: 4px; }
|
||||||
|
input[type="radio"] { border-radius: 50%; }
|
||||||
|
input[type="checkbox"]:hover,
|
||||||
|
input[type="radio"]:hover { border-color: var(--muted-fg); }
|
||||||
|
input[type="checkbox"]:checked,
|
||||||
|
input[type="radio"]:checked { background: var(--accent); border-color: var(--accent); }
|
||||||
|
input[type="checkbox"]:checked::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='10' viewBox='0 0 12 10'%3E%3Cpath d='M1 5l4 4 6-8' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / 11px no-repeat;
|
||||||
|
}
|
||||||
|
input[type="radio"]:checked::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute; inset: 3px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
input[type="checkbox"]:focus-visible,
|
||||||
|
input[type="radio"]:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* buttons: every submit-style button gets the .btn treatment,
|
||||||
|
and .btn itself gains the house surface/border/hover pattern */
|
||||||
|
button[type="submit"] {
|
||||||
|
background: var(--accent); color: var(--bg); border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius); font: inherit; font-weight: 600;
|
||||||
|
padding: 8px 18px; cursor: pointer;
|
||||||
|
transition: filter .12s ease;
|
||||||
|
}
|
||||||
|
button[type="submit"]:hover { filter: brightness(1.08); }
|
||||||
|
button[type="submit"]:focus-visible,
|
||||||
|
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.btn {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
/* settings: give the Admin link clear separation from the theme grid */
|
||||||
|
.admin-link-row {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #38: attachment upload on /new + attachment display on paste view */
|
||||||
|
.side-section .file-input { display: none; }
|
||||||
|
.dropzone {
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 14px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted-fg);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20.7px;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
background: var(--surface-2, transparent);
|
||||||
|
}
|
||||||
|
.dropzone:hover, .dropzone.dragover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.dropzone.dragover { border-width: 2px; }
|
||||||
|
.file-chip {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 8px 14px; margin-top: 8px; background: var(--surface);
|
||||||
|
font-size: 21.6px;
|
||||||
|
}
|
||||||
|
.file-chip .file-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||||
|
.file-chip .file-chip-size { color: var(--muted-fg); font-size: 19px; }
|
||||||
|
.file-chip .file-chip-remove {
|
||||||
|
border: none; background: none; color: var(--muted-fg); cursor: pointer;
|
||||||
|
font-size: 22px; padding: 0 4px; border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
.file-chip .file-chip-remove:hover { color: var(--danger, #c0392b); }
|
||||||
|
.attachment-bar { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.attachment-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 12px; align-self: flex-start;
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 8px 16px; text-decoration: none; color: var(--fg);
|
||||||
|
background: var(--surface-2); font-size: 21.6px;
|
||||||
|
}
|
||||||
|
.attachment-chip:hover { border-color: var(--accent); }
|
||||||
|
.attachment-chip .attachment-size { color: var(--muted-fg); font-size: 19px; }
|
||||||
|
.attachment-preview img { max-width: 480px; max-height: 360px; border-radius: var(--radius); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
/* line wrap toggle (#130): body wrap variants + toggle button */
|
||||||
|
.editor.wrap, .codebody.wrap { white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.wrap-toggle[aria-pressed="true"] { background: var(--accent); color: var(--bg); border-color: var(--accent); }
|
||||||
|
.editor-tools { display: flex; justify-content: flex-end; }
|
||||||
|
|||||||
@@ -3,11 +3,48 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<script>
|
<script>
|
||||||
// preset hook (#16): ?theme= wins; else persisted choice (#100)
|
// preset hook (#16): ?theme= wins; else persisted choice (#100).
|
||||||
|
// #127: theme pairs - each base theme has a light and dark variant;
|
||||||
|
// palette-theme stores the base id, palette-dark the dark flag.
|
||||||
(function () {
|
(function () {
|
||||||
|
var PAIRS = {
|
||||||
|
'midnight': { light: 'midnight-light', dark: 'midnight' },
|
||||||
|
'smooth': { light: 'smooth', dark: 'smooth-dark' },
|
||||||
|
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
|
||||||
|
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
|
||||||
|
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
|
||||||
|
};
|
||||||
|
function resolve(t, dark) {
|
||||||
|
for (var base in PAIRS) {
|
||||||
|
if (t === PAIRS[base].light || t === PAIRS[base].dark) return t;
|
||||||
|
if (t === base) return dark ? PAIRS[base].dark : PAIRS[base].light;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
var t = new URLSearchParams(location.search).get('theme');
|
var t = new URLSearchParams(location.search).get('theme');
|
||||||
if (!t) try { t = localStorage.getItem('palette-theme'); } catch (e) {}
|
// precedence: URL ?theme= > stored pair+dark prefs > server default dark > dark on
|
||||||
if (t) document.documentElement.dataset.preset = t;
|
var dark = {{ defaultDark }};
|
||||||
|
try {
|
||||||
|
var stored = localStorage.getItem('palette-dark');
|
||||||
|
if (stored !== null) dark = stored !== 'false';
|
||||||
|
} catch (e) {}
|
||||||
|
if (t) {
|
||||||
|
// URL ?theme= wins and accepts both base and variant ids; a variant id
|
||||||
|
// also sets the dark flag and normalizes t back to its base pair id.
|
||||||
|
var found = false;
|
||||||
|
for (var base in PAIRS) {
|
||||||
|
if (t === PAIRS[base].dark) { dark = true; t = base; found = true; break; }
|
||||||
|
if (t === PAIRS[base].light) { dark = false; t = base; found = true; break; }
|
||||||
|
if (t === base) { found = true; break; }
|
||||||
|
}
|
||||||
|
if (!found) t = null;
|
||||||
|
}
|
||||||
|
if (!t) {
|
||||||
|
try { t = localStorage.getItem('palette-theme'); } catch (e) {}
|
||||||
|
}
|
||||||
|
if (!PAIRS[t]) t = 'midnight';
|
||||||
|
// do not persist anything here: URL theme is a one-off override
|
||||||
|
document.documentElement.dataset.preset = dark ? PAIRS[t].dark : PAIRS[t].light;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -22,8 +59,57 @@
|
|||||||
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
|
<a href="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git<svg class="ext" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<a class="iconbtn gear" href="/settings" title="Settings" aria-label="Settings">
|
<button class="iconbtn dark-toggle" id="dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
</button>
|
||||||
|
<a class="iconbtn gear{{if eq .Page "settings"}} on{{end}}" href="/settings" title="Settings" aria-label="Settings">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
// #127: dark mode toggle - flips to the other variant of the active pair.
|
||||||
|
(function () {
|
||||||
|
var PAIRS = {
|
||||||
|
'midnight': { light: 'midnight-light', dark: 'midnight' },
|
||||||
|
'smooth': { light: 'smooth', dark: 'smooth-dark' },
|
||||||
|
'pastel-lavender': { light: 'pastel-lavender', dark: 'pastel-lavender-dark' },
|
||||||
|
'pastel-peach': { light: 'pastel-peach', dark: 'pastel-peach-dark' },
|
||||||
|
'pastel-cloud': { light: 'pastel-cloud', dark: 'pastel-cloud-dark' }
|
||||||
|
};
|
||||||
|
var root = document.documentElement;
|
||||||
|
function state() {
|
||||||
|
var p = root.dataset.preset || 'midnight';
|
||||||
|
for (var base in PAIRS) {
|
||||||
|
if (p === PAIRS[base].dark) return { base: base, dark: true };
|
||||||
|
if (p === PAIRS[base].light) return { base: base, dark: false };
|
||||||
|
}
|
||||||
|
return { base: 'midnight', dark: true };
|
||||||
|
}
|
||||||
|
function apply() {
|
||||||
|
var s = state();
|
||||||
|
document.body.classList.toggle('dark', s.dark);
|
||||||
|
root.classList.toggle('dark', s.dark);
|
||||||
|
}
|
||||||
|
function sync(btns) {
|
||||||
|
btns.forEach(function (b) {
|
||||||
|
b.setAttribute('aria-pressed', state().dark ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var btns = document.querySelectorAll('.dark-toggle');
|
||||||
|
apply();
|
||||||
|
sync(Array.prototype.slice.call(btns));
|
||||||
|
btns.forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
var s = state();
|
||||||
|
var dark = !s.dark;
|
||||||
|
root.dataset.preset = dark ? PAIRS[s.base].dark : PAIRS[s.base].light;
|
||||||
|
try { localStorage.setItem('palette-dark', dark ? 'true' : 'false'); } catch (e) {}
|
||||||
|
apply();
|
||||||
|
sync(Array.prototype.slice.call(btns));
|
||||||
|
document.dispatchEvent(new CustomEvent('palette-darkchange'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
<div class="gutter" id="gutter">1</div>
|
<div class="gutter" id="gutter">1</div>
|
||||||
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
|
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="editor-tools">
|
||||||
|
<button class="iconbtn wrap-toggle" id="wrap-toggle" type="button" title="Toggle line wrap" aria-label="Toggle line wrap" aria-pressed="false">wrap</button>
|
||||||
|
</div>
|
||||||
<div class="created-banner" id="created"></div>
|
<div class="created-banner" id="created"></div>
|
||||||
<div class="actionbar">
|
<div class="actionbar">
|
||||||
<span class="hint">Ctrl+Enter to create</span>
|
<span class="hint">Ctrl+Enter to create</span>
|
||||||
@@ -65,17 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="float side-section">
|
<div class="float side-section">
|
||||||
<h3>Custom URL</h3>
|
<h3>Custom URL</h3>
|
||||||
<input type="text" id="custom" class="custom-input" placeholder="/my-snippet">
|
<input type="text" id="custom" class="custom-input" placeholder="my-snippet">
|
||||||
<div class="hint" style="margin-top:6px; font-size:19px;">Stays reserved while the paste exists</div>
|
|
||||||
</div>
|
|
||||||
<div class="float side-section">
|
|
||||||
<h3>Can contents</h3>
|
|
||||||
<label class="toggle"><input type="checkbox" id="iscan"> Bundle as a can (multiple text items)</label>
|
|
||||||
<div class="pw-row" id="canrow" style="display:none">
|
|
||||||
<div id="can-items"></div>
|
|
||||||
<button class="btn btn-icon" id="can-add" type="button" title="Add item" style="margin-top:6px">+ Add item</button>
|
|
||||||
<div class="hint" style="margin-top:6px; font-size:19px;">Each item gets its own card on the can page. File uploads in cans come later.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="float side-section" id="result-card" style="display:none">
|
<div class="float side-section" id="result-card" style="display:none">
|
||||||
<h3>Result</h3>
|
<h3>Result</h3>
|
||||||
@@ -154,21 +147,6 @@ $('pwreveal').addEventListener('click', () => {
|
|||||||
$('pwreveal').title = show ? 'Hide password' : 'Show password';
|
$('pwreveal').title = show ? 'Hide password' : 'Show password';
|
||||||
});
|
});
|
||||||
|
|
||||||
// #4: can builder — multiple text items bundled into one shareable page.
|
|
||||||
// The main editor becomes the first item; extra items are added below.
|
|
||||||
$('iscan').addEventListener('change', e => {
|
|
||||||
$('canrow').style.display = e.target.checked ? 'block' : 'none';
|
|
||||||
if (e.target.checked && !$('can-items').children.length) addCanItem();
|
|
||||||
});
|
|
||||||
function addCanItem() {
|
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'can-item-row';
|
|
||||||
row.innerHTML = '<input class="can-item-title" placeholder="Item title">' +
|
|
||||||
'<textarea class="can-item-content" placeholder="Item content" rows="3" spellcheck="false"></textarea>';
|
|
||||||
$('can-items').appendChild(row);
|
|
||||||
}
|
|
||||||
$('can-add').addEventListener('click', addCanItem);
|
|
||||||
|
|
||||||
let guessed = ''; // last auto-detected language, '' = user override
|
let guessed = ''; // last auto-detected language, '' = user override
|
||||||
|
|
||||||
// #105: map backend machine-readable error codes to plain-language guidance.
|
// #105: map backend machine-readable error codes to plain-language guidance.
|
||||||
@@ -248,8 +226,6 @@ $('reguess').addEventListener('click', guessLang);
|
|||||||
content.addEventListener('paste', () => setTimeout(guessLang, 0));
|
content.addEventListener('paste', () => setTimeout(guessLang, 0));
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
// #4: can mode — bundle the editor + extra items into a can via multipart
|
|
||||||
if ($('iscan').checked) return createCan();
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
content: content.value,
|
content: content.value,
|
||||||
@@ -307,61 +283,6 @@ async function create() {
|
|||||||
}
|
}
|
||||||
$('create').addEventListener('click', create);
|
$('create').addEventListener('click', create);
|
||||||
|
|
||||||
// #4: can creation — POST multipart to /api/pastes/can. The main editor is
|
|
||||||
// the first item; each extra can-item row is another text item.
|
|
||||||
async function createCan() {
|
|
||||||
const items = [];
|
|
||||||
if (content.value.trim()) {
|
|
||||||
items.push({title: $('title').value || 'main', content: content.value, language: $('language').value || ''});
|
|
||||||
}
|
|
||||||
document.querySelectorAll('#can-items .can-item-row').forEach(row => {
|
|
||||||
const t = row.querySelector('.can-item-title').value.trim();
|
|
||||||
const c = row.querySelector('.can-item-content').value;
|
|
||||||
if (c.trim()) items.push({title: t || ('item-' + (items.length + 1)), content: c});
|
|
||||||
});
|
|
||||||
if (!items.length) { toast('Nothing to put in the can', 'error'); return; }
|
|
||||||
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('title', $('title').value || 'Untitled can');
|
|
||||||
fd.append('json_items', JSON.stringify(items));
|
|
||||||
if ($('haspw').checked) fd.append('password', $('password').value);
|
|
||||||
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
|
|
||||||
const exp = document.querySelector('input[name="exp"]:checked').value;
|
|
||||||
if (exp === 'custom') {
|
|
||||||
const dur = composeCustomExpiry();
|
|
||||||
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
|
|
||||||
if (dur) fd.append('expires_in', dur);
|
|
||||||
} else if (exp) {
|
|
||||||
fd.append('expires_in', exp);
|
|
||||||
}
|
|
||||||
if ($('custom').value.trim()) fd.append('custom_slug', $('custom').value.trim());
|
|
||||||
|
|
||||||
const res = await fetch('/api/pastes/can', {method: 'POST', body: fd});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) {
|
|
||||||
showResult(friendlyError(data), 'err');
|
|
||||||
toast('Can create failed', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = location.origin + data.url;
|
|
||||||
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', 'ok');
|
|
||||||
const copyBtn = document.getElementById('result-copy');
|
|
||||||
copyBtn.addEventListener('click', () => {
|
|
||||||
try {
|
|
||||||
navigator.clipboard.writeText(url);
|
|
||||||
copyBtn.classList.add('ok');
|
|
||||||
copyBtn.textContent = 'Success!';
|
|
||||||
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
|
|
||||||
} catch(e) { toast('Copy failed', 'error'); }
|
|
||||||
});
|
|
||||||
// password-protected can: unlock now with the password we already have (#26 parity)
|
|
||||||
if ($('haspw').checked && data.id) {
|
|
||||||
const pd = new FormData();
|
|
||||||
pd.append('password', $('password').value);
|
|
||||||
try { await fetch('/can/' + data.id, {method: 'POST', body: pd}); } catch(e) {}
|
|
||||||
}
|
|
||||||
location.href = data.url;
|
|
||||||
}
|
|
||||||
// reset stale result state when returning via Back (bfcache) (#28)
|
// reset stale result state when returning via Back (bfcache) (#28)
|
||||||
window.addEventListener('pageshow', e => {
|
window.addEventListener('pageshow', e => {
|
||||||
if (!e.persisted) return;
|
if (!e.persisted) return;
|
||||||
@@ -373,5 +294,25 @@ window.addEventListener('pageshow', e => {
|
|||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #130: line wrap toggle, shared preference with the viewer (palette-wrap)
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('content');
|
||||||
|
var btn = document.getElementById('wrap-toggle');
|
||||||
|
function apply(on) {
|
||||||
|
el.classList.toggle('wrap', on);
|
||||||
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
var on = false;
|
||||||
|
try { on = localStorage.getItem('palette-wrap') === 'true'; } catch (e) {}
|
||||||
|
apply(on);
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
on = !on;
|
||||||
|
try { localStorage.setItem('palette-wrap', on ? 'true' : 'false'); } catch (e) {}
|
||||||
|
apply(on);
|
||||||
|
});
|
||||||
|
// follow changes made in settings on other tabs/pages
|
||||||
|
document.addEventListener('palette-wrapchange', function (e) { apply(e.detail); });
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{template "foot" .}}
|
{{template "foot" .}}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
||||||
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
|
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
|
<button class="iconbtn wrap-toggle" id="wrap-toggle" type="button" title="Toggle line wrap" aria-label="Toggle line wrap" aria-pressed="false">wrap</button>
|
||||||
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
||||||
<a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a>
|
<a class="iconbtn" href="#" id="copy-btn" onclick="copyContent(this); return false;">copy</a>
|
||||||
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
|
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
|
||||||
@@ -80,5 +81,24 @@ function redeem(token) {
|
|||||||
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
|
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
|
||||||
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
|
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #130: line wrap toggle, shared preference with the editor (palette-wrap)
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('codebody');
|
||||||
|
var btn = document.getElementById('wrap-toggle');
|
||||||
|
function apply(on) {
|
||||||
|
el.classList.toggle('wrap', on);
|
||||||
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
var on = false;
|
||||||
|
try { on = localStorage.getItem('palette-wrap') === 'true'; } catch (e) {}
|
||||||
|
apply(on);
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
on = !on;
|
||||||
|
try { localStorage.setItem('palette-wrap', on ? 'true' : 'false'); } catch (e) {}
|
||||||
|
apply(on);
|
||||||
|
});
|
||||||
|
document.addEventListener('palette-wrapchange', function (e) { apply(e.detail); });
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{template "foot" .}}
|
{{template "foot" .}}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<h3>Theme</h3>
|
<h3>Theme</h3>
|
||||||
<div class="theme-grid" id="theme-grid"></div>
|
<div class="theme-grid" id="theme-grid"></div>
|
||||||
|
<button class="iconbtn dark-toggle" id="settings-dark-toggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
<span>Dark mode</span>
|
||||||
|
</button>
|
||||||
|
<h3>Line wrap</h3>
|
||||||
|
<label class="toggle"><input type="checkbox" id="settings-wrap-toggle"> Wrap lines in the editor and paste view</label>
|
||||||
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
|
<p class="admin-link-row"><a class="admin-link" href="/admin">Admin</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +23,8 @@
|
|||||||
(function () {
|
(function () {
|
||||||
// #112: derive each preset's swatches from the real CSS variables in
|
// #112: derive each preset's swatches from the real CSS variables in
|
||||||
// app.css by temporarily applying data-preset, so they can never drift.
|
// app.css by temporarily applying data-preset, so they can never drift.
|
||||||
var themeNames = [
|
// #127: 5 theme pairs, light swatches top row, dark bottom row.
|
||||||
|
var pairs = [
|
||||||
{ id: 'midnight', name: 'Midnight' },
|
{ id: 'midnight', name: 'Midnight' },
|
||||||
{ id: 'smooth', name: 'Smooth' },
|
{ id: 'smooth', name: 'Smooth' },
|
||||||
{ id: 'pastel-lavender', name: 'Pastel Lavender' },
|
{ id: 'pastel-lavender', name: 'Pastel Lavender' },
|
||||||
@@ -35,26 +43,69 @@
|
|||||||
return colors;
|
return colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// current base pair + dark flag from the resolved data-preset.
|
||||||
|
// "midnight" is itself the dark variant, so check dark ids first.
|
||||||
|
function state() {
|
||||||
|
var p = document.documentElement.dataset.preset || 'midnight';
|
||||||
|
if (p === 'midnight' || /-dark$/.test(p)) {
|
||||||
|
return { base: p === 'midnight' ? 'midnight' : p.replace(/-dark$/, ''), dark: true };
|
||||||
|
}
|
||||||
|
return { base: p === 'midnight-light' ? 'midnight' : p, dark: false };
|
||||||
|
}
|
||||||
|
|
||||||
var grid = document.getElementById('theme-grid');
|
var grid = document.getElementById('theme-grid');
|
||||||
var current = document.documentElement.dataset.preset || 'midnight';
|
var cards = {};
|
||||||
themeNames.forEach(function (t) {
|
pairs.forEach(function (t) {
|
||||||
var colors = presetColors(t.id);
|
var light = presetColors(t.id);
|
||||||
|
var dark = presetColors(t.id + '-dark');
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'theme-card';
|
btn.className = 'theme-card';
|
||||||
btn.setAttribute('aria-pressed', current === t.id ? 'true' : 'false');
|
btn.setAttribute('aria-pressed', 'false');
|
||||||
|
btn.setAttribute('data-pair', t.id);
|
||||||
btn.innerHTML = '<strong>' + t.name + '</strong>' +
|
btn.innerHTML = '<strong>' + t.name + '</strong>' +
|
||||||
'<span class="swatches">' + colors.map(function (c) {
|
'<span class="swatches">' + light.map(function (c) {
|
||||||
|
return '<span class="swatch" style="background:' + c + '"></span>';
|
||||||
|
}).join('') + '</span>' +
|
||||||
|
'<span class="swatches">' + dark.map(function (c) {
|
||||||
return '<span class="swatch" style="background:' + c + '"></span>';
|
return '<span class="swatch" style="background:' + c + '"></span>';
|
||||||
}).join('') + '</span>';
|
}).join('') + '</span>';
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
document.documentElement.dataset.preset = t.id;
|
var dark = state().dark;
|
||||||
|
document.documentElement.dataset.preset = dark ? t.id + '-dark' : t.id;
|
||||||
try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
|
try { localStorage.setItem('palette-theme', t.id); } catch (e) {}
|
||||||
grid.querySelectorAll('.theme-card').forEach(function (c) { c.setAttribute('aria-pressed', 'false'); });
|
Object.keys(cards).forEach(function (k) { cards[k].setAttribute('aria-pressed', 'false'); });
|
||||||
btn.setAttribute('aria-pressed', 'true');
|
btn.setAttribute('aria-pressed', 'true');
|
||||||
});
|
});
|
||||||
|
cards[t.id] = btn;
|
||||||
grid.appendChild(btn);
|
grid.appendChild(btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var s = state();
|
||||||
|
Object.keys(cards).forEach(function (k) {
|
||||||
|
cards[k].setAttribute('aria-pressed', k === s.base ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
var dt = document.getElementById('settings-dark-toggle');
|
||||||
|
if (dt) dt.setAttribute('aria-pressed', s.dark ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
sync();
|
||||||
|
// the topbar script runs before this button exists, so wire it here
|
||||||
|
var dt = document.getElementById('settings-dark-toggle');
|
||||||
|
dt.addEventListener('click', function () {
|
||||||
|
var btns = document.querySelectorAll('.topbar .dark-toggle');
|
||||||
|
if (btns.length) btns[0].click(); else document.dispatchEvent(new CustomEvent('palette-darkchange'));
|
||||||
|
});
|
||||||
|
document.addEventListener('palette-darkchange', sync);
|
||||||
|
// #130: line wrap setting shares the palette-wrap preference with the
|
||||||
|
// editor and paste view toggles.
|
||||||
|
var wt = document.getElementById('settings-wrap-toggle');
|
||||||
|
try { wt.checked = localStorage.getItem('palette-wrap') === 'true'; } catch (e) {}
|
||||||
|
wt.addEventListener('change', function () {
|
||||||
|
var on = wt.checked;
|
||||||
|
try { localStorage.setItem('palette-wrap', on ? 'true' : 'false'); } catch (e) {}
|
||||||
|
document.dispatchEvent(new CustomEvent('palette-wrapchange', { detail: on }));
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{{template "foot" .}}
|
{{template "foot" .}}
|
||||||
|
|||||||
+41
-2
@@ -24,6 +24,44 @@ import (
|
|||||||
//go:embed templates/*.html
|
//go:embed templates/*.html
|
||||||
var tmplFS embed.FS
|
var tmplFS embed.FS
|
||||||
|
|
||||||
|
// DefaultDark is the server-configured default dark mode state for visitors
|
||||||
|
// without stored preferences (#127). Set via PALETTE_DEFAULT_DARK.
|
||||||
|
var DefaultDark = true
|
||||||
|
|
||||||
|
// SetDefaultDark applies the PALETTE_DEFAULT_DARK env var: unset = dark on,
|
||||||
|
// "false"/"0"/"off" = dark off.
|
||||||
|
func SetDefaultDark(v string) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "false", "0", "off":
|
||||||
|
DefaultDark = false
|
||||||
|
default:
|
||||||
|
DefaultDark = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePreset maps a theme pair id (or explicit variant id) plus dark state
|
||||||
|
// to a concrete preset id. Returns the defaults when pair is empty or unknown.
|
||||||
|
// Precedence handled by callers: URL ?theme= > stored pair+dark > defaults.
|
||||||
|
func ResolvePreset(pair string, dark bool) string {
|
||||||
|
variants := map[string][2]string{ // pair -> {light, dark}
|
||||||
|
"midnight": {"midnight-light", "midnight"},
|
||||||
|
"smooth": {"smooth", "smooth-dark"},
|
||||||
|
"pastel-lavender": {"pastel-lavender", "pastel-lavender-dark"},
|
||||||
|
"pastel-peach": {"pastel-peach", "pastel-peach-dark"},
|
||||||
|
"pastel-cloud": {"pastel-cloud", "pastel-cloud-dark"},
|
||||||
|
}
|
||||||
|
if v, ok := variants[pair]; ok {
|
||||||
|
if dark {
|
||||||
|
return v[1]
|
||||||
|
}
|
||||||
|
return v[0]
|
||||||
|
}
|
||||||
|
if dark {
|
||||||
|
return "midnight"
|
||||||
|
}
|
||||||
|
return "midnight-light"
|
||||||
|
}
|
||||||
|
|
||||||
//go:embed static
|
//go:embed static
|
||||||
var staticFS embed.FS
|
var staticFS embed.FS
|
||||||
|
|
||||||
@@ -33,8 +71,9 @@ type UI struct {
|
|||||||
|
|
||||||
func New() (*UI, error) {
|
func New() (*UI, error) {
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"humanSize": humanSize,
|
"humanSize": humanSize,
|
||||||
"version": func() string { return Version }, // #93: topbar version label
|
"version": func() string { return Version }, // #93: topbar version label
|
||||||
|
"defaultDark": func() bool { return DefaultDark }, // #127: server-configured default
|
||||||
}
|
}
|
||||||
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
|
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolvePreset(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
pair string
|
||||||
|
dark bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"", false, "midnight-light"},
|
||||||
|
{"", true, "midnight"},
|
||||||
|
{"midnight", false, "midnight-light"},
|
||||||
|
{"midnight", true, "midnight"},
|
||||||
|
{"smooth", false, "smooth"},
|
||||||
|
{"smooth", true, "smooth-dark"},
|
||||||
|
{"pastel-lavender", true, "pastel-lavender-dark"},
|
||||||
|
{"pastel-peach", false, "pastel-peach"},
|
||||||
|
{"pastel-cloud", true, "pastel-cloud-dark"},
|
||||||
|
{"bogus", true, "midnight"},
|
||||||
|
{"bogus", false, "midnight-light"},
|
||||||
|
// explicit variant ids also resolve (URL ?theme= may pass them)
|
||||||
|
{"smooth-dark", true, "midnight"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ResolvePreset(c.pair, c.dark); got != c.want {
|
||||||
|
t.Errorf("ResolvePreset(%q, %v) = %q, want %q", c.pair, c.dark, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetDefaultDark(t *testing.T) {
|
||||||
|
for _, off := range []string{"false", "0", "off", "OFF", " false "} {
|
||||||
|
DefaultDark = true
|
||||||
|
SetDefaultDark(off)
|
||||||
|
if DefaultDark {
|
||||||
|
t.Errorf("SetDefaultDark(%q): want dark off", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, on := range []string{"true", "1", "on", "anything"} {
|
||||||
|
DefaultDark = false
|
||||||
|
SetDefaultDark(on)
|
||||||
|
if !DefaultDark {
|
||||||
|
t.Errorf("SetDefaultDark(%q): want dark on", on)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DefaultDark = true
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user