WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)

+2 -528
+2 -528
apps/web/src/routes/admin.tsx
··· 12 12 } from "../lib/session.js"; 13 13 import { isProgrammingError } from "../lib/errors.js"; 14 14 import { logger } from "../lib/logger.js"; 15 - import neobrutalLight from "../styles/presets/neobrutal-light.json"; 16 - import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 15 + import { createAdminThemeRoutes } from "./admin-themes.js"; 17 16 18 17 // ─── Types ───────────────────────────────────────────────────────────────── 19 18 ··· 61 60 reason: string | null; 62 61 createdAt: string; 63 62 } 64 - 65 - interface AdminThemeEntry { 66 - id: string; 67 - uri: string; 68 - name: string; 69 - colorScheme: string; 70 - tokens: Record<string, string>; 71 - cssOverrides: string | null; 72 - fontUrls: string[] | null; 73 - createdAt: string; 74 - indexedAt: string; 75 - } 76 - 77 - interface ThemePolicy { 78 - defaultLightThemeUri: string | null; 79 - defaultDarkThemeUri: string | null; 80 - allowUserChoice: boolean; 81 - availableThemes: Array<{ uri: string; cid: string }>; 82 - } 83 - 84 - // Preset token maps — used by POST /admin/themes to seed tokens on creation 85 - const THEME_PRESETS: Record<string, Record<string, string>> = { 86 - "neobrutal-light": neobrutalLight as Record<string, string>, 87 - "neobrutal-dark": neobrutalDark as Record<string, string>, 88 - "blank": {}, 89 - }; 90 63 91 64 const ACTION_LABELS: Record<string, string> = { 92 65 "space.atbb.modAction.ban": "Ban", ··· 1490 1463 1491 1464 // ─── Themes ──────────────────────────────────────────────────────────────── 1492 1465 1493 - app.get("/admin/themes", async (c) => { 1494 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1495 - 1496 - if (!auth.authenticated) { 1497 - return c.redirect("/login"); 1498 - } 1499 - 1500 - if (!canManageThemes(auth)) { 1501 - return c.html( 1502 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1503 - <PageHeader title="Themes" /> 1504 - <p>You don&apos;t have permission to manage themes.</p> 1505 - </BaseLayout>, 1506 - 403 1507 - ); 1508 - } 1509 - 1510 - const cookie = c.req.header("cookie") ?? ""; 1511 - const errorMsg = c.req.query("error") ?? null; 1512 - 1513 - let adminThemes: AdminThemeEntry[] = []; 1514 - let policy: ThemePolicy | null = null; 1515 - 1516 - try { 1517 - const [themesRes, policyRes] = await Promise.all([ 1518 - fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }), 1519 - fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }), 1520 - ]); 1521 - 1522 - if (themesRes.ok) { 1523 - try { 1524 - const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 1525 - adminThemes = data.themes; 1526 - } catch { 1527 - logger.error("Failed to parse admin themes response", { 1528 - operation: "GET /admin/themes", 1529 - status: themesRes.status, 1530 - }); 1531 - } 1532 - } else { 1533 - logger.error("Failed to fetch admin themes list", { 1534 - operation: "GET /admin/themes", 1535 - status: themesRes.status, 1536 - }); 1537 - } 1538 - 1539 - if (policyRes.ok) { 1540 - try { 1541 - policy = (await policyRes.json()) as ThemePolicy; 1542 - } catch { 1543 - logger.error("Failed to parse theme policy response", { 1544 - operation: "GET /admin/themes", 1545 - status: policyRes.status, 1546 - }); 1547 - } 1548 - } else if (policyRes.status !== 404) { 1549 - logger.error("Failed to fetch theme policy", { 1550 - operation: "GET /admin/themes", 1551 - status: policyRes.status, 1552 - }); 1553 - } 1554 - // 404 = no policy yet — render page with empty policy (not an error) 1555 - } catch (error) { 1556 - if (isProgrammingError(error)) throw error; 1557 - logger.error("Network error fetching themes data", { 1558 - operation: "GET /admin/themes", 1559 - error: error instanceof Error ? error.message : String(error), 1560 - }); 1561 - } 1562 - 1563 - const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri)); 1564 - const lightThemes = adminThemes.filter((t) => t.colorScheme === "light"); 1565 - const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); 1566 - 1567 - return c.html( 1568 - <BaseLayout title="Themes — atBB Admin" auth={auth}> 1569 - <PageHeader title="Themes" /> 1570 - 1571 - {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 1572 - 1573 - {adminThemes.length === 0 ? ( 1574 - <EmptyState message="No themes yet. Create one below." /> 1575 - ) : ( 1576 - <div class="structure-list"> 1577 - {adminThemes.map((theme) => { 1578 - const themeRkey = theme.uri.split("/").pop() ?? theme.id; 1579 - const dialogId = `confirm-delete-theme-${themeRkey}`; 1580 - const swatchTokens = [ 1581 - "color-bg", 1582 - "color-surface", 1583 - "color-primary", 1584 - "color-secondary", 1585 - "color-border", 1586 - ] as const; 1587 - 1588 - return ( 1589 - <div class="structure-item"> 1590 - <div class="structure-item__header"> 1591 - <div class="structure-item__title"> 1592 - <label> 1593 - <input 1594 - type="checkbox" 1595 - form="policy-form" 1596 - name="availableThemes" 1597 - value={theme.uri} 1598 - checked={availableUris.has(theme.uri)} 1599 - /> 1600 - {" "} 1601 - {theme.name} 1602 - </label> 1603 - <span class={`badge badge--${theme.colorScheme}`}> 1604 - {theme.colorScheme} 1605 - </span> 1606 - </div> 1607 - 1608 - <div class="theme-swatches" aria-hidden="true"> 1609 - {swatchTokens.map((token) => { 1610 - const value = theme.tokens[token] ?? "#cccccc"; 1611 - const safe = 1612 - !value.startsWith("var(") && 1613 - !value.includes(";") && 1614 - !value.includes("<"); 1615 - return ( 1616 - <span 1617 - class="theme-swatch" 1618 - style={safe ? `background:${value}` : "background:#cccccc"} 1619 - title={token} 1620 - /> 1621 - ); 1622 - })} 1623 - </div> 1624 - 1625 - <div class="structure-item__actions"> 1626 - <span class="btn btn-secondary btn-sm" aria-disabled="true"> 1627 - Edit 1628 - </span> 1629 - 1630 - <form 1631 - method="post" 1632 - action={`/admin/themes/${themeRkey}/duplicate`} 1633 - style="display:inline" 1634 - > 1635 - <button type="submit" class="btn btn-secondary btn-sm"> 1636 - Duplicate 1637 - </button> 1638 - </form> 1639 - 1640 - <button 1641 - type="button" 1642 - class="btn btn-danger btn-sm" 1643 - onclick={`document.getElementById('${dialogId}').showModal()`} 1644 - > 1645 - Delete 1646 - </button> 1647 - </div> 1648 - </div> 1649 - 1650 - <dialog id={dialogId} class="structure-confirm-dialog"> 1651 - <p> 1652 - Delete theme &quot;{theme.name}&quot;? This cannot be undone. 1653 - </p> 1654 - <form 1655 - method="post" 1656 - action={`/admin/themes/${themeRkey}/delete`} 1657 - class="dialog-actions" 1658 - > 1659 - <button type="submit" class="btn btn-danger"> 1660 - Delete 1661 - </button> 1662 - <button 1663 - type="button" 1664 - class="btn btn-secondary" 1665 - onclick={`document.getElementById('${dialogId}').close()`} 1666 - > 1667 - Cancel 1668 - </button> 1669 - </form> 1670 - </dialog> 1671 - </div> 1672 - ); 1673 - })} 1674 - </div> 1675 - )} 1676 - 1677 - {/* Policy form — availability checkboxes on cards associate via form="policy-form" */} 1678 - <section class="admin-section"> 1679 - <h2>Theme Policy</h2> 1680 - <form id="policy-form" method="post" action="/admin/theme-policy"> 1681 - <div class="form-group"> 1682 - <label for="defaultLightThemeUri">Default Light Theme</label> 1683 - <select id="defaultLightThemeUri" name="defaultLightThemeUri"> 1684 - <option value="">— none —</option> 1685 - {lightThemes.map((t) => ( 1686 - <option 1687 - value={t.uri} 1688 - selected={policy?.defaultLightThemeUri === t.uri} 1689 - > 1690 - {t.name} 1691 - </option> 1692 - ))} 1693 - </select> 1694 - </div> 1695 - 1696 - <div class="form-group"> 1697 - <label for="defaultDarkThemeUri">Default Dark Theme</label> 1698 - <select id="defaultDarkThemeUri" name="defaultDarkThemeUri"> 1699 - <option value="">— none —</option> 1700 - {darkThemes.map((t) => ( 1701 - <option 1702 - value={t.uri} 1703 - selected={policy?.defaultDarkThemeUri === t.uri} 1704 - > 1705 - {t.name} 1706 - </option> 1707 - ))} 1708 - </select> 1709 - </div> 1710 - 1711 - <div class="form-group"> 1712 - <label> 1713 - <input 1714 - type="checkbox" 1715 - name="allowUserChoice" 1716 - checked={policy?.allowUserChoice ?? true} 1717 - /> 1718 - {" "}Allow users to choose their own theme 1719 - </label> 1720 - </div> 1721 - 1722 - <p class="form-hint"> 1723 - Check themes above to make them available to users. 1724 - </p> 1725 - <button type="submit" class="btn btn-primary"> 1726 - Save Policy 1727 - </button> 1728 - </form> 1729 - </section> 1730 - 1731 - {/* Create new theme */} 1732 - <details class="structure-add-form"> 1733 - <summary class="structure-add-form__trigger">+ Create New Theme</summary> 1734 - <form 1735 - method="post" 1736 - action="/admin/themes" 1737 - class="structure-edit-form__body" 1738 - > 1739 - <div class="form-group"> 1740 - <label for="new-theme-name">Name</label> 1741 - <input 1742 - id="new-theme-name" 1743 - type="text" 1744 - name="name" 1745 - required 1746 - placeholder="My Custom Theme" 1747 - /> 1748 - </div> 1749 - <div class="form-group"> 1750 - <label for="new-theme-scheme">Color Scheme</label> 1751 - <select id="new-theme-scheme" name="colorScheme"> 1752 - <option value="light">Light</option> 1753 - <option value="dark">Dark</option> 1754 - </select> 1755 - </div> 1756 - <div class="form-group"> 1757 - <label for="new-theme-preset">Start from Preset</label> 1758 - <select id="new-theme-preset" name="preset"> 1759 - <option value="neobrutal-light">Neobrutal Light</option> 1760 - <option value="neobrutal-dark">Neobrutal Dark</option> 1761 - <option value="blank">Blank</option> 1762 - </select> 1763 - </div> 1764 - <button type="submit" class="btn btn-primary"> 1765 - Create Theme 1766 - </button> 1767 - </form> 1768 - </details> 1769 - </BaseLayout> 1770 - ); 1771 - }); 1772 - 1773 - // ── POST /admin/themes ──────────────────────────────────────────────────── 1774 - 1775 - app.post("/admin/themes", async (c) => { 1776 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1777 - if (!auth.authenticated) return c.redirect("/login"); 1778 - if (!canManageThemes(auth)) { 1779 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1780 - } 1781 - 1782 - const cookie = c.req.header("cookie") ?? ""; 1783 - 1784 - let body: Record<string, string | File>; 1785 - try { 1786 - body = await c.req.parseBody(); 1787 - } catch (error) { 1788 - if (isProgrammingError(error)) throw error; 1789 - return c.redirect( 1790 - `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1791 - 302 1792 - ); 1793 - } 1794 - 1795 - const name = typeof body.name === "string" ? body.name.trim() : ""; 1796 - const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light"; 1797 - const preset = typeof body.preset === "string" ? body.preset : "blank"; 1798 - 1799 - if (!name) { 1800 - return c.redirect( 1801 - `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`, 1802 - 302 1803 - ); 1804 - } 1805 - 1806 - const tokens = THEME_PRESETS[preset] ?? {}; 1807 - 1808 - let apiRes: Response; 1809 - try { 1810 - apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 1811 - method: "POST", 1812 - headers: { "Content-Type": "application/json", Cookie: cookie }, 1813 - body: JSON.stringify({ name, colorScheme, tokens }), 1814 - }); 1815 - } catch (error) { 1816 - if (isProgrammingError(error)) throw error; 1817 - logger.error("Network error creating theme", { 1818 - operation: "POST /admin/themes", 1819 - error: error instanceof Error ? error.message : String(error), 1820 - }); 1821 - return c.redirect( 1822 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1823 - 302 1824 - ); 1825 - } 1826 - 1827 - if (!apiRes.ok) { 1828 - const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again."); 1829 - return c.redirect( 1830 - `/admin/themes?error=${encodeURIComponent(msg)}`, 1831 - 302 1832 - ); 1833 - } 1834 - 1835 - return c.redirect("/admin/themes", 302); 1836 - }); 1837 - 1838 - // ── POST /admin/themes/:rkey/duplicate ──────────────────────────────────── 1839 - 1840 - app.post("/admin/themes/:rkey/duplicate", async (c) => { 1841 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1842 - if (!auth.authenticated) return c.redirect("/login"); 1843 - if (!canManageThemes(auth)) { 1844 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1845 - } 1846 - 1847 - const cookie = c.req.header("cookie") ?? ""; 1848 - const themeRkey = c.req.param("rkey"); 1849 - 1850 - let apiRes: Response; 1851 - try { 1852 - apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, { 1853 - method: "POST", 1854 - headers: { Cookie: cookie }, 1855 - }); 1856 - } catch (error) { 1857 - if (isProgrammingError(error)) throw error; 1858 - logger.error("Network error duplicating theme", { 1859 - operation: "POST /admin/themes/:rkey/duplicate", 1860 - themeRkey, 1861 - error: error instanceof Error ? error.message : String(error), 1862 - }); 1863 - return c.redirect( 1864 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1865 - 302 1866 - ); 1867 - } 1868 - 1869 - if (!apiRes.ok) { 1870 - const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again."); 1871 - return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1872 - } 1873 - 1874 - return c.redirect("/admin/themes", 302); 1875 - }); 1876 - 1877 - // ── POST /admin/themes/:rkey/delete ────────────────────────────────────── 1878 - 1879 - app.post("/admin/themes/:rkey/delete", async (c) => { 1880 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1881 - if (!auth.authenticated) return c.redirect("/login"); 1882 - if (!canManageThemes(auth)) { 1883 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1884 - } 1885 - 1886 - const cookie = c.req.header("cookie") ?? ""; 1887 - const themeRkey = c.req.param("rkey"); 1888 - 1889 - let apiRes: Response; 1890 - try { 1891 - apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1892 - method: "DELETE", 1893 - headers: { Cookie: cookie }, 1894 - }); 1895 - } catch (error) { 1896 - if (isProgrammingError(error)) throw error; 1897 - logger.error("Network error deleting theme", { 1898 - operation: "POST /admin/themes/:rkey/delete", 1899 - themeRkey, 1900 - error: error instanceof Error ? error.message : String(error), 1901 - }); 1902 - return c.redirect( 1903 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1904 - 302 1905 - ); 1906 - } 1907 - 1908 - if (!apiRes.ok) { 1909 - if (apiRes.status === 409) { 1910 - return c.redirect( 1911 - `/admin/themes?error=${encodeURIComponent("Cannot delete a theme that is currently set as a default.")}`, 1912 - 302 1913 - ); 1914 - } 1915 - const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again."); 1916 - return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1917 - } 1918 - 1919 - return c.redirect("/admin/themes", 302); 1920 - }); 1921 - 1922 - // ── POST /admin/theme-policy ────────────────────────────────────────────── 1923 - 1924 - app.post("/admin/theme-policy", async (c) => { 1925 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1926 - if (!auth.authenticated) return c.redirect("/login"); 1927 - if (!canManageThemes(auth)) { 1928 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1929 - } 1930 - 1931 - const cookie = c.req.header("cookie") ?? ""; 1932 - 1933 - let rawBody: Record<string, string | string[] | File | File[]>; 1934 - try { 1935 - rawBody = await c.req.parseBody({ all: true }); 1936 - } catch (error) { 1937 - if (isProgrammingError(error)) throw error; 1938 - return c.redirect( 1939 - `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1940 - 302 1941 - ); 1942 - } 1943 - 1944 - const defaultLightThemeUri = 1945 - typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : ""; 1946 - const defaultDarkThemeUri = 1947 - typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : ""; 1948 - // Checkbox: present with value "on" when checked, absent when unchecked 1949 - const allowUserChoice = rawBody.allowUserChoice === "on"; 1950 - 1951 - // availableThemes may be a single string, an array, or absent 1952 - const rawAvailable = rawBody.availableThemes; 1953 - const availableThemes = 1954 - rawAvailable === undefined 1955 - ? [] 1956 - : Array.isArray(rawAvailable) 1957 - ? rawAvailable.filter((v): v is string => typeof v === "string") 1958 - : typeof rawAvailable === "string" 1959 - ? [rawAvailable] 1960 - : []; 1961 - 1962 - let apiRes: Response; 1963 - try { 1964 - apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, { 1965 - method: "PUT", 1966 - headers: { "Content-Type": "application/json", Cookie: cookie }, 1967 - body: JSON.stringify({ 1968 - defaultLightThemeUri, 1969 - defaultDarkThemeUri, 1970 - allowUserChoice, 1971 - availableThemes: availableThemes.map((uri) => ({ uri })), 1972 - }), 1973 - }); 1974 - } catch (error) { 1975 - if (isProgrammingError(error)) throw error; 1976 - logger.error("Network error updating theme policy", { 1977 - operation: "POST /admin/theme-policy", 1978 - error: error instanceof Error ? error.message : String(error), 1979 - }); 1980 - return c.redirect( 1981 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1982 - 302 1983 - ); 1984 - } 1985 - 1986 - if (!apiRes.ok) { 1987 - const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again."); 1988 - return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1989 - } 1990 - 1991 - return c.redirect("/admin/themes", 302); 1992 - }); 1466 + app.route("/", createAdminThemeRoutes(appviewUrl)); 1993 1467 1994 1468 return app; 1995 1469 }