Compare commits
17
Commits
v0.1.0
..
9d75d2f80d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d75d2f80d | ||
|
|
6b21f997b5 | ||
|
|
103a7a6af5 | ||
|
|
1f162c4003 | ||
|
|
0bbe65ce05 | ||
|
|
db17bd20b0 | ||
|
|
f542ce0407 | ||
|
|
c32a6e406d | ||
|
|
ec8f75d80b | ||
|
|
efa551c566 | ||
|
|
3e0e64dc4f | ||
|
|
4a467ca999 | ||
|
|
026d9b8c92 | ||
|
|
4d98a92b09 | ||
|
|
603b807c51 | ||
|
|
acafc13d20 | ||
|
|
176ef77737 |
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [debian-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
# build & push image only on tags (releases)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [debian-latest]
|
||||
env:
|
||||
# dind sidecar listens on tcp; job containers reach it via the docker bridge gateway
|
||||
DOCKER_HOST: tcp://172.17.0.1:2375
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
__pycache__/
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
palette
|
||||
palette.db
|
||||
palette.db-shm
|
||||
palette.db-wal
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// guessLang heuristically detects a language from source content.
|
||||
func guessLang(s string) string {
|
||||
src := strings.TrimSpace(s)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// JSON: must start with { or [ and parse
|
||||
if src[0] == '{' || src[0] == '[' {
|
||||
var v any
|
||||
if json.Unmarshal([]byte(src), &v) == nil {
|
||||
return "json"
|
||||
}
|
||||
}
|
||||
|
||||
rules := []struct {
|
||||
lang string
|
||||
re *regexp.Regexp
|
||||
}{
|
||||
{"python", regexp.MustCompile(`(?m)^\s*(def |class |import |from \w+ import |@decorator)`)},
|
||||
{"python", regexp.MustCompile(`(^|\n)\s*#!.*python`)},
|
||||
{"go", regexp.MustCompile(`(?m)^\s*(package \w+|import \(|func (\w+|\() )`)},
|
||||
{"rust", regexp.MustCompile(`(?m)(\bfn \w+|let mut \b|\bimpl \b|\bmatch \w+ \{|use std::)`)},
|
||||
{"javascript", regexp.MustCompile(`(?m)(\bconst \w+ = |require\(|import \w+ from |=> \{|\bconsole\.log\()` )},
|
||||
{"java", regexp.MustCompile(`(?m)(\bpublic (static |final |class )|\bSystem\.out\.print|import java\.)`)},
|
||||
{"c", regexp.MustCompile(`(?m)(#include\s*<\w+\.h>|printf\(|\bint main\()` )},
|
||||
{"cpp", regexp.MustCompile(`(?m)(#include\s*<(iostream|vector|string)>|std::|\bcout\s*<<)`)},
|
||||
{"bash", regexp.MustCompile(`(?m)^(#!.*bash|#!.*sh|\w+\(\)\s*\{)` )},
|
||||
{"sql", regexp.MustCompile(`(?i)\b(SELECT .+ FROM|INSERT INTO|CREATE TABLE|UPDATE \w+ SET)\b`)},
|
||||
{"yaml", regexp.MustCompile(`(?m)^(\w[\w-]*:\s*(\||\S)| \w[\w-]*: |---\s*$)`)},
|
||||
{"markdown", regexp.MustCompile("(?m)^(#{1,6} \\S|\\|.*\\||-\\s\\[\\s?\\]|```)")},
|
||||
}
|
||||
|
||||
scores := map[string]int{}
|
||||
for _, r := range rules {
|
||||
n := len(r.re.FindAllString(src, -1))
|
||||
if n > 0 {
|
||||
// later generic rules shouldn't drown out earlier specific ones
|
||||
scores[r.lang] += n
|
||||
}
|
||||
}
|
||||
|
||||
best, bestN := "", 0
|
||||
for lang, n := range scores {
|
||||
if n > bestN {
|
||||
best, bestN = lang, n
|
||||
}
|
||||
}
|
||||
if bestN == 0 {
|
||||
return "text"
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func (a *apiServer) handleGuessLang(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "invalid json body")
|
||||
return
|
||||
}
|
||||
lang := guessLang(req.Content)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"language": lang})
|
||||
}
|
||||
@@ -23,6 +23,7 @@ var webFS embed.FS
|
||||
|
||||
const (
|
||||
softDeleteGraceDays = 7
|
||||
customSlugReservationDays = 30
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -272,11 +273,34 @@ func (s *Store) SweepExpired() {
|
||||
s.db.Exec(`DELETE FROM pastes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, grace)
|
||||
}
|
||||
|
||||
// ReleaseCustomSlugs frees custom URLs so they can be reused:
|
||||
// - pastes whose expires_at has passed (expired or soft-deleted/expired),
|
||||
// - pastes created more than 30 days ago (custom URLs are a reservation, not permanent).
|
||||
//
|
||||
// It returns the number of pastes whose custom_slug was released.
|
||||
func (s *Store) ReleaseCustomSlugs() (int64, error) {
|
||||
now := time.Now().Unix()
|
||||
res, err := s.db.Exec(`UPDATE pastes SET custom_slug = NULL
|
||||
WHERE custom_slug IS NOT NULL
|
||||
AND (expires_at IS NOT NULL AND expires_at > 0 AND expires_at < ?
|
||||
OR created_at < ?)`,
|
||||
now, now-customSlugReservationDays*86400)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
log.Printf("released %d custom slug(s)", n)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *Store) StartSweeper(every time.Duration) {
|
||||
go func() {
|
||||
t := time.NewTicker(every)
|
||||
for range t.C {
|
||||
s.SweepExpired()
|
||||
s.ReleaseCustomSlugs()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -327,6 +351,7 @@ func (a *apiServer) routes() http.Handler {
|
||||
r.Delete("/pastes/{id}", a.handleDeletePaste)
|
||||
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
|
||||
r.Get("/public", a.handleListPublic)
|
||||
r.Post("/guess-language", a.handleGuessLang)
|
||||
r.Post("/pastes/can", a.handleCreateCan)
|
||||
r.Get("/cans/{id}", a.handleGetCan)
|
||||
r.Get("/cans/{id}/items/{item}", a.handleCanItem)
|
||||
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// insertPasteWithSlug creates a paste directly with a custom slug and controlled
|
||||
// created_at/expires_at, bypassing the API's timestamp handling.
|
||||
func insertPasteWithSlug(t *testing.T, s *Store, slug string, createdAt, expiresAt int64) string {
|
||||
t.Helper()
|
||||
id := genSlug(6)
|
||||
_, err := s.db.Exec(`INSERT INTO pastes (id, custom_slug, content, content_type, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`, id, slug, "x", "text/plain", createdAt, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func TestReleaseSlugOnExpiredPaste(t *testing.T) {
|
||||
s := testServer(t)
|
||||
now := time.Now().Unix()
|
||||
insertPasteWithSlug(t, s.store, "release-notes", now-3600, now-60)
|
||||
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
|
||||
t.Fatalf("released %d err %v, want 1", n, err)
|
||||
}
|
||||
if taken, _ := s.store.SlugTaken("release-notes"); taken {
|
||||
t.Fatal("slug should be released after expiry")
|
||||
}
|
||||
// slug must be reusable by a new paste
|
||||
p, err := s.store.CreatePaste(&Paste{Content: "new", CustomSlug: strPtr("release-notes")})
|
||||
if err != nil {
|
||||
t.Fatalf("reuse slug: %v", err)
|
||||
}
|
||||
if p.CustomSlug == nil || *p.CustomSlug != "release-notes" {
|
||||
t.Fatal("new paste did not claim released slug")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseSlugOnOldPaste(t *testing.T) {
|
||||
s := testServer(t)
|
||||
now := time.Now().Unix()
|
||||
// created 31 days ago, no expiry -> released by 30-day reservation rule
|
||||
insertPasteWithSlug(t, s.store, "old-url", now-31*86400, 0)
|
||||
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 1 {
|
||||
t.Fatalf("released %d err %v, want 1", n, err)
|
||||
}
|
||||
if taken, _ := s.store.SlugTaken("old-url"); taken {
|
||||
t.Fatal("slug should be released after 30-day reservation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepSlugOnRecentUnexpiredPaste(t *testing.T) {
|
||||
s := testServer(t)
|
||||
now := time.Now().Unix()
|
||||
insertPasteWithSlug(t, s.store, "fresh-url", now-3600, now+86400)
|
||||
insertPasteWithSlug(t, s.store, "fresh-url2", now-3600, 0)
|
||||
if n, err := s.store.ReleaseCustomSlugs(); err != nil || n != 0 {
|
||||
t.Fatalf("released %d err %v, want 0", n, err)
|
||||
}
|
||||
for _, slug := range []string{"fresh-url", "fresh-url2"} {
|
||||
if taken, _ := s.store.SlugTaken(slug); !taken {
|
||||
t.Fatalf("slug %q should still be held", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweeperTickerReleasesSlugs(t *testing.T) {
|
||||
s := testServer(t)
|
||||
now := time.Now().Unix()
|
||||
insertPasteWithSlug(t, s.store, "ticker-url", now-7200, now-3600)
|
||||
s.store.StartSweeper(10 * time.Millisecond)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if taken, _ := s.store.SlugTaken("ticker-url"); !taken {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("ticker did not release slug in time")
|
||||
}
|
||||
+147
-59
@@ -3,7 +3,7 @@
|
||||
--bg: #241B30; --surface: #2D2340; --surface-2: #3A2D52;
|
||||
--muted: #7A6A9E; --muted-fg: #C0B2DE; --fg: #F2EDF8;
|
||||
--accent: #C4A8F0; --border: #42355C;
|
||||
--radius: 14px;
|
||||
--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;
|
||||
}
|
||||
@@ -21,147 +21,147 @@ body {
|
||||
line-height: 1.45;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
font-size: 14px;
|
||||
font-size: 24.2px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 20px;
|
||||
padding: 0 20px; height: 52px;
|
||||
padding: 0 22px; height: 76px;
|
||||
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 { 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: 8px; font-size: 13.5px; }
|
||||
.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: 11px; border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px; color: var(--muted-fg); }
|
||||
.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);
|
||||
box-shadow: 0 2px 6px rgba(20,14,32,.25), 0 12px 32px rgba(20,14,32,.3);
|
||||
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; height: calc(100vh - 52px);
|
||||
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: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted-fg); margin-bottom: 10px; }
|
||||
.pane-l { display: flex; flex-direction: column; }
|
||||
.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: 10px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
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: 13.5px; flex: 1;
|
||||
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: 7px; padding: 4px 10px; font: inherit; font-size: 12.5px; cursor: pointer;
|
||||
border-radius: var(--radius); padding: 4px 10px; font: inherit; font-size: 21.6px; 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;
|
||||
font-size: 21.6px; 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;
|
||||
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: 12px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
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: 8px; font: inherit; font-size: 13px; font-weight: 600;
|
||||
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: 12px; color: var(--muted-fg); }
|
||||
.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: 7px; cursor: pointer; font-size: 13px; }
|
||||
.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: 13px; cursor: pointer; padding: 5px 8px; border-radius: 7px; }
|
||||
.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); }
|
||||
.row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; padding: 4px 0; }
|
||||
.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: 7px; padding: 5px 8px; background: var(--bg);
|
||||
color: var(--fg); font: inherit; font-size: 12.5px; width: 130px;
|
||||
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: 13px; background: var(--surface-2);
|
||||
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: 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 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; }
|
||||
.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 { 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: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);
|
||||
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: 13px; line-height: 1.7;
|
||||
font-family: var(--font-mono); font-size: 22.4px; 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; }
|
||||
.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: 860px; margin: 0 auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.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: 20px; font-weight: 600; }
|
||||
.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: 10px; padding: 8px 14px; width: 260px;
|
||||
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: 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; }
|
||||
.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: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||
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); }
|
||||
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); }
|
||||
td a.slug { font-family: var(--font-mono); font-size: 12.5px; color: var(--fg); text-decoration: none; }
|
||||
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: 11px; border: 1px solid var(--border); color: var(--muted-fg); border-radius: 999px; padding: 1px 8px; }
|
||||
.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: 12.5px; color: var(--muted-fg); }
|
||||
.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: 7px; padding: 4px 11px; font: inherit; font-size: 12.5px; cursor: pointer; }
|
||||
.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: 13px; }
|
||||
.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); }
|
||||
@@ -169,16 +169,104 @@ td a.slug:hover { color: var(--accent); }
|
||||
.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;
|
||||
background: var(--surface-2); display: flex; align-items: center; justify-content: center; font-size: 41.4px;
|
||||
}
|
||||
.inner h1 { font-size: 17px; font-weight: 600; margin-bottom: 6px; }
|
||||
.inner .sub { font-size: 13px; color: var(--muted-fg); margin-bottom: 20px; }
|
||||
.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: 9px;
|
||||
background: var(--bg); color: var(--fg); font: inherit; font-size: 13.5px; outline: none; text-align: center;
|
||||
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: 12.5px; color: #ff8fa3; }
|
||||
.center .foot { font-size: 12px; color: var(--muted-fg); padding: 14px; border-top: 1px solid var(--border); }
|
||||
.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); }
|
||||
|
||||
/* 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; }
|
||||
|
||||
+53
-12
@@ -4,28 +4,28 @@
|
||||
<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="search"><input id="filter" placeholder="Search…"><span class="search-spinner" id="search-spinner"></span></div>
|
||||
<div class="float">
|
||||
<table>
|
||||
<colgroup><col style="width:220px"><col><col style="width:156px"><col style="width:138px"><col style="width:111px"><col style="width:165px"></colgroup>
|
||||
<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">
|
||||
</div>
|
||||
<div class="pager float">
|
||||
<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 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'; }
|
||||
function ago(ts) {
|
||||
const s = Math.floor(Date.now()/1000) - ts;
|
||||
if (s < 60) return s + 's ago';
|
||||
@@ -34,28 +34,52 @@ function ago(ts) {
|
||||
return Math.floor(s/86400) + 'd ago';
|
||||
}
|
||||
|
||||
let filter = '';
|
||||
|
||||
function matchesFilter(it) {
|
||||
if (!filter) return true;
|
||||
const f = filter.toLowerCase();
|
||||
return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const spinner = document.getElementById('search-spinner');
|
||||
spinner.style.visibility = 'visible';
|
||||
try {
|
||||
const filtered = filter.length > 0;
|
||||
const off = (page - 1) * PER;
|
||||
const res = await fetch('/api/public?limit=' + PER + '&offset=' + off);
|
||||
const url = filtered
|
||||
? '/api/public?limit=200&offset=0'
|
||||
: '/api/public?limit=' + PER + '&offset=' + off;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
total = data.total;
|
||||
|
||||
$('count').textContent = total.toLocaleString() + ' total';
|
||||
const items = filtered ? data.items.filter(matchesFilter) : data.items;
|
||||
|
||||
$('count').textContent = filtered
|
||||
? items.length.toLocaleString() + ' matches (of ' + total.toLocaleString() + ' total)'
|
||||
: total.toLocaleString() + ' total';
|
||||
const rows = $('rows');
|
||||
if (data.items.length === 0) {
|
||||
if (items.length === 0) {
|
||||
rows.innerHTML = '';
|
||||
$('empty').style.display = 'block';
|
||||
$('empty').textContent = filtered ? 'No pastes match your search.' : 'No pastes yet. Create the first one.';
|
||||
} 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>` +
|
||||
rows.innerHTML = items.map(it =>
|
||||
`<tr class="row" data-href="/${esc(it.id)}"><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>none</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));
|
||||
if (filtered) {
|
||||
$('showing').textContent = 'Showing ' + items.length.toLocaleString() + ' matches for "' + filter + '"';
|
||||
$('pg').innerHTML = '';
|
||||
} else {
|
||||
$('showing').textContent = total === 0 ? 'Nothing here yet' :
|
||||
`Showing ${off+1}–${Math.min(off+PER, total)} of ${total.toLocaleString()} · page ${page} of ${pages}`;
|
||||
|
||||
@@ -74,6 +98,10 @@ async function load() {
|
||||
add('›', page+1, {dis: page===pages});
|
||||
$('pg').innerHTML = btns.join('');
|
||||
}
|
||||
} finally {
|
||||
spinner.style.visibility = 'hidden';
|
||||
}
|
||||
}
|
||||
|
||||
$('pg').addEventListener('click', e => {
|
||||
const b = e.target.closest('button[data-p]');
|
||||
@@ -82,7 +110,20 @@ $('pg').addEventListener('click', e => {
|
||||
load();
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
$('filter').addEventListener('input', () => { page = 1; load(); });
|
||||
$('rows').addEventListener('click', e => {
|
||||
const tr = e.target.closest('tr.row[data-href]');
|
||||
if (!tr || e.target.closest('a')) return; // let real links (e.g. middle-click) work
|
||||
window.location.href = tr.dataset.href;
|
||||
});
|
||||
let filterTimer = null;
|
||||
$('filter').addEventListener('input', e => {
|
||||
clearTimeout(filterTimer);
|
||||
filterTimer = setTimeout(() => {
|
||||
filter = e.target.value.trim();
|
||||
page = 1;
|
||||
load();
|
||||
}, 200);
|
||||
});
|
||||
load();
|
||||
setInterval(load, 30000); // auto-refresh history every 30s
|
||||
</script>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
<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>
|
||||
<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="https://git.archfox.org/poslop/palette" target="_blank" rel="noopener">Git</a>
|
||||
</nav>
|
||||
<div class="spacer"></div>
|
||||
</div>
|
||||
|
||||
+91
-14
@@ -1,9 +1,10 @@
|
||||
{{template "head" .}}
|
||||
{{template "topbar" .}}
|
||||
<div class="deck">
|
||||
<div class="float pane-l">
|
||||
<div class="pane-l-col">
|
||||
<div class="float pane-l-head">
|
||||
<div class="editor-head">
|
||||
<input id="title" placeholder="title (optional)">
|
||||
<input id="title" placeholder="Title">
|
||||
<select id="language">
|
||||
<option value="">auto</option>
|
||||
<option>go</option><option>python</option><option>javascript</option>
|
||||
@@ -11,16 +12,18 @@
|
||||
<option>bash</option><option>sql</option><option>yaml</option><option>json</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 class="editor-wrap">
|
||||
</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>
|
||||
<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>
|
||||
<button class="btn" id="create">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,18 +40,21 @@
|
||||
</div>
|
||||
<div class="float side-section">
|
||||
<h3>Protection</h3>
|
||||
<div class="protect">
|
||||
<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;">
|
||||
<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>
|
||||
<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>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">—</div>
|
||||
<div class="hint" id="result" style="word-break:break-all">empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +71,70 @@ function updateGutter() {
|
||||
content.addEventListener('input', updateGutter);
|
||||
updateGutter();
|
||||
|
||||
$('haspw').addEventListener('change', e => { $('password').style.display = e.target.checked ? 'block' : 'none'; });
|
||||
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);
|
||||
}
|
||||
$('haspw').addEventListener('change', e => { $('pwrow').style.display = e.target.checked ? 'block' : 'none'; });
|
||||
$('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', rust: 'main.rs',
|
||||
c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
|
||||
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json',
|
||||
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 = {
|
||||
@@ -86,17 +155,25 @@ async function create() {
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
$('result').textContent = 'Error: ' + (data.error || res.status);
|
||||
showResult('Error: ' + (data.error || res.status), true);
|
||||
return;
|
||||
}
|
||||
const url = location.origin + '/' + (data.custom_slug || data.id);
|
||||
$('result').innerHTML = '<a href="' + url + '">' + url + '</a>';
|
||||
showResult('<a href="' + url + '">' + url + '</a>', false);
|
||||
$('result').dataset.token = data.deletion_token || '';
|
||||
try { navigator.clipboard.writeText(url); } catch(e) {}
|
||||
try { navigator.clipboard.writeText(url); toast('Copied'); } catch(e) {}
|
||||
// show the paste
|
||||
location.href = '/' + data.id + '?created=1&token=' + encodeURIComponent(data.deletion_token || '');
|
||||
}
|
||||
$('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(); }
|
||||
});
|
||||
|
||||
@@ -33,8 +33,17 @@
|
||||
</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 copyContent() {
|
||||
navigator.clipboard.writeText(document.getElementById('raw-content').value);
|
||||
toast('Copied');
|
||||
}
|
||||
function redeem(token) {
|
||||
if (!confirm('Hard delete this paste immediately?')) return;
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
{{template "head" .}}
|
||||
{{template "topbar" .}}
|
||||
<div class="center">
|
||||
<div class="float">
|
||||
<div class="float unlock-card">
|
||||
<div class="inner">
|
||||
<div class="lockring">🔒</div>
|
||||
<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="/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}}
|
||||
<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 {{.CreatedAgo}}</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" .}}
|
||||
|
||||
Reference in New Issue
Block a user