diff --git a/client/src/main.ts b/client/src/main.ts index 7ef4b80..e223199 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -14,12 +14,54 @@ type Capture = { }; const tokenKey = "aishare.adminToken"; +const themeKey = "aishare.adminTheme"; const app = document.querySelector("#app")!; let token = localStorage.getItem(tokenKey) || ""; let page = 1; let pageSize = 20; +let totalPages = 1; let polling: number | undefined; let toastTimer: number | undefined; +let manualTheme = false; + +const icons = { + sun: ``, + moon: `` +}; + +function preferredTheme() { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +function savedTheme() { + const theme = localStorage.getItem(themeKey); + return theme === "light" || theme === "dark" ? theme : null; +} + +function applyTheme(theme: "light" | "dark", manual: boolean) { + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + manualTheme = manual; + if (manual) localStorage.setItem(themeKey, theme); + updateThemeToggle(); +} + +function updateThemeToggle() { + const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "light"; + const toggle = document.querySelector("[data-theme-toggle]"); + if (!toggle) return; + toggle.setAttribute("aria-label", theme === "dark" ? "Switch to light theme" : "Switch to dark theme"); + toggle.setAttribute("title", theme === "dark" ? "Switch to light theme" : "Switch to dark theme"); +} + +function initTheme() { + const theme = savedTheme(); + applyTheme(theme || preferredTheme(), Boolean(theme)); + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); + systemTheme.addEventListener("change", () => { + if (!manualTheme) applyTheme(preferredTheme(), false); + }); +} function serviceBaseUrl() { const url = new URL(location.href); @@ -98,7 +140,10 @@ function renderShell() {

AI Share Admin

Capture public ChatGPT shares as read-only snapshots.

- +
+ + +
@@ -179,6 +224,11 @@ function renderShell() { renderShell(); }); + document.querySelector("[data-theme-toggle]")?.addEventListener("click", () => { + applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark", true); + }); + updateThemeToggle(); + document.querySelector("#capture-form")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; @@ -227,6 +277,7 @@ function renderShell() { } }); document.querySelector("#next")?.addEventListener("click", async () => { + if (page >= totalPages) return; page += 1; await loadCaptures(); }); @@ -315,7 +366,7 @@ async function loadCaptures() { }); const total = Number(body.total || 0); - const totalPages = Math.max(1, Math.ceil(total / pageSize)); + totalPages = Math.max(1, Math.ceil(total / pageSize)); if (page > totalPages) { page = totalPages; await loadCaptures(); @@ -350,5 +401,6 @@ function escapeHtml(value: string) { .replace(/"/g, """); } +initTheme(); renderShell(); void loadCaptures(); diff --git a/client/src/styles.css b/client/src/styles.css index c20577f..30e7f37 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -7,6 +7,9 @@ --border: #d9dee7; --accent: #0f766e; --danger: #b42318; + --toast-bg: #ffffff; + --toast-text: #1f2328; + --shadow: rgb(15 23 42 / 14%); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Arial, sans-serif; } @@ -19,14 +22,47 @@ --border: #3f3f46; --accent: #14b8a6; --danger: #f87171; + --toast-bg: #2f2f33; + --toast-text: #f4f4f5; + --shadow: rgb(0 0 0 / 34%); } } +html[data-theme="light"] { + color-scheme: light; + --bg: #f6f7f9; + --panel: #ffffff; + --text: #1f2328; + --muted: #6b7280; + --border: #d9dee7; + --accent: #0f766e; + --danger: #b42318; + --toast-bg: #ffffff; + --toast-text: #1f2328; + --shadow: rgb(15 23 42 / 14%); +} + +html[data-theme="dark"] { + color-scheme: dark; + --bg: #18181b; + --panel: #242428; + --text: #f4f4f5; + --muted: #a1a1aa; + --border: #3f3f46; + --accent: #14b8a6; + --danger: #f87171; + --toast-bg: #2f2f33; + --toast-text: #f4f4f5; + --shadow: rgb(0 0 0 / 34%); +} + * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font-size: 15px; } button, input, select { font: inherit; } +body, .panel, input, select, button, .small.link, .table-wrap, th, td, .pill { transition: background-color 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease; } .shell { max-width: 1120px; margin: 0 auto; padding: 28px 20px 48px; } .header { display: flex; justify-content: space-between; gap: 20px; align-items: start; margin-bottom: 22px; } +.header-actions { display: flex; align-items: center; gap: 8px; } h1, h2, p { margin: 0; } h1 { font-size: 30px; line-height: 1.15; letter-spacing: 0; } h2 { font-size: 18px; } @@ -38,9 +74,15 @@ label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; } input, select { min-height: 40px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px; background: var(--panel); color: var(--text); } input { width: 100%; } button, .small.link { min-height: 38px; border: 1px solid var(--border); border-radius: 7px; padding: 8px 12px; background: var(--panel); color: var(--text); text-decoration: none; cursor: pointer; white-space: nowrap; } +button:disabled { cursor: not-allowed; opacity: 0.42; } button[type="submit"] { background: var(--accent); border-color: var(--accent); color: white; } .danger { color: var(--danger); } .icon-button { min-height: 34px; } +.theme-toggle { width: 38px; min-height: 38px; padding: 8px; display: inline-flex; align-items: center; justify-content: center; } +.theme-toggle svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } +.theme-toggle .moon-icon { display: none; } +html[data-theme="dark"] .theme-toggle .sun-icon { display: none; } +html[data-theme="dark"] .theme-toggle .moon-icon { display: block; } .table-head { justify-content: space-between; align-items: center; margin-bottom: 12px; } .table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; } table { width: 100%; border-collapse: collapse; min-width: 760px; } @@ -61,14 +103,16 @@ tr:last-child td { border-bottom: 0; } .pager-button { width: 38px; min-height: 36px; padding: 6px 10px; } .page-label { display: inline-flex; align-items: center; justify-content: center; min-width: 58px; font-variant-numeric: tabular-nums; color: var(--text); } .pager select { width: auto; min-height: 36px; padding: 6px 30px 6px 10px; } -.toast { position: fixed; left: 50%; bottom: 22px; z-index: 50; transform: translate(-50%, 12px); padding: 9px 13px; border: 1px solid var(--border); border-radius: 8px; background: var(--text); color: var(--bg); box-shadow: 0 10px 28px rgb(0 0 0 / 18%); opacity: 0; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease; } +.toast { position: fixed; left: 50%; bottom: 22px; z-index: 50; transform: translate(-50%, 12px); padding: 9px 13px; border: 1px solid var(--border); border-radius: 8px; background: var(--toast-bg); color: var(--toast-text); box-shadow: 0 10px 28px var(--shadow); opacity: 0; pointer-events: none; transition: opacity 160ms ease, transform 160ms ease; } .toast[data-visible="true"] { opacity: 1; transform: translate(-50%, 0); } @media (max-width: 760px) { .shell { padding: 20px 14px 36px; } .header, .table-head, .actions { align-items: stretch; flex-direction: column; } + .header-actions { align-self: flex-end; } .capture-form { grid-template-columns: 1fr; } button { width: 100%; } + .theme-toggle { width: 38px; } .row-actions { justify-content: flex-start; flex-wrap: wrap; } .row-actions .small, .pager button, .pager select { width: auto; } .pager { flex-direction: row; justify-content: flex-end; align-items: center; }