2241 lines
118 KiB
HTML
2241 lines
118 KiB
HTML
<!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 https://example.com,My Link,tag1 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>
|
||
</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 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)">Right-click to save</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- ── ADMIN ── -->
|
||
<div class="view" id="view-admin">
|
||
<!-- 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
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> <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}
|
||
<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 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">
|
||
<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,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
||
|
||
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 tags = document.getElementById(`edit-tags-${code}`).value.split(',').map(t=>t.trim()).filter(Boolean);
|
||
const resp = await authFetch(`${BASE}/api/links/${code}`, {method:'PATCH',body:JSON.stringify({url,title,expires_at:expiry||null,tags})});
|
||
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 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';
|
||
}
|
||
|
||
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 btn = document.getElementById('qr-generate-btn');
|
||
|
||
if (qrLogoBase64 || qrStyle !== 'square') {
|
||
btn.disabled = true; btn.textContent = 'Generating...';
|
||
try {
|
||
const body = { url, fg, bg, size, style: qrStyle };
|
||
if (qrLogoBase64) body.logo = qrLogoBase64;
|
||
const resp = await authFetch(`${BASE}/api/qr/custom`, {method:'POST', body:JSON.stringify(body)});
|
||
if (!resp.ok) { toast('Failed to generate QR','error'); return; }
|
||
const blob = await resp.blob();
|
||
const blobUrl = URL.createObjectURL(blob);
|
||
document.getElementById('qr-preview-wrap').innerHTML =
|
||
`<div class="qr-preview-img"><img src="${blobUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
|
||
document.getElementById('qr-dl-row').style.display = 'flex';
|
||
document.getElementById('qr-download-link').href = blobUrl;
|
||
} catch(e) { toast('Network error','error'); }
|
||
finally { btn.disabled = false; btn.textContent = 'Generate QR Code'; }
|
||
} else {
|
||
const qrUrl = `${BASE}/api/qr/custom?url=${encodeURIComponent(url)}&fg=${fg}&bg=${bg}&size=${size}&t=${Date.now()}`;
|
||
document.getElementById('qr-preview-wrap').innerHTML =
|
||
`<div class="qr-preview-img"><img src="${qrUrl}" width="${Math.min(size,300)}" height="${Math.min(size,300)}" alt="QR"></div>`;
|
||
document.getElementById('qr-dl-row').style.display = 'flex';
|
||
document.getElementById('qr-download-link').href = qrUrl;
|
||
}
|
||
toast('QR generated!');
|
||
});
|
||
|
||
// ── 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&¤tUser.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&¤tUser.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>
|