593 lines
20 KiB
HTML
593 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>squid.conf · proxy + wpad configurator</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
html, body { height: 100%; }
|
|
body {
|
|
background: #0a0908;
|
|
color: #e8e4dc;
|
|
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
.mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; }
|
|
.display { font-family: 'IBM Plex Sans Condensed', sans-serif; letter-spacing: -0.01em; }
|
|
|
|
header {
|
|
border-bottom: 1px solid #2a2724;
|
|
padding: 20px 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.logo {
|
|
width: 36px; height: 36px;
|
|
border: 1px solid #ff8a00;
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: #ff8a00;
|
|
}
|
|
.title { font-size: 22px; font-weight: 600; line-height: 1; }
|
|
.subtitle { font-size: 11px; color: #8a857d; margin-top: 4px; }
|
|
|
|
.layout {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
|
|
min-height: calc(100vh - 78px);
|
|
}
|
|
@media (max-width: 900px) {
|
|
.layout { grid-template-columns: 1fr; }
|
|
.left { border-right: none !important; border-bottom: 1px solid #2a2724; }
|
|
}
|
|
.left {
|
|
padding: 32px;
|
|
border-right: 1px solid #2a2724;
|
|
display: flex; flex-direction: column; gap: 32px;
|
|
}
|
|
.right {
|
|
padding: 32px;
|
|
display: flex; flex-direction: column; gap: 20px;
|
|
}
|
|
|
|
.section-head {
|
|
display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
|
|
}
|
|
.section-icon { color: #ff8a00; flex-shrink: 0; }
|
|
.section-title {
|
|
font-size: 12px; font-weight: 500; color: #e8e4dc;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.section-sub {
|
|
font-size: 10px; color: #8a857d; margin-top: 2px;
|
|
}
|
|
|
|
.chip-list { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
|
.chip {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
padding: 5px 10px;
|
|
background: #161513;
|
|
border: 1px solid #2a2724;
|
|
font-size: 11px;
|
|
transition: border-color 120ms ease;
|
|
}
|
|
.chip:hover { border-color: #ff8a00; }
|
|
.chip-x {
|
|
background: transparent; border: none; color: #ff8a00;
|
|
cursor: pointer; padding: 0; display: flex; align-items: center;
|
|
opacity: 0; transition: opacity 120ms ease;
|
|
}
|
|
.chip:hover .chip-x { opacity: 1; }
|
|
.chip-empty { font-size: 11px; color: #5a5751; padding: 8px 0; }
|
|
|
|
.input-row { display: flex; gap: 6px; }
|
|
.field {
|
|
background: #0a0908;
|
|
border: 1px solid #2a2724;
|
|
color: #e8e4dc;
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
transition: border-color 120ms ease;
|
|
width: 100%;
|
|
}
|
|
.field:focus { outline: none; border-color: #ff8a00; }
|
|
.field-label {
|
|
font-size: 9px; color: #5a5751; text-transform: uppercase;
|
|
letter-spacing: 0.1em; margin-bottom: 4px;
|
|
}
|
|
.field-grid { display: grid; grid-template-columns: 1fr 100px; gap: 8px; }
|
|
|
|
.btn {
|
|
background: transparent;
|
|
color: #8a857d;
|
|
border: 1px solid #2a2724;
|
|
padding: 8px 14px;
|
|
font-size: 11px;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
cursor: pointer;
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
transition: all 120ms ease;
|
|
}
|
|
.btn:hover:not(:disabled) { background: #1f1d1a; border-color: #ff8a00; color: #e8e4dc; }
|
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
.btn-primary { color: #ff8a00; border-color: #ff8a00; }
|
|
.btn-primary:hover:not(:disabled) { background: #ff8a00; color: #0a0908; }
|
|
.btn-success { color: #4ade80; border-color: #4ade80; }
|
|
.btn-danger { color: #f87171; border-color: #f87171; }
|
|
|
|
.actions { display: flex; gap: 8px; align-items: center; }
|
|
|
|
.footnote {
|
|
font-size: 10px; color: #5a5751;
|
|
border-top: 1px solid #2a2724;
|
|
padding-top: 16px;
|
|
margin-top: auto;
|
|
line-height: 1.7;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
}
|
|
|
|
.out-card {
|
|
background: #0f0e0c;
|
|
border: 1px solid #2a2724;
|
|
}
|
|
.out-head {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 12px 16px; border-bottom: 1px solid #2a2724; gap: 8px;
|
|
}
|
|
.out-name {
|
|
font-size: 12px; font-weight: 500; color: #e8e4dc;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
}
|
|
.out-path {
|
|
font-size: 10px; color: #5a5751; margin-top: 1px;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.out-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
|
.out-actions .btn { padding: 6px 10px; font-size: 10px; }
|
|
.out-code {
|
|
margin: 0; padding: 16px;
|
|
font-size: 11px; line-height: 1.7;
|
|
color: #c8c4bc;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
overflow: auto; max-height: 320px;
|
|
background: #0a0908;
|
|
white-space: pre;
|
|
}
|
|
.out-code::-webkit-scrollbar { height: 6px; width: 6px; }
|
|
.out-code::-webkit-scrollbar-thumb { background: #2a2724; border-radius: 3px; }
|
|
.out-code::-webkit-scrollbar-thumb:hover { background: #ff8a00; }
|
|
|
|
.right-head {
|
|
display: flex; justify-content: space-between; align-items: flex-end;
|
|
margin-bottom: 4px; gap: 16px;
|
|
}
|
|
.gen-label {
|
|
font-size: 10px; color: #ff8a00;
|
|
letter-spacing: 0.1em; margin-bottom: 4px;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
}
|
|
.gen-title { font-size: 18px; font-weight: 600; }
|
|
|
|
.toast {
|
|
position: fixed; bottom: 24px; right: 24px;
|
|
background: #161513; border: 1px solid #2a2724;
|
|
padding: 12px 16px; font-size: 11px;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
display: flex; align-items: center; gap: 8px;
|
|
transform: translateY(100px); opacity: 0;
|
|
transition: all 200ms ease;
|
|
z-index: 100;
|
|
}
|
|
.toast.show { transform: translateY(0); opacity: 1; }
|
|
.toast.ok { border-color: #4ade80; color: #4ade80; }
|
|
.toast.warn { border-color: #facc15; color: #facc15; }
|
|
.toast.err { border-color: #f87171; color: #f87171; }
|
|
|
|
.dirty-dot {
|
|
width: 6px; height: 6px; border-radius: 50%;
|
|
background: #ff8a00;
|
|
display: inline-block;
|
|
}
|
|
|
|
.loading {
|
|
display: flex; align-items: center; justify-content: center;
|
|
height: 100vh; color: #8a857d; font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 12px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="root"><div class="loading">loading...</div></div>
|
|
|
|
<script type="module">
|
|
import { h, render } from 'https://esm.sh/preact@10.24.3';
|
|
import { useState, useEffect, useCallback } from 'https://esm.sh/preact@10.24.3/hooks';
|
|
import htm from 'https://esm.sh/htm@3.1.1';
|
|
const html = htm.bind(h);
|
|
|
|
// --- Inline SVG icons (no external icon lib for size) ---
|
|
const Icon = (paths, size = 14) => () => html`
|
|
<svg width=${size} height=${size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
${paths.map((d) => html`<path d=${d} />`)}
|
|
</svg>
|
|
`;
|
|
const NetworkIcon = Icon(["M9 2L9 7","M15 2L15 7","M9 17L9 22","M15 17L15 22","M2 9L7 9","M2 15L7 15","M17 9L22 9","M17 15L22 15","M7 7L17 7L17 17L7 17Z"]);
|
|
const GlobeIcon = Icon(["M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z","M2 12h20","M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"]);
|
|
const ServerIcon = Icon(["M2 4h20v6H2z","M2 14h20v6H2z","M6 7h.01","M6 17h.01"]);
|
|
const TerminalIcon = Icon(["M4 17l6-6-6-6","M12 19h8"]);
|
|
const FileIcon = Icon(["M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z","M14 2v6h6","M16 13H8","M16 17H8","M10 9H8"]);
|
|
const CodeIcon = Icon(["M16 18l6-6-6-6","M8 6l-6 6 6 6"]);
|
|
const PlusIcon = Icon(["M12 5v14","M5 12h14"]);
|
|
const XIcon = Icon(["M18 6L6 18","M6 6l12 12"], 11);
|
|
const CopyIcon = Icon(["M16 3H4a2 2 0 0 0-2 2v12","M8 7h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"], 11);
|
|
const DownloadIcon = Icon(["M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4","M7 10l5 5 5-5","M12 15V3"], 11);
|
|
const CheckIcon = Icon(["M20 6L9 17l-5-5"], 11);
|
|
const ResetIcon = Icon(["M3 12a9 9 0 1 0 9-9","M3 4v5h5"], 12);
|
|
const SaveIcon = Icon(["M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z","M17 21v-8H7v8","M7 3v5h8"]);
|
|
const RefreshIcon = Icon(["M23 4v6h-6","M1 20v-6h6","M3.51 9a9 9 0 0 1 14.85-3.36L23 10","M1 14l4.64 4.36A9 9 0 0 0 20.49 15"], 12);
|
|
|
|
// --- API ---
|
|
async function apiGet(url) {
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error((await r.json()).error || r.statusText);
|
|
return r.json();
|
|
}
|
|
async function apiPost(url, body) {
|
|
const r = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const data = await r.json();
|
|
if (!r.ok) throw new Error(data.error || r.statusText);
|
|
return data;
|
|
}
|
|
|
|
// --- Generators (mirror server, for live preview) ---
|
|
function genSquid(s) {
|
|
return `http_port 3128
|
|
|
|
# Erlaubte Subnetze
|
|
${s.networks.map(n => `acl localnet src ${n}`).join('\n')}
|
|
|
|
http_access allow localnet
|
|
http_access deny all
|
|
|
|
# Domain-Whitelist
|
|
acl vpn_domains dstdomain "/squid-config/vpn_domains.txt"
|
|
|
|
# Upstream zu Gluetun
|
|
cache_peer ${s.upstreamHost} parent ${s.upstreamPort} 0 no-query default name=gluetun
|
|
never_direct allow vpn_domains
|
|
cache_peer_access gluetun allow vpn_domains
|
|
cache_peer_access gluetun deny all
|
|
|
|
never_direct deny all
|
|
forwarded_for delete
|
|
|
|
access_log /var/log/squid/access.log
|
|
cache_log /var/log/squid/cache.log
|
|
`;
|
|
}
|
|
function genDomains(s) {
|
|
let wildcardBases = s.domains
|
|
.filter(d => d.startsWith('*.'))
|
|
.map(d => d.slice(2));
|
|
wildcardBases = wildcardBases.filter(base =>
|
|
!wildcardBases.some(other => other !== base && base.endsWith('.' + other))
|
|
);
|
|
const isCovered = d => wildcardBases.some(b => d === b || d.endsWith('.' + b));
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const base of wildcardBases) {
|
|
const dotted = '.' + base;
|
|
if (!seen.has(dotted)) { out.push(dotted); seen.add(dotted); }
|
|
}
|
|
for (const d of s.domains) {
|
|
if (d.startsWith('*.')) continue;
|
|
if (isCovered(d) || seen.has(d)) continue;
|
|
out.push(d); seen.add(d);
|
|
}
|
|
return out.join('\n') + '\n';
|
|
}
|
|
function genWpad(s) {
|
|
return `function FindProxyForURL(url, host) {
|
|
var vpn_domains = [
|
|
${s.domains.map(d => ` "${d}"`).join(',\n')}
|
|
];
|
|
|
|
for (var i = 0; i < vpn_domains.length; i++) {
|
|
if (shExpMatch(host, vpn_domains[i])) {
|
|
return "PROXY ${s.proxyHost}:${s.proxyPort}";
|
|
}
|
|
}
|
|
|
|
return "DIRECT";
|
|
}
|
|
`;
|
|
}
|
|
|
|
// --- Components ---
|
|
function App() {
|
|
const [state, setState] = useState(null);
|
|
const [savedState, setSavedState] = useState(null);
|
|
const [newNet, setNewNet] = useState('');
|
|
const [newDom, setNewDom] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [toast, setToast] = useState(null);
|
|
const [copied, setCopied] = useState(null);
|
|
|
|
useEffect(() => {
|
|
apiGet('/api/config')
|
|
.then((cfg) => { setState(cfg); setSavedState(JSON.stringify(cfg)); })
|
|
.catch((e) => showToast('err', `load failed: ${e.message}`));
|
|
}, []);
|
|
|
|
const showToast = (kind, msg, ms = 2500) => {
|
|
setToast({ kind, msg });
|
|
setTimeout(() => setToast(null), ms);
|
|
};
|
|
|
|
const update = (patch) => setState((s) => ({ ...s, ...patch }));
|
|
const dirty = state && JSON.stringify(state) !== savedState;
|
|
|
|
const save = async () => {
|
|
if (!state) return;
|
|
setBusy(true);
|
|
try {
|
|
const r = await apiPost('/api/config', state);
|
|
setSavedState(JSON.stringify(state));
|
|
if (r.reloaded) showToast('ok', 'saved & squid reloaded');
|
|
else showToast('warn', `saved, reload failed: ${r.reloadError ?? 'unknown'}`, 4000);
|
|
} catch (e) {
|
|
showToast('err', e.message, 4000);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const reload = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await apiPost('/api/reload');
|
|
showToast('ok', 'squid reloaded');
|
|
} catch (e) {
|
|
showToast('err', e.message, 4000);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const reset = async () => {
|
|
if (!confirm('Auf Defaults zurücksetzen? Wird erst beim Speichern persistiert.')) return;
|
|
try {
|
|
// Reset to defaults but don't save yet
|
|
const defaults = {
|
|
networks: ['192.168.0.0/24', '192.168.1.0/24', '192.168.2.0/24'],
|
|
domains: ['*.netflix.com', '*.spotify.com'],
|
|
upstreamHost: 'gluetun', upstreamPort: '8888',
|
|
proxyHost: 'cerberus.lan', proxyPort: '3128',
|
|
};
|
|
setState(defaults);
|
|
} catch (e) {
|
|
showToast('err', e.message);
|
|
}
|
|
};
|
|
|
|
const addNet = () => {
|
|
const v = newNet.trim();
|
|
if (v && !state.networks.includes(v)) {
|
|
update({ networks: [...state.networks, v] });
|
|
setNewNet('');
|
|
}
|
|
};
|
|
const addDom = () => {
|
|
const v = newDom.trim();
|
|
if (v && !state.domains.includes(v)) {
|
|
update({ domains: [...state.domains, v] });
|
|
setNewDom('');
|
|
}
|
|
};
|
|
|
|
const copyText = async (key, text) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
} catch {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
|
document.body.appendChild(ta); ta.select(); document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
}
|
|
setCopied(key);
|
|
setTimeout(() => setCopied(null), 1500);
|
|
};
|
|
|
|
const downloadFile = (name, content) => {
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = name;
|
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
if (!state) return html`<div class="loading">loading...</div>`;
|
|
|
|
const outputs = [
|
|
{ key: 'squid', name: 'squid.conf', path: '/opt/squid/config/squid.conf', content: genSquid(state), Icon: TerminalIcon },
|
|
{ key: 'domains', name: 'vpn_domains.txt', path: '/opt/squid/config/vpn_domains.txt', content: genDomains(state), Icon: FileIcon },
|
|
{ key: 'wpad', name: 'wpad.dat', path: '/opt/squid/wpad/wpad.dat', content: genWpad(state), Icon: CodeIcon },
|
|
];
|
|
|
|
return html`
|
|
<header>
|
|
<div style="display:flex; align-items:center; gap:16px;">
|
|
<div class="logo">${h(NetworkIcon, { size: 18 })}</div>
|
|
<div>
|
|
<div class="display title">squid<span style="color:#ff8a00;">.</span>conf</div>
|
|
<div class="subtitle mono">proxy + wpad configurator</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<button class="btn" onClick=${reset} disabled=${busy}>
|
|
${h(ResetIcon, {})} reset
|
|
</button>
|
|
<button class="btn" onClick=${reload} disabled=${busy}>
|
|
${h(RefreshIcon, {})} reload squid
|
|
</button>
|
|
<button class=${`btn ${dirty ? 'btn-primary' : ''}`}
|
|
onClick=${save} disabled=${!dirty || busy}>
|
|
${h(SaveIcon, { size: 12 })} ${busy ? 'saving...' : 'save & apply'}
|
|
${dirty && !busy ? html`<span class="dirty-dot"></span>` : ''}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="layout">
|
|
<div class="left">
|
|
<${Section} icon=${NetworkIcon} title="allowed networks" subtitle="subnets that may use this proxy">
|
|
<div class="chip-list">
|
|
${state.networks.map((n) => html`
|
|
<div class="chip mono" key=${n}>
|
|
<span>${n}</span>
|
|
<button class="chip-x" onClick=${() => update({ networks: state.networks.filter(x => x !== n) })}>
|
|
${h(XIcon, {})}
|
|
</button>
|
|
</div>
|
|
`)}
|
|
${state.networks.length === 0 && html`<div class="chip-empty mono">no networks — proxy will deny all</div>`}
|
|
</div>
|
|
<${InputRow} value=${newNet} onChange=${setNewNet} onSubmit=${addNet} placeholder="192.168.3.0/24" />
|
|
<//>
|
|
|
|
<${Section} icon=${GlobeIcon} title="proxied domains" subtitle="whitelist routed through gluetun">
|
|
<div class="chip-list">
|
|
${state.domains.map((d) => html`
|
|
<div class="chip mono" key=${d}>
|
|
<span>${d}</span>
|
|
<button class="chip-x" onClick=${() => update({ domains: state.domains.filter(x => x !== d) })}>
|
|
${h(XIcon, {})}
|
|
</button>
|
|
</div>
|
|
`)}
|
|
${state.domains.length === 0 && html`<div class="chip-empty mono">no domains — nothing will be proxied</div>`}
|
|
</div>
|
|
<${InputRow} value=${newDom} onChange=${setNewDom} onSubmit=${addDom} placeholder="*.example.com" />
|
|
<div class="mono" style="font-size:10px; color:#5a5751; margin-top:8px;">
|
|
wildcards work in PAC. for squid, leading dot is auto-added.
|
|
</div>
|
|
<//>
|
|
|
|
<${Section} icon=${ServerIcon} title="upstream proxy (gluetun)" subtitle="where matched traffic is forwarded">
|
|
<div class="field-grid">
|
|
<${Field} label="host" value=${state.upstreamHost} onChange=${(v) => update({ upstreamHost: v })} placeholder="gluetun" />
|
|
<${Field} label="port" value=${state.upstreamPort} onChange=${(v) => update({ upstreamPort: v })} placeholder="8888" />
|
|
</div>
|
|
<//>
|
|
|
|
<${Section} icon=${TerminalIcon} title="squid address (for PAC)" subtitle="how clients reach this squid instance">
|
|
<div class="field-grid">
|
|
<${Field} label="host" value=${state.proxyHost} onChange=${(v) => update({ proxyHost: v })} placeholder="cerberus.lan" />
|
|
<${Field} label="port" value=${state.proxyPort} onChange=${(v) => update({ proxyPort: v })} placeholder="3128" />
|
|
</div>
|
|
<//>
|
|
|
|
<div class="footnote">
|
|
changes apply on save · backend writes files atomically · squid reloads via docker exec
|
|
</div>
|
|
</div>
|
|
|
|
<div class="right">
|
|
<div class="right-head">
|
|
<div>
|
|
<div class="gen-label">GENERATED OUTPUT</div>
|
|
<div class="gen-title display">${dirty ? 'Live-Vorschau (ungespeichert)' : 'Aktuelle Konfiguration'}</div>
|
|
</div>
|
|
</div>
|
|
|
|
${outputs.map((o) => html`
|
|
<div class="out-card" key=${o.key}>
|
|
<div class="out-head">
|
|
<div style="display:flex; align-items:center; gap:10px; min-width:0;">
|
|
${h(o.Icon, { size: 13 })}
|
|
<div style="min-width:0;">
|
|
<div class="out-name">${o.name}</div>
|
|
<div class="out-path">${o.path}</div>
|
|
</div>
|
|
</div>
|
|
<div class="out-actions">
|
|
<button class=${`btn ${copied === o.key ? 'btn-success' : ''}`}
|
|
onClick=${() => copyText(o.key, o.content)}>
|
|
${copied === o.key ? h(CheckIcon, {}) : h(CopyIcon, {})}
|
|
${copied === o.key ? 'copied' : 'copy'}
|
|
</button>
|
|
<button class="btn" onClick=${() => downloadFile(o.name, o.content)}>
|
|
${h(DownloadIcon, {})} save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<pre class="out-code">${o.content}</pre>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
</div>
|
|
|
|
${toast && html`
|
|
<div class=${`toast show ${toast.kind}`}>
|
|
${toast.kind === 'ok' ? h(CheckIcon, {}) : '⚠'} ${toast.msg}
|
|
</div>
|
|
`}
|
|
`;
|
|
}
|
|
|
|
function Section({ icon, title, subtitle, children }) {
|
|
return html`
|
|
<section>
|
|
<div class="section-head">
|
|
<div class="section-icon">${h(icon, {})}</div>
|
|
<div>
|
|
<div class="section-title mono">${title}</div>
|
|
<div class="section-sub mono">${subtitle}</div>
|
|
</div>
|
|
</div>
|
|
${children}
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
function InputRow({ value, onChange, onSubmit, placeholder }) {
|
|
return html`
|
|
<div class="input-row">
|
|
<input class="field" type="text" value=${value}
|
|
placeholder=${placeholder}
|
|
onInput=${(e) => onChange(e.target.value)}
|
|
onKeyDown=${(e) => e.key === 'Enter' && onSubmit()} />
|
|
<button class="btn btn-primary" onClick=${onSubmit}>
|
|
${h(PlusIcon, { size: 12 })} add
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function Field({ label, value, onChange, placeholder }) {
|
|
return html`
|
|
<div>
|
|
<div class="field-label mono">${label}</div>
|
|
<input class="field" type="text" value=${value}
|
|
placeholder=${placeholder}
|
|
onInput=${(e) => onChange(e.target.value)} />
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render(html`<${App}/>`, document.getElementById('root'));
|
|
</script>
|
|
</body>
|
|
</html> |