Paste view: collapsible stats pill under title (#17)
This commit is contained in:
@@ -102,11 +102,21 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
|
|||||||
if row.ExpiresAt.Valid {
|
if row.ExpiresAt.Valid {
|
||||||
expIn = expiryString(row.ExpiresAt.Int64)
|
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{
|
data := map[string]any{
|
||||||
"Page": "paste",
|
"Page": "paste",
|
||||||
"ID": row.ID,
|
"ID": row.ID,
|
||||||
"Title": row.Title.String,
|
"Title": row.Title.String,
|
||||||
"Language": row.Language.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(highlightCode(row.Content, row.Language.String)), // safe: highlightCode escapes all non-span text
|
"ContentHTML": template.HTML(highlightCode(row.Content, row.Language.String)), // safe: highlightCode escapes all non-span text
|
||||||
"ContentAttr": row.Content,
|
"ContentAttr": row.Content,
|
||||||
"Gutter": strings.TrimSuffix(gutter, "\n"),
|
"Gutter": strings.TrimSuffix(gutter, "\n"),
|
||||||
|
|||||||
@@ -114,6 +114,18 @@ body {
|
|||||||
.meta-bar h1 { font-size: 29.2px; font-weight: 600; }
|
.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); }
|
.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; }
|
.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: 10px; width: 100%; background: none; border: 0; color: var(--muted-fg); font: inherit; font-size: 21.6px; padding: 10px 14px; 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; }
|
||||||
|
.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; }
|
.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 { 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:hover { color: var(--fg); border-color: var(--muted); }
|
||||||
|
|||||||
+32
-10
@@ -2,17 +2,36 @@
|
|||||||
{{template "topbar" .}}
|
{{template "topbar" .}}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<div class="float">
|
<div class="float">
|
||||||
<div class="meta-bar">
|
<div class="paste-title-bar">
|
||||||
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
<h1>{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}</h1>
|
||||||
<span class="slug">/{{.ID}}</span>
|
<span class="slug">/{{if .CustomSlug}}{{.CustomSlug}}{{else}}{{.ID}}{{end}}</span>
|
||||||
{{if .Language}}<span class="tag">{{.Language}}</span>{{end}}
|
|
||||||
{{if .ExpiresAt}}<span class="tag">expires in {{.ExpiresIn}}</span>{{end}}
|
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
<a class="iconbtn" href="/raw/{{.ID}}">raw</a>
|
||||||
<a class="iconbtn" href="#" onclick="copyContent(); return false;">copy</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}}
|
{{if .DeletionToken}}<a class="iconbtn danger" href="#" onclick="redeem('{{.DeletionToken}}'); return false;">delete</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</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">{{.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}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{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}}
|
{{if .JustCreated}}
|
||||||
<div class="float">
|
<div class="float">
|
||||||
<div class="created-banner" style="display:block">
|
<div class="created-banner" style="display:block">
|
||||||
@@ -22,13 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="float">
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
<input type="hidden" id="raw-content" value="{{.ContentAttr}}">
|
||||||
@@ -41,6 +54,15 @@ function toast(msg) {
|
|||||||
clearTimeout(t._h);
|
clearTimeout(t._h);
|
||||||
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
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() {
|
function copyContent() {
|
||||||
navigator.clipboard.writeText(document.getElementById('raw-content').value);
|
navigator.clipboard.writeText(document.getElementById('raw-content').value);
|
||||||
toast('Copied');
|
toast('Copied');
|
||||||
|
|||||||
Reference in New Issue
Block a user