@@ -41,6 +41,7 @@ export default async function Admin({ queries = {} }) {
4141 { id : 'promotions' , label : 'Promotions' , content : < Promotions /> } ,
4242 { id : 'sponsors' , label : 'Sponsors' , content : < Sponsors /> } ,
4343 { id : 'plugins' , label : 'Plugins' , content : < Plugins /> } ,
44+ { id : 'modes' , label : 'Modes' , content : < Modes /> } ,
4445 ] }
4546 />
4647 </ section >
@@ -1464,3 +1465,217 @@ function Promotions() {
14641465 </ div >
14651466 ) ;
14661467}
1468+
1469+ function Modes ( ) {
1470+ const listRef = Ref ( ) ;
1471+ const statusRef = Ref ( ) ;
1472+
1473+ function attachAutocomplete ( input ) {
1474+ let searchTimer ;
1475+ let dropdown ;
1476+ let abortController ;
1477+
1478+ const text = ( s ) => document . createTextNode ( s ) ;
1479+
1480+ const hideDropdown = ( ) => {
1481+ if ( dropdown ) {
1482+ dropdown . remove ( ) ;
1483+ dropdown = null ;
1484+ }
1485+ } ;
1486+
1487+ const showDropdown = ( items ) => {
1488+ hideDropdown ( ) ;
1489+ dropdown = document . createElement ( 'div' ) ;
1490+ dropdown . className = 'mode-autocomplete' ;
1491+ for ( const item of items ) {
1492+ const el = document . createElement ( 'div' ) ;
1493+ el . className = 'mode-autocomplete-item' ;
1494+ el . append ( text ( item . id ) , text ( ' — ' ) , text ( item . name ) ) ;
1495+ el . addEventListener ( 'mousedown' , ( e ) => {
1496+ e . preventDefault ( ) ;
1497+ const val = input . value ;
1498+ const lastComma = val . lastIndexOf ( ',' ) ;
1499+ const ids = val
1500+ . slice ( 0 , lastComma + 1 )
1501+ . split ( ',' )
1502+ . map ( ( s ) => s . trim ( ) )
1503+ . filter ( Boolean ) ;
1504+ ids . push ( item . id ) ;
1505+ input . value = ids . join ( ', ' ) ;
1506+ hideDropdown ( ) ;
1507+ input . focus ( ) ;
1508+ } ) ;
1509+ dropdown . append ( el ) ;
1510+ }
1511+ const rect = input . getBoundingClientRect ( ) ;
1512+ dropdown . style . top = `${ rect . bottom + window . scrollY } px` ;
1513+ dropdown . style . left = `${ rect . left + window . scrollX } px` ;
1514+ dropdown . style . minWidth = `${ rect . width } px` ;
1515+ document . body . append ( dropdown ) ;
1516+ } ;
1517+
1518+ input . addEventListener ( 'input' , ( ) => {
1519+ const val = input . value ;
1520+ const lastComma = val . lastIndexOf ( ',' ) ;
1521+ const segment = val . slice ( lastComma + 1 ) . trim ( ) ;
1522+ if ( segment . length < 2 ) {
1523+ hideDropdown ( ) ;
1524+ return ;
1525+ }
1526+ clearTimeout ( searchTimer ) ;
1527+ searchTimer = setTimeout ( async ( ) => {
1528+ if ( abortController ) abortController . abort ( ) ;
1529+ abortController = new AbortController ( ) ;
1530+ try {
1531+ const res = await fetch ( `/api/admin/plugins/search?q=${ encodeURIComponent ( segment ) } ` , { signal : abortController . signal } ) ;
1532+ const items = await res . json ( ) ;
1533+ if ( items . length ) showDropdown ( items ) ;
1534+ else hideDropdown ( ) ;
1535+ } catch {
1536+ // aborted
1537+ }
1538+ } , 300 ) ;
1539+ } ) ;
1540+
1541+ input . addEventListener ( 'blur' , ( ) => {
1542+ setTimeout ( hideDropdown , 150 ) ;
1543+ } ) ;
1544+
1545+ input . addEventListener ( 'keydown' , ( e ) => {
1546+ if ( ! dropdown ) return ;
1547+ const items = dropdown . querySelectorAll ( '.mode-autocomplete-item' ) ;
1548+ const active = dropdown . querySelector ( '.mode-autocomplete-item.active' ) ;
1549+ if ( e . key === 'ArrowDown' ) {
1550+ e . preventDefault ( ) ;
1551+ if ( active ) {
1552+ active . classList . remove ( 'active' ) ;
1553+ const next = active . nextElementSibling || items [ 0 ] ;
1554+ next . classList . add ( 'active' ) ;
1555+ } else {
1556+ items [ 0 ] ?. classList . add ( 'active' ) ;
1557+ }
1558+ } else if ( e . key === 'ArrowUp' ) {
1559+ e . preventDefault ( ) ;
1560+ if ( active ) {
1561+ active . classList . remove ( 'active' ) ;
1562+ const prev = active . previousElementSibling || items [ items . length - 1 ] ;
1563+ prev . classList . add ( 'active' ) ;
1564+ } else {
1565+ items [ items . length - 1 ] ?. classList . add ( 'active' ) ;
1566+ }
1567+ } else if ( e . key === 'Enter' ) {
1568+ if ( active ) {
1569+ e . preventDefault ( ) ;
1570+ active . dispatchEvent ( new Event ( 'mousedown' , { bubbles : true } ) ) ;
1571+ }
1572+ } else if ( e . key === 'Escape' ) {
1573+ hideDropdown ( ) ;
1574+ }
1575+ } ) ;
1576+ }
1577+
1578+ const createFormRow = ( modeItem = { } ) => {
1579+ const inputRef = Ref ( ) ;
1580+ const row = (
1581+ < div className = 'mode-form-row' >
1582+ < input type = 'text' placeholder = 'Mode (e.g. csv, python)' value = { modeItem . mode || '' } className = 'mode-name' />
1583+ < input
1584+ ref = { inputRef }
1585+ type = 'text'
1586+ placeholder = 'Search & add plugin IDs...'
1587+ value = { Array . isArray ( modeItem . pluginIds ) ? modeItem . pluginIds . join ( ', ' ) : '' }
1588+ className = 'mode-plugins'
1589+ />
1590+ < button
1591+ type = 'button'
1592+ className = 'icon delete mode-delete'
1593+ onclick = { ( e ) => {
1594+ e . target . closest ( '.mode-form-row' ) . remove ( ) ;
1595+ } }
1596+ />
1597+ </ div >
1598+ ) ;
1599+ inputRef . onref = ( el ) => {
1600+ if ( el ) attachAutocomplete ( el ) ;
1601+ } ;
1602+ return row ;
1603+ } ;
1604+
1605+ ( async ( ) => {
1606+ try {
1607+ const res = await fetch ( '/api/admin/modes' ) ;
1608+ if ( ! res . ok ) {
1609+ if ( statusRef . el ) statusRef . el . textContent = 'Failed to load modes' ;
1610+ return ;
1611+ }
1612+ const json = await res . json ( ) ;
1613+ if ( Array . isArray ( json ) ) {
1614+ for ( const m of json ) {
1615+ const row = createFormRow ( m ) ;
1616+ listRef . el . append ( row ) ;
1617+ }
1618+ }
1619+ } catch {
1620+ if ( statusRef . el ) statusRef . el . textContent = 'Failed to load modes' ;
1621+ }
1622+ } ) ( ) ;
1623+
1624+ const onAdd = ( ) => {
1625+ const row = createFormRow ( ) ;
1626+ listRef . el . append ( row ) ;
1627+ } ;
1628+
1629+ const onSave = async ( ) => {
1630+ const rows = listRef . el . querySelectorAll ( '.mode-form-row' ) ;
1631+ const modes = [ ] ;
1632+ for ( const row of rows ) {
1633+ const mode = row . querySelector ( '.mode-name' ) . value . trim ( ) ;
1634+ const pluginIdsRaw = row . querySelector ( '.mode-plugins' ) . value . trim ( ) ;
1635+ if ( ! mode ) {
1636+ alert ( 'ERROR' , 'Mode name is required for each entry' ) ;
1637+ return ;
1638+ }
1639+ const pluginIds = pluginIdsRaw
1640+ ? pluginIdsRaw
1641+ . split ( ',' )
1642+ . map ( ( id ) => id . trim ( ) )
1643+ . filter ( Boolean )
1644+ : [ ] ;
1645+ modes . push ( { mode, pluginIds } ) ;
1646+ }
1647+ try {
1648+ const res = await fetch ( '/api/admin/modes' , {
1649+ method : 'PUT' ,
1650+ headers : { 'Content-Type' : 'application/json' } ,
1651+ body : JSON . stringify ( { modes } ) ,
1652+ } ) ;
1653+ const json = await res . json ( ) ;
1654+ if ( json . error ) {
1655+ alert ( 'ERROR' , json . error ) ;
1656+ } else {
1657+ statusRef . el . textContent = 'Saved!' ;
1658+ setTimeout ( ( ) => {
1659+ if ( statusRef . el ) statusRef . el . textContent = '' ;
1660+ } , 2000 ) ;
1661+ }
1662+ } catch {
1663+ alert ( 'ERROR' , 'Failed to save modes' ) ;
1664+ }
1665+ } ;
1666+
1667+ return (
1668+ < div className = 'modes' >
1669+ < div ref = { listRef } className = 'mode-list' />
1670+ < div className = 'mode-actions' >
1671+ < button type = 'button' onclick = { onAdd } className = 'mode-add-btn' >
1672+ + Add Mode
1673+ </ button >
1674+ < button type = 'button' onclick = { onSave } className = 'mode-save-btn' >
1675+ Save All
1676+ </ button >
1677+ < span ref = { statusRef } className = 'status' />
1678+ </ div >
1679+ </ div >
1680+ ) ;
1681+ }
0 commit comments