Real web UI: /new editor, /history with live pagination, paste view with gutter and deletion banner, /unlock page; size in public API

This commit is contained in:
2026-09-08 16:50:44 -05:00
parent 9ff4d158ad
commit c6233dbbec
12 changed files with 658 additions and 7 deletions
+17 -6
View File
@@ -66,6 +66,7 @@ type PasteRow struct {
CreatedAt int64 CreatedAt int64
DeletedAt sql.NullInt64 DeletedAt sql.NullInt64
ViewCount int ViewCount int
Size int
DeletionToken sql.NullString DeletionToken sql.NullString
} }
@@ -230,7 +231,7 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) {
} }
func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count FROM pastes rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content) FROM pastes
WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)
ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset) ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), limit, offset)
if err != nil { if err != nil {
@@ -241,7 +242,7 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) {
for rows.Next() { for rows.Next() {
var r PasteRow var r PasteRow
var cs, lang, title sql.NullString var cs, lang, title sql.NullString
if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount); err != nil { if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size); err != nil {
return nil, 0, err return nil, 0, err
} }
r.CustomSlug = cs r.CustomSlug = cs
@@ -337,9 +338,14 @@ func (a *apiServer) routes() http.Handler {
// raw // raw
r.Get("/raw/{id}", a.handleRaw) r.Get("/raw/{id}", a.handleRaw)
// web (minimal for now) // web pages
r.Get("/", a.handleHome) r.Get("/", http.RedirectHandler("/history", http.StatusFound).ServeHTTP)
r.Get("/{id}", a.handlePastePage) r.Get("/new", a.handleNewPage)
r.Get("/history", a.handleHistoryPage)
r.Handle("/static/*", staticHandler())
r.Get("/unlock/{id}", a.handlePasteView)
r.Post("/unlock/{id}", a.handlePasteView)
r.Get("/{id}", a.handlePasteView)
r.NotFound(func(w http.ResponseWriter, r *http.Request) { r.NotFound(func(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "not found") writeErr(w, 404, "not found")
@@ -447,7 +453,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) {
lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title) lang, title := nullStrPtr(row.Language), nullStrPtr(row.Title)
items = append(items, map[string]any{ items = append(items, map[string]any{
"id": row.ID, "title": title, "language": lang, "id": row.ID, "title": title, "language": lang,
"created_at": row.CreatedAt, "view_count": row.ViewCount, "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size,
}) })
} }
writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items}) writeJSON(w, 200, map[string]any{"total": total, "limit": limit, "offset": offset, "items": items})
@@ -538,6 +544,11 @@ func main() {
} }
store.StartSweeper(time.Minute) store.StartSweeper(time.Minute)
ui, err := NewWebUI()
if err != nil {
log.Fatal(err)
}
webUIInstance = ui
srv := &apiServer{store: store, cfg: cfg} srv := &apiServer{store: store, cfg: cfg}
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()))
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+183
View File
@@ -0,0 +1,183 @@
package main
import (
"embed"
"fmt"
"html/template"
"io/fs"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
//go:embed web/templates/*.html
var tmplFS embed.FS
//go:embed web/static
var staticFS embed.FS
type webUI struct {
tmpl *template.Template
}
func NewWebUI() (*webUI, error) {
funcs := template.FuncMap{
"humanSize": humanSize,
}
t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "web/templates/*.html")
if err != nil {
return nil, err
}
return &webUI{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 staticHandler() http.Handler {
sub, _ := fs.Sub(staticFS, "web/static")
return http.StripPrefix("/static/", http.FileServer(http.FS(sub)))
}
func renderPage(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := webUIInstance.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "template error: "+err.Error(), 500)
}
}
var webUIInstance *webUI
func (a *apiServer) handleNewPage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "new.html", map[string]any{"Page": "new"})
}
func (a *apiServer) handleHistoryPage(w http.ResponseWriter, r *http.Request) {
renderPage(w, "history.html", map[string]any{"Page": "history"})
}
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)
}
}
func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string) {
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)
}
data := map[string]any{
"Page": "paste",
"ID": row.ID,
"Title": row.Title.String,
"Language": row.Language.String,
"ContentHTML": template.HTMLEscapeString(row.Content),
"ContentAttr": row.Content,
"Gutter": strings.TrimSuffix(gutter, "\n"),
"LineCount": lines,
"SizeBytes": len(row.Content),
"CreatedAgo": agoString(row.CreatedAt),
"ViewCount": row.ViewCount,
"Visibility": row.Visibility,
"ExpiresAt": row.ExpiresAt.Valid,
"ExpiresIn": expIn,
"DeletionToken": deletionToken,
"JustCreated": justCreated,
"Host": "this host",
}
renderPage(w, "paste.html", data)
}
// handlePastePage renders the paste view; supports both ID and custom slug.
func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
row, err := a.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 {
r.ParseForm()
pw := r.FormValue("password")
if pw != "" && checkPassword(row.PasswordHash.String, pw) {
http.SetCookie(w, &http.Cookie{
Name: "pw_" + row.ID, Value: "1", Path: "/",
MaxAge: 3600, HttpOnly: true, SameSite: http.SameSiteLaxMode,
})
// re-render without lock
a.renderPaste(w, row, false, "")
return
}
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt)})
return
}
// check cookie
c, err := r.Cookie("pw_" + row.ID)
if err != nil || c.Value != "1" {
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": false, "CreatedAgo": agoString(row.CreatedAt)})
return
}
}
a.store.IncrementViews(row.ID)
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})
}
// only pass the token to the template right after creation
if justCreated {
a.renderPaste(w, row, true, token)
return
}
a.renderPaste(w, row, false, "")
}
var _ = strconv.Itoa
+184 -1
View File
@@ -1 +1,184 @@
/* palette static assets placeholder */ /* 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: 14px;
--font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", monospace;
}
[data-preset="smooth"] {
--bg: #F6F5FA; --surface: #FFFFFF; --surface-2: #DAD7E6;
--muted: #B5B1C9; --muted-fg: #7A7796; --fg: #2A2A36;
--accent: #7A7796; --border: #DAD7E6;
}
* { 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: 14px;
}
.topbar {
display: flex; align-items: center; gap: 20px;
padding: 0 20px; height: 52px;
background: var(--surface); border-bottom: 1px solid var(--border);
}
.logo { font-weight: 700; font-size: 16px; 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: 8px; font-size: 13.5px; }
.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: 11px; border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; color: var(--muted-fg); }
.float {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 2px 6px rgba(20,14,32,.25), 0 12px 32px rgba(20,14,32,.3);
overflow: hidden;
}
/* new paste page */
.deck {
display: grid; grid-template-columns: 1fr 300px; gap: 16px;
padding: 16px 20px; height: calc(100vh - 52px);
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: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
.pane-l { display: flex; flex-direction: column; }
.editor-head {
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
border-bottom: 1px solid var(--border);
}
.editor-head input {
border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13.5px; flex: 1;
}
.editor-head select {
border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg);
border-radius: 7px; padding: 4px 10px; font: inherit; font-size: 12.5px; cursor: pointer;
}
.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: 12.5px; line-height: 1.7; 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: 12.5px; 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: 12px 16px;
border-top: 1px solid var(--border);
}
.btn {
background: var(--accent); color: var(--bg); border: none; cursor: pointer;
padding: 8px 18px; border-radius: 8px; font: inherit; font-size: 13px; font-weight: 600;
}
.btn:hover { filter: brightness(1.08); }
.hint { font-size: 12px; 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: 7px; cursor: pointer; font-size: 13px; }
.seg label:hover { background: var(--surface-2); }
.seg input { accent-color: var(--accent); }
.toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; padding: 5px 8px; border-radius: 7px; }
.toggle:hover { background: var(--surface-2); }
.toggle input { accent-color: var(--accent); }
.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; padding: 4px 0; }
.row input[type="text"] {
border: 1px solid var(--border); border-radius: 7px; padding: 5px 8px; background: var(--bg);
color: var(--fg); font: inherit; font-size: 12.5px; width: 130px;
}
.created-banner {
display: none; padding: 10px 16px; font-size: 13px; 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: 17px; font-weight: 600; }
.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--muted-fg); background: var(--surface-2); padding: 3px 9px; border-radius: 7px; }
.tag { font-size: 11.5px; color: var(--muted-fg); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; }
.meta-bar .spacer { flex: 1; }
.iconbtn { border: 1px solid var(--border); background: var(--surface-2); color: var(--muted-fg); border-radius: 8px; padding: 5px 12px; font: inherit; font-size: 12.5px; cursor: pointer; text-decoration: none; }
.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: 12.5px; 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: 13px; line-height: 1.7;
padding: 14px 0; display: flex; overflow-x: auto;
}
.code .gutter { flex-shrink: 0; }
.codebody { padding: 0 18px; white-space: pre; }
.footnote { display: flex; gap: 20px; padding: 10px 18px; font-size: 12px; color: var(--muted-fg); border-top: 1px solid var(--border); flex-wrap: wrap; }
/* history */
.page { max-width: 860px; 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: 20px; font-weight: 600; }
.search {
display: flex; align-items: center; gap: 8px; background: var(--surface);
border: 1px solid var(--border); border-radius: 10px; padding: 8px 14px; width: 260px;
}
.search input { border: none; outline: none; background: transparent; color: var(--fg); font: inherit; font-size: 13px; width: 100%; }
table { width: 100%; border-collapse: collapse; font-size: 13px; table-layout: fixed; }
th:nth-child(1), td:nth-child(1) { width: 130px; }
th:nth-child(2), td:nth-child(2) { width: auto; }
th:nth-child(3), td:nth-child(3) { width: 80px; }
th:nth-child(4), td:nth-child(4) { width: 80px; }
th:nth-child(5), td:nth-child(5) { width: 64px; }
th:nth-child(6), td:nth-child(6) { width: 90px; }
th {
text-align: left; font-size: 11px; 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); }
tr:last-child td { border-bottom: none; }
tr.row:hover td { background: var(--surface-2); }
td a.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); text-decoration: none; }
td a.slug:hover { color: var(--accent); }
.badge { font-size: 11px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: 999px; 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: 12.5px; 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: 7px; padding: 4px 11px; font: inherit; font-size: 12.5px; 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: 13px; }
/* 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: 24px;
}
.inner h1 { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
.inner .sub { font-size: 13px; color: var(--muted-fg); margin-bottom: 20px; }
.pwinput {
width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 9px;
background: var(--bg); color: var(--fg); font: inherit; font-size: 13.5px; 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: 12.5px; color: #ff8fa3; }
.center .foot { font-size: 12px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
+1
View File
@@ -0,0 +1 @@
{{define "foot"}}{{end}}
+89
View File
@@ -0,0 +1,89 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="head-row">
<h1>Public pastes</h1>
<span class="count" id="count"></span>
<div class="spacer"></div>
<div class="search"><input id="filter" placeholder="filter…"></div>
</div>
<div class="float">
<table>
<thead><tr><th>Paste</th><th>Description</th><th>Language</th><th>Size</th><th>Views</th><th>Created</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 class="pager">
<span id="showing"></span>
<div class="pg" id="pg"></div>
</div>
</div>
</div>
<script>
const PER = 25;
let page = 1, total = 0;
const $ = id => document.getElementById(id);
function esc(s) { const d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function fmtSize(n) { if (n == null) return '—'; if (n < 1024) return n + ' B'; if (n < 1048576) return (n/1024).toFixed(1) + ' KB'; return (n/1048576).toFixed(1) + ' MB'; }
function 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';
}
async function load() {
const off = (page - 1) * PER;
const res = await fetch('/api/public?limit=' + PER + '&offset=' + off);
const data = await res.json();
total = data.total;
$('count').textContent = total.toLocaleString() + ' total';
const rows = $('rows');
if (data.items.length === 0) {
rows.innerHTML = '';
$('empty').style.display = 'block';
} else {
$('empty').style.display = 'none';
rows.innerHTML = data.items.map(it =>
`<tr class="row"><td><a class="slug" href="/${esc(it.id)}">${esc(it.id)}</a></td>` +
`<td class="title-cell">${it.title ? esc(it.title) : '<span class=dim>—</span>'}</td>` +
`<td><span class="badge">${esc(it.language || 'text')}</span></td>` +
`<td class="dim">${fmtSize(it.size)}</td><td class="dim">${it.view_count}</td><td class="dim">${ago(it.created_at)}</td></tr>`
).join('');
}
const pages = Math.max(1, Math.ceil(total / PER));
$('showing').textContent = total === 0 ? 'Nothing here yet' :
`Showing ${off+1}${Math.min(off+PER, total)} of ${total.toLocaleString()} · page ${page} of ${pages}`;
const btns = [];
const add = (label, target, opts={}) => btns.push(`<button ${opts.on?'class="on"':''} ${opts.dis?'disabled':''} data-p="${target}">${label}</button>`);
add('', page-1, {dis: page===1});
const win = new Set([1, 2, page-1, page, 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===page});
last = i;
}
}
add('', page+1, {dis: page===pages});
$('pg').innerHTML = btns.join('');
}
$('pg').addEventListener('click', e => {
const b = e.target.closest('button[data-p]');
if (!b || b.disabled) return;
page = parseInt(b.dataset.p);
load();
window.scrollTo(0, 0);
});
$('filter').addEventListener('input', () => { page = 1; load(); });
load();
setInterval(load, 30000); // auto-refresh history every 30s
</script>
{{template "foot" .}}
+17
View File
@@ -0,0 +1,17 @@
{{define "head"}}
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/app.css">
{{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}}>history</a>
<a href="https://git.archfox.org/poslop/palette">git</a>
</nav>
<div class="spacer"></div>
</div>
{{end}}
+104
View File
@@ -0,0 +1,104 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="deck">
<div class="float pane-l">
<div class="editor-head">
<input id="title" placeholder="title (optional)">
<select id="language">
<option value="">auto</option>
<option>go</option><option>python</option><option>javascript</option>
<option>rust</option><option>c</option><option>cpp</option><option>java</option>
<option>bash</option><option>sql</option><option>yaml</option><option>json</option>
<option>markdown</option><option>text</option>
</select>
</div>
<div class="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>
</div>
</div>
<div class="float side-section">
<h3>Protection</h3>
<label class="toggle"><input type="checkbox" id="haspw"> Password lock</label>
<input type="password" id="password" class="pwinput" placeholder="password" style="display:none; margin: 6px 8px 0; width: auto;">
<label class="toggle"><input type="checkbox" id="burn"> Burn after read</label>
<label class="toggle"><input type="checkbox" id="unlisted"> Unlisted</label>
</div>
<div class="float side-section">
<h3>Custom URL</h3>
<div class="row"><span>/</span><input type="text" id="custom" placeholder="my-snippet"></div>
</div>
<div class="float side-section">
<h3>Result</h3>
<div class="hint" id="result" style="word-break:break-all"></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();
$('haspw').addEventListener('change', e => { $('password').style.display = e.target.checked ? 'block' : 'none'; });
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 ($('haspw').checked) body.password = $('password').value;
const exp = document.querySelector('input[name="exp"]:checked').value;
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) {
$('result').textContent = 'Error: ' + (data.error || res.status);
return;
}
const url = location.origin + '/' + (data.custom_slug || data.id);
$('result').innerHTML = '<a href="' + url + '">' + url + '</a>';
$('result').dataset.token = data.deletion_token || '';
try { navigator.clipboard.writeText(url); } catch(e) {}
// show the paste
location.href = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
}
$('create').addEventListener('click', create);
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
});
</script>
{{template "foot" .}}
+45
View File
@@ -0,0 +1,45 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="page">
<div class="float">
<div class="meta-bar">
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
<span class="slug">/{{.ID}}</span>
{{if .Language}}<span class="tag">{{.Language}}</span>{{end}}
{{if .ExpiresAt}}<span class="tag">expires in {{.ExpiresIn}}</span>{{end}}
<div class="spacer"></div>
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
<a class="iconbtn" href="#" onclick="copyContent(); return false;">copy</a>
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
</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-head"><span class="dot"></span> {{.LineCount}} lines · {{.SizeBytes}} bytes</div>
<div class="code"><div class="gutter">{{.Gutter}}</div><div class="codebody" id="codebody">{{.ContentHTML}}</div></div>
<div class="footnote">
<span>Created {{.CreatedAgo}}</span>
<span>{{.ViewCount}} views</span>
<span>{{.Visibility}}</span>
</div>
</div>
</div>
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
<script>
function copyContent() {
navigator.clipboard.writeText(document.getElementById('raw-content').value);
}
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" .}}
+18
View File
@@ -0,0 +1,18 @@
{{template "head" .}}
{{template "topbar" .}}
<div class="center">
<div class="float">
<div class="inner">
<div class="lockring">🔒</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="/unlock/{{.ID}}">
<input type="password" name="password" class="pwinput" placeholder="••••••••" autofocus>
{{if .Wrong}}<p class="err" style="display:block">Wrong password. Try again.</p>{{end}}
<button class="btn" type="submit">Unlock</button>
</form>
</div>
<div class="foot">Created {{.CreatedAgo}}</div>
</div>
</div>
{{template "foot" .}}