♻️ refactor: unify web app patterns across monorepo

- Rename authStore.svelte.ts to auth.svelte.ts (manacore, manadeck, moodlit)
- Add i18n setup to Finance and Mail apps (DE, EN, FR, ES, IT)
- Add feedback pages using shared component to Finance and Mail
- Create @manacore/shared-api-client package with API client factory
- Create @manacore/shared-vite-config package with SSR config helpers
- Create @manacore/shared-stores package with toast, navigation, theme factories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-05 03:35:26 +01:00
parent c93aca0cce
commit cfbc8a2c15
60 changed files with 2213 additions and 173 deletions

View file

@ -0,0 +1,21 @@
{
"name": "@manacore/shared-stores",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "echo 'Skipping: shared-stores uses Svelte 5 runes, type-checked at build time'"
},
"devDependencies": {
"svelte": "^5.0.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@manacore/shared-auth": "workspace:*"
}
}

View file

@ -0,0 +1,12 @@
/**
* Shared Store Factories for ManaCore Apps
* Provides reusable Svelte 5 runes-based stores.
*/
export { createToastStore, type Toast, type ToastStore, type ToastType } from './toast.svelte';
export {
createNavigationStore,
type NavigationItem,
type NavigationStore,
} from './navigation.svelte';
export { createThemeStore, type ThemeStore, type ThemeMode } from './theme.svelte';

View file

@ -0,0 +1,117 @@
/**
* Navigation Store Factory
* Creates a navigation state store with Svelte 5 runes.
*/
export interface NavigationItem {
href: string;
label: string;
icon?: string;
badge?: string | number;
children?: NavigationItem[];
}
export interface NavigationStore {
readonly items: NavigationItem[];
readonly isOpen: boolean;
readonly isSidebarMode: boolean;
readonly isCollapsed: boolean;
setItems: (items: NavigationItem[]) => void;
toggle: () => void;
open: () => void;
close: () => void;
setSidebarMode: (isSidebar: boolean) => void;
setCollapsed: (collapsed: boolean) => void;
}
export interface NavigationStoreConfig {
/** Initial navigation items */
initialItems?: NavigationItem[];
/** Storage key for persisting sidebar mode */
storageKey?: string;
/** Whether to start in sidebar mode */
defaultSidebarMode?: boolean;
/** Whether to start collapsed */
defaultCollapsed?: boolean;
}
/**
* Create a navigation store with Svelte 5 runes.
*/
export function createNavigationStore(config: NavigationStoreConfig = {}): NavigationStore {
const {
initialItems = [],
storageKey,
defaultSidebarMode = false,
defaultCollapsed = false,
} = config;
let items = $state<NavigationItem[]>(initialItems);
let isOpen = $state(false);
let isSidebarMode = $state(defaultSidebarMode);
let isCollapsed = $state(defaultCollapsed);
// Load from localStorage if available
if (storageKey && typeof localStorage !== 'undefined') {
const savedSidebar = localStorage.getItem(`${storageKey}-sidebar`);
const savedCollapsed = localStorage.getItem(`${storageKey}-collapsed`);
if (savedSidebar !== null) {
isSidebarMode = savedSidebar === 'true';
}
if (savedCollapsed !== null) {
isCollapsed = savedCollapsed === 'true';
}
}
function setItems(newItems: NavigationItem[]) {
items = newItems;
}
function toggle() {
isOpen = !isOpen;
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
}
function setSidebarMode(sidebar: boolean) {
isSidebarMode = sidebar;
if (storageKey && typeof localStorage !== 'undefined') {
localStorage.setItem(`${storageKey}-sidebar`, String(sidebar));
}
}
function setCollapsed(collapsed: boolean) {
isCollapsed = collapsed;
if (storageKey && typeof localStorage !== 'undefined') {
localStorage.setItem(`${storageKey}-collapsed`, String(collapsed));
}
}
return {
get items() {
return items;
},
get isOpen() {
return isOpen;
},
get isSidebarMode() {
return isSidebarMode;
},
get isCollapsed() {
return isCollapsed;
},
setItems,
toggle,
open,
close,
setSidebarMode,
setCollapsed,
};
}

View file

