2026-05-13 00:32:27 +07:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-05-13 20:52:37 +07:00
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Discord Moderation Watcher</ title >
< link rel = "preconnect" href = "https://fonts.googleapis.com" >
< link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin >
< link href = "https://fonts.googleapis.com/css2?family=Archivo+Black&family=JetBrains+Mono:wght@400;600;700&family=Manrope:wght@500;700;800&display=swap" rel = "stylesheet" >
< style >
: root {
--bg : #080a0f ;
--panel : rgba ( 18 , 22 , 32 , 0.86 );
--panel-strong : #121720 ;
--line : rgba ( 255 , 255 , 255 , 0.12 );
--text : #edf4ff ;
--muted : #91a0b6 ;
--faint : #536176 ;
--cyan : #00e5ff ;
--green : #39ff88 ;
--yellow : #ffe45e ;
--red : #ff4f6d ;
--blue : #6275ff ;
--shadow : 0 24 px 70 px rgba ( 0 , 0 , 0 , 0.46 );
}
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
* { box-sizing : border-box ; }
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
body {
margin : 0 ;
min-height : 100 vh ;
color : var ( -- text );
font-family : Manrope , sans-serif ;
background :
radial-gradient ( circle at 12 % 8 % , rgba ( 0 , 229 , 255 , 0.18 ), transparent 32 rem ),
radial-gradient ( circle at 88 % 0 % , rgba ( 98 , 117 , 255 , 0.2 ), transparent 28 rem ),
linear-gradient ( 145 deg , #05060a , var ( -- bg ));
overflow-x : hidden ;
}
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
body :: before {
content : "" ;
position : fixed ;
inset : 0 ;
pointer-events : none ;
background-image :
linear-gradient ( rgba ( 255 , 255 , 255 , 0.035 ) 1 px , transparent 1 px ),
linear-gradient ( 90 deg , rgba ( 255 , 255 , 255 , 0.035 ) 1 px , transparent 1 px );
background-size : 42 px 42 px ;
mask-image : linear-gradient ( to bottom , black , transparent 84 % );
}
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
button , select { font : inherit ; }
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
. shell {
width : min ( 1440 px , calc ( 100 % - 32 px ));
margin : 0 auto ;
padding : 28 px 0 44 px ;
}
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
. hero {
display : grid ;
grid-template-columns : 1.2 fr 0.8 fr ;
gap : 18 px ;
align-items : stretch ;
margin-bottom : 18 px ;
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
. brand-card ,
. status-card ,
. tab-panel ,
. content-card {
border : 1 px solid var ( -- line );
background : var ( -- panel );
backdrop-filter : blur ( 18 px );
box-shadow : var ( -- shadow );
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
. brand-card {
position : relative ;
padding : 28 px ;
border-radius : 28 px ;
overflow : hidden ;
min-height : 190 px ;
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
. brand-card :: after {
content : "WATCHER" ;
position : absolute ;
right : -14 px ;
bottom : -20 px ;
font-family : "Archivo Black" , sans-serif ;
font-size : clamp ( 58 px , 9 vw , 132 px );
letter-spacing : -0.08 em ;
color : rgba ( 255 , 255 , 255 , 0.035 );
line-height : 0.78 ;
}
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
. eyebrow {
display : inline-flex ;
align-items : center ;
gap : 10 px ;
margin-bottom : 18 px ;
color : var ( -- cyan );
font : 700 12 px / 1 "JetBrains Mono" , monospace ;
text-transform : uppercase ;
letter-spacing : 0.18 em ;
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
. pulse {
width : 9 px ;
height : 9 px ;
border-radius : 999 px ;
background : var ( -- green );
box-shadow : 0 0 20 px var ( -- green );
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
h1 {
margin : 0 ;
max-width : 840 px ;
font-family : "Archivo Black" , sans-serif ;
font-size : clamp ( 40 px , 6 vw , 82 px );
line-height : 0.88 ;
letter-spacing : -0.06 em ;
text-transform : uppercase ;
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
. subtitle {
margin : 18 px 0 0 ;
max-width : 720 px ;
color : var ( -- muted );
font-size : 15 px ;
line-height : 1.7 ;
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
. status-card {
border-radius : 28 px ;
padding : 24 px ;
display : grid ;
gap : 14 px ;
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
. status-row {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
padding : 14 px 0 ;
border-bottom : 1 px solid var ( -- line );
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
. status-row : last-child { border-bottom : 0 ; }
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
. status-label {
color : var ( -- muted );
font : 700 12 px / 1 "JetBrains Mono" , monospace ;
text-transform : uppercase ;
letter-spacing : 0.12 em ;
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
. status-value {
display : inline-flex ;
align-items : center ;
gap : 8 px ;
color : var ( -- text );
font : 700 13 px / 1 "JetBrains Mono" , monospace ;
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
. dot {
width : 9 px ;
height : 9 px ;
border-radius : 99 px ;
background : var ( -- faint );
}
. dot . on { background : var ( -- green ); box-shadow : 0 0 16 px var ( -- green ); }
. dot . warn { background : var ( -- yellow ); box-shadow : 0 0 16 px var ( -- yellow ); }
. tab-panel {
position : sticky ;
top : 14 px ;
z-index : 5 ;
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
padding : 12 px ;
border-radius : 24 px ;
margin-bottom : 18 px ;
}
. tabs {
display : flex ;
gap : 8 px ;
flex-wrap : wrap ;
}
. tab-btn {
border : 1 px solid transparent ;
color : var ( -- muted );
background : transparent ;
border-radius : 16 px ;
padding : 12 px 16 px ;
cursor : pointer ;
font-weight : 800 ;
transition : 160 ms ease ;
}
. tab-btn : hover { color : var ( -- text ); background : rgba ( 255 , 255 , 255 , 0.06 ); }
. tab-btn . active {
color : #061014 ;
background : linear-gradient ( 135 deg , var ( -- cyan ), var ( -- green ));
box-shadow : 0 12 px 28 px rgba ( 0 , 229 , 255 , 0.18 );
}
. filter-row {
display : flex ;
align-items : center ;
gap : 10 px ;
color : var ( -- muted );
font-size : 13 px ;
}
select {
min-width : 240 px ;
color : var ( -- text );
background : rgba ( 5 , 8 , 14 , 0.78 );
border : 1 px solid var ( -- line );
border-radius : 14 px ;
padding : 11 px 14 px ;
outline : none ;
}
. grid {
display : grid ;
grid-template-columns : minmax ( 0 , 1 fr );
gap : 18 px ;
}
. tab-content { display : none ; }
. tab-content . active { display : block ; }
. voice-layout {
display : grid ;
grid-template-columns : 380 px 1 fr ;
gap : 18 px ;
}
. content-card {
border-radius : 28 px ;
padding : 22 px ;
}
. card-title {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
margin-bottom : 18 px ;
}
. card-title h2 {
margin : 0 ;
font-family : "Archivo Black" , sans-serif ;
font-size : 26 px ;
letter-spacing : -0.04 em ;
text-transform : uppercase ;
}
. mini {
color : var ( -- faint );
font : 700 11 px / 1 "JetBrains Mono" , monospace ;
text-transform : uppercase ;
letter-spacing : 0.12 em ;
}
. field-group { display : grid ; gap : 8 px ; margin-bottom : 14 px ; }
. field-group label { color : var ( -- muted ); font-size : 13 px ; font-weight : 800 ; }
. button-row { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 10 px ; }
. btn {
border : 0 ;
border-radius : 16 px ;
padding : 13 px 16 px ;
color : #061014 ;
cursor : pointer ;
font-weight : 900 ;
transition : transform 140 ms ease , filter 140 ms ease ;
}
. btn : hover { transform : translateY ( -1 px ); filter : brightness ( 1.08 ); }
. btn-primary { background : linear-gradient ( 135 deg , var ( -- cyan ), var ( -- blue )); color : white ; }
. btn-success { background : linear-gradient ( 135 deg , var ( -- green ), var ( -- cyan )); }
. btn-danger { background : linear-gradient ( 135 deg , var ( -- red ), #ff9a6b ); color : white ; }
. voice-status { color : var ( -- muted ); font-size : 13 px ; margin-top : 12 px ; min-height : 20 px ; }
. visualizer {
display : flex ;
align-items : flex-end ;
gap : 5 px ;
height : 130 px ;
padding : 16 px ;
border : 1 px solid var ( -- line );
border-radius : 20 px ;
background : rgba ( 0 , 0 , 0 , 0.22 );
overflow : hidden ;
}
. bar {
flex : 1 ;
min-width : 5 px ;
border-radius : 999 px ;
height : 3 px ;
background : linear-gradient ( to top , var ( -- blue ), var ( -- cyan ), var ( -- green ));
box-shadow : 0 0 18 px rgba ( 0 , 229 , 255 , 0.16 );
}
. participants {
display : grid ;
gap : 10 px ;
}
. user-item {
display : flex ;
align-items : center ;
gap : 12 px ;
padding : 12 px ;
border : 1 px solid var ( -- line );
border-radius : 18 px ;
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. user-item . speaking { border-color : rgba ( 57 , 255 , 136 , 0.55 ); background : rgba ( 57 , 255 , 136 , 0.08 ); }
. user-item img { width : 34 px ; height : 34 px ; border-radius : 999 px ; }
. feed {
display : grid ;
gap : 12 px ;
}
. event-card {
display : grid ;
gap : 10 px ;
padding : 16 px ;
border : 1 px solid var ( -- line );
border-radius : 20 px ;
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. event-head {
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 12 px ;
}
. author {
display : flex ;
align-items : center ;
gap : 10 px ;
min-width : 0 ;
}
. avatar {
width : 34 px ;
height : 34 px ;
border-radius : 999 px ;
background : linear-gradient ( 135 deg , var ( -- blue ), var ( -- cyan ));
flex : 0 0 auto ;
}
. avatar img { width : 100 % ; height : 100 % ; border-radius : inherit ; object-fit : cover ; }
. name { font-weight : 900 ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap ; }
. time { color : var ( -- faint ); font : 600 11 px / 1 "JetBrains Mono" , monospace ; white-space : nowrap ; }
. message-text { color : #dbe6f7 ; line-height : 1.6 ; white-space : pre-wrap ; word-break : break-word ; }
2026-05-13 21:04:45 +07:00
. sticker-strip , . attachment-strip { display : flex ; gap : 10 px ; flex-wrap : wrap ; }
2026-05-13 20:52:37 +07:00
. sticker-img { width : 96 px ; height : 96 px ; object-fit : contain ; border-radius : 16 px ; background : rgba ( 0 , 0 , 0 , 0.22 ); border : 1 px solid var ( -- line ); padding : 8 px ; }
2026-05-13 21:04:45 +07:00
. attachment-chip { color : var ( -- cyan ); text-decoration : none ; border : 1 px solid var ( -- line ); border-radius : 14 px ; padding : 8 px 10 px ; font : 700 12 px / 1 "JetBrains Mono" , monospace ; background : rgba ( 0 , 229 , 255 , 0.06 ); }
. embed-card { border-left : 4 px solid var ( -- blue ); border-radius : 16 px ; padding : 12 px ; background : rgba ( 98 , 117 , 255 , 0.08 ); display : grid ; gap : 8 px ; }
. embed-title { font-weight : 900 ; color : var ( -- text ); }
. embed-description { color : var ( -- muted ); line-height : 1.5 ; white-space : pre-wrap ; }
. embed-image { max-width : 360 px ; width : 100 % ; border-radius : 14 px ; border : 1 px solid var ( -- line ); }
2026-05-13 20:52:37 +07:00
. badges { display : flex ; gap : 8 px ; flex-wrap : wrap ; }
. badge {
border : 1 px solid var ( -- line );
border-radius : 999 px ;
padding : 5 px 9 px ;
color : var ( -- muted );
font : 700 10 px / 1 "JetBrains Mono" , monospace ;
text-transform : uppercase ;
}
. badge . edit { color : var ( -- yellow ); border-color : rgba ( 255 , 228 , 94 , 0.36 ); }
. badge . delete { color : var ( -- red ); border-color : rgba ( 255 , 79 , 109 , 0.42 ); }
. image-grid {
display : grid ;
grid-template-columns : repeat ( auto - fill , minmax ( 220 px , 1 fr ));
gap : 14 px ;
}
. image-card {
overflow : hidden ;
border : 1 px solid var ( -- line );
border-radius : 22 px ;
background : rgba ( 255 , 255 , 255 , 0.035 );
}
. image-preview {
height : 180 px ;
display : grid ;
place-items : center ;
background : #05070b ;
color : var ( -- faint );
font : 700 12 px / 1 "JetBrains Mono" , monospace ;
}
. image-preview img { width : 100 % ; height : 100 % ; object-fit : cover ; }
. image-meta { padding : 14 px ; display : grid ; gap : 8 px ; }
. filename { font-size : 13 px ; font-weight : 900 ; word-break : break-word ; }
. link { color : var ( -- cyan ); text-decoration : none ; font-weight : 900 ; }
. link : hover { text-decoration : underline ; }
. empty {
padding : 34 px ;
text-align : center ;
color : var ( -- faint );
border : 1 px dashed var ( -- line );
border-radius : 22 px ;
}
. error {
display : none ;
margin-bottom : 14 px ;
padding : 12 px 14 px ;
color : #ffd8df ;
border : 1 px solid rgba ( 255 , 79 , 109 , 0.5 );
border-radius : 16 px ;
background : rgba ( 255 , 79 , 109 , 0.12 );
}
@ media ( max-width : 980px ) {
. hero , . voice-layout { grid-template-columns : 1 fr ; }
. tab-panel { align-items : stretch ; flex-direction : column ; }
. filter-row { align-items : stretch ; flex-direction : column ; }
select { width : 100 % ; min-width : 0 ; }
}
</ style >
2026-05-13 00:32:27 +07:00
</ head >
< body >
2026-05-13 20:52:37 +07:00
< main class = "shell" >
< section class = "hero" >
< div class = "brand-card" >
< div class = "eyebrow" >< span class = "pulse" ></ span > Discord moderation command center</ div >
< h1 > Voice. Text. Image. One Watch Floor.</ h1 >
< p class = "subtitle" > Single-page watcher for live voice bridge, captured messages, and uploaded image evidence. Built for dense moderation review without jumping between pages.</ p >
</ div >
< div class = "status-card" >
< div class = "status-row" >< span class = "status-label" > WebSocket</ span >< span class = "status-value" >< span id = "wsDot" class = "dot" ></ span >< span id = "wsStatusText" > Connecting</ span ></ span ></ div >
< div class = "status-row" >< span class = "status-label" > Voice Link</ span >< span id = "voiceStatusText" class = "status-value" > Not connected</ span ></ div >
< div class = "status-row" >< span class = "status-label" > Active Tab</ span >< span id = "activeTabLabel" class = "status-value" > Voice</ span ></ div >
</ div >
</ section >
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
< nav class = "tab-panel" >
< div class = "tabs" >
< button class = "tab-btn active" data-tab = "voice" > Voice</ button >
< button class = "tab-btn" data-tab = "text" > Text</ button >
< button class = "tab-btn" data-tab = "images" > Images</ button >
</ div >
< div class = "filter-row" >
< span > Channel / Thread</ span >
< select id = "channelFilter" >< option value = "" > Select channel</ option ></ select >
</ div >
</ nav >
< div id = "errorBox" class = "error" ></ div >
< section id = "voice" class = "tab-content active" >
< div class = "voice-layout" >
< div class = "content-card" >
< div class = "card-title" >< h2 > Voice Control</ h2 >< span class = "mini" > bridge</ span ></ div >
< div class = "field-group" >
< label for = "guildSelect" > Guild</ label >
< select id = "guildSelect" ></ select >
</ div >
< div class = "field-group" >
< label for = "channelSelect" > Voice Channel</ label >
< select id = "channelSelect" ></ select >
</ div >
< div class = "button-row" >
< button id = "joinVoiceBtn" class = "btn btn-success" > Join</ button >
< button id = "disconnectVoiceBtn" class = "btn btn-danger" > Disconnect</ button >
</ div >
< div class = "voice-status" id = "voiceStatusNote" > Idle</ div >
2026-05-13 18:23:20 +07:00
</ div >
2026-05-13 20:52:37 +07:00
< div class = "content-card" >
< div class = "card-title" >< h2 > Live Audio</ h2 >< span class = "mini" id = "listenStatus" > speaker off</ span ></ div >
< div style = "display:grid; gap:12px; grid-template-columns: 1fr 1fr; margin-bottom:14px;" >
2026-05-13 02:30:09 +07:00
< button id = "toggleBtn" class = "btn btn-primary" > Start Transmitting</ button >
< button id = "listenBtn" class = "btn btn-success" > Join Listen Channel</ button >
2026-05-13 20:52:37 +07:00
</ div >
< div class = "visualizer" id = "visualizer" ></ div >
2026-05-13 00:32:27 +07:00
</ div >
2026-05-13 20:52:37 +07:00
</ div >
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
< div class = "content-card" style = "margin-top:18px;" >
< div class = "card-title" >< h2 > Participants</ h2 >< span class = "mini" > speaking now</ span ></ div >
< div id = "userList" class = "participants" ></ div >
</ div >
</ section >
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
< section id = "text" class = "tab-content" >
< div class = "content-card" >
< div class = "card-title" >< h2 > Text Watch</ h2 >< span class = "mini" > create / edit / delete</ span ></ div >
< div id = "textList" class = "feed" ></ div >
</ div >
</ section >
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
< section id = "images" class = "tab-content" >
< div class = "content-card" >
< div class = "card-title" >< h2 > Image Watch</ h2 >< span class = "mini" > picser raw commit</ span ></ div >
< div id = "imageGrid" class = "image-grid" ></ div >
</ div >
</ section >
</ main >
< script >
const state = {
socket : null ,
activeTab : 'voice' ,
selectedChannel : '' ,
text : [],
images : [],
isStreaming : false ,
isListening : false ,
audioContextTransmit : null ,
audioContextListen : null ,
processor : null ,
nextStartTime : 0 ,
noiseGateHold : 0 ,
};
const SAMPLE_RATE = 24000 ;
const NOISE_GATE_THRESHOLD = 0.01 ;
const NOISE_GATE_HOLD_FRAMES = 3 ;
const el = {
wsDot : document . getElementById ( 'wsDot' ),
wsStatusText : document . getElementById ( 'wsStatusText' ),
activeTabLabel : document . getElementById ( 'activeTabLabel' ),
errorBox : document . getElementById ( 'errorBox' ),
guildSelect : document . getElementById ( 'guildSelect' ),
channelSelect : document . getElementById ( 'channelSelect' ),
channelFilter : document . getElementById ( 'channelFilter' ),
joinVoiceBtn : document . getElementById ( 'joinVoiceBtn' ),
disconnectVoiceBtn : document . getElementById ( 'disconnectVoiceBtn' ),
voiceStatusText : document . getElementById ( 'voiceStatusText' ),
voiceStatusNote : document . getElementById ( 'voiceStatusNote' ),
toggleBtn : document . getElementById ( 'toggleBtn' ),
listenBtn : document . getElementById ( 'listenBtn' ),
listenStatus : document . getElementById ( 'listenStatus' ),
visualizer : document . getElementById ( 'visualizer' ),
userList : document . getElementById ( 'userList' ),
textList : document . getElementById ( 'textList' ),
imageGrid : document . getElementById ( 'imageGrid' ),
};
for ( let i = 0 ; i < 32 ; i ++ ) {
const bar = document . createElement ( 'div' );
bar . className = 'bar' ;
el . visualizer . appendChild ( bar );
}
const bars = [... document . querySelectorAll ( '.bar' )];
async function apiRequest ( url , options = {}) {
const response = await fetch ( url , {
headers : { 'Content-Type' : 'application/json' , ...( options . headers || {}) },
... options ,
});
if ( ! response . ok ) {
const error = await response . json (). catch (() => ({ message : response . statusText }));
throw new Error ( error . message || response . statusText );
}
return response . json ();
}
function showError ( message ) {
el . errorBox . textContent = message ;
el . errorBox . style . display = 'block' ;
setTimeout (() => { el . errorBox . style . display = 'none' ; }, 4500 );
}
function renderOptions ( select , items , placeholder ) {
select . replaceChildren ();
const first = document . createElement ( 'option' );
first . value = '' ;
first . textContent = placeholder ;
select . appendChild ( first );
for ( const item of items ) {
const option = document . createElement ( 'option' );
option . value = item . id ;
option . textContent = item . name ;
select . appendChild ( option );
}
}
async function loadGuilds () {
const guilds = await apiRequest ( '/api/guilds' );
renderOptions ( el . guildSelect , guilds , 'Select guild' );
if ( guilds . length > 0 ) {
el . guildSelect . value = guilds [ 0 ]. id ;
await loadChannels ( guilds [ 0 ]. id );
}
}
async function loadChannels ( guildId ) {
const [ voiceChannels , watchChannels ] = await Promise . all ([
apiRequest ( `/api/guilds/ ${ guildId } /voice-channels` ),
apiRequest ( `/api/guilds/ ${ guildId } /channels` ),
]);
renderOptions ( el . channelSelect , voiceChannels , 'Select voice channel' );
renderOptions ( el . channelFilter , watchChannels , 'Select channel or thread' );
}
async function refreshStatus () {
try {
const status = await apiRequest ( '/api/status' );
el . voiceStatusText . textContent = status . connected ? status . activeChannelName || 'Connected' : 'Not connected' ;
el . voiceStatusNote . textContent = status . connected ? `Connected to ${ status . activeChannelName } ` : 'Idle' ;
} catch ( error ) {
showError ( error . message );
}
}
async function connectVoice () {
const guildId = el . guildSelect . value ;
const channelId = el . channelSelect . value ;
if ( ! guildId || ! channelId ) return showError ( 'Select guild and voice channel first' );
const status = await apiRequest ( '/api/connect' , { method : 'POST' , body : JSON . stringify ({ guildId , channelId }) });
el . voiceStatusText . textContent = status . activeChannelName || 'Connected' ;
el . voiceStatusNote . textContent = `Connected to ${ status . activeChannelName } ` ;
}
async function disconnectVoice () {
await apiRequest ( '/api/disconnect' , { method : 'POST' });
await refreshStatus ();
}
function connectWebSocket () {
const protocol = window . location . protocol === 'https:' ? 'wss:' : 'ws:' ;
state . socket = new WebSocket ( ` ${ protocol } // ${ window . location . host } /ws` );
state . socket . binaryType = 'arraybuffer' ;
state . socket . onopen = () => {
el . wsDot . classList . add ( 'on' );
el . wsStatusText . textContent = 'Connected' ;
};
state . socket . onclose = () => {
el . wsDot . classList . remove ( 'on' );
el . wsStatusText . textContent = 'Reconnecting' ;
setTimeout ( connectWebSocket , 2500 );
};
state . socket . onerror = () => {
el . wsDot . classList . remove ( 'on' );
el . wsDot . classList . add ( 'warn' );
el . wsStatusText . textContent = 'Socket error' ;
};
state . socket . onmessage = ( event ) => {
if ( typeof event . data === 'string' ) {
handleJsonEvent ( event . data );
return ;
2026-05-13 00:32:27 +07:00
}
2026-05-13 20:52:37 +07:00
if ( state . isListening ) playPcm ( event . data );
};
}
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
function handleJsonEvent ( raw ) {
const message = JSON . parse ( raw );
if ( message . type === 'user_state' ) return renderUsers ( message . users || []);
if ( message . type === 'message_created' ) {
state . text . unshift ( message . data );
renderText ();
}
if ( message . type === 'message_updated' ) {
const item = state . text . find (( entry ) => entry . id === message . data . id );
if ( item ) Object . assign ( item , { edited_content : message . data . edited_content , edited_at : message . data . edited_at , type : 'edited' });
renderText ();
}
if ( message . type === 'message_deleted' ) {
const item = state . text . find (( entry ) => entry . id === message . data . id );
if ( item ) Object . assign ( item , { deleted_at : message . data . deleted_at , type : 'deleted' });
renderText ();
}
if ( message . type === 'attachment_uploaded' ) fetchImages ();
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
function renderUsers ( users ) {
el . userList . replaceChildren ();
if ( users . length === 0 ) {
const empty = document . createElement ( 'div' );
empty . className = 'empty' ;
empty . textContent = 'No active speakers' ;
el . userList . appendChild ( empty );
return ;
}
for ( const user of users ) {
const row = document . createElement ( 'div' );
row . className = `user-item ${ user . speaking ? ' speaking' : '' } ` ;
const img = document . createElement ( 'img' );
img . src = user . avatar || '' ;
img . alt = '' ;
const name = document . createElement ( 'span' );
name . textContent = user . username ;
row . append ( img , name );
el . userList . appendChild ( row );
}
}
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
async function fetchText () {
if ( ! state . selectedChannel ) return renderText ();
const result = await apiRequest ( `/api/messages?channel= ${ encodeURIComponent ( state . selectedChannel ) } &type=text&limit=80` );
state . text = result . data || [];
renderText ();
}
async function fetchImages () {
if ( ! state . selectedChannel ) return renderImages ();
const result = await apiRequest ( `/api/messages?channel= ${ encodeURIComponent ( state . selectedChannel ) } &type=image&limit=80` );
state . images = result . data || [];
renderImages ();
}
function parseMetadata ( value ) {
if ( ! value ) return {};
try { return JSON . parse ( value ); } catch { return {}; }
}
function renderText () {
el . textList . replaceChildren ();
if ( ! state . selectedChannel ) return appendEmpty ( el . textList , 'Select channel to view text captures' );
if ( state . text . length === 0 ) return appendEmpty ( el . textList , 'No text captures yet' );
for ( const msg of state . text ) {
const metadata = parseMetadata ( msg . metadata );
const card = document . createElement ( 'article' );
card . className = 'event-card' ;
const head = document . createElement ( 'div' );
head . className = 'event-head' ;
const author = document . createElement ( 'div' );
author . className = 'author' ;
const avatar = document . createElement ( 'div' );
avatar . className = 'avatar' ;
if ( msg . avatar_url ) {
const img = document . createElement ( 'img' );
img . src = msg . avatar_url ;
img . alt = '' ;
avatar . appendChild ( img );
2026-05-13 18:23:20 +07:00
}
2026-05-13 20:52:37 +07:00
const name = document . createElement ( 'div' );
name . className = 'name' ;
name . textContent = msg . username || msg . user_id ;
author . append ( avatar , name );
const time = document . createElement ( 'div' );
time . className = 'time' ;
time . textContent = new Date ( msg . created_at ). toLocaleString ();
head . append ( author , time );
const text = document . createElement ( 'div' );
text . className = 'message-text' ;
text . textContent = msg . edited_content || msg . content || '(empty message)' ;
2026-05-13 21:04:45 +07:00
const stickers = renderStickers ( metadata . stickers || []);
const embeds = renderEmbeds ( metadata . embeds || []);
const attachments = renderAttachments ( metadata . attachments || []);
2026-05-13 20:52:37 +07:00
const badges = document . createElement ( 'div' );
badges . className = 'badges' ;
2026-05-13 21:04:45 +07:00
if ( metadata . reference ? . messageId ) appendBadge ( badges , 'reply' , '' );
if ( msg . thread_id ) appendBadge ( badges , metadata . channel ? . threadName ? `thread: ${ metadata . channel . threadName } ` : 'thread' , '' );
2026-05-13 20:52:37 +07:00
if ( msg . edited_at ) appendBadge ( badges , 'edited' , 'edit' );
if ( msg . deleted_at ) appendBadge ( badges , 'deleted' , 'delete' );
card . append ( head , text );
if ( stickers . childElementCount > 0 ) card . appendChild ( stickers );
2026-05-13 21:04:45 +07:00
if ( embeds . childElementCount > 0 ) card . appendChild ( embeds );
if ( attachments . childElementCount > 0 ) card . appendChild ( attachments );
2026-05-13 20:52:37 +07:00
card . appendChild ( badges );
el . textList . appendChild ( card );
}
}
2026-05-13 18:23:20 +07:00
2026-05-13 21:04:45 +07:00
function renderStickers ( stickers ) {
const wrap = document . createElement ( 'div' );
wrap . className = 'sticker-strip' ;
for ( const sticker of stickers ) {
const img = document . createElement ( 'img' );
img . className = 'sticker-img' ;
img . src = sticker . url ;
img . alt = sticker . name ;
wrap . appendChild ( img );
}
return wrap ;
}
function renderEmbeds ( embeds ) {
const wrap = document . createElement ( 'div' );
wrap . className = 'feed' ;
for ( const embed of embeds ) {
const card = document . createElement ( 'div' );
card . className = 'embed-card' ;
if ( embed . title ) {
const title = document . createElement ( embed . url ? 'a' : 'div' );
title . className = 'embed-title' ;
title . textContent = embed . title ;
if ( embed . url ) {
title . href = embed . url ;
title . target = '_blank' ;
title . rel = 'noreferrer' ;
}
card . appendChild ( title );
}
if ( embed . description ) {
const desc = document . createElement ( 'div' );
desc . className = 'embed-description' ;
desc . textContent = embed . description ;
card . appendChild ( desc );
}
for ( const field of embed . fields || []) {
const fieldNode = document . createElement ( 'div' );
fieldNode . className = 'embed-description' ;
fieldNode . textContent = ` ${ field . name } : ${ field . value } ` ;
card . appendChild ( fieldNode );
}
if ( embed . image || embed . thumbnail ) {
const img = document . createElement ( 'img' );
img . className = 'embed-image' ;
img . src = embed . image || embed . thumbnail ;
img . alt = embed . title || 'embed image' ;
card . appendChild ( img );
}
wrap . appendChild ( card );
}
return wrap ;
}
function renderAttachments ( attachments ) {
const wrap = document . createElement ( 'div' );
wrap . className = 'attachment-strip' ;
for ( const attachment of attachments ) {
const link = document . createElement ( 'a' );
link . className = 'attachment-chip' ;
link . href = attachment . url ;
link . target = '_blank' ;
link . rel = 'noreferrer' ;
link . textContent = ` ${ attachment . name } ( ${ ( attachment . size / 1024 ). toFixed ( 1 ) } KB)` ;
wrap . appendChild ( link );
}
return wrap ;
}
2026-05-13 20:52:37 +07:00
function renderImages () {
el . imageGrid . replaceChildren ();
if ( ! state . selectedChannel ) return appendEmpty ( el . imageGrid , 'Select channel to view image captures' );
if ( state . images . length === 0 ) return appendEmpty ( el . imageGrid , 'No image captures yet' );
for ( const image of state . images ) {
const card = document . createElement ( 'article' );
card . className = 'image-card' ;
const preview = document . createElement ( 'div' );
preview . className = 'image-preview' ;
if ( image . uploaded_url ) {
const img = document . createElement ( 'img' );
img . src = image . uploaded_url ;
img . alt = image . filename ;
preview . appendChild ( img );
} else {
preview . textContent = image . upload_status || 'pending' ;
2026-05-13 18:23:20 +07:00
}
2026-05-13 20:52:37 +07:00
const meta = document . createElement ( 'div' );
meta . className = 'image-meta' ;
const filename = document . createElement ( 'div' );
filename . className = 'filename' ;
filename . textContent = image . filename ;
const info = document . createElement ( 'div' );
info . className = 'time' ;
info . textContent = ` ${ ( image . size / 1024 ). toFixed ( 1 ) } KB • ${ image . upload_status }${ image . thread_id ? ' • thread' : '' } ` ;
meta . append ( filename , info );
if ( image . uploaded_url ) {
const link = document . createElement ( 'a' );
link . className = 'link' ;
link . href = image . uploaded_url ;
link . target = '_blank' ;
link . rel = 'noreferrer' ;
link . textContent = 'open raw commit' ;
meta . appendChild ( link );
2026-05-13 18:23:20 +07:00
}
2026-05-13 20:52:37 +07:00
card . append ( preview , meta );
el . imageGrid . appendChild ( card );
}
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
function appendBadge ( parent , label , className ) {
const badge = document . createElement ( 'span' );
badge . className = `badge ${ className } ` ;
badge . textContent = label ;
parent . appendChild ( badge );
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
function appendEmpty ( parent , message ) {
const empty = document . createElement ( 'div' );
empty . className = 'empty' ;
empty . textContent = message ;
parent . appendChild ( empty );
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
async function startStreaming () {
try {
const stream = await navigator . mediaDevices . getUserMedia ({ audio : true });
state . audioContextTransmit = new AudioContext ({ sampleRate : SAMPLE_RATE });
const source = state . audioContextTransmit . createMediaStreamSource ( stream );
state . processor = state . audioContextTransmit . createScriptProcessor ( 2048 , 1 , 1 );
source . connect ( state . processor );
state . processor . connect ( state . audioContextTransmit . destination );
state . processor . onaudioprocess = ( event ) => {
if ( ! state . isStreaming || state . socket ? . readyState !== WebSocket . OPEN ) return ;
const input = event . inputBuffer . getChannelData ( 0 );
let sum = 0 ;
for ( let i = 0 ; i < input . length ; i ++ ) sum += input [ i ] * input [ i ];
const rms = Math . sqrt ( sum / input . length );
if ( rms < NOISE_GATE_THRESHOLD && state . noiseGateHold <= 0 ) return ;
state . noiseGateHold = rms >= NOISE_GATE_THRESHOLD ? NOISE_GATE_HOLD_FRAMES : state . noiseGateHold - 1 ;
const pcm = new Int16Array ( input . length );
for ( let i = 0 ; i < input . length ; i ++ ) pcm [ i ] = Math . max ( - 1 , Math . min ( 1 , input [ i ])) * 32767 ;
state . socket . send ( pcm . buffer );
updateVisualizer ( rms );
2026-05-13 18:23:20 +07:00
};
2026-05-13 20:52:37 +07:00
state . isStreaming = true ;
el . toggleBtn . textContent = 'Stop Transmitting' ;
} catch ( error ) {
showError ( `Microphone error: ${ error . message } ` );
}
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
function stopStreaming () {
state . isStreaming = false ;
state . processor ? . disconnect ();
state . audioContextTransmit ? . close ();
state . processor = null ;
state . audioContextTransmit = null ;
el . toggleBtn . textContent = 'Start Transmitting' ;
updateVisualizer ( 0 );
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
function toggleListen () {
state . isListening = ! state . isListening ;
if ( state . isListening ) {
state . audioContextListen = new AudioContext ({ sampleRate : 24000 });
state . nextStartTime = state . audioContextListen . currentTime ;
el . listenBtn . textContent = 'Leave Listen Channel' ;
el . listenStatus . textContent = 'speaker on' ;
} else {
state . audioContextListen ? . close ();
state . audioContextListen = null ;
el . listenBtn . textContent = 'Join Listen Channel' ;
el . listenStatus . textContent = 'speaker off' ;
}
}
2026-05-13 18:23:20 +07:00
2026-05-13 20:52:37 +07:00
function playPcm ( arrayBuffer ) {
if ( ! state . audioContextListen ) return ;
const bytes = new Uint8Array ( arrayBuffer );
if ( bytes . byteLength <= 4 ) return ;
const pcm = new Int16Array ( bytes . buffer , bytes . byteOffset + 4 , ( bytes . byteLength - 4 ) / 2 );
const audioBuffer = state . audioContextListen . createBuffer ( 1 , pcm . length , 24000 );
const channel = audioBuffer . getChannelData ( 0 );
for ( let i = 0 ; i < pcm . length ; i ++ ) channel [ i ] = pcm [ i ] / 32768 ;
const source = state . audioContextListen . createBufferSource ();
source . buffer = audioBuffer ;
source . connect ( state . audioContextListen . destination );
const startAt = Math . max ( state . nextStartTime , state . audioContextListen . currentTime );
source . start ( startAt );
state . nextStartTime = startAt + audioBuffer . duration ;
}
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
function updateVisualizer ( level ) {
bars . forEach (( bar , index ) => {
const wave = Math . sin ( index * 0.55 + Date . now () / 140 ) * 0.35 + 0.65 ;
bar . style . height = ` ${ Math . max ( 3 , level * 190 * wave ) } px` ;
});
}
2026-05-13 17:56:08 +07:00
2026-05-13 20:52:37 +07:00
document . querySelectorAll ( '.tab-btn' ). forEach (( button ) => {
button . addEventListener ( 'click' , async () => {
document . querySelectorAll ( '.tab-btn' ). forEach (( item ) => item . classList . remove ( 'active' ));
document . querySelectorAll ( '.tab-content' ). forEach (( item ) => item . classList . remove ( 'active' ));
button . classList . add ( 'active' );
state . activeTab = button . dataset . tab ;
document . getElementById ( state . activeTab ). classList . add ( 'active' );
el . activeTabLabel . textContent = button . textContent ;
if ( state . activeTab === 'text' ) await fetchText ();
if ( state . activeTab === 'images' ) await fetchImages ();
});
});
2026-05-13 02:30:09 +07:00
2026-05-13 20:52:37 +07:00
el . guildSelect . addEventListener ( 'change' , () => loadChannels ( el . guildSelect . value ). catch (( error ) => showError ( error . message )));
el . joinVoiceBtn . addEventListener ( 'click' , () => connectVoice (). catch (( error ) => showError ( error . message )));
el . disconnectVoiceBtn . addEventListener ( 'click' , () => disconnectVoice (). catch (( error ) => showError ( error . message )));
el . toggleBtn . addEventListener ( 'click' , () => state . isStreaming ? stopStreaming () : startStreaming ());
el . listenBtn . addEventListener ( 'click' , toggleListen );
el . channelFilter . addEventListener ( 'change' , async () => {
state . selectedChannel = el . channelFilter . value ;
await Promise . all ([ fetchText (), fetchImages ()]). catch (( error ) => showError ( error . message ));
});
2026-05-13 00:32:27 +07:00
2026-05-13 20:52:37 +07:00
connectWebSocket ();
loadGuilds (). then ( refreshStatus ). catch (( error ) => showError ( error . message ));
setInterval (() => {
if ( state . activeTab === 'text' ) fetchText (). catch (() => {});
if ( state . activeTab === 'images' ) fetchImages (). catch (() => {});
}, 7000 );
</ script >
2026-05-13 00:32:27 +07:00
</ body >
</ html >