This commit is contained in:
Justin Visser 2026-02-17 20:24:12 +01:00
parent efd21a7297
commit 441676be27
97 changed files with 10764 additions and 223 deletions

View file

@ -0,0 +1,71 @@
import { defineStore } from "pinia";
import { ApiRequestError } from "@/lib/api/errors";
import { fetchCsrfBootstrap } from "@/lib/api/csrf";
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
import { useUiStore } from "@/stores/ui";
export type AuthState = "unknown" | "authenticated" | "anonymous";
export const useAuthStore = defineStore("auth", {
state: () => ({
state: "unknown" as AuthState,
csrfToken: null as string | null,
user: null as SessionUser | null,
}),
getters: {
isAuthenticated: (state) => state.state === "authenticated",
isAdmin: (state) => Boolean(state.user?.is_admin),
},
actions: {
async bootstrapSession(): Promise<void> {
const ui = useUiStore();
ui.clearGlobalError();
try {
const csrf = await fetchCsrfBootstrap();
this.csrfToken = csrf.data.csrf_token;
if (!csrf.data.authenticated) {
this.state = "anonymous";
this.user = null;
return;
}
const me = await fetchMe();
this.state = "authenticated";
this.user = me.data.user;
this.csrfToken = me.data.csrf_token;
} catch (error) {
this.state = "anonymous";
this.user = null;
if (error instanceof ApiRequestError) {
ui.setGlobalError({
message: error.message,
requestId: error.requestId,
});
}
}
},
async login(email: string, password: string): Promise<void> {
const response = await loginSession({ email, password });
this.state = "authenticated";
this.user = response.data.user;
this.csrfToken = response.data.csrf_token;
},
async logout(): Promise<void> {
await logoutSession();
this.state = "anonymous";
this.user = null;
this.csrfToken = null;
try {
const csrf = await fetchCsrfBootstrap();
this.csrfToken = csrf.data.csrf_token;
} catch (_err) {
// No-op: app can still function by refreshing.
}
},
},
});

View file

@ -0,0 +1,51 @@
import { defineStore } from "pinia";
export type ThemePreference = "system" | "light" | "dark";
export type ThemeValue = "light" | "dark";
const STORAGE_KEY = "scholarr-theme-preference";
function systemTheme(): ThemeValue {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: ThemeValue, preference: ThemePreference): void {
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
root.setAttribute("data-theme-preference", preference);
}
export const useThemeStore = defineStore("theme", {
state: () => ({
preference: "system" as ThemePreference,
active: "light" as ThemeValue,
}),
actions: {
initialize(): void {
let stored: string | null = null;
try {
stored = localStorage.getItem(STORAGE_KEY);
} catch (_err) {
stored = null;
}
if (stored === "light" || stored === "dark" || stored === "system") {
this.preference = stored;
}
this.active = this.preference === "system" ? systemTheme() : this.preference;
applyTheme(this.active, this.preference);
},
setPreference(preference: ThemePreference): void {
this.preference = preference;
this.active = preference === "system" ? systemTheme() : preference;
applyTheme(this.active, this.preference);
try {
localStorage.setItem(STORAGE_KEY, preference);
} catch (_err) {
// Ignore storage failures in private contexts.
}
},
},
});

20
frontend/src/stores/ui.ts Normal file
View file

@ -0,0 +1,20 @@
import { defineStore } from "pinia";
export interface GlobalErrorState {
message: string;
requestId: string | null;
}
export const useUiStore = defineStore("ui", {
state: () => ({
globalError: null as GlobalErrorState | null,
}),
actions: {
setGlobalError(error: GlobalErrorState): void {
this.globalError = error;
},
clearGlobalError(): void {
this.globalError = null;
},
},
});