Files
qrknit/index.html
T
Jason Stedwell 8f2bfd23d2 feat: API key management endpoints and dashboard panel
Self-serve keys: GET/POST /api/keys, DELETE /api/keys/:id (admins may
revoke any key), GET /api/admin/keys for a platform-wide view. Tokens are
qrk_-prefixed, stored only as a SHA-256 hash, and returned exactly once at
creation. Read-scope keys are rejected with 403 on non-GET requests; key
creation and revocation are recorded in the audit log.

Dashboard gets a self-serve key panel with copy-once token display, scope
badges, and last-used timestamps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 01:39:50 -05:00

2401 lines
128 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<script>
(function(){var t=localStorage.getItem('qrknit-theme');if(t&&t!=='default')document.documentElement.setAttribute('data-theme',t);})();
</script>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>QRknit — Dashboard</title>
<meta name="description" content="Manage your short links and QR codes in the QRknit dashboard. View click analytics, create new links, generate QR codes, and manage your team.">
<meta name="robots" content="noindex, nofollow">
<link rel="canonical" href="https://qrknit.com/app">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://qrknit.com/app">
<meta property="og:site_name" content="QRknit">
<meta property="og:title" content="QRknit — Dashboard">
<meta property="og:description" content="Manage your short links and QR codes in the QRknit dashboard.">
<meta property="og:image" content="https://qrknit.com/static/og-image.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="/static/qk-ico.png">
<link rel="apple-touch-icon" href="/static/qk-ico.png">
<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=Orbitron:wght@400;700;900&family=Share+Tech+Mono&family=Barlow:wght@300;400;500&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0a0f;
--surface: #111118;
--surface2: #181824;
--surface3: #1e1e2e;
--border: rgba(255,255,255,0.07);
--accent: #00b8d4;
--accent-rgb: 0,184,212;
--accent-grad: #1a52c8;
--accent-grad-rgb: 26,82,200;
--accent2: #ff5e62;
--accent3: #7c6af7;
--accent3-rgb: 124,106,247;
--accent3-light: #a89cf7;
--text: #f0f0f8;
--muted: #808098;
--radius: 12px;
--font-head: 'Orbitron', sans-serif;
--font-mono: 'Share Tech Mono', monospace;
--font-body: 'Barlow', sans-serif;
}
[data-theme="gold"] {
--accent: #f5a623; --accent-rgb: 245,166,35;
--accent-grad: #c47f17; --accent-grad-rgb: 196,127,23;
--accent3: #d4a847; --accent3-rgb: 212,168,71;
--accent3-light: #e8c96a;
}
[data-theme="green"] {
--accent: #00c896; --accent-rgb: 0,200,150;
--accent-grad: #007a58; --accent-grad-rgb: 0,122,88;
--accent3: #4caf7a; --accent3-rgb: 76,175,122;
--accent3-light: #7fd9a4;
}
[data-theme="red"] {
--accent: #ff4757; --accent-rgb: 255,71,87;
--accent-grad: #c0392b; --accent-grad-rgb: 192,57,43;
--accent3: #ff6b7a; --accent3-rgb: 255,107,122;
--accent3-light: #ff9daa;
}
html { scroll-behavior: smooth; }
body { background: var(--bg); color: var(--text); font-family: var(--font-body); font-size: 15px; line-height: 1.6; min-height: 100vh; overflow-x: hidden; }
body::before {
content:''; position:fixed; inset:0; pointer-events:none; z-index:0;
background: radial-gradient(ellipse 80% 60% at 20% -10%,rgba(var(--accent-rgb),.08) 0%,transparent 60%),
radial-gradient(ellipse 50% 40% at 85% 85%,rgba(var(--accent-grad-rgb),.07) 0%,transparent 55%),
radial-gradient(ellipse 40% 40% at 50% 50%,rgba(var(--accent-rgb),.03) 0%,transparent 60%);
}
/* ── HEADER ── */
header {
position:sticky; top:0; z-index:100;
display:flex; align-items:center; justify-content:space-between;
padding:0 40px; height:64px;
background:rgba(10,10,15,.88); backdrop-filter:blur(20px);
border-bottom:1px solid var(--border);
}
.logo { font-family:var(--font-head); font-size:22px; font-weight:700; letter-spacing:.04em; text-decoration:none; color:var(--text); display:inline-flex; align-items:center; gap:5px; }
.logo-dot-text { color:var(--accent); text-shadow:0 0 12px var(--accent); }
.logo-alwisp { text-transform:uppercase; }
/* Icon + per-theme hue tint */
.logo-icon-wrap { position:relative; display:inline-flex; align-items:center; flex-shrink:0; isolation:isolate; }
.logo-icon { height:35px; width:36px; object-fit:contain; border-radius:3px; display:block; }
.logo-icon-tint { position:absolute; inset:0; background:var(--accent); mix-blend-mode:hue; border-radius:3px; pointer-events:none; }
nav { display:flex; gap:4px; }
nav button { background:transparent; border:none; color:var(--muted); cursor:pointer; font-family:var(--font-body); font-size:14px; padding:6px 16px; border-radius:8px; transition:all .2s; }
nav button:hover { color:var(--text); background:var(--surface2); }
nav button.active { color:var(--accent); background:rgba(var(--accent-rgb),.08); }
.header-right { display:flex; align-items:center; gap:10px; }
.user-badge {
display:flex; align-items:center; gap:8px;
background:var(--surface2); border:1px solid var(--border);
border-radius:8px; padding:5px 12px; font-size:13px;
font-family:var(--font-mono);
}
.user-badge .admin-tag { color:var(--accent); font-size:10px; letter-spacing:.06em; }
.user-badge .plan-tag { font-size:10px; letter-spacing:.06em; color:var(--muted); border:1px solid var(--border); border-radius:4px; padding:1px 5px; }
.plan-usage-bar { margin-top:16px; background:var(--surface2); border:1px solid var(--border); border-radius:10px; padding:14px 18px; }
.plan-usage-bar h4 { font-family:var(--font-mono); font-size:11px; letter-spacing:.08em; color:var(--muted); margin:0 0 12px; }
.plan-usage-row { display:flex; align-items:center; gap:10px; margin-bottom:8px; font-family:var(--font-mono); font-size:12px; }
.plan-usage-row:last-child { margin-bottom:0; }
.plan-usage-label { color:var(--muted); width:130px; flex-shrink:0; }
.plan-usage-track { flex:1; height:6px; background:var(--border); border-radius:3px; overflow:hidden; }
.plan-usage-fill { height:100%; border-radius:3px; background:var(--accent); transition:width .4s; }
.plan-usage-fill.warn { background:#f59e0b; }
.plan-usage-fill.danger { background:var(--accent2); }
.plan-usage-count { color:var(--fg); min-width:80px; text-align:right; }
.btn-logout { background:transparent; border:1px solid var(--border); color:var(--muted); font-family:var(--font-mono); font-size:12px; padding:5px 12px; border-radius:8px; cursor:pointer; transition:all .2s; }
.btn-logout:hover { border-color:var(--accent2); color:var(--accent2); }
/* ── PORTAL ── */
.portal-btn { position:relative; background:transparent; border:1px solid rgba(var(--accent-rgb),.3); color:var(--accent); font-family:var(--font-mono); font-size:11px; letter-spacing:.08em; padding:5px 12px; border-radius:8px; cursor:pointer; transition:all .2s; }
.portal-btn:hover { background:rgba(var(--accent-rgb),.08); border-color:var(--accent); }
.portal-badge { position:absolute; top:-5px; right:-5px; min-width:16px; height:16px; background:var(--accent2); color:#fff; font-family:var(--font-mono); font-size:9px; font-weight:700; border-radius:8px; padding:0 4px; display:flex; align-items:center; justify-content:center; line-height:1; }
.portal-panel { position:absolute; top:calc(100% + 8px); right:0; width:380px; max-height:520px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 16px 48px rgba(0,0,0,.5); display:none; flex-direction:column; z-index:300; overflow:hidden; }
.portal-panel.open { display:flex; }
.portal-panel-hdr { display:flex; align-items:center; justify-content:space-between; padding:14px 16px 10px; border-bottom:1px solid var(--border); flex-shrink:0; }
.portal-panel-hdr h4 { font-family:var(--font-mono); font-size:12px; letter-spacing:.06em; color:var(--accent); }
.portal-hdr-actions { display:flex; gap:8px; align-items:center; }
.portal-mark-all { background:none; border:none; font-family:var(--font-mono); font-size:10px; color:var(--muted); cursor:pointer; padding:2px 6px; border-radius:4px; transition:color .2s; }
.portal-mark-all:hover { color:var(--text); }
.portal-msgs { overflow-y:auto; flex:1; }
.portal-msg { border-bottom:1px solid var(--border); padding:14px 16px; cursor:default; transition:background .15s; }
.portal-msg:last-child { border-bottom:none; }
.portal-msg.unread { background:rgba(var(--accent-rgb),.04); }
.portal-msg-hdr { display:flex; align-items:flex-start; justify-content:space-between; gap:8px; margin-bottom:4px; }
.portal-msg-from { font-family:var(--font-mono); font-size:11px; color:var(--text); font-weight:600; }
.portal-msg-tag { font-family:var(--font-mono); font-size:9px; letter-spacing:.06em; padding:2px 6px; border-radius:4px; background:rgba(var(--accent-rgb),.12); color:var(--accent); white-space:nowrap; }
.portal-msg-tag.tag-notify { background:rgba(var(--accent3-rgb),.12); color:var(--accent3-light); }
.portal-msg-email { font-size:12px; color:var(--muted); margin-bottom:4px; }
.portal-msg-body { font-size:12px; color:var(--muted); line-height:1.5; word-break:break-word; }
.portal-msg-foot { display:flex; align-items:center; justify-content:space-between; margin-top:8px; }
.portal-msg-date { font-family:var(--font-mono); font-size:10px; color:var(--muted); opacity:.6; }
.portal-del-btn { background:none; border:none; font-family:var(--font-mono); font-size:10px; color:var(--muted); cursor:pointer; padding:2px 6px; border-radius:4px; transition:color .2s; }
.portal-del-btn:hover { color:var(--accent2); }
.portal-empty { padding:32px 16px; text-align:center; color:var(--muted); font-size:13px; font-family:var(--font-mono); }
.portal-wrap { position:relative; }
/* ── UPTIME WIDGET ── */
.header-left { display:flex; align-items:center; gap:16px; }
.uptime-widget {
display:flex; flex-direction:column; gap:3px;
padding:5px 10px;
background:var(--surface2); border:1px solid var(--border); border-radius:8px;
min-width:132px; cursor:default; user-select:none;
}
.uptime-top { display:flex; align-items:center; justify-content:space-between; }
.uptime-label { font-family:var(--font-mono); font-size:9px; letter-spacing:.08em; color:var(--muted); text-transform:uppercase; }
.uptime-pct { font-family:var(--font-mono); font-size:10px; color:var(--accent); }
#uptime-canvas { width:100%; height:14px; display:block; }
#uptime-tip {
position:fixed; pointer-events:none; z-index:500;
background:var(--surface); border:1px solid var(--border); border-radius:6px;
padding:4px 9px; font-family:var(--font-mono); font-size:10px; color:var(--text);
white-space:nowrap; display:none;
}
/* ── LAYOUT ── */
main { position:relative; z-index:1; }
.view { display:none; padding:40px; max-width:980px; margin:0 auto; }
.view.active { display:block; animation:fadeIn .25s ease; }
@keyframes fadeIn { from{opacity:0;transform:translateY(8px)} to{opacity:1;transform:none} }
/* ── HERO ── */
.hero { text-align:center; padding:60px 20px 40px; }
.hero h1 { font-family:var(--font-head); font-size:clamp(42px,8vw,88px); font-weight:900; line-height:1; letter-spacing:.06em; margin-bottom:40px; text-transform:lowercase; }
.hero-to { color:var(--text); }
.hero-dot { color:var(--accent); text-shadow:0 0 20px var(--accent),0 0 40px rgba(var(--accent-rgb),.4); }
.hero-alwisp { text-transform:uppercase; background:linear-gradient(135deg,var(--accent) 0%,var(--accent-grad) 100%); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
/* ── CARDS / PANELS ── */
.shorten-card { background:var(--surface); border:1px solid var(--border); border-radius:20px; padding:32px; max-width:720px; margin:0 auto; box-shadow:0 24px 64px rgba(0,0,0,.4); }
.panel { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:24px; }
.panel h3 { font-family:var(--font-head); font-size:13px; font-weight:700; margin-bottom:18px; letter-spacing:.04em; }
/* ── INPUTS ── */
.input-group { display:flex; gap:10px; margin-bottom:20px; }
.input-group input, .field-input {
background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius);
color:var(--text); font-family:var(--font-mono); font-size:13px; padding:12px 16px; outline:none;
transition:border-color .2s,box-shadow .2s; width:100%;
}
.input-group input { flex:1; }
.input-group input:focus, .field-input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(var(--accent-rgb),.1); }
.input-group input::placeholder, .field-input::placeholder { color:var(--muted); }
.field-label { font-size:11px; font-family:var(--font-mono); letter-spacing:.08em; color:var(--muted); margin-bottom:5px; display:block; }
/* ── BUTTONS ── */
.btn-primary { background:linear-gradient(135deg,var(--accent),var(--accent-grad)); color:#fff; border:none; cursor:pointer; font-family:var(--font-head); font-weight:700; font-size:12px; letter-spacing:.04em; padding:12px 22px; border-radius:var(--radius); transition:all .2s; white-space:nowrap; }
.btn-primary:hover { transform:translateY(-1px); box-shadow:0 8px 24px rgba(var(--accent-rgb),.35); }
.btn-primary:disabled { opacity:.5; cursor:not-allowed; transform:none; box-shadow:none; }
.btn-sm { border:1px solid var(--border); background:var(--surface2); color:var(--text); font-size:11px; font-family:var(--font-mono); padding:6px 14px; border-radius:8px; cursor:pointer; transition:all .2s; }
.btn-sm:hover { border-color:var(--accent); color:var(--accent); }
.btn-sm.danger:hover { border-color:var(--accent2); color:var(--accent2); }
.btn-sm.save { background:rgba(var(--accent-rgb),.1); border-color:rgba(var(--accent-rgb),.3); color:var(--accent); }
/* ── AUTH MODAL ── */
.modal-overlay {
position:fixed; inset:0; background:rgba(0,0,0,.75); backdrop-filter:blur(8px);
display:flex; align-items:center; justify-content:center; z-index:200;
animation:fadeIn .2s ease;
}
.modal-overlay.hidden { display:none; }
.modal {
background:var(--surface); border:1px solid var(--border);
border-radius:20px; padding:40px; width:100%; max-width:420px;
box-shadow:0 32px 80px rgba(0,0,0,.6);
animation:modalIn .3s cubic-bezier(.34,1.56,.64,1);
}
@keyframes modalIn { from{opacity:0;transform:scale(.94) translateY(10px)} to{opacity:1;transform:none} }
.modal-logo { font-family:var(--font-head); font-size:22px; font-weight:900; text-align:center; margin-bottom:28px; }
.modal-tabs { display:flex; background:var(--surface2); border-radius:10px; padding:4px; margin-bottom:24px; }
.modal-tab { flex:1; padding:8px; border:none; background:transparent; color:var(--muted); font-family:var(--font-mono); font-size:12px; cursor:pointer; border-radius:7px; transition:all .2s; }
.modal-tab.active { background:var(--surface3); color:var(--accent); }
.modal-form { display:none; flex-direction:column; gap:14px; }
.modal-form.active { display:flex; }
.modal-form label { display:flex; flex-direction:column; gap:5px; font-size:11px; font-family:var(--font-mono); color:var(--muted); letter-spacing:.06em; }
.modal-form input { background:var(--surface2); border:1px solid var(--border); border-radius:8px; color:var(--text); font-family:var(--font-mono); font-size:13px; padding:11px 14px; outline:none; transition:border-color .2s; }
.modal-form input:focus { border-color:var(--accent); }
.modal-err { color:var(--accent2); font-size:12px; font-family:var(--font-mono); display:none; }
.modal-err.show { display:block; }
.invite-note { font-size:12px; color:var(--muted); font-family:var(--font-mono); text-align:center; }
/* ── STATS STRIP ── */
.stats-strip { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; margin-bottom:28px; }
.stat-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:20px; position:relative; overflow:hidden; }
.stat-card::before { content:''; position:absolute; top:0; left:0; right:0; height:2px; background:linear-gradient(90deg,var(--accent),transparent); }
.stat-value { font-family:var(--font-head); font-size:28px; font-weight:900; line-height:1; margin-bottom:4px; background:linear-gradient(135deg,var(--text),var(--muted)); -webkit-background-clip:text; -webkit-text-fill-color:transparent; background-clip:text; }
.stat-label { font-size:10px; color:var(--muted); font-family:var(--font-mono); letter-spacing:.06em; }
/* ── TOOLBAR ── */
.toolbar { display:flex; align-items:center; gap:10px; margin-bottom:18px; flex-wrap:wrap; }
.search-wrap { flex:1; min-width:180px; position:relative; }
.search-wrap svg { position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--muted); pointer-events:none; }
.search-input { width:100%; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); color:var(--text); font-family:var(--font-mono); font-size:13px; padding:10px 14px 10px 38px; outline:none; transition:border-color .2s; }
.search-input:focus { border-color:var(--accent); }
.search-input::placeholder { color:var(--muted); }
.tag-filter-list { display:flex; gap:5px; flex-wrap:wrap; }
.tag-filter-btn { background:var(--surface); border:1px solid var(--border); color:var(--muted); font-family:var(--font-mono); font-size:11px; padding:5px 11px; border-radius:6px; cursor:pointer; transition:all .2s; }
.tag-filter-btn:hover { border-color:var(--accent3); color:var(--accent3-light); }
.tag-filter-btn.active { background:rgba(var(--accent3-rgb),.1); border-color:rgba(var(--accent3-rgb),.4); color:var(--accent3-light); }
/* ── WORKSPACE SELECTOR ── */
.ws-row { display:flex; align-items:center; gap:8px; margin-bottom:18px; flex-wrap:wrap; }
.ws-btn { background:var(--surface2); border:1px solid var(--border); color:var(--muted); font-family:var(--font-mono); font-size:11px; padding:5px 12px; border-radius:6px; cursor:pointer; transition:all .2s; }
.ws-btn:hover { border-color:var(--accent); color:var(--accent); }
.ws-btn.active { background:rgba(var(--accent-rgb),.08); border-color:rgba(var(--accent-rgb),.3); color:var(--accent); }
/* ── LINK ITEMS ── */
.links-list { display:flex; flex-direction:column; gap:8px; }
.link-item { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); overflow:hidden; transition:border-color .2s; }
.link-item:hover { border-color:rgba(var(--accent-rgb),.15); }
.link-item.expanded{ border-color:rgba(var(--accent-rgb),.25); }
.link-row { padding:14px 18px; display:flex; align-items:center; gap:12px; cursor:pointer; user-select:none; }
.link-code { font-family:var(--font-head); font-size:14px; font-weight:700; color:var(--accent); min-width:72px; flex-shrink:0; }
.link-details { flex:1; overflow:hidden; }
.link-title { font-size:13px; margin-bottom:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.link-original { font-family:var(--font-mono); font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.link-tags { display:flex; gap:4px; flex-wrap:wrap; margin-top:3px; }
.link-tag { background:rgba(var(--accent3-rgb),.1); border:1px solid rgba(var(--accent3-rgb),.25); color:var(--accent3-light); font-family:var(--font-mono); font-size:10px; padding:1px 6px; border-radius:3px; }
.link-owner-badge { font-family:var(--font-mono); font-size:10px; color:var(--muted); white-space:nowrap; }
.link-owner-btn { background:var(--surface3); border:1px solid var(--border); color:var(--muted); font-family:var(--font-mono); font-size:10px; padding:1px 7px; border-radius:4px; cursor:pointer; transition:all .2s; }
.link-owner-btn:hover { color:var(--accent); border-color:rgba(var(--accent-rgb),.4); background:rgba(var(--accent-rgb),.07); }
.user-filter-bar { display:flex; align-items:center; gap:8px; padding:6px 12px; background:rgba(var(--accent-rgb),.06); border:1px solid rgba(var(--accent-rgb),.2); border-radius:8px; margin-bottom:14px; font-family:var(--font-mono); font-size:12px; color:var(--accent); }
.ws-tag { background:rgba(var(--accent-rgb),.08); border:1px solid rgba(var(--accent-rgb),.2); color:var(--accent); font-family:var(--font-mono); font-size:10px; padding:1px 6px; border-radius:3px; }
.link-meta { display:flex; align-items:center; gap:8px; flex-shrink:0; }
.link-clicks { font-family:var(--font-mono); font-size:11px; color:var(--muted); white-space:nowrap; }
.link-clicks strong { color:var(--text); }
.link-chevron { color:var(--muted); transition:transform .25s; flex-shrink:0; }
.link-item.expanded .link-chevron { transform:rotate(180deg); }
/* ── EXPANDED PANEL ── */
.link-expanded { display:none; border-top:1px solid var(--border); padding:20px; background:var(--surface2); animation:expandIn .2s ease; }
@keyframes expandIn { from{opacity:0} to{opacity:1} }
.link-item.expanded .link-expanded { display:block; }
.expanded-actions { display:flex; gap:8px; margin-bottom:18px; flex-wrap:wrap; }
.edit-form { background:var(--surface3); border:1px solid var(--border); border-radius:10px; padding:18px; margin-bottom:18px; display:none; }
.edit-form.open { display:block; }
.edit-form h4 { font-family:var(--font-mono); font-size:11px; color:var(--muted); letter-spacing:.08em; margin-bottom:14px; }
.edit-grid { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:12px; }
.edit-full { grid-column:1/-1; }
.edit-actions { display:flex; gap:8px; justify-content:flex-end; }
/* ── ANALYTICS ── */
.analytics-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
.analytics-title { font-family:var(--font-mono); font-size:11px; color:var(--muted); letter-spacing:.08em; }
.analytics-days { display:flex; gap:4px; }
.day-btn { background:var(--surface3); border:1px solid var(--border); color:var(--muted); font-family:var(--font-mono); font-size:11px; padding:3px 9px; border-radius:5px; cursor:pointer; transition:all .2s; }
.day-btn:hover { border-color:var(--accent); color:var(--accent); }
.day-btn.active { background:rgba(var(--accent-rgb),.1); border-color:rgba(var(--accent-rgb),.3); color:var(--accent); }
.chart-wrap { background:var(--surface3); border:1px solid var(--border); border-radius:10px; padding:14px; margin-bottom:14px; }
.chart-canvas { width:100%; height:90px; display:block; }
.breakdown-grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }
.breakdown-card { background:var(--surface3); border:1px solid var(--border); border-radius:10px; padding:12px; }
.breakdown-card h5 { font-family:var(--font-mono); font-size:10px; color:var(--muted); letter-spacing:.08em; margin-bottom:8px; }
.breakdown-row { display:flex; align-items:center; gap:6px; margin-bottom:5px; }
.breakdown-label { font-family:var(--font-mono); font-size:11px; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.breakdown-bar-wrap { flex:2; height:4px; background:var(--surface2); border-radius:2px; overflow:hidden; }
.breakdown-bar { height:100%; border-radius:2px; background:var(--accent); transition:width .4s; }
.breakdown-count { font-family:var(--font-mono); font-size:10px; color:var(--muted); min-width:24px; text-align:right; }
/* ── DASHBOARD CHART ── */
.dash-chart-wrap { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:18px 20px; margin-bottom:22px; }
.dash-chart-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:12px; }
.dash-chart-title { font-family:var(--font-mono); font-size:11px; color:var(--muted); letter-spacing:.08em; }
.dash-chart-total { font-family:var(--font-mono); font-size:11px; color:var(--muted); }
.dash-chart-canvas { width:100%; display:block; }
/* ── HOURLY HEATMAP ── */
.heatmap-wrap { margin-top:14px; background:var(--surface3); border:1px solid var(--border); border-radius:10px; padding:12px; }
.heatmap-title { font-family:var(--font-mono); font-size:10px; color:var(--muted); letter-spacing:.08em; margin-bottom:8px; }
.heatmap-grid { display:grid; grid-template-columns:28px repeat(24,1fr); gap:2px; }
.heatmap-hour-label { font-family:var(--font-mono); font-size:7px; color:var(--muted); text-align:center; line-height:12px; }
.heatmap-day-label { font-family:var(--font-mono); font-size:8px; color:var(--muted); display:flex; align-items:center; padding-right:4px; }
.heatmap-cell { height:12px; border-radius:2px; }
/* ── SECTION HEAD ── */
.section-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:20px; }
.section-title { font-family:var(--font-head); font-size:18px; font-weight:700; }
/* ── ADVANCED ── */
.advanced-toggle { color:var(--muted); font-size:12px; cursor:pointer; display:flex; align-items:center; gap:6px; margin-bottom:14px; user-select:none; transition:color .2s; width:fit-content; }
.advanced-toggle:hover { color:var(--text); }
.advanced-toggle svg { transition:transform .2s; }
.advanced-toggle.open svg { transform:rotate(90deg); }
.advanced-fields { display:none; gap:10px; }
.advanced-fields.open { display:grid; grid-template-columns:1fr 1fr; }
/* ── TAG INPUT ── */
.tag-input-wrap { background:var(--surface2); border:1px solid var(--border); border-radius:8px; padding:8px 12px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; transition:border-color .2s; cursor:text; }
.tag-input-wrap:focus-within { border-color:var(--accent); }
.tag-pill { background:rgba(var(--accent3-rgb),.15); border:1px solid rgba(var(--accent3-rgb),.3); color:var(--accent3-light); font-family:var(--font-mono); font-size:11px; padding:2px 8px; border-radius:4px; display:flex; align-items:center; gap:4px; }
.tag-pill button { background:none; border:none; color:inherit; cursor:pointer; font-size:13px; line-height:1; padding:0; opacity:.6; }
.tag-pill button:hover { opacity:1; }
.tag-input-inner { background:none; border:none; color:var(--text); font-family:var(--font-mono); font-size:13px; outline:none; min-width:80px; flex:1; }
.tag-input-inner::placeholder { color:var(--muted); }
.tag-suggestions { position:absolute; top:100%; left:0; right:0; background:var(--surface2); border:1px solid var(--border); border-radius:8px; margin-top:4px; max-height:160px; overflow-y:auto; z-index:200; display:none; box-shadow:0 4px 16px rgba(0,0,0,.3); }
.tag-suggestions.open { display:block; }
.tag-suggestion-item { padding:6px 12px; cursor:pointer; font-family:var(--font-mono); font-size:12px; color:var(--text); transition:background .1s; }
.tag-suggestion-item:hover, .tag-suggestion-item.active { background:rgba(var(--accent-rgb),.15); color:var(--accent); }
/* ── RESULT CARD ── */
.result-card { background:linear-gradient(135deg,rgba(var(--accent-rgb),.06),rgba(var(--accent-rgb),.03)); border:1px solid rgba(var(--accent-rgb),.2); border-radius:16px; padding:22px; margin-top:18px; display:none; animation:slideIn .4s cubic-bezier(.34,1.56,.64,1); }
@keyframes slideIn { from{opacity:0;transform:scale(.96) translateY(8px)} to{opacity:1;transform:none} }
.result-card.visible { display:block; }
.result-top { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap; }
.result-short-url { font-family:var(--font-mono); font-size:20px; font-weight:500; color:var(--accent); text-decoration:none; letter-spacing:-.5px; word-break:break-all; }
.result-short-url:hover { text-decoration:underline; }
.result-original { font-size:11px; color:var(--muted); font-family:var(--font-mono); margin-top:4px; word-break:break-all; }
.result-actions { display:flex; gap:7px; flex-shrink:0; }
.result-qr { margin-top:18px; display:flex; align-items:center; gap:20px; padding-top:18px; border-top:1px solid var(--border); flex-wrap:wrap; }
.qr-img-wrap { background:#fff; border-radius:10px; padding:10px; flex-shrink:0; box-shadow:0 4px 20px rgba(0,0,0,.3); }
.qr-img-wrap img { display:block; width:100px; height:100px; }
.qr-info h4 { font-family:var(--font-head); font-size:12px; margin-bottom:5px; }
.qr-info p { font-size:11px; color:var(--muted); margin-bottom:10px; }
/* ── ADMIN / SETTINGS PANELS ── */
.tabs { display:flex; background:var(--surface2); border-radius:10px; padding:4px; margin-bottom:22px; gap:2px; }
.tab-btn { flex:1; padding:8px; border:none; background:transparent; color:var(--muted); font-family:var(--font-mono); font-size:11px; cursor:pointer; border-radius:7px; transition:all .2s; letter-spacing:.04em; }
.tab-btn.active { background:var(--surface3); color:var(--accent); }
.tab-content { display:none; }
.tab-content.active { display:block; }
.data-table { width:100%; border-collapse:collapse; font-size:13px; }
.data-table th { font-family:var(--font-mono); font-size:10px; color:var(--muted); letter-spacing:.08em; text-align:left; padding:8px 12px; border-bottom:1px solid var(--border); }
.data-table td { padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.04); font-family:var(--font-mono); font-size:12px; }
.data-table tr:hover td { background:rgba(255,255,255,.02); }
.badge { display:inline-block; font-family:var(--font-mono); font-size:10px; padding:2px 8px; border-radius:4px; }
.badge.admin { background:rgba(var(--accent-rgb),.1); border:1px solid rgba(var(--accent-rgb),.25); color:var(--accent); }
.badge.member{ background:rgba(var(--accent3-rgb),.1); border:1px solid rgba(var(--accent3-rgb),.25); color:var(--accent3-light); }
.badge.inactive{ background:rgba(255,94,98,.1); border:1px solid rgba(255,94,98,.25); color:var(--accent2); }
/* ── KEY CARD ── */
.key-card { background:var(--surface2); border:1px solid var(--border); border-radius:10px; padding:14px 18px; display:flex; align-items:center; gap:12px; margin-bottom:8px; }
.key-prefix { font-family:var(--font-mono); font-size:13px; flex:1; }
.key-label { font-size:12px; color:var(--muted); }
.key-reveal { background:rgba(var(--accent-rgb),.1); border:1px solid rgba(var(--accent-rgb),.25); color:var(--accent); font-family:var(--font-mono); font-size:12px; padding:6px 16px; border-radius:8px; margin:12px 0; word-break:break-all; display:none; }
.key-reveal.show { display:block; }
/* ── INVITE CARD ── */
.invite-card { background:var(--surface2); border:1px solid var(--border); border-radius:10px; padding:14px 18px; display:flex; align-items:center; gap:12px; margin-bottom:8px; }
.invite-url { font-family:var(--font-mono); font-size:12px; flex:1; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.invite-used { color:var(--accent2); font-size:11px; font-family:var(--font-mono); }
.invite-exp { color:var(--muted); font-size:11px; font-family:var(--font-mono); }
/* ── QR VIEW ── */
.qr-grid { display:grid; grid-template-columns:1fr 1fr; gap:24px; }
.qr-preview-panel { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:20px; min-height:280px; }
.qr-preview-img { background:#fff; border-radius:12px; padding:14px; box-shadow:0 8px 32px rgba(0,0,0,.4); }
.qr-preview-img img { display:block; width:200px; height:200px; }
.qr-placeholder { width:200px; height:200px; border:2px dashed var(--border); border-radius:8px; display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:12px; font-family:var(--font-mono); text-align:center; padding:20px; }
.color-row { display:flex; gap:12px; margin-bottom:14px; }
.color-field { flex:1; }
.color-field label { font-size:11px; color:var(--muted); font-family:var(--font-mono); display:block; margin-bottom:5px; letter-spacing:.06em; }
.color-input-wrap { display:flex; align-items:center; gap:8px; background:var(--surface2); border:1px solid var(--border); border-radius:8px; padding:8px 12px; }
input[type="color"] { width:24px; height:24px; border:none; border-radius:4px; padding:0; cursor:pointer; background:none; flex-shrink:0; }
.color-hex { font-family:var(--font-mono); font-size:13px; background:none; border:none; color:var(--text); width:70px; outline:none; }
input[type="range"] { -webkit-appearance:none; width:100%; height:4px; background:var(--surface2); border-radius:2px; outline:none; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance:none; width:16px; height:16px; background:var(--accent); border-radius:50%; cursor:pointer; }
/* ── TOAST ── */
.toast-container { position:fixed; bottom:24px; right:24px; display:flex; flex-direction:column; gap:8px; z-index:999; }
.toast { background:var(--surface2); border:1px solid var(--border); border-radius:10px; padding:11px 16px; font-size:12px; font-family:var(--font-mono); display:flex; align-items:center; gap:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); animation:toastIn .3s cubic-bezier(.34,1.56,.64,1); }
@keyframes toastIn { from{opacity:0;transform:translateX(16px) scale(.95)} to{opacity:1;transform:none} }
.toast.success { border-color:rgba(var(--accent-rgb),.3); }
.toast.error { border-color:rgba(255,94,98,.3); }
/* ── MISC ── */
.empty-state { text-align:center; padding:50px 20px; color:var(--muted); }
.empty-state .empty-icon { font-size:40px; margin-bottom:14px; opacity:.3; }
.empty-state p { font-family:var(--font-mono); font-size:12px; }
.spinner { width:14px; height:14px; border:2px solid rgba(0,0,0,.2); border-top-color:#000; border-radius:50%; animation:spin .7s linear infinite; display:inline-block; }
@keyframes spin { to{transform:rotate(360deg)} }
.divider { border:none; border-top:1px solid var(--border); margin:20px 0; }
.grid-2 { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
@media(max-width:700px) {
header { padding:0 16px; }
.view { padding:16px 12px; }
.stats-strip { grid-template-columns:1fr 1fr; }
.input-group { flex-direction:column; }
.advanced-fields.open,.edit-grid,.qr-grid,.breakdown-grid,.grid-2 { grid-template-columns:1fr; }
}
/* ── PHASE 4: QR STYLE SELECTOR ── */
.style-selector { display:flex; gap:6px; flex-wrap:wrap; }
.style-btn { background:var(--surface2); border:1px solid var(--border); color:var(--muted); font-family:var(--font-mono); font-size:11px; padding:6px 12px; border-radius:6px; cursor:pointer; transition:all .2s; }
.style-btn:hover { border-color:var(--accent3); color:var(--accent3-light); }
.style-btn.active { background:rgba(var(--accent3-rgb),.1); border-color:rgba(var(--accent3-rgb),.4); color:var(--accent3-light); }
/* ── PHASE 4: LOGO UPLOAD ── */
.logo-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
/* ── PHASE 4: BULK ACTION BAR ── */
.bulk-bar { display:none; align-items:center; gap:8px; padding:10px 14px; background:var(--surface2); border:1px solid rgba(var(--accent-rgb),.2); border-radius:var(--radius); margin-bottom:14px; flex-wrap:wrap; }
.bulk-count { font-family:var(--font-mono); font-size:12px; color:var(--accent); flex:1; min-width:80px; }
/* ── PHASE 4: IMPORT PANEL ── */
.import-panel { display:none; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:18px; margin-bottom:14px; }
.import-panel.open { display:block; animation:fadeIn .2s ease; }
.import-panel textarea { width:100%; background:var(--surface3); border:1px solid var(--border); border-radius:8px; color:var(--text); font-family:var(--font-mono); font-size:12px; padding:12px; outline:none; resize:vertical; min-height:100px; transition:border-color .2s; box-sizing:border-box; }
.import-panel textarea:focus { border-color:var(--accent); }
.import-result { font-family:var(--font-mono); font-size:11px; color:var(--muted); margin-top:8px; white-space:pre-wrap; }
/* ── PHASE 4: SELECT CHECKBOX ── */
.select-check { display:none; align-items:center; margin-right:6px; flex-shrink:0; }
.link-checkbox { width:15px; height:15px; cursor:pointer; accent-color:var(--accent); }
/* ── THEME PICKER ── */
.theme-picker { display:flex; align-items:center; gap:6px; padding:0 4px; }
.theme-swatch {
width:18px; height:18px; border-radius:50%; border:2px solid transparent;
cursor:pointer; transition:transform .15s, border-color .15s; flex-shrink:0;
outline:none; padding:0;
}
.theme-swatch:hover { transform:scale(1.2); }
.theme-swatch.active { border-color:var(--text); transform:scale(1.15); }
.theme-swatch[data-theme="default"] { background:linear-gradient(135deg,#00b8d4,#1a52c8); }
.theme-swatch[data-theme="gold"] { background:linear-gradient(135deg,#f5a623,#c47f17); }
.theme-swatch[data-theme="green"] { background:linear-gradient(135deg,#00c896,#007a58); }
.theme-swatch[data-theme="red"] { background:linear-gradient(135deg,#ff4757,#c0392b); }
/* ── PIN BUTTON ── */
.pin-btn { background:none; border:none; cursor:pointer; font-size:14px; padding:2px 4px; flex-shrink:0; line-height:1; opacity:.2; transition:opacity .15s; }
.pin-btn:hover { opacity:.6; }
.pin-btn.pinned { opacity:1; color:var(--accent); }
/* ── INLINE COPY ── */
.copy-inline { background:none; border:1px solid transparent; cursor:pointer; color:var(--muted); font-size:10px; font-family:var(--font-mono); padding:2px 8px; border-radius:5px; flex-shrink:0; transition:all .15s; letter-spacing:.03em; }
.copy-inline:hover { border-color:var(--border); color:var(--text); }
/* ── INLINE QR THUMBNAIL (expanded panel) ── */
.qr-thumb-wrap { display:flex; gap:14px; align-items:flex-start; margin-bottom:16px; }
.qr-thumb-img { background:#fff; border-radius:8px; padding:7px; flex-shrink:0; box-shadow:0 4px 16px rgba(0,0,0,.35); }
.qr-thumb-img img { display:block; width:80px; height:80px; }
.qr-thumb-btns { display:flex; flex-direction:column; gap:6px; justify-content:center; }
</style>
</head>
<body>
<!-- ── AUTH MODAL ── -->
<div class="modal-overlay" id="auth-modal">
<div class="modal">
<div class="modal-logo">
<span style="color:var(--text)" id="brand-modal-pre">to</span><span style="color:var(--accent);text-shadow:0 0 12px var(--accent)" id="brand-modal-dot">.</span><span style="text-transform:uppercase" id="brand-modal-suf">ALWISP</span>
</div>
<div class="modal-form active" id="form-login">
<label>USERNAME<input type="text" id="login-username" placeholder="admin" autocomplete="username" onkeydown="if(event.key==='Enter')document.getElementById('login-password').focus()"></label>
<label>PASSWORD<input type="password" id="login-password" placeholder="••••••••" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()"></label>
<div class="modal-err" id="login-err"></div>
<button class="btn-primary" onclick="doLogin()" style="width:100%">Login</button>
</div>
</div>
</div>
<!-- ── HEADER ── -->
<header>
<div class="header-left">
<a class="logo" href="/app">
<span class="logo-icon-wrap"><img class="logo-icon" src="/static/qk-ico.png" alt=""><span class="logo-icon-tint"></span></span><span class="logo-to" id="brand-logo-pre">to</span><span class="logo-dot-text" id="brand-logo-dot">.</span><span class="logo-alwisp" id="brand-logo-suf">ALWISP</span>
</a>
<div class="uptime-widget" id="uptime-widget">
<div class="uptime-top">
<span class="uptime-label">30D SLA</span>
<span class="uptime-pct" id="uptime-pct"></span>
</div>
<canvas id="uptime-canvas"></canvas>
</div>
</div>
<nav>
<button class="active" data-view="home">Shorten</button>
<button data-view="dashboard">Dashboard</button>
<button data-view="qr">QR Codes</button>
<button data-view="admin" id="admin-nav-btn" style="display:none">Users</button>
</nav>
<div class="header-right">
<div class="theme-picker" id="theme-picker">
<button class="theme-swatch" data-theme="default" title="Default (Blue)" aria-label="Default blue theme"></button>
<button class="theme-swatch" data-theme="gold" title="Gold" aria-label="Gold theme"></button>
<button class="theme-swatch" data-theme="green" title="Green" aria-label="Green theme"></button>
<button class="theme-swatch" data-theme="red" title="Red" aria-label="Red theme"></button>
</div>
<div class="portal-wrap" id="portal-wrap" style="display:none">
<button class="portal-btn" id="portal-btn" onclick="togglePortal()">
PORTAL
<span class="portal-badge" id="portal-badge" style="display:none"></span>
</button>
<div class="portal-panel" id="portal-panel">
<div class="portal-panel-hdr">
<h4>PORTAL MESSAGES</h4>
<div class="portal-hdr-actions">
<button class="portal-mark-all" onclick="markAllPortalRead()">Mark all read</button>
</div>
</div>
<div class="portal-msgs" id="portal-msgs"></div>
</div>
</div>
<div class="user-badge" id="user-badge" style="display:none">
<span id="user-badge-name"></span>
<span class="admin-tag" id="user-badge-admin" style="display:none">ADMIN</span>
<span class="plan-tag" id="user-badge-plan" style="display:none"></span>
</div>
<button class="btn-logout" id="logout-btn" style="display:none" onclick="doLogout()">Logout</button>
</div>
</header>
<div id="uptime-tip"></div>
<main>
<!-- ── HOME ── -->
<div class="view active" id="view-home">
<div class="hero">
<h1><span class="hero-to" id="brand-hero-pre">to</span><span class="hero-dot" id="brand-hero-dot">.</span><span class="hero-alwisp" id="brand-hero-suf">ALWISP</span></h1>
<div class="shorten-card">
<div class="input-group">
<input type="url" id="url-input" placeholder="https://your-long-url.com/goes/here" autocomplete="off" spellcheck="false">
<button class="btn-primary" id="shorten-btn"><span id="shorten-label">Knit it →</span></button>
</div>
<div class="advanced-toggle" id="adv-toggle">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"><path d="M4.5 2L8.5 6L4.5 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
Advanced options
</div>
<div class="advanced-fields" id="adv-fields">
<div><label class="field-label">CUSTOM CODE</label><input class="field-input" id="custom-code" placeholder="e.g. mylink" maxlength="20"></div>
<div><label class="field-label">TITLE (optional)</label><input class="field-input" id="link-title" placeholder="e.g. My Homepage"></div>
<div><label class="field-label">EXPIRY DATE</label><input class="field-input" type="date" id="link-expiry"></div>
<div style="grid-column:1/-1; position:relative">
<label class="field-label">TAGS (Enter or comma)</label>
<div class="tag-input-wrap" id="tag-input-wrap">
<input class="tag-input-inner" id="tag-input" placeholder="e.g. marketing, social" autocomplete="off">
</div>
<div class="tag-suggestions" id="tag-suggestions"></div>
</div>
</div>
<div class="result-card" id="result-card">
<div class="result-top">
<div>
<a class="result-short-url" id="result-url" href="#" target="_blank"></a>
<div class="result-original" id="result-original"></div>
</div>
<div class="result-actions">
<button class="btn-sm" id="copy-btn">Copy</button>
<button class="btn-sm" id="dl-qr-btn">↓ QR</button>
</div>
</div>
<div class="result-qr">
<div class="qr-img-wrap"><img id="result-qr-img" src="" alt="QR"></div>
<div class="qr-info">
<h4>QR Code Ready</h4>
<p>Download or customize in the QR tab.</p>
<button class="btn-sm" id="customize-qr-btn">Customize QR →</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── DASHBOARD ── -->
<div class="view" id="view-dashboard">
<div class="stats-strip">
<div class="stat-card"><div class="stat-value" id="stat-total"></div><div class="stat-label">TOTAL LINKS</div></div>
<div class="stat-card"><div class="stat-value" id="stat-clicks"></div><div class="stat-label">TOTAL CLICKS</div></div>
<div class="stat-card"><div class="stat-value" id="stat-7d"></div><div class="stat-label">CLICKS / 7D</div></div>
<div class="stat-card"><div class="stat-value" id="stat-avg"></div><div class="stat-label">AVG / LINK</div></div>
</div>
<div class="plan-usage-bar" id="plan-usage-bar" style="display:none">
<h4 id="plan-usage-title">PLAN — FREE</h4>
<div class="plan-usage-row" id="plan-links-row" style="display:none">
<span class="plan-usage-label">ACTIVE LINKS</span>
<div class="plan-usage-track"><div class="plan-usage-fill" id="plan-links-fill" style="width:0%"></div></div>
<span class="plan-usage-count" id="plan-links-count"></span>
</div>
<div class="plan-usage-row" id="plan-clicks-row" style="display:none">
<span class="plan-usage-label">MONTHLY CLICKS</span>
<div class="plan-usage-track"><div class="plan-usage-fill" id="plan-clicks-fill" style="width:0%"></div></div>
<span class="plan-usage-count" id="plan-clicks-count"></span>
</div>
</div>
<div class="dash-chart-wrap" id="dash-chart-wrap" style="display:none">
<div class="dash-chart-header">
<div class="dash-chart-title">CLICKS — LAST 30 DAYS</div>
<div class="dash-chart-total" id="dash-chart-total"></div>
</div>
<canvas class="dash-chart-canvas" id="dash-chart" height="60"></canvas>
</div>
<div class="toolbar">
<div class="search-wrap">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="6.5" cy="6.5" r="5.5" stroke="currentColor" stroke-width="1.5"/><path d="M11 11L14.5 14.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
<input class="search-input" id="search-input" placeholder="Search links, URLs, titles...">
</div>
<div class="tag-filter-list" id="tag-filter-list"></div>
<button class="btn-sm" onclick="loadDashboard()"></button>
<button class="btn-sm" id="select-btn" onclick="toggleSelectMode()">⊡ Select</button>
<button class="btn-sm" onclick="exportCSV()">↓ Export</button>
<button class="btn-sm" onclick="toggleImport()">↑ Import</button>
</div>
<div class="import-panel" id="import-panel">
<div style="font-family:var(--font-mono);font-size:11px;color:var(--muted);margin-bottom:8px;letter-spacing:.06em">CSV IMPORT — columns: <code>url</code> (required), <code>custom_code</code>, <code>title</code>, <code>tags</code>, <code>expires_at</code></div>
<textarea id="import-csv" placeholder="url,title,tags&#10;https://example.com,My Link,tag1&#10;https://another.com,,"></textarea>
<div style="display:flex;gap:8px;margin-top:10px;align-items:center;flex-wrap:wrap">
<button class="btn-sm" onclick="document.getElementById('import-file').click()">📎 From file</button>
<input type="file" id="import-file" accept=".csv,text/csv" style="display:none">
<button class="btn-primary" id="import-btn" onclick="doImport()" style="padding:8px 18px">Import</button>
</div>
<div class="import-result" id="import-result"></div>
</div>
<div class="bulk-bar" id="bulk-bar">
<span class="bulk-count" id="bulk-count">0 selected</span>
<button class="btn-sm" onclick="selectAll()">Select all</button>
<button class="btn-sm danger" onclick="bulkDelete()">✕ Delete</button>
<button class="btn-sm" onclick="bulkTag()">⊕ Tag</button>
<button class="btn-sm" onclick="bulkExpire()">⏱ Set Expiry</button>
<button class="btn-sm" onclick="toggleSelectMode()">Cancel</button>
</div>
<div class="links-list" id="links-list">
<div class="empty-state"><div class="empty-icon">🔗</div><p>Loading...</p></div>
</div>
<!-- API keys — programmatic access tokens -->
<div class="panel" id="apikeys-panel" style="margin-top:28px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<h3 style="margin:0">API KEYS</h3>
<button class="btn-sm" onclick="createApiKey()">+ New Key</button>
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:12px">
Authenticate API requests with <code style="font-family:var(--font-mono)">Authorization: Bearer qrk_…</code> — see the <a href="/api-docs" target="_blank" style="color:var(--accent)">API docs</a>.
</div>
<div id="apikey-new" style="display:none;border:1px solid var(--accent);border-radius:8px;padding:12px 14px;margin-bottom:12px;font-family:var(--font-mono);font-size:12px">
<div style="color:var(--accent);letter-spacing:.06em;margin-bottom:6px">NEW KEY — COPY IT NOW, IT WON'T BE SHOWN AGAIN</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<code id="apikey-token" style="word-break:break-all"></code>
<button class="btn-sm" onclick="copyToClipboard(document.getElementById('apikey-token').textContent)">Copy</button>
</div>
</div>
<div id="apikeys-list"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
</div>
</div>
<!-- ── QR ── -->
<div class="view" id="view-qr">
<div class="section-head" style="margin-bottom:24px">
<div><div class="section-title">QR Generator</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Generate and customize QR codes for any URL</div></div>
</div>
<div class="qr-grid">
<div class="panel">
<h3>Configure</h3>
<label class="field-label">URL</label>
<input class="field-input" id="qr-url-input" placeholder="https://example.com" style="margin-bottom:18px">
<div class="color-row">
<div class="color-field"><label>FOREGROUND</label><div class="color-input-wrap"><input type="color" id="qr-fg-picker" value="#000000"><input class="color-hex" id="qr-fg-hex" value="000000" maxlength="6"></div></div>
<div class="color-field"><label>BACKGROUND</label><div class="color-input-wrap"><input type="color" id="qr-bg-picker" value="#ffffff"><input class="color-hex" id="qr-bg-hex" value="ffffff" maxlength="6"></div></div>
</div>
<div style="margin-bottom:18px"><label class="field-label">SIZE: <span id="qr-size-label">300px</span></label><input type="range" id="qr-size" min="150" max="600" value="300" step="50"></div>
<div style="margin-bottom:18px">
<label class="field-label">DOT STYLE</label>
<div class="style-selector" id="qr-style-selector">
<button class="style-btn active" data-style="square">■ Square</button>
<button class="style-btn" data-style="rounded">▣ Rounded</button>
<button class="style-btn" data-style="dots">● Dots</button>
<button class="style-btn" data-style="vertical">▐ V-Bars</button>
<button class="style-btn" data-style="horizontal">▬ H-Bars</button>
</div>
</div>
<div class="color-row" style="margin-bottom:18px">
<div>
<label class="field-label">FORMAT</label>
<select class="field-input" id="qr-format">
<option value="png" selected>PNG — raster</option>
<option value="svg">SVG — vector (print)</option>
<option value="pdf">PDF — print-ready</option>
</select>
<div style="font-size:10px;color:var(--muted);font-family:var(--font-mono);margin-top:4px">Dot styles apply to PNG &amp; PDF only</div>
</div>
<div>
<label class="field-label">ERROR CORRECTION</label>
<select class="field-input" id="qr-ec">
<option value="L">L — 7% (densest)</option>
<option value="M" selected>M — 15% (default)</option>
<option value="Q">Q — 25%</option>
<option value="H">H — 30% (logos)</option>
</select>
</div>
</div>
<div style="margin-bottom:18px">
<label class="field-label">LOGO / ICON <span style="font-weight:400;color:var(--muted)">(optional — centered overlay)</span></label>
<div class="logo-row">
<button class="btn-sm" onclick="document.getElementById('qr-logo-input').click()">↑ Upload Logo</button>
<span id="qr-logo-name" style="font-family:var(--font-mono);font-size:11px;color:var(--muted)"></span>
<button class="btn-sm danger" id="qr-logo-clear" style="display:none" onclick="clearQrLogo()">✕ Remove</button>
</div>
<input type="file" id="qr-logo-input" accept="image/*" style="display:none">
</div>
<button class="btn-primary" id="qr-generate-btn" style="width:100%">Generate QR Code</button>
</div>
<div class="panel qr-preview-panel">
<h3 style="align-self:flex-start;width:100%">Preview</h3>
<div id="qr-preview-wrap"><div class="qr-placeholder">Enter a URL and click Generate</div></div>
<div id="qr-dl-row" style="display:none;flex-direction:column;align-items:center;gap:8px;width:100%">
<a class="btn-primary" id="qr-download-link" download="qrcode.png" style="text-decoration:none;display:inline-block;text-align:center">↓ Download PNG</a>
<div style="font-size:11px;color:var(--muted);font-family:var(--font-mono)" id="qr-dl-hint">Right-click to save</div>
</div>
</div>
</div>
</div>
<!-- ── ADMIN ── -->
<div class="view" id="view-admin">
<!-- Platform overview -->
<div class="section-head" style="margin-bottom:18px">
<div><div class="section-title">Platform Overview</div><div style="color:var(--muted);font-size:13px;margin-top:3px">All users, links, and clicks across the instance</div></div>
<button class="btn-sm" onclick="loadAdminOverview()">↻ Refresh</button>
</div>
<div class="stats-strip" id="admin-stats">
<div class="stat-card"><div class="stat-value" id="astat-users"></div><div class="stat-label">USERS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-orgs"></div><div class="stat-label">ORGS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-links"></div><div class="stat-label">ACTIVE LINKS</div></div>
<div class="stat-card"><div class="stat-value" id="astat-clicks30"></div><div class="stat-label">CLICKS / 30D</div></div>
</div>
<div class="panel" style="margin-bottom:32px">
<h3>QUOTA USAGE</h3>
<div id="admin-usage"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Loading…</div></div>
</div>
<!-- Users -->
<div class="section-head" style="margin-bottom:24px">
<div><div class="section-title">User Management</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Create and manage user accounts</div></div>
<button class="btn-primary" onclick="showCreateUser()">+ New User</button>
</div>
<div class="panel" id="create-user-panel" style="display:none;margin-bottom:20px">
<h3>CREATE USER</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px">
<div><label class="field-label">USERNAME</label><input class="field-input" id="new-username" placeholder="username"></div>
<div><label class="field-label">PASSWORD</label><input class="field-input" type="password" id="new-password" placeholder="••••••••"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:14px">
<div>
<label class="field-label">PLAN</label>
<select class="field-input" id="new-plan">
<option value="free">Free</option>
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
</div>
<div>
<label class="field-label">ORGANIZATION</label>
<select class="field-input" id="new-org-id">
<option value="">— None —</option>
</select>
</div>
<div style="display:flex;align-items:flex-end;padding-bottom:2px">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;font-family:var(--font-mono)">
<input type="checkbox" id="new-is-admin"> Admin
</label>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" onclick="createUser()">Create</button>
<button class="btn-sm" onclick="hideCreateUser()">Cancel</button>
</div>
<div class="modal-err" id="create-user-err" style="margin-top:10px"></div>
</div>
<div id="users-list"><div class="empty-state"><p>Loading...</p></div></div>
<!-- Organizations -->
<div class="section-head" style="margin-bottom:24px;margin-top:40px">
<div><div class="section-title">Organizations</div><div style="color:var(--muted);font-size:13px;margin-top:3px">Group users to share quotas, links, and tags</div></div>
<button class="btn-primary" onclick="showCreateOrg()">+ New Org</button>
</div>
<div class="panel" id="create-org-panel" style="display:none;margin-bottom:20px">
<h3>CREATE ORGANIZATION</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px">
<div><label class="field-label">NAME</label><input class="field-input" id="new-org-name" placeholder="Acme Corp"></div>
<div>
<label class="field-label">PLAN</label>
<select class="field-input" id="new-org-plan">
<option value="free">Free</option>
<option value="starter">Starter</option>
<option value="pro">Pro</option>
<option value="team" selected>Team</option>
</select>
</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn-primary" onclick="createOrg()">Create</button>
<button class="btn-sm" onclick="hideCreateOrg()">Cancel</button>
</div>
<div class="modal-err" id="create-org-err" style="margin-top:10px"></div>
</div>
<div id="orgs-list"><div class="empty-state"><p>Loading...</p></div></div>
</div>
</main>
<div class="toast-container" id="toast-container"></div>
<!-- Debug panel (only visible when ?debug=1 is in the URL) -->
<div id="debug-panel" style="display:none;position:fixed;bottom:0;left:0;right:0;background:#1a1a2e;border-top:2px solid #e040fb;padding:12px 16px;font-family:monospace;font-size:11px;color:#ccc;z-index:9999;max-height:220px;overflow-y:auto;">
<strong style="color:#e040fb">DEBUG PANEL</strong>
<button onclick="document.getElementById('debug-panel').style.display='none'" style="float:right;background:#333;border:none;color:#fff;cursor:pointer;padding:2px 8px;border-radius:4px"></button>
<br><br>
<div id="dbg-content">Loading...</div>
<br>
<button onclick="runDebug()" style="background:#333;border:1px solid #555;color:#ccc;cursor:pointer;padding:4px 10px;border-radius:4px;font-size:11px">Refresh</button>
<button onclick="testStats()" style="background:#333;border:1px solid #555;color:#ccc;cursor:pointer;padding:4px 10px;border-radius:4px;font-size:11px;margin-left:6px">Test /api/stats</button>
<pre id="dbg-extra" style="color:#aaa;margin-top:8px;white-space:pre-wrap;"></pre>
</div>
<script>
const BASE = window.location.origin;
let loggedIn = false;
let currentUser = null;
let allTags = [];
let activeTag = null;
let activeUser = null;
let tagInputTags = [];
let searchTimer = null;
let selectMode = false;
let selectedCodes = new Set();
let qrStyle = 'square';
let qrLogoBase64 = null;
async function authFetch(url, opts = {}) {
const resp = await fetch(url, {
...opts,
credentials: 'include',
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
});
if (resp.status === 401) {
loggedIn = false;
onLoggedOut();
}
return resp;
}
// ── Navigation ─────────────────────────────────────
document.querySelectorAll('nav button[data-view]').forEach(btn => {
btn.addEventListener('click', () => showView(btn.dataset.view));
});
function showView(name) {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
const btn = document.querySelector(`[data-view="${name}"]`);
if (btn) btn.classList.add('active');
document.getElementById(`view-${name}`)?.classList.add('active');
if (name === 'dashboard') loadDashboard();
else activeUser = null;
if (name === 'admin') loadAdminPanel();
}
// ── Toast ──────────────────────────────────────────
function toast(msg, type = 'success') {
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerHTML = `<span>${type==='success'?'✓':'✕'}</span><span>${msg}</span>`;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => el.remove(), 3000);
}
// ── Auth Modal ─────────────────────────────────────
function showAuthModal() { document.getElementById('auth-modal').classList.remove('hidden'); }
function hideAuthModal() { document.getElementById('auth-modal').classList.add('hidden'); }
async function doLogin() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const errEl = document.getElementById('login-err');
errEl.classList.remove('show');
try {
const resp = await fetch(`${BASE}/api/auth/login`, {
method:'POST', credentials:'include', headers:{'Content-Type':'application/json'},
body: JSON.stringify({username, password})
});
const d = await resp.json();
if (!resp.ok) { errEl.textContent = d.error; errEl.classList.add('show'); return; }
loggedIn = true;
onLoggedIn(d);
} catch(e) { errEl.textContent='Network error'; errEl.classList.add('show'); }
}
async function doLogout() {
await fetch(`${BASE}/api/auth/logout`, {method:'POST', credentials:'include'}).catch(()=>{});
loggedIn = false;
currentUser = null;
onLoggedOut();
}
function onLoggedIn(userData) {
currentUser = userData;
hideAuthModal();
// User badge
const badge = document.getElementById('user-badge');
const badgeName = document.getElementById('user-badge-name');
const badgeAdmin = document.getElementById('user-badge-admin');
badgeName.textContent = userData.username || '';
badge.style.display = 'flex';
badgeAdmin.style.display = userData.is_admin ? 'inline' : 'none';
const planTag = document.getElementById('user-badge-plan');
if (planTag) {
if (userData.is_admin) {
// ADMIN badge already shown — no need to duplicate with a plan tag
planTag.style.display = 'none';
} else {
planTag.textContent = (userData.plan || 'free').toUpperCase();
planTag.style.display = 'inline';
}
}
// Admin nav tab
document.getElementById('admin-nav-btn').style.display = userData.is_admin ? '' : 'none';
document.getElementById('logout-btn').style.display = 'block';
// Portal (admin only)
if (userData.is_admin) {
document.getElementById('portal-wrap').style.display = '';
loadPortalMessages();
}
loadDashboard();
}
function onLoggedOut() {
currentUser = null;
document.getElementById('user-badge').style.display = 'none';
document.getElementById('admin-nav-btn').style.display = 'none';
document.getElementById('logout-btn').style.display = 'none';
document.getElementById('portal-wrap').style.display = 'none';
closePortal();
showView('home');
showAuthModal();
}
// ── Portal inbox ────────────────────────────────────
let portalOpen = false;
let portalMessages = [];
function togglePortal() {
portalOpen = !portalOpen;
document.getElementById('portal-panel').classList.toggle('open', portalOpen);
if (portalOpen) loadPortalMessages();
}
function closePortal() {
portalOpen = false;
const panel = document.getElementById('portal-panel');
if (panel) panel.classList.remove('open');
}
async function loadPortalMessages() {
try {
const resp = await authFetch(`${BASE}/api/admin/messages`);
if (!resp.ok) return;
portalMessages = await resp.json();
renderPortalMessages();
updatePortalBadge();
} catch {}
}
function renderPortalMessages() {
const container = document.getElementById('portal-msgs');
if (!portalMessages.length) {
container.innerHTML = '<div class="portal-empty">No messages yet</div>';
return;
}
const SUBJECT_LABELS = {
'request-access': { label: 'REQUEST ACCESS', cls: '' },
'notify-me-cloud': { label: 'NOTIFY: CLOUD', cls: 'tag-notify' },
'notify-me-team': { label: 'NOTIFY: TEAM', cls: 'tag-notify' },
};
container.innerHTML = portalMessages.map(m => {
const tag = SUBJECT_LABELS[m.subject] || { label: m.subject.toUpperCase(), cls: '' };
const date = m.created_at ? m.created_at.slice(0, 16).replace('T', ' ') : '';
const body = m.body ? `<div class="portal-msg-body">${escHtml(m.body)}</div>` : '';
return `<div class="portal-msg${m.is_read ? '' : ' unread'}" id="pmsg-${m.id}">
<div class="portal-msg-hdr">
<span class="portal-msg-from">${escHtml(m.name)}</span>
<span class="portal-msg-tag ${tag.cls}">${tag.label}</span>
</div>
<div class="portal-msg-email">${escHtml(m.email)}</div>
${body}
<div class="portal-msg-foot">
<span class="portal-msg-date">${date}</span>
<button class="portal-del-btn" onclick="deletePortalMessage(${m.id})">Delete</button>
</div>
</div>`;
}).join('');
// Mark unread messages as read after a short delay
portalMessages.filter(m => !m.is_read).forEach(m => {
setTimeout(() => markPortalMessageRead(m.id), 800);
});
}
async function markPortalMessageRead(id) {
try {
await authFetch(`${BASE}/api/admin/messages/${id}/read`, {method:'PATCH'});
const msg = portalMessages.find(m => m.id === id);
if (msg) { msg.is_read = 1; updatePortalBadge(); }
const el = document.getElementById(`pmsg-${id}`);
if (el) el.classList.remove('unread');
} catch {}
}
async function markAllPortalRead() {
const unread = portalMessages.filter(m => !m.is_read);
for (const m of unread) await markPortalMessageRead(m.id);
}
async function deletePortalMessage(id) {
try {
const resp = await authFetch(`${BASE}/api/admin/messages/${id}`, {method:'DELETE'});
if (!resp.ok) return;
portalMessages = portalMessages.filter(m => m.id !== id);
renderPortalMessages();
updatePortalBadge();
} catch {}
}
function updatePortalBadge() {
const unread = portalMessages.filter(m => !m.is_read).length;
const badge = document.getElementById('portal-badge');
if (unread > 0) {
badge.textContent = unread > 99 ? '99+' : unread;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Close portal when clicking outside
document.addEventListener('click', e => {
const wrap = document.getElementById('portal-wrap');
if (portalOpen && wrap && !wrap.contains(e.target)) closePortal();
});
// ── Branding ───────────────────────────────────────
async function applyConfig() {
try {
const resp = await fetch(`${BASE}/api/config`);
if (!resp.ok) return;
const { app_name: name } = await resp.json();
if (!name) return;
// Split on the first dot so "to.ALWISP" → pre="to", suf="ALWISP"
// Names without a dot render in full with dot+suffix hidden.
const dotIdx = name.indexOf('.');
const pre = dotIdx > 0 ? name.slice(0, dotIdx) : name;
const suf = dotIdx > 0 ? name.slice(dotIdx + 1).toUpperCase() : '';
const hasDot = suf !== '';
document.title = `${name} — URL Shortener`;
['logo', 'hero', 'modal'].forEach(ctx => {
const preEl = document.getElementById(`brand-${ctx}-pre`);
const dotEl = document.getElementById(`brand-${ctx}-dot`);
const sufEl = document.getElementById(`brand-${ctx}-suf`);
if (preEl) preEl.textContent = pre;
if (dotEl) dotEl.style.display = hasDot ? '' : 'none';
if (sufEl) { sufEl.textContent = suf; sufEl.style.display = hasDot ? '' : 'none'; }
});
} catch(e) {}
}
// ── Theme Picker ────────────────────────────────────
function applyTheme(name) {
if (name && name !== 'default') {
document.documentElement.setAttribute('data-theme', name);
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('qrknit-theme', name || 'default');
document.querySelectorAll('.theme-swatch').forEach(s => {
s.classList.toggle('active', s.dataset.theme === (name || 'default'));
});
if (_uptimeDays.length) renderUptimeChart(_uptimeDays);
}
document.querySelectorAll('.theme-swatch').forEach(swatch => {
swatch.addEventListener('click', () => applyTheme(swatch.dataset.theme));
});
// Mark active swatch on load
(function() {
const saved = localStorage.getItem('qrknit-theme') || 'default';
document.querySelectorAll('.theme-swatch').forEach(s => {
s.classList.toggle('active', s.dataset.theme === saved);
});
})();
// ── Uptime Widget ──────────────────────────────────
let _uptimeDays = [];
async function loadUptimeWidget() {
try {
const d = await (await fetch(`${BASE}/api/uptime`)).json();
_uptimeDays = d.days || [];
document.getElementById('uptime-pct').textContent =
d.overall !== null ? d.overall.toFixed(2) + '%' : '';
renderUptimeChart(_uptimeDays);
} catch(e) {}
}
function renderUptimeChart(days) {
const canvas = document.getElementById('uptime-canvas');
if (!canvas) return;
requestAnimationFrame(() => {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth || 132, H = 14;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const n = days.length;
const barW = Math.max(2, (W - (n - 1) * 1.5) / n);
const step = barW + 1.5;
const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '0,184,212';
days.forEach((pt, i) => {
const x = i * step;
let fill;
if (pt.pct === null) fill = 'rgba(128,128,150,.15)';
else if (pt.pct >= 99.9) fill = `rgba(${accentRgb},.8)`;
else if (pt.pct >= 99) fill = 'rgba(245,166,35,.8)';
else fill = 'rgba(255,71,87,.8)';
ctx.fillStyle = fill;
ctx.beginPath(); ctx.roundRect(x, 0, barW, H, 2); ctx.fill();
});
});
const tip = document.getElementById('uptime-tip');
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const n = _uptimeDays.length;
const W = canvas.offsetWidth || 132;
const barW = Math.max(2, (W - (n - 1) * 1.5) / n);
const step = barW + 1.5;
const idx = Math.min(n - 1, Math.max(0, Math.floor(x / step)));
const pt = _uptimeDays[idx];
if (pt) {
tip.textContent = `${pt.date} ${pt.pct !== null ? pt.pct.toFixed(2) + '%' : 'No data'}`;
tip.style.display = '';
tip.style.left = (e.clientX + 14) + 'px';
tip.style.top = (e.clientY - 34) + 'px';
}
});
canvas.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
}
// ── Init ───────────────────────────────────────────
(async function init() {
const params = new URLSearchParams(window.location.search);
if (params.get('error') === 'not_found') toast('Link not found', 'error');
if (params.get('error') === 'expired') toast('Link has expired', 'error');
if (params.get('error') === 'quota_exceeded') toast('Monthly click quota exceeded for this link\'s account', 'error');
applyConfig(); // fire-and-forget — fills in app name from env
loadUptimeWidget(); // fire-and-forget — public endpoint, no auth needed
try {
const resp = await fetch(`${BASE}/api/auth/me`, {credentials:'include'});
if (resp.ok) {
const data = await resp.json();
loggedIn = true;
onLoggedIn(data);
return;
}
} catch(e) {}
showAuthModal();
})();
// ── Dashboard ──────────────────────────────────────
async function loadDashboard() {
await Promise.all([loadStats(), loadTags(), loadPlanUsage()]);
await loadLinks();
// ── API keys ────────────────────────────────────────
async function loadApiKeys() {
const container = document.getElementById('apikeys-list');
try {
const d = await (await authFetch(`${BASE}/api/keys`)).json();
const keys = d.keys || [];
if (!keys.length) {
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">No API keys yet.</div>';
return;
}
container.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:12px;font-family:var(--font-mono)">
<thead><tr style="color:var(--muted);font-size:10px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:6px 10px">NAME</th>
<th style="text-align:left;padding:6px 10px">KEY</th>
<th style="text-align:left;padding:6px 10px">SCOPE</th>
<th style="text-align:left;padding:6px 10px">CREATED</th>
<th style="text-align:left;padding:6px 10px">LAST USED</th>
<th style="text-align:right;padding:6px 10px"></th>
</tr></thead>
<tbody>${keys.map(k=>`
<tr style="border-bottom:1px solid var(--border);${k.revoked?'opacity:.45':''}">
<td style="padding:8px 10px">${escHtml(k.name)}</td>
<td style="padding:8px 10px;color:var(--muted)">${escHtml(k.prefix)}…</td>
<td style="padding:8px 10px"><span style="color:${k.scope==='write'?'var(--accent)':'var(--muted)'}">${k.scope.toUpperCase()}</span></td>
<td style="padding:8px 10px;color:var(--muted)">${(k.created_at||'').slice(0,10)}</td>
<td style="padding:8px 10px;color:var(--muted)">${k.last_used_at?k.last_used_at.slice(0,16).replace('T',' '):'never'}</td>
<td style="padding:8px 10px;text-align:right">${k.revoked?'<span style="color:var(--muted);font-size:10px">REVOKED</span>':`<button class="btn-sm danger" onclick="revokeApiKey(${k.id},'${escHtml(k.name)}')">Revoke</button>`}</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch(e) {
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load keys</div>';
}
}
async function createApiKey() {
const name = prompt('Name for this key (e.g. "CI pipeline"):');
if (name === null) return;
const write = confirm('Allow WRITE access (create/edit/delete links)?\n\nOK = read + write · Cancel = read-only');
try {
const resp = await authFetch(`${BASE}/api/keys`, {method:'POST', body:JSON.stringify({name: name||'API key', scope: write?'write':'read'})});
const d = await resp.json();
if (!resp.ok) { toast(d.error||'Failed to create key','error'); return; }
document.getElementById('apikey-new').style.display = 'block';
document.getElementById('apikey-token').textContent = d.token;
toast('API key created — copy it now');
loadApiKeys();
} catch(e) { toast('Network error','error'); }
}
async function revokeApiKey(id, name) {
if (!confirm(`Revoke API key "${name}"? Requests using it will stop working immediately.`)) return;
const resp = await authFetch(`${BASE}/api/keys/${id}`, {method:'DELETE'});
if (!resp.ok) { toast('Failed to revoke','error'); return; }
toast(`Key "${name}" revoked`);
loadApiKeys();
}
async function loadStats() {
try {
const d = await (await authFetch(`${BASE}/api/stats`)).json();
document.getElementById('stat-total').textContent = d.total_links.toLocaleString();
document.getElementById('stat-clicks').textContent = d.total_clicks.toLocaleString();
document.getElementById('stat-7d').textContent = d.clicks_7d.toLocaleString();
document.getElementById('stat-avg').textContent = d.total_links>0 ? (d.total_clicks/d.total_links).toFixed(1) : '0';
if (d.daily && d.daily.length) renderDashChart(d.daily);
} catch(e) {}
}
async function loadPlanUsage() {
try {
const d = await (await authFetch(`${BASE}/api/plan`)).json();
const bar = document.getElementById('plan-usage-bar');
if (!bar) return;
bar.style.display = '';
const planLabel = d.plan === 'admin' ? 'TIER — ADMIN' : `PLAN — ${(d.plan||'free').toUpperCase()}`;
document.getElementById('plan-usage-title').textContent = planLabel;
const { limits, usage } = d;
// Links usage bar
const linksRow = document.getElementById('plan-links-row');
if (limits.max_links !== null) {
linksRow.style.display = '';
const pct = Math.min(100, (usage.active_links / limits.max_links) * 100);
const fill = document.getElementById('plan-links-fill');
fill.style.width = pct + '%';
fill.className = 'plan-usage-fill' + (pct >= 100 ? ' danger' : pct >= 80 ? ' warn' : '');
document.getElementById('plan-links-count').textContent =
`${usage.active_links.toLocaleString()} / ${limits.max_links.toLocaleString()}`;
} else {
linksRow.style.display = 'none';
}
// Monthly clicks usage bar
const clicksRow = document.getElementById('plan-clicks-row');
if (limits.monthly_clicks !== null) {
clicksRow.style.display = '';
const pct = Math.min(100, (usage.monthly_clicks / limits.monthly_clicks) * 100);
const fill = document.getElementById('plan-clicks-fill');
fill.style.width = pct + '%';
fill.className = 'plan-usage-fill' + (pct >= 100 ? ' danger' : pct >= 80 ? ' warn' : '');
document.getElementById('plan-clicks-count').textContent =
`${usage.monthly_clicks.toLocaleString()} / ${limits.monthly_clicks.toLocaleString()}`;
} else {
clicksRow.style.display = 'none';
}
} catch(e) {}
}
function renderDashChart(daily) {
const wrap = document.getElementById('dash-chart-wrap');
const canvas = document.getElementById('dash-chart');
if (!wrap || !canvas) return;
const total30 = daily.reduce((s,x)=>s+x.clicks,0);
document.getElementById('dash-chart-total').textContent = total30.toLocaleString() + ' clicks';
wrap.style.display = total30 > 0 ? '' : 'none';
if (total30 === 0) return;
requestAnimationFrame(() => {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth, H = 60;
canvas.width = W * dpr; canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const n = daily.length;
const max = Math.max(...daily.map(x => x.clicks), 1);
const barW = Math.max(2, (W / n) - 2);
const gap = (W - barW * n) / (n - 1 || 1);
const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '0,184,212';
daily.forEach((pt, i) => {
const x = i * (barW + gap), bH = (pt.clicks / max) * (H - 10), y = H - bH - 2;
ctx.fillStyle = `rgba(${accentRgb},${pt.clicks > 0 ? .75 : .12})`;
ctx.beginPath(); ctx.roundRect(x, y, barW, bH + 2, [2, 2, 0, 0]); ctx.fill();
});
ctx.fillStyle = 'rgba(107,107,128,.8)'; ctx.font = '9px "Share Tech Mono"';
ctx.textAlign = 'left'; ctx.fillText(daily[0].date.slice(5), 0, H);
ctx.textAlign = 'right'; ctx.fillText(daily[daily.length-1].date.slice(5), W, H);
});
}
async function loadTags() {
try {
const d = await (await authFetch(`${BASE}/api/tags`)).json();
allTags = d.tags || [];
renderTagFilters();
} catch(e) {}
}
function renderTagFilters() {
const container = document.getElementById('tag-filter-list');
container.innerHTML = '';
allTags.forEach(t => {
const btn = document.createElement('button');
btn.className = 'tag-filter-btn' + (activeTag===t.name?' active':'');
btn.textContent = `${t.name} (${t.link_count})`;
btn.onclick = () => { activeTag = activeTag===t.name?null:t.name; renderTagFilters(); loadLinks(); };
container.appendChild(btn);
});
}
async function loadLinks() {
const q = document.getElementById('search-input').value.trim();
let url = `${BASE}/api/links?per_page=50`;
if (q) url += `&q=${encodeURIComponent(q)}`;
if (activeTag) url += `&tag=${encodeURIComponent(activeTag)}`;
if (activeUser) url += `&user=${encodeURIComponent(activeUser)}`;
try {
const d = await (await authFetch(url)).json();
renderLinks(d.links || []);
} catch(e) {
document.getElementById('links-list').innerHTML='<div class="empty-state"><div class="empty-icon">⚠️</div><p>Could not load</p></div>';
}
}
document.getElementById('search-input').addEventListener('input', () => {
clearTimeout(searchTimer); searchTimer = setTimeout(loadLinks, 300);
});
function filterByUser(username) {
activeUser = username || null;
loadLinks();
}
function renderLinks(links) {
const container = document.getElementById('links-list');
container.innerHTML = '';
if (activeUser) {
const bar = document.createElement('div');
bar.className = 'user-filter-bar';
bar.innerHTML = `Showing links by <strong>@${escHtml(activeUser)}</strong> &nbsp;<button class="btn-sm" onclick="filterByUser(null)">✕ Clear filter</button>`;
container.appendChild(bar);
}
if (!links.length) { container.insertAdjacentHTML('beforeend','<div class="empty-state"><div class="empty-icon">🔗</div><p>No links found</p></div>'); return; }
links.forEach(l => container.appendChild(buildLinkItem(l)));
}
function buildLinkItem(l) {
const item = document.createElement('div');
item.className = 'link-item';
item.id = `item-${l.code}`;
const tagPills = (l.tags||[]).map(t=>`<span class="link-tag">${t.name}</span>`).join('');
const expNote = l.expires_at ? `<span style="font-size:10px;color:var(--muted);font-family:var(--font-mono)"> · exp ${l.expires_at.slice(0,10)}</span>`:'';
const canEdit = loggedIn;
const canCopyImage = typeof ClipboardItem !== 'undefined' && !!navigator.clipboard?.write;
const isAdmin = currentUser && currentUser.is_admin;
const ownerBadge = (isAdmin && l.created_by)
? `<button class="link-owner-btn" onclick="event.stopPropagation();filterByUser('${escHtml(l.created_by)}')" title="Filter by this user">@${escHtml(l.created_by)}</button>`
: '';
item.innerHTML = `
<div class="link-row" onclick="toggleExpand('${l.code}')">
<div class="select-check" style="display:${selectMode?'flex':'none'}">
<input type="checkbox" class="link-checkbox" id="chk-${l.code}"
${selectedCodes.has(l.code)?'checked':''} onclick="event.stopPropagation()"
onchange="toggleSelect('${l.code}')">
</div>
<button class="pin-btn${l.is_pinned?' pinned':''}" id="pin-${l.code}"
title="${l.is_pinned?'Unpin':'Pin to top'}"
onclick="event.stopPropagation();togglePin('${l.code}',${l.is_pinned?1:0})">★</button>
<div class="link-code">${l.code}</div>
<div class="link-details">
<div class="link-title">${escHtml(l.title||l.long_url)}</div>
<div class="link-original">${escHtml(l.long_url)}</div>
${tagPills?`<div class="link-tags">${tagPills}</div>`:''}
</div>
<div class="link-meta">
${ownerBadge}
${l.has_password?'<span title="Password protected" style="font-size:12px">🔒</span>':''}
<div class="link-clicks"><strong>${l.clicks}</strong> clicks${expNote}</div>
<button class="copy-inline" title="Copy short URL"
onclick="event.stopPropagation();copyToClipboard('${l.short_url}')">copy</button>
<svg class="link-chevron" width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M2 5L7 10L12 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</div>
</div>
<div class="link-expanded" id="exp-${l.code}">
<div class="qr-thumb-wrap">
<div class="qr-thumb-img">
<img src="${BASE}/api/qr/${l.code}" alt="QR code for ${l.code}" loading="lazy" width="80" height="80">
</div>
<div class="qr-thumb-btns">
<a class="btn-sm" href="${BASE}/api/qr/${l.code}" download="qr-${l.code}.png" style="text-decoration:none;text-align:center">↓ Download</a>
${canCopyImage?`<button class="btn-sm" onclick="copyQrToClipboard('${BASE}/api/qr/${l.code}')">⎘ Copy image</button>`:''}
<button class="btn-sm" onclick="document.getElementById('qr-url-input').value='${l.short_url}';showView('qr')">✦ Customize</button>
</div>
</div>
<div class="expanded-actions">
<button class="btn-sm" onclick="copyToClipboard('${l.short_url}')">Copy link</button>
${canEdit?`<button class="btn-sm" onclick="toggleEdit('${l.code}')">✎ Edit</button>`:''}
${canEdit?`<button class="btn-sm danger" onclick="deleteLink('${l.code}')">✕ Delete</button>`:''}
${isAdmin && l.created_by ? `<span class="link-owner-badge">by <button class="link-owner-btn" onclick="filterByUser('${escHtml(l.created_by)}')" title="Show all links by this user">@${escHtml(l.created_by)}</button></span>` : ''}
</div>
${canEdit?`
<div class="edit-form" id="edit-${l.code}">
<h4>EDIT LINK</h4>
<div class="edit-grid">
<div class="edit-full"><label class="field-label">DESTINATION URL</label><input class="field-input" id="edit-url-${l.code}" value="${escHtml(l.long_url)}" onblur="autoFetchTitleForEdit('${l.code}')"></div>
<div><label class="field-label">TITLE</label><input class="field-input" id="edit-title-${l.code}" value="${escHtml(l.title||'')}"></div>
<div><label class="field-label">EXPIRY DATE</label><input class="field-input" type="date" id="edit-expiry-${l.code}" value="${(l.expires_at||'').slice(0,10)}"></div>
<div><label class="field-label">REDIRECT TYPE</label>
<select class="field-input" id="edit-rt-${l.code}">
<option value="302"${l.redirect_type==301?'':' selected'}>302 — Temporary (recommended)</option>
<option value="301"${l.redirect_type==301?' selected':''}>301 — Permanent (browsers cache)</option>
</select>
</div>
<div><label class="field-label">PASSWORD ${l.has_password?'<span style="color:var(--accent)">● set</span>':''}</label>
<div style="display:flex;gap:6px">
<input class="field-input" type="password" id="edit-pw-${l.code}" autocomplete="new-password" style="flex:1"
placeholder="${l.has_password?'Unchanged — type to replace':'None — type to protect'}">
${l.has_password?`<button class="btn-sm danger" onclick="removeLinkPassword('${l.code}')" title="Remove password protection">✕</button>`:''}
</div>
</div>
<div class="edit-full"><label class="field-label">TAGS (comma separated)</label><input class="field-input" id="edit-tags-${l.code}" value="${(l.tags||[]).map(t=>t.name).join(', ')}"></div>
</div>
<div class="edit-actions">
<button class="btn-sm" onclick="toggleEdit('${l.code}')">Cancel</button>
<button class="btn-sm save" onclick="saveEdit('${l.code}')">Save</button>
</div>
</div>`:''}
<div class="analytics-wrap" id="analytics-${l.code}">
<div class="analytics-header">
<div class="analytics-title">CLICK ANALYTICS</div>
<div style="display:flex;align-items:center;gap:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:10px;font-family:var(--font-mono);color:var(--muted);cursor:pointer;letter-spacing:.04em">
<input type="checkbox" id="bots-${l.code}" onchange="loadAnalytics('${l.code}', analyticsDaysByCode['${l.code}']||7)"> NO BOTS
</label>
<div class="analytics-days">
<button class="day-btn active" onclick="loadAnalytics('${l.code}',7,this)">7d</button>
<button class="day-btn" onclick="loadAnalytics('${l.code}',30,this)">30d</button>
<button class="day-btn" onclick="loadAnalytics('${l.code}',90,this)">90d</button>
</div>
<button class="btn-sm" onclick="exportClickCSV('${l.code}')" title="Export raw click events as CSV">↓ Clicks CSV</button>
</div>
</div>
<div id="analytics-content-${l.code}"><div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Expand to load analytics</div></div>
</div>
</div>`;
return item;
}
function escHtml(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
async function toggleExpand(code) {
const item = document.getElementById(`item-${code}`);
const was = item.classList.contains('expanded');
item.classList.toggle('expanded');
if (!was) loadAnalytics(code, 7);
}
function toggleEdit(code) {
const form = document.getElementById(`edit-${code}`);
if (!form) return;
form.classList.toggle('open');
if (form.classList.contains('open')) initEditTagAutocomplete(code);
}
function initEditTagAutocomplete(code) {
const input = document.getElementById(`edit-tags-${code}`);
if (!input || input._acInit) return;
input._acInit = true;
input.setAttribute('autocomplete','off');
const wrap = input.parentElement;
wrap.style.position = 'relative';
const dropdown = document.createElement('div');
dropdown.className = 'tag-suggestions';
wrap.appendChild(dropdown);
let idx = -1;
function partial() { const p=input.value.split(','); return p[p.length-1].trim().toLowerCase().replace(/[^a-z0-9-_]/g,''); }
function entered() { const p=input.value.split(','); p.pop(); return p.map(t=>t.trim().toLowerCase()).filter(Boolean); }
function showSugg() {
const q=partial(); if(!q){hideSugg();return;}
const matches=allTags.map(t=>t.name).filter(n=>n.includes(q)&&!entered().includes(n));
if(!matches.length){hideSugg();return;}
idx=-1;
dropdown.innerHTML=matches.map(n=>`<div class="tag-suggestion-item" data-tag="${n}">${n}</div>`).join('');
dropdown.querySelectorAll('.tag-suggestion-item').forEach(el=>{
el.addEventListener('mousedown',e=>{e.preventDefault();selectSugg(el.dataset.tag);});
});
dropdown.classList.add('open');
}
function hideSugg() { dropdown.classList.remove('open'); idx=-1; }
function updateActive() { dropdown.querySelectorAll('.tag-suggestion-item').forEach((el,i)=>el.classList.toggle('active',i===idx)); }
function selectSugg(tag) {
const parts=input.value.split(','); parts[parts.length-1]=' '+tag;
input.value=parts.join(',')+', '; input.focus(); hideSugg();
}
input.addEventListener('input', showSugg);
input.addEventListener('keydown', e=>{
const items=dropdown.querySelectorAll('.tag-suggestion-item');
if(e.key==='ArrowDown'&&items.length){e.preventDefault();idx=Math.min(idx+1,items.length-1);updateActive();return;}
if(e.key==='ArrowUp'&&items.length){e.preventDefault();idx=Math.max(idx-1,-1);updateActive();return;}
if(e.key==='Escape'){hideSugg();return;}
if((e.key==='Enter'||e.key===',')&&idx>=0&&items.length){e.preventDefault();selectSugg(items[idx].dataset.tag);return;}
});
input.addEventListener('blur',()=>setTimeout(hideSugg,150));
}
async function saveEdit(code) {
const url = document.getElementById(`edit-url-${code}`).value.trim();
const title = document.getElementById(`edit-title-${code}`).value.trim();
const expiry = document.getElementById(`edit-expiry-${code}`).value;
const rt = parseInt(document.getElementById(`edit-rt-${code}`).value);
const pw = document.getElementById(`edit-pw-${code}`).value;
const tags = document.getElementById(`edit-tags-${code}`).value.split(',').map(t=>t.trim()).filter(Boolean);
const body = {url, title, expires_at: expiry||null, tags, redirect_type: rt};
if (pw) body.password = pw; // omit = leave password unchanged
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH',body:JSON.stringify(body)});
const data = await resp.json();
if (!resp.ok) { toast(data.error||'Save failed','error'); return; }
toast('Link updated!');
const old = document.getElementById(`item-${code}`);
const fresh = buildLinkItem(data);
fresh.classList.add('expanded');
old.parentNode.replaceChild(fresh, old);
loadStats(); loadTags();
}
async function removeLinkPassword(code) {
if (!confirm(`Remove password protection from /${code}?`)) return;
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH', body:JSON.stringify({password:''})});
const data = await resp.json();
if (!resp.ok) { toast(data.error||'Failed','error'); return; }
toast('Password removed');
const old = document.getElementById(`item-${code}`);
const fresh = buildLinkItem(data);
fresh.classList.add('expanded');
old.parentNode.replaceChild(fresh, old);
}
async function deleteLink(code) {
if (!confirm(`Delete /${code}?`)) return;
await authFetch(`${BASE}/api/links/${code}`, {method:'DELETE'});
document.getElementById(`item-${code}`)?.remove();
toast('Deleted'); loadStats();
}
function copyToClipboard(url) { navigator.clipboard.writeText(url); toast('Copied!'); }
async function copyQrToClipboard(qrUrl) {
try {
const resp = await fetch(qrUrl);
const blob = await resp.blob();
await navigator.clipboard.write([new ClipboardItem({'image/png': blob})]);
toast('QR image copied!');
} catch(e) {
toast('Copy failed — try right-clicking the image', 'error');
}
}
async function togglePin(code, currentPinned) {
const newPinned = currentPinned ? 0 : 1;
const resp = await authFetch(`${BASE}/api/links/${code}`, {
method: 'PATCH', body: JSON.stringify({is_pinned: newPinned === 1})
});
if (!resp.ok) { toast('Could not update pin', 'error'); return; }
toast(newPinned ? 'Pinned!' : 'Unpinned');
loadLinks();
}
async function autoFetchTitleForEdit(code) {
const urlEl = document.getElementById(`edit-url-${code}`);
const titleEl = document.getElementById(`edit-title-${code}`);
if (!urlEl || !titleEl || titleEl.value.trim()) return;
const url = urlEl.value.trim();
if (!url.startsWith('http')) return;
const prev = titleEl.placeholder;
titleEl.placeholder = 'Fetching title…';
try {
const resp = await authFetch(`${BASE}/api/fetch-title?url=${encodeURIComponent(url)}`);
if (resp.ok) { const d = await resp.json(); if (d.title && !titleEl.value.trim()) titleEl.value = d.title; }
} catch(e) {}
titleEl.placeholder = prev;
}
// ── Analytics ──────────────────────────────────────
async function loadAnalytics(code, days, btnEl) {
if (btnEl) {
document.querySelectorAll(`#analytics-${code} .day-btn`).forEach(b=>b.classList.remove('active'));
btnEl.classList.add('active');
}
const container = document.getElementById(`analytics-content-${code}`);
container.innerHTML = '<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono);padding:10px 0">Loading...</div>';
try {
const d = await (await authFetch(`${BASE}/api/links/${code}/analytics?days=${days}`)).json();
renderAnalytics(code, d);
} catch(e) { container.innerHTML='<div style="color:var(--muted);font-size:12px;font-family:var(--font-mono)">Could not load</div>'; }
}
function renderAnalytics(code, d) {
const container = document.getElementById(`analytics-content-${code}`);
const chartId = `chart-${code}`;
const heatId = `heat-${code}`;
// Build country rows HTML (only if we have country data)
const hasCountries = d.countries && d.countries.length > 0;
const countryCard = hasCountries
? `<div class="breakdown-card"><h5>COUNTRIES</h5>${renderBreakdown(d.countries.map(r=>({label:r.country,count:r.count})))}</div>`
: '';
container.innerHTML = `
<div class="chart-wrap"><canvas class="chart-canvas" id="${chartId}" height="90"></canvas></div>
<div class="breakdown-grid" style="grid-template-columns:${hasCountries?'1fr 1fr 1fr 1fr':'1fr 1fr 1fr'}">
<div class="breakdown-card"><h5>REFERRERS</h5>${renderBreakdown(d.referrers.map(r=>({label:r.source,count:r.count})))}</div>
<div class="breakdown-card"><h5>DEVICES</h5>${renderBreakdown(d.devices.map(r=>({label:r.device,count:r.count})))}</div>
<div class="breakdown-card"><h5>BROWSERS</h5>${renderBreakdown(d.browsers.map(r=>({label:r.browser,count:r.count})))}</div>
${countryCard}
</div>
<div class="heatmap-wrap" id="${heatId}"></div>`;
requestAnimationFrame(() => {
// Daily bar chart
const canvas = document.getElementById(chartId);
if (canvas) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio||1;
const W = canvas.offsetWidth, H = 90;
canvas.width = W*dpr; canvas.height = H*dpr; ctx.scale(dpr,dpr);
const data = d.daily, n = data.length;
const max = Math.max(...data.map(x=>x.clicks),1);
const barW = Math.max(2,(W/n)-2);
const gap = (W-barW*n)/(n-1||1);
const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim()||'0,184,212';
data.forEach((pt,i) => {
const x = i*(barW+gap), bH = (pt.clicks/max)*(H-14), y = H-bH-2;
ctx.fillStyle = `rgba(${accentRgb},${pt.clicks>0?.8:.15})`;
ctx.beginPath(); ctx.roundRect(x,y,barW,bH+2,[2,2,0,0]); ctx.fill();
});
ctx.fillStyle='rgba(107,107,128,.8)'; ctx.font=`10px "Share Tech Mono"`;
if (data.length>0) {
ctx.textAlign='left'; ctx.fillText(data[0].date.slice(5),0,H);
ctx.textAlign='right'; ctx.fillText(data[data.length-1].date.slice(5),W,H);
}
}
// Hourly heatmap
if (d.heatmap) renderHeatmap(heatId, d.heatmap);
});
}
function renderHeatmap(containerId, heatmap) {
const el = document.getElementById(containerId);
if (!el) return;
const days = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
const hours = Array.from({length:24},(_,i)=>i);
const maxVal = Math.max(...heatmap.flat(), 1);
const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '0,184,212';
const totalHeat = heatmap.flat().reduce((s,v)=>s+v,0);
if (totalHeat === 0) { el.style.display='none'; return; }
let html = '<div class="heatmap-title">HOURLY ACTIVITY (LOCAL TIME)</div><div class="heatmap-grid">';
// Header row: blank + 24 hour labels
html += '<div></div>';
hours.forEach(h => { html += `<div class="heatmap-hour-label">${h%6===0?h:''}</div>`; });
// Data rows
days.forEach((day, di) => {
html += `<div class="heatmap-day-label">${day}</div>`;
hours.forEach(h => {
const v = heatmap[di][h];
const pct = v / maxVal;
const opacity = v > 0 ? (0.15 + pct * 0.85).toFixed(2) : '0.06';
html += `<div class="heatmap-cell" style="background:rgba(${accentRgb},${opacity})" title="${day} ${h}:00 — ${v} click${v!==1?'s':''}"></div>`;
});
});
html += '</div>';
el.innerHTML = html;
}
function renderBreakdown(rows) {
if (!rows.length) return '<div style="color:var(--muted);font-size:11px;font-family:var(--font-mono)">No data</div>';
const max = Math.max(...rows.map(r=>r.count));
return rows.map(r=>`
<div class="breakdown-row">
<div class="breakdown-label">${escHtml(r.label)}</div>
<div class="breakdown-bar-wrap"><div class="breakdown-bar" style="width:${Math.round((r.count/max)*100)}%"></div></div>
<div class="breakdown-count">${r.count}</div>
</div>`).join('');
}
async function exportClickCSV(code) {
const url = `${BASE}/api/links/${code}/clicks/export`;
const resp = await authFetch(url);
if (!resp.ok) { showToast('Export failed'); return; }
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `clicks-${code}.csv`;
a.click();
URL.revokeObjectURL(a.href);
}
// ── Shorten ────────────────────────────────────────
document.getElementById('adv-toggle').addEventListener('click', function() { this.classList.toggle('open'); document.getElementById('adv-fields').classList.toggle('open'); });
document.getElementById('shorten-btn').addEventListener('click', doShorten);
document.getElementById('url-input').addEventListener('keydown', e=>{ if(e.key==='Enter') doShorten(); });
// Auto-fetch page title when URL field loses focus (only if title is empty)
let _titleFetching = false;
document.getElementById('url-input').addEventListener('blur', async function() {
const url = this.value.trim();
const titleEl = document.getElementById('link-title');
if (!url.startsWith('http') || titleEl.value.trim() || _titleFetching) return;
_titleFetching = true;
const prev = titleEl.placeholder;
titleEl.placeholder = 'Fetching title…';
try {
const resp = await authFetch(`${BASE}/api/fetch-title?url=${encodeURIComponent(url)}`);
if (resp.ok) { const d = await resp.json(); if (d.title && !titleEl.value.trim()) titleEl.value = d.title; }
} catch(e) {}
titleEl.placeholder = prev;
_titleFetching = false;
});
// Tag input with autocomplete
const tagInputEl = document.getElementById('tag-input');
const tagSuggestionsEl = document.getElementById('tag-suggestions');
let tagSuggestionIndex = -1;
document.getElementById('tag-input-wrap').addEventListener('click',()=>tagInputEl.focus());
function showTagSuggestions(query) {
query = query.toLowerCase().replace(/[^a-z0-9-_]/g,'');
if (!query) { hideTagSuggestions(); return; }
const matches = allTags.map(t=>t.name).filter(n=>n.includes(query)&&!tagInputTags.includes(n));
if (!matches.length) { hideTagSuggestions(); return; }
tagSuggestionIndex = -1;
tagSuggestionsEl.innerHTML = matches.map(n=>`<div class="tag-suggestion-item" data-tag="${n}">${n}</div>`).join('');
tagSuggestionsEl.querySelectorAll('.tag-suggestion-item').forEach(el=>{
el.addEventListener('mousedown', e=>{ e.preventDefault(); addTagPill(el.dataset.tag); tagInputEl.value=''; hideTagSuggestions(); });
});
tagSuggestionsEl.classList.add('open');
}
function hideTagSuggestions() { tagSuggestionsEl.classList.remove('open'); tagSuggestionIndex=-1; }
function updateActiveTagSuggestion() {
tagSuggestionsEl.querySelectorAll('.tag-suggestion-item').forEach((el,i)=>el.classList.toggle('active',i===tagSuggestionIndex));
}
tagInputEl.addEventListener('input', ()=>showTagSuggestions(tagInputEl.value.trim()));
tagInputEl.addEventListener('keydown', e=>{
const items = tagSuggestionsEl.querySelectorAll('.tag-suggestion-item');
if (e.key==='ArrowDown'&&items.length) { e.preventDefault(); tagSuggestionIndex=Math.min(tagSuggestionIndex+1,items.length-1); updateActiveTagSuggestion(); return; }
if (e.key==='ArrowUp'&&items.length) { e.preventDefault(); tagSuggestionIndex=Math.max(tagSuggestionIndex-1,-1); updateActiveTagSuggestion(); return; }
if (e.key==='Escape') { hideTagSuggestions(); return; }
if (e.key==='Enter'||e.key===',') {
e.preventDefault();
if (tagSuggestionIndex>=0&&items.length) { addTagPill(items[tagSuggestionIndex].dataset.tag); }
else { addTagPill(tagInputEl.value.trim().replace(/,$/,'')); }
tagInputEl.value=''; hideTagSuggestions(); return;
}
if (e.key==='Tab'&&tagSuggestionIndex>=0&&items.length) { e.preventDefault(); addTagPill(items[tagSuggestionIndex].dataset.tag); tagInputEl.value=''; hideTagSuggestions(); return; }
if (e.key==='Backspace'&&tagInputEl.value===''&&tagInputTags.length>0) { removeTagPill(tagInputTags[tagInputTags.length-1]); }
});
tagInputEl.addEventListener('blur',()=>{
setTimeout(hideTagSuggestions, 150);
if(tagInputEl.value.trim()){addTagPill(tagInputEl.value.trim());tagInputEl.value='';}
});
function addTagPill(name) {
name = name.toLowerCase().replace(/[^a-z0-9-_]/g,'');
if(!name||tagInputTags.includes(name))return;
tagInputTags.push(name); renderTagPills();
}
function removeTagPill(name) { tagInputTags=tagInputTags.filter(t=>t!==name); renderTagPills(); }
function renderTagPills() {
const wrap = document.getElementById('tag-input-wrap');
wrap.querySelectorAll('.tag-pill').forEach(e=>e.remove());
tagInputTags.forEach(name=>{
const pill=document.createElement('div'); pill.className='tag-pill';
pill.innerHTML=`${name}<button onclick="removeTagPill('${name}')">×</button>`;
wrap.insertBefore(pill,tagInputEl);
});
}
async function doShorten() {
const url = document.getElementById('url-input').value.trim();
const code = document.getElementById('custom-code').value.trim();
const title = document.getElementById('link-title').value.trim();
const expiry = document.getElementById('link-expiry').value;
if (!url) { toast('Please enter a URL','error'); return; }
const btn=document.getElementById('shorten-btn'), lbl=document.getElementById('shorten-label');
btn.disabled=true; lbl.innerHTML='<span class="spinner"></span>';
try {
const resp = await authFetch(`${BASE}/api/shorten`,{method:'POST',body:JSON.stringify({
url, custom_code:code, title, expires_at:expiry||null, tags:tagInputTags
})});
const data = await resp.json();
if(!resp.ok){toast(data.error||'Error','error');return;}
showResult(data); toast('Link shortened!');
document.getElementById('url-input').value='';
document.getElementById('custom-code').value='';
document.getElementById('link-title').value='';
document.getElementById('link-expiry').value='';
tagInputEl.value='';
tagInputTags=[]; renderTagPills();
} catch(e){toast('Network error','error');}
finally{btn.disabled=false;lbl.textContent='Knit it →';}
}
function showResult(data) {
const card=document.getElementById('result-card');
document.getElementById('result-url').href=data.short_url;
document.getElementById('result-url').textContent=data.short_url;
document.getElementById('result-original').textContent=data.long_url;
document.getElementById('result-qr-img').src=`${BASE}/api/qr/${data.code}?t=${Date.now()}`;
card.classList.add('visible');
document.getElementById('copy-btn').onclick=()=>{navigator.clipboard.writeText(data.short_url);toast('Copied!');};
document.getElementById('dl-qr-btn').onclick=()=>{const a=document.createElement('a');a.href=`${BASE}/api/qr/${data.code}`;a.download=`qr-${data.code}.png`;a.click();};
document.getElementById('customize-qr-btn').onclick=()=>{document.getElementById('qr-url-input').value=data.short_url;showView('qr');};
}
// ── QR Generator ───────────────────────────────────
const qrFgP=document.getElementById('qr-fg-picker'), qrBgP=document.getElementById('qr-bg-picker');
const qrFgH=document.getElementById('qr-fg-hex'), qrBgH=document.getElementById('qr-bg-hex');
const qrSz =document.getElementById('qr-size');
qrFgP.addEventListener('input',()=>qrFgH.value=qrFgP.value.replace('#',''));
qrBgP.addEventListener('input',()=>qrBgH.value=qrBgP.value.replace('#',''));
qrFgH.addEventListener('input',()=>{ if(qrFgH.value.length===6) qrFgP.value='#'+qrFgH.value; });
qrBgH.addEventListener('input',()=>{ if(qrBgH.value.length===6) qrBgP.value='#'+qrBgH.value; });
qrSz.addEventListener('input',()=>document.getElementById('qr-size-label').textContent=qrSz.value+'px');
// QR style selector
document.querySelectorAll('#qr-style-selector .style-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#qr-style-selector .style-btn').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
qrStyle = btn.dataset.style;
});
});
// Logo upload
document.getElementById('qr-logo-input').addEventListener('change', function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
qrLogoBase64 = e.target.result.split(',')[1];
document.getElementById('qr-logo-name').textContent = file.name;
document.getElementById('qr-logo-clear').style.display = 'inline-block';
};
reader.readAsDataURL(file);
});
function clearQrLogo() {
qrLogoBase64 = null;
document.getElementById('qr-logo-input').value = '';
document.getElementById('qr-logo-name').textContent = '';
document.getElementById('qr-logo-clear').style.display = 'none';
}
async function postQrBlob(body) {
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
if (!resp.ok) throw new Error('QR generation failed');
return await resp.blob();
}
document.getElementById('qr-generate-btn').addEventListener('click', async () => {
const url = document.getElementById('qr-url-input').value.trim();
if (!url) { toast('Please enter a URL','error'); return; }
const fg = qrFgH.value.replace('#','') || '000000';
const bg = qrBgH.value.replace('#','') || 'ffffff';
const size = parseInt(qrSz.value);
const fmt = document.getElementById('qr-format').value;
const ec = document.getElementById('qr-ec').value;
const btn = document.getElementById('qr-generate-btn');
const dlLink = document.getElementById('qr-download-link');
// PDFs can't render in an <img> — preview those as PNG, download as PDF
const previewFmt = fmt === 'pdf' ? 'png' : fmt;
btn.disabled = true; btn.textContent = 'Generating...';
try {
let previewUrl, dlUrl;
if (qrLogoBase64) {
const params = { url, fg, bg, size, style: qrStyle, ec, logo: qrLogoBase64 };
const previewBlob = await postQrBlob({...params, format: previewFmt});
previewUrl = URL.createObjectURL(previewBlob);
dlUrl = fmt === 'pdf'
? URL.createObjectURL(await postQrBlob({...params, format: 'pdf'}))
: previewUrl;
} else {
const qs = `url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&style=${qrStyle}&ec=${ec}`;
previewUrl = `${BASE}/api/qr/custom?${qs}&format=${previewFmt}&t=${Date.now()}`;
dlUrl = `${BASE}/api/qr/custom?${qs}&format=${fmt}&download=1`;
}
document.getElementById('qr-preview-wrap').innerHTML =
`<div class="qr-preview-img"><img src="${previewUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
document.getElementById('qr-dl-row').style.display = 'flex';
dlLink.href = dlUrl;
dlLink.download = `qrcode.${fmt}`;
dlLink.textContent = `↓ Download ${fmt.toUpperCase()}`;
document.getElementById('qr-dl-hint').textContent =
fmt === 'svg' ? 'Vector — scales losslessly for print' :
fmt === 'pdf' ? 'Print-ready single-page PDF' : 'Right-click to save';
toast('QR generated!');
} catch(e) { toast('Failed to generate QR','error'); }
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
});
// ── Select Mode & Bulk Ops ─────────────────────────
function toggleSelectMode() {
selectMode = !selectMode;
selectedCodes.clear();
const btn = document.getElementById('select-btn');
btn.classList.toggle('active', selectMode);
btn.textContent = selectMode ? '✓ Selecting' : '⊡ Select';
document.querySelectorAll('.select-check').forEach(el => {
el.style.display = selectMode ? 'flex' : 'none';
});
document.querySelectorAll('.link-checkbox').forEach(el => { el.checked = false; });
updateBulkBar();
}
function toggleSelect(code) {
if (selectedCodes.has(code)) selectedCodes.delete(code);
else selectedCodes.add(code);
updateBulkBar();
}
function selectAll() {
const checkboxes = document.querySelectorAll('.link-checkbox');
const allChecked = [...checkboxes].every(cb => cb.checked);
checkboxes.forEach(cb => {
cb.checked = !allChecked;
const code = cb.id.replace('chk-', '');
if (!allChecked) selectedCodes.add(code); else selectedCodes.delete(code);
});
updateBulkBar();
}
function updateBulkBar() {
const bar = document.getElementById('bulk-bar');
if (selectMode && selectedCodes.size > 0) {
bar.style.display = 'flex';
document.getElementById('bulk-count').textContent = `${selectedCodes.size} selected`;
} else {
bar.style.display = 'none';
}
}
async function bulkDelete() {
if (!selectedCodes.size) return;
if (!confirm(`Delete ${selectedCodes.size} link(s)?`)) return;
const codes = [...selectedCodes];
const resp = await authFetch(`${BASE}/api/links/bulk`, {method:'POST', body:JSON.stringify({action:'delete', codes})});
if (!resp.ok) { toast('Bulk delete failed','error'); return; }
codes.forEach(c => document.getElementById(`item-${c}`)?.remove());
selectedCodes.clear(); updateBulkBar(); loadStats();
toast(`Deleted ${codes.length} link(s)`);
}
async function bulkTag() {
if (!selectedCodes.size) return;
const input = prompt('Tags to apply (comma-separated):');
if (!input) return;
const tags = input.split(',').map(t=>t.trim().toLowerCase()).filter(Boolean);
const codes = [...selectedCodes];
const resp = await authFetch(`${BASE}/api/links/bulk`, {method:'POST', body:JSON.stringify({action:'tag', codes, tags})});
if (!resp.ok) { toast('Bulk tag failed','error'); return; }
selectedCodes.clear(); updateBulkBar(); loadDashboard();
toast(`Tagged ${codes.length} link(s)`);
}
async function bulkExpire() {
if (!selectedCodes.size) return;
const dateVal = prompt('Expiry date (YYYY-MM-DD), blank to clear:');
if (dateVal === null) return;
const codes = [...selectedCodes];
const resp = await authFetch(`${BASE}/api/links/bulk`, {method:'POST', body:JSON.stringify({action:'expire', codes, expires_at: dateVal||null})});
if (!resp.ok) { toast('Bulk expire failed','error'); return; }
selectedCodes.clear(); updateBulkBar(); loadDashboard();
toast(`Updated ${codes.length} link(s)`);
}
// ── CSV Export ─────────────────────────────────────
async function exportCSV() {
const resp = await authFetch(`${BASE}/api/links/export`);
if (!resp.ok) { toast('Export failed','error'); return; }
const blob = await resp.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'qrknit-export.csv'; a.click();
URL.revokeObjectURL(url);
toast('Export downloaded!');
}
// ── CSV Import ─────────────────────────────────────
function toggleImport() {
document.getElementById('import-panel').classList.toggle('open');
}
document.getElementById('import-file').addEventListener('change', function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => { document.getElementById('import-csv').value = e.target.result; };
reader.readAsText(file);
});
async function doImport() {
const csv = document.getElementById('import-csv').value.trim();
if (!csv) { toast('Paste CSV data first','error'); return; }
const btn = document.getElementById('import-btn');
btn.disabled = true; btn.textContent = 'Importing...';
try {
const resp = await authFetch(`${BASE}/api/links/import`, {method:'POST', body:JSON.stringify({csv})});
const data = await resp.json();
if (!resp.ok) { toast(data.error||'Import failed','error'); return; }
const resultEl = document.getElementById('import-result');
let msg = `✓ Created ${data.created} link(s)`;
if (data.errors.length) { msg += `\n✕ Errors:\n` + data.errors.join('\n'); }
resultEl.textContent = msg;
toast(`Imported ${data.created} link(s)`, data.errors.length ? 'error' : 'success');
if (data.created > 0) loadDashboard();
} catch(e) { toast('Network error','error'); }
finally { btn.disabled = false; btn.textContent = 'Import'; }
}
// ── Admin — User Management ────────────────────────
let allOrgs = [];
async function loadAdminPanel() {
await loadOrgs();
await loadUsers();
}
async function loadUsers() {
const container = document.getElementById('users-list');
try {
const resp = await authFetch(`${BASE}/api/admin/users`);
if (!resp.ok) { container.innerHTML = '<div class="empty-state"><p>Access denied.</p></div>'; return; }
const { users } = await resp.json();
if (!users.length) { container.innerHTML = '<div class="empty-state"><p>No users yet.</p></div>'; return; }
// Build org options for dropdowns
const orgOptions = `<option value="">— None —</option>` +
allOrgs.map(o => `<option value="${o.id}">${o.name} (${o.plan})</option>`).join('');
container.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px;font-family:var(--font-mono)">
<thead><tr style="color:var(--muted);font-size:11px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:8px 12px">USERNAME</th>
<th style="text-align:left;padding:8px 12px">ROLE</th>
<th style="text-align:left;padding:8px 12px">PLAN</th>
<th style="text-align:left;padding:8px 12px">ORG</th>
<th style="text-align:left;padding:8px 12px">LINKS</th>
<th style="text-align:left;padding:8px 12px">CREATED</th>
<th style="text-align:right;padding:8px 12px">ACTIONS</th>
</tr></thead>
<tbody>${users.map(u => {
// Plan cell: admin users show ADMIN (locked), org users show inherited plan (locked), solo users get a dropdown
let planCell;
if (u.is_admin) {
planCell = `<span style="color:var(--accent);font-size:11px;letter-spacing:.06em">ADMIN</span>`;
} else if (u.org_id) {
const orgPlan = (u.org_plan || u.plan || 'free').toUpperCase();
planCell = `<span style="color:var(--muted);font-size:11px" title="Inherited from org: ${u.org_name}">${orgPlan} <span style="opacity:.5;font-size:9px">via org</span></span>`;
} else {
planCell = `<select class="field-input" style="padding:3px 6px;font-size:11px;width:auto" onchange="changePlan(${u.id},'${u.username}',this.value,this)">
${['free','starter','pro','team'].map(p=>`<option value="${p}"${(u.plan||'free')===p?' selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
</select>`;
}
// Org cell: dropdown to assign/change org
const orgCell = `<select class="field-input" style="padding:3px 6px;font-size:11px;width:auto" onchange="changeUserOrg(${u.id},'${u.username}',this.value,this)">
${orgOptions.replace(`value="${u.org_id || ''}"`, `value="${u.org_id || ''}" selected`)}
</select>`;
return `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:10px 12px">${u.username}${u.id === (currentUser&&currentUser.id) ? ' <span style="color:var(--muted);font-size:10px">(you)</span>' : ''}</td>
<td style="padding:10px 12px"><span style="color:${u.is_admin?'var(--accent)':'var(--muted)'};font-size:11px">${u.is_admin?'ADMIN':'USER'}</span></td>
<td style="padding:10px 12px">${planCell}</td>
<td style="padding:10px 12px">${orgCell}</td>
<td style="padding:10px 12px;color:var(--muted)">${u.link_count}</td>
<td style="padding:10px 12px;color:var(--muted)">${u.created_at.slice(0,10)}</td>
<td style="padding:10px 12px;text-align:right;display:flex;gap:6px;justify-content:flex-end">
<button class="btn-sm" onclick="promptChangePassword(${u.id},'${u.username}')">Change PW</button>
${u.id !== (currentUser&&currentUser.id) ? `<button class="btn-sm danger" onclick="deleteUser(${u.id},'${u.username}')">Delete</button>` : ''}
</td>
</tr>`;
}).join('')}
</tbody>
</table>`;
} catch(e) { container.innerHTML = '<div class="empty-state"><p>Failed to load users.</p></div>'; }
}
function showCreateUser() {
// Populate org dropdown in create-user form
const sel = document.getElementById('new-org-id');
sel.innerHTML = `<option value="">— None —</option>` +
allOrgs.map(o => `<option value="${o.id}">${o.name} (${o.plan})</option>`).join('');
document.getElementById('create-user-panel').style.display = 'block';
document.getElementById('new-username').focus();
}
function hideCreateUser() {
document.getElementById('create-user-panel').style.display = 'none';
document.getElementById('new-username').value = '';
document.getElementById('new-password').value = '';
document.getElementById('new-is-admin').checked = false;
document.getElementById('new-plan').value = 'free';
document.getElementById('new-org-id').value = '';
document.getElementById('create-user-err').textContent = '';
}
async function createUser() {
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const is_admin = document.getElementById('new-is-admin').checked;
const plan = document.getElementById('new-plan').value;
const orgVal = document.getElementById('new-org-id').value;
const org_id = orgVal ? parseInt(orgVal) : null;
const errEl = document.getElementById('create-user-err');
errEl.textContent = '';
if (!username || !password) { errEl.textContent = 'Username and password required'; return; }
try {
const resp = await authFetch(`${BASE}/api/admin/users`, {
method:'POST', body: JSON.stringify({username, password, is_admin, plan})
});
const d = await resp.json();
if (!resp.ok) { errEl.textContent = d.error; return; }
// If org assigned, update the user's org
if (org_id) {
await authFetch(`${BASE}/api/admin/users/${d.id}`, {
method:'PATCH', body: JSON.stringify({org_id})
});
}
toast(`User "${username}" created`);
hideCreateUser();
loadUsers();
} catch(e) { errEl.textContent = 'Network error'; }
}
async function changePlan(id, username, plan, selectEl) {
try {
const resp = await authFetch(`${BASE}/api/admin/users/${id}`, {
method:'PATCH', body: JSON.stringify({plan})
});
const d = await resp.json();
if (!resp.ok) { toast(d.error || 'Failed to update plan', 'error'); selectEl.value = selectEl.dataset.prev || 'free'; return; }
toast(`Plan for "${username}" set to ${plan}`);
} catch(e) { toast('Network error', 'error'); }
}
async function changeUserOrg(id, username, orgVal, selectEl) {
const org_id = orgVal ? parseInt(orgVal) : null;
try {
const resp = await authFetch(`${BASE}/api/admin/users/${id}`, {
method:'PATCH', body: JSON.stringify({org_id})
});
const d = await resp.json();
if (!resp.ok) { toast(d.error || 'Failed to update org', 'error'); return; }
const orgName = org_id ? (allOrgs.find(o=>o.id===org_id)||{}).name || 'org' : 'no org';
toast(`"${username}" assigned to ${orgName}`);
// Reload to update plan display (org may override plan)
loadUsers();
} catch(e) { toast('Network error', 'error'); }
}
async function deleteUser(id, username) {
if (!confirm(`Delete user "${username}"? Their links will remain in the database.`)) return;
try {
const resp = await authFetch(`${BASE}/api/admin/users/${id}`, {method:'DELETE'});
if (!resp.ok) { const d = await resp.json(); toast(d.error, 'error'); return; }
toast(`User "${username}" deleted`);
loadUsers();
} catch(e) { toast('Network error', 'error'); }
}
async function promptChangePassword(id, username) {
const pw = prompt(`New password for "${username}":`);
if (!pw) return;
try {
const resp = await authFetch(`${BASE}/api/admin/users/${id}/password`, {
method:'PATCH', body: JSON.stringify({password: pw})
});
if (!resp.ok) { const d = await resp.json(); toast(d.error, 'error'); return; }
toast(`Password updated for "${username}"`);
} catch(e) { toast('Network error', 'error'); }
}
// ── Admin — Organization Management ───────────────
async function loadOrgs() {
const container = document.getElementById('orgs-list');
try {
const resp = await authFetch(`${BASE}/api/admin/organizations`);
if (!resp.ok) { container.innerHTML = '<div class="empty-state"><p>Access denied.</p></div>'; return; }
const { organizations } = await resp.json();
allOrgs = organizations || [];
if (!allOrgs.length) {
container.innerHTML = '<div class="empty-state"><p>No organizations yet. Create one to group users.</p></div>';
return;
}
container.innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px;font-family:var(--font-mono)">
<thead><tr style="color:var(--muted);font-size:11px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:8px 12px">NAME</th>
<th style="text-align:left;padding:8px 12px">PLAN</th>
<th style="text-align:left;padding:8px 12px">MEMBERS</th>
<th style="text-align:left;padding:8px 12px">CREATED</th>
<th style="text-align:right;padding:8px 12px">ACTIONS</th>
</tr></thead>
<tbody>${allOrgs.map(o => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:10px 12px">${o.name}</td>
<td style="padding:10px 12px">
<select class="field-input" style="padding:3px 6px;font-size:11px;width:auto" onchange="changeOrgPlan(${o.id},'${o.name}',this.value,this)">
${['free','starter','pro','team'].map(p=>`<option value="${p}"${o.plan===p?' selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
</select>
</td>
<td style="padding:10px 12px;color:var(--muted)">${o.member_count}</td>
<td style="padding:10px 12px;color:var(--muted)">${o.created_at.slice(0,10)}</td>
<td style="padding:10px 12px;text-align:right">
<button class="btn-sm danger" onclick="deleteOrg(${o.id},'${o.name}')">Delete</button>
</td>
</tr>`).join('')}
</tbody>
</table>`;
} catch(e) { container.innerHTML = '<div class="empty-state"><p>Failed to load organizations.</p></div>'; }
}
function showCreateOrg() {
document.getElementById('create-org-panel').style.display = 'block';
document.getElementById('new-org-name').focus();
}
function hideCreateOrg() {
document.getElementById('create-org-panel').style.display = 'none';
document.getElementById('new-org-name').value = '';
document.getElementById('new-org-plan').value = 'team';
document.getElementById('create-org-err').textContent = '';
}
async function createOrg() {
const name = document.getElementById('new-org-name').value.trim();
const plan = document.getElementById('new-org-plan').value;
const errEl = document.getElementById('create-org-err');
errEl.textContent = '';
if (!name) { errEl.textContent = 'Organization name required'; return; }
try {
const resp = await authFetch(`${BASE}/api/admin/organizations`, {
method:'POST', body: JSON.stringify({name, plan})
});
const d = await resp.json();
if (!resp.ok) { errEl.textContent = d.error; return; }
toast(`Organization "${name}" created`);
hideCreateOrg();
loadAdminPanel();
} catch(e) { errEl.textContent = 'Network error'; }
}
async function changeOrgPlan(id, name, plan, selectEl) {
try {
const resp = await authFetch(`${BASE}/api/admin/organizations/${id}`, {
method:'PATCH', body: JSON.stringify({plan})
});
const d = await resp.json();
if (!resp.ok) { toast(d.error || 'Failed to update plan', 'error'); return; }
allOrgs = allOrgs.map(o => o.id === id ? {...o, plan} : o);
toast(`Plan for "${name}" set to ${plan}`);
// Reload users to reflect new org plan
loadUsers();
} catch(e) { toast('Network error', 'error'); }
}
async function deleteOrg(id, name) {
if (!confirm(`Delete org "${name}"? Members will be unassigned but their accounts and data remain.`)) return;
try {
const resp = await authFetch(`${BASE}/api/admin/organizations/${id}`, {method:'DELETE'});
if (!resp.ok) { const d = await resp.json(); toast(d.error, 'error'); return; }
toast(`Organization "${name}" deleted`);
loadAdminPanel();
} catch(e) { toast('Network error', 'error'); }
}
// ── Debug panel (activated by ?debug=1 in URL) ─────
if (new URLSearchParams(window.location.search).get('debug') === '1') {
document.getElementById('debug-panel').style.display = 'block';
runDebug();
}
async function runDebug() {
const panel = document.getElementById('dbg-content');
panel.innerHTML = [
`<b>window.location.origin:</b> ${window.location.origin}`,
`<b>loggedIn:</b> ${loggedIn}`,
].join('<br>');
try {
const r = await fetch(`${BASE}/api/auth/me`, {credentials:'include'});
document.getElementById('dbg-extra').textContent = `/api/auth/me → HTTP ${r.status}`;
} catch(e) {
document.getElementById('dbg-extra').textContent = 'Failed: ' + e;
}
}
async function testStats() {
const extra = document.getElementById('dbg-extra');
try {
const r = await authFetch(`${BASE}/api/stats`);
const body = await r.text();
extra.textContent = `/api/stats → HTTP ${r.status}\n${body.slice(0,500)}`;
} catch(e) {
extra.textContent = 'Error: ' + e;
}
}
</script>
<!-- © 2025 QRknit. All rights reserved. Third-party notices: /notices -->
</body>
</html>