849 lines
26 KiB
JavaScript
849 lines
26 KiB
JavaScript
import m from "https://esm.sh/mithril@2.3.8";
|
|
|
|
const API = "/api/v1/flashcards";
|
|
const SEARCH_API = "/api/v1/flashcards/search";
|
|
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;
|
|
v.state.query = "";
|
|
v.state.searching = false;
|
|
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 doSearch = async () => {
|
|
const q = v.state.query.trim();
|
|
if (!q) {
|
|
v.state.searching = true;
|
|
const res = await fetch(API);
|
|
const data = await res.json();
|
|
v.state.cards = data.flashcards || [];
|
|
v.state.searching = false;
|
|
v.state.page = 1;
|
|
m.redraw();
|
|
return;
|
|
}
|
|
v.state.searching = true;
|
|
m.redraw();
|
|
const res = await fetch(SEARCH_API + "?q=" + encodeURIComponent(q));
|
|
const data = await res.json();
|
|
v.state.cards = (data.results || []).map((r) => ({
|
|
id: r.id,
|
|
version: r.version,
|
|
note: r.note,
|
|
tags: r.tags,
|
|
host: r.host,
|
|
user: r.user,
|
|
score: r.score,
|
|
}));
|
|
v.state.searching = false;
|
|
v.state.page = 1;
|
|
m.redraw();
|
|
};
|
|
|
|
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(
|
|
"form.cyber-flex.cyber-gap-sm.cyber-mb-md",
|
|
{
|
|
onsubmit: (e) => {
|
|
e.preventDefault();
|
|
doSearch();
|
|
},
|
|
},
|
|
[
|
|
m("input.cyber-input", {
|
|
style: "flex:1",
|
|
placeholder: "Search flashcards\u2026",
|
|
value: v.state.query,
|
|
oninput: (e) => {
|
|
v.state.query = e.target.value;
|
|
},
|
|
}),
|
|
m(
|
|
"button.cyber-btn.cyber-btn--primary",
|
|
{ type: "submit", disabled: v.state.searching },
|
|
v.state.searching ? "..." : "Search",
|
|
),
|
|
v.state.query
|
|
? m(
|
|
"button.cyber-btn.cyber-btn--ghost",
|
|
{
|
|
type: "button",
|
|
onclick: () => {
|
|
v.state.query = "";
|
|
doSearch();
|
|
},
|
|
},
|
|
"Clear",
|
|
)
|
|
: null,
|
|
],
|
|
),
|
|
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)) },
|
|
});
|