Refactor: split monolith into cmd/palette + internal/{store,api,web,lang} (#35)
CI / test (push) Successful in 19s
CI / docker (push) Failing after 2m7s

This commit is contained in:
2026-09-09 01:33:39 -05:00
parent a3349b4a98
commit 4f1e901f04
43 changed files with 1251 additions and 1203 deletions
+458
View File
@@ -0,0 +1,458 @@
/* Palette UI tokens (mirrors sketches/themes/tokens.css, midnight approved preset) */
:root {
--bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52;
--muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8;
--accent: #C4A8F0; --border: #42355C;
--radius-lg: 20px; --radius: 10px; --radius-sm: 999px;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace;
}
/* semantic status colors (dark preset values; light presets override below) */
:root {
--ok: #9CD49C; --warn: #E8C77B; --err: #F2A3B3;
--on-accent: #241B30; /* text placed on accent-colored backgrounds */
}
[data-preset="smooth"] {
--bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6;
--muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36;
--accent: #7A7796; --border: #DAD7E6;
--ok: #456F45; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #F6F5FA;
}
[data-preset="pastel-lavender"] {
--bg: #e6e0f5; --surface: #f1edfa; --surface-2: #cbb8e7;
--muted: #a99cc9; --muted-fg: #5f5390; --fg: #3E3059;
--accent: #806bb8; --border: #c4b6e0;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #f1edfa;
}
[data-preset="pastel-peach"] {
--bg: #ffe0d6; --surface: #fff0ea; --surface-2: #ffc4a8;
--muted: #d9a08c; --muted-fg: #7a4632; --fg: #4F2318;
--accent: #f9826c; --border: #ffc9b5;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #4F2318;
}
[data-preset="pastel-cloud"] {
--bg: #fff0f6; --surface: #fff7fb; --surface-2: #ffc8dd;
--muted: #d9b9c9; --muted-fg: #7d5670; --fg: #4A3355;
--accent: #a2d2ff; --border: #ffccd9;
--ok: #3E6B3E; --warn: #7A5E1B; --err: #9E4054;
--on-accent: #274a6b;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--fg);
line-height: 1.45;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
font-size: 24.2px;
}
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 0 22px; height: 76px;
background: var(--surface); border-bottom: 1px solid var(--border);
}
.logo { font-weight: 700; font-size: 27.6px; letter-spacing: -0.02em; text-decoration: none; color: var(--fg); }
.logo em { font-style: normal; color: var(--muted-fg); font-weight: 400; }
.topbar nav { display: flex; gap: 4px; }
.topbar nav a { color: var(--muted-fg); text-decoration: none; padding: 6px 12px; border-radius: var(--radius); font-size: 23.2px; }
.topbar nav a:hover { background: var(--surface-2); color: var(--fg); }
.topbar nav a.on { background: var(--accent); color: var(--bg); }
.topbar .spacer { flex: 1; }
.kbd { font-family: var(--font-mono); font-size: 18.9px; border: 1px solid var(--border); border-radius: var(--radius); padding: 2px 6px; color: var(--muted-fg); }
.float {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg);
box-shadow:
0 1px 2px rgba(0, 0, 0, .10),
0 2px 6px rgba(0, 0, 0, .08),
0 8px 24px rgba(0, 0, 0, .07);
overflow: hidden;
}
/* new paste page */
.deck {
display: grid; grid-template-columns: 1fr 300px; gap: 16px;
padding: 16px 20px 20px; height: calc(100vh - 76px);
max-width: 1400px; margin: 0 auto;
}
.pane-r { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; padding-bottom: 4px; }
.side-section { padding: 14px 16px; flex-shrink: 0; }
.side-section h3 { font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
.pane-l-col { display: flex; flex-direction: column; gap: 14px; min-height: 0; }
.pane-l-head { flex-shrink: 0; }
.editor-head {
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
}
.editor-head input {
border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 23.2px; flex: 1;
}
.editor-head select {
border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg);
border-radius: var(--radius); padding: 4px 10px; font: inherit; font-size: 21.6px; cursor: pointer;
}
/* shared code line metrics (#50): gutter + code must share one line box */
:root { --code-lh: 1.7; --code-fs: 21.6px; }
.editor-wrap { flex: 1; display: flex; min-height: 0; }
.gutter {
padding: 14px 10px; text-align: right; color: var(--muted); font-family: var(--font-mono);
font-size: var(--code-fs); line-height: var(--code-lh); user-select: none; white-space: pre; overflow: hidden;
border-right: 1px solid var(--border);
}
.editor {
flex: 1; padding: 14px 16px; font-family: var(--font-mono); font-size: 21.6px; line-height: 1.7;
white-space: pre; outline: none; overflow: auto; border: none; background: transparent; color: var(--fg);
resize: none; width: 100%;
}
.editor::placeholder { color: var(--muted); }
.actionbar {
display: flex; align-items: center; gap: 14px;
padding: 10px 4px;
}
.actionbar .btn { padding: 10px 26px; }
.btn {
background: var(--accent); color: var(--bg); border: none; cursor: pointer;
padding: 8px 18px; border-radius: var(--radius); font: inherit; font-size: 22.4px; font-weight: 600;
}
.btn:hover { filter: brightness(1.08); }
.hint { font-size: 20.7px; color: var(--muted-fg); }
.hint b { color: var(--fg); font-weight: 550; }
.seg { display: flex; flex-direction: column; gap: 2px; }
.seg label { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: var(--radius); cursor: pointer; font-size: 22.4px; }
.seg label:hover { background: var(--surface-2); }
.seg input { accent-color: var(--accent); }
.toggle { display: flex; align-items: center; gap: 8px; font-size: 22.4px; cursor: pointer; padding: 5px 8px; border-radius: var(--radius); }
.toggle:hover { background: var(--surface-2); }
.toggle input { accent-color: var(--accent); }
.deck .row { display: flex; justify-content: space-between; align-items: center; font-size: 22.4px; padding: 4px 0; }
.row input[type="text"] {
border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 21.6px; width: 130px;
}
.created-banner {
display: none; padding: 10px 16px; font-size: 22.4px; background: var(--surface-2);
border-bottom: 1px solid var(--border); word-break: break-all;
}
.created-banner a { color: var(--accent); }
/* paste view */
.meta-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.meta-bar h1 { font-size: 29.2px; font-weight: 600; }
.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: var(--radius); }
.tag { font-size: 19.8px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 2px 9px; }
.paste-title-bar { display: flex; align-items: center; gap: 12px; padding: 12px 18px; flex-wrap: wrap; }
.paste-title-bar h1 { font-size: 29.2px; font-weight: 600; margin: 0; }
.stats-pill { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
.stats-head { display: flex; align-items: center; gap: 16px; width: 100%; background: none; border: 0; color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 14px 18px; cursor: pointer; text-align: left; }
.stats-head:hover { color: var(--fg); background: var(--surface-2); }
.stats-chev { width: 18px; height: 18px; flex: none; transition: transform 0.15s ease; }
.stats-pill.open .stats-chev { transform: rotate(180deg); }
.stats-summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: .01em; }
@media (max-width: 640px) { .stats-summary { white-space: normal; word-break: break-word; } }
.stats-body { border-top: 1px solid var(--border); }
.stats-grid { display: grid; grid-template-columns: max-content 1fr; gap: 6px 18px; padding: 12px 16px; font-size: 20.7px; }
.stats-k { color: var(--muted-fg); }
.stats-v { color: var(--fg); word-break: break-all; }
.meta-bar .spacer { flex: 1; }
.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; }
.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; }
.iconbtn:hover { color: var(--fg); border-color: var(--muted); }
.iconbtn.danger:hover { color: #ff8fa3; border-color: #ff8fa3; }
.code-head {
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
border-bottom: 1px solid var(--border); font-size: 21.6px; color: var(--muted-fg);
}
.code-head .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.code {
font-family: var(--font-mono); font-size: var(--code-fs); line-height: var(--code-lh);
padding: 14px 0; display: flex; overflow-x: auto;
}
.code .gutter { flex-shrink: 0; }
/* gutter/code share line metrics; the editor gutter keeps its own padding (#50) */
.code .gutter { padding-top: 0; padding-bottom: 0; }
.codebody { padding: 0 18px; white-space: pre; }
/* syntax highlight tokens (#1) */
.tok-kw { color: #c792ea; }
.tok-str { color: #a5e075; }
.tok-num { color: #f78c6c; }
.tok-com { color: #6a737d; font-style: italic; }
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 20.7px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
/* history */
.page { max-width: 1200px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.head-row { display: flex; align-items: baseline; gap: 14px; }
.head-row h1 { font-size: 34.5px; font-weight: 600; }
.search {
display: flex; align-items: center; gap: 8px; background: var(--surface);
border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 14px; width: 260px;
}
.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 22.4px; width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 22.4px; table-layout: fixed; }
th {
text-align: left; font-size: 18.9px; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted-fg); padding: 10px 16px; border-bottom: 1px solid var(--border); font-weight: 600;
}
td { padding: 10px 16px; border-bottom: 1px solid var(--border); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
tr:last-child td { border-bottom: none; }
tr.row { cursor: pointer; }
tr.row:hover td { background: var(--surface-2); }
tr.row:hover td a.slug { color: var(--accent); }
td a.slug { font-family: var(--font-mono); font-size: 21.6px; color: var(--fg); text-decoration: none; }
td a.slug:hover { color: var(--accent); }
.badge { font-size: 18.9px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: var(--radius-sm); padding: 1px 8px; }
.badge.lock { color: var(--accent); border-color: var(--accent); }
.dim { color: var(--muted-fg); white-space: nowrap; }
.pager { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; font-size: 21.6px; color: var(--muted-fg); }
.pager .pg { display: flex; gap: 6px; }
.pager button { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: var(--radius); padding: 4px 11px; font: inherit; font-size: 21.6px; cursor: pointer; }
.pager button:hover:not(:disabled) { color: var(--fg); border-color: var(--muted); }
.pager button.on { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.pager button:disabled { opacity: .4; cursor: default; }
.empty { text-align: center; padding: 40px 16px; color: var(--muted-fg); font-size: 22.4px; }
/* unlock */
.center { display: flex; align-items: center; justify-content: center; padding: 20px; height: calc(100vh - 52px); }
.center .float { width: 400px; max-width: 100%; }
.inner { padding: 28px; text-align: center; }
.lockring {
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 50%;
background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 41.4px;
}
.inner h1 { font-size: 29.2px; font-weight: 600; margin-bottom: 6px; }
.inner .sub { font-size: 22.4px; color: var(--muted-fg); margin-bottom: 20px; }
.pwinput {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius);
background: var(--bg); color: var(--fg); font: inherit; font-size: 23.2px; outline: none; text-align: center;
letter-spacing: .12em;
}
.pwinput:focus { border-color: var(--accent); }
.center .btn { width: 100%; margin-top: 12px; }
.err { display: none; margin-top: 12px; font-size: 21.6px; color: #ff8fa3; }
.center .foot { font-size: 20.7px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
.btn-icon {
padding: 4px 8px; font-size: 24.2px; line-height: 1; overflow: visible;
display: inline-flex; align-items: center; justify-content: center;
min-width: 40px; height: 36px;
}
/* selection controls: pill-style selected states (#14) */
.seg label, .toggle {
border: 1px solid transparent;
transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.seg label:hover, .toggle:hover {
background: var(--surface-2);
color: var(--fg);
}
.seg label:has(input:checked),
.toggle:has(input:checked) {
background: var(--surface-2);
border-color: var(--accent);
color: var(--fg);
border-radius: var(--radius-sm);
}
.seg label:has(input:focus-visible),
.toggle:has(input:focus-visible) {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.seg input, .toggle input { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; }
/* toast (#19) */
.toast {
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%) translateY(8px);
background: var(--surface-2); color: var(--fg); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 6px 18px; font-size: 20.7px;
opacity: 0; pointer-events: none; transition: opacity .25s ease, transform .25s ease; z-index: 200;
box-shadow: 0 4px 16px rgba(0,0,0,.25);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
/* status variants (#16) */
.toast.success { border-color: var(--ok); color: var(--ok); }
.toast.error { border-color: var(--err); color: var(--err); }
/* protection section rhythm (#20) */
.protect { display: flex; flex-direction: column; gap: 2px; }
.protect .pw-row { padding: 2px 8px 4px; }
.pw-field {
display: flex; align-items: center; gap: 2px; width: 100%;
border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg);
}
.pw-field:focus-within { border-color: var(--accent); }
.pw-field input {
flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: var(--fg);
font: inherit; font-size: 21.6px; padding: 7px 12px; letter-spacing: .08em;
}
.pw-field input::placeholder { color: var(--muted); letter-spacing: normal; }
.pw-field .reveal {
background: none; border: none; color: var(--muted-fg); cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0 10px; height: 100%;
flex-shrink: 0;
}
.pw-field .reveal:hover { color: var(--fg); }
.pw-field .reveal .eye-slash { display: none; }
.pw-field .reveal.off .eye-slash { display: block; }
.pw-field svg { width: 20px; height: 20px; display: block; }
/* custom URL input (#22) */
.deck .row input[type="text"] { width: 100%; }
.custom-input {
display: block; width: 100%;
border: 1px solid var(--border); border-radius: var(--radius); padding: 7px 12px;
background: var(--bg); color: var(--fg); font: inherit; font-size: 21.6px; outline: none;
}
.custom-input:focus { border-color: var(--accent); }
.custom-input::placeholder { color: var(--muted); }
/* btn-icon svg (#24) */
.btn-icon svg { width: 20px; height: 20px; display: block; }
/* search spinner (#32) */
.search-spinner {
width: 16px; height: 16px; flex-shrink: 0;
border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%;
animation: spin .8s linear infinite; visibility: hidden;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* unlock redesign (#27) */
.unlock-card .pw-field { margin: 18px 0 4px; text-align: left; }
.unlock-card .pw-field input { text-align: left; }
.unlock-err { margin-top: 10px; font-size: 20.7px; color: #ff8fa3; }
/* paste name under slug pill in Paste column (#43) */
.paste-sub { font-size: 19.8px; color: var(--muted-fg); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.paste-sub.dim { color: var(--muted); }
td .url-link { font-size: 19.8px; }
td .id-link { color: var(--muted-fg); text-decoration: none; font-family: var(--font-mono); font-size: 19.8px; }
td .id-link:hover { color: var(--accent); }
/* sortable column headers (#42) */
th.sortable { cursor: pointer; user-select: none; }
th.sortable:hover { color: var(--fg); }
.sort-ind { display: inline-block; width: 0; height: 0; margin-left: 6px; vertical-align: middle; border-left: 5px solid transparent; border-right: 5px solid transparent; }
th.sorted.asc .sort-ind { border-bottom: 6px solid var(--accent); }
th.sorted.desc .sort-ind { border-top: 6px solid var(--accent); }
/* ============================================================
Consistency audit (#18) — shared tokens across inputs, buttons,
headings. Visual-only, no behavior change.
============================================================ */
/* shared text-input treatment: .search input, .pw-field input, #title, #custom */
.search input,
.pw-field input,
.editor-head input#title,
.custom-input,
.row input[type="text"] {
font-size: 21.6px;
color: var(--fg);
}
.search input::placeholder,
.pw-field input::placeholder,
.editor-head input#title::placeholder,
.custom-input::placeholder {
color: var(--muted);
}
.custom-input { border-radius: var(--radius); }
/* buttons (incl. icon-only variants) share one radius + focus ring */
.btn-icon, .iconbtn {
border-radius: var(--radius);
transition: color .12s ease, border-color .12s ease, background .12s ease;
}
.iconbtn:focus-visible, .btn:focus-visible, .btn-icon:focus-visible, .pager button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* in-place copy success feedback (#53) */
.iconbtn.ok, .btn.ok {
color: var(--ok);
border-color: var(--ok);
}
/* headings: unified treatment (mirrors .side-section h3) */
.settings-head h1, .paste-title-bar h1, .head-row h1, .inner h1 {
letter-spacing: -0.01em;
}
/* consistent card padding scale: 12px 18px for wide card heads/bodies */
.settings-head { padding: 12px 18px; }
.settings-body { padding: 16px 18px; }
/* topbar: Git external-link arrow (#56) */
.topbar nav a .ext { width: 14px; height: 14px; margin-left: 4px; opacity: .55; vertical-align: -1px; }
@media (max-width: 640px) {
body { font-size: 16px; }
/* topbar: tighten so logo + nav + gear fit */
.topbar { gap: 10px; padding: 0 12px; height: 56px; }
.logo { font-size: 17px; white-space: nowrap; }
.logo em { display: none; }
.topbar nav { gap: 2px; flex-shrink: 0; }
.topbar nav a { padding: 5px 8px; font-size: 15px; }
.iconbtn.gear { padding: 4px 7px; flex-shrink: 0; }
.iconbtn.gear svg { width: 18px; height: 18px; }
/* new paste: stack editor above sidebar, natural page height */
.deck {
display: flex; flex-direction: column;
height: auto; min-height: calc(100vh - 56px);
padding: 12px; gap: 12px;
}
.pane-l-col { order: 0; }
.pane-r { order: 1; overflow-y: visible; }
.editor-wrap { min-height: 45vh; }
.editor { font-size: 15px; }
.gutter { font-size: 15px; }
.editor-head input { min-width: 0; font-size: 16px; }
.editor-head select { max-width: 120px; font-size: 14px; }
.actionbar { flex-wrap: wrap; }
.actionbar .btn { padding: 12px 22px; }
.hint { font-size: 13px; }
/* history: horizontal-scroll table inside its card */
.page { padding: 12px; }
.head-row { flex-wrap: wrap; }
.head-row h1 { font-size: 22px; }
.search { width: 100%; }
.search input { font-size: 16px; }
.float { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { min-width: 720px; }
th { padding: 8px 10px; font-size: 12px; white-space: nowrap; }
td { padding: 8px 10px; font-size: 14px; }
.pager { flex-wrap: wrap; gap: 8px; font-size: 13px; }
/* paste view */
.paste-title-bar { padding: 10px 12px; gap: 8px; }
.paste-title-bar h1 { font-size: 18px; }
.slug { font-size: 13px; word-break: break-all; }
.stats-head { font-size: 13px; }
.stats-grid { font-size: 13px; padding: 10px 12px; }
.code { font-size: 13px; }
.codebody { padding: 0 12px; }
.footnote { font-size: 12px; padding: 8px 12px; gap: 10px; }
.created-banner { font-size: 13px; }
.iconbtn { font-size: 13px; padding: 6px 10px; }
/* unlock card */
.center { height: auto; min-height: calc(100vh - 56px); padding: 16px; }
.center .float { width: 100%; }
.inner { padding: 20px 16px; }
.inner h1 { font-size: 20px; }
.inner .sub { font-size: 14px; }
.unlock-err { font-size: 13px; }
.center .foot { font-size: 13px; }
}
+163
View File
@@ -0,0 +1,163 @@
// Shared table logic for history (/api/public) and saved (/api/mine) pages (#57).
// Provides: live search, client-side sort with indicators, row rendering via
// a page-supplied rowHtml(), pagination state, and row click-through.
const PaletteTable = (() => {
const $ = id => document.getElementById(id);
const esc = s => { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; };
const fmtSize = n => { if (n == null) return 'none'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; };
const ago = ts => {
const s = Math.floor(Date.now()/1000) - ts;
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s/60) + 'm ago';
if (s < 86400) return Math.floor(s/3600) + 'h ago';
return Math.floor(s/86400) + 'd ago';
};
const sortVal = (it, k) => {
let v = it[k];
if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase();
if (k === 'language') v = (v == null || v === '') ? 'text' : String(v).toLowerCase();
if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v;
return v == null ? null : v;
};
function init(opts) {
// opts: {endpoint, perPage, hasPager, rowHtml(it), emptyFiltered, emptyAll}
const state = { filter: '', sortKey: null, sortDir: 1, page: 1, total: 0 };
let timer = null;
function sortItems(items) {
if (!state.sortKey) return items;
const k = state.sortKey, dir = state.sortDir;
return items.slice().sort((a, b) => {
const va = sortVal(a, k), vb = sortVal(b, k);
const na = va == null, nb = vb == null;
if (na && nb) return 0;
if (na) return 1;
if (nb) return -1;
if (va < vb) return -1 * dir;
if (va > vb) return 1 * dir;
return (a.created_at || 0) < (b.created_at || 0) ? 1 : -1;
});
}
function matches(it) {
if (!state.filter) return true;
const f = state.filter.toLowerCase();
return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f) ||
(it.custom_slug || '').toLowerCase().includes(f);
}
function renderSortIndicators() {
document.querySelectorAll('th.sortable').forEach(th => {
th.classList.toggle('sorted', th.dataset.sort === state.sortKey);
th.classList.toggle('asc', th.dataset.sort === state.sortKey && state.sortDir === 1);
th.classList.toggle('desc', th.dataset.sort === state.sortKey && state.sortDir === -1);
});
}
async function load() {
const spinner = $('search-spinner');
if (spinner) spinner.style.visibility = 'visible';
try {
const filtered = state.filter.length > 0;
const off = (state.page - 1) * opts.perPage;
const url = (filtered || state.sortKey)
? opts.endpoint + '?limit=500&offset=0'
: opts.endpoint + '?limit=' + opts.perPage + '&offset=' + off;
const res = await fetch(url);
const data = await res.json();
state.total = data.total;
let items = filtered ? data.items.filter(matches) : data.items;
items = sortItems(items);
const count = $('count');
if (count) count.textContent = filtered
? items.length.toLocaleString() + ' matches (of ' + state.total.toLocaleString() + ' total)'
: state.total.toLocaleString() + ' total';
const rows = $('rows'), empty = $('empty');
if (!items.length) {
rows.innerHTML = '';
empty.style.display = 'block';
empty.textContent = filtered ? opts.emptyFiltered : opts.emptyAll;
} else {
empty.style.display = 'none';
rows.innerHTML = items.map(opts.rowHtml).join('');
}
const pager = $('pg'), showing = $('showing');
if (opts.hasPager && pager && showing) {
const pages = Math.max(1, Math.ceil(state.total / opts.perPage));
if (filtered || state.sortKey) {
showing.textContent = state.sortKey
? 'Sorted by ' + state.sortKey + ' (' + (state.sortDir === 1 ? 'ascending' : 'descending') + ') · ' + items.length.toLocaleString() + ' of ' + state.total.toLocaleString()
: 'Showing ' + items.length.toLocaleString() + ' matches for "' + state.filter + '"';
pager.innerHTML = '';
} else {
showing.textContent = state.total === 0 ? 'Nothing here yet' :
`Showing ${off+1}${Math.min(off+opts.perPage, state.total)} of ${state.total.toLocaleString()} · page ${state.page} of ${pages}`;
const btns = [];
const add = (label, target, o={}) => btns.push(`<button ${o.on?'class="on"':''} ${o.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', state.page-1, {dis: state.page===1});
const win = new Set([1, 2, state.page-1, state.page, state.page+1, pages]);
let last = 0;
for (let i = 1; i <= pages; i++) {
if (win.has(i)) {
if (last && i - last > 1) btns.push('<span class="dim">…</span>');
add(String(i), i, {on: i===state.page});
last = i;
}
}
add('', state.page+1, {dis: state.page===pages});
pager.innerHTML = btns.join('');
}
} else if (showing) {
showing.textContent = '';
}
renderSortIndicators();
} finally {
if (spinner) spinner.style.visibility = 'hidden';
}
}
document.querySelector('thead').addEventListener('click', e => {
const th = e.target.closest('th.sortable');
if (!th) return;
const k = th.dataset.sort;
if (state.sortKey === k) { state.sortDir = -state.sortDir; } else { state.sortKey = k; state.sortDir = 1; }
renderSortIndicators();
load();
});
const rows = $('rows');
if (rows) rows.addEventListener('click', e => {
const tr = e.target.closest('tr.row[data-href]');
if (!tr || e.target.closest('a') || e.target.closest('button')) return;
window.location.href = tr.dataset.href;
});
const pg = $('pg');
if (pg) pg.addEventListener('click', e => {
const b = e.target.closest('button[data-p]');
if (!b || b.disabled) return;
state.page = parseInt(b.dataset.p);
load();
window.scrollTo(0, 0);
});
const filter = $('filter');
if (filter) filter.addEventListener('input', e => {
clearTimeout(timer);
timer = setTimeout(() => {
state.filter = e.target.value.trim();
state.page = 1;
load();
}, 200);
});
return { load, state, esc, fmtSize, ago };
}
return { init, esc, fmtSize, ago };
})();
+1
View File
@@ -0,0 +1 @@
placeholder
+107
View File
@@ -0,0 +1,107 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Admin</h1>
</div>
<div class="settings-body">
<p>Enter the admin key to manage server settings. The key is kept in
sessionStorage for this tab only and is sent as a request header — it is
never stored in a cookie, so it will not accompany normal paste requests.</p>
<form id="admin-key-form">
<label for="admin-key">Admin key</label><br>
<input type="password" id="admin-key" autocomplete="off" style="width:100%">
<button type="submit">Unlock</button>
<span id="admin-key-status"></span>
</form>
<div id="admin-panel" style="display:none">
<h2>Settings</h2>
<form id="admin-settings-form">
<table>
<tr><td>Rate-limit burst</td><td><input type="number" id="rl-burst" min="1" step="1"></td></tr>
<tr><td>Rate-limit refill per minute</td><td><input type="number" id="rl-refill" min="0.1" step="0.1"></td></tr>
<tr><td>Max content bytes</td><td><input type="number" id="max-content" min="1" step="1"></td></tr>
<tr><td>Default expiry</td><td><input type="text" id="default-expiry" placeholder="e.g. 168h, 30m, 0 = never"></td></tr>
<tr><td>Custom URL reservation days</td><td><input type="number" id="slug-days" min="1" step="1"></td></tr>
<tr><td>Burn viewer window (minutes)</td><td><input type="number" id="burn-window" min="1" step="1"></td></tr>
</table>
<button type="submit">Save</button>
<span id="admin-save-status"></span>
</form>
</div>
</div>
</div>
</div>
<script>
(function () {
var KEY = 'palette_admin_key';
var keyInput = document.getElementById('admin-key');
var status = document.getElementById('admin-key-status');
var panel = document.getElementById('admin-panel');
function key() { return sessionStorage.getItem(KEY) || ''; }
function api(path, opts) {
opts = opts || {};
opts.headers = { 'X-Admin-Key': key() };
if (opts.body) opts.headers['Content-Type'] = 'application/json';
return fetch(path, opts);
}
function loadSettings() {
api('/admin/api/settings').then(function (r) {
if (r.status !== 200) { showLock(); return; }
return r.json();
}).then(function (s) {
if (!s) return;
document.getElementById('rl-burst').value = s.rate_limit_burst;
document.getElementById('rl-refill').value = s.rate_limit_per_minute;
document.getElementById('max-content').value = s.max_content_bytes;
document.getElementById('default-expiry').value = s.default_expiry;
document.getElementById('slug-days').value = s.custom_slug_reservation_days;
document.getElementById('burn-window').value = s.burn_viewer_window_minutes;
panel.style.display = '';
});
}
function showLock() {
panel.style.display = 'none';
sessionStorage.removeItem(KEY);
}
document.getElementById('admin-key-form').addEventListener('submit', function (e) {
e.preventDefault();
sessionStorage.setItem(KEY, keyInput.value);
api('/admin/api/settings').then(function (r) {
if (r.status === 200) {
status.textContent = '✓';
keyInput.value = '';
loadSettings();
} else {
status.textContent = 'invalid key';
showLock();
}
});
});
document.getElementById('admin-settings-form').addEventListener('submit', function (e) {
e.preventDefault();
var body = {
rate_limit_burst: parseFloat(document.getElementById('rl-burst').value),
rate_limit_per_minute: parseFloat(document.getElementById('rl-refill').value),
max_content_bytes: parseInt(document.getElementById('max-content').value, 10),
default_expiry: document.getElementById('default-expiry').value,
custom_slug_reservation_days: parseInt(document.getElementById('slug-days').value, 10),
burn_viewer_window_minutes: parseInt(document.getElementById('burn-window').value, 10)
};
api('/admin/api/settings', { method: 'POST', body: JSON.stringify(body) }).then(function (r) {
document.getElementById('admin-save-status').textContent = r.status === 200 ? 'saved' : 'error';
if (r.status !== 200) showLock();
});
});
if (key()) loadSettings();
})();
</script>
{{template "foot" .}}
+21
View File
@@ -0,0 +1,21 @@
{{define "foot"}}<script>
// live relative-time counters (#46): tick any [data-ts] (epoch seconds) every second
(function () {
function fmt(ts) {
const s = Math.max(0, Math.floor(Date.now() / 1000) - ts);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function tick() {
document.querySelectorAll('[data-ts]').forEach(el => {
const ts = parseInt(el.dataset.ts, 10);
if (!isNaN(ts)) el.textContent = fmt(ts);
});
}
setInterval(tick, 1000);
document.addEventListener('DOMContentLoaded', tick);
tick();
})();
</script>{{end}}
+52
View File
@@ -0,0 +1,52 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Public pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:96px"><col style="width:140px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="view_count" class="sortable"><span class="sort-ind"></span>Views</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes yet. Create the first one.</div>
</div>
<div class="pager float">
<span id="showing"></span>
<div class="pg" id="pg"></div>
</div>
</div>
<script src="/static/table.js"></script>
<script>
const t = PaletteTable.init({
endpoint: '/api/public',
perPage: 25,
hasPager: true,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td></tr>`,
emptyFiltered: 'No pastes match your search.',
emptyAll: 'No pastes yet. Create the first one.',
});
t.load();
setInterval(t.load, 30000); // auto-refresh history every 30s
</script>
{{template "foot" .}}
+28
View File
@@ -0,0 +1,28 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
<script>
// preset preview hook (#16): ?theme=<name> sets data-preset for screenshots only
(function () {
var t = new URLSearchParams(location.search).get('theme');
if (t) document.documentElement.dataset.preset = t;
})();
</script>
{{end}}
{{define "topbar"}}
<div class="topbar">
<a class="logo" href="/history">Palette <em>/ beta</em></a>
<nav>
<a href="/new" {{if eq .Page "new"}}class="on"{{end}}>New</a>
<a href="/history" {{if eq .Page "history"}}class="on"{{end}}>Public</a>
<a href="/mine" {{if eq .Page "mine"}}class="on"{{end}}>Saved</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>
<div class="spacer"></div>
<a class="iconbtn gear" 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>
</a>
</div>
{{end}}
+73
View File
@@ -0,0 +1,73 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Saved pastes</h1>
<span class="count" id="count"></span>
</div>
<div class="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
<div class="float">
<table>
<colgroup><col style="width:260px"><col style="width:140px"><col style="width:120px"><col style="width:150px"><col style="width:190px"><col style="width:100px"></colgroup>
<thead><tr>
<th data-sort="title" class="sortable"><span class="sort-ind"></span>Paste</th>
<th data-sort="language" class="sortable"><span class="sort-ind"></span>Language</th>
<th data-sort="size" class="sortable"><span class="sort-ind"></span>Size</th>
<th data-sort="created_at" class="sortable"><span class="sort-ind"></span>Created</th>
<th data-sort="custom_slug" class="sortable"><span class="sort-ind"></span>URL</th>
<th data-sort="id" class="sortable"><span class="sort-ind"></span>ID</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">No pastes from this browser yet.</div>
</div>
</div>
<script src="/static/table.js"></script>
<script>
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
const t = PaletteTable.init({
endpoint: '/api/mine',
perPage: 50,
hasPager: false,
rowHtml: it =>
`<tr class="row" data-href="/${t.esc(it.id)}"><td>` +
(it.title
? `${t.esc(it.title)}`
: `<a class="slug" href="/${t.esc(it.id)}">${t.esc(it.id)}</a>`) +
`</td>` +
`<td><span class="badge">${t.esc(it.language || 'text')}</span></td>` +
`<td class="dim">${t.fmtSize(it.size)}</td><td class="dim" data-ts="${it.created_at}">${t.ago(it.created_at)}</td>` +
(it.custom_slug ? `<td><a class="slug url-link" href="/${t.esc(it.custom_slug)}">/${t.esc(it.custom_slug)}</a></td>` : `<td class="dim">none</td>`) +
`<td class="dim"><a class="id-link" href="/${t.esc(it.id)}">${t.esc(it.id)}</a></td>` +
`<td><button class="btn btn-icon del" data-id="${t.esc(it.id)}" title="Delete paste" aria-label="Delete paste">&times;</button></td></tr>`,
emptyFiltered: 'No pastes from this browser match your search.',
emptyAll: 'No pastes from this browser yet.',
});
// delete buttons (viewer-scoped, enforced server-side #37)
document.getElementById('rows').addEventListener('click', async e => {
const del = e.target.closest('button.del');
if (!del) return;
e.stopPropagation();
del.disabled = true;
try {
const res = await fetch('/api/pastes/' + del.dataset.id, { method: 'DELETE' });
if (res.ok) { toast('Deleted', 'success'); t.load(); }
else { toast('Delete failed', 'error'); del.disabled = false; }
} catch (err) { toast('Delete failed', 'error'); del.disabled = false; }
});
t.load();
</script>
{{template "foot" .}}
+267
View File
@@ -0,0 +1,267 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="deck">
<div class="pane-l-col">
<div class="float pane-l-head">
<div class="editor-head">
<input id="title" placeholder="Title">
<select id="language">
<option value="">auto</option>
<option>go</option><option>python</option><option>javascript</option><option>typescript</option>
<option>rust</option><option>c</option><option>cpp</option><option>java</option><option>csharp</option>
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
<option>html</option><option>css</option><option>xml</option><option>php</option>
<option>ruby</option><option>perl</option><option>lua</option><option>dockerfile</option>
<option>toml</option><option>ini</option><option>diff</option>
<option>markdown</option><option>text</option>
</select>
<button class="btn btn-icon" id="reguess" title="Re-detect language" type="button"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><polyline points="21 3 21 9 15 9"/></svg></button>
</div>
</div>
<div class="float editor-wrap">
<div class="gutter" id="gutter">1</div>
<textarea class="editor" id="content" placeholder="Paste your code, text, or notes here…" spellcheck="false"></textarea>
</div>
<div class="created-banner" id="created"></div>
<div class="actionbar">
<span class="hint">Ctrl+Enter to create</span>
<div class="spacer" style="flex:1"></div>
<button class="btn" id="create">Create</button>
</div>
</div>
<div class="pane-r">
<div class="float side-section">
<h3>Expiry</h3>
<div class="seg">
<label><input type="radio" name="exp" value=""> Never</label>
<label><input type="radio" name="exp" value="1h"> 1 hour</label>
<label><input type="radio" name="exp" value="24h"> 1 day</label>
<label><input type="radio" name="exp" value="168h" checked> 1 week</label>
<label><input type="radio" name="exp" value="720h"> 30 days</label>
<label><input type="radio" name="exp" value="custom"> Custom</label>
</div>
<div class="pw-row" id="customexp-row" style="display:none">
<input type="number" id="expnum" min="1" style="width:80px" placeholder="90">
<select id="expunit">
<option value="m">minutes</option>
<option value="h" selected>hours</option>
<option value="d">days</option>
<option value="w">weeks</option>
<option value="mo">months</option>
</select>
<div class="hint" id="customexp-err" style="display:none; color:var(--danger, #c0392b); margin-top:6px;"></div>
</div>
</div>
<div class="float side-section">
<h3>Protection</h3>
<div class="protect">
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<div class="pw-row" id="pwrow" style="display:none"><div class="pw-field"><input type="password" id="password" placeholder="Password" autocomplete="new-password"><button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/><line class="eye-slash" x1="4" y1="4" x2="20" y2="20"/></svg></button></div></div>
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<div class="pw-row" id="burnrow" style="display:none"><label class="hint" style="font-size:19px;">Readable <input type="number" id="burnreads" min="1" value="1" style="width:64px"> times</label></div>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<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" id="result-card" style="display:none">
<h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all">empty</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const content = $('content'), gutter = $('gutter');
function updateGutter() {
const lines = content.value.split('\n').length;
let s = '';
for (let i = 1; i <= Math.max(lines, 1); i++) s += i + '\n';
gutter.textContent = s;
}
content.addEventListener('input', updateGutter);
updateGutter();
function toast(msg, kind) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.remove('success', 'error');
if (kind === 'success') t.classList.add('success');
if (kind === 'error') t.classList.add('error');
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
$('burn').addEventListener('change', e => { $('burnrow').style.display = e.target.checked ? 'block' : 'none'; });
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
$('customexp-row').style.display = document.querySelector('input[name="exp"]:checked').value === 'custom' ? 'block' : 'none';
$('customexp-err').style.display = 'none';
}));
// compose the expires_in Go-duration string when Custom is checked (#48).
// Returns the string, or null with an inline error shown.
function composeCustomExpiry() {
const n = parseInt($('expnum').value, 10);
const unit = $('expunit').value;
let mins = NaN;
if (n > 0) {
if (unit === 'm') mins = n;
else if (unit === 'h') mins = n * 60;
else if (unit === 'd') mins = n * 1440;
else if (unit === 'w') mins = n * 10080;
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
}
const err = $('customexp-err');
if (!(mins >= 1)) {
err.textContent = 'Enter a duration of at least 1 minute.';
err.style.display = 'block';
return null;
}
if (mins > 525600) { // more than 1 year
err.textContent = 'Custom expiry cannot exceed 1 year.';
err.style.display = 'block';
return null;
}
err.style.display = 'none';
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
const hours = Math.floor(mins / 60), rem = mins % 60;
if (rem === 0) return hours + 'h';
if (hours === 0) return rem + 'm';
return hours + 'h' + rem + 'm';
}
$('pwreveal').addEventListener('click', () => {
const pw = $('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
$('pwreveal').classList.toggle('off', !show);
$('pwreveal').title = show ? 'Hide password' : 'Show password';
});
let guessed = ''; // last auto-detected language, '' = user override
function showResult(html, isError) {
$('result').innerHTML = html;
$('result').dataset.token = isError ? '' : ($('result').dataset.token || '');
$('result-card').style.display = 'block';
}
function defaultFilename(lang) {
const names = {
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
ini: 'config.ini', diff: 'changes.diff',
markdown: 'notes.md', text: 'Text.txt',
};
return names[lang] || '';
}
// fill default filename when title is still blank
function maybeSetDefaultTitle(lang) {
const title = $('title');
if (lang && !title.value.trim()) {
const fn = defaultFilename(lang);
if (fn) title.value = fn;
}
}
async function guessLang() {
if (!content.value.trim()) return;
try {
const res = await fetch('/api/guess-language', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({content: content.value}),
});
const data = await res.json();
if (res.ok && data.language) {
guessed = data.language;
$('language').value = data.language;
maybeSetDefaultTitle(data.language);
}
} catch(e) {}
}
// refresh button: always re-detect, even if user picked something
$('reguess').addEventListener('click', guessLang);
// auto-guess when pasting into the editor
content.addEventListener('paste', () => setTimeout(guessLang, 0));
async function create() {
const body = {
content: content.value,
title: $('title').value || null,
language: $('language').value || null,
custom_slug: $('custom').value || null,
burn_after_read: $('burn').checked,
};
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
if ($('haspw').checked) body.password = $('password').value;
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; }
body.expires_in = dur;
} else if (exp) {
body.expires_in = exp;
}
const res = await fetch('/api/pastes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
showResult('Error: ' + (data.error || res.status), true);
toast('Create failed', 'error');
return;
}
const url = location.origin + '/' + (data.custom_slug || data.id);
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon" id="result-copy" title="Copy URL" type="button">⧉</button>', false);
$('result').dataset.token = data.deletion_token || '';
const copyBtn = document.getElementById('result-copy');
copyBtn.addEventListener('click', () => {
try {
navigator.clipboard.writeText(url);
copyBtn.classList.add('ok'); // in-place success feedback (#53)
copyBtn.textContent = 'Success!';
setTimeout(() => { copyBtn.classList.remove('ok'); copyBtn.textContent = '⧉'; }, 2000);
} catch(e) { toast('Copy failed', 'error'); }
});
const dest = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
// password-protected: unlock now with the password we already have (#26)
if ($('haspw').checked && data.id) {
const fd = new FormData();
fd.append('password', $('password').value);
fd.append('next', dest);
try {
await fetch('/' + data.id, {method: 'POST', body: fd});
} catch(e) {}
}
// show the paste
location.href = dest;
}
$('create').addEventListener('click', create);
// reset stale result state when returning via Back (bfcache) (#28)
window.addEventListener('pageshow', e => {
if (!e.persisted) return;
const rc = document.getElementById('result-card');
if (rc) rc.style.display = 'none';
const r = document.getElementById('result');
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
</script>
{{template "foot" .}}
+84
View File
@@ -0,0 +1,84 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="paste-title-bar">
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
{{if .CustomSlug}}<span class="slug">/{{.CustomSlug}}</span>{{end}}
<div class="spacer"></div>
<a class="iconbtn" href="/raw/{{.ID}}">raw</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}}
</div>
</div>
<div class="float">
<div class="stats-pill" id="stats-pill">
<button type="button" class="stats-head" id="stats-toggle" aria-expanded="false" onclick="toggleStats()">
<svg class="stats-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
<span class="stats-summary">{{.StatsSummary}}</span>
</button>
<div class="stats-body" id="stats-body" hidden>
<div class="stats-grid">
<span class="stats-k">Language</span><span class="stats-v">{{if .Language}}{{.Language}}{{else}}text{{end}}</span>
<span class="stats-k">Size</span><span class="stats-v">{{.SizeHuman}} ({{.LineCount}} lines)</span>
<span class="stats-k">Views</span><span class="stats-v">{{.ViewCount}}</span>
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
{{if .ExpiresAt}}<span class="stats-k">Expires</span><span class="stats-v">in {{.ExpiresIn}}</span>{{end}}
<span class="stats-k">Password</span><span class="stats-v">{{if .HasPassword}}protected{{else}}none{{end}}</span>
{{if .BurnAfterRead}}{{if .ReadsLimit}}{{with .ReadsLeftN}}<span class="stats-k">Reads left</span><span class="stats-v">{{.}} of {{$.ReadsTotal}}</span>{{end}}{{else}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}{{end}}
{{if .CustomSlug}}<span class="stats-k">Custom URL</span><span class="stats-v">/{{.CustomSlug}}</span>{{end}}
<span class="stats-k">Visibility</span><span class="stats-v">{{.Visibility}}</span>
</div>
</div>
</div>
</div>
{{if .JustCreated}}
<div class="float">
<div class="created-banner" style="display:block">
Paste created. Link copied to clipboard: <a href="/{{.ID}}">{{.Host}}/{{.ID}}</a>
{{if .DeletionToken}} · deletion token: <code>{{.DeletionToken}}</code>{{end}}
</div>
</div>
{{end}}
<div class="float">
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
</div>
</div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script>
function toast(msg) {
let t = document.querySelector('.toast');
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
t.textContent = msg;
t.classList.add('show');
clearTimeout(t._h);
t._h = setTimeout(() => t.classList.remove('show'), 2000);
}
function toggleStats() {
const body = document.getElementById('stats-body');
const pill = document.getElementById('stats-pill');
const btn = document.getElementById('stats-toggle');
const open = body.hidden;
body.hidden = !open;
pill.classList.toggle('open', open);
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
function copyContent(btn) {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
// in-place success feedback (#53)
if (btn) {
btn.classList.add('ok');
btn.textContent = 'Success!';
clearTimeout(btn._okh);
btn._okh = setTimeout(() => { btn.classList.remove('ok'); btn.textContent = 'copy'; }, 2000);
} else {
toast('Copied', 'success');
}
}
function redeem(token) {
if (!confirm('Hard delete this paste immediately?')) return;
fetch('/api/pastes/{{.ID}}/redeem?token=' + encodeURIComponent(token), {method: 'DELETE'})
.then(r => { if (r.ok) location.href = '/history'; else alert('delete failed'); });
}
</script>
{{template "foot" .}}
+13
View File
@@ -0,0 +1,13 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="settings-head">
<h1>Settings</h1>
</div>
<div class="settings-body">
<p>Settings are under construction.</p>
</div>
</div>
</div>
{{template "foot" .}}
+29
View File
@@ -0,0 +1,29 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float unlock-card">
<div class="inner">
<div class="lockring"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div>
<h1>This paste is locked</h1>
<p class="sub">Enter the password to view <span class="slug">/{{.ID}}</span></p>
<form method="post" action="">
<div class="pw-field">
<input type="password" name="password" id="password" placeholder="Password" autocomplete="current-password" autofocus>
<button type="button" class="reveal" id="pwreveal" title="Show password" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg></button>
</div>
{{if .Wrong}}<p class="unlock-err">Wrong password. Try again.</p>{{end}}
<button class="btn" type="submit">Unlock</button>
</form>
</div>
<div class="foot">Created <span data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span></div>
</div>
</div>
<script>
document.getElementById('pwreveal').addEventListener('click', () => {
const pw = document.getElementById('password');
const show = pw.type === 'password';
pw.type = show ? 'text' : 'password';
document.getElementById('pwreveal').title = show ? 'Hide password' : 'Show password';
});
</script>
{{template "foot" .}}
+292
View File
@@ -0,0 +1,292 @@
// Package web serves palette's HTML routes: paste pages, cans, unlock, and
// the admin page. Templates and static assets are embedded in this package.
package web
import (
"crypto/hmac"
cryptorand "crypto/rand"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
langpkg "palette/internal/lang"
"palette/internal/store"
)
//go:embed templates/*.html
var tmplFS embed.FS
//go:embed static
var staticFS embed.FS
type UI struct {
tmpl *template.Template
}
func New() (*UI, error) {
funcs := template.FuncMap{
"humanSize": humanSize,
}
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html")
if err != nil {
return nil, err
}
return &UI{tmpl: t}, nil
}
func humanSize(n int) string {
if n < 1024 {
return fmt.Sprintf("%d B", n)
}
if n < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(n)/1024)
}
return fmt.Sprintf("%.1f MB", float64(n)/(1024*1024))
}
func (u *UI) StaticHandler() http.Handler {
sub, _ := fs.Sub(staticFS, "static")
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
}
func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "template error: "+err.Error(), 500)
}
}
// #34: per-paste unlock tokens. unlockSecret is generated once at startup
// (also derivable from PALETTE_UNLOCK_SECRET for multi-instance deploys) and
// used to HMAC paste ids, so a client can only hold a valid pw_<id> cookie by
// actually submitting the correct password for that paste.
var unlockSecret = resolveUnlockSecret()
func resolveUnlockSecret() []byte {
if v := os.Getenv("PALETTE_UNLOCK_SECRET"); v != "" {
return []byte(v)
}
b := make([]byte, 32)
if _, err := cryptorand.Read(b); err != nil {
log.Fatal("cannot generate unlock secret: ", err)
}
return b
}
func unlockToken(id string) string {
mac := hmac.New(sha256.New, unlockSecret)
mac.Write([]byte("unlock:" + id))
return hex.EncodeToString(mac.Sum(nil))
}
func agoString(ts int64) string {
s := time.Now().Unix() - ts
switch {
case s < 60:
return fmt.Sprintf("%ds ago", s)
case s < 3600:
return fmt.Sprintf("%dm ago", s/60)
case s < 86400:
return fmt.Sprintf("%dh ago", s/3600)
default:
return fmt.Sprintf("%dd ago", s/86400)
}
}
func expiryString(expiresAt int64) string {
s := expiresAt - time.Now().Unix()
switch {
case s < 3600:
return fmt.Sprintf("%dm", s/60)
case s < 86400:
return fmt.Sprintf("%dh", s/3600)
default:
return fmt.Sprintf("%dd", s/86400)
}
}
// Handlers is the set of store callbacks the web pages need. The web package
// renders HTML; all queries go through the store.
type Handlers struct {
UI *UI
Store *store.Store
ViewerID func(r *http.Request) string
BurnWindowMin func() int
RateLimitOK func(id string, r *http.Request) bool // per-paste unlock limiter
}
func (h *Handlers) rateLimitUnlock(id string, r *http.Request) bool {
if h.RateLimitOK != nil {
return h.RateLimitOK(id, r)
}
return true
}
func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSecs))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(429)
w.Write([]byte(`{"error":"rate limit exceeded"}`))
}
func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) {
lines := strings.Count(row.Content, "\n") + 1
gutter := ""
for i := 1; i <= lines; i++ {
gutter += fmt.Sprintf("%d\n", i)
}
expIn := ""
if row.ExpiresAt.Valid {
expIn = expiryString(row.ExpiresAt.Int64)
}
lang := row.Language.String
if lang == "" {
lang = "text"
}
summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt))
data := map[string]any{
"Page": "paste",
"ID": row.ID,
"Title": row.Title.String,
"Language": row.Language.String,
"StatsSummary": summary,
"SizeHuman": humanSize(len(row.Content)),
"HasPassword": row.PasswordHash.Valid,
"BurnAfterRead": row.BurnAfterRead,
"CustomSlug": row.CustomSlug.String,
"ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text
"ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines,
"SizeBytes": len(row.Content),
"CreatedAgo": agoString(row.CreatedAt),
"CreatedAtUnix": row.CreatedAt,
"ViewCount": row.ViewCount,
"Visibility": row.Visibility,
"ExpiresAt": row.ExpiresAt.Valid,
"ExpiresIn": expIn,
"DeletionToken": deletionToken,
"ReadsLimit": row.ReadsLimit.Valid,
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
"ReadsTotal": int(row.ReadsLimit.Int64),
"JustCreated": justCreated,
"Host": "this host",
}
h.renderPage(w, "paste.html", data)
}
// HandlePasteView renders the paste view; supports both ID and custom slug.
func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
row, err := h.Store.GetPaste(id)
if err != nil {
http.Error(w, "db error", 500)
return
}
if row == nil {
http.NotFound(w, r)
return
}
if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
http.Error(w, "paste expired", 404)
return
}
if row.PasswordHash.Valid {
// if a password was submitted via unlock form, verify and set cookie for this paste
if r.Method == http.MethodPost {
if !h.rateLimitUnlock(row.ID, r) {
h.writeRateLimited(w, 60)
return
}
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && store.CheckPassword(row.PasswordHash.String, pw) {
// #34: the unlock cookie must be bound to this specific paste and
// unforgable. A static value ("1") let anyone bypass the password
// by setting pw_<id>=1 for any paste id. The token is an HMAC of
// the paste id under the server's random secret.
http.SetCookie(w, &http.Cookie{
Name: "pw_" + row.ID, Value: unlockToken(row.ID), Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
// re-render without lock, or redirect if ?next= was given (#26)
if next := r.FormValue("next"); next != "" {
// only allow same-origin relative paths
if len(next) > 0 && next[0] == '/' && !strings.HasPrefix(next, "//") {
http.Redirect(w, r, next, http.StatusSeeOther)
return
}
}
h.renderPaste(w, row, false, "", nil)
return
}
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
return
}
// check cookie — must carry the valid per-paste unlock token (#34)
c, err := r.Cookie("pw_" + row.ID)
if err != nil || c.Value != unlockToken(row.ID) {
h.renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
return
}
}
justCreated := r.URL.Query().Get("created") == "1"
token := r.URL.Query().Get("token")
if justCreated && token != "" {
// one-time display of the deletion token via the created banner
http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
// Count the view for every real page render. Raw views increment in
// handleRaw; the HTML path was missing its increment, so view_count only
// ever moved via /raw and the API-stored count stayed at 0 (#33).
// The just-created banner render does not count as a view.
if !justCreated {
h.Store.IncrementViews(row.ID)
}
// #49: burn-after-N-reads budget (per-viewer dedupe window).
// Just-created first render does not count as a read for the creator.
if !justCreated {
rem, _ := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin())
h.renderPaste(w, row, false, "", rem)
return
}
// only pass the token to the template right after creation
h.renderPaste(w, row, true, token, nil)
}
// HandleNewPage serves /new.
func (h *Handlers) HandleNewPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "new.html", map[string]any{"Page": "new"})
}
// HandleHistoryPage serves /history.
func (h *Handlers) HandleHistoryPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "history.html", map[string]any{"Page": "history"})
}
// HandleSettingsPage serves /settings.
func (h *Handlers) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "settings.html", map[string]any{"Page": "settings"})
}
// HandleMinePage serves /mine.
func (h *Handlers) HandleMinePage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "mine.html", map[string]any{"Page": "mine"})
}
// HandleAdminPage serves /admin.
func (h *Handlers) HandleAdminPage(w http.ResponseWriter, r *http.Request) {
h.renderPage(w, "admin.html", map[string]any{"Page": "admin"})
}
// Handlers builds a web.Handlers bound to this UI.
func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} }