feat: production-harden app and finalize merge-ready UX/theme baseline
- complete tokenized theme preset system and admin style guide visibility - refine dashboard layout/scroll behavior and shared async/request UI states - add user nav-visibility settings across API, migration, and frontend stores - harden security headers/CSP handling and related test coverage - enforce CI repository hygiene + frontend contract/theme/build quality gates - update docs/env references and make migration smoke test head-aware
This commit is contained in:
parent
ab1d29b203
commit
ae2ca8f149
66 changed files with 5655 additions and 853 deletions
|
|
@ -4,6 +4,7 @@ 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";
|
||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
export type AuthState = "unknown" | "authenticated" | "anonymous";
|
||||
|
||||
|
|
@ -53,12 +54,16 @@ export const useAuthStore = defineStore("auth", {
|
|||
this.state = "authenticated";
|
||||
this.user = response.data.user;
|
||||
this.csrfToken = response.data.csrf_token;
|
||||
const userSettings = useUserSettingsStore();
|
||||
await userSettings.bootstrap();
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
await logoutSession();
|
||||
this.state = "anonymous";
|
||||
this.user = null;
|
||||
this.csrfToken = null;
|
||||
const userSettings = useUserSettingsStore();
|
||||
userSettings.reset();
|
||||
|
||||
try {
|
||||
const csrf = await fetchCsrfBootstrap();
|
||||
|
|
|
|||
|
|
@ -1,45 +1,87 @@
|
|||
import { defineStore } from "pinia";
|
||||
import {
|
||||
DEFAULT_THEME_PRESET,
|
||||
THEME_PRESET_OPTIONS,
|
||||
getThemePreset,
|
||||
isThemePresetId,
|
||||
themeCssVariables,
|
||||
type ThemePresetId,
|
||||
} from "@/theme/presets";
|
||||
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
export type ThemeValue = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "scholarr-theme-preference";
|
||||
const PRESET_STORAGE_KEY = "scholarr-theme-preset";
|
||||
|
||||
function systemTheme(): ThemeValue {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeValue, preference: ThemePreference): void {
|
||||
function applyTheme(theme: ThemeValue, preference: ThemePreference, preset: ThemePresetId): void {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
root.setAttribute("data-theme-preference", preference);
|
||||
root.setAttribute("data-theme-preset", preset);
|
||||
|
||||
const variables = themeCssVariables(preset, theme);
|
||||
Object.entries(variables).forEach(([name, value]) => {
|
||||
root.style.setProperty(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore("theme", {
|
||||
state: () => ({
|
||||
preference: "system" as ThemePreference,
|
||||
active: "light" as ThemeValue,
|
||||
preset: DEFAULT_THEME_PRESET as ThemePresetId,
|
||||
}),
|
||||
getters: {
|
||||
availablePresets: () => THEME_PRESET_OPTIONS,
|
||||
presetLabel: (state) => getThemePreset(state.preset).label,
|
||||
},
|
||||
actions: {
|
||||
initialize(): void {
|
||||
let stored: string | null = null;
|
||||
let storedPreference: string | null = null;
|
||||
let storedPreset: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
storedPreference = localStorage.getItem(STORAGE_KEY);
|
||||
storedPreset = localStorage.getItem(PRESET_STORAGE_KEY);
|
||||
} catch (_err) {
|
||||
stored = null;
|
||||
storedPreference = null;
|
||||
storedPreset = null;
|
||||
}
|
||||
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
this.preference = stored;
|
||||
if (storedPreference === "light" || storedPreference === "dark" || storedPreference === "system") {
|
||||
this.preference = storedPreference;
|
||||
}
|
||||
|
||||
if (storedPreset && isThemePresetId(storedPreset)) {
|
||||
this.preset = storedPreset;
|
||||
}
|
||||
|
||||
this.active = this.preference === "system" ? systemTheme() : this.preference;
|
||||
applyTheme(this.active, this.preference);
|
||||
applyTheme(this.active, this.preference, this.preset);
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleSystemChange = (): void => {
|
||||
if (this.preference !== "system") {
|
||||
return;
|
||||
}
|
||||
this.active = systemTheme();
|
||||
applyTheme(this.active, this.preference, this.preset);
|
||||
};
|
||||
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", handleSystemChange);
|
||||
} else if (typeof mediaQuery.addListener === "function") {
|
||||
mediaQuery.addListener(handleSystemChange);
|
||||
}
|
||||
},
|
||||
setPreference(preference: ThemePreference): void {
|
||||
this.preference = preference;
|
||||
this.active = preference === "system" ? systemTheme() : preference;
|
||||
applyTheme(this.active, this.preference);
|
||||
applyTheme(this.active, this.preference, this.preset);
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, preference);
|
||||
|
|
@ -47,5 +89,19 @@ export const useThemeStore = defineStore("theme", {
|
|||
// Ignore storage failures in private contexts.
|
||||
}
|
||||
},
|
||||
setPreset(preset: ThemePresetId): void {
|
||||
if (!isThemePresetId(preset)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.preset = preset;
|
||||
applyTheme(this.active, this.preference, this.preset);
|
||||
|
||||
try {
|
||||
localStorage.setItem(PRESET_STORAGE_KEY, preset);
|
||||
} catch (_err) {
|
||||
// Ignore storage failures in private contexts.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
87
frontend/src/stores/user_settings.ts
Normal file
87
frontend/src/stores/user_settings.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
import { fetchSettings, type UserSettings } from "@/features/settings";
|
||||
|
||||
export const REQUIRED_NAV_PAGES = ["dashboard", "scholars", "settings"] as const;
|
||||
export const DEFAULT_NAV_VISIBLE_PAGES = [
|
||||
"dashboard",
|
||||
"scholars",
|
||||
"publications",
|
||||
"settings",
|
||||
"style-guide",
|
||||
"runs",
|
||||
"users",
|
||||
] as const;
|
||||
|
||||
const ALLOWED_NAV_PAGES = new Set<string>(DEFAULT_NAV_VISIBLE_PAGES);
|
||||
const REQUIRED_NAV_PAGES_SET = new Set<string>(REQUIRED_NAV_PAGES);
|
||||
|
||||
function normalizeNavVisiblePages(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [...DEFAULT_NAV_VISIBLE_PAGES];
|
||||
}
|
||||
|
||||
const deduped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const candidate of value) {
|
||||
if (typeof candidate !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageId = candidate.trim();
|
||||
if (!ALLOWED_NAV_PAGES.has(pageId) || seen.has(pageId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(pageId);
|
||||
deduped.push(pageId);
|
||||
}
|
||||
|
||||
for (const requiredPage of REQUIRED_NAV_PAGES) {
|
||||
if (!seen.has(requiredPage)) {
|
||||
deduped.push(requiredPage);
|
||||
seen.add(requiredPage);
|
||||
}
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
export const useUserSettingsStore = defineStore("userSettings", {
|
||||
state: () => ({
|
||||
navVisiblePages: [...DEFAULT_NAV_VISIBLE_PAGES] as string[],
|
||||
}),
|
||||
getters: {
|
||||
visiblePageSet: (state) => new Set(state.navVisiblePages),
|
||||
},
|
||||
actions: {
|
||||
setNavVisiblePages(value: unknown): void {
|
||||
this.navVisiblePages = normalizeNavVisiblePages(value);
|
||||
},
|
||||
applySettings(settings: UserSettings): void {
|
||||
this.setNavVisiblePages(settings.nav_visible_pages);
|
||||
},
|
||||
reset(): void {
|
||||
this.navVisiblePages = [...DEFAULT_NAV_VISIBLE_PAGES];
|
||||
},
|
||||
isPageVisible(pageId: string): boolean {
|
||||
if (REQUIRED_NAV_PAGES_SET.has(pageId)) {
|
||||
return true;
|
||||
}
|
||||
return this.visiblePageSet.has(pageId);
|
||||
},
|
||||
async bootstrap(): Promise<void> {
|
||||
try {
|
||||
const settings = await fetchSettings();
|
||||
this.applySettings(settings);
|
||||
} catch {
|
||||
this.reset();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export function normalizeUserNavVisiblePages(value: unknown): string[] {
|
||||
return normalizeNavVisiblePages(value);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue