diff --git a/README.md b/README.md index 0c15dbd..aaac322 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ go build -o palette ./cmd/palette | `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 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 `/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 install (`PALETTE_ADMIN_KEY` env var) and resettable locally — see diff --git a/cmd/palette/main.go b/cmd/palette/main.go index 2807582..11f494b 100644 --- a/cmd/palette/main.go +++ b/cmd/palette/main.go @@ -42,6 +42,7 @@ func main() { if err != nil { log.Fatal(err) } + web.SetDefaultDark(os.Getenv("PALETTE_DEFAULT_DARK")) srv := api.NewServer(st, cfg, ui, ss, adminKey) log.Printf("palette listening on %s", cfg.Addr) log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes())) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 4dd8e73..5a9a411 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -203,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.gear { display: inline-flex; align-items: center; padding: 5px 9px; } .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 h1 { font-size: 29.2px; font-weight: 600; margin: 0; } .settings-body { padding: 16px 18px; color: var(--muted-fg); font-size: 21.6px; } @@ -480,6 +496,12 @@ a.admin-link:hover { color: var(--fg); text-decoration: underline; } .topbar nav a { padding: 5px 8px; font-size: 15px; } .iconbtn.gear { padding: 4px 7px; flex-shrink: 0; } .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 */ .deck { @@ -627,3 +649,45 @@ button[type="submit"]:focus-visible, 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); } diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index aaf33db..46337d6 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -3,11 +3,48 @@ {{end}} @@ -22,8 +59,57 @@ Git
- + + + {{end}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index d5a2870..f3c195d 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -8,6 +8,11 @@

Theme

+
@@ -16,17 +21,13 @@ (function () { // #112: derive each preset's swatches from the real CSS variables in // 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: 'smooth', name: 'Smooth' }, { id: 'pastel-lavender', name: 'Pastel Lavender' }, { id: 'pastel-peach', name: 'Pastel Peach' }, - { id: 'pastel-cloud', name: 'Pastel Cloud' }, - { id: 'midnight-light', name: 'Midnight Light' }, - { id: 'smooth-dark', name: 'Smooth Dark' }, - { id: 'pastel-lavender-dark', name: 'Pastel Lavender Dark' }, - { id: 'pastel-peach-dark', name: 'Pastel Peach Dark' }, - { id: 'pastel-cloud-dark', name: 'Pastel Cloud Dark' } + { id: 'pastel-cloud', name: 'Pastel Cloud' } ]; var SWATCH_VARS = ['--bg', '--surface', '--surface-2', '--muted', '--accent']; @@ -40,26 +41,60 @@ 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 current = document.documentElement.dataset.preset || 'midnight'; - themeNames.forEach(function (t) { - var colors = presetColors(t.id); + var cards = {}; + pairs.forEach(function (t) { + var light = presetColors(t.id); + var dark = presetColors(t.id + '-dark'); var btn = document.createElement('button'); btn.type = 'button'; 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 = '' + t.name + '' + - '' + colors.map(function (c) { + '' + light.map(function (c) { + return ''; + }).join('') + '' + + '' + dark.map(function (c) { return ''; }).join('') + ''; 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) {} - 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'); }); + cards[t.id] = 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); })(); {{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index 6d164ae..e7ba16f 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -24,6 +24,44 @@ import ( //go:embed templates/*.html 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 var staticFS embed.FS @@ -33,8 +71,9 @@ type UI struct { func New() (*UI, error) { funcs := template.FuncMap{ - "humanSize": humanSize, - "version": func() string { return Version }, // #93: topbar version label + "humanSize": humanSize, + "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") if err != nil { diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..56fc339 --- /dev/null +++ b/internal/web/web_test.go @@ -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 +}