Skip to content

Commit 48e1ff7

Browse files
authored
feat(admin): add Modes tab for managing mode-plugin mappings (#62)
1 parent f8451e8 commit 48e1ff7

4 files changed

Lines changed: 489 additions & 1 deletion

File tree

client/pages/admin/index.js

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)