2026-03-06 10:17:59 -06:00
<!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 : 12 px ;
--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 : 15 px ; line-height : 1.6 ; min-height : 100 vh ; 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 40 px ; height : 64 px ;
background : rgba ( 10 , 10 , 15 , .88 ); backdrop-filter : blur ( 20 px );
border-bottom : 1 px solid var ( -- border );
}
. logo { font-family : var ( -- font - head ); font-size : 22 px ; font-weight : 700 ; letter-spacing : .04 em ; text-decoration : none ; color : var ( -- text ); display : inline-flex ; align-items : center ; gap : 5 px ; }
. logo-dot-text { color : var ( -- accent ); text-shadow : 0 0 12 px 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 : 35 px ; width : 36 px ; object-fit : contain ; border-radius : 3 px ; display : block ; }
. logo-icon-tint { position : absolute ; inset : 0 ; background : var ( -- accent ); mix-blend-mode : hue ; border-radius : 3 px ; pointer-events : none ; }
nav { display : flex ; gap : 4 px ; }
nav button { background : transparent ; border : none ; color : var ( -- muted ); cursor : pointer ; font-family : var ( -- font - body ); font-size : 14 px ; padding : 6 px 16 px ; border-radius : 8 px ; transition : all .2 s ; }
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 : 10 px ; }
. user-badge {
display : flex ; align-items : center ; gap : 8 px ;
background : var ( -- surface2 ); border : 1 px solid var ( -- border );
border-radius : 8 px ; padding : 5 px 12 px ; font-size : 13 px ;
font-family : var ( -- font - mono );
}
. user-badge . admin-tag { color : var ( -- accent ); font-size : 10 px ; letter-spacing : .06 em ; }
. user-badge . plan-tag { font-size : 10 px ; letter-spacing : .06 em ; color : var ( -- muted ); border : 1 px solid var ( -- border ); border-radius : 4 px ; padding : 1 px 5 px ; }
. plan-usage-bar { margin-top : 16 px ; background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 14 px 18 px ; }
. plan-usage-bar h4 { font-family : var ( -- font - mono ); font-size : 11 px ; letter-spacing : .08 em ; color : var ( -- muted ); margin : 0 0 12 px ; }
. plan-usage-row { display : flex ; align-items : center ; gap : 10 px ; margin-bottom : 8 px ; font-family : var ( -- font - mono ); font-size : 12 px ; }
. plan-usage-row : last-child { margin-bottom : 0 ; }
. plan-usage-label { color : var ( -- muted ); width : 130 px ; flex-shrink : 0 ; }
. plan-usage-track { flex : 1 ; height : 6 px ; background : var ( -- border ); border-radius : 3 px ; overflow : hidden ; }
. plan-usage-fill { height : 100 % ; border-radius : 3 px ; background : var ( -- accent ); transition : width .4 s ; }
. plan-usage-fill . warn { background : #f59e0b ; }
. plan-usage-fill . danger { background : var ( -- accent2 ); }
. plan-usage-count { color : var ( -- fg ); min-width : 80 px ; text-align : right ; }
. btn-logout { background : transparent ; border : 1 px solid var ( -- border ); color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 12 px ; padding : 5 px 12 px ; border-radius : 8 px ; cursor : pointer ; transition : all .2 s ; }
. btn-logout : hover { border-color : var ( -- accent2 ); color : var ( -- accent2 ); }
/* ── PORTAL ── */
. portal-btn { position : relative ; background : transparent ; border : 1 px solid rgba ( var ( -- accent - rgb ), .3 ); color : var ( -- accent ); font-family : var ( -- font - mono ); font-size : 11 px ; letter-spacing : .08 em ; padding : 5 px 12 px ; border-radius : 8 px ; cursor : pointer ; transition : all .2 s ; }
. portal-btn : hover { background : rgba ( var ( -- accent - rgb ), .08 ); border-color : var ( -- accent ); }
. portal-badge { position : absolute ; top : -5 px ; right : -5 px ; min-width : 16 px ; height : 16 px ; background : var ( -- accent2 ); color : #fff ; font-family : var ( -- font - mono ); font-size : 9 px ; font-weight : 700 ; border-radius : 8 px ; padding : 0 4 px ; display : flex ; align-items : center ; justify-content : center ; line-height : 1 ; }
. portal-panel { position : absolute ; top : calc ( 100 % + 8 px ); right : 0 ; width : 380 px ; max-height : 520 px ; background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius ); box-shadow : 0 16 px 48 px 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 : 14 px 16 px 10 px ; border-bottom : 1 px solid var ( -- border ); flex-shrink : 0 ; }
. portal-panel-hdr h4 { font-family : var ( -- font - mono ); font-size : 12 px ; letter-spacing : .06 em ; color : var ( -- accent ); }
. portal-hdr-actions { display : flex ; gap : 8 px ; align-items : center ; }
. portal-mark-all { background : none ; border : none ; font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); cursor : pointer ; padding : 2 px 6 px ; border-radius : 4 px ; transition : color .2 s ; }
. portal-mark-all : hover { color : var ( -- text ); }
. portal-msgs { overflow-y : auto ; flex : 1 ; }
. portal-msg { border-bottom : 1 px solid var ( -- border ); padding : 14 px 16 px ; cursor : default ; transition : background .15 s ; }
. 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 : 8 px ; margin-bottom : 4 px ; }
. portal-msg-from { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- text ); font-weight : 600 ; }
. portal-msg-tag { font-family : var ( -- font - mono ); font-size : 9 px ; letter-spacing : .06 em ; padding : 2 px 6 px ; border-radius : 4 px ; 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 : 12 px ; color : var ( -- muted ); margin-bottom : 4 px ; }
. portal-msg-body { font-size : 12 px ; 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 : 8 px ; }
. portal-msg-date { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); opacity : .6 ; }
. portal-del-btn { background : none ; border : none ; font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); cursor : pointer ; padding : 2 px 6 px ; border-radius : 4 px ; transition : color .2 s ; }
. portal-del-btn : hover { color : var ( -- accent2 ); }
. portal-empty { padding : 32 px 16 px ; text-align : center ; color : var ( -- muted ); font-size : 13 px ; font-family : var ( -- font - mono ); }
. portal-wrap { position : relative ; }
/* ── UPTIME WIDGET ── */
. header-left { display : flex ; align-items : center ; gap : 16 px ; }
. uptime-widget {
display : flex ; flex-direction : column ; gap : 3 px ;
padding : 5 px 10 px ;
background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 8 px ;
min-width : 132 px ; 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 : 9 px ; letter-spacing : .08 em ; color : var ( -- muted ); text-transform : uppercase ; }
. uptime-pct { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- accent ); }
# uptime-canvas { width : 100 % ; height : 14 px ; display : block ; }
# uptime-tip {
position : fixed ; pointer-events : none ; z-index : 500 ;
background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : 6 px ;
padding : 4 px 9 px ; font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- text );
white-space : nowrap ; display : none ;
}
/* ── LAYOUT ── */
main { position : relative ; z-index : 1 ; }
. view { display : none ; padding : 40 px ; max-width : 980 px ; margin : 0 auto ; }
. view . active { display : block ; animation : fadeIn .25 s ease ; }
@ keyframes fadeIn { from { opacity : 0 ; transform : translateY ( 8 px )} to { opacity : 1 ; transform : none } }
/* ── HERO ── */
. hero { text-align : center ; padding : 60 px 20 px 40 px ; }
. hero h1 { font-family : var ( -- font - head ); font-size : clamp ( 42 px , 8 vw , 88 px ); font-weight : 900 ; line-height : 1 ; letter-spacing : .06 em ; margin-bottom : 40 px ; text-transform : lowercase ; }
. hero-to { color : var ( -- text ); }
. hero-dot { color : var ( -- accent ); text-shadow : 0 0 20 px var ( -- accent ), 0 0 40 px rgba ( var ( -- accent - rgb ), .4 ); }
. hero-alwisp { text-transform : uppercase ; background : linear-gradient ( 135 deg , 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 : 1 px solid var ( -- border ); border-radius : 20 px ; padding : 32 px ; max-width : 720 px ; margin : 0 auto ; box-shadow : 0 24 px 64 px rgba ( 0 , 0 , 0 , .4 ); }
. panel { background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius ); padding : 24 px ; }
. panel h3 { font-family : var ( -- font - head ); font-size : 13 px ; font-weight : 700 ; margin-bottom : 18 px ; letter-spacing : .04 em ; }
/* ── INPUTS ── */
. input-group { display : flex ; gap : 10 px ; margin-bottom : 20 px ; }
. input-group input , . field-input {
background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius );
color : var ( -- text ); font-family : var ( -- font - mono ); font-size : 13 px ; padding : 12 px 16 px ; outline : none ;
transition : border-color .2 s , box-shadow .2 s ; width : 100 % ;
}
. input-group input { flex : 1 ; }
. input-group input : focus , . field-input : focus { border-color : var ( -- accent ); box-shadow : 0 0 0 3 px rgba ( var ( -- accent - rgb ), .1 ); }
. input-group input :: placeholder , . field-input :: placeholder { color : var ( -- muted ); }
. field-label { font-size : 11 px ; font-family : var ( -- font - mono ); letter-spacing : .08 em ; color : var ( -- muted ); margin-bottom : 5 px ; display : block ; }
/* ── BUTTONS ── */
. btn-primary { background : linear-gradient ( 135 deg , var ( -- accent ), var ( -- accent - grad )); color : #fff ; border : none ; cursor : pointer ; font-family : var ( -- font - head ); font-weight : 700 ; font-size : 12 px ; letter-spacing : .04 em ; padding : 12 px 22 px ; border-radius : var ( -- radius ); transition : all .2 s ; white-space : nowrap ; }
. btn-primary : hover { transform : translateY ( -1 px ); box-shadow : 0 8 px 24 px rgba ( var ( -- accent - rgb ), .35 ); }
. btn-primary : disabled { opacity : .5 ; cursor : not-allowed ; transform : none ; box-shadow : none ; }
. btn-sm { border : 1 px solid var ( -- border ); background : var ( -- surface2 ); color : var ( -- text ); font-size : 11 px ; font-family : var ( -- font - mono ); padding : 6 px 14 px ; border-radius : 8 px ; cursor : pointer ; transition : all .2 s ; }
. 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 ( 8 px );
display : flex ; align-items : center ; justify-content : center ; z-index : 200 ;
animation : fadeIn .2 s ease ;
}
. modal-overlay . hidden { display : none ; }
. modal {
background : var ( -- surface ); border : 1 px solid var ( -- border );
border-radius : 20 px ; padding : 40 px ; width : 100 % ; max-width : 420 px ;
box-shadow : 0 32 px 80 px rgba ( 0 , 0 , 0 , .6 );
animation : modalIn .3 s cubic-bezier ( .34 , 1.56 , .64 , 1 );
}
@ keyframes modalIn { from { opacity : 0 ; transform : scale ( .94 ) translateY ( 10 px )} to { opacity : 1 ; transform : none } }
. modal-logo { font-family : var ( -- font - head ); font-size : 22 px ; font-weight : 900 ; text-align : center ; margin-bottom : 28 px ; }
. modal-tabs { display : flex ; background : var ( -- surface2 ); border-radius : 10 px ; padding : 4 px ; margin-bottom : 24 px ; }
. modal-tab { flex : 1 ; padding : 8 px ; border : none ; background : transparent ; color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 12 px ; cursor : pointer ; border-radius : 7 px ; transition : all .2 s ; }
. modal-tab . active { background : var ( -- surface3 ); color : var ( -- accent ); }
. modal-form { display : none ; flex-direction : column ; gap : 14 px ; }
. modal-form . active { display : flex ; }
. modal-form label { display : flex ; flex-direction : column ; gap : 5 px ; font-size : 11 px ; font-family : var ( -- font - mono ); color : var ( -- muted ); letter-spacing : .06 em ; }
. modal-form input { background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 8 px ; color : var ( -- text ); font-family : var ( -- font - mono ); font-size : 13 px ; padding : 11 px 14 px ; outline : none ; transition : border-color .2 s ; }
. modal-form input : focus { border-color : var ( -- accent ); }
. modal-err { color : var ( -- accent2 ); font-size : 12 px ; font-family : var ( -- font - mono ); display : none ; }
. modal-err . show { display : block ; }
. invite-note { font-size : 12 px ; color : var ( -- muted ); font-family : var ( -- font - mono ); text-align : center ; }
/* ── STATS STRIP ── */
. stats-strip { display : grid ; grid-template-columns : repeat ( 4 , 1 fr ); gap : 14 px ; margin-bottom : 28 px ; }
. stat-card { background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius ); padding : 20 px ; position : relative ; overflow : hidden ; }
. stat-card :: before { content : '' ; position : absolute ; top : 0 ; left : 0 ; right : 0 ; height : 2 px ; background : linear-gradient ( 90 deg , var ( -- accent ), transparent ); }
. stat-value { font-family : var ( -- font - head ); font-size : 28 px ; font-weight : 900 ; line-height : 1 ; margin-bottom : 4 px ; background : linear-gradient ( 135 deg , var ( -- text ), var ( -- muted )); -webkit- background-clip : text ; -webkit- text-fill-color : transparent ; background-clip : text ; }
. stat-label { font-size : 10 px ; color : var ( -- muted ); font-family : var ( -- font - mono ); letter-spacing : .06 em ; }
/* ── TOOLBAR ── */
. toolbar { display : flex ; align-items : center ; gap : 10 px ; margin-bottom : 18 px ; flex-wrap : wrap ; }
. search-wrap { flex : 1 ; min-width : 180 px ; position : relative ; }
. search-wrap svg { position : absolute ; left : 12 px ; top : 50 % ; transform : translateY ( -50 % ); color : var ( -- muted ); pointer-events : none ; }
. search-input { width : 100 % ; background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius ); color : var ( -- text ); font-family : var ( -- font - mono ); font-size : 13 px ; padding : 10 px 14 px 10 px 38 px ; outline : none ; transition : border-color .2 s ; }
. search-input : focus { border-color : var ( -- accent ); }
. search-input :: placeholder { color : var ( -- muted ); }
. tag-filter-list { display : flex ; gap : 5 px ; flex-wrap : wrap ; }
. tag-filter-btn { background : var ( -- surface ); border : 1 px solid var ( -- border ); color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 11 px ; padding : 5 px 11 px ; border-radius : 6 px ; cursor : pointer ; transition : all .2 s ; }
. 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 : 8 px ; margin-bottom : 18 px ; flex-wrap : wrap ; }
. ws-btn { background : var ( -- surface2 ); border : 1 px solid var ( -- border ); color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 11 px ; padding : 5 px 12 px ; border-radius : 6 px ; cursor : pointer ; transition : all .2 s ; }
. 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 : 8 px ; }
. link-item { background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius ); overflow : hidden ; transition : border-color .2 s ; }
. link-item : hover { border-color : rgba ( var ( -- accent - rgb ), .15 ); }
. link-item . expanded { border-color : rgba ( var ( -- accent - rgb ), .25 ); }
. link-row { padding : 14 px 18 px ; display : flex ; align-items : center ; gap : 12 px ; cursor : pointer ; user-select : none ; }
. link-code { font-family : var ( -- font - head ); font-size : 14 px ; font-weight : 700 ; color : var ( -- accent ); min-width : 72 px ; flex-shrink : 0 ; }
. link-details { flex : 1 ; overflow : hidden ; }
. link-title { font-size : 13 px ; margin-bottom : 1 px ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. link-original { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. link-tags { display : flex ; gap : 4 px ; flex-wrap : wrap ; margin-top : 3 px ; }
. link-tag { background : rgba ( var ( -- accent3 - rgb ), .1 ); border : 1 px solid rgba ( var ( -- accent3 - rgb ), .25 ); color : var ( -- accent3 - light ); font-family : var ( -- font - mono ); font-size : 10 px ; padding : 1 px 6 px ; border-radius : 3 px ; }
. link-owner-badge { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); white-space : nowrap ; }
. link-owner-btn { background : var ( -- surface3 ); border : 1 px solid var ( -- border ); color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 10 px ; padding : 1 px 7 px ; border-radius : 4 px ; cursor : pointer ; transition : all .2 s ; }
. 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 : 8 px ; padding : 6 px 12 px ; background : rgba ( var ( -- accent - rgb ), .06 ); border : 1 px solid rgba ( var ( -- accent - rgb ), .2 ); border-radius : 8 px ; margin-bottom : 14 px ; font-family : var ( -- font - mono ); font-size : 12 px ; color : var ( -- accent ); }
. ws-tag { background : rgba ( var ( -- accent - rgb ), .08 ); border : 1 px solid rgba ( var ( -- accent - rgb ), .2 ); color : var ( -- accent ); font-family : var ( -- font - mono ); font-size : 10 px ; padding : 1 px 6 px ; border-radius : 3 px ; }
. link-meta { display : flex ; align-items : center ; gap : 8 px ; flex-shrink : 0 ; }
. link-clicks { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); white-space : nowrap ; }
. link-clicks strong { color : var ( -- text ); }
. link-chevron { color : var ( -- muted ); transition : transform .25 s ; flex-shrink : 0 ; }
. link-item . expanded . link-chevron { transform : rotate ( 180 deg ); }
/* ── EXPANDED PANEL ── */
. link-expanded { display : none ; border-top : 1 px solid var ( -- border ); padding : 20 px ; background : var ( -- surface2 ); animation : expandIn .2 s ease ; }
@ keyframes expandIn { from { opacity : 0 } to { opacity : 1 } }
. link-item . expanded . link-expanded { display : block ; }
. expanded-actions { display : flex ; gap : 8 px ; margin-bottom : 18 px ; flex-wrap : wrap ; }
. edit-form { background : var ( -- surface3 ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 18 px ; margin-bottom : 18 px ; display : none ; }
. edit-form . open { display : block ; }
. edit-form h4 { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); letter-spacing : .08 em ; margin-bottom : 14 px ; }
. edit-grid { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 10 px ; margin-bottom : 12 px ; }
. edit-full { grid-column : 1 /- 1 ; }
. edit-actions { display : flex ; gap : 8 px ; justify-content : flex-end ; }
/* ── ANALYTICS ── */
. analytics-header { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 14 px ; }
. analytics-title { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); letter-spacing : .08 em ; }
. analytics-days { display : flex ; gap : 4 px ; }
. day-btn { background : var ( -- surface3 ); border : 1 px solid var ( -- border ); color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 11 px ; padding : 3 px 9 px ; border-radius : 5 px ; cursor : pointer ; transition : all .2 s ; }
. 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 : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 14 px ; margin-bottom : 14 px ; }
. chart-canvas { width : 100 % ; height : 90 px ; display : block ; }
. breakdown-grid { display : grid ; grid-template-columns : 1 fr 1 fr 1 fr ; gap : 10 px ; }
. breakdown-card { background : var ( -- surface3 ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 12 px ; }
. breakdown-card h5 { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); letter-spacing : .08 em ; margin-bottom : 8 px ; }
. breakdown-row { display : flex ; align-items : center ; gap : 6 px ; margin-bottom : 5 px ; }
. breakdown-label { font-family : var ( -- font - mono ); font-size : 11 px ; flex : 1 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. breakdown-bar-wrap { flex : 2 ; height : 4 px ; background : var ( -- surface2 ); border-radius : 2 px ; overflow : hidden ; }
. breakdown-bar { height : 100 % ; border-radius : 2 px ; background : var ( -- accent ); transition : width .4 s ; }
. breakdown-count { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); min-width : 24 px ; text-align : right ; }
/* ── DASHBOARD CHART ── */
. dash-chart-wrap { background : var ( -- surface ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius ); padding : 18 px 20 px ; margin-bottom : 22 px ; }
. dash-chart-header { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 12 px ; }
. dash-chart-title { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); letter-spacing : .08 em ; }
. dash-chart-total { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); }
. dash-chart-canvas { width : 100 % ; display : block ; }
/* ── HOURLY HEATMAP ── */
. heatmap-wrap { margin-top : 14 px ; background : var ( -- surface3 ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 12 px ; }
. heatmap-title { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); letter-spacing : .08 em ; margin-bottom : 8 px ; }
. heatmap-grid { display : grid ; grid-template-columns : 28 px repeat ( 24 , 1 fr ); gap : 2 px ; }
. heatmap-hour-label { font-family : var ( -- font - mono ); font-size : 7 px ; color : var ( -- muted ); text-align : center ; line-height : 12 px ; }
. heatmap-day-label { font-family : var ( -- font - mono ); font-size : 8 px ; color : var ( -- muted ); display : flex ; align-items : center ; padding-right : 4 px ; }
. heatmap-cell { height : 12 px ; border-radius : 2 px ; }
/* ── SECTION HEAD ── */
. section-head { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 20 px ; }
. section-title { font-family : var ( -- font - head ); font-size : 18 px ; font-weight : 700 ; }
/* ── ADVANCED ── */
. advanced-toggle { color : var ( -- muted ); font-size : 12 px ; cursor : pointer ; display : flex ; align-items : center ; gap : 6 px ; margin-bottom : 14 px ; user-select : none ; transition : color .2 s ; width : fit-content ; }
. advanced-toggle : hover { color : var ( -- text ); }
. advanced-toggle svg { transition : transform .2 s ; }
. advanced-toggle . open svg { transform : rotate ( 90 deg ); }
. advanced-fields { display : none ; gap : 10 px ; }
. advanced-fields . open { display : grid ; grid-template-columns : 1 fr 1 fr ; }
/* ── TAG INPUT ── */
. tag-input-wrap { background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 8 px ; padding : 8 px 12 px ; display : flex ; flex-wrap : wrap ; gap : 5 px ; align-items : center ; transition : border-color .2 s ; cursor : text ; }
. tag-input-wrap : focus-within { border-color : var ( -- accent ); }
. tag-pill { background : rgba ( var ( -- accent3 - rgb ), .15 ); border : 1 px solid rgba ( var ( -- accent3 - rgb ), .3 ); color : var ( -- accent3 - light ); font-family : var ( -- font - mono ); font-size : 11 px ; padding : 2 px 8 px ; border-radius : 4 px ; display : flex ; align-items : center ; gap : 4 px ; }
. tag-pill button { background : none ; border : none ; color : inherit ; cursor : pointer ; font-size : 13 px ; 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 : 13 px ; outline : none ; min-width : 80 px ; flex : 1 ; }
. tag-input-inner :: placeholder { color : var ( -- muted ); }
. tag-suggestions { position : absolute ; top : 100 % ; left : 0 ; right : 0 ; background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 8 px ; margin-top : 4 px ; max-height : 160 px ; overflow-y : auto ; z-index : 200 ; display : none ; box-shadow : 0 4 px 16 px rgba ( 0 , 0 , 0 , .3 ); }
. tag-suggestions . open { display : block ; }
. tag-suggestion-item { padding : 6 px 12 px ; cursor : pointer ; font-family : var ( -- font - mono ); font-size : 12 px ; color : var ( -- text ); transition : background .1 s ; }
. tag-suggestion-item : hover , . tag-suggestion-item . active { background : rgba ( var ( -- accent - rgb ), .15 ); color : var ( -- accent ); }
/* ── RESULT CARD ── */
. result-card { background : linear-gradient ( 135 deg , rgba ( var ( -- accent - rgb ), .06 ), rgba ( var ( -- accent - rgb ), .03 )); border : 1 px solid rgba ( var ( -- accent - rgb ), .2 ); border-radius : 16 px ; padding : 22 px ; margin-top : 18 px ; display : none ; animation : slideIn .4 s cubic-bezier ( .34 , 1.56 , .64 , 1 ); }
@ keyframes slideIn { from { opacity : 0 ; transform : scale ( .96 ) translateY ( 8 px )} to { opacity : 1 ; transform : none } }
. result-card . visible { display : block ; }
. result-top { display : flex ; align-items : flex-start ; justify-content : space-between ; gap : 16 px ; flex-wrap : wrap ; }
. result-short-url { font-family : var ( -- font - mono ); font-size : 20 px ; font-weight : 500 ; color : var ( -- accent ); text-decoration : none ; letter-spacing : -.5 px ; word-break : break-all ; }
. result-short-url : hover { text-decoration : underline ; }
. result-original { font-size : 11 px ; color : var ( -- muted ); font-family : var ( -- font - mono ); margin-top : 4 px ; word-break : break-all ; }
. result-actions { display : flex ; gap : 7 px ; flex-shrink : 0 ; }
. result-qr { margin-top : 18 px ; display : flex ; align-items : center ; gap : 20 px ; padding-top : 18 px ; border-top : 1 px solid var ( -- border ); flex-wrap : wrap ; }
. qr-img-wrap { background : #fff ; border-radius : 10 px ; padding : 10 px ; flex-shrink : 0 ; box-shadow : 0 4 px 20 px rgba ( 0 , 0 , 0 , .3 ); }
. qr-img-wrap img { display : block ; width : 100 px ; height : 100 px ; }
. qr-info h4 { font-family : var ( -- font - head ); font-size : 12 px ; margin-bottom : 5 px ; }
. qr-info p { font-size : 11 px ; color : var ( -- muted ); margin-bottom : 10 px ; }
/* ── ADMIN / SETTINGS PANELS ── */
. tabs { display : flex ; background : var ( -- surface2 ); border-radius : 10 px ; padding : 4 px ; margin-bottom : 22 px ; gap : 2 px ; }
. tab-btn { flex : 1 ; padding : 8 px ; border : none ; background : transparent ; color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 11 px ; cursor : pointer ; border-radius : 7 px ; transition : all .2 s ; letter-spacing : .04 em ; }
. 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 : 13 px ; }
. data-table th { font-family : var ( -- font - mono ); font-size : 10 px ; color : var ( -- muted ); letter-spacing : .08 em ; text-align : left ; padding : 8 px 12 px ; border-bottom : 1 px solid var ( -- border ); }
. data-table td { padding : 10 px 12 px ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , .04 ); font-family : var ( -- font - mono ); font-size : 12 px ; }
. data-table tr : hover td { background : rgba ( 255 , 255 , 255 , .02 ); }
. badge { display : inline-block ; font-family : var ( -- font - mono ); font-size : 10 px ; padding : 2 px 8 px ; border-radius : 4 px ; }
. badge . admin { background : rgba ( var ( -- accent - rgb ), .1 ); border : 1 px solid rgba ( var ( -- accent - rgb ), .25 ); color : var ( -- accent ); }
. badge . member { background : rgba ( var ( -- accent3 - rgb ), .1 ); border : 1 px solid rgba ( var ( -- accent3 - rgb ), .25 ); color : var ( -- accent3 - light ); }
. badge . inactive { background : rgba ( 255 , 94 , 98 , .1 ); border : 1 px solid rgba ( 255 , 94 , 98 , .25 ); color : var ( -- accent2 ); }
/* ── KEY CARD ── */
. key-card { background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 14 px 18 px ; display : flex ; align-items : center ; gap : 12 px ; margin-bottom : 8 px ; }
. key-prefix { font-family : var ( -- font - mono ); font-size : 13 px ; flex : 1 ; }
. key-label { font-size : 12 px ; color : var ( -- muted ); }
. key-reveal { background : rgba ( var ( -- accent - rgb ), .1 ); border : 1 px solid rgba ( var ( -- accent - rgb ), .25 ); color : var ( -- accent ); font-family : var ( -- font - mono ); font-size : 12 px ; padding : 6 px 16 px ; border-radius : 8 px ; margin : 12 px 0 ; word-break : break-all ; display : none ; }
. key-reveal . show { display : block ; }
/* ── INVITE CARD ── */
. invite-card { background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 14 px 18 px ; display : flex ; align-items : center ; gap : 12 px ; margin-bottom : 8 px ; }
. invite-url { font-family : var ( -- font - mono ); font-size : 12 px ; flex : 1 ; color : var ( -- muted ); white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; }
. invite-used { color : var ( -- accent2 ); font-size : 11 px ; font-family : var ( -- font - mono ); }
. invite-exp { color : var ( -- muted ); font-size : 11 px ; font-family : var ( -- font - mono ); }
/* ── QR VIEW ── */
. qr-grid { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 24 px ; }
. qr-preview-panel { display : flex ; flex-direction : column ; align-items : center ; justify-content : center ; gap : 20 px ; min-height : 280 px ; }
. qr-preview-img { background : #fff ; border-radius : 12 px ; padding : 14 px ; box-shadow : 0 8 px 32 px rgba ( 0 , 0 , 0 , .4 ); }
. qr-preview-img img { display : block ; width : 200 px ; height : 200 px ; }
. qr-placeholder { width : 200 px ; height : 200 px ; border : 2 px dashed var ( -- border ); border-radius : 8 px ; display : flex ; align-items : center ; justify-content : center ; color : var ( -- muted ); font-size : 12 px ; font-family : var ( -- font - mono ); text-align : center ; padding : 20 px ; }
. color-row { display : flex ; gap : 12 px ; margin-bottom : 14 px ; }
. color-field { flex : 1 ; }
. color-field label { font-size : 11 px ; color : var ( -- muted ); font-family : var ( -- font - mono ); display : block ; margin-bottom : 5 px ; letter-spacing : .06 em ; }
. color-input-wrap { display : flex ; align-items : center ; gap : 8 px ; background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 8 px ; padding : 8 px 12 px ; }
input [ type = "color" ] { width : 24 px ; height : 24 px ; border : none ; border-radius : 4 px ; padding : 0 ; cursor : pointer ; background : none ; flex-shrink : 0 ; }
. color-hex { font-family : var ( -- font - mono ); font-size : 13 px ; background : none ; border : none ; color : var ( -- text ); width : 70 px ; outline : none ; }
input [ type = "range" ] { -webkit- appearance : none ; width : 100 % ; height : 4 px ; background : var ( -- surface2 ); border-radius : 2 px ; outline : none ; }
input [ type = "range" ] :: -webkit-slider-thumb { -webkit- appearance : none ; width : 16 px ; height : 16 px ; background : var ( -- accent ); border-radius : 50 % ; cursor : pointer ; }
/* ── TOAST ── */
. toast-container { position : fixed ; bottom : 24 px ; right : 24 px ; display : flex ; flex-direction : column ; gap : 8 px ; z-index : 999 ; }
. toast { background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 11 px 16 px ; font-size : 12 px ; font-family : var ( -- font - mono ); display : flex ; align-items : center ; gap : 8 px ; box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , .4 ); animation : toastIn .3 s cubic-bezier ( .34 , 1.56 , .64 , 1 ); }
@ keyframes toastIn { from { opacity : 0 ; transform : translateX ( 16 px ) 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 : 50 px 20 px ; color : var ( -- muted ); }
. empty-state . empty-icon { font-size : 40 px ; margin-bottom : 14 px ; opacity : .3 ; }
. empty-state p { font-family : var ( -- font - mono ); font-size : 12 px ; }
. spinner { width : 14 px ; height : 14 px ; border : 2 px solid rgba ( 0 , 0 , 0 , .2 ); border-top-color : #000 ; border-radius : 50 % ; animation : spin .7 s linear infinite ; display : inline-block ; }
@ keyframes spin { to { transform : rotate ( 360 deg )} }
. divider { border : none ; border-top : 1 px solid var ( -- border ); margin : 20 px 0 ; }
. grid-2 { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 16 px ; }
@ media ( max-width : 700px ) {
header { padding : 0 16 px ; }
. view { padding : 16 px 12 px ; }
. stats-strip { grid-template-columns : 1 fr 1 fr ; }
. input-group { flex-direction : column ; }
. advanced-fields . open , . edit-grid , . qr-grid , . breakdown-grid , . grid-2 { grid-template-columns : 1 fr ; }
}
/* ── PHASE 4: QR STYLE SELECTOR ── */
. style-selector { display : flex ; gap : 6 px ; flex-wrap : wrap ; }
. style-btn { background : var ( -- surface2 ); border : 1 px solid var ( -- border ); color : var ( -- muted ); font-family : var ( -- font - mono ); font-size : 11 px ; padding : 6 px 12 px ; border-radius : 6 px ; cursor : pointer ; transition : all .2 s ; }
. 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 : 8 px ; flex-wrap : wrap ; }
/* ── PHASE 4: BULK ACTION BAR ── */
. bulk-bar { display : none ; align-items : center ; gap : 8 px ; padding : 10 px 14 px ; background : var ( -- surface2 ); border : 1 px solid rgba ( var ( -- accent - rgb ), .2 ); border-radius : var ( -- radius ); margin-bottom : 14 px ; flex-wrap : wrap ; }
. bulk-count { font-family : var ( -- font - mono ); font-size : 12 px ; color : var ( -- accent ); flex : 1 ; min-width : 80 px ; }
/* ── PHASE 4: IMPORT PANEL ── */
. import-panel { display : none ; background : var ( -- surface2 ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius ); padding : 18 px ; margin-bottom : 14 px ; }
. import-panel . open { display : block ; animation : fadeIn .2 s ease ; }
. import-panel textarea { width : 100 % ; background : var ( -- surface3 ); border : 1 px solid var ( -- border ); border-radius : 8 px ; color : var ( -- text ); font-family : var ( -- font - mono ); font-size : 12 px ; padding : 12 px ; outline : none ; resize : vertical ; min-height : 100 px ; transition : border-color .2 s ; box-sizing : border-box ; }
. import-panel textarea : focus { border-color : var ( -- accent ); }
. import-result { font-family : var ( -- font - mono ); font-size : 11 px ; color : var ( -- muted ); margin-top : 8 px ; white-space : pre-wrap ; }
/* ── PHASE 4: SELECT CHECKBOX ── */
. select-check { display : none ; align-items : center ; margin-right : 6 px ; flex-shrink : 0 ; }
. link-checkbox { width : 15 px ; height : 15 px ; cursor : pointer ; accent-color : var ( -- accent ); }
/* ── THEME PICKER ── */
. theme-picker { display : flex ; align-items : center ; gap : 6 px ; padding : 0 4 px ; }
. theme-swatch {
width : 18 px ; height : 18 px ; border-radius : 50 % ; border : 2 px solid transparent ;
cursor : pointer ; transition : transform .15 s , border-color .15 s ; 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 ( 135 deg , #00b8d4 , #1a52c8 ); }
. theme-swatch [ data-theme = "gold" ] { background : linear-gradient ( 135 deg , #f5a623 , #c47f17 ); }
. theme-swatch [ data-theme = "green" ] { background : linear-gradient ( 135 deg , #00c896 , #007a58 ); }
. theme-swatch [ data-theme = "red" ] { background : linear-gradient ( 135 deg , #ff4757 , #c0392b ); }
/* ── PIN BUTTON ── */
. pin-btn { background : none ; border : none ; cursor : pointer ; font-size : 14 px ; padding : 2 px 4 px ; flex-shrink : 0 ; line-height : 1 ; opacity : .2 ; transition : opacity .15 s ; }
. pin-btn : hover { opacity : .6 ; }
. pin-btn . pinned { opacity : 1 ; color : var ( -- accent ); }
/* ── INLINE COPY ── */
. copy-inline { background : none ; border : 1 px solid transparent ; cursor : pointer ; color : var ( -- muted ); font-size : 10 px ; font-family : var ( -- font - mono ); padding : 2 px 8 px ; border-radius : 5 px ; flex-shrink : 0 ; transition : all .15 s ; letter-spacing : .03 em ; }
. copy-inline : hover { border-color : var ( -- border ); color : var ( -- text ); }
/* ── INLINE QR THUMBNAIL (expanded panel) ── */
. qr-thumb-wrap { display : flex ; gap : 14 px ; align-items : flex-start ; margin-bottom : 16 px ; }
. qr-thumb-img { background : #fff ; border-radius : 8 px ; padding : 7 px ; flex-shrink : 0 ; box-shadow : 0 4 px 16 px rgba ( 0 , 0 , 0 , .35 ); }
. qr-thumb-img img { display : block ; width : 80 px ; height : 80 px ; }
. qr-thumb-btns { display : flex ; flex-direction : column ; gap : 6 px ; 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 }
2026-07-03 01:39:50 -05:00
${ l . has_password ? '<span title="Password protected" style="font-size:12px">🔒</span>' : '' }
2026-03-06 10:17:59 -06:00
<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>
2026-07-03 01:39:50 -05:00
<div><label class="field-label">REDIRECT TYPE</label>
<select class="field-input" id="edit-rt- ${ l . code } ">
<option value="302" ${ l . redirect_type == 301 ? '' : ' selected' } >302 — Temporary (recommended)</option>
<option value="301" ${ l . redirect_type == 301 ? ' selected' : '' } >301 — Permanent (browsers cache)</option>
</select>
</div>
<div><label class="field-label">PASSWORD ${ l . has_password ? '<span style="color:var(--accent)">● set</span>' : '' } </label>
<div style="display:flex;gap:6px">
<input class="field-input" type="password" id="edit-pw- ${ l . code } " autocomplete="new-password" style="flex:1"
placeholder=" ${ l . has_password ? 'Unchanged — type to replace' : 'None — type to protect' } ">
${ l . has_password ? `<button class="btn-sm danger" onclick="removeLinkPassword(' ${ l . code } ')" title="Remove password protection">✕</button>` : '' }
</div>
</div>
2026-03-06 10:17:59 -06:00
<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">
2026-07-03 01:39:50 -05:00
<label style="display:flex;align-items:center;gap:4px;font-size:10px;font-family:var(--font-mono);color:var(--muted);cursor:pointer;letter-spacing:.04em">
<input type="checkbox" id="bots- ${ l . code } " onchange="loadAnalytics(' ${ l . code } ', analyticsDaysByCode[' ${ l . code } ']||7)"> NO BOTS
</label>
2026-03-06 10:17:59 -06:00
<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 ;
2026-07-03 01:39:50 -05:00
const rt = parseInt ( document . getElementById ( `edit-rt- ${ code } ` ). value );
const pw = document . getElementById ( `edit-pw- ${ code } ` ). value ;
2026-03-06 10:17:59 -06:00
const tags = document . getElementById ( `edit-tags- ${ code } ` ). value . split ( ',' ). map ( t => t . trim ()). filter ( Boolean );
2026-07-03 01:39:50 -05:00
const body = { url , title , expires_at : expiry || null , tags , redirect_type : rt };
if ( pw ) body . password = pw ; // omit = leave password unchanged
const resp = await authFetch ( ` ${ BASE } /api/links/ ${ code } ` , { method : 'PATCH' , body : JSON . stringify ( body )});
2026-03-06 10:17:59 -06:00
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 ();
}
2026-07-03 01:39:50 -05:00
async function removeLinkPassword ( code ) {
if ( ! confirm ( `Remove password protection from / ${ code } ?` )) return ;
const resp = await authFetch ( ` ${ BASE } /api/links/ ${ code } ` , { method : 'PATCH' , body : JSON . stringify ({ password : '' })});
const data = await resp . json ();
if ( ! resp . ok ) { toast ( data . error || 'Failed' , 'error' ); return ; }
toast ( 'Password removed' );
const old = document . getElementById ( `item- ${ code } ` );
const fresh = buildLinkItem ( data );
fresh . classList . add ( 'expanded' );
old . parentNode . replaceChild ( fresh , old );
}
2026-03-06 10:17:59 -06:00
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 && currentUser . id ) ? ' <span style="color:var(--muted);font-size:10px">(you)</span>' : '' } </td>
<td style="padding:10px 12px"><span style="color: ${ u . is_admin ? 'var(--accent)' : 'var(--muted)' } ;font-size:11px"> ${ u . is_admin ? 'ADMIN' : 'USER' } </span></td>
<td style="padding:10px 12px"> ${ planCell } </td>
<td style="padding:10px 12px"> ${ orgCell } </td>
<td style="padding:10px 12px;color:var(--muted)"> ${ u . link_count } </td>
<td style="padding:10px 12px;color:var(--muted)"> ${ u . created_at . slice ( 0 , 10 ) } </td>
<td style="padding:10px 12px;text-align:right;display:flex;gap:6px;justify-content:flex-end">
<button class="btn-sm" onclick="promptChangePassword( ${ u . id } ,' ${ u . username } ')">Change PW</button>
${ u . id !== ( currentUser && currentUser . id ) ? `<button class="btn-sm danger" onclick="deleteUser( ${ u . id } ,' ${ u . username } ')">Delete</button>` : '' }
</td>
</tr>` ;
}). join ( '' )}
< /tbody>
< /table>`;
} catch ( e ) { container . innerHTML = '<div class="empty-state"><p>Failed to load users.</p></div>' ; }
}
function showCreateUser () {
// Populate org dropdown in create-user form
const sel = document . getElementById ( 'new-org-id' );
sel . innerHTML = `<option value="">— None —</option>` +
allOrgs . map ( o => `<option value=" ${ o . id } "> ${ o . name } ( ${ o . plan } )</option>` ). join ( '' );
document . getElementById ( 'create-user-panel' ). style . display = 'block' ;
document . getElementById ( 'new-username' ). focus ();
}
function hideCreateUser () {
document . getElementById ( 'create-user-panel' ). style . display = 'none' ;
document . getElementById ( 'new-username' ). value = '' ;
document . getElementById ( 'new-password' ). value = '' ;
document . getElementById ( 'new-is-admin' ). checked = false ;
document . getElementById ( 'new-plan' ). value = 'free' ;
document . getElementById ( 'new-org-id' ). value = '' ;
document . getElementById ( 'create-user-err' ). textContent = '' ;
}
async function createUser () {
const username = document . getElementById ( 'new-username' ). value . trim ();
const password = document . getElementById ( 'new-password' ). value ;
const is_admin = document . getElementById ( 'new-is-admin' ). checked ;
const plan = document . getElementById ( 'new-plan' ). value ;
const orgVal = document . getElementById ( 'new-org-id' ). value ;
const org_id = orgVal ? parseInt ( orgVal ) : null ;
const errEl = document . getElementById ( 'create-user-err' );
errEl . textContent = '' ;
if ( ! username || ! password ) { errEl . textContent = 'Username and password required' ; return ; }
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/users` , {
method : 'POST' , body : JSON . stringify ({ username , password , is_admin , plan })
});
const d = await resp . json ();
if ( ! resp . ok ) { errEl . textContent = d . error ; return ; }
// If org assigned, update the user's org
if ( org_id ) {
await authFetch ( ` ${ BASE } /api/admin/users/ ${ d . id } ` , {
method : 'PATCH' , body : JSON . stringify ({ org_id })
});
}
toast ( `User " ${ username } " created` );
hideCreateUser ();
loadUsers ();
} catch ( e ) { errEl . textContent = 'Network error' ; }
}
async function changePlan ( id , username , plan , selectEl ) {
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/users/ ${ id } ` , {
method : 'PATCH' , body : JSON . stringify ({ plan })
});
const d = await resp . json ();
if ( ! resp . ok ) { toast ( d . error || 'Failed to update plan' , 'error' ); selectEl . value = selectEl . dataset . prev || 'free' ; return ; }
toast ( `Plan for " ${ username } " set to ${ plan } ` );
} catch ( e ) { toast ( 'Network error' , 'error' ); }
}
async function changeUserOrg ( id , username , orgVal , selectEl ) {
const org_id = orgVal ? parseInt ( orgVal ) : null ;
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/users/ ${ id } ` , {
method : 'PATCH' , body : JSON . stringify ({ org_id })
});
const d = await resp . json ();
if ( ! resp . ok ) { toast ( d . error || 'Failed to update org' , 'error' ); return ; }
const orgName = org_id ? ( allOrgs . find ( o => o . id === org_id ) || {}). name || 'org' : 'no org' ;
toast ( `" ${ username } " assigned to ${ orgName } ` );
// Reload to update plan display (org may override plan)
loadUsers ();
} catch ( e ) { toast ( 'Network error' , 'error' ); }
}
async function deleteUser ( id , username ) {
if ( ! confirm ( `Delete user " ${ username } "? Their links will remain in the database.` )) return ;
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/users/ ${ id } ` , { method : 'DELETE' });
if ( ! resp . ok ) { const d = await resp . json (); toast ( d . error , 'error' ); return ; }
toast ( `User " ${ username } " deleted` );
loadUsers ();
} catch ( e ) { toast ( 'Network error' , 'error' ); }
}
async function promptChangePassword ( id , username ) {
const pw = prompt ( `New password for " ${ username } ":` );
if ( ! pw ) return ;
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/users/ ${ id } /password` , {
method : 'PATCH' , body : JSON . stringify ({ password : pw })
});
if ( ! resp . ok ) { const d = await resp . json (); toast ( d . error , 'error' ); return ; }
toast ( `Password updated for " ${ username } "` );
} catch ( e ) { toast ( 'Network error' , 'error' ); }
}
// ── Admin — Organization Management ───────────────
async function loadOrgs () {
const container = document . getElementById ( 'orgs-list' );
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/organizations` );
if ( ! resp . ok ) { container . innerHTML = '<div class="empty-state"><p>Access denied.</p></div>' ; return ; }
const { organizations } = await resp . json ();
allOrgs = organizations || [];
if ( ! allOrgs . length ) {
container . innerHTML = '<div class="empty-state"><p>No organizations yet. Create one to group users.</p></div>' ;
return ;
}
container . innerHTML = `
<table style="width:100%;border-collapse:collapse;font-size:13px;font-family:var(--font-mono)">
<thead><tr style="color:var(--muted);font-size:11px;letter-spacing:.06em;border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:8px 12px">NAME</th>
<th style="text-align:left;padding:8px 12px">PLAN</th>
<th style="text-align:left;padding:8px 12px">MEMBERS</th>
<th style="text-align:left;padding:8px 12px">CREATED</th>
<th style="text-align:right;padding:8px 12px">ACTIONS</th>
</tr></thead>
<tbody> ${ allOrgs . map ( o => `
<tr style="border-bottom:1px solid var(--border)">
<td style="padding:10px 12px"> ${ o . name } </td>
<td style="padding:10px 12px">
<select class="field-input" style="padding:3px 6px;font-size:11px;width:auto" onchange="changeOrgPlan( ${ o . id } ,' ${ o . name } ',this.value,this)">
${ [ 'free' , 'starter' , 'pro' , 'team' ]. map ( p => `<option value=" ${ p } " ${ o . plan === p ? ' selected' : '' } > ${ p . charAt ( 0 ). toUpperCase () + p . slice ( 1 ) } </option>` ). join ( '' ) }
</select>
</td>
<td style="padding:10px 12px;color:var(--muted)"> ${ o . member_count } </td>
<td style="padding:10px 12px;color:var(--muted)"> ${ o . created_at . slice ( 0 , 10 ) } </td>
<td style="padding:10px 12px;text-align:right">
<button class="btn-sm danger" onclick="deleteOrg( ${ o . id } ,' ${ o . name } ')">Delete</button>
</td>
</tr>` ). join ( '' ) }
</tbody>
</table>` ;
} catch ( e ) { container . innerHTML = '<div class="empty-state"><p>Failed to load organizations.</p></div>' ; }
}
function showCreateOrg () {
document . getElementById ( 'create-org-panel' ). style . display = 'block' ;
document . getElementById ( 'new-org-name' ). focus ();
}
function hideCreateOrg () {
document . getElementById ( 'create-org-panel' ). style . display = 'none' ;
document . getElementById ( 'new-org-name' ). value = '' ;
document . getElementById ( 'new-org-plan' ). value = 'team' ;
document . getElementById ( 'create-org-err' ). textContent = '' ;
}
async function createOrg () {
const name = document . getElementById ( 'new-org-name' ). value . trim ();
const plan = document . getElementById ( 'new-org-plan' ). value ;
const errEl = document . getElementById ( 'create-org-err' );
errEl . textContent = '' ;
if ( ! name ) { errEl . textContent = 'Organization name required' ; return ; }
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/organizations` , {
method : 'POST' , body : JSON . stringify ({ name , plan })
});
const d = await resp . json ();
if ( ! resp . ok ) { errEl . textContent = d . error ; return ; }
toast ( `Organization " ${ name } " created` );
hideCreateOrg ();
loadAdminPanel ();
} catch ( e ) { errEl . textContent = 'Network error' ; }
}
async function changeOrgPlan ( id , name , plan , selectEl ) {
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/organizations/ ${ id } ` , {
method : 'PATCH' , body : JSON . stringify ({ plan })
});
const d = await resp . json ();
if ( ! resp . ok ) { toast ( d . error || 'Failed to update plan' , 'error' ); return ; }
allOrgs = allOrgs . map ( o => o . id === id ? {... o , plan } : o );
toast ( `Plan for " ${ name } " set to ${ plan } ` );
// Reload users to reflect new org plan
loadUsers ();
} catch ( e ) { toast ( 'Network error' , 'error' ); }
}
async function deleteOrg ( id , name ) {
if ( ! confirm ( `Delete org " ${ name } "? Members will be unassigned but their accounts and data remain.` )) return ;
try {
const resp = await authFetch ( ` ${ BASE } /api/admin/organizations/ ${ id } ` , { method : 'DELETE' });
if ( ! resp . ok ) { const d = await resp . json (); toast ( d . error , 'error' ); return ; }
toast ( `Organization " ${ name } " deleted` );
loadAdminPanel ();
} catch ( e ) { toast ( 'Network error' , 'error' ); }
}
// ── Debug panel (activated by ?debug=1 in URL) ─────
if ( new URLSearchParams ( window . location . search ). get ( 'debug' ) === '1' ) {
document . getElementById ( 'debug-panel' ). style . display = 'block' ;
runDebug ();
}
async function runDebug () {
const panel = document . getElementById ( 'dbg-content' );
panel . innerHTML = [
`<b>window.location.origin:</b> ${ window . location . origin } ` ,
`<b>loggedIn:</b> ${ loggedIn } ` ,
]. join ( '<br>' );
try {
const r = await fetch ( ` ${ BASE } /api/auth/me` , { credentials : 'include' });
document . getElementById ( 'dbg-extra' ). textContent = `/api/auth/me → HTTP ${ r . status } ` ;
} catch ( e ) {
document . getElementById ( 'dbg-extra' ). textContent = 'Failed: ' + e ;
}
}
async function testStats () {
const extra = document . getElementById ( 'dbg-extra' );
try {
const r = await authFetch ( ` ${ BASE } /api/stats` );
const body = await r . text ();
extra . textContent = `/api/stats → HTTP ${ r . status } \n ${ body . slice ( 0 , 500 ) } ` ;
} catch ( e ) {
extra . textContent = 'Error: ' + e ;
}
}
</ script >
<!-- © 2025 QRknit. All rights reserved. Third-party notices: /notices -->
</ body >
</ html >