peer.paste style/layout

This commit is contained in:
Raven Scott
2026-05-28 00:41:51 -04:00
parent 5559c9096d
commit ac06ffe22d
5 changed files with 864 additions and 442 deletions
+85
View File
@@ -34,6 +34,91 @@ Temporary P2P text snippets for the P2NS network.
- `GET /api/openapi.json` machine-readable OpenAPI schema
- `GET /p/:id` human-readable paste page
## curl examples
Replace `https://peer.paste` with your nodes plugin URL. If you use the local P2NS TLS cert, add `-k` to skip verification.
### Create a public paste
Public pastes store plaintext on the server (no browser encryption step), which is easiest for API clients:
```bash
curl -sS -X POST 'https://peer.paste/api/pastes' \
-H 'Content-Type: application/json' \
-d '{
"public": true,
"content": "Hello from curl",
"format": "text",
"language": "plaintext",
"expiresHours": 24
}'
```
Example response (fields may vary):
```json
{
"success": true,
"paste": { "id": "abc123...", "format": "text", "isPublic": true, ... },
"viewUrl": "/p/abc123...",
"apiUrl": "/api/pastes/abc123...",
"rawUrl": "/api/pastes/abc123.../raw"
}
```
Save `paste.id` (or parse it with `jq`):
```bash
PASTE_ID=$(curl -sS -X POST 'https://peer.paste/api/pastes' \
-H 'Content-Type: application/json' \
-d '{"public":true,"content":"Quick test","format":"text"}' \
| jq -r '.paste.id')
echo "$PASTE_ID"
```
Markdown example:
```bash
curl -sS -X POST 'https://peer.paste/api/pastes' \
-H 'Content-Type: application/json' \
-d '{
"public": true,
"content": "# Title\n\nSome **markdown**.",
"format": "markdown",
"language": "markdown"
}'
```
Encrypted pastes require a client-generated `encrypted` payload (`iv`, `ciphertext`, optional `salt`); use the web UI or your own crypto before calling `POST /api/pastes` without `"public": true`.
### View a paste
**Metadata** (does not increment read count):
```bash
curl -sS "https://peer.paste/api/pastes/${PASTE_ID}"
```
**Raw content** (increments `readCount`; may delete burn-after-read pastes):
```bash
curl -sS "https://peer.paste/api/pastes/${PASTE_ID}/raw"
```
For a public paste, the JSON includes `paste.content`. For encrypted pastes, use `paste.encryptedPayload` and decrypt in your client, or open the share link in a browser with the `#k=...` key fragment.
**Browser page** (HTML UI, decrypt UI for encrypted pastes):
```text
https://peer.paste/p/${PASTE_ID}
```
Pretty-print raw JSON:
```bash
curl -sS "https://peer.paste/api/pastes/${PASTE_ID}/raw" | jq .
```
## Realtime WebSocket Messages
- Client -> server: `request-pastes`, `create-paste`, `destroy-paste`
+202 -115
View File
@@ -18,6 +18,123 @@ const DEFAULT_PAGE_SIZE = 25;
const ENC_PREFIX = 'ENCv1:';
const PROJECT_ROOT = pathMod.resolve(__dirname, '../..');
const PASTE_VIEW_STYLES = `
:root{color-scheme:dark;--bg:#09090b;--bg-subtle:#0f0f12;--surface:#141416;--border:#27272a;--border-strong:#3f3f46;--text:#fafafa;--muted:#a1a1aa;--muted2:#71717a;--accent:#6366f1;--accent-hover:#4f46e5;--accent-muted:rgba(99,102,241,.12);--radius:10px;--mono:ui-monospace,SFMono-Regular,Menlo,monospace}
html,body{height:100%;margin:0;overflow:hidden}
body{font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;font-size:14px;line-height:1.5;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
body::before{content:"";position:fixed;inset:0;pointer-events:none;background:radial-gradient(circle at 30% -10%,rgba(99,102,241,.07),transparent 50%);z-index:0}
a{color:#a5b4fc;text-decoration:none}
a:hover{color:#c7d2fe;text-decoration:underline}
.view-shell{position:relative;z-index:1;display:flex;flex-direction:column;height:100%;max-height:100dvh}
.view-header{flex:0 0 auto;display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;padding:.65rem 1rem;border-bottom:1px solid var(--border);background:var(--surface)}
.view-brand{min-width:0}
.view-brand .home{font-size:.75rem;color:var(--muted2);text-decoration:none}
.view-brand .home:hover{color:var(--muted)}
.view-brand h1{margin:.15rem 0 0;font-size:1rem;font-weight:600;letter-spacing:-.02em;font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(52vw,28rem)}
.view-chips{display:flex;flex-wrap:wrap;gap:.35rem .5rem;margin-top:.35rem}
.chip{font-size:.6875rem;color:var(--muted);background:var(--bg-subtle);border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem}
.chip.accent{color:#c7d2fe;border-color:rgba(99,102,241,.35);background:var(--accent-muted)}
.view-toolbar{flex:0 0 auto;display:flex;gap:.4rem;flex-wrap:wrap;align-items:center}
.view-banner{flex:0 0 auto;padding:.5rem 1rem;border-bottom:1px solid var(--border);background:var(--bg-subtle)}
.view-banner.hidden{display:none}
.key-row{display:flex;gap:.45rem;flex-wrap:wrap;align-items:center}
.view-body{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr)}
.view-body.has-aside{grid-template-columns:minmax(0,1fr) 260px}
.view-scroll{min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:1rem 1.1rem}
.view-scroll::-webkit-scrollbar{width:7px}
.view-scroll::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:999px}
.view-content{max-width:52rem;margin:0 auto}
.view-content pre{white-space:pre-wrap;word-break:break-word;background:#0c0c0e;border:1px solid var(--border);border-radius:var(--radius);padding:.85rem 1rem;margin:.5rem 0 0;font-family:var(--mono);font-size:.8125rem;line-height:1.55}
.view-content .preview,.view-content #md-render{white-space:pre-wrap;word-break:break-word;font-size:.9rem;line-height:1.6}
.view-content #md-render pre{margin:.75rem 0}
.view-content .lang-hint{font-size:.75rem;color:var(--muted);margin:0 0 .35rem}
.view-aside{min-height:0;overflow-y:auto;border-left:1px solid var(--border);background:var(--surface);padding:.75rem}
.view-aside h2{margin:0 0 .5rem;font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--muted2)}
.attach-list{display:flex;flex-direction:column;gap:.4rem}
.attach-item{display:block;padding:.45rem .55rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-subtle);font-size:.8125rem}
.attach-item small{display:block;color:var(--muted2);font-size:.6875rem;margin-top:.15rem}
.view-footer{flex:0 0 auto;padding:.45rem 1rem;border-top:1px solid var(--border);background:var(--surface);font-size:.8125rem}
.home-link{color:var(--muted)}
input{flex:1;min-width:10rem;background:#121214;color:var(--text);border:1px solid var(--border);border-radius:var(--radius);padding:.45rem .6rem;font:inherit;font-size:.8125rem}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-muted)}
button,.btn{border:0;border-radius:var(--radius);background:var(--accent);color:#fff;padding:.42rem .8rem;font-size:.8125rem;font-weight:600;cursor:pointer}
button:hover,.btn:hover{background:var(--accent-hover)}
.btn.ghost{background:transparent;border:1px solid var(--border-strong);color:var(--text)}
.btn.ghost:hover{background:var(--bg-subtle)}
.btn.ok{background:#16a34a}
@keyframes pulse{0%{transform:scale(1)}35%{transform:scale(1.03)}100%{transform:scale(1)}}
.btn.ok{animation:pulse .55s ease}
.error-shell .view-scroll{display:flex;align-items:center;justify-content:center}
.error-card{max-width:28rem;padding:1.25rem;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)}
.error-card h1{margin:0 0 .5rem;font-size:1.1rem}
.error-card p{margin:0;color:var(--muted);font-size:.875rem;line-height:1.5}
.error-code{font-family:var(--mono);font-size:.75rem;color:var(--muted2);background:var(--bg-subtle);border:1px solid var(--border);border-radius:var(--radius);padding:.5rem .65rem;margin:.75rem 0}
.cta{display:inline-block;margin-top:.25rem;background:var(--accent);color:#fff!important;text-decoration:none!important;padding:.5rem .9rem;border-radius:var(--radius);font-weight:600;font-size:.8125rem}
.cta:hover{background:var(--accent-hover)}
@media(max-width:720px){.view-body.has-aside{grid-template-columns:1fr}.view-aside{border-left:0;border-top:1px solid var(--border);max-height:28vh}}
`;
function escapeHtmlAttr(s) {
return String(s).replace(/[<>&"]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[m]));
}
function buildPasteMetaChips(items) {
return items
.filter(Boolean)
.map((text, i) => `<span class="chip${i === 0 ? ' accent' : ''}">${escapeHtmlAttr(text)}</span>`)
.join('');
}
function renderAttachmentsAside(attachments) {
if (!attachments || attachments.length === 0) return { aside: '', hasAside: false };
const items = attachments.map((a) => `
<a class="attach-item" download="${escapeHtmlAttr(a.name)}" href="${a.data}">
${escapeHtmlAttr(a.name)}
<small>${a.size} bytes</small>
</a>
`).join('');
return {
hasAside: true,
aside: `<h2>Attachments</h2><div class="attach-list">${items}</div>`
};
}
function wrapPasteViewDocument({ pasteId, title, chips, toolbar, banner, bodyHtml, aside, scripts, extraHead = '' }) {
const hasAside = !!aside;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${escapeHtmlAttr(title)}</title>
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
<style>${PASTE_VIEW_STYLES}</style>
${extraHead}
</head>
<body>
<div class="view-shell">
<header class="view-header">
<div class="view-brand">
<a class="home" href="/">peer.paste</a>
<h1 title="${escapeHtmlAttr(pasteId)}">${escapeHtmlAttr(pasteId)}</h1>
<div class="view-chips">${chips}</div>
</div>
<div class="view-toolbar">${toolbar}</div>
</header>
${banner || ''}
<div class="view-body${hasAside ? ' has-aside' : ''}">
<div class="view-scroll" id="view-scroll">
<div class="view-content" id="paste-content">${bodyHtml}</div>
</div>
${hasAside ? `<aside class="view-aside" aria-label="Attachments">${aside}</aside>` : ''}
</div>
<footer class="view-footer"><a class="home-link" href="/">← Create a new paste</a></footer>
</div>
${scripts || ''}
</body>
</html>`;
}
let cleanupTimer = null;
const metrics = {
queryTimeoutFallbacks: 0,
@@ -645,49 +762,32 @@ function renderPastePage(paste) {
try { attachments = JSON.parse(safePaste.attachmentRefs || '[]'); } catch {}
const encryptedPayload = decodeEncryptedContent(paste.content);
if (encryptedPayload) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>peer.paste - ${paste.id}</title>
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
<style>
body{margin:0;font-family:system-ui;background:#0f172a;color:#e2e8f0}
.wrap{max-width:960px;margin:2rem auto;padding:0 1rem}
.card{background:#111827;border:1px solid #334155;border-radius:12px;padding:1rem}
a{color:#60a5fa}
.meta{font-size:.9rem;color:#94a3b8;margin:.5rem 0 1rem}
pre{white-space:pre-wrap;word-break:break-word;background:#020617;border:1px solid #1e293b;border-radius:8px;padding:1rem}
.key-row{display:flex;gap:.5rem;margin:.75rem 0}
.key-row.hidden{display:none}
input{flex:1;background:#020617;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:.5rem}
button{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:.5rem .85rem;cursor:pointer}
.preview{white-space:pre-wrap;word-break:break-word}
.actions{display:flex;gap:.5rem;margin:.75rem 0 0}
.btn{background:#1d4ed8;color:#fff;border:0;border-radius:8px;padding:.45rem .8rem;cursor:pointer;transition:transform 120ms ease, background-color 120ms ease}
.btn:hover{transform:translateY(-1px);background:#2563eb}
.btn.ok{background:#16a34a;animation:pulse .55s ease}
@keyframes pulse{0%{transform:scale(1)}35%{transform:scale(1.04)}100%{transform:scale(1)}}
</style>
</head>
<body>
<div class="wrap">
<h1>paste ${paste.id}</h1>
<div class="meta">Encrypted ${safePaste.format} paste (${safePaste.encryptionMode || 'aes-gcm'}). Key must be present in URL fragment.</div>
<div class="key-row" id="key-row">
<input id="manual-key" placeholder="Paste key (base64url) if missing from URL" />
<button id="decrypt-btn" type="button">Decrypt</button>
</div>
<div class="actions">
const { aside, hasAside } = renderAttachmentsAside(attachments);
const chips = buildPasteMetaChips([
'Encrypted',
safePaste.format || 'text',
safePaste.encryptionMode || 'aes-gcm'
]);
const toolbar = `
<button id="copy-btn" class="btn" type="button">Copy</button>
<button id="download-btn" class="btn" type="button">Download</button>
</div>
<div class="card"><div id="out" class="preview">Decrypting...</div></div>
${attachments.length ? `<p class="meta">Attachments: ${attachments.map((a) => a.name).join(', ')}</p>` : ''}
<p><a href="/">Create another paste</a></p>
</div>
<script src="/api/vendor/marked.min.js"></script>
`;
const banner = `
<div class="view-banner" id="key-banner">
<div class="key-row" id="key-row">
<input id="manual-key" placeholder="Decryption key (or use #k= in URL)" />
<button id="decrypt-btn" type="button">Decrypt</button>
</div>
</div>`;
return wrapPasteViewDocument({
pasteId: paste.id,
title: `peer.paste - ${paste.id}`,
chips,
toolbar,
banner,
bodyHtml: '<div id="out" class="preview">Decrypting…</div>',
aside: hasAside ? aside : '',
scripts: `<script src="/api/vendor/marked.min.js"></script>
<script src="/api/vendor/highlight.min.js"></script>
<script>
const payload = ${JSON.stringify(encryptedPayload)};
@@ -740,7 +840,7 @@ function renderPastePage(paste) {
const hasConfidence = typeof detected.relevance === 'number' ? detected.relevance >= 4 : false;
if (looksLikeCode && hasConfidence) {
const lang = detected.language || 'plaintext';
out.innerHTML = '<div style="color:#9fb0cb;font-size:.82rem;margin-bottom:.45rem">Detected language: ' + lang + '</div>'
out.innerHTML = '<div class="lang-hint">Detected language: ' + lang + '</div>'
+ '<pre><code class="hljs language-' + lang + '">' + detected.value + '</code></pre>';
} else {
out.textContent = text;
@@ -784,7 +884,7 @@ function renderPastePage(paste) {
function initDecryptView() {
const btn = document.getElementById('decrypt-btn');
const input = document.getElementById('manual-key');
const keyRow = document.getElementById('key-row');
const keyBanner = document.getElementById('key-banner');
btn.addEventListener('click', () => decryptAndRender(input.value));
document.getElementById('copy-btn').addEventListener('click', copyCurrent);
document.getElementById('download-btn').addEventListener('click', downloadCurrent);
@@ -794,10 +894,10 @@ function renderPastePage(paste) {
const initial = readKeyFromHash();
if (initial) {
input.value = initial;
keyRow.classList.add('hidden');
if (keyBanner) keyBanner.classList.add('hidden');
decryptAndRender(initial);
} else {
keyRow.classList.remove('hidden');
} else if (keyBanner) {
keyBanner.classList.remove('hidden');
decryptAndRender('');
}
}
@@ -806,51 +906,38 @@ function renderPastePage(paste) {
} else {
initDecryptView();
}
</script>
</body>
</html>`;
</script>`
});
}
const language = safePaste.language || 'plain';
const expiresIn = Math.max(0, paste.expiresAt - now());
const hours = Math.floor(expiresIn / (1000 * 60 * 60));
const minutes = Math.floor((expiresIn % (1000 * 60 * 60)) / (1000 * 60));
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>peer.paste - ${paste.id}</title>
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
<style>
body{margin:0;font-family:system-ui;background:#0f172a;color:#e2e8f0}
.wrap{max-width:960px;margin:2rem auto;padding:0 1rem}
.card{background:#111827;border:1px solid #334155;border-radius:12px;padding:1rem}
pre{white-space:pre-wrap;word-break:break-word;background:#020617;border:1px solid #1e293b;border-radius:8px;padding:1rem}
a{color:#60a5fa}
.meta{font-size:.9rem;color:#94a3b8;margin:.5rem 0 1rem}
.actions{display:flex;gap:.5rem;margin:0 0 .75rem}
.btn{background:#1d4ed8;color:#fff;border:0;border-radius:8px;padding:.45rem .8rem;cursor:pointer;transition:transform 120ms ease, background-color 120ms ease}
.btn:hover{transform:translateY(-1px);background:#2563eb}
.btn.ok{background:#16a34a;animation:pulse .55s ease}
@keyframes pulse{0%{transform:scale(1)}35%{transform:scale(1.04)}100%{transform:scale(1)}}
</style>
</head>
<body>
<div class="wrap">
<h1>paste ${paste.id}</h1>
<div class="meta">Language: ${language} | Format: ${safePaste.format} | Expires in: ${hours}h ${minutes}m</div>
<div class="actions">
<button id="copy-btn" class="btn" type="button">Copy</button>
<button id="download-btn" class="btn" type="button">Download</button>
</div>
<div class="card">${safePaste.format === 'markdown'
? `<div id="md-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</div>`
: `<pre><code id="code-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</code></pre>`}</div>
${attachments.length ? `<div class="card"><h3>Attachments</h3>${attachments.map((a) => `<p><a download="${a.name}" href="${a.data}">${a.name}</a> (${a.size} bytes)</p>`).join('')}</div>` : ''}
<p><a href="/">Create another paste</a></p>
</div>
${safePaste.format === 'markdown'
const { aside, hasAside } = renderAttachmentsAside(attachments);
const chips = buildPasteMetaChips([
safePaste.isPublic ? 'Public' : 'Encrypted',
safePaste.format || 'text',
language,
`Expires ${hours}h ${minutes}m`
]);
const toolbar = `
<button id="copy-btn" class="btn" type="button">Copy</button>
<button id="download-btn" class="btn" type="button">Download</button>
`;
const bodyHtml = safePaste.format === 'markdown'
? `<div id="md-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</div>`
: `<pre><code id="code-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</code></pre>`;
return wrapPasteViewDocument({
pasteId: paste.id,
title: `peer.paste - ${paste.id}`,
chips,
toolbar,
banner: '',
bodyHtml,
aside: hasAside ? aside : '',
scripts: safePaste.format === 'markdown'
? `<script src="/api/vendor/marked.min.js"></script>
<script src="/api/vendor/highlight.min.js"></script>
<script>
@@ -893,7 +980,7 @@ function renderPastePage(paste) {
});
}
</script>`
: `<script src="/api/vendor/highlight.min.js"></script>
: `<script src="/api/vendor/highlight.min.js"></script>
<script>
const sourceText = ${JSON.stringify(paste.content)};
const copyBtn = document.getElementById('copy-btn');
@@ -932,37 +1019,40 @@ function renderPastePage(paste) {
}
} catch {}
}
</script>`}
</body>
</html>`;
</script>`
});
}
function renderPasteErrorPage(reason = 'Paste not found') {
const safeReason = reason.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]));
return `<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Paste unavailable - peer.paste</title>
<style>
body{margin:0;font-family:Inter,system-ui;background:radial-gradient(circle at top,#1e293b 0%,#0b1220 45%,#070d18 100%);color:#e6edf8}
.wrap{max-width:760px;margin:4rem auto;padding:0 1rem}
.card{background:rgba(12,18,32,.84);border:1px solid rgba(148,163,184,.25);border-radius:16px;padding:1.25rem}
h1{margin:.1rem 0 .7rem}
p{color:#9fb0cb;line-height:1.5}
.code{font-family:ui-monospace,monospace;background:#020617;border:1px solid #1e293b;border-radius:8px;padding:.65rem;margin:.8rem 0}
a{display:inline-block;margin-top:.8rem;background:#2563eb;color:#fff;text-decoration:none;padding:.55rem .9rem;border-radius:10px}
</style>
<style>${PASTE_VIEW_STYLES}</style>
</head>
<body>
<main class="wrap">
<section class="card">
<h1>This paste is unavailable</h1>
<p>The paste may have expired, been consumed by burn-after-read, or been deleted by its owner.</p>
<div class="code">${reason.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</div>
<a href="/">Create a new paste</a>
</section>
</main>
<div class="view-shell error-shell">
<header class="view-header">
<div class="view-brand">
<a class="home" href="/">peer.paste</a>
<h1>Unavailable</h1>
<div class="view-chips"><span class="chip accent">Not found</span></div>
</div>
</header>
<div class="view-body">
<div class="view-scroll">
<div class="error-card">
<h1>This paste is unavailable</h1>
<p>It may have expired, been consumed by burn-after-read, or been deleted.</p>
<div class="error-code">${safeReason}</div>
<a class="cta" href="/">Create a new paste</a>
</div>
</div>
</div>
</div>
</body>
</html>`;
}
@@ -974,15 +1064,12 @@ function apiDocsHtml(baseUrl) {
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>peer.paste API Docs</title>
<style>
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;margin:0;padding:1.25rem}
.wrap{max-width:1100px;margin:0 auto}
.card{background:#111827;border:1px solid #334155;border-radius:12px;padding:1rem;margin-bottom:1rem}
.m{display:inline-block;padding:.2rem .5rem;border-radius:6px;font-weight:700;font-size:.8rem;margin-right:.5rem}
.GET{background:#059669}.POST{background:#2563eb}.PUT{background:#d97706}.DELETE{background:#dc2626}
code,pre{background:#020617;border:1px solid #1e293b;border-radius:8px}
<style>${PASTE_VIEW_STYLES}
.wrap{max-width:900px}
.m{display:inline-block;padding:.2rem .5rem;border-radius:6px;font-weight:700;font-size:.75rem;margin-right:.5rem;color:#fff}
.GET{background:#15803d}.POST{background:var(--accent)}.PUT{background:#b45309}.DELETE{background:#b91c1c}
code,pre{background:#0c0c0e;border:1px solid var(--border);border-radius:8px;font-family:var(--mono);font-size:.8125rem}
pre{padding:.75rem;overflow:auto}
a{color:#60a5fa}
</style>
</head>
<body>
+94 -76
View File
@@ -8,88 +8,106 @@
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
</head>
<body>
<main class="page">
<section class="hero">
<h1>peer.paste</h1>
<p>Share temporary text with one clean link.</p>
</section>
<div class="app-shell">
<header class="app-header">
<div class="brand">
<h1>peer.paste</h1>
<p>Share temporary text with one clean link.</p>
</div>
<span id="live-dot" class="live-dot">LIVE</span>
</header>
<section class="panel">
<form id="create-form" class="create-form">
<label for="content" class="label">Secret / Paste</label>
<div class="toolbar">
<select id="format">
<option value="text">Text</option>
<option value="markdown">Markdown</option>
</select>
<select id="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<button type="button" id="toggle-preview" class="ghost">Preview</button>
<button type="button" id="copy-editor" class="ghost">Copy Text</button>
</div>
<textarea id="content" rows="14" required placeholder="Paste text here..."></textarea>
<div id="preview-panel" class="preview-panel hidden">
<div id="preview-content">Preview will appear here.</div>
</div>
<div class="app-body">
<section class="compose panel">
<form id="create-form" class="create-form">
<div class="compose-grid">
<div class="compose-main">
<label for="content" class="label">Paste</label>
<div class="toolbar">
<select id="format" title="Format">
<option value="text">Text</option>
<option value="markdown">Markdown</option>
</select>
<select id="theme" title="Theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<button type="button" id="toggle-preview" class="ghost">Preview</button>
<button type="button" id="copy-editor" class="ghost">Copy</button>
</div>
<textarea id="content" required placeholder="Paste text here…"></textarea>
<div id="preview-panel" class="preview-panel hidden">
<div id="preview-content">Preview will appear here.</div>
</div>
</div>
<details class="advanced">
<summary>Advanced options</summary>
<div class="advanced-grid">
<label>Language (optional)
<input type="text" id="language" maxlength="32" placeholder="plain / js / json / md" />
</label>
<label>Expires in hours
<input type="number" id="expiresHours" min="1" max="168" value="24" />
</label>
<label>Max reads (0 = unlimited)
<input type="number" id="maxReads" min="0" max="1000000" value="0" />
</label>
<label class="check-row">
<input type="checkbox" id="destroyOnRead" />
Burn after first read
</label>
<label class="check-row">
<input type="checkbox" id="publicPaste" />
Public paste (disable encryption)
</label>
<label>Passphrase (optional encryption key derivation)
<input id="passphrase" type="password" autocomplete="off" placeholder="Optional extra layer" />
</label>
<label>Attachments (up to 64MB total)
<input id="attachments" type="file" multiple />
</label>
<aside class="compose-options" aria-label="Paste options">
<h2 class="options-title">Options</h2>
<div class="options-stack">
<label class="field">
<span>Language</span>
<input type="text" id="language" maxlength="32" placeholder="plain, js, json…" />
</label>
<label class="field">
<span>Expires (hours)</span>
<input type="number" id="expiresHours" min="1" max="168" value="24" />
</label>
<label class="field">
<span>Max reads</span>
<input type="number" id="maxReads" min="0" max="1000000" value="0" title="0 = unlimited" />
</label>
<label class="field">
<span>Passphrase</span>
<input id="passphrase" type="password" autocomplete="off" placeholder="Optional" />
</label>
<label class="field">
<span>Attachments</span>
<input id="attachments" type="file" multiple />
</label>
<label class="check-row">
<input type="checkbox" id="destroyOnRead" />
<span>Burn after first read</span>
</label>
<label class="check-row">
<input type="checkbox" id="publicPaste" />
<span>Public (no encryption)</span>
</label>
</div>
</aside>
</div>
</details>
<div class="actions">
<button type="submit" id="create-btn">Create secure link</button>
<button type="button" id="clear-btn" class="ghost">Clear</button>
<div id="create-result" class="result hidden">
<div class="result-inner">
<div class="result-copy">
<span class="result-label">Share link</span>
<div class="link-row">
<input id="share-link" type="text" readonly />
<button id="copy-link-btn" type="button" class="ghost">Copy</button>
<a id="open-link-btn" class="ghost link-btn" href="#" target="_blank" rel="noopener">Open</a>
</div>
</div>
<p class="meta-row" id="result-meta"></p>
</div>
</div>
<div class="actions">
<button type="submit" id="create-btn">Create secure link</button>
<button type="button" id="clear-btn" class="ghost">Clear</button>
</div>
</form>
</section>
<aside class="sidebar panel" aria-label="Recent pastes">
<div class="list-head">
<h2>Recent</h2>
</div>
</form>
<div id="create-result" class="result hidden">
<h2>Share this link</h2>
<p class="hint">Anyone with this link can read the paste.</p>
<div class="link-row">
<input id="share-link" type="text" readonly />
<button id="copy-link-btn" type="button" class="ghost">Copy</button>
<a id="open-link-btn" class="ghost link-btn" href="#" target="_blank" rel="noopener">Open</a>
<div id="sidebar-scroll" class="sidebar-scroll">
<div id="list" class="sidebar-list"></div>
<div id="list-loading" class="list-loading hidden">Loading more…</div>
</div>
<div class="meta-row" id="result-meta"></div>
</div>
</section>
<section class="panel compact">
<div class="list-head">
<h2>Recent active pastes</h2>
<span id="live-dot" class="live-dot">LIVE</span>
</div>
<div id="list"></div>
<div id="list-loading" class="list-loading hidden">Loading more...</div>
</section>
</main>
</aside>
</div>
</div>
<script src="/api/vendor/marked.min.js"></script>
<script src="/api/vendor/highlight.min.js"></script>
<script src="/main.js"></script>
+28 -17
View File
@@ -184,24 +184,24 @@ function renderRecentList() {
list.textContent = 'No active pastes yet.';
return;
}
list.innerHTML = rows.map((p) => `
<div class="paste-item">
<div class="paste-title">${escapeHtml(p.id)}</div>
list.innerHTML = rows.map((p) => {
const idLabel = p.id.length > 14 ? `${p.id.slice(0, 12)}` : p.id;
return `
<article class="paste-item">
<div class="paste-title" title="${escapeHtml(p.id)}">${escapeHtml(idLabel)}</div>
<div class="paste-meta">
id: ${p.id} |
reads: ${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} |
expires: ${msToHuman(p.expiresInMs)} |
format: ${escapeHtml(p.format || 'text')} |
${p.isPublic ? 'public' : (p.encryptionMode || 'encrypted')}
<span>${msToHuman(p.expiresInMs)}</span>
<span>${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} reads</span>
<span>${escapeHtml(p.format || 'text')}</span>
</div>
<div class="paste-actions">
<a href="/p/${p.id}" target="_blank" rel="noopener">Open</a>
<a href="/api/pastes/${p.id}/raw" target="_blank" rel="noopener">Raw</a>
<a href="#" data-copy-url="/p/${p.id}">Copy link</a>
<a href="#" data-copy-url="/p/${p.id}">Copy</a>
<a href="#" data-destroy-id="${p.id}" class="danger">Destroy</a>
</div>
</div>
`).join('');
</article>
`;
}).join('');
}
function handleRealtimePage(data, reset = false) {
@@ -342,7 +342,7 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
setCreateState(err.message, true);
} finally {
createBtn.disabled = false;
createBtn.textContent = 'Create secure link';
updateCreateButtonLabel();
}
});
@@ -446,10 +446,21 @@ function connectWebSocket() {
});
}
window.addEventListener('scroll', () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 280;
if (nearBottom) loadNextPage();
});
function onSidebarScroll() {
const el = document.getElementById('sidebar-scroll');
if (!el) return;
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 64;
if (!nearBottom) return;
if (visibleCount < allRecent.length) {
visibleCount = Math.min(visibleCount + PAGE_SIZE, allRecent.length);
renderRecentList();
return;
}
loadNextPage();
}
document.getElementById('sidebar-scroll').addEventListener('scroll', onSidebarScroll, { passive: true });
document.getElementById('toggle-preview').addEventListener('click', () => {
const panel = document.getElementById('preview-panel');
+455 -234
View File
@@ -1,366 +1,587 @@
:root {
color-scheme: dark;
--bg: #09090b;
--bg-subtle: #0f0f12;
--bg-input: #121214;
--surface: #141416;
--surface-hover: #1a1a1e;
--border: #27272a;
--border-strong: #3f3f46;
--text: #fafafa;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--accent: #6366f1;
--accent-hover: #4f46e5;
--accent-muted: rgba(99, 102, 241, 0.12);
--success: #22c55e;
--danger: #f87171;
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 12px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--header-h: 3.25rem;
--sidebar-w: 300px;
--options-w: 220px;
}
* {
box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
scrollbar-color: var(--border-strong) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
width: 7px;
height: 7px;
}
*::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(96, 165, 250, 0.45), rgba(148, 163, 184, 0.45));
background: var(--border-strong);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
}
*::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, rgba(96, 165, 250, 0.7), rgba(148, 163, 184, 0.7));
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
background: radial-gradient(circle at top, #1e293b 0%, #0b1220 45%, #070d18 100%);
color: #e6edf8;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.45;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.page {
max-width: 920px;
margin: 2.25rem auto;
padding: 0 1rem 2.5rem;
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image: radial-gradient(circle at 30% -10%, rgba(99, 102, 241, 0.07), transparent 50%);
z-index: 0;
}
.hero h1 {
.app-shell {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
max-height: 100dvh;
}
.app-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.65rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
min-height: var(--header-h);
}
.brand h1 {
margin: 0;
font-size: 2rem;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.03em;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.03);
}
.hero p {
margin: 0.35rem 0 1.25rem;
color: #9fb0cb;
.brand p {
margin: 0.1rem 0 0;
font-size: 0.75rem;
color: var(--text-tertiary);
}
.app-body {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--sidebar-w);
gap: 0;
}
.panel {
background: rgba(12, 18, 32, 0.84);
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px;
padding: 1rem;
margin-bottom: 1rem;
backdrop-filter: blur(6px);
box-shadow: 0 8px 28px rgba(2, 6, 23, 0.28);
background: var(--surface);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.compact {
padding: 0.9rem;
.compose.panel {
display: flex;
flex-direction: column;
min-height: 0;
margin: 0.65rem 0 0.65rem 0.65rem;
border-radius: var(--radius-lg);
padding: 0.85rem;
}
.sidebar.panel {
display: flex;
flex-direction: column;
min-height: 0;
margin: 0.65rem 0.65rem 0.65rem 0;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
border-right: 0;
padding: 0.75rem 0.65rem;
}
.create-form {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.compose-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) var(--options-w);
gap: 0.75rem;
}
.compose-main {
display: flex;
flex-direction: column;
min-height: 0;
}
.label {
display: block;
font-size: 0.92rem;
color: #9fb0cb;
margin-bottom: 0.4rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.35rem;
}
.toolbar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
margin-bottom: 0.55rem;
padding: 0.4rem;
border: 1px solid #223047;
border-radius: 10px;
background: rgba(10, 15, 28, 0.72);
margin-bottom: 0.4rem;
}
.toolbar select {
background: #070d18;
color: #edf3ff;
border: 1px solid #334155;
border-radius: 8px;
padding: 0.45rem 0.6rem;
background: var(--bg-input);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.3rem 0.5rem;
font-size: 0.8125rem;
cursor: pointer;
}
.toolbar .ghost {
padding: 0.3rem 0.55rem;
font-size: 0.8125rem;
}
#content {
flex: 1;
min-height: 8rem;
width: 100%;
resize: none;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
padding: 0.65rem 0.75rem;
font-family: var(--font-mono);
font-size: 0.8125rem;
line-height: 1.5;
}
textarea,
input[type="text"],
input[type="number"] {
width: 100%;
border-radius: 10px;
border: 1px solid #334155;
background: #070d18;
color: #edf3ff;
padding: 0.68rem 0.72rem;
input[type="number"],
input[type="password"],
input[type="file"] {
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-input);
color: var(--text);
font: inherit;
transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
textarea {
resize: vertical;
min-height: 240px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
input[type="text"],
input[type="number"],
input[type="password"] {
width: 100%;
padding: 0.4rem 0.5rem;
font-size: 0.8125rem;
}
input[type="file"] {
width: 100%;
padding: 0.25rem 0;
font-size: 0.75rem;
border: 0;
background: transparent;
}
textarea::placeholder,
input::placeholder {
color: var(--text-tertiary);
}
textarea:focus,
input:focus,
button:focus,
a:focus {
outline: 2px solid #3b82f6;
outline-offset: 1px;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.22);
button:focus-visible,
a:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-muted);
}
.advanced {
margin-top: 0.85rem;
border-top: 1px solid #1e293b;
padding-top: 0.8rem;
.compose-options {
display: flex;
flex-direction: column;
min-height: 0;
border-left: 1px solid var(--border);
padding-left: 0.75rem;
}
.advanced summary {
cursor: pointer;
color: #9fb0cb;
.options-title {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
}
.advanced-grid {
margin-top: 0.85rem;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
.options-stack {
display: flex;
flex-direction: column;
gap: 0.5rem;
overflow-y: auto;
min-height: 0;
}
.advanced-grid label {
display: block;
font-size: 0.88rem;
color: #9fb0cb;
.field {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.field > span {
font-weight: 500;
}
.check-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 1.7rem;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-secondary);
line-height: 1.35;
cursor: pointer;
}
.check-row input[type="checkbox"] {
width: auto;
margin-top: 0.15rem;
accent-color: var(--accent);
flex-shrink: 0;
}
.preview-panel {
flex: 0 0 auto;
max-height: 28%;
margin-top: 0.4rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.5rem 0.65rem;
background: var(--bg-subtle);
overflow: auto;
}
#preview-content {
color: var(--text);
font-size: 0.8125rem;
}
#preview-content pre {
background: var(--bg);
padding: 0.5rem;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
margin: 0;
}
.result {
flex: 0 0 auto;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
.result-inner {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.result-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
}
.link-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.35rem;
}
.link-row input {
font-family: var(--font-mono);
font-size: 0.75rem;
padding: 0.35rem 0.5rem;
}
.meta-row {
margin: 0;
font-size: 0.75rem;
color: var(--text-secondary);
}
.actions {
flex: 0 0 auto;
display: flex;
gap: 0.6rem;
margin-top: 0.95rem;
gap: 0.45rem;
margin-top: 0.5rem;
}
button,
.link-btn {
border: 0;
border-radius: 10px;
background: #3b82f6;
color: #f8fbff;
padding: 0.62rem 0.95rem;
border-radius: var(--radius-md);
background: var(--accent);
color: #fff;
padding: 0.45rem 0.85rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 110ms ease, background-color 120ms ease, border-color 120ms ease;
transition: background-color 0.15s ease;
}
button:hover,
.link-btn:hover {
background: #2563eb;
transform: translateY(-1px);
background: var(--accent-hover);
}
.ghost {
background: transparent;
border: 1px solid #334155;
color: #d6e2f7;
border: 1px solid var(--border-strong);
color: var(--text);
}
.ghost:hover {
background: #122039;
background: var(--surface-hover);
}
.hidden {
display: none;
}
.result {
margin-top: 1rem;
border-top: 1px solid #1e293b;
padding-top: 1rem;
}
.preview-panel {
margin-top: 0.65rem;
border: 1px solid #334155;
border-radius: 10px;
padding: 0.8rem;
background: #050b16;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02);
}
#preview-content {
color: #dbe7ff;
overflow-x: auto;
}
.preview-meta {
color: #9fb0cb;
font-size: 0.82rem;
margin-bottom: 0.45rem;
}
#preview-content pre {
background: #020617;
padding: 0.8rem;
border-radius: 8px;
border: 1px solid #1e293b;
}
#preview-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
body.light {
background: #f7fafc;
color: #0f172a;
}
body.light .panel {
background: #ffffff;
border-color: #cbd5e1;
}
body.light textarea,
body.light input[type="text"],
body.light input[type="number"],
body.light .toolbar select {
background: #ffffff;
color: #0f172a;
border-color: #cbd5e1;
}
body.light .preview-panel {
background: #f8fafc;
border-color: #dbeafe;
}
body.light .toolbar {
background: #f8fafc;
border-color: #dbeafe;
}
body.light *::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, rgba(59, 130, 246, 0.45), rgba(71, 85, 105, 0.45));
}
.result h2 {
margin: 0 0 0.2rem;
font-size: 1.02rem;
}
.hint {
margin: 0 0 0.65rem;
color: #9fb0cb;
font-size: 0.9rem;
}
.link-row {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.5rem;
}
.meta-row {
margin-top: 0.7rem;
color: #9fb0cb;
font-size: 0.9rem;
}
.list-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.live-dot {
font-size: 0.74rem;
letter-spacing: 0.08em;
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.45);
border-radius: 999px;
padding: 0.2rem 0.5rem;
flex: 0 0 auto;
margin-bottom: 0.45rem;
padding: 0 0.15rem;
}
.list-head h2 {
margin: 0;
font-size: 1rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-tertiary);
}
#list {
color: #9fb0cb;
.live-dot {
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--success);
border: 1px solid rgba(34, 197, 94, 0.35);
background: rgba(34, 197, 94, 0.08);
border-radius: 999px;
padding: 0.18rem 0.45rem;
flex-shrink: 0;
}
.sidebar-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 0.15rem;
}
.sidebar-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.paste-item {
padding: 0.72rem 0;
border-top: 1px solid #1e293b;
transition: background-color 120ms ease;
border-radius: 8px;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.paste-item:first-child {
border-top: 0;
padding: 0.55rem 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-subtle);
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.paste-item:hover {
background: rgba(37, 99, 235, 0.08);
background: var(--accent-muted);
border-color: rgba(99, 102, 241, 0.35);
}
.paste-title {
color: #eff5ff;
color: var(--text);
font-weight: 600;
font-size: 0.8125rem;
font-family: var(--font-mono);
}
.paste-meta {
margin-top: 0.2rem;
font-size: 0.88rem;
display: flex;
flex-wrap: wrap;
gap: 0.35rem 0.5rem;
font-size: 0.6875rem;
color: var(--text-tertiary);
}
.paste-actions {
margin-top: 0.35rem;
display: flex;
gap: 0.65rem;
font-size: 0.9rem;
flex-wrap: wrap;
}
.result .link-row input {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.danger {
color: #fca5a5;
gap: 0.5rem;
font-size: 0.75rem;
}
.list-loading {
margin-top: 0.6rem;
color: #9fb0cb;
font-size: 0.9rem;
padding: 0.5rem;
text-align: center;
color: var(--text-tertiary);
font-size: 0.75rem;
}
.danger {
color: var(--danger) !important;
}
a {
color: #7db1ff;
color: #a5b4fc;
text-decoration: none;
}
a:hover {
color: #c7d2fe;
text-decoration: underline;
}
body.light {
color-scheme: light;
--bg: #fafafa;
--bg-subtle: #f4f4f5;
--bg-input: #ffffff;
--surface: #ffffff;
--surface-hover: #f4f4f5;
--border: #e4e4e7;
--border-strong: #d4d4d8;
--text: #18181b;
--text-secondary: #52525b;
--text-tertiary: #71717a;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-muted: rgba(79, 70, 229, 0.08);
}
body.light::before {
background-image: radial-gradient(circle at 30% -10%, rgba(79, 70, 229, 0.06), transparent 50%);
}
body.light a {
color: var(--accent);
}
body.light a:hover {
color: var(--accent-hover);
}
@media (max-width: 960px) {
:root {
--sidebar-w: 260px;
--options-w: 190px;
}
}
@media (max-width: 820px) {
html,
body {
overflow: auto;
height: auto;
}
.app-shell {
height: auto;
max-height: none;
min-height: 100dvh;
}
.app-body {
grid-template-columns: 1fr;
}
.compose.panel {
margin: 0.5rem;
min-height: min(70vh, 640px);
}
.sidebar.panel {
margin: 0 0.5rem 0.5rem;
border-radius: var(--radius-lg);
border-right: 1px solid var(--border);
max-height: 42vh;
}
.compose-grid {
grid-template-columns: 1fr;
}
.compose-options {
border-left: 0;
border-top: 1px solid var(--border);
padding-left: 0;
padding-top: 0.65rem;
}
.options-stack {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
}
}