2026-03-14 20:34:37 +01:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-03-19 05:32:40 +01:00
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > OwnCord — Admin Panel</ title >
< style >
: root {
--bg-tertiary : #1e1f22 ; --bg-secondary : #2b2d31 ; --bg-primary : #313338 ;
--bg-input : #1e1f22 ; --bg-hover : #35373c ; --bg-active : #404249 ;
--bg-overlay : rgba ( 0 , 0 , 0 , .7 ); --bg-card : #2b2d31 ; --bg-table-hover : rgba ( 88 , 101 , 242 , .06 );
--accent : #5865f2 ; --accent-hover : #4752c4 ; --accent-active : #3c45a5 ; --accent-glow : rgba ( 88 , 101 , 242 , .25 );
--text-normal : #dbdee1 ; --text-muted : #949ba4 ; --text-faint : #80848e ; --text-micro : #6d6f78 ; --text-link : #00a8fc ;
--green : #23a55a ; --yellow : #f0b232 ; --red : #f23f43 ; --border : #3f4147 ; --border-strong : #4e5058 ;
--role-owner : #e74c3c ; --role-admin : #f39c12 ; --role-mod : #2ecc71 ; --role-member : #949ba4 ;
--font-body : "Segoe UI Variable Text" , "Segoe UI" , system-ui , sans-serif ;
--font-mono : "Cascadia Code" , "Consolas" , monospace ;
--radius-sm : 4 px ; --radius-md : 8 px ; --sidebar-w : 240 px ;
}
*,* :: before ,* :: after { box-sizing : border-box ; margin : 0 ; padding : 0 }
html , body { height : 100 % ; overflow : hidden }
body { font-family : var ( -- font - body ); font-size : 14 px ; color : var ( -- text - normal ); background : var ( -- bg - tertiary ); -webkit- font-smoothing : antialiased }
button { font-family : inherit ; border : none ; cursor : pointer ; outline : none }
input , select , textarea { font-family : inherit ; border : none ; outline : none }
:: -webkit-scrollbar { width : 6 px }:: -webkit-scrollbar-track { background : transparent }
:: -webkit-scrollbar-thumb { background : var ( -- bg - tertiary ); border-radius : 3 px }
:: -webkit-scrollbar-thumb : hover { background : var ( -- bg - hover )}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. admin { display : flex ; height : 100 vh }
. sidebar { width : var ( -- sidebar - w ); background : var ( -- bg - secondary ); display : flex ; flex-direction : column ; flex-shrink : 0 ; overflow-y : auto }
. sidebar-header { padding : 20 px 16 px 16 px ; border-bottom : 1 px solid var ( -- border ); flex-shrink : 0 }
. sidebar-brand { display : flex ; align-items : center ; gap : 10 px }
. sidebar-logo { width : 36 px ; height : 36 px ; border-radius : 10 px ; background : var ( -- accent ); display : flex ; align-items : center ; justify-content : center ; flex-shrink : 0 }
. sidebar-logo svg { width : 22 px ; height : 22 px }
. sidebar-title { font-size : 15 px ; font-weight : 700 ; color : white }
. sidebar-subtitle { font-size : 11 px ; color : var ( -- text - faint ); letter-spacing : .03 em }
. sidebar-nav { flex : 1 ; padding : 8 px }
. sidebar-label { font-size : 11 px ; font-weight : 700 ; color : var ( -- text - faint ); letter-spacing : .05 em ; text-transform : uppercase ; padding : 12 px 12 px 4 px }
. sidebar-sep { height : 1 px ; background : var ( -- border ); margin : 4 px 12 px }
. nav-item { display : flex ; align-items : center ; gap : 10 px ; padding : 8 px 12 px ; border-radius : var ( -- radius - sm ); font-size : 14 px ; color : var ( -- text - muted ); background : transparent ; width : 100 % ; text-align : left ; transition : all .15 s ; position : relative }
. nav-item : hover { background : var ( -- bg - hover ); color : var ( -- text - normal )}
. nav-item . active { background : var ( -- bg - active ); color : white }
. nav-item svg { width : 18 px ; height : 18 px ; flex-shrink : 0 ; opacity : .7 }
. nav-item . active svg { opacity : 1 }
. nav-item . danger { color : var ( -- red )}. nav-item . danger : hover { background : rgba ( 242 , 63 , 67 , .1 )}
. nav-item . unsaved-dot { position : absolute ; left : 6 px ; top : 50 % ; transform : translateY ( -50 % ); width : 6 px ; height : 6 px ; border-radius : 50 % ; background : var ( -- yellow )}
. sidebar-footer { padding : 12 px 16 px ; border-top : 1 px solid var ( -- border ); flex-shrink : 0 ; font-size : 11 px ; color : var ( -- text - micro ); text-align : center }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. content { flex : 1 ; overflow-y : auto ; padding : 32 px 40 px ; background : var ( -- bg - primary )}
. page-title { font-size : 22 px ; font-weight : 700 ; color : white ; margin-bottom : 4 px }
. page-desc { font-size : 13 px ; color : var ( -- text - faint ); margin-bottom : 24 px }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. stat-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 200 px , 1 fr )); gap : 14 px ; margin-bottom : 24 px }
. stat-card { background : var ( -- bg - card ); border-radius : var ( -- radius - md ); padding : 18 px 20 px ; border : 1 px solid var ( -- border ); transition : border-color .2 s }
. stat-card : hover { border-color : var ( -- border - strong )}
. stat-card-header { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 10 px }
. stat-card-label { font-size : 11 px ; font-weight : 700 ; color : var ( -- text - faint ); letter-spacing : .04 em ; text-transform : uppercase }
. stat-card-icon { width : 32 px ; height : 32 px ; border-radius : var ( -- radius - md ); display : flex ; align-items : center ; justify-content : center ; flex-shrink : 0 }
. stat-card-icon svg { width : 16 px ; height : 16 px }
. stat-card-value { font-size : 28 px ; font-weight : 700 ; color : white ; font-family : var ( -- font - mono ); line-height : 1.1 }
. stat-card-sub { font-size : 12 px ; color : var ( -- text - faint ); margin-top : 4 px ; font-family : var ( -- font - mono )}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. section-card { background : var ( -- bg - card ); border-radius : var ( -- radius - md ); border : 1 px solid var ( -- border ); margin-bottom : 20 px ; overflow : hidden }
. section-card-header { padding : 16 px 20 px ; border-bottom : 1 px solid var ( -- border ); display : flex ; align-items : center ; justify-content : space-between }
. section-card-header h3 { font-size : 14 px ; font-weight : 700 ; color : white }
. section-card-body { padding : 16 px 20 px }
. section-card-body . no-pad { padding : 0 }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. tbl { width : 100 % ; border-collapse : collapse }
. tbl th { font-size : 11 px ; font-weight : 700 ; color : var ( -- text - faint ); letter-spacing : .04 em ; text-transform : uppercase ; text-align : left ; padding : 10 px 16 px ; border-bottom : 2 px solid var ( -- border )}
. tbl td { padding : 10 px 16 px ; font-size : 13 px ; border-bottom : 1 px solid var ( -- border ); vertical-align : middle }
. tbl tr : hover td { background : var ( -- bg - table - hover )}
. tbl tr : last-child td { border-bottom : none }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. avatar { width : 28 px ; height : 28 px ; border-radius : 50 % ; display : inline-flex ; align-items : center ; justify-content : center ; font-weight : 700 ; font-size : 12 px ; color : white ; flex-shrink : 0 }
. dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; display : inline-block ; margin-right : 6 px ; flex-shrink : 0 }
. dot . online { background : var ( -- green )}. dot . offline { background : var ( -- text - micro )}. dot . banned { background : var ( -- red )}
. role-badge { display : inline-flex ; align-items : center ; gap : 4 px ; font-size : 12 px ; font-weight : 600 ; padding : 2 px 8 px ; border-radius : 10 px ; background : rgba ( 255 , 255 , 255 , .06 )}
. role-dot { width : 8 px ; height : 8 px ; border-radius : 50 % ; flex-shrink : 0 }
. act-btn { width : 28 px ; height : 28 px ; border-radius : var ( -- radius - sm ); background : transparent ; color : var ( -- text - faint ); display : inline-flex ; align-items : center ; justify-content : center ; transition : all .15 s }
. act-btn : hover { background : var ( -- bg - active ); color : var ( -- text - normal )}
. act-btn . danger : hover { background : var ( -- red ); color : white }
. act-btn svg { width : 14 px ; height : 14 px }
. act-group { display : flex ; gap : 2 px }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. badge { display : inline-block ; font-size : 11 px ; font-weight : 600 ; padding : 2 px 8 px ; border-radius : 10 px }
. badge-green { background : rgba ( 35 , 165 , 90 , .15 ); color : var ( -- green )}
. badge-red { background : rgba ( 242 , 63 , 67 , .15 ); color : var ( -- red )}
. badge-yellow { background : rgba ( 240 , 178 , 50 , .15 ); color : var ( -- yellow )}
. badge-muted { background : rgba ( 128 , 132 , 142 , .15 ); color : var ( -- text - faint )}
. badge-accent { background : var ( -- accent - glow ); color : var ( -- accent )}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. filter-bar { display : flex ; gap : 10 px ; margin-bottom : 16 px ; align-items : center ; flex-wrap : wrap }
. filter-search { flex : 1 ; min-width : 200 px ; padding : 8 px 12 px ; background : var ( -- bg - input ); color : var ( -- text - normal ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius - sm ); font-size : 13 px ; transition : border-color .2 s }
. filter-search :: placeholder { color : var ( -- text - micro )}. filter-search : focus { border-color : var ( -- accent )}
. filter-select { padding : 8 px 28 px 8 px 10 px ; background : var ( -- bg - input ); color : var ( -- text - normal ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius - sm ); font-size : 13 px ; appearance : none ; cursor : pointer }
. filter-select : focus { border-color : var ( -- accent )}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. btn { padding : 8 px 16 px ; border-radius : var ( -- radius - sm ); font-size : 13 px ; font-weight : 600 ; transition : all .15 s ; display : inline-flex ; align-items : center ; gap : 6 px }
. btn-accent { background : var ( -- accent ); color : white }. btn-accent : hover { background : var ( -- accent - hover )}
. btn-danger { background : var ( -- red ); color : white }. btn-danger : hover { background : #d83135 }
. btn-ghost { background : var ( -- bg - hover ); color : var ( -- text - muted )}. btn-ghost : hover { background : var ( -- bg - active ); color : var ( -- text - normal )}
. btn-outline { background : transparent ; color : var ( -- text - muted ); border : 1 px solid var ( -- border )}. btn-outline : hover { border-color : var ( -- border - strong ); color : var ( -- text - normal )}
. btn : disabled { opacity : .5 ; cursor : not-allowed }
. btn . spinner { width : 14 px ; height : 14 px ; border : 2 px solid rgba ( 255 , 255 , 255 , .3 ); border-top-color : white ; border-radius : 50 % ; animation : spin .6 s linear infinite }
@ keyframes spin { to { transform : rotate ( 360 deg )}}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. form-group { margin-bottom : 16 px }
. form-label { display : block ; font-size : 11 px ; font-weight : 700 ; color : var ( -- text - muted ); letter-spacing : .02 em ; text-transform : uppercase ; margin-bottom : 6 px }
. form-label . req { color : var ( -- red ); margin-left : 2 px }
. form-input { width : 100 % ; padding : 10 px 12 px ; background : var ( -- bg - input ); color : var ( -- text - normal ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius - sm ); font-size : 14 px ; transition : border-color .2 s }
. form-input :: placeholder { color : var ( -- text - micro )}. form-input : focus { border-color : var ( -- accent )}
. form-textarea { resize : vertical ; min-height : 80 px }
. toggle { width : 40 px ; height : 22 px ; border-radius : 11 px ; background : var ( -- border - strong ); cursor : pointer ; position : relative ; transition : background .2 s ; flex-shrink : 0 ; padding : 0 ; appearance : none ; -webkit- appearance : none ; border : none }
. toggle . on { background : var ( -- green )}
. toggle :: after { content : '' ; position : absolute ; width : 16 px ; height : 16 px ; border-radius : 50 % ; background : white ; top : 3 px ; left : 3 px ; transition : transform .2 s }
. toggle . on :: after { transform : translateX ( 18 px )}
. setting-row { display : flex ; align-items : center ; justify-content : space-between ; padding : 12 px 0 ; border-bottom : 1 px solid var ( -- border )}
. setting-row : last-child { border-bottom : none }
. setting-info { flex : 1 ; min-width : 0 }. setting-name { font-size : 14 px ; color : var ( -- text - normal ); margin-bottom : 2 px }
. setting-desc { font-size : 12 px ; color : var ( -- text - faint )}. setting-ctrl { flex-shrink : 0 ; margin-left : 16 px }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. modal-overlay { position : fixed ; inset : 0 ; background : var ( -- bg - overlay ); display : none ; align-items : center ; justify-content : center ; z-index : 100 }
. modal-overlay . visible { display : flex }
. modal { background : var ( -- bg - primary ); border-radius : var ( -- radius - md ); width : 480 px ; max-height : 80 vh ; overflow-y : auto ; box-shadow : 0 8 px 48 px rgba ( 0 , 0 , 0 , .5 ); animation : modalIn .25 s cubic-bezier ( .16 , 1 , .3 , 1 )}
@ keyframes modalIn { from { opacity : 0 ; transform : translateY ( 20 px ) scale ( .96 )} to { opacity : 1 ; transform : translateY ( 0 ) scale ( 1 )}}
. modal-header { padding : 20 px 24 px 0 ; display : flex ; align-items : center ; justify-content : space-between }
. modal-header h3 { font-size : 18 px ; font-weight : 700 ; color : white }
. modal-close { background : transparent ; color : var ( -- text - faint ); font-size : 20 px ; padding : 4 px ; border-radius : var ( -- radius - sm ); transition : color .15 s }
. modal-close : hover { color : var ( -- text - normal )}
. modal-body { padding : 20 px 24 px }
. modal-footer { padding : 16 px 24 px ; background : var ( -- bg - secondary ); border-radius : 0 0 var ( -- radius - md ) var ( -- radius - md ); display : flex ; justify-content : flex-end ; gap : 8 px }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. pagination { display : flex ; align-items : center ; justify-content : space-between ; padding : 12 px 0 ; margin-top : 4 px }
. pagination-info { font-size : 12 px ; color : var ( -- text - faint )}
. pagination-btns { display : flex ; gap : 4 px }
. page-btn { width : 32 px ; height : 32 px ; border-radius : var ( -- radius - sm ); background : transparent ; color : var ( -- text - muted ); font-size : 13 px ; display : flex ; align-items : center ; justify-content : center ; transition : all .15 s }
. page-btn : hover { background : var ( -- bg - hover ); color : var ( -- text - normal )}
. page-btn . active { background : var ( -- accent ); color : white }
. page-btn : disabled { opacity : .3 ; cursor : not-allowed }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. toast { position : fixed ; bottom : 24 px ; right : 24 px ; padding : 12 px 20 px ; border-radius : var ( -- radius - md ); font-size : 13 px ; font-weight : 600 ; display : none ; align-items : center ; gap : 8 px ; z-index : 200 ; box-shadow : 0 4 px 24 px rgba ( 0 , 0 , 0 , .4 ); animation : toastIn .3 s cubic-bezier ( .16 , 1 , .3 , 1 )}
. toast . visible { display : flex }. toast . success { background : var ( -- green ); color : white }. toast . error { background : var ( -- red ); color : white }. toast . info { background : var ( -- accent ); color : white }
@ keyframes toastIn { from { opacity : 0 ; transform : translateY ( 12 px )} to { opacity : 1 ; transform : translateY ( 0 )}}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. activity-item { display : flex ; gap : 10 px ; padding : 10 px 0 ; border-bottom : 1 px solid var ( -- border )}
. activity-item : last-child { border-bottom : none }
. activity-icon { width : 28 px ; height : 28 px ; border-radius : 50 % ; display : flex ; align-items : center ; justify-content : center ; flex-shrink : 0 }
. activity-icon svg { width : 14 px ; height : 14 px }
. activity-text { font-size : 13 px ; color : var ( -- text - muted ); line-height : 1.4 }
. activity-text strong { color : var ( -- text - normal )}
. activity-time { font-size : 11 px ; color : var ( -- text - micro ); margin-top : 2 px }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. update-card { display : flex ; align-items : flex-start ; gap : 16 px ; padding : 20 px ; background : var ( -- bg - card ); border-radius : var ( -- radius - md ); border : 1 px solid var ( -- border )}
. update-icon { width : 48 px ; height : 48 px ; border-radius : var ( -- radius - md ); display : flex ; align-items : center ; justify-content : center ; flex-shrink : 0 }
. update-icon svg { width : 24 px ; height : 24 px }
. update-info { flex : 1 }. update-ver { font-size : 18 px ; font-weight : 700 ; color : white }
. update-notes { font-size : 13 px ; color : var ( -- text - faint ); margin-top : 6 px ; line-height : 1.5 }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. code-copy { display : flex ; align-items : center ; gap : 8 px ; background : var ( -- bg - tertiary ); padding : 8 px 12 px ; border-radius : var ( -- radius - sm ); margin-top : 8 px }
. code-copy code { flex : 1 ; font-family : var ( -- font - mono ); font-size : 13 px ; color : var ( -- text - link ); word-break : break-all }
. code-copy . btn { padding : 4 px 10 px ; font-size : 11 px }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
/* Auth overlays */
. auth-overlay { position : fixed ; inset : 0 ; background : var ( -- bg - tertiary ); display : none ; align-items : center ; justify-content : center ; z-index : 50 }
. auth-overlay . visible { display : flex }
. auth-box { background : var ( -- bg - primary ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius - md ); padding : 32 px ; width : 380 px }
. auth-box h2 { font-size : 20 px ; font-weight : 700 ; color : white ; margin-bottom : 20 px }
. auth-error { color : var ( -- red ); font-size : 13 px ; margin-top : 10 px ; min-height : 20 px }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
/* Log viewer */
. log-toolbar { display : flex ; gap : 8 px ; margin-bottom : 12 px ; align-items : center ; flex-wrap : wrap }
. level-toggle { padding : 4 px 10 px ; border-radius : 10 px ; font-size : 11 px ; font-weight : 700 ; background : var ( -- bg - hover ); color : var ( -- text - micro ); transition : all .15 s ; text-transform : uppercase ; letter-spacing : .03 em }
. level-toggle : hover { color : var ( -- text - muted )}
. level-toggle . active-debug { background : rgba ( 128 , 132 , 142 , .2 ); color : var ( -- text - muted )}
. level-toggle . active-info { background : rgba ( 35 , 165 , 90 , .2 ); color : var ( -- green )}
. level-toggle . active-warn { background : rgba ( 240 , 178 , 50 , .2 ); color : var ( -- yellow )}
. level-toggle . active-error { background : rgba ( 242 , 63 , 67 , .2 ); color : var ( -- red )}
. log-output { background : var ( -- bg - tertiary ); border : 1 px solid var ( -- border ); border-radius : var ( -- radius - sm ); font-family : var ( -- font - mono ); font-size : 12 px ; line-height : 1.6 ; padding : 8 px 12 px ; overflow-y : auto ; height : calc ( 100 vh - 240 px ); white-space : pre-wrap ; word-break : break-all }
. log-line { padding : 1 px 0 }
. log-line . log-ts { color : var ( -- text - micro ); margin-right : 8 px }
. log-line . log-lvl { display : inline-block ; width : 44 px ; font-weight : 700 ; margin-right : 6 px }
. log-line . log-src { color : var ( -- text - faint ); margin-right : 8 px ; font-size : 11 px }
. log-line . l-debug . log-lvl { color : var ( -- text - micro )}
. log-line . l-info . log-lvl { color : var ( -- green )}
. log-line . l-warn . log-lvl { color : var ( -- yellow )}
. log-line . l-error . log-lvl { color : var ( -- red )}
. log-line . l-error { color : #f5a0a2 }
. log-status { display : flex ; align-items : center ; gap : 8 px ; margin-top : 8 px ; font-size : 11 px ; color : var ( -- text - micro )}
. log-status . dot-live { width : 6 px ; height : 6 px ; border-radius : 50 % ; background : var ( -- green ); animation : pulse 2 s infinite }
. log-status . dot-off { width : 6 px ; height : 6 px ; border-radius : 50 % ; background : var ( -- red )}
@ keyframes pulse { 0 %, 100 % { opacity : 1 } 50 % { opacity : .4 }}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
. hidden { display : none !important }
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
@ media ( max-width : 900px ) {. sidebar { width : 200 px }. content { padding : 24 px 20 px }. stat-grid { grid-template-columns : repeat ( auto - fill , minmax ( 160 px , 1 fr ))}}
@ media ( max-width : 600px ) {. admin { flex-direction : column }. sidebar { width : 100 % ; height : auto ; max-height : 56 px ; overflow : hidden }. content { padding : 16 px 12 px }. stat-grid { grid-template-columns : 1 fr 1 fr }. filter-bar { flex-direction : column }. modal { width : 95 vw ; max-height : 90 vh }}
</ style >
2026-03-14 20:34:37 +01:00
</ head >
< body >
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
<!-- Auth: Setup -->
< div class = "auth-overlay" id = "setupOverlay" >
< div class = "auth-box" >
2026-03-14 22:36:35 +01:00
< h2 > Welcome to OwnCord</ h2 >
2026-03-19 05:32:40 +01:00
< p style = "color:var(--text-muted);font-size:14px;margin-bottom:20px" > No accounts exist yet. Create the owner account to get started.</ p >
< div class = "form-group" >< label class = "form-label" > Username</ label >< input class = "form-input" id = "setupUser" autocomplete = "username" placeholder = "Choose a username" ></ div >
< div class = "form-group" >< label class = "form-label" > Password</ label >< input class = "form-input" id = "setupPass" type = "password" autocomplete = "new-password" placeholder = "Min 8 characters" ></ div >
< div class = "form-group" >< label class = "form-label" > Confirm Password</ label >< input class = "form-input" id = "setupConfirm" type = "password" autocomplete = "new-password" placeholder = "Re-enter password" ></ div >
< button class = "btn btn-accent" id = "setupBtn" style = "width:100%" > Create Owner Account</ button >
< div class = "auth-error" id = "setupErr" ></ div >
2026-03-14 22:36:35 +01:00
</ div >
</ div >
2026-03-19 05:32:40 +01:00
<!-- Auth: Setup Success -->
< div class = "auth-overlay" id = "setupSuccessOverlay" >
< div class = "auth-box" >
2026-03-14 22:36:35 +01:00
< h2 > Setup Complete!</ h2 >
2026-03-19 05:32:40 +01:00
< p style = "color:var(--text-muted);font-size:14px;margin-bottom:16px" > Your owner account has been created. Here's your invite code:</ p >
< div class = "code-copy" >< code id = "inviteCode" ></ code >< button class = "btn btn-ghost" onclick = "copyInvite()" > Copy</ button ></ div >
< p style = "color:var(--yellow);font-size:13px;margin:16px 0" > Save this code! Share it with people you want to invite.</ p >
< button class = "btn btn-accent" id = "setupContinueBtn" style = "width:100%" > Continue to Admin Panel</ button >
2026-03-14 22:36:35 +01:00
</ div >
</ div >
2026-03-19 05:32:40 +01:00
<!-- Auth: Login -->
< div class = "auth-overlay" id = "loginOverlay" >
< div class = "auth-box" >
2026-03-14 21:31:03 +01:00
< h2 > OwnCord Admin</ h2 >
2026-03-19 05:32:40 +01:00
< div class = "form-group" >< label class = "form-label" > Username</ label >< input class = "form-input" id = "loginUser" autocomplete = "username" placeholder = "admin" ></ div >
< div class = "form-group" >< label class = "form-label" > Password</ label >< input class = "form-input" id = "loginPass" type = "password" autocomplete = "current-password" placeholder = "••••••••" ></ div >
< button class = "btn btn-accent" id = "loginBtn" style = "width:100%" > Sign In</ button >
< div class = "auth-error" id = "loginErr" ></ div >
2026-03-14 21:31:03 +01:00
</ div >
</ div >
2026-03-19 05:32:40 +01:00
<!-- Admin Shell -->
< div class = "admin hidden" id = "adminShell" >
< div class = "sidebar" >
< div class = "sidebar-header" >< div class = "sidebar-brand" >
< div class = "sidebar-logo" >< svg viewBox = "0 0 24 24" fill = "white" >< path d = "M12 2L4 5.5v5c0 5.25 3.4 10.15 8 11.5 4.6-1.35 8-6.25 8-11.5v-5L12 2zm0 3l1.5 3h3.2l-2.6 1.9 1 3.1L12 11.1 8.9 13l1-3.1L7.3 8h3.2L12 5z" /></ svg ></ div >
< div >< div class = "sidebar-title" > OwnCord</ div >< div class = "sidebar-subtitle" > Admin Panel</ div ></ div >
</ div ></ div >
< nav class = "sidebar-nav" id = "sidebarNav" role = "navigation" aria-label = "Admin sections" ></ nav >
< div class = "sidebar-footer" id = "sidebarFooter" > OwnCord</ div >
2026-03-14 21:31:03 +01:00
</ div >
2026-03-19 05:32:40 +01:00
< div class = "content" id = "content" ></ div >
2026-03-14 21:31:03 +01:00
</ div >
2026-03-19 05:32:40 +01:00
<!-- Modal -->
< div class = "modal-overlay" id = "modal" role = "dialog" aria-modal = "true" aria-hidden = "true" >
< div class = "modal" id = "modalInner" ></ div >
</ div >
<!-- Toast -->
< div class = "toast" id = "toast" role = "status" aria-live = "polite" ></ div >
2026-03-14 21:31:03 +01:00
< script >
2026-03-19 05:32:40 +01:00
/* ═══ Icons ═══ */
const I = {
dashboard : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>' ,
users : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>' ,
channels : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>' ,
settings : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></svg>' ,
backup : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="12" x2="2" y2="12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>' ,
updates : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>' ,
audit : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>' ,
logout : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>' ,
edit : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>' ,
trash : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>' ,
ban : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>' ,
disconnect : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' ,
check : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' ,
plus : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' ,
refresh : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>' ,
download : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' ,
voice : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14"/></svg>' ,
megaphone : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' ,
logs : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>' ,
};
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
/* ═══ State ═══ */
const PAGE_SIZE = 50 ;
const state = { section : 'dashboard' , token : localStorage . getItem ( 'admin_token' ) || '' ,
usersPage : 1 , auditPage : 1 , auditSearch : '' , auditActionFilter : 'all' , auditCache : [], settingsChanged : false , backupRunning : false , updateApplying : false ,
cachedStats : null , cachedUpdate : null ,
logEntries : [], logLevels : { DEBUG : true , INFO : true , WARN : true , ERROR : true },
2026-03-29 19:39:46 +02:00
logSearch : '' , logAutoScroll : true , logPaused : false , logEventSource : null , logReconnectTimer : null , logConnectSeq : 0 , logMaxLines : 2000 };
2026-03-19 05:32:40 +01:00
/* ═══ API ═══ */
async function api ( method , path , body ){
const opts = { method , headers : { 'Authorization' : 'Bearer ' + state . token , 'Content-Type' : 'application/json' }};
if ( body !== undefined ) opts . body = JSON . stringify ( body );
const res = await fetch ( '/admin/api' + path , opts );
if ( res . status === 204 ) return null ;
const data = await res . json ();
if ( ! res . ok ) throw new Error ( data . message || res . statusText );
2026-03-14 21:31:03 +01:00
return data ;
}
2026-03-19 05:32:40 +01:00
/* ═══ Utilities ═══ */
function esc ( s ){ if ( s === null || s === undefined ) return '' ; return String ( s ). replace ( /&/g , '&' ). replace ( /</g , '<' ). replace ( />/g , '>' ). replace ( /"/g , '"' )}
function fmtBytes ( b ){ if ( b < 1024 ) return b + ' B' ; if ( b < 1048576 ) return ( b / 1024 ). toFixed ( 1 ) + ' KB' ; if ( b < 1073741824 ) return ( b / 1048576 ). toFixed ( 1 ) + ' MB' ; return ( b / 1073741824 ). toFixed ( 2 ) + ' GB' }
function actionBadge ( a ){ if ( ! a ) return 'badge-muted' ; if ( a . includes ( 'ban' ) || a . includes ( 'kick' ) || a . includes ( 'delete' )) return 'badge-red' ; if ( a . includes ( 'create' )) return 'badge-green' ; if ( a . includes ( 'update' )) return 'badge-yellow' ; return 'badge-accent' }
function actionColor ( a ){ if ( ! a ) return 'var(--accent)' ; if ( a . includes ( 'ban' ) || a . includes ( 'kick' ) || a . includes ( 'delete' )) return 'var(--red)' ; if ( a . includes ( 'create' )) return 'var(--green)' ; if ( a . includes ( 'update' )) return 'var(--yellow)' ; return 'var(--accent)' }
function roleColor ( rid ){ return { 1 : 'var(--role-owner)' , 2 : 'var(--role-admin)' , 3 : 'var(--role-mod)' }[ rid ] || 'var(--role-member)' }
function roleName ( rid ){ return { 1 : 'Owner' , 2 : 'Admin' , 3 : 'Moderator' , 4 : 'Member' }[ rid ] || 'Member' }
function showToast ( msg , type = 'success' ){
const t = document . getElementById ( 'toast' );
t . className = 'toast visible ' + type ;
t . innerHTML = ( type === 'success' ? I . check : type === 'error' ? I . ban : I . check ) + '<span>' + esc ( msg ) + '</span>' ;
clearTimeout ( window . _tt ); window . _tt = setTimeout (()=> t . classList . remove ( 'visible' ), 3000 );
}
function openModal ( html ){
const o = document . getElementById ( 'modal' );
document . getElementById ( 'modalInner' ). innerHTML = html ;
o . classList . add ( 'visible' ); o . setAttribute ( 'aria-hidden' , 'false' );
}
function closeModal (){
const o = document . getElementById ( 'modal' );
o . classList . remove ( 'visible' ); o . setAttribute ( 'aria-hidden' , 'true' );
}
/* ═══ Auth ═══ */
function hideAll (){[ 'setupOverlay' , 'setupSuccessOverlay' , 'loginOverlay' ]. forEach ( id => document . getElementById ( id ). classList . remove ( 'visible' )); document . getElementById ( 'adminShell' ). classList . add ( 'hidden' )}
function showOverlay ( id ){ hideAll (); document . getElementById ( id ). classList . add ( 'visible' )}
function showApp (){ hideAll (); document . getElementById ( 'adminShell' ). classList . remove ( 'hidden' )}
async function checkAuth (){
try { const r = await fetch ( '/admin/api/setup/status' ); const d = await r . json (); if ( d . needs_setup ){ showOverlay ( 'setupOverlay' ); return }} catch ( e ){ console . error ( 'setup check:' , e )}
if ( ! state . token ){ showOverlay ( 'loginOverlay' ); return }
try { await api ( 'GET' , '/stats' ); showApp (); renderNav (); renderContent ()} catch ( e ){ showOverlay ( 'loginOverlay' )}
}
document . getElementById ( 'setupBtn' ). onclick = async ()=>{
const u = document . getElementById ( 'setupUser' ). value . trim (), p = document . getElementById ( 'setupPass' ). value , c = document . getElementById ( 'setupConfirm' ). value , err = document . getElementById ( 'setupErr' );
err . textContent = '' ;
if ( ! u ||! p ){ err . textContent = 'Username and password are required.' ; return }
if ( p !== c ){ err . textContent = 'Passwords do not match.' ; return }
try { const r = await fetch ( '/admin/api/setup' ,{ method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ username : u , password : p })}); const d = await r . json (); if ( ! r . ok ) throw new Error ( d . message || 'Setup failed' );
state . token = d . token ; localStorage . setItem ( 'admin_token' , state . token );
document . getElementById ( 'inviteCode' ). textContent = d . invite_code ; showOverlay ( 'setupSuccessOverlay' );
} catch ( e ){ err . textContent = e . message }
};
document . getElementById ( 'setupContinueBtn' ). onclick = ()=>{ showApp (); renderNav (); renderContent ()};
function copyInvite (){ navigator . clipboard . writeText ( document . getElementById ( 'inviteCode' ). textContent ). then (()=> showToast ( 'Copied!' , 'info' ))}
document . getElementById ( 'loginBtn' ). onclick = async ()=>{
const u = document . getElementById ( 'loginUser' ). value . trim (), p = document . getElementById ( 'loginPass' ). value , err = document . getElementById ( 'loginErr' );
err . textContent = '' ;
try { const r = await fetch ( '/api/v1/auth/login' ,{ method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ username : u , password : p })}); const d = await r . json (); if ( ! r . ok ) throw new Error ( d . message || 'Login failed' );
state . token = d . token ; localStorage . setItem ( 'admin_token' , state . token ); showApp (); renderNav (); renderContent ();
} catch ( e ){ err . textContent = e . message }
};
[ 'setupUser' , 'setupPass' , 'setupConfirm' ]. forEach ( id => document . getElementById ( id ). addEventListener ( 'keydown' , e =>{ if ( e . key === 'Enter' ) document . getElementById ( 'setupBtn' ). click ()}));
[ 'loginUser' , 'loginPass' ]. forEach ( id => document . getElementById ( id ). addEventListener ( 'keydown' , e =>{ if ( e . key === 'Enter' ) document . getElementById ( 'loginBtn' ). click ()}));
/* ═══ Nav ═══ */
const NAV = [
{ section : 'Management' },
{ id : 'dashboard' , label : 'Dashboard' , icon : I . dashboard },
{ id : 'users' , label : 'Users' , icon : I . users },
{ id : 'channels' , label : 'Channels' , icon : I . channels },
{ sep : true },
{ section : 'Configuration' },
{ id : 'audit' , label : 'Audit Log' , icon : I . audit },
{ id : 'logs' , label : 'Server Logs' , icon : I . logs },
{ id : 'settings' , label : 'Settings' , icon : I . settings , unsaved : ()=> state . settingsChanged },
{ id : 'backups' , label : 'Backups' , icon : I . backup },
{ id : 'updates' , label : 'Updates' , icon : I . updates },
{ sep : true },
{ id : 'logout' , label : 'Sign Out' , icon : I . logout , danger : true },
];
function renderNav (){
document . getElementById ( 'sidebarNav' ). innerHTML = NAV . map ( n =>{
if ( n . section ) return '<div class="sidebar-label">' + n . section + '</div>' ;
if ( n . sep ) return '<div class="sidebar-sep"></div>' ;
const active = state . section === n . id ? 'active' : '' ;
const cls = n . danger ? 'danger' : '' ;
const unsaved = n . unsaved && n . unsaved () ? '<span class="unsaved-dot"></span>' : '' ;
if ( n . id === 'logout' ) return '<button class="nav-item ' + cls + '" onclick="doLogout()">' + n . icon + '<span>' + n . label + '</span></button>' ;
return '<button class="nav-item ' + active + ' ' + cls + '" role="tab" onclick="navigateTo(\'' + n . id + '\')">' + unsaved + n . icon + '<span>' + n . label + '</span></button>' ;
}). join ( '' );
}
function navigateTo ( id ){
2026-03-29 19:39:46 +02:00
if ( state . section === 'logs' && id !== 'logs' ){ state . logConnectSeq ++ ; if ( state . logEventSource ){ state . logEventSource . close (); state . logEventSource = null } if ( state . logReconnectTimer ){ clearTimeout ( state . logReconnectTimer ); state . logReconnectTimer = null }}
2026-03-19 05:32:40 +01:00
state . section = id ; renderNav (); renderContent ();
}
2026-03-29 19:39:46 +02:00
function doLogout (){ state . logConnectSeq ++ ; if ( state . logEventSource ){ state . logEventSource . close (); state . logEventSource = null } if ( state . logReconnectTimer ){ clearTimeout ( state . logReconnectTimer ); state . logReconnectTimer = null } state . token = '' ; localStorage . removeItem ( 'admin_token' ); showOverlay ( 'loginOverlay' )}
2026-03-19 05:32:40 +01:00
/* ═══ Content Router ═══ */
function renderContent (){
const c = document . getElementById ( 'content' ); c . scrollTop = 0 ;
const r = { dashboard : renderDashboard , users : renderUsers , channels : renderChannels , audit : renderAudit , logs : renderLogs , settings : renderSettings , backups : renderBackups , updates : renderUpdates };
c . innerHTML = '<div class="page-title">Loading...</div>' ;
const fn = r [ state . section ] || renderDashboard ;
const result = fn ();
if ( result instanceof Promise ) result . then ( html =>{ c . innerHTML = html }). catch ( e =>{ c . innerHTML = '<div class="page-title">Error</div><p style="color:var(--red)">' + esc ( e . message ) + '</p>' });
else c . innerHTML = result ;
}
/* ═══ Dashboard ═══ */
async function renderDashboard (){
try { state . cachedStats = await api ( 'GET' , '/stats' )} catch ( e ){ return '<div class="page-title">Dashboard</div><p style="color:var(--red)">Failed to load stats: ' + esc ( e . message ) + '</p>' }
try { const r = await fetch ( '/admin/api/updates' ,{ headers : { 'Authorization' : 'Bearer ' + state . token }}); if ( r . ok ) state . cachedUpdate = await r . json ()} catch ( e ){}
const s = state . cachedStats ; const u = state . cachedUpdate ;
let html = '<div class="page-title">Dashboard</div><div class="page-desc">Server overview and statistics</div>' ;
if ( u && u . update_available ) html += '<div class="update-card" style="border-color:var(--accent);margin-bottom:20px"><div class="update-icon" style="background:var(--accent-glow);color:var(--accent)">' + I . updates + '</div><div class="update-info"><div class="update-ver">Update Available: ' + esc ( u . latest ) + '</div><div class="update-notes">Current: ' + esc ( u . current ) + ' — <button class="btn btn-accent" style="margin-left:8px" onclick="navigateTo(\'updates\')">View Update</button></div></div></div>' ;
html += '<div class="stat-grid">' ;
html += '<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Total Users</span><div class="stat-card-icon" style="background:rgba(35,165,90,.15);color:var(--green)">' + I . users + '</div></div><div class="stat-card-value">' + ( s . user_count || 0 ) + '</div><div class="stat-card-sub">registered</div></div>' ;
html += '<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Messages</span><div class="stat-card-icon" style="background:var(--accent-glow);color:var(--accent)">' + I . megaphone + '</div></div><div class="stat-card-value">' + ( s . message_count || 0 ). toLocaleString () + '</div><div class="stat-card-sub">total</div></div>' ;
html += '<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Channels</span><div class="stat-card-icon" style="background:rgba(240,178,50,.15);color:var(--yellow)">' + I . channels + '</div></div><div class="stat-card-value">' + ( s . channel_count || 0 ) + '</div><div class="stat-card-sub">active</div></div>' ;
html += '<div class="stat-card"><div class="stat-card-header"><span class="stat-card-label">Database</span><div class="stat-card-icon" style="background:rgba(88,101,242,.15);color:var(--accent)">' + I . backup + '</div></div><div class="stat-card-value">' + fmtBytes ( s . db_size_bytes || 0 ) + '</div><div class="stat-card-sub">SQLite</div></div>' ;
html += '</div>' ;
// Recent audit
try {
const entries = await api ( 'GET' , '/audit-log?limit=5&offset=0' );
if ( entries && entries . length ){
html += '<div class="section-card"><div class="section-card-header"><h3>Recent Activity</h3><button class="btn btn-ghost" onclick="navigateTo(\'audit\')">View All</button></div><div class="section-card-body">' ;
entries . forEach ( a =>{ html += '<div class="activity-item"><div class="activity-icon" style="background:' + actionColor ( a . action ) + '22;color:' + actionColor ( a . action ) + '">' + I . audit + '</div><div><div class="activity-text"><strong>' + esc ( a . actor_name || a . actor_id ) + '</strong> ' + esc ( a . action ) + ' <strong>' + esc ( a . target_type ) + ( a . target_id ? ' #' + a . target_id : '' ) + '</strong></div><div class="activity-time">' + esc ( a . created_at ) + ( a . detail ? ' — ' + esc ( a . detail ) : '' ) + '</div></div></div>' });
html += '</div></div>' ;
2026-03-14 22:36:35 +01:00
}
2026-03-19 05:32:40 +01:00
} catch ( e ){}
return html ;
2026-03-14 21:31:03 +01:00
}
2026-03-19 05:32:40 +01:00
/* ═══ Users ═══ */
async function renderUsers (){
const offset = ( state . usersPage - 1 ) * PAGE_SIZE ;
let users ;
try { users = await api ( 'GET' , '/users?limit=' + PAGE_SIZE + '&offset=' + offset )} catch ( e ){ return '<div class="page-title">Users</div><p style="color:var(--red)">' + esc ( e . message ) + '</p>' }
const totalPages = Math . max ( 1 , Math . ceil ( users . length / PAGE_SIZE ));
let html = '<div class="page-title">Users</div><div class="page-desc">Manage server members</div>' ;
html += '<div class="section-card"><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>User</th><th>Role</th><th>Status</th><th>Banned</th><th style="text-align:right">Actions</th></tr></thead><tbody>' ;
if ( ! users . length ) html += '<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No users found</td></tr>' ;
users . forEach ( u =>{
const uid = u . id || u . ID ; const uname = u . Username || u . username || '' ; const rid = u . role_id || u . RoleID || 4 ;
const status = u . Status || u . status || 'offline' ; const banned = u . Banned || u . banned || false ;
const statusDot = banned ? 'banned' : status ; const statusLabel = banned ? 'Banned' : status === 'online' ? 'Online' : 'Offline' ;
const initial = uname ? uname [ 0 ]. toUpperCase () : '?' ;
html += '<tr><td><div style="display:flex;align-items:center;gap:8px"><div class="avatar" style="background:var(--accent)">' + initial + '</div><strong>' + esc ( uname ) + '</strong></div></td>' ;
html += '<td><span class="role-badge"><span class="role-dot" style="background:' + roleColor ( rid ) + '"></span>' + roleName ( rid ) + '</span></td>' ;
html += '<td><span class="dot ' + statusDot + '"></span>' + statusLabel + '</td>' ;
html += '<td>' + ( banned ? '<span class="badge badge-red">Yes</span>' : '<span class="badge badge-muted">No</span>' ) + '</td>' ;
html += '<td><div class="act-group" style="justify-content:flex-end">' ;
html += '<button class="act-btn" title="Edit role" onclick="openEditUser(' + uid + ',\'' + esc ( uname ) + '\',' + rid + ')">' + I . edit + '</button>' ;
html += '<button class="act-btn" title="Force logout" onclick="forceLogout(' + uid + ')">' + I . disconnect + '</button>' ;
if ( banned ) html += '<button class="act-btn" title="Unban" onclick="unbanUser(' + uid + ')">' + I . check + '</button>' ;
else html += '<button class="act-btn danger" title="Ban" onclick="openBanUser(' + uid + ',\'' + esc ( uname ) + '\')">' + I . ban + '</button>' ;
html += '</div></td></tr>' ;
2026-03-14 22:36:35 +01:00
});
2026-03-19 05:32:40 +01:00
html += '</tbody></table></div></div>' ;
html += '<div class="pagination"><div class="pagination-info">Page ' + state . usersPage + '</div><div class="pagination-btns">' ;
html += '<button class="page-btn" ' + ( state . usersPage <= 1 ? 'disabled' : '' ) + ' onclick="state.usersPage--;renderContent()"><</button>' ;
html += '<button class="page-btn active">' + state . usersPage + '</button>' ;
html += '<button class="page-btn" ' + ( users . length < PAGE_SIZE ? 'disabled' : '' ) + ' onclick="state.usersPage++;renderContent()">></button>' ;
html += '</div></div>' ;
return html ;
}
2026-03-14 22:36:35 +01:00
2026-03-19 05:32:40 +01:00
function openEditUser ( uid , uname , currentRole ){
openModal ( '<div class="modal-header"><h3>Edit User</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><div style="display:flex;align-items:center;gap:12px;margin-bottom:20px"><div class="avatar" style="background:var(--accent);width:48px;height:48px;font-size:20px">' + uname [ 0 ]. toUpperCase () + '</div><div style="font-size:16px;font-weight:700;color:white">' + esc ( uname ) + '</div></div><div class="form-group"><label class="form-label">Role</label><select class="form-input" id="editRoleSelect" style="appearance:auto"><option value="1" ' + ( currentRole === 1 ? 'selected' : '' ) + '>Owner</option><option value="2" ' + ( currentRole === 2 ? 'selected' : '' ) + '>Admin</option><option value="3" ' + ( currentRole === 3 ? 'selected' : '' ) + '>Moderator</option><option value="4" ' + ( currentRole === 4 ? 'selected' : '' ) + '>Member</option></select></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveUserRole(' + uid + ')">Save</button></div>' );
}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
async function saveUserRole ( uid ){
const sel = document . getElementById ( 'editRoleSelect' ); if ( ! sel ) return ;
try { await api ( 'PATCH' , '/users/' + uid ,{ role_id : parseInt ( sel . value )}); closeModal (); showToast ( 'Role updated' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
function openBanUser ( uid , uname ){
openModal ( '<div class="modal-header"><h3>Ban User</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted);margin-bottom:16px">Ban <strong style="color:white">' + esc ( uname ) + '</strong> from the server?</p><div class="form-group"><label class="form-label">Reason</label><textarea class="form-input form-textarea" id="banReason" placeholder="Reason for ban..."></textarea></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmBan(' + uid + ')">Ban User</button></div>' );
}
async function confirmBan ( uid ){
const reason = document . getElementById ( 'banReason' ) ? . value || '' ;
try { await api ( 'PATCH' , '/users/' + uid ,{ banned : true , ban_reason : reason }); closeModal (); showToast ( 'User banned' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
async function unbanUser ( uid ){
try { await api ( 'PATCH' , '/users/' + uid ,{ banned : false }); showToast ( 'User unbanned' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
async function forceLogout ( uid ){
openModal ( '<div class="modal-header"><h3>Force Logout</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Terminate all sessions for this user?</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmForceLogout(' + uid + ')">Logout</button></div>' );
}
async function confirmForceLogout ( uid ){
try { await api ( 'DELETE' , '/users/' + uid + '/sessions' ); closeModal (); showToast ( 'Sessions terminated' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
/* ═══ Channels ═══ */
async function renderChannels (){
let channels ;
try { channels = await api ( 'GET' , '/channels' )} catch ( e ){ return '<div class="page-title">Channels</div><p style="color:var(--red)">' + esc ( e . message ) + '</p>' }
const chIcon = t => t === 'voice' ? I . voice : t === 'announcement' ? I . megaphone : I . channels ;
let html = '<div class="page-title">Channels</div><div class="page-desc">' + channels . length + ' channels</div>' ;
html += '<div class="filter-bar"><button class="btn btn-accent" onclick="openChannelModal(null)">' + I . plus + ' Create Channel</button></div>' ;
html += '<div class="section-card"><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Channel</th><th>Type</th><th>Category</th><th>Archived</th><th style="text-align:right">Actions</th></tr></thead><tbody>' ;
if ( ! channels . length ) html += '<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No channels</td></tr>' ;
channels . forEach ( ch =>{
const id = ch . id || ch . ID ; const name = ch . name || ch . Name || '' ; const type = ch . type || ch . Type || 'text' ;
const cat = ch . category || ch . Category || '' ; const archived = ch . archived || ch . Archived || false ;
html += '<tr><td><div style="display:flex;align-items:center;gap:8px"><span style="color:var(--text-faint)">' + chIcon ( type ) + '</span><strong>' + esc ( name ) + '</strong></div></td>' ;
html += '<td><span class="badge ' + ( type === 'voice' ? 'badge-yellow' : type === 'announcement' ? 'badge-accent' : 'badge-muted' ) + '">' + esc ( type ) + '</span></td>' ;
html += '<td style="font-size:12px;color:var(--text-faint)">' + esc ( cat ) + '</td>' ;
html += '<td>' + ( archived ? '<span class="badge badge-muted">Yes</span>' : '<span class="badge badge-green">No</span>' ) + '</td>' ;
html += '<td><div class="act-group" style="justify-content:flex-end"><button class="act-btn" title="Edit" onclick="openChannelEditModal(' + id + ',\'' + esc ( name ) + '\')">' + I . edit + '</button><button class="act-btn danger" title="Delete" onclick="openDeleteChannel(' + id + ',\'' + esc ( name ) + '\')">' + I . trash + '</button></div></td></tr>' ;
2026-03-14 22:36:35 +01:00
});
2026-03-19 05:32:40 +01:00
html += '</tbody></table></div></div>' ;
return html ;
}
2026-03-14 22:36:35 +01:00
2026-03-19 05:32:40 +01:00
function openChannelModal (){
openModal ( '<div class="modal-header"><h3>Create Channel</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><div class="form-group"><label class="form-label">Name <span class="req">*</span></label><input class="form-input" id="chName" placeholder="general"></div><div class="form-group"><label class="form-label">Type</label><select class="form-input" id="chType" style="appearance:auto"><option value="text">Text</option><option value="voice">Voice</option><option value="announcement">Announcement</option></select></div><div class="form-group"><label class="form-label">Category</label><input class="form-input" id="chCat" placeholder="Text Channels"></div><div class="form-group"><label class="form-label">Topic</label><input class="form-input" id="chTopic"></div><div class="form-group"><label class="form-label">Position</label><input class="form-input" id="chPos" type="number" value="0" min="0"></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="createChannel()">Create</button></div>' );
}
async function createChannel (){
const body = { name : document . getElementById ( 'chName' ). value . trim (), type : document . getElementById ( 'chType' ). value , category : document . getElementById ( 'chCat' ). value . trim (), topic : document . getElementById ( 'chTopic' ). value . trim (), position : parseInt ( document . getElementById ( 'chPos' ). value ) || 0 };
if ( ! body . name ){ showToast ( 'Name is required' , 'error' ); return }
try { await api ( 'POST' , '/channels' , body ); closeModal (); showToast ( 'Channel created' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
function openChannelEditModal ( id , name ){
openModal ( '<div class="modal-header"><h3>Edit Channel</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><div class="form-group"><label class="form-label">Name</label><input class="form-input" id="chEditName" value="' + esc ( name ) + '"></div></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-accent" onclick="saveChannelEdit(' + id + ')">Save</button></div>' );
}
async function saveChannelEdit ( id ){
const name = document . getElementById ( 'chEditName' ). value . trim ();
try { await api ( 'PATCH' , '/channels/' + id ,{ name }); closeModal (); showToast ( 'Channel updated' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
function openDeleteChannel ( id , name ){
openModal ( '<div class="modal-header"><h3>Delete Channel</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Permanently delete <strong style="color:white">#' + esc ( name ) + '</strong> and all its messages?</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmDeleteChannel(' + id + ')">Delete</button></div>' );
}
async function confirmDeleteChannel ( id ){
try { await api ( 'DELETE' , '/channels/' + id ); closeModal (); showToast ( 'Channel deleted' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
/* ═══ Audit Log ═══ */
async function renderAudit (){
const offset = ( state . auditPage - 1 ) * PAGE_SIZE ;
let entries ;
try { entries = await api ( 'GET' , '/audit-log?limit=' + PAGE_SIZE + '&offset=' + offset )} catch ( e ){ return '<div class="page-title">Audit Log</div><p style="color:var(--red)">' + esc ( e . message ) + '</p>' }
state . auditCache = entries || [];
// Collect unique action types for the filter dropdown.
const actionTypes = [... new Set ( state . auditCache . map ( e => e . action ). filter ( Boolean ))]. sort ();
// Client-side filter on fetched page.
const filtered = state . auditCache . filter ( e =>{
if ( state . auditActionFilter !== 'all' && e . action !== state . auditActionFilter ) return false ;
if ( state . auditSearch ){ const s = state . auditSearch . toLowerCase ();
if ( ! ( e . actor_name || String ( e . actor_id ) || '' ). toLowerCase (). includes ( s ) &&! ( e . action || '' ). toLowerCase (). includes ( s ) &&! ( e . target_type || '' ). toLowerCase (). includes ( s ) &&! ( e . detail || '' ). toLowerCase (). includes ( s )) return false }
return true ;
});
let html = '<div class="page-title">Audit Log</div><div class="page-desc">Action history — ' + state . auditCache . length + ' entries on this page</div>' ;
// Filter bar
html += '<div class="filter-bar">' ;
html += '<input class="filter-search" placeholder="Search audit log..." value="' + esc ( state . auditSearch ) + '" oninput="state.auditSearch=this.value;refilterAudit()">' ;
html += '<select class="filter-select" onchange="state.auditActionFilter=this.value;refilterAudit()">' ;
html += '<option value="all" ' + ( state . auditActionFilter === 'all' ? 'selected' : '' ) + '>All Actions</option>' ;
actionTypes . forEach ( t =>{ html += '<option value="' + esc ( t ) + '" ' + ( state . auditActionFilter === t ? 'selected' : '' ) + '>' + esc ( t ) + '</option>' });
html += '</select>' ;
html += '<button class="btn btn-ghost" onclick="copyAuditLog()" title="Copy filtered entries">Copy All</button>' ;
html += '<button class="btn btn-ghost" onclick="exportAuditCSV()" title="Export as CSV">Export CSV</button>' ;
html += '</div>' ;
// Table
html += '<div class="section-card"><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>Detail</th></tr></thead><tbody id="auditTbody">' ;
if ( ! filtered . length ) html += '<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No matching entries</td></tr>' ;
else filtered . forEach ( e =>{ html += renderAuditRow ( e )});
html += '</tbody></table></div></div>' ;
// Pagination
html += '<div class="pagination"><div class="pagination-info">Page ' + state . auditPage + ( state . auditSearch || state . auditActionFilter !== 'all' ? ' (' + filtered . length + ' of ' + state . auditCache . length + ' shown)' : '' ) + '</div><div class="pagination-btns">' ;
html += '<button class="page-btn" ' + ( state . auditPage <= 1 ? 'disabled' : '' ) + ' onclick="state.auditPage--;renderContent()"><</button>' ;
html += '<button class="page-btn active">' + state . auditPage + '</button>' ;
html += '<button class="page-btn" ' + ( ! entries || entries . length < PAGE_SIZE ? 'disabled' : '' ) + ' onclick="state.auditPage++;renderContent()">></button>' ;
html += '</div></div>' ;
return html ;
}
function renderAuditRow ( e ){
return '<tr><td style="font-size:12px;color:var(--text-faint);white-space:nowrap">' + esc ( e . created_at ) + '</td>'
+ '<td><strong>' + esc ( e . actor_name || e . actor_id ) + '</strong></td>'
+ '<td><span class="badge ' + actionBadge ( e . action ) + '">' + esc ( e . action ) + '</span></td>'
+ '<td>' + esc ( e . target_type ) + ( e . target_id ? ' #' + e . target_id : '' ) + '</td>'
+ '<td style="font-size:12px;color:var(--text-faint)">' + esc ( e . detail ) + '</td></tr>' ;
}
function refilterAudit (){
const tbody = document . getElementById ( 'auditTbody' ); if ( ! tbody ) return ;
const filtered = state . auditCache . filter ( e =>{
if ( state . auditActionFilter !== 'all' && e . action !== state . auditActionFilter ) return false ;
if ( state . auditSearch ){ const s = state . auditSearch . toLowerCase ();
if ( ! ( e . actor_name || String ( e . actor_id ) || '' ). toLowerCase (). includes ( s ) &&! ( e . action || '' ). toLowerCase (). includes ( s ) &&! ( e . target_type || '' ). toLowerCase (). includes ( s ) &&! ( e . detail || '' ). toLowerCase (). includes ( s )) return false }
return true ;
});
if ( ! filtered . length ) tbody . innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-faint);padding:24px">No matching entries</td></tr>' ;
else tbody . innerHTML = filtered . map ( renderAuditRow ). join ( '' );
}
function copyAuditLog (){
const filtered = state . auditCache . filter ( e =>{
if ( state . auditActionFilter !== 'all' && e . action !== state . auditActionFilter ) return false ;
if ( state . auditSearch ){ const s = state . auditSearch . toLowerCase ();
if ( ! ( e . actor_name || String ( e . actor_id ) || '' ). toLowerCase (). includes ( s ) &&! ( e . action || '' ). toLowerCase (). includes ( s ) &&! ( e . target_type || '' ). toLowerCase (). includes ( s ) &&! ( e . detail || '' ). toLowerCase (). includes ( s )) return false }
return true ;
});
const lines = filtered . map ( e =>( e . created_at || '' ) + '\t' + ( e . actor_name || e . actor_id ) + '\t' + ( e . action || '' ) + '\t' + ( e . target_type || '' ) + ( e . target_id ? ' #' + e . target_id : '' ) + '\t' + ( e . detail || '' ));
navigator . clipboard . writeText ( lines . join ( '\n' )). then (()=> showToast ( 'Copied ' + lines . length + ' entries' , 'info' )). catch (()=> showToast ( 'Copy failed' , 'error' ));
}
function exportAuditCSV (){
const filtered = state . auditCache . filter ( e =>{
if ( state . auditActionFilter !== 'all' && e . action !== state . auditActionFilter ) return false ;
if ( state . auditSearch ){ const s = state . auditSearch . toLowerCase ();
if ( ! ( e . actor_name || String ( e . actor_id ) || '' ). toLowerCase (). includes ( s ) &&! ( e . action || '' ). toLowerCase (). includes ( s ) &&! ( e . target_type || '' ). toLowerCase (). includes ( s ) &&! ( e . detail || '' ). toLowerCase (). includes ( s )) return false }
return true ;
});
const csvQ = v => '"' + String ( v || '' ). replace ( /"/g , '""' ) + '"' ;
let csv = 'Time,Actor,Action,Target,Detail\n' ;
filtered . forEach ( e =>{ csv += csvQ ( e . created_at ) + ',' + csvQ ( e . actor_name || e . actor_id ) + ',' + csvQ ( e . action ) + ',' + csvQ (( e . target_type || '' ) + ( e . target_id ? ' #' + e . target_id : '' )) + ',' + csvQ ( e . detail ) + '\n' });
const blob = new Blob ([ csv ],{ type : 'text/csv' }); const url = URL . createObjectURL ( blob );
const a = document . createElement ( 'a' ); a . href = url ; a . download = 'audit_log_' + new Date (). toISOString (). slice ( 0 , 10 ) + '.csv' ; a . click ();
URL . revokeObjectURL ( url ); showToast ( 'Exported ' + filtered . length + ' entries' , 'info' );
}
/* ═══ Server Logs ═══ */
function renderLogs (){
const lvlBtn = ( l )=>{ const on = state . logLevels [ l ]; return '<button class="level-toggle ' + ( on ? 'active-' + l . toLowerCase () : '' ) + '" onclick="toggleLogLevel(\'' + l + '\')">' + l + '</button>' };
let html = '<div class="page-title">Server Logs</div><div class="page-desc">Real-time structured log stream</div>' ;
html += '<div class="log-toolbar">' ;
html += lvlBtn ( 'DEBUG' ) + lvlBtn ( 'INFO' ) + lvlBtn ( 'WARN' ) + lvlBtn ( 'ERROR' );
html += '<input class="filter-search" placeholder="Filter logs..." style="flex:1;min-width:150px" value="' + esc ( state . logSearch ) + '" oninput="state.logSearch=this.value;renderLogLines()">' ;
html += '<button class="btn btn-ghost" onclick="toggleLogAutoScroll()" id="autoScrollBtn" title="Auto-scroll">' + ( state . logAutoScroll ? '⬇ Auto' : '⏸ Manual' ) + '</button>' ;
html += '<button class="btn btn-ghost" onclick="toggleLogPause()" id="pauseBtn">' + ( state . logPaused ? '▶ Resume' : '⏸ Pause' ) + '</button>' ;
html += '<button class="btn btn-ghost" onclick="copyAllLogs()" title="Copy visible logs">Copy All</button>' ;
html += '<button class="btn btn-ghost" onclick="clearLogs()" title="Clear log view">Clear</button>' ;
html += '</div>' ;
html += '<div class="log-output" id="logOutput"></div>' ;
html += '<div class="log-status"><span class="' + ( state . logPaused ? 'dot-off' : 'dot-live' ) + '" id="logDot"></span><span id="logStatusText">' + ( state . logPaused ? 'Paused' : 'Connecting...' ) + '</span><span style="margin-left:auto" id="logCount">' + state . logEntries . length + ' entries</span></div>' ;
setTimeout (()=>{ renderLogLines (); if ( ! state . logPaused ) connectLogStream ()}, 0 );
return html ;
}
function toggleLogLevel ( l ){ state . logLevels [ l ] =! state . logLevels [ l ]; const btns = document . querySelectorAll ( '.level-toggle' ); btns . forEach ( b =>{ if ( b . textContent === l ){ b . className = 'level-toggle ' + ( state . logLevels [ l ] ? 'active-' + l . toLowerCase () : '' )}}); renderLogLines ()}
function toggleLogAutoScroll (){ state . logAutoScroll =! state . logAutoScroll ; const btn = document . getElementById ( 'autoScrollBtn' ); if ( btn ) btn . textContent = state . logAutoScroll ? '⬇ Auto' : '⏸ Manual' }
function toggleLogPause (){
state . logPaused =! state . logPaused ;
const btn = document . getElementById ( 'pauseBtn' ); if ( btn ) btn . textContent = state . logPaused ? '▶ Resume' : '⏸ Pause' ;
const dot = document . getElementById ( 'logDot' ); if ( dot ) dot . className = state . logPaused ? 'dot-off' : 'dot-live' ;
const txt = document . getElementById ( 'logStatusText' );
2026-03-29 19:39:46 +02:00
if ( state . logPaused ){ state . logConnectSeq ++ ; if ( state . logEventSource ){ state . logEventSource . close (); state . logEventSource = null } if ( state . logReconnectTimer ){ clearTimeout ( state . logReconnectTimer ); state . logReconnectTimer = null } if ( txt ) txt . textContent = 'Paused' }
2026-03-19 05:32:40 +01:00
else { connectLogStream ()}
}
2026-03-29 19:39:46 +02:00
function scheduleLogReconnect (){
if ( state . logPaused || state . section !== 'logs' || state . logReconnectTimer ) return ;
state . logReconnectTimer = setTimeout ( function (){ state . logReconnectTimer = null ; connectLogStream ()}, 1500 );
}
async function connectLogStream (){
if ( state . logReconnectTimer ){ clearTimeout ( state . logReconnectTimer ); state . logReconnectTimer = null }
if ( state . logEventSource ){ state . logEventSource . close (); state . logEventSource = null }
2026-03-19 05:32:40 +01:00
if ( state . logPaused || state . section !== 'logs' ) return ;
2026-03-29 19:39:46 +02:00
const connectSeq =++ state . logConnectSeq ;
let ticket ;
try { const res = await api ( 'POST' , '/logs/ticket' ); ticket = res . ticket } catch ( err ){ const t = document . getElementById ( 'logStatusText' ); const d = document . getElementById ( 'logDot' ); const msg = ( err && err . message ) || '' ; if ( /authorization|invalid or expired session|session has expired|missing or invalid|administrator permission required/i . test ( msg )){ state . logPaused = true ; state . logConnectSeq ++ ; if ( state . logReconnectTimer ){ clearTimeout ( state . logReconnectTimer ); state . logReconnectTimer = null } if ( state . logEventSource ){ state . logEventSource . close (); state . logEventSource = null } state . token = '' ; localStorage . removeItem ( 'admin_token' ); if ( t ) t . textContent = 'Session expired' ; if ( d ) d . className = 'dot-off' ; showOverlay ( 'loginOverlay' ); return } if ( t ) t . textContent = 'Reconnect failed' ; if ( d ) d . className = 'dot-off' ; scheduleLogReconnect (); return }
if ( connectSeq !== state . logConnectSeq || state . logPaused || state . section !== 'logs' ) return ;
const es = new EventSource ( '/admin/api/logs/stream?ticket=' + encodeURIComponent ( ticket ));
2026-03-19 05:32:40 +01:00
state . logEventSource = es ;
es . onopen = function (){ const t = document . getElementById ( 'logStatusText' ); if ( t ) t . textContent = 'Connected' };
es . onmessage = function ( e ){
try { const entry = JSON . parse ( e . data ); state . logEntries . push ( entry );
while ( state . logEntries . length > state . logMaxLines ) state . logEntries . shift ();
appendLogLine ( entry );
const c = document . getElementById ( 'logCount' ); if ( c ) c . textContent = state . logEntries . length + ' entries' ;
} catch ( err ){}
2026-03-14 21:31:03 +01:00
};
2026-03-29 19:39:46 +02:00
es . onerror = function (){ if ( state . logEventSource === es ){ state . logEventSource . close (); state . logEventSource = null } const t = document . getElementById ( 'logStatusText' ); if ( t ) t . textContent = 'Reconnecting...' ; const d = document . getElementById ( 'logDot' ); if ( d ) d . className = 'dot-off' ; scheduleLogReconnect ()};
2026-03-14 21:31:03 +01:00
}
2026-03-19 05:32:40 +01:00
function matchesLogFilter ( entry ){
if ( ! state . logLevels [ entry . level ]) return false ;
if ( state . logSearch ){ const s = state . logSearch . toLowerCase (); if ( ! ( entry . msg || '' ). toLowerCase (). includes ( s ) &&! ( entry . source || '' ). toLowerCase (). includes ( s ) &&! ( entry . attrs || '' ). toLowerCase (). includes ( s )) return false }
return true ;
2026-03-14 22:06:54 +01:00
}
2026-03-19 05:32:40 +01:00
function appendLogLine ( entry ){
if ( ! matchesLogFilter ( entry )) return ;
const out = document . getElementById ( 'logOutput' ); if ( ! out ) return ;
const div = document . createElement ( 'div' );
div . className = 'log-line l-' + entry . level . toLowerCase ();
const ts = entry . ts ? entry . ts . substring ( 11 , 23 ) : '' ;
div . innerHTML = '<span class="log-ts">' + esc ( ts ) + '</span><span class="log-lvl">' + esc ( entry . level ) + '</span><span class="log-src">[' + esc ( entry . source || 'server' ) + ']</span>' + esc ( entry . msg ) + ( entry . attrs && entry . attrs !== '{}' ? ' <span style="color:var(--text-micro)">' + esc ( entry . attrs ) + '</span>' : '' );
out . appendChild ( div );
// Trim DOM to max lines
while ( out . children . length > state . logMaxLines ) out . removeChild ( out . firstChild );
if ( state . logAutoScroll ) out . scrollTop = out . scrollHeight ;
2026-03-14 22:06:54 +01:00
}
2026-03-19 05:32:40 +01:00
function renderLogLines (){
const out = document . getElementById ( 'logOutput' ); if ( ! out ) return ;
out . innerHTML = '' ;
state . logEntries . forEach ( e =>{ if ( matchesLogFilter ( e )) appendLogLine ( e )});
2026-03-14 21:31:03 +01:00
}
2026-03-19 05:32:40 +01:00
function copyAllLogs (){
const out = document . getElementById ( 'logOutput' ); if ( ! out ) return ;
const lines = []; state . logEntries . forEach ( e =>{ if ( matchesLogFilter ( e )) lines . push (( e . ts || '' ) + ' ' + e . level + ' [' + ( e . source || 'server' ) + '] ' + e . msg + ( e . attrs && e . attrs !== '{}' ? ' ' + e . attrs : '' ))});
navigator . clipboard . writeText ( lines . join ( '\n' )). then (()=> showToast ( 'Copied ' + lines . length + ' log lines' , 'info' )). catch (()=> showToast ( 'Copy failed' , 'error' ));
2026-03-14 21:31:03 +01:00
}
2026-03-19 05:32:40 +01:00
function clearLogs (){ state . logEntries = []; const out = document . getElementById ( 'logOutput' ); if ( out ) out . innerHTML = '' ; const c = document . getElementById ( 'logCount' ); if ( c ) c . textContent = '0 entries' ; showToast ( 'Log view cleared' , 'info' )}
2026-03-14 21:31:03 +01:00
2026-03-19 05:32:40 +01:00
/* ═══ Settings ═══ */
async function renderSettings (){
let settings ;
try { settings = await api ( 'GET' , '/settings' )} catch ( e ){ return '<div class="page-title">Settings</div><p style="color:var(--red)">' + esc ( e . message ) + '</p>' }
state . _settings = {... settings };
const v = k => settings [ k ] || '' ;
2026-03-30 21:48:14 +02:00
const isOn = k => v ( k ) === '1' || v ( k ) === 'true' ;
2026-03-19 05:32:40 +01:00
let html = '<div class="page-title">Server Settings</div><div class="page-desc">Configure your OwnCord server</div>' ;
html += '<div class="section-card"><div class="section-card-header"><h3>General</h3></div><div class="section-card-body">' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Server Name</div></div><div class="setting-ctrl"><input class="form-input" id="s-server_name" value="' + esc ( v ( 'server_name' )) + '" style="width:240px" oninput="markSettingsChanged()"></div></div>' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Server Icon URL</div></div><div class="setting-ctrl"><input class="form-input" id="s-server_icon" value="' + esc ( v ( 'server_icon' )) + '" style="width:240px" oninput="markSettingsChanged()"></div></div>' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Message of the Day</div><div class="setting-desc">Shown to users when they connect</div></div><div class="setting-ctrl"><input class="form-input" id="s-motd" value="' + esc ( v ( 'motd' )) + '" style="width:300px" oninput="markSettingsChanged()"></div></div>' ;
html += '</div></div>' ;
html += '<div class="section-card"><div class="section-card-header"><h3>Limits</h3></div><div class="section-card-body">' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Max Upload Size (bytes)</div></div><div class="setting-ctrl"><input class="form-input" id="s-max_upload_bytes" value="' + esc ( v ( 'max_upload_bytes' )) + '" style="width:160px" type="number" oninput="markSettingsChanged()"></div></div>' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Voice Quality</div></div><div class="setting-ctrl"><select class="filter-select" id="s-voice_quality" onchange="markSettingsChanged()"><option value="low" ' + ( v ( 'voice_quality' ) === 'low' ? 'selected' : '' ) + '>Low</option><option value="medium" ' + ( v ( 'voice_quality' ) === 'medium' ? 'selected' : '' ) + '>Medium</option><option value="high" ' + ( v ( 'voice_quality' ) === 'high' ? 'selected' : '' ) + '>High</option></select></div></div>' ;
html += '</div></div>' ;
html += '<div class="section-card"><div class="section-card-header"><h3>Security</h3></div><div class="section-card-body">' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Require 2FA</div><div class="setting-desc">Require all users to enable two-factor authentication</div></div><div class="setting-ctrl"><button class="toggle ' + ( isOn ( 'require_2fa' ) ? 'on' : '' ) + '" id="s-require_2fa" onclick="this.classList.toggle(\'on\');markSettingsChanged()"></button></div></div>' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Registration Open</div><div class="setting-desc">Allow new users to register with invite codes</div></div><div class="setting-ctrl"><button class="toggle ' + ( isOn ( 'registration_open' ) ? 'on' : '' ) + '" id="s-registration_open" onclick="this.classList.toggle(\'on\');markSettingsChanged()"></button></div></div>' ;
html += '</div></div>' ;
html += '<div class="section-card"><div class="section-card-header"><h3>Backup</h3></div><div class="section-card-body">' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Schedule</div></div><div class="setting-ctrl"><select class="filter-select" id="s-backup_schedule" onchange="markSettingsChanged()"><option value="off" ' + ( v ( 'backup_schedule' ) === 'off' ? 'selected' : '' ) + '>Off</option><option value="daily" ' + ( v ( 'backup_schedule' ) === 'daily' ? 'selected' : '' ) + '>Daily</option><option value="weekly" ' + ( v ( 'backup_schedule' ) === 'weekly' ? 'selected' : '' ) + '>Weekly</option></select></div></div>' ;
html += '<div class="setting-row"><div class="setting-info"><div class="setting-name">Retention (days)</div></div><div class="setting-ctrl"><input class="form-input" id="s-backup_retention" value="' + esc ( v ( 'backup_retention' )) + '" style="width:100px" type="number" oninput="markSettingsChanged()"></div></div>' ;
html += '</div></div>' ;
html += '<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px"><button class="btn btn-accent" id="saveSettingsBtn" ' + ( state . settingsChanged ? '' : 'disabled' ) + ' onclick="saveSettings()">Save Changes</button></div>' ;
return html ;
2026-03-14 21:31:03 +01:00
}
2026-03-19 05:32:40 +01:00
function markSettingsChanged (){ state . settingsChanged = true ; renderNav (); const btn = document . getElementById ( 'saveSettingsBtn' ); if ( btn ) btn . disabled = false }
async function saveSettings (){
const body = {};
[ 'server_name' , 'server_icon' , 'motd' , 'max_upload_bytes' , 'voice_quality' , 'backup_schedule' , 'backup_retention' ]. forEach ( k =>{ const el = document . getElementById ( 's-' + k ); if ( el ) body [ k ] = el . value });
[ 'require_2fa' , 'registration_open' ]. forEach ( k =>{ const el = document . getElementById ( 's-' + k ); if ( el ) body [ k ] = el . classList . contains ( 'on' ) ? 'true' : 'false' });
try { await api ( 'PATCH' , '/settings' , body ); state . settingsChanged = false ; renderNav (); showToast ( 'Settings saved' )} catch ( e ){ showToast ( e . message , 'error' )}
2026-03-14 21:31:03 +01:00
}
2026-03-19 05:32:40 +01:00
/* ═══ Backups ═══ */
async function renderBackups (){
let backups ;
try { backups = await api ( 'GET' , '/backups' )} catch ( e ){ return '<div class="page-title">Backups</div><p style="color:var(--red)">' + esc ( e . message ) + '</p>' }
let html = '<div class="page-title">Backups</div><div class="page-desc">Database backup and restore</div>' ;
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px"><div class="section-card"><div class="section-card-header"><h3>Manual Backup</h3></div><div class="section-card-body" style="text-align:center;padding:32px"><button class="btn btn-accent" style="font-size:15px;padding:12px 32px" onclick="createBackup()" ' + ( state . backupRunning ? 'disabled' : '' ) + '>' + ( state . backupRunning ? '<div class="spinner"></div> Running...' : I . download + ' Create Backup Now' ) + '</button></div></div>' ;
html += '<div class="section-card"><div class="section-card-header"><h3>Schedule</h3></div><div class="section-card-body"><p style="color:var(--text-faint);font-size:13px">Configure backup schedule in Settings.</p><button class="btn btn-ghost" style="margin-top:8px" onclick="navigateTo(\'settings\')">Go to Settings</button></div></div></div>' ;
html += '<div class="section-card"><div class="section-card-header"><h3>Backup History</h3></div><div class="section-card-body no-pad"><table class="tbl"><thead><tr><th>Filename</th><th>Size</th><th>Date</th><th style="text-align:right">Actions</th></tr></thead><tbody>' ;
if ( ! backups ||! backups . length ) html += '<tr><td colspan="4" style="text-align:center;color:var(--text-faint);padding:24px">No backups found</td></tr>' ;
else backups . forEach ( b =>{
html += '<tr><td><code style="font-family:var(--font-mono);font-size:12px">' + esc ( b . name ) + '</code></td>' ;
html += '<td>' + fmtBytes ( b . size ) + '</td><td>' + ( b . date ? new Date ( b . date ). toLocaleString () : '' ) + '</td>' ;
html += '<td><div class="act-group" style="justify-content:flex-end"><button class="btn btn-ghost" onclick="openRestoreModal(\'' + esc ( b . name ) + '\')">Restore</button><button class="act-btn danger" title="Delete" onclick="confirmDeleteBackup(\'' + esc ( b . name ) + '\')">' + I . trash + '</button></div></td></tr>' ;
2026-03-14 21:31:03 +01:00
});
2026-03-19 05:32:40 +01:00
html += '</tbody></table></div></div>' ;
return html ;
}
async function createBackup (){
state . backupRunning = true ; renderContent ();
try { await api ( 'POST' , '/backup' ); state . backupRunning = false ; showToast ( 'Backup created' ); renderContent ()} catch ( e ){ state . backupRunning = false ; showToast ( e . message , 'error' ); renderContent ()}
}
function openRestoreModal ( name ){
openModal ( '<div class="modal-header"><h3>Restore Backup</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">Overwrite the current database with <strong style="color:white">' + esc ( name ) + '</strong>? A pre-restore backup will be created. Server restart recommended after restore.</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmRestore(\'' + esc ( name ) + '\')">Restore</button></div>' );
}
async function confirmRestore ( name ){
try { await api ( 'POST' , '/backups/' + encodeURIComponent ( name ) + '/restore' ); closeModal (); showToast ( 'Database restored. Restart recommended.' , 'info' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
async function confirmDeleteBackup ( name ){
try { await fetch ( '/admin/api/backups/' + encodeURIComponent ( name ),{ method : 'DELETE' , headers : { 'Authorization' : 'Bearer ' + state . token }}); showToast ( 'Backup deleted' ); renderContent ()} catch ( e ){ showToast ( e . message , 'error' )}
}
/* ═══ Updates ═══ */
async function renderUpdates (){
let info ;
try { const r = await fetch ( '/admin/api/updates' ,{ headers : { 'Authorization' : 'Bearer ' + state . token }}); if ( r . ok ) info = await r . json ()} catch ( e ){}
let html = '<div class="page-title">Updates</div><div class="page-desc">Server version management</div>' ;
html += '<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px">' ;
html += '<div class="update-card"><div class="update-icon" style="background:rgba(35,165,90,.15);color:var(--green)">' + I . check + '</div><div class="update-info"><div class="update-ver">' + ( info ? esc ( info . current ) : 'unknown' ) + '</div><div class="update-notes">Current version</div></div></div>' ;
if ( info && info . update_available ) html += '<div class="update-card" style="border-color:var(--accent)"><div class="update-icon" style="background:var(--accent-glow);color:var(--accent)">' + I . updates + '</div><div class="update-info"><div class="update-ver">' + esc ( info . latest ) + ' <span class="badge badge-accent">New</span></div><div class="update-notes">Available for download</div></div></div>' ;
else html += '<div class="update-card"><div class="update-icon" style="background:rgba(35,165,90,.15);color:var(--green)">' + I . check + '</div><div class="update-info"><div class="update-ver">Up to date</div><div class="update-notes">You\'re running the latest version</div></div></div>' ;
html += '</div>' ;
if ( info && info . update_available ){
html += '<div style="display:flex;gap:8px"><button class="btn btn-danger" onclick="applyUpdate()" ' + ( state . updateApplying ? 'disabled' : '' ) + '>' + ( state . updateApplying ? '<div class="spinner"></div> Applying...' : 'Apply Update & Restart' ) + '</button>' ;
html += '<button class="btn btn-ghost" onclick="renderContent()">' + I . refresh + ' Check Again</button></div>' ;
} else {
html += '<div style="margin-top:16px"><button class="btn btn-ghost" onclick="renderContent()">' + I . refresh + ' Check for Updates</button></div>' ;
2026-03-14 21:31:03 +01:00
}
2026-03-19 05:32:40 +01:00
return html ;
2026-03-17 02:56:19 +01:00
}
2026-03-19 05:32:40 +01:00
async function applyUpdate (){
openModal ( '<div class="modal-header"><h3>Apply Update</h3><button class="modal-close" onclick="closeModal()">×</button></div><div class="modal-body"><p style="color:var(--text-muted)">This will restart the server. All connected users will be briefly disconnected. Continue?</p></div><div class="modal-footer"><button class="btn btn-ghost" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmApplyUpdate()">Update & Restart</button></div>' );
2026-03-17 02:56:19 +01:00
}
2026-03-19 05:32:40 +01:00
async function confirmApplyUpdate (){
closeModal (); state . updateApplying = true ; renderContent ();
try {
const r = await fetch ( '/admin/api/updates/apply' ,{ method : 'POST' , headers : { 'Authorization' : 'Bearer ' + state . token }});
if ( r . ok ){ showToast ( 'Update applied! Server restarting...' , 'info' ); setTimeout (()=> location . reload (), 10000 )}
else { const e = await r . json (); showToast ( e . message || 'Update failed' , 'error' )}
} catch ( e ){ showToast ( e . message , 'error' )}
state . updateApplying = false ;
2026-03-17 02:56:19 +01:00
}
2026-03-19 05:32:40 +01:00
/* ═══ Keyboard + Init ═══ */
document . addEventListener ( 'keydown' , e =>{
if ( e . key === 'Escape' ) closeModal ();
if ( e . key === '/' &&! document . querySelector ( '.modal-overlay.visible' )){ const s = document . querySelector ( '.filter-search' ); if ( s ){ e . preventDefault (); s . focus ()}}
});
document . getElementById ( 'modal' ). addEventListener ( 'click' , e =>{ if ( e . target === e . currentTarget ) closeModal ()});
2026-03-17 02:56:19 +01:00
2026-03-14 21:31:03 +01:00
checkAuth ();
</ script >
2026-03-14 20:34:37 +01:00
</ body >
</ html >