import m from "https://esm.sh/mithril@2.3.8"; const API = "/api/v1/flashcards"; const AUTH = "/api/v1/auth"; const USERS = "/api/v1/users"; const SESSIONS = "/api/v1/sessions"; let user = null; let authLoading = true; async function checkAuth() { try { const res = await fetch(AUTH + "/me"); user = res.ok ? await res.json() : null; } catch { user = null; } authLoading = false; } const Login = { view: function (v) { if (authLoading) { return m(".cyber-center-xy", [ m(".cyber-spinner.cyber-spinner--lg"), m("p.cyber-text-muted.cyber-mt-md", "loading..."), ]); } if (user) return null; const err = v.state.error; return m(".cyber-center-xy", [ m( ".cyber-card.cyber-card--holo", { style: "width:360px;max-width:90vw" }, [ m(".cyber-card__header", [ m("h2.cyber-text-cyan", "St-Pierre"), m("p.cyber-text-muted", "Login"), ]), m(".cyber-card__body", [ err ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) : null, m( "form", { onsubmit: async function (e) { e.preventDefault(); v.state.error = null; const fd = new FormData(e.target); const res = await fetch(AUTH + "/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: fd.get("username"), password: fd.get("password"), }), }); if (res.ok) { await checkAuth(); m.redraw(); return; } const d = await res.json(); v.state.error = d.error || "Login failed"; m.redraw(); }, }, [ m(".cyber-field.cyber-mb-sm", [ m( "input.cyber-input", { name: "username", placeholder: "Username", autofocus: true, required: true, }, ), ]), m(".cyber-field.cyber-mb-md", [ m("input.cyber-input", { name: "password", type: "password", placeholder: "Password", required: true, }), ]), m( "button.cyber-btn.cyber-btn--primary.cyber-btn--block", { type: "submit" }, "Login", ), ], ), ]), ], ), ]); }, }; const Layout = { oninit: async function () { if (user === null) { await checkAuth(); m.redraw(); } }, view: function (v) { if (authLoading) { return m(".cyber-center-xy", [ m(".cyber-spinner.cyber-spinner--lg"), m("p.cyber-text-muted.cyber-mt-md", "loading..."), ]); } if (!user) return m(Login); return m("div", [ m( "header.cyber-flex.cyber-justify-between.cyber-items-center.cyber-p-md", { style: "border-bottom:1px solid var(--cyber-void-700)" }, [ m( "button.cyber-btn.cyber-btn--ghost.cyber-font-mono", { onclick: () => m.route.set("/") }, "St-Pierre", ), m("div.cyber-flex.cyber-items-center.cyber-gap-sm", [ m( "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", { onclick: () => m.route.set("/tokens") }, "Tokens", ), m("span.cyber-badge.cyber-badge--pulse", user.username), m( "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", { onclick: () => m.route.set("/profile") }, "Profile", ), m( "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", { onclick: async () => { await fetch(AUTH + "/logout", { method: "POST" }); user = null; m.redraw(); }, }, "Logout", ), ]), ], ), m("main", v.children), ]); }, }; const Card = { view: function (v) { const c = v.attrs.card; const maxNote = (c.note || "").length > 120 ? c.note.slice(0, 120) + "\u2026" : c.note; return m( ".cyber-card.cyber-card--interactive.cyber-neon-border.cyber-mb-md", { onclick: () => m.route.set("/flashcard/" + c.id), style: "cursor:pointer", }, [ c.tags?.length ? m( ".cyber-card__header", c.tags.map((t) => m("span.cyber-badge.cyber-badge--pulse.cyber-mr-xs", t), ), ) : null, m(".cyber-card__body", [ m(".cyber-text-muted.cyber-font-mono", `#${c.id} v${c.version}`), m( "p.cyber-text-primary.cyber-mt-sm", maxNote || m("em.cyber-text-muted", "no content"), ), ]), ], ); }, }; const PAGE_SIZE = 20; const Pager = { view: function (v) { const { page, total } = v.attrs; if (total <= 1) return null; return m( ".cyber-flex.cyber-justify-center.cyber-items-center.cyber-gap-sm.cyber-my-md", [ m( "button.cyber-btn.cyber-btn--sm" + (page <= 1 ? ".cyber-btn--ghost" : ""), { disabled: page <= 1, onclick: () => v.attrs.onpage(page - 1) }, "\u2190", ), m("span.cyber-font-mono.cyber-text-muted", `${page} / ${total}`), m( "button.cyber-btn.cyber-btn--sm" + (page >= total ? ".cyber-btn--ghost" : ""), { disabled: page >= total, onclick: () => v.attrs.onpage(page + 1) }, "\u2192", ), ], ); }, }; const List = { oninit: async function (v) { v.state.cards = []; v.state.loading = true; const res = await fetch(API); const data = await res.json(); v.state.cards = data.flashcards || []; v.state.loading = false; v.state.page = 1; m.redraw(); }, view: function (v) { if (v.state.loading) { return m(".cyber-center-xy", [ m(".cyber-spinner.cyber-spinner--lg"), m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"), ]); } const totalPages = Math.max(1, Math.ceil(v.state.cards.length / PAGE_SIZE)); const page = Math.min(v.state.page, totalPages); const slice = v.state.cards.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [ m(Pager, { page, total: totalPages, onpage: (n) => { v.state.page = n; }, }), m( ".cyber-scanlines", slice.map((c) => m(Card, { card: c })), ), m(Pager, { page, total: totalPages, onpage: (n) => { v.state.page = n; }, }), ]); }, }; const Detail = { oninit: async function (v) { v.state.entry = null; v.state.loading = true; try { const res = await fetch(`${API}/${v.attrs.id}`); if (res.ok) v.state.entry = await res.json(); } catch {} v.state.loading = false; m.redraw(); }, view: function (v) { if (v.state.loading) { return m(".cyber-center-xy", m(".cyber-spinner.cyber-spinner--lg")); } const e = v.state.entry; if (!e) { return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [ m( "button.cyber-btn.cyber-btn--ghost", { onclick: () => m.route.set("/") }, "\u2B05 Back", ), m("p.cyber-text-magenta", "Flashcard not found."), ]); } return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [ m( "button.cyber-btn.cyber-btn--ghost.cyber-mb-md", { onclick: () => m.route.set("/") }, "\u2B05 Back", ), m(".cyber-card.cyber-card--holo", [ m(".cyber-card__header", [ m("span.cyber-font-mono.cyber-text-cyan", `#${e.id} v${e.version}`), ]), m(".cyber-card__body", [ m(".cyber-field", [m(".cyber-field__label", "Note"), m("p", e.note)]), e.host ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Host"), m("p", e.host), ]) : null, e.user ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "User"), m("p", e.user), ]) : null, e.context ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Context"), m("p", e.context), ]) : null, e.repos?.length ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Repos"), m( "div", e.repos.map((r) => m("span.cyber-badge.cyber-mr-xs", r)), ), ]) : null, e.tags?.length ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Tags"), m( "div", e.tags.map((t) => m("span.cyber-badge.cyber-mr-xs", t)), ), ]) : null, m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Created"), m("p", e.created), ]), e.delete_timestamp ? m(".cyber-field.cyber-mt-sm", [ m(".cyber-field__label", "Deleted"), m("p", e.delete_timestamp), ]) : null, ]), ]), ]); }, }; const Users = { oninit: async function (v) { v.state.users = []; v.state.loading = true; try { const res = await fetch(USERS); const data = await res.json(); v.state.users = data.users || []; } catch {} v.state.loading = false; m.redraw(); }, view: function (v) { if (v.state.loading) { return m(".cyber-center-xy", [ m(".cyber-spinner.cyber-spinner--lg"), m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"), ]); } const err = v.state.error; const ok = v.state.ok; return m(".cyber-p-lg", { style: "max-width:640px;margin:0 auto" }, [ m("h2.cyber-text-cyan.cyber-mb-md", "Users"), ok ? m(".cyber-alert.cyber-alert--success.cyber-mb-md", ok) : null, err ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) : null, m( ".cyber-card.cyber-card--holo.cyber-mb-lg", [ m(".cyber-card__header", m("h3", "Create User")), m(".cyber-card__body", [ m( "form", { onsubmit: async function (e) { e.preventDefault(); v.state.error = null; v.state.ok = null; const fd = new FormData(e.target); const res = await fetch(USERS, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: fd.get("username"), password: fd.get("password"), }), }); if (res.ok) { e.target.reset(); v.state.ok = `User ${fd.get("username")} created.`; const data = await fetch(USERS).then((r) => r.json()); v.state.users = data.users || []; } else { const d = await res.json(); v.state.error = d.error || "Failed to create user"; } m.redraw(); }, }, [ m(".cyber-flex.cyber-gap-md.cyber-mb-sm", [ m("input.cyber-input", { name: "username", placeholder: "Username", required: true, style: "flex:1", }), m("input.cyber-input", { name: "password", type: "password", placeholder: "Password (min 8 chars)", required: true, minlength: 8, style: "flex:1", }), m( "button.cyber-btn.cyber-btn--primary", { type: "submit" }, "Create", ), ]), ], ), ]), ], ), v.state.users.length === 0 ? m("p.cyber-text-muted", "No users found.") : m( ".cyber-card.cyber-card--holo", [ m(".cyber-card__header", m("h3", `All Users (${v.state.users.length})`)), m(".cyber-card__body", [ m( "table.cyber-table", { style: "width:100%" }, [ m("thead", m("tr", [ m("th", "ID"), m("th", "Username"), m("th", "Created"), m("th", "Active"), ])), m( "tbody", v.state.users.map((u) => m("tr", [ m("td.cyber-font-mono", u.id), m("td", u.username), m("td.cyber-font-mono", u.created_at), m("td", u.active ? m("span.cyber-badge.cyber-badge--pulse", "yes") : m("span.cyber-badge", "no")), ]), ), ), ], ), ]), ], ), ]); }, }; const Profile = { view: function (v) { const err = v.state.error; const ok = v.state.ok; return m(".cyber-p-lg", { style: "max-width:640px;margin:0 auto" }, [ m("h2.cyber-text-cyan.cyber-mb-md", "Profile"), m( ".cyber-card.cyber-card--holo.cyber-mb-lg", [ m(".cyber-card__header", m("h3", user.username)), m(".cyber-card__body", [ m(".cyber-field.cyber-mb-sm", [ m(".cyber-field__label", "User ID"), m("p.cyber-font-mono", user.id), ]), m(".cyber-field", [ m(".cyber-field__label", "Created"), m("p.cyber-font-mono", user.created_at), ]), ]), ], ), m( ".cyber-card.cyber-card--holo", [ m(".cyber-card__header", m("h3", "Change Password")), m(".cyber-card__body", [ ok ? m(".cyber-alert.cyber-alert--success.cyber-mb-md", ok) : null, err ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) : null, m( "form", { onsubmit: async function (e) { e.preventDefault(); v.state.error = null; v.state.ok = null; const fd = new FormData(e.target); const res = await fetch(AUTH + "/password", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ old_password: fd.get("old_password"), new_password: fd.get("new_password"), }), }); if (res.ok) { e.target.reset(); v.state.ok = "Password changed."; } else { const d = await res.json(); v.state.error = d.error || "Failed to change password"; } m.redraw(); }, }, [ m(".cyber-field.cyber-mb-sm", [ m("input.cyber-input", { name: "old_password", type: "password", placeholder: "Current password", required: true, autofocus: true, }), ]), m(".cyber-field.cyber-mb-md", [ m("input.cyber-input", { name: "new_password", type: "password", placeholder: "New password (min 8 chars)", required: true, minlength: 8, }), ]), m( "button.cyber-btn.cyber-btn--primary.cyber-btn--block", { type: "submit" }, "Change Password", ), ], ), ]), ], ), ]); }, }; const Tokens = { oninit: async function (v) { v.state.sessions = []; v.state.loading = true; try { const res = await fetch(SESSIONS); const data = await res.json(); v.state.sessions = data.sessions || []; } catch {} v.state.loading = false; m.redraw(); }, view: function (v) { if (v.state.loading) { return m(".cyber-center-xy", [ m(".cyber-spinner.cyber-spinner--lg"), m("p.cyber-text-muted.cyber-mt-md", "loading\u2026"), ]); } const err = v.state.error; const newToken = v.state.newToken; return m(".cyber-p-lg", { style: "max-width:720px;margin:0 auto" }, [ m("h2.cyber-text-cyan.cyber-mb-md", "API Tokens"), newToken ? m( ".cyber-card.cyber-card--holo.cyber-mb-lg", { style: "border-color:var(--cyber-yellow-500)" }, [ m(".cyber-card__header", [ m( "span.cyber-badge.cyber-badge--pulse.cyber-mr-sm", "NEW", ), m("span.cyber-text-yellow", newToken.label), ]), m(".cyber-card__body", [ m("p.cyber-text-yellow.cyber-mb-sm", [ m("strong", "Copy this token now. "), "It will never be shown again.", ]), m( ".cyber-field.cyber-mb-sm", m( ".cyber-flex.cyber-gap-sm", [ m("input.cyber-input.cyber-font-mono", { value: newToken.token, readonly: true, style: "flex:1;user-select:all", onfocus: function (e) { e.target.select(); }, }), m( "button.cyber-btn.cyber-btn--sm", { onclick: function () { const input = v.dom.querySelector( "input[readonly]", ); navigator.clipboard.writeText(newToken.token); input.select(); }, }, "Copy", ), ], ), ), m( "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm", { onclick: () => { v.state.newToken = null; } }, "Dismiss", ), ]), ], ) : null, err ? m(".cyber-alert.cyber-alert--danger.cyber-mb-md", err) : null, m( ".cyber-card.cyber-card--holo.cyber-mb-lg", [ m(".cyber-card__header", m("h3", "Create Token")), m(".cyber-card__body", [ m( "form", { onsubmit: async function (e) { e.preventDefault(); v.state.error = null; const fd = new FormData(e.target); const res = await fetch(SESSIONS, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label: fd.get("label") }), }); if (res.ok) { const token = await res.json(); e.target.reset(); const data = await fetch(SESSIONS).then((r) => r.json()); v.state.sessions = data.sessions || []; v.state.newToken = token; } else { const d = await res.json(); v.state.error = d.error || "Failed to create token"; } m.redraw(); }, }, [ m(".cyber-flex.cyber-gap-md.cyber-mb-sm", [ m("input.cyber-input", { name: "label", placeholder: "e.g. opencode on shulgi", required: true, style: "flex:1", }), m( "button.cyber-btn.cyber-btn--primary", { type: "submit" }, "Generate", ), ]), ], ), ]), ], ), v.state.sessions.length === 0 ? m("p.cyber-text-muted", "No tokens yet.") : m( ".cyber-card.cyber-card--holo", [ m(".cyber-card__header", m("h3", `Your Tokens (${v.state.sessions.length})`)), m(".cyber-card__body", [ m( "table.cyber-table", { style: "width:100%" }, [ m("thead", m("tr", [ m("th", "ID"), m("th", "Label"), m("th", "Created"), m("th", "Last Used"), m("th", "Status"), m("th"), ])), m( "tbody", v.state.sessions.map((s) => m("tr", [ m("td.cyber-font-mono", s.id), m("td", s.label), m("td.cyber-font-mono", s.created_at), m("td.cyber-font-mono", s.last_used_at || "\u2014"), m("td", s.active ? m("span.cyber-badge.cyber-badge--pulse", "active") : m("span.cyber-badge.cyber-badge--ghost", "revoked"), ), m("td", s.active ? m( "button.cyber-btn.cyber-btn--ghost.cyber-btn--sm.cyber-text-magenta", { onclick: async () => { await fetch(`${SESSIONS}/${s.id}`, { method: "DELETE", }); const data = await fetch(SESSIONS).then((r) => r.json()); v.state.sessions = data.sessions || []; m.redraw(); }, }, "Revoke", ) : null, ), ]), ), ), ], ), ]), ], ), ]); }, }; m.route(document.getElementById("app"), "/", { "/": { render: () => m(Layout, m(List)) }, "/flashcard/:id": { render: (v) => m(Layout, m(Detail, { id: v.attrs.id })) }, "/users": { render: () => m(Layout, m(Users)) }, "/tokens": { render: () => m(Layout, m(Tokens)) }, "/profile": { render: () => m(Layout, m(Profile)) }, });