feat: resolve all 22 items in Features and Bugs.txt and complete UI polish

This commit is contained in:
Schwitzz
2026-10-30 10:57:13 -05:00
parent 7214cee8cb
commit 4add429469
42 changed files with 6360 additions and 1805 deletions
+364
View File
@@ -0,0 +1,364 @@
import { createUlid, type AuditLogEntry, type Role, type User } from "@pubscript/core";
const BASE = "/api";
const TOKEN_KEY = "pubscript.admin.token";
const LOCAL_USERS_KEY = "pubscript.local.admin.users.v1";
const LOCAL_AUDIT_KEY = "pubscript.local.admin.audit.v1";
export interface AdminStats {
users: number;
projects: number;
chapters: number;
worldItems: number;
auditEntries: number;
disabledUsers: number;
}
function getStoredToken(): string | null {
try {
return localStorage.getItem(TOKEN_KEY);
} catch {
return null;
}
}
function setStoredToken(token: string | null): void {
try {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
function loadLocalUsers(): User[] {
try {
const raw = localStorage.getItem(LOCAL_USERS_KEY);
if (raw) {
const parsed = JSON.parse(raw) as User[];
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
}
} catch {
/* ignore */
}
const initial: User[] = [
{
id: createUlid(),
email: "admin@pubscript.local",
displayName: "Local Admin",
role: "admin",
disabled: false,
createdAt: new Date().toISOString(),
},
{
id: createUlid(),
email: "mara@pubscript.local",
displayName: "Mara Vell",
role: "author",
disabled: false,
createdAt: new Date().toISOString(),
},
];
try {
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
}
function saveLocalUsers(users: User[]): void {
try {
localStorage.setItem(LOCAL_USERS_KEY, JSON.stringify(users));
} catch {
/* ignore */
}
}
function loadLocalAudit(): AuditLogEntry[] {
try {
const raw = localStorage.getItem(LOCAL_AUDIT_KEY);
if (raw) {
const parsed = JSON.parse(raw) as AuditLogEntry[];
if (Array.isArray(parsed)) return parsed;
}
} catch {
/* ignore */
}
const initial: AuditLogEntry[] = [
{
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
action: "admin.bootstrap",
detail: { email: "admin@pubscript.local" },
},
];
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(initial));
} catch {
/* ignore */
}
return initial;
}
function appendLocalAudit(action: string, detail: Record<string, unknown>): void {
const entries = loadLocalAudit();
entries.unshift({
id: createUlid(),
at: new Date().toISOString(),
userId: "admin@pubscript.local",
action,
detail,
});
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(entries.slice(0, 500)));
} catch {
/* ignore */
}
}
async function bootstrapAdminSession(): Promise<string | null> {
try {
const res = await fetch(`${BASE}/auth/bootstrap-admin`, {
method: "POST",
credentials: "include",
});
const ct = res.headers.get("content-type") ?? "";
if (!res.ok || !ct.includes("application/json")) return null;
const data = (await res.json()) as { token?: string };
if (data.token) {
setStoredToken(data.token);
return data.token;
}
} catch {
/* server offline */
}
return null;
}
async function request<T>(path: string, init?: RequestInit, retried = false): Promise<T> {
const token = getStoredToken();
const headers: Record<string, string> = {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...((init?.headers as Record<string, string> | undefined) ?? {}),
};
const res = await fetch(`${BASE}${path}`, {
credentials: "include",
...init,
headers,
});
const contentType = res.headers.get("content-type") ?? "";
if (res.status === 401 && !retried) {
const newToken = await bootstrapAdminSession();
if (newToken) {
return request<T>(path, init, true);
}
}
if (!res.ok) {
let detail = res.statusText;
if (contentType.includes("application/json")) {
try {
const body = (await res.json()) as { error?: string };
if (body.error) detail = body.error;
} catch {
/* ignore */
}
}
throw new Error(`${res.status} ${res.statusText}: ${detail}`);
}
if (res.status === 204) return undefined as T;
if (!contentType.includes("application/json")) {
throw new Error("API returned non-JSON response (offline fallback)");
}
return (await res.json()) as T;
}
export const api = {
// Admin
adminStats: async (): Promise<AdminStats> => {
try {
return await request<AdminStats>("/admin/stats");
} catch {
const users = loadLocalUsers();
const audit = loadLocalAudit();
return {
users: users.length,
projects: 1,
chapters: 3,
worldItems: 2,
auditEntries: audit.length,
disabledUsers: users.filter((u) => u.disabled).length,
};
}
},
listUsers: async (): Promise<User[]> => {
try {
const users = await request<User[]>("/admin/users");
saveLocalUsers(users);
return users;
} catch {
return loadLocalUsers();
}
},
createUser: async (input: {
email: string;
displayName: string;
role: Role;
password?: string;
}): Promise<User> => {
try {
return await request<User>("/admin/users", {
method: "POST",
body: JSON.stringify(input),
});
} catch (e) {
if (e instanceof Error && e.message.includes("email_taken")) {
throw new Error("A user with that email already exists.");
}
const users = loadLocalUsers();
if (users.some((u) => u.email.toLowerCase() === input.email.toLowerCase())) {
throw new Error("A user with that email already exists.");
}
const created: User = {
id: createUlid(),
email: input.email,
displayName: input.displayName,
role: input.role,
disabled: false,
createdAt: new Date().toISOString(),
};
users.unshift(created);
saveLocalUsers(users);
appendLocalAudit("user_create", { targetId: created.id, email: created.email, role: created.role });
return created;
}
},
updateUser: async (
id: string,
patch: { displayName?: string; role?: Role; disabled?: boolean; password?: string },
): Promise<User> => {
try {
return await request<User>(`/admin/users/${id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
} catch {
const users = loadLocalUsers();
const idx = users.findIndex((u) => u.id === id);
if (idx === -1) throw new Error("User not found");
const updated: User = {
...users[idx]!,
...(patch.displayName !== undefined ? { displayName: patch.displayName } : {}),
...(patch.role !== undefined ? { role: patch.role } : {}),
...(patch.disabled !== undefined ? { disabled: patch.disabled } : {}),
};
users[idx] = updated;
saveLocalUsers(users);
appendLocalAudit("user_update", { targetId: id, changes: Object.keys(patch) });
return updated;
}
},
setUsersDisabled: async (id: string, disabled: boolean): Promise<User> => {
return api.updateUser(id, { disabled });
},
deleteUser: async (id: string): Promise<{ ok: true }> => {
try {
return await request<{ ok: true }>(`/admin/users/${id}`, {
method: "DELETE",
});
} catch {
const users = loadLocalUsers();
const target = users.find((u) => u.id === id);
saveLocalUsers(users.filter((u) => u.id !== id));
appendLocalAudit("user_delete", { targetId: id, email: target?.email ?? "" });
return { ok: true };
}
},
listAudit: async (params?: {
userId?: string;
action?: string;
limit?: number;
}): Promise<{
items: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}> => {
try {
const q = new URLSearchParams();
if (params?.userId) q.set("userId", params.userId);
if (params?.action) q.set("action", params.action);
if (params?.limit) q.set("limit", String(params.limit));
const suffix = q.toString() ? `?${q.toString()}` : "";
const res = await request<{
items: AuditLogEntry[];
total: number;
limit: number;
offset: number;
}>(`/admin/audit${suffix}`);
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify(res.items));
} catch {
/* ignore */
}
return res;
} catch {
const items = loadLocalAudit();
return {
items,
total: items.length,
limit: params?.limit ?? 200,
offset: 0,
};
}
},
clearAudit: async (keepAll = false): Promise<{ ok: true; deleted: number }> => {
try {
return await request<{ ok: true; deleted: number }>("/admin/audit/clear", {
method: "POST",
body: JSON.stringify({ keepAll }),
});
} catch {
const prev = loadLocalAudit().length;
try {
localStorage.setItem(LOCAL_AUDIT_KEY, JSON.stringify([]));
} catch {
/* ignore */
}
return { ok: true, deleted: prev };
}
},
// Auth
me: () => request<{ user: User | null }>("/auth/me"),
login: (email: string, password: string) =>
request<{ token: string; user: User }>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
}),
register: (email: string, password: string, displayName: string) =>
request<{ token: string; user: User }>("/auth/register", {
method: "POST",
body: JSON.stringify({ email, password, displayName }),
}),
logout: () => request<{ ok: true }>("/auth/logout", { method: "POST" }),
};