forked from snxraven/p2ns
peer.paste style/layout
This commit is contained in:
@@ -34,6 +34,91 @@ Temporary P2P text snippets for the P2NS network.
|
|||||||
- `GET /api/openapi.json` machine-readable OpenAPI schema
|
- `GET /api/openapi.json` machine-readable OpenAPI schema
|
||||||
- `GET /p/:id` human-readable paste page
|
- `GET /p/:id` human-readable paste page
|
||||||
|
|
||||||
|
## curl examples
|
||||||
|
|
||||||
|
Replace `https://peer.paste` with your node’s 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
|
## Realtime WebSocket Messages
|
||||||
|
|
||||||
- Client -> server: `request-pastes`, `create-paste`, `destroy-paste`
|
- Client -> server: `request-pastes`, `create-paste`, `destroy-paste`
|
||||||
|
|||||||
+202
-115
@@ -18,6 +18,123 @@ const DEFAULT_PAGE_SIZE = 25;
|
|||||||
const ENC_PREFIX = 'ENCv1:';
|
const ENC_PREFIX = 'ENCv1:';
|
||||||
const PROJECT_ROOT = pathMod.resolve(__dirname, '../..');
|
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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[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;
|
let cleanupTimer = null;
|
||||||
const metrics = {
|
const metrics = {
|
||||||
queryTimeoutFallbacks: 0,
|
queryTimeoutFallbacks: 0,
|
||||||
@@ -645,49 +762,32 @@ function renderPastePage(paste) {
|
|||||||
try { attachments = JSON.parse(safePaste.attachmentRefs || '[]'); } catch {}
|
try { attachments = JSON.parse(safePaste.attachmentRefs || '[]'); } catch {}
|
||||||
const encryptedPayload = decodeEncryptedContent(paste.content);
|
const encryptedPayload = decodeEncryptedContent(paste.content);
|
||||||
if (encryptedPayload) {
|
if (encryptedPayload) {
|
||||||
return `<!doctype html>
|
const { aside, hasAside } = renderAttachmentsAside(attachments);
|
||||||
<html>
|
const chips = buildPasteMetaChips([
|
||||||
<head>
|
'Encrypted',
|
||||||
<meta charset="utf-8" />
|
safePaste.format || 'text',
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
safePaste.encryptionMode || 'aes-gcm'
|
||||||
<title>peer.paste - ${paste.id}</title>
|
]);
|
||||||
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
|
const toolbar = `
|
||||||
<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">
|
|
||||||
<button id="copy-btn" class="btn" type="button">Copy</button>
|
<button id="copy-btn" class="btn" type="button">Copy</button>
|
||||||
<button id="download-btn" class="btn" type="button">Download</button>
|
<button id="download-btn" class="btn" type="button">Download</button>
|
||||||
</div>
|
`;
|
||||||
<div class="card"><div id="out" class="preview">Decrypting...</div></div>
|
const banner = `
|
||||||
${attachments.length ? `<p class="meta">Attachments: ${attachments.map((a) => a.name).join(', ')}</p>` : ''}
|
<div class="view-banner" id="key-banner">
|
||||||
<p><a href="/">Create another paste</a></p>
|
<div class="key-row" id="key-row">
|
||||||
</div>
|
<input id="manual-key" placeholder="Decryption key (or use #k= in URL)" />
|
||||||
<script src="/api/vendor/marked.min.js"></script>
|
<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 src="/api/vendor/highlight.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const payload = ${JSON.stringify(encryptedPayload)};
|
const payload = ${JSON.stringify(encryptedPayload)};
|
||||||
@@ -740,7 +840,7 @@ function renderPastePage(paste) {
|
|||||||
const hasConfidence = typeof detected.relevance === 'number' ? detected.relevance >= 4 : false;
|
const hasConfidence = typeof detected.relevance === 'number' ? detected.relevance >= 4 : false;
|
||||||
if (looksLikeCode && hasConfidence) {
|
if (looksLikeCode && hasConfidence) {
|
||||||
const lang = detected.language || 'plaintext';
|
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>';
|
+ '<pre><code class="hljs language-' + lang + '">' + detected.value + '</code></pre>';
|
||||||
} else {
|
} else {
|
||||||
out.textContent = text;
|
out.textContent = text;
|
||||||
@@ -784,7 +884,7 @@ function renderPastePage(paste) {
|
|||||||
function initDecryptView() {
|
function initDecryptView() {
|
||||||
const btn = document.getElementById('decrypt-btn');
|
const btn = document.getElementById('decrypt-btn');
|
||||||
const input = document.getElementById('manual-key');
|
const input = document.getElementById('manual-key');
|
||||||
const keyRow = document.getElementById('key-row');
|
const keyBanner = document.getElementById('key-banner');
|
||||||
btn.addEventListener('click', () => decryptAndRender(input.value));
|
btn.addEventListener('click', () => decryptAndRender(input.value));
|
||||||
document.getElementById('copy-btn').addEventListener('click', copyCurrent);
|
document.getElementById('copy-btn').addEventListener('click', copyCurrent);
|
||||||
document.getElementById('download-btn').addEventListener('click', downloadCurrent);
|
document.getElementById('download-btn').addEventListener('click', downloadCurrent);
|
||||||
@@ -794,10 +894,10 @@ function renderPastePage(paste) {
|
|||||||
const initial = readKeyFromHash();
|
const initial = readKeyFromHash();
|
||||||
if (initial) {
|
if (initial) {
|
||||||
input.value = initial;
|
input.value = initial;
|
||||||
keyRow.classList.add('hidden');
|
if (keyBanner) keyBanner.classList.add('hidden');
|
||||||
decryptAndRender(initial);
|
decryptAndRender(initial);
|
||||||
} else {
|
} else if (keyBanner) {
|
||||||
keyRow.classList.remove('hidden');
|
keyBanner.classList.remove('hidden');
|
||||||
decryptAndRender('');
|
decryptAndRender('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,51 +906,38 @@ function renderPastePage(paste) {
|
|||||||
} else {
|
} else {
|
||||||
initDecryptView();
|
initDecryptView();
|
||||||
}
|
}
|
||||||
</script>
|
</script>`
|
||||||
</body>
|
});
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const language = safePaste.language || 'plain';
|
const language = safePaste.language || 'plain';
|
||||||
const expiresIn = Math.max(0, paste.expiresAt - now());
|
const expiresIn = Math.max(0, paste.expiresAt - now());
|
||||||
const hours = Math.floor(expiresIn / (1000 * 60 * 60));
|
const hours = Math.floor(expiresIn / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((expiresIn % (1000 * 60 * 60)) / (1000 * 60));
|
const minutes = Math.floor((expiresIn % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
return `<!doctype html>
|
const { aside, hasAside } = renderAttachmentsAside(attachments);
|
||||||
<html>
|
const chips = buildPasteMetaChips([
|
||||||
<head>
|
safePaste.isPublic ? 'Public' : 'Encrypted',
|
||||||
<meta charset="utf-8" />
|
safePaste.format || 'text',
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
language,
|
||||||
<title>peer.paste - ${paste.id}</title>
|
`Expires ${hours}h ${minutes}m`
|
||||||
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
|
]);
|
||||||
<style>
|
const toolbar = `
|
||||||
body{margin:0;font-family:system-ui;background:#0f172a;color:#e2e8f0}
|
<button id="copy-btn" class="btn" type="button">Copy</button>
|
||||||
.wrap{max-width:960px;margin:2rem auto;padding:0 1rem}
|
<button id="download-btn" class="btn" type="button">Download</button>
|
||||||
.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}
|
const bodyHtml = safePaste.format === 'markdown'
|
||||||
a{color:#60a5fa}
|
? `<div id="md-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '<', '>': '>', '&': '&' }[m]))}</div>`
|
||||||
.meta{font-size:.9rem;color:#94a3b8;margin:.5rem 0 1rem}
|
: `<pre><code id="code-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '<', '>': '>', '&': '&' }[m]))}</code></pre>`;
|
||||||
.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}
|
return wrapPasteViewDocument({
|
||||||
.btn:hover{transform:translateY(-1px);background:#2563eb}
|
pasteId: paste.id,
|
||||||
.btn.ok{background:#16a34a;animation:pulse .55s ease}
|
title: `peer.paste - ${paste.id}`,
|
||||||
@keyframes pulse{0%{transform:scale(1)}35%{transform:scale(1.04)}100%{transform:scale(1)}}
|
chips,
|
||||||
</style>
|
toolbar,
|
||||||
</head>
|
banner: '',
|
||||||
<body>
|
bodyHtml,
|
||||||
<div class="wrap">
|
aside: hasAside ? aside : '',
|
||||||
<h1>paste ${paste.id}</h1>
|
scripts: safePaste.format === 'markdown'
|
||||||
<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) => ({ '<': '<', '>': '>', '&': '&' }[m]))}</div>`
|
|
||||||
: `<pre><code id="code-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '<', '>': '>', '&': '&' }[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'
|
|
||||||
? `<script src="/api/vendor/marked.min.js"></script>
|
? `<script src="/api/vendor/marked.min.js"></script>
|
||||||
<script src="/api/vendor/highlight.min.js"></script>
|
<script src="/api/vendor/highlight.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -893,7 +980,7 @@ function renderPastePage(paste) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>`
|
</script>`
|
||||||
: `<script src="/api/vendor/highlight.min.js"></script>
|
: `<script src="/api/vendor/highlight.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const sourceText = ${JSON.stringify(paste.content)};
|
const sourceText = ${JSON.stringify(paste.content)};
|
||||||
const copyBtn = document.getElementById('copy-btn');
|
const copyBtn = document.getElementById('copy-btn');
|
||||||
@@ -932,37 +1019,40 @@ function renderPastePage(paste) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
</script>`}
|
</script>`
|
||||||
</body>
|
});
|
||||||
</html>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPasteErrorPage(reason = 'Paste not found') {
|
function renderPasteErrorPage(reason = 'Paste not found') {
|
||||||
|
const safeReason = reason.replace(/[<>&]/g, (m) => ({ '<': '<', '>': '>', '&': '&' }[m]));
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Paste unavailable - peer.paste</title>
|
<title>Paste unavailable - peer.paste</title>
|
||||||
<style>
|
<style>${PASTE_VIEW_STYLES}</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>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="wrap">
|
<div class="view-shell error-shell">
|
||||||
<section class="card">
|
<header class="view-header">
|
||||||
<h1>This paste is unavailable</h1>
|
<div class="view-brand">
|
||||||
<p>The paste may have expired, been consumed by burn-after-read, or been deleted by its owner.</p>
|
<a class="home" href="/">peer.paste</a>
|
||||||
<div class="code">${reason.replace(/[<>&]/g, (m) => ({ '<': '<', '>': '>', '&': '&' }[m]))}</div>
|
<h1>Unavailable</h1>
|
||||||
<a href="/">Create a new paste</a>
|
<div class="view-chips"><span class="chip accent">Not found</span></div>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
@@ -974,15 +1064,12 @@ function apiDocsHtml(baseUrl) {
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>peer.paste API Docs</title>
|
<title>peer.paste API Docs</title>
|
||||||
<style>
|
<style>${PASTE_VIEW_STYLES}
|
||||||
body{font-family:system-ui;background:#0f172a;color:#e2e8f0;margin:0;padding:1.25rem}
|
.wrap{max-width:900px}
|
||||||
.wrap{max-width:1100px;margin:0 auto}
|
.m{display:inline-block;padding:.2rem .5rem;border-radius:6px;font-weight:700;font-size:.75rem;margin-right:.5rem;color:#fff}
|
||||||
.card{background:#111827;border:1px solid #334155;border-radius:12px;padding:1rem;margin-bottom:1rem}
|
.GET{background:#15803d}.POST{background:var(--accent)}.PUT{background:#b45309}.DELETE{background:#b91c1c}
|
||||||
.m{display:inline-block;padding:.2rem .5rem;border-radius:6px;font-weight:700;font-size:.8rem;margin-right:.5rem}
|
code,pre{background:#0c0c0e;border:1px solid var(--border);border-radius:8px;font-family:var(--mono);font-size:.8125rem}
|
||||||
.GET{background:#059669}.POST{background:#2563eb}.PUT{background:#d97706}.DELETE{background:#dc2626}
|
|
||||||
code,pre{background:#020617;border:1px solid #1e293b;border-radius:8px}
|
|
||||||
pre{padding:.75rem;overflow:auto}
|
pre{padding:.75rem;overflow:auto}
|
||||||
a{color:#60a5fa}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -8,88 +8,106 @@
|
|||||||
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
|
<link rel="stylesheet" href="/api/vendor/highlight-dark.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="page">
|
<div class="app-shell">
|
||||||
<section class="hero">
|
<header class="app-header">
|
||||||
<h1>peer.paste</h1>
|
<div class="brand">
|
||||||
<p>Share temporary text with one clean link.</p>
|
<h1>peer.paste</h1>
|
||||||
</section>
|
<p>Share temporary text with one clean link.</p>
|
||||||
|
</div>
|
||||||
|
<span id="live-dot" class="live-dot">LIVE</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section class="panel">
|
<div class="app-body">
|
||||||
<form id="create-form" class="create-form">
|
<section class="compose panel">
|
||||||
<label for="content" class="label">Secret / Paste</label>
|
<form id="create-form" class="create-form">
|
||||||
<div class="toolbar">
|
<div class="compose-grid">
|
||||||
<select id="format">
|
<div class="compose-main">
|
||||||
<option value="text">Text</option>
|
<label for="content" class="label">Paste</label>
|
||||||
<option value="markdown">Markdown</option>
|
<div class="toolbar">
|
||||||
</select>
|
<select id="format" title="Format">
|
||||||
<select id="theme">
|
<option value="text">Text</option>
|
||||||
<option value="dark">Dark</option>
|
<option value="markdown">Markdown</option>
|
||||||
<option value="light">Light</option>
|
</select>
|
||||||
</select>
|
<select id="theme" title="Theme">
|
||||||
<button type="button" id="toggle-preview" class="ghost">Preview</button>
|
<option value="dark">Dark</option>
|
||||||
<button type="button" id="copy-editor" class="ghost">Copy Text</button>
|
<option value="light">Light</option>
|
||||||
</div>
|
</select>
|
||||||
<textarea id="content" rows="14" required placeholder="Paste text here..."></textarea>
|
<button type="button" id="toggle-preview" class="ghost">Preview</button>
|
||||||
<div id="preview-panel" class="preview-panel hidden">
|
<button type="button" id="copy-editor" class="ghost">Copy</button>
|
||||||
<div id="preview-content">Preview will appear here.</div>
|
</div>
|
||||||
</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">
|
<aside class="compose-options" aria-label="Paste options">
|
||||||
<summary>Advanced options</summary>
|
<h2 class="options-title">Options</h2>
|
||||||
<div class="advanced-grid">
|
<div class="options-stack">
|
||||||
<label>Language (optional)
|
<label class="field">
|
||||||
<input type="text" id="language" maxlength="32" placeholder="plain / js / json / md" />
|
<span>Language</span>
|
||||||
</label>
|
<input type="text" id="language" maxlength="32" placeholder="plain, js, json…" />
|
||||||
<label>Expires in hours
|
</label>
|
||||||
<input type="number" id="expiresHours" min="1" max="168" value="24" />
|
<label class="field">
|
||||||
</label>
|
<span>Expires (hours)</span>
|
||||||
<label>Max reads (0 = unlimited)
|
<input type="number" id="expiresHours" min="1" max="168" value="24" />
|
||||||
<input type="number" id="maxReads" min="0" max="1000000" value="0" />
|
</label>
|
||||||
</label>
|
<label class="field">
|
||||||
<label class="check-row">
|
<span>Max reads</span>
|
||||||
<input type="checkbox" id="destroyOnRead" />
|
<input type="number" id="maxReads" min="0" max="1000000" value="0" title="0 = unlimited" />
|
||||||
Burn after first read
|
</label>
|
||||||
</label>
|
<label class="field">
|
||||||
<label class="check-row">
|
<span>Passphrase</span>
|
||||||
<input type="checkbox" id="publicPaste" />
|
<input id="passphrase" type="password" autocomplete="off" placeholder="Optional" />
|
||||||
Public paste (disable encryption)
|
</label>
|
||||||
</label>
|
<label class="field">
|
||||||
<label>Passphrase (optional encryption key derivation)
|
<span>Attachments</span>
|
||||||
<input id="passphrase" type="password" autocomplete="off" placeholder="Optional extra layer" />
|
<input id="attachments" type="file" multiple />
|
||||||
</label>
|
</label>
|
||||||
<label>Attachments (up to 64MB total)
|
<label class="check-row">
|
||||||
<input id="attachments" type="file" multiple />
|
<input type="checkbox" id="destroyOnRead" />
|
||||||
</label>
|
<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>
|
</div>
|
||||||
</details>
|
|
||||||
|
|
||||||
<div class="actions">
|
<div id="create-result" class="result hidden">
|
||||||
<button type="submit" id="create-btn">Create secure link</button>
|
<div class="result-inner">
|
||||||
<button type="button" id="clear-btn" class="ghost">Clear</button>
|
<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>
|
</div>
|
||||||
</form>
|
<div id="sidebar-scroll" class="sidebar-scroll">
|
||||||
|
<div id="list" class="sidebar-list"></div>
|
||||||
<div id="create-result" class="result hidden">
|
<div id="list-loading" class="list-loading hidden">Loading more…</div>
|
||||||
<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>
|
</div>
|
||||||
<div class="meta-row" id="result-meta"></div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<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>
|
|
||||||
<script src="/api/vendor/marked.min.js"></script>
|
<script src="/api/vendor/marked.min.js"></script>
|
||||||
<script src="/api/vendor/highlight.min.js"></script>
|
<script src="/api/vendor/highlight.min.js"></script>
|
||||||
<script src="/main.js"></script>
|
<script src="/main.js"></script>
|
||||||
|
|||||||
@@ -184,24 +184,24 @@ function renderRecentList() {
|
|||||||
list.textContent = 'No active pastes yet.';
|
list.textContent = 'No active pastes yet.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = rows.map((p) => `
|
list.innerHTML = rows.map((p) => {
|
||||||
<div class="paste-item">
|
const idLabel = p.id.length > 14 ? `${p.id.slice(0, 12)}…` : p.id;
|
||||||
<div class="paste-title">${escapeHtml(p.id)}</div>
|
return `
|
||||||
|
<article class="paste-item">
|
||||||
|
<div class="paste-title" title="${escapeHtml(p.id)}">${escapeHtml(idLabel)}</div>
|
||||||
<div class="paste-meta">
|
<div class="paste-meta">
|
||||||
id: ${p.id} |
|
<span>${msToHuman(p.expiresInMs)}</span>
|
||||||
reads: ${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} |
|
<span>${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} reads</span>
|
||||||
expires: ${msToHuman(p.expiresInMs)} |
|
<span>${escapeHtml(p.format || 'text')}</span>
|
||||||
format: ${escapeHtml(p.format || 'text')} |
|
|
||||||
${p.isPublic ? 'public' : (p.encryptionMode || 'encrypted')}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="paste-actions">
|
<div class="paste-actions">
|
||||||
<a href="/p/${p.id}" target="_blank" rel="noopener">Open</a>
|
<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</a>
|
||||||
<a href="#" data-copy-url="/p/${p.id}">Copy link</a>
|
|
||||||
<a href="#" data-destroy-id="${p.id}" class="danger">Destroy</a>
|
<a href="#" data-destroy-id="${p.id}" class="danger">Destroy</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRealtimePage(data, reset = false) {
|
function handleRealtimePage(data, reset = false) {
|
||||||
@@ -342,7 +342,7 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
|
|||||||
setCreateState(err.message, true);
|
setCreateState(err.message, true);
|
||||||
} finally {
|
} finally {
|
||||||
createBtn.disabled = false;
|
createBtn.disabled = false;
|
||||||
createBtn.textContent = 'Create secure link';
|
updateCreateButtonLabel();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -446,10 +446,21 @@ function connectWebSocket() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('scroll', () => {
|
function onSidebarScroll() {
|
||||||
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 280;
|
const el = document.getElementById('sidebar-scroll');
|
||||||
if (nearBottom) loadNextPage();
|
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', () => {
|
document.getElementById('toggle-preview').addEventListener('click', () => {
|
||||||
const panel = document.getElementById('preview-panel');
|
const panel = document.getElementById('preview-panel');
|
||||||
|
|||||||
@@ -1,366 +1,587 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
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;
|
box-sizing: border-box;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 7px;
|
||||||
height: 8px;
|
height: 7px;
|
||||||
}
|
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb {
|
*::-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-radius: 999px;
|
||||||
border: 2px solid transparent;
|
|
||||||
background-clip: padding-box;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-thumb:hover {
|
html,
|
||||||
background: linear-gradient(180deg, rgba(96, 165, 250, 0.7), rgba(148, 163, 184, 0.7));
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
font-family: var(--font-sans);
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
font-size: 14px;
|
||||||
background: radial-gradient(circle at top, #1e293b 0%, #0b1220 45%, #070d18 100%);
|
line-height: 1.45;
|
||||||
color: #e6edf8;
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
body::before {
|
||||||
max-width: 920px;
|
content: "";
|
||||||
margin: 2.25rem auto;
|
position: fixed;
|
||||||
padding: 0 1rem 2.5rem;
|
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;
|
margin: 0;
|
||||||
font-size: 2rem;
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero p {
|
.brand p {
|
||||||
margin: 0.35rem 0 1.25rem;
|
margin: 0.1rem 0 0;
|
||||||
color: #9fb0cb;
|
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 {
|
.panel {
|
||||||
background: rgba(12, 18, 32, 0.84);
|
background: var(--surface);
|
||||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
border: 1px solid var(--border);
|
||||||
border-radius: 16px;
|
box-shadow: var(--shadow);
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
box-shadow: 0 8px 28px rgba(2, 6, 23, 0.28);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact {
|
.compose.panel {
|
||||||
padding: 0.9rem;
|
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 {
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 0.92rem;
|
font-size: 0.75rem;
|
||||||
color: #9fb0cb;
|
font-weight: 500;
|
||||||
margin-bottom: 0.4rem;
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.55rem;
|
margin-bottom: 0.4rem;
|
||||||
padding: 0.4rem;
|
|
||||||
border: 1px solid #223047;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: rgba(10, 15, 28, 0.72);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar select {
|
.toolbar select {
|
||||||
background: #070d18;
|
background: var(--bg-input);
|
||||||
color: #edf3ff;
|
color: var(--text);
|
||||||
border: 1px solid #334155;
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 0.45rem 0.6rem;
|
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,
|
textarea,
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="number"] {
|
input[type="number"],
|
||||||
width: 100%;
|
input[type="password"],
|
||||||
border-radius: 10px;
|
input[type="file"] {
|
||||||
border: 1px solid #334155;
|
border-radius: var(--radius-sm);
|
||||||
background: #070d18;
|
border: 1px solid var(--border);
|
||||||
color: #edf3ff;
|
background: var(--bg-input);
|
||||||
padding: 0.68rem 0.72rem;
|
color: var(--text);
|
||||||
font: inherit;
|
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 {
|
input[type="text"],
|
||||||
resize: vertical;
|
input[type="number"],
|
||||||
min-height: 240px;
|
input[type="password"] {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
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,
|
textarea:focus,
|
||||||
input:focus,
|
input:focus,
|
||||||
button:focus,
|
button:focus-visible,
|
||||||
a:focus {
|
a:focus-visible {
|
||||||
outline: 2px solid #3b82f6;
|
outline: none;
|
||||||
outline-offset: 1px;
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.22);
|
box-shadow: 0 0 0 2px var(--accent-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.advanced {
|
.compose-options {
|
||||||
margin-top: 0.85rem;
|
display: flex;
|
||||||
border-top: 1px solid #1e293b;
|
flex-direction: column;
|
||||||
padding-top: 0.8rem;
|
min-height: 0;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
padding-left: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advanced summary {
|
.options-title {
|
||||||
cursor: pointer;
|
margin: 0 0 0.5rem;
|
||||||
color: #9fb0cb;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.advanced-grid {
|
.options-stack {
|
||||||
margin-top: 0.85rem;
|
display: flex;
|
||||||
display: grid;
|
flex-direction: column;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
gap: 0.5rem;
|
||||||
gap: 0.75rem;
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.advanced-grid label {
|
.field {
|
||||||
display: block;
|
display: flex;
|
||||||
font-size: 0.88rem;
|
flex-direction: column;
|
||||||
color: #9fb0cb;
|
gap: 0.2rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field > span {
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.check-row {
|
.check-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.4rem;
|
||||||
padding-top: 1.7rem;
|
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 {
|
.actions {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.6rem;
|
gap: 0.45rem;
|
||||||
margin-top: 0.95rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button,
|
||||||
.link-btn {
|
.link-btn {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-md);
|
||||||
background: #3b82f6;
|
background: var(--accent);
|
||||||
color: #f8fbff;
|
color: #fff;
|
||||||
padding: 0.62rem 0.95rem;
|
padding: 0.45rem 0.85rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: transform 110ms ease, background-color 120ms ease, border-color 120ms ease;
|
transition: background-color 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover,
|
button:hover,
|
||||||
.link-btn:hover {
|
.link-btn:hover {
|
||||||
background: #2563eb;
|
background: var(--accent-hover);
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
.ghost {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #334155;
|
border: 1px solid var(--border-strong);
|
||||||
color: #d6e2f7;
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost:hover {
|
.ghost:hover {
|
||||||
background: #122039;
|
background: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none;
|
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 {
|
.list-head {
|
||||||
display: flex;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
margin-bottom: 0.45rem;
|
||||||
justify-content: space-between;
|
padding: 0 0.15rem;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head h2 {
|
.list-head h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
#list {
|
.live-dot {
|
||||||
color: #9fb0cb;
|
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 {
|
.paste-item {
|
||||||
padding: 0.72rem 0;
|
padding: 0.55rem 0.5rem;
|
||||||
border-top: 1px solid #1e293b;
|
border: 1px solid var(--border);
|
||||||
transition: background-color 120ms ease;
|
border-radius: var(--radius-md);
|
||||||
border-radius: 8px;
|
background: var(--bg-subtle);
|
||||||
padding-left: 0.25rem;
|
transition: background-color 0.12s ease, border-color 0.12s ease;
|
||||||
padding-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.paste-item:first-child {
|
|
||||||
border-top: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.paste-item:hover {
|
.paste-item:hover {
|
||||||
background: rgba(37, 99, 235, 0.08);
|
background: var(--accent-muted);
|
||||||
|
border-color: rgba(99, 102, 241, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.paste-title {
|
.paste-title {
|
||||||
color: #eff5ff;
|
color: var(--text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.paste-meta {
|
.paste-meta {
|
||||||
margin-top: 0.2rem;
|
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 {
|
.paste-actions {
|
||||||
margin-top: 0.35rem;
|
margin-top: 0.35rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.65rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.75rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result .link-row input {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.danger {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-loading {
|
.list-loading {
|
||||||
margin-top: 0.6rem;
|
padding: 0.5rem;
|
||||||
color: #9fb0cb;
|
text-align: center;
|
||||||
font-size: 0.9rem;
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: var(--danger) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user