♻️ 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,17 @@
{
"name": "@manacore/shared-api-client",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,218 @@
/**
* Shared API Client Factory
* Creates a configured API client for making authenticated requests.
*/
import type { ApiResponse, FetchOptions, HttpMethod } from './types';
export interface ApiClientConfig {
/** Base URL for the API (e.g., 'http://localhost:3002') */
baseUrl: string;
/** Optional API prefix (default: '/api') */
apiPrefix?: string;
/** Function to get the current auth token */
getToken?: () => Promise<string | null> | string | null;
/** Whether running in browser environment */
isBrowser?: boolean;
/** Local storage key for token fallback */
tokenStorageKey?: string;
}
export interface ApiClient {
/** Make a GET request */
get: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) => Promise<ApiResponse<T>>;
/** Make a POST request */
post: <T>(
endpoint: string,
body?: unknown,
options?: Omit<FetchOptions, 'method' | 'body'>
) => Promise<ApiResponse<T>>;
/** Make a PUT request */
put: <T>(
endpoint: string,
body?: unknown,
options?: Omit<FetchOptions, 'method' | 'body'>
) => Promise<ApiResponse<T>>;
/** Make a PATCH request */
patch: <T>(
endpoint: string,
body?: unknown,
options?: Omit<FetchOptions, 'method' | 'body'>
) => Promise<ApiResponse<T>>;
/** Make a DELETE request */
delete: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) => Promise<ApiResponse<T>>;
/** Make a request with any method */
request: <T>(endpoint: string, options?: FetchOptions) => Promise<ApiResponse<T>>;
/** Upload a single file */
uploadFile: <T>(endpoint: string, file: File, token?: string) => Promise<ApiResponse<T>>;
/** Upload multiple files */
uploadFiles: <T>(endpoint: string, files: File[], token?: string) => Promise<ApiResponse<T>>;
}
/**
* Create an API client with the given configuration.
*/
export function createApiClient(config: ApiClientConfig): ApiClient {
const { baseUrl, apiPrefix = '/api', getToken, isBrowser = true, tokenStorageKey } = config;
async function getAuthToken(providedToken?: string): Promise<string | undefined> {
if (providedToken) return providedToken;
if (getToken) {
const token = await getToken();
if (token) return token;
}
// Fallback to localStorage if in browser and key provided
if (isBrowser && tokenStorageKey && typeof localStorage !== 'undefined') {
return localStorage.getItem(tokenStorageKey) || undefined;
}
return undefined;
}
async function request<T>(endpoint: string, options: FetchOptions = {}): Promise<ApiResponse<T>> {
const { method = 'GET', body, token, isFormData = false, headers: customHeaders } = options;
const authToken = await getAuthToken(token);
try {
const headers: Record<string, string> = { ...customHeaders };
// Don't set Content-Type for FormData - browser sets it automatically with boundary
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const url = `${baseUrl}${apiPrefix}${endpoint}`;
const response = await fetch(url, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
async function uploadFile<T>(
endpoint: string,
file: File,
token?: string
): Promise<ApiResponse<T>> {
const authToken = await getAuthToken(token);
try {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `Upload error: ${response.status}`),
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Upload failed'),
};
}
}
async function uploadFiles<T>(
endpoint: string,
files: File[],
token?: string
): Promise<ApiResponse<T>> {
const authToken = await getAuthToken(token);
try {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${baseUrl}${apiPrefix}${endpoint}`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `Upload error: ${response.status}`),
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Upload failed'),
};
}
}
return {
get: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) =>
request<T>(endpoint, { ...options, method: 'GET' }),
post: <T>(endpoint: string, body?: unknown, options?: Omit<FetchOptions, 'method' | 'body'>) =>
request<T>(endpoint, { ...options, method: 'POST', body }),
put: <T>(endpoint: string, body?: unknown, options?: Omit<FetchOptions, 'method' | 'body'>) =>
request<T>(endpoint, { ...options, method: 'PUT', body }),
patch: <T>(endpoint: string, body?: unknown, options?: Omit<FetchOptions, 'method' | 'body'>) =>
request<T>(endpoint, { ...options, method: 'PATCH', body }),
delete: <T>(endpoint: string, options?: Omit<FetchOptions, 'method'>) =>
request<T>(endpoint, { ...options, method: 'DELETE' }),
request,
uploadFile,
uploadFiles,
};
}

View file

@ -0,0 +1,7 @@
/**
* Shared API Client for ManaCore Apps
* Provides a unified way to make API calls with authentication.
*/
export { createApiClient, type ApiClientConfig, type ApiClient } from './client';
export { type ApiResponse, type FetchOptions, type HttpMethod } from './types';

View file

@ -0,0 +1,18 @@
/**
* Shared API Client Types
*/
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
export interface FetchOptions {
method?: HttpMethod;
body?: unknown;
token?: string;
isFormData?: boolean;
headers?: Record<string, string>;
}
export interface ApiResponse<T> {
data: T | null;
error: Error | null;
}

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"]
}

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"]
}

View file

@ -0,0 +1,18 @@
{
"name": "@manacore/shared-vite-config",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.0.0",
"vite": "^6.0.0"
}
}

View file

@ -0,0 +1,130 @@
/**
* Shared Vite Configuration for ManaCore Web Apps
* Provides consistent SSR and optimization settings.
*/
import type { UserConfig } from 'vite';
/**
* Common ManaCore shared packages that need SSR configuration.
* These packages contain Svelte 5 runes or other client-side state.
*/
export const MANACORE_SHARED_PACKAGES = [
'@manacore/shared-icons',
'@manacore/shared-ui',
'@manacore/shared-tailwind',
'@manacore/shared-theme',
'@manacore/shared-theme-ui',
'@manacore/shared-feedback-ui',
'@manacore/shared-feedback-service',
'@manacore/shared-feedback-types',
'@manacore/shared-auth',
'@manacore/shared-auth-ui',
'@manacore/shared-branding',
'@manacore/shared-subscription-ui',
'@manacore/shared-profile-ui',
'@manacore/shared-i18n',
'@manacore/shared-api-client',
] as const;
export interface ViteConfigOptions {
/** Server port */
port: number;
/** Additional packages to include in noExternal (e.g., app-specific shared packages) */
additionalPackages?: string[];
/** Additional packages to exclude from optimization */
additionalExcludes?: string[];
/** Override default shared packages (if you need a subset) */
sharedPackages?: string[];
}
/**
* Get the SSR noExternal configuration for ManaCore apps.
*/
export function getSsrNoExternal(additionalPackages: string[] = []): string[] {
return [...MANACORE_SHARED_PACKAGES, ...additionalPackages];
}
/**
* Get the optimizeDeps exclude configuration for ManaCore apps.
*/
export function getOptimizeDepsExclude(additionalExcludes: string[] = []): string[] {
return [...MANACORE_SHARED_PACKAGES, ...additionalExcludes];
}
/**
* Create a base Vite configuration for ManaCore SvelteKit apps.
* Merge this with your app-specific configuration.
*/
export function createViteConfig(options: ViteConfigOptions): Partial<UserConfig> {
const { port, additionalPackages = [], additionalExcludes = [] } = options;
const packages = options.sharedPackages || [...MANACORE_SHARED_PACKAGES];
const noExternal = [...packages, ...additionalPackages];
const exclude = [...packages, ...additionalExcludes];
return {
server: {
port,
strictPort: true,
},
ssr: {
noExternal,
},
optimizeDeps: {
exclude,
},
};
}
/**
* Merge base config with app-specific plugins and settings.
* Use this in your vite.config.ts:
*
* @example
* ```ts
* import { sveltekit } from '@sveltejs/kit/vite';
* import tailwindcss from '@tailwindcss/vite';
* import { defineConfig } from 'vite';
* import { createViteConfig, mergeViteConfig } from '@manacore/shared-vite-config';
*
* const baseConfig = createViteConfig({
* port: 5174,
* additionalPackages: ['@chat/shared'],
* });
*
* export default defineConfig(mergeViteConfig(baseConfig, {
* plugins: [tailwindcss(), sveltekit()],
* }));
* ```
*/
export function mergeViteConfig(
baseConfig: Partial<UserConfig>,
appConfig: Partial<UserConfig>
): UserConfig {
return {
...baseConfig,
...appConfig,
server: {
...baseConfig.server,
...appConfig.server,
},
ssr: {
...baseConfig.ssr,
...appConfig.ssr,
noExternal: [
...((baseConfig.ssr?.noExternal as string[]) || []),
...((appConfig.ssr?.noExternal as string[]) || []),
],
},
optimizeDeps: {
...baseConfig.optimizeDeps,
...appConfig.optimizeDeps,
exclude: [
...(baseConfig.optimizeDeps?.exclude || []),
...(appConfig.optimizeDeps?.exclude || []),
],
},
plugins: [...(baseConfig.plugins || []), ...(appConfig.plugins || [])],
};
}

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"]
}