diff --git a/internal/api/issue138_cookie_test.go b/internal/api/issue138_cookie_test.go index d5e8ca2..93459be 100644 --- a/internal/api/issue138_cookie_test.go +++ b/internal/api/issue138_cookie_test.go @@ -29,7 +29,7 @@ func newTestServer138(t *testing.T) *httptest.ResponseRecorder { globalSettingsFn = ss.get t.Cleanup(func() { globalSettingsFn = nil }) a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} - req := httptest.NewRequest("GET", "/history", nil) + req := httptest.NewRequest("GET", "/public", nil) rec := httptest.NewRecorder() a.routes().ServeHTTP(rec, req) return rec diff --git a/internal/api/mine_test.go b/internal/api/mine_test.go index 233e5a5..c3adcfd 100644 --- a/internal/api/mine_test.go +++ b/internal/api/mine_test.go @@ -58,7 +58,7 @@ func TestMineCreateListDelete(t *testing.T) { a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} h := a.routes() - alice := viewerCookieFor(t, h, "/history") + alice := viewerCookieFor(t, h, "/public") if alice == "" { t.Fatal("no viewer cookie issued") } @@ -89,7 +89,7 @@ func TestMineCreateListDelete(t *testing.T) { } // a different browser's cookie does NOT see it - bob := viewerCookieFor(t, h, "/history") + bob := viewerCookieFor(t, h, "/public") rec = doReq(t, h, "GET", "/api/mine", bob, "") json.Unmarshal(rec.Body.Bytes(), &list) if list.Total != 0 { diff --git a/internal/api/routes_rename_test.go b/internal/api/routes_rename_test.go new file mode 100644 index 0000000..6a02ec0 --- /dev/null +++ b/internal/api/routes_rename_test.go @@ -0,0 +1,52 @@ +package api + +// #256: renamed page routes; old URLs redirect. +import ( + "palette/internal/store" + "palette/internal/web" + + "net/http" + "net/http/httptest" + "testing" +) + +func TestRenamedPageRoutes(t *testing.T) { + globalLimiter = newLimiter() // fresh rate-limit buckets + st, err := store.OpenStore(":memory:") + if err != nil { + t.Fatal(err) + } + ui, err := web.New() + if err != nil { + t.Fatal(err) + } + cfg := Config{MaxTextBytes: 5 * 1024 * 1024} + ss := NewTestSettingsStore(t, cfg) + globalSettingsFn = ss.get + t.Cleanup(func() { globalSettingsFn = nil }) + a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"} + h := a.routes() + + // new routes render pages + for _, path := range []string{"/public", "/saved"} { + req := httptest.NewRequest("GET", path, nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("GET %s: %d, want 200", path, rec.Code) + } + } + + // old routes redirect + for _, tc := range [][2]string{{"/history", "/public"}, {"/mine", "/saved"}, {"/", "/public"}} { + req := httptest.NewRequest("GET", tc[0], nil) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusFound { + t.Fatalf("GET %s: %d, want redirect", tc[0], rec.Code) + } + if loc := rec.Header().Get("Location"); loc != tc[1] { + t.Fatalf("GET %s redirects to %s, want %s", tc[0], loc, tc[1]) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index aae5c96..b36a714 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -118,11 +118,14 @@ func (a *apiServer) routes() http.Handler { r.Get("/raw/{id}", a.handleRaw) // web pages - r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP) + r.Get("/", http.RedirectHandler("/public", http.StatusFound).ServeHTTP) r.Get("/new", a.ui.Handlers().HandleNewPage) - r.Get("/history", a.ui.Handlers().HandleHistoryPage) + r.Get("/public", a.ui.Handlers().HandleHistoryPage) + r.Get("/saved", a.ui.Handlers().HandleMinePage) r.Get("/settings", a.ui.Handlers().HandleSettingsPage) - r.Get("/mine", a.ui.Handlers().HandleMinePage) + // #256: old URLs redirect to the renamed pages + r.Get("/history", http.RedirectHandler("/public", http.StatusMovedPermanently).ServeHTTP) + r.Get("/mine", http.RedirectHandler("/saved", http.StatusMovedPermanently).ServeHTTP) r.Handle("/static/*", a.ui.StaticHandler()) r.Get("/unlock/{id}", a.handlePasteView) r.Post("/unlock/{id}", a.handlePasteView) diff --git a/internal/store/customslug.go b/internal/store/customslug.go index 65f0ae4..e39b378 100644 --- a/internal/store/customslug.go +++ b/internal/store/customslug.go @@ -13,7 +13,7 @@ var reservedSlugs = map[string]bool{ "api": true, "raw": true, "can": true, "cans": true, "public": true, "history": true, "static": true, "assets": true, "favicon.ico": true, "new": true, "login": true, "logout": true, "admin": true, "settings": true, - "mine": true, "unlock": true, "guess": true, "f": true, + "mine": true, "saved": true, "unlock": true, "guess": true, "f": true, } var ErrInvalidSlug = errors.New("custom slug must be 1-64 chars: letters, digits, dash, underscore; must start with letter or digit") diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 16276d6..a18cc9e 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -875,6 +875,9 @@ button[type="submit"]:focus-visible, .col-a { width: 260px; } .col-b { width: 140px; } .col-c { width: 120px; } .col-d { width: 96px; } .col-d2 { width: 150px; } .col-e { width: 190px; } .col-f { width: 100px; } .col-g { width: 190px; } +/* #255: history's URL column had its own narrow width (col-f doubles as + /mine's ID column); give it a dedicated class. */ +.col-url { width: 150px; } /* #210: /mine rows render a delete button cell that had no declared column, so under table-layout:fixed it overlapped the ID column. */ .col-del { width: 64px; } @@ -890,3 +893,17 @@ button[type="submit"]:focus-visible, .created-banner { display: block; } /* #167: gutter rows for wrapped paste view — one row per visual code line */ .gutline { display: block; } + +/* #267: jump to top/bottom pills for long pastes and the editor. + Hidden unless JS (jump.js) detects content more than 2x the viewport. */ +.jumpnav { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 50; + display: flex; + flex-direction: column; + gap: 8px; +} +.jumpnav.hidden { display: none; } +.jump-btn { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); } diff --git a/internal/web/static/jump.js b/internal/web/static/jump.js new file mode 100644 index 0000000..03fb611 --- /dev/null +++ b/internal/web/static/jump.js @@ -0,0 +1,41 @@ +/* #267: jump to top / bottom controls for long content. + Paste view scrolls the window; the /new editor scrolls its textarea. + The active scroller is chosen via data-jump-scroll on the script tag. */ +(function () { + var nav = document.getElementById('jumpnav'); + if (!nav) return; + var scroller = window; + var sel = nav.dataset.jumpScroll; + if (sel) scroller = document.querySelector(sel); + + function el() { + return scroller === window ? document.scrollingElement : scroller; + } + function isLarge() { + var e = el(); + if (!e) return false; + var visible = scroller === window ? window.innerHeight : e.clientHeight; + return e.scrollHeight > visible * 2; + } + function refresh() { + nav.classList.toggle('hidden', !isLarge()); + } + function jump(toTop) { + var e = el(); + if (!e) return; + if (scroller === window) { + window.scrollTo({ top: toTop ? 0 : e.scrollHeight }); + } else { + e.scrollTop = toTop ? 0 : e.scrollHeight; + } + } + nav.addEventListener('click', function (ev) { + var b = ev.target.closest('[data-jump]'); + if (!b) return; + ev.preventDefault(); + jump(b.dataset.jump === 'top'); + }); + window.addEventListener('resize', refresh); + if (scroller !== window && scroller) scroller.addEventListener('input', refresh); + refresh(); +})(); diff --git a/internal/web/static/new.js b/internal/web/static/new.js index 3e4cf2c..fb1d1a1 100644 --- a/internal/web/static/new.js +++ b/internal/web/static/new.js @@ -9,6 +9,8 @@ function updateGutter() { gutter.textContent = s; } content.addEventListener('input', updateGutter); +// #259: the editor scrolls itself; keep the gutter's numbers in step with it. +content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; }); updateGutter(); function toast(msg, kind) { @@ -197,7 +199,7 @@ function finishCreate(data) { try { navigator.clipboard.writeText(url); copyBtn.classList.add('ok'); // in-place success feedback (#53) - copyBtn.textContent = 'Success!'; + copyBtn.textContent = '✓'; setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000); } catch(e) { toast('Copy failed', 'error'); } }); diff --git a/internal/web/static/paste-lines.js b/internal/web/static/paste-lines.js index e385e9d..fc6aabb 100644 --- a/internal/web/static/paste-lines.js +++ b/internal/web/static/paste-lines.js @@ -44,6 +44,23 @@ // measured, not derived from span counts or heights. function renumber() { var lines = body.querySelectorAll('.codeline'); + // #257: size the gutter column to the widest line number so numbers in + // the 100s+ fit their own column instead of bleeding into the code text. + // The gutter is box-sizing: border-box, so the column width must be the + // digits PLUS the 10px left + 10px right padding; at the CSS default 3ch + // the padding alone leaves only ~19px of content, and any 2+ digit + // number overflows into the code. Numbers are right-aligned, and the + // width below fits the widest number exactly. Set via CSSOM (CSP + // forbids inline style attributes). Only touch the width when it + // changes: the resize observer below re-runs renumber() when the gutter + // width reflows the code column, and rewriting the same value would + // ping-pong the fixed point forever. + var digits = String(lines.length || 1).length; + var w = 'calc(' + digits + 'ch + 20px)'; + if (gutter.style.width !== w) { + gutter.style.minWidth = w; + gutter.style.width = w; + } if (!wrapOn() || !lines.length) { // wrap OFF: one number per logical line (pre-existing behavior, // including the gutter scrolling with horizontal scroll). diff --git a/internal/web/static/paste.js b/internal/web/static/paste.js index 0e88715..a32b5e3 100644 --- a/internal/web/static/paste.js +++ b/internal/web/static/paste.js @@ -20,7 +20,7 @@ function copyFeedback(btn) { if (!btn) return; if (!btn.dataset.label) btn.dataset.label = btn.textContent; // remember the original label (Copy/Link) btn.classList.add('ok'); - btn.textContent = 'Success!'; + btn.textContent = '✓'; clearTimeout(btn._okh); btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = btn.dataset.label; }, 2000); } @@ -42,7 +42,7 @@ function redeem() { try { tok = sessionStorage.getItem('deletion_token_' + PASTE_ID) || ''; } catch(e) {} if (!tok) { alert('deletion token not available in this browser'); return; } fetch('/api/pastes/' + PASTE_ID + '/redeem', {method: 'DELETE', headers: {'Authorization': 'Bearer ' + tok}}) - .then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); }); + .then(r => { if (r.ok) location.href = '/public'; else alert('delete failed'); }); } // wiring (moved from inline handlers for CSP #139) diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index 88359c7..2896a62 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -8,7 +8,7 @@
- + diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index b38e262..61eeca0 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -8,11 +8,11 @@ {{define "topbar"}}
- +
diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index ef90b5f..e21941f 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -33,6 +33,10 @@
+
@@ -90,4 +94,5 @@
+ {{template "foot" .}} diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index 4465632..f035074 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -55,8 +55,13 @@
{{.Gutter}}
{{.ContentHTML}}
{{end}} + + {{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index cbd71a7..049817d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -433,9 +433,9 @@ func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) { h.renderPage(w, "new.html", map[string]any{"Page": "new"}) } -// HandleHistoryPage serves /history. +// HandleHistoryPage serves /public. func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) { - h.renderPage(w, "history.html", map[string]any{"Page": "history"}) + h.renderPage(w, "history.html", map[string]any{"Page": "public"}) } // HandleSettingsPage serves /settings. @@ -452,9 +452,9 @@ func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { h.renderPage(w, "settings.html", map[string]any{"Page": "settings", "Themes": themes}) } -// HandleMinePage serves /mine. +// HandleMinePage serves /saved. func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) { - h.renderPage(w, "mine.html", map[string]any{"Page": "mine"}) + h.renderPage(w, "mine.html", map[string]any{"Page": "saved"}) } // HandleAdminPage serves /admin.
Paste Type