@ -0,0 +1,125 @@
/**
* Theme Store Factory
* Creates a theme state store with Svelte 5 runes.
*/
export type ThemeMode = 'light' | 'dark' | 'system';
export interface ThemeStore {
readonly isDark: boolean;
readonly mode: ThemeMode;
readonly variant: string;
initialize: () => () => void;
setMode: (mode: ThemeMode) => void;
setVariant: (variant: string) => void;
toggle: () => void;
}
export interface ThemeStoreConfig {
/** Storage key prefix (default: 'theme') */
storagePrefix?: string;
/** Default theme mode */
defaultMode?: ThemeMode;
/** Default theme variant */
defaultVariant?: string;
/** CSS class to add/remove for dark mode */
darkClass?: string;
/** Data attribute for variant */
variantAttribute?: string;
}
/**
* Create a theme store with Svelte 5 runes.
*/
export function createThemeStore(config: ThemeStoreConfig = {}): ThemeStore {
const {
storagePrefix = 'theme',
defaultMode = 'system',
defaultVariant = 'default',
darkClass = 'dark',
variantAttribute = 'data-theme',
} = config;
let isDark = $state(false);
let mode = $state<ThemeMode>(defaultMode);
let variant = $state(defaultVariant);
function updateTheme() {
if (typeof window === 'undefined') return;
let shouldBeDark = false;
if (mode === 'dark') {
shouldBeDark = true;
} else if (mode === 'system') {
shouldBeDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
isDark = shouldBeDark;
document.documentElement.classList.toggle(darkClass, isDark);
}
function initialize(): () => void {
if (typeof window === 'undefined') return () => {};
// Load from localStorage
const savedMode = localStorage.getItem(`${storagePrefix}-mode`) as ThemeMode | null;
const savedVariant = localStorage.getItem(`${storagePrefix}-variant`);
if (savedMode) mode = savedMode;
if (savedVariant) {
variant = savedVariant;
document.documentElement.setAttribute(variantAttribute, variant);
}
updateTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (mode === 'system') {
updateTheme();
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}
function setMode(newMode: ThemeMode) {
mode = newMode;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(`${storagePrefix}-mode`, newMode);
}
updateTheme();
}
function setVariant(newVariant: string) {
variant = newVariant;
if (typeof localStorage !== 'undefined') {
localStorage.setItem(`${storagePrefix}-variant`, newVariant);
}
if (typeof document !== 'undefined') {
document.documentElement.setAttribute(variantAttribute, newVariant);
}
}
function toggle() {
setMode(isDark ? 'light' : 'dark');
}
return {
get isDark() {
return isDark;
},
get mode() {
return mode;
},
get variant() {
return variant;
},
initialize,
setMode,
setVariant,
toggle,
};
}

View file

@ -0,0 +1,76 @@
/**
* Toast Store Factory
* Creates a toast notification store with Svelte 5 runes.
*/
export type ToastType = 'success' | 'error' | 'info' | 'warning';
export interface Toast {
id: string;
type: ToastType;
message: string;
duration?: number;
}
export interface ToastStore {
readonly toasts: Toast[];
show: (message: string, type?: ToastType, duration?: number) => void;
success: (message: string, duration?: number) => void;
error: (message: string, duration?: number) => void;
info: (message: string, duration?: number) => void;
warning: (message: string, duration?: number) => void;
dismiss: (id: string) => void;
clear: () => void;
}
export interface ToastStoreConfig {
/** Default duration in milliseconds (default: 5000) */
defaultDuration?: number;
/** Maximum number of toasts visible at once */
maxToasts?: number;
}
/**
* Create a toast store with Svelte 5 runes.
*/
export function createToastStore(config: ToastStoreConfig = {}): ToastStore {
const { defaultDuration = 5000, maxToasts = 5 } = config;
let toasts = $state<Toast[]>([]);
function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
function show(message: string, type: ToastType = 'info', duration: number = defaultDuration) {
const id = generateId();
const toast: Toast = { id, type, message, duration };
toasts = [...toasts.slice(-(maxToasts - 1)), toast];
if (duration > 0) {
setTimeout(() => dismiss(id), duration);
}
}
function dismiss(id: string) {
toasts = toasts.filter((t) => t.id !== id);
}
function clear() {
toasts = [];
}
return {
get toasts() {
return toasts;
},
show,
success: (message: string, duration?: number) => show(message, 'success', duration),
error: (message: string, duration?: number) => show(message, 'error', duration),
info: (message: string, duration?: number) => show(message, 'info', duration),
warning: (message: string, duration?: number) => show(message, 'warning', duration),
dismiss,
clear,
};
}

View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}