7878 .heat th .hh { font-size : 8px ; }
7979 .heat td .cell { border-radius : 2px ; outline : 1px solid # 14151a ; }
8080 .heat td .day { width : 30px ; color : # 9a9ba2 ; font-size : 10px ; text-align : right; padding-right : 5px ; }
81+ /* Settings gear + dropdown */
82+ .gear { background : none; border : none; color : # 9a9ba2 ; font-size : 20px ; padding : 0 4px ; line-height : 1 ; cursor : pointer; }
83+ .menu { position : absolute; right : 0 ; top : 26px ; background : # 1f2128 ; border : 1px solid # 363842 ; border-radius : 10px ; min-width : 170px ; z-index : 20 ; overflow : hidden; }
84+ .menu div { padding : 11px 14px ; cursor : pointer; }
85+ .menu div : hover { background : # 2a2c33 ; }
86+ .item { display : flex; justify-content : space-between; align-items : center; gap : 10px ; padding : 10px 12px ; border : 1px solid # 2a2c33 ; border-radius : 10px ; margin-bottom : 8px ; background : # 1b1d23 ; }
87+ .item .nm { font-weight : 600 ; }
88+ button .small { padding : 6px 10px ; font-size : 13px ; }
89+ button .danger { background : # 7a2b2b ; }
8190</ style >
8291</ head >
8392< body >
8493< header >
85- < h1 > Abuse Reports</ h1 >
86- < div class ="sub " id ="hdr-sub "> Locked</ div >
94+ < div style ="display:flex;justify-content:space-between;align-items:flex-start;gap:10px ">
95+ < div >
96+ < h1 > Abuse Reports</ h1 >
97+ < div class ="sub " id ="hdr-sub "> Locked</ div >
98+ </ div >
99+ < div style ="position:relative ">
100+ < button class ="gear hidden " id ="gear-btn " title ="Settings "> ⚙</ button >
101+ < div class ="menu hidden " id ="gear-menu ">
102+ < div data-open ="keys "> API keys</ div >
103+ < div data-open ="sources "> Sources</ div >
104+ </ div >
105+ </ div >
106+ </ div >
87107</ header >
88108< main >
89109 <!-- Global page password gate -->
@@ -107,6 +127,7 @@ <h1>Abuse Reports</h1>
107127 < label for ="target "> Create a new report</ label >
108128 < div class ="row ">
109129 < textarea id ="target " rows ="1 " placeholder ="ID, @username, file or URLs " style ="flex:1;min-width:180px "> </ textarea >
130+ < select id ="create-source " title ="Source "> </ select >
110131 < button id ="create-btn "> Create</ button >
111132 </ div >
112133 < div class ="err " id ="create-err "> </ div >
@@ -169,13 +190,48 @@ <h1>Abuse Reports</h1>
169190 < div class ="row " id ="st-lang-chips " style ="justify-content:center "> </ div >
170191 </ div >
171192
193+ < div class ="card ">
194+ < b > Sources</ b >
195+ < div style ="display:flex;justify-content:center;margin:12px 0 ">
196+ < svg id ="st-src-pie " viewBox ="0 0 200 200 " width ="220 " height ="220 " role ="img "> </ svg >
197+ </ div >
198+ < div class ="row " id ="st-src-legend " style ="justify-content:center "> </ div >
199+ </ div >
200+
172201 < div class ="card ">
173202 < b > Posting Time</ b >
174203 < div class ="heat " id ="st-heat " style ="margin-top:10px "> </ div >
175204 </ div >
176205
177206 < div class ="err " id ="st-err "> </ div >
178207 </ div >
208+
209+ <!-- ============ API KEYS ============ -->
210+ < div id ="pane-keys " class ="hidden ">
211+ < div class ="card ">
212+ < label for ="key-name "> New API key</ label >
213+ < div class ="row ">
214+ < input type ="text " id ="key-name " placeholder ="e.g. cloudflare-feed " style ="flex:1;min-width:160px ">
215+ < button id ="key-add "> Create</ button >
216+ </ div >
217+ < div class ="err " id ="key-err "> </ div >
218+ < div id ="key-new " class ="hidden " style ="margin-top:10px "> </ div >
219+ </ div >
220+ < div class ="card "> < div id ="key-list "> </ div > </ div >
221+ </ div >
222+
223+ <!-- ============ SOURCES ============ -->
224+ < div id ="pane-sources " class ="hidden ">
225+ < div class ="card ">
226+ < label for ="src-name "> New source</ label >
227+ < div class ="row ">
228+ < input type ="text " id ="src-name " placeholder ="e.g. cybertip.ca " style ="flex:1;min-width:160px ">
229+ < button id ="src-add "> Create</ button >
230+ </ div >
231+ < div class ="err " id ="src-err "> </ div >
232+ </ div >
233+ < div class ="card "> < div id ="src-list "> </ div > </ div >
234+ </ div >
179235 </ div >
180236</ main >
181237
@@ -205,7 +261,9 @@ <h1>Abuse Reports</h1>
205261 try { sessionStorage . setItem ( "ris_report_pw" , P2 ) ; } catch ( e ) { }
206262 $ ( "unlock-card" ) . classList . add ( "hidden" ) ;
207263 $ ( "body" ) . classList . remove ( "hidden" ) ;
264+ $ ( "gear-btn" ) . classList . remove ( "hidden" ) ;
208265 $ ( "hdr-sub" ) . classList . add ( "hidden" ) ;
266+ loadSources ( ) . catch ( ( ) => { } ) ;
209267 } catch ( e ) { $ ( "unlock-err" ) . textContent = "" + e . message ; P2 = "" ; }
210268} ;
211269
@@ -282,7 +340,7 @@ <h1>Abuse Reports</h1>
282340 try {
283341 const r = await api ( "/api/create" , {
284342 method : "POST" , headers : { "Content-Type" : "application/json" } ,
285- body : JSON . stringify ( { target } ) ,
343+ body : JSON . stringify ( { target, source : $ ( "create-source" ) . value } ) ,
286344 } ) ;
287345 const j = await r . json ( ) ;
288346 // One entry per unique uploader found in the input.
@@ -352,17 +410,148 @@ <h1>Abuse Reports</h1>
352410 ta . remove ( ) ;
353411} ) ;
354412
355- // ============ Tabs ============
356- document . querySelectorAll ( ".tab" ) . forEach ( t => {
357- t . onclick = ( ) => {
358- document . querySelectorAll ( ".tab" ) . forEach ( x => x . classList . toggle ( "active" , x === t ) ) ;
359- const isStats = t . dataset . tab === "stats" ;
360- $ ( "pane-reports" ) . classList . toggle ( "hidden" , isStats ) ;
361- $ ( "pane-stats" ) . classList . toggle ( "hidden" , ! isStats ) ;
362- if ( isStats ) loadStats ( ) . catch ( e => { $ ( "st-err" ) . textContent = "" + e . message ; } ) ;
363- } ;
413+ // ============ Tabs / panes ============
414+ // Four panes share the body; the two tabs cover reports+stats, the gear menu
415+ // opens keys/sources. showPane() is the single place any of them is switched.
416+ const PANES = [ "reports" , "stats" , "keys" , "sources" ] ;
417+
418+ function showPane ( name ) {
419+ PANES . forEach ( p => $ ( "pane-" + p ) . classList . toggle ( "hidden" , p !== name ) ) ;
420+ document . querySelectorAll ( ".tabs" ) [ 0 ] . classList . toggle ( "hidden" , name === "keys" || name === "sources" ) ;
421+ document . querySelectorAll ( ".tab" ) . forEach ( t => t . classList . toggle ( "active" , t . dataset . tab === name ) ) ;
422+ if ( name === "stats" ) loadStats ( ) . catch ( e => { $ ( "st-err" ) . textContent = "" + e . message ; } ) ;
423+ if ( name === "keys" ) loadKeys ( ) . catch ( e => { $ ( "key-err" ) . textContent = "" + e . message ; } ) ;
424+ if ( name === "sources" ) loadSources ( ) . catch ( e => { $ ( "src-err" ) . textContent = "" + e . message ; } ) ;
425+ }
426+
427+ document . querySelectorAll ( ".tab" ) . forEach ( t => { t . onclick = ( ) => showPane ( t . dataset . tab ) ; } ) ;
428+
429+ $ ( "gear-btn" ) . onclick = ev => { ev . stopPropagation ( ) ; $ ( "gear-menu" ) . classList . toggle ( "hidden" ) ; } ;
430+ document . addEventListener ( "click" , ev => {
431+ const item = ev . target . closest ( "#gear-menu div" ) ;
432+ if ( item ) showPane ( item . dataset . open ) ;
433+ if ( ! ev . target . closest ( "#gear-btn" ) ) $ ( "gear-menu" ) . classList . add ( "hidden" ) ;
364434} ) ;
365435
436+ // ============ Sources ============
437+ let SOURCES = [ ] ;
438+ let DEFAULT_SOURCE = "sweep" ;
439+
440+ async function loadSources ( ) {
441+ const j = await ( await api ( "/api/sources" ) ) . json ( ) ;
442+ SOURCES = j . sources ;
443+ DEFAULT_SOURCE = j . default ;
444+ renderSources ( ) ;
445+ fillSourceSelect ( ) ;
446+ }
447+
448+ // Keep the create dropdown on whatever was selected; default otherwise.
449+ function fillSourceSelect ( ) {
450+ const sel = $ ( "create-source" ) ;
451+ const keep = sel . value ;
452+ sel . innerHTML = SOURCES . map ( s => `<option value="${ escapeHtml ( s . name ) } ">${ escapeHtml ( s . name ) } </option>` ) . join ( "" ) ;
453+ sel . value = SOURCES . some ( s => s . name === keep ) ? keep : DEFAULT_SOURCE ;
454+ }
455+
456+ function renderSources ( ) {
457+ const box = $ ( "src-list" ) ;
458+ box . innerHTML = "" ;
459+ for ( const s of SOURCES ) {
460+ const el = document . createElement ( "div" ) ;
461+ el . className = "item" ;
462+ el . innerHTML = `<div><div class="nm">${ escapeHtml ( s . name ) } </div>` +
463+ `<div class="muted">${ s . reports } report(s) · ${ fmtTs ( s . created_at ) } </div></div>` ;
464+ // The default source is permanent, and a used one is kept so the statistics
465+ // stay attributable — the server enforces both; no button is offered.
466+ if ( s . name !== DEFAULT_SOURCE && ! s . reports ) {
467+ const b = document . createElement ( "button" ) ;
468+ b . className = "small danger" ;
469+ b . textContent = "Delete" ;
470+ b . onclick = ( ) => sourceAction ( { action : "delete" , id : s . id } ) ;
471+ el . appendChild ( b ) ;
472+ }
473+ box . appendChild ( el ) ;
474+ }
475+ }
476+
477+ async function sourceAction ( body ) {
478+ $ ( "src-err" ) . textContent = "" ;
479+ try {
480+ const j = await ( await api ( "/api/sources" , {
481+ method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( body ) ,
482+ } ) ) . json ( ) ;
483+ SOURCES = j . sources ;
484+ DEFAULT_SOURCE = j . default ;
485+ renderSources ( ) ;
486+ fillSourceSelect ( ) ;
487+ } catch ( e ) { $ ( "src-err" ) . textContent = "" + e . message ; }
488+ }
489+
490+ $ ( "src-add" ) . onclick = async ( ) => {
491+ const name = $ ( "src-name" ) . value . trim ( ) ;
492+ if ( ! name ) { $ ( "src-err" ) . textContent = "Enter a name." ; return ; }
493+ await sourceAction ( { action : "add" , name } ) ;
494+ $ ( "src-name" ) . value = "" ;
495+ } ;
496+
497+ // ============ API keys ============
498+ async function loadKeys ( newKey ) {
499+ const j = await ( await api ( "/api/keys" ) ) . json ( ) ;
500+ renderKeys ( j . keys , newKey ) ;
501+ }
502+
503+ function renderKeys ( keys , newKey ) {
504+ // The key itself only ever exists in this one response — show it until the
505+ // pane is reloaded, then it is gone for good.
506+ const nb = $ ( "key-new" ) ;
507+ nb . classList . toggle ( "hidden" , ! newKey ) ;
508+ if ( newKey ) {
509+ nb . innerHTML = `<div>Copy it now — it is not shown again.</div>` +
510+ `<div style="margin-top:4px"><code class="copyable" data-copy="${ escapeHtml ( newKey ) } ">${ escapeHtml ( newKey ) } </code></div>` ;
511+ }
512+ const box = $ ( "key-list" ) ;
513+ box . innerHTML = keys . length ? "" : `<div class="muted">No API keys yet.</div>` ;
514+ for ( const k of keys ) {
515+ const used = k . last_used_at ? ` · used ${ fmtTs ( k . last_used_at ) } ` : "" ;
516+ const rotated = k . rotated_at ? ` · rotated ${ fmtTs ( k . rotated_at ) } ` : "" ;
517+ const el = document . createElement ( "div" ) ;
518+ el . className = "item" ;
519+ el . innerHTML = `<div><div class="nm">${ escapeHtml ( k . name ) } </div>` +
520+ `<div class="muted"><code>${ escapeHtml ( k . key_preview ) } </code> · ${ fmtTs ( k . created_at ) } ${ rotated } ${ used } </div></div>` ;
521+ const row = document . createElement ( "div" ) ;
522+ row . className = "row" ;
523+ for ( const [ label , body , cls ] of [
524+ [ "Revoke" , { action : "rotate" , id : k . id } , "small ghost" ] ,
525+ [ "Delete" , { action : "delete" , id : k . id } , "small danger" ] ,
526+ ] ) {
527+ const b = document . createElement ( "button" ) ;
528+ b . className = cls ;
529+ b . textContent = label ;
530+ b . onclick = ( ) => keyAction ( body ) ;
531+ row . appendChild ( b ) ;
532+ }
533+ el . appendChild ( row ) ;
534+ box . appendChild ( el ) ;
535+ }
536+ }
537+
538+ async function keyAction ( body ) {
539+ $ ( "key-err" ) . textContent = "" ;
540+ try {
541+ const j = await ( await api ( "/api/keys" , {
542+ method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( body ) ,
543+ } ) ) . json ( ) ;
544+ renderKeys ( j . keys , j . new_key ) ;
545+ } catch ( e ) { $ ( "key-err" ) . textContent = "" + e . message ; }
546+ }
547+
548+ $ ( "key-add" ) . onclick = async ( ) => {
549+ const name = $ ( "key-name" ) . value . trim ( ) ;
550+ if ( ! name ) { $ ( "key-err" ) . textContent = "Enter a name." ; return ; }
551+ await keyAction ( { action : "add" , name } ) ;
552+ $ ( "key-name" ) . value = "" ;
553+ } ;
554+
366555// ============ Statistics ============
367556let STATS = null ; // raw records from the server (filed only)
368557const MONTHS = [ "Jan" , "Feb" , "Mar" , "Apr" , "May" , "Jun" , "Jul" , "Aug" , "Sep" , "Oct" , "Nov" , "Dec" ] ;
@@ -435,6 +624,7 @@ <h1>Abuse Reports</h1>
435624 $ ( "st-reports" ) . textContent = recs . length ;
436625 $ ( "st-users" ) . textContent = new Set ( recs . map ( r => r . user_id ) ) . size ;
437626 renderLangPie ( recs ) ;
627+ renderSourcePie ( recs ) ;
438628 renderHeatmap ( recs ) ;
439629}
440630
@@ -525,15 +715,19 @@ <h1>Abuse Reports</h1>
525715
526716// Render the pie from the current PIE_CUR fractions (normalized to fill the circle).
527717function paintPie ( langs , color ) {
528- const svg = $ ( "st-pie" ) ;
718+ $ ( "st-pie" ) . innerHTML = pieSvg ( langs . map ( k => [ k , PIE_CUR [ k ] || 0 ] ) , color , fmtLang ) ;
719+ }
720+
721+ // Pie markup for [key, weight] pairs — shared by the animated language pie and
722+ // the static source pie.
723+ function pieSvg ( pairs , color , label ) {
529724 const cx = 100 , cy = 100 , rad = 96 ;
530- const parts = langs . map ( k => [ k , PIE_CUR [ k ] || 0 ] ) . filter ( ( [ , f ] ) => f > 0.0005 ) ;
725+ const parts = pairs . filter ( ( [ , f ] ) => f > 0.0005 ) ;
531726 const sum = parts . reduce ( ( s , [ , f ] ) => s + f , 0 ) ;
532- if ( sum < 0.0005 ) { svg . innerHTML = `<circle cx="100" cy="100" r="96" fill="#1b1d23"/>` ; return ; }
727+ if ( sum < 0.0005 ) return `<circle cx="100" cy="100" r="96" fill="#1b1d23"/>` ;
533728 // A single slice at ~100% can't be drawn as an arc (start==end) — use a circle.
534729 if ( parts . length === 1 ) {
535- svg . innerHTML = `<circle cx="100" cy="100" r="96" fill="${ color [ parts [ 0 ] [ 0 ] ] } "/>` + pieLabel ( cx , cy - 40 , "100%" ) ;
536- return ;
730+ return `<circle cx="100" cy="100" r="96" fill="${ color [ parts [ 0 ] [ 0 ] ] } "/>` + pieLabel ( cx , cy - 40 , "100%" ) ;
537731 }
538732 let angle = - Math . PI / 2 ; // 12 o'clock
539733 let out = "" ;
@@ -544,15 +738,29 @@ <h1>Abuse Reports</h1>
544738 const large = frac > 0.5 ? 1 : 0 ;
545739 const x1 = cx + rad * Math . cos ( angle ) , y1 = cy + rad * Math . sin ( angle ) ;
546740 const x2 = cx + rad * Math . cos ( end ) , y2 = cy + rad * Math . sin ( end ) ;
547- out += `<path d="M${ cx } ,${ cy } L${ x1 . toFixed ( 2 ) } ,${ y1 . toFixed ( 2 ) } A${ rad } ,${ rad } 0 ${ large } ,1 ${ x2 . toFixed ( 2 ) } ,${ y2 . toFixed ( 2 ) } Z" fill="${ color [ k ] } "><title>${ escapeHtml ( fmtLang ( k ) ) } : ${ Math . round ( frac * 100 ) } %</title></path>` ;
741+ out += `<path d="M${ cx } ,${ cy } L${ x1 . toFixed ( 2 ) } ,${ y1 . toFixed ( 2 ) } A${ rad } ,${ rad } 0 ${ large } ,1 ${ x2 . toFixed ( 2 ) } ,${ y2 . toFixed ( 2 ) } Z" fill="${ color [ k ] } "><title>${ escapeHtml ( label ( k ) ) } : ${ Math . round ( frac * 100 ) } %</title></path>` ;
548742 if ( frac >= 0.06 ) {
549743 const mid = angle + frac * Math . PI ;
550744 const lr = rad * 0.6 ;
551745 labels . push ( pieLabel ( cx + lr * Math . cos ( mid ) , cy + lr * Math . sin ( mid ) , `${ Math . round ( frac * 100 ) } %` ) ) ;
552746 }
553747 angle = end ;
554748 }
555- svg . innerHTML = out + labels . join ( "" ) ;
749+ return out + labels . join ( "" ) ;
750+ }
751+
752+ // Static pie of filed reports per source, with a plain (non-toggle) legend.
753+ function renderSourcePie ( recs ) {
754+ const counts = { } ;
755+ for ( const r of recs ) { const k = r . source || "—" ; counts [ k ] = ( counts [ k ] || 0 ) + 1 ; }
756+ const names = Object . keys ( counts ) . sort ( ( a , b ) => counts [ b ] - counts [ a ] ) ;
757+ const color = { } ;
758+ names . forEach ( ( k , i ) => { color [ k ] = PIE_COLORS [ i % PIE_COLORS . length ] ; } ) ;
759+ $ ( "st-src-pie" ) . innerHTML = pieSvg ( names . map ( k => [ k , counts [ k ] ] ) , color , k => k ) ;
760+ const legend = $ ( "st-src-legend" ) ;
761+ legend . innerHTML = names . length
762+ ? names . map ( k => `<span class="lang-chip" style="background:${ color [ k ] } ;cursor:default">${ escapeHtml ( k ) } · ${ counts [ k ] } </span>` ) . join ( "" )
763+ : `<div class="muted">No data for this period.</div>` ;
556764}
557765
558766function pieLabel ( x , y , text ) {
0 commit comments