Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 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 } 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

@ -187,14 +187,22 @@ async function makeRequestWithToken(
/**
* Check if response indicates token expiration
* Only return true for explicit token expiration, not generic unauthorized errors
*/
function isTokenExpiredResponse(responseData: Record<string, unknown>): boolean {
const error = responseData.error as Record<string, unknown> | undefined;
const errorMessage = String(error?.message || responseData.message || responseData.error || '');
const errorMessage = String(
error?.message || responseData.message || responseData.error || ''
).toLowerCase();
const errorCode = String(responseData.code || error?.code || '');
// Only trigger refresh for explicit token expiration messages
return (
errorMessage === 'JWT expired' || errorCode === 'PGRST301' || errorMessage === 'Unauthorized'
errorMessage.includes('jwt expired') ||
errorMessage.includes('token expired') ||
errorMessage.includes('token has expired') ||
errorCode === 'PGRST301' ||
errorCode === 'TOKEN_EXPIRED'
);
}

View file

@ -54,6 +54,18 @@ const calendarSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fil
// Storage icon (cloud storage with gradient)
const storageSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#storageGrad)"/><path d="M720 520C720 448.471 662.529 391 591 391C584.614 391 578.337 391.479 572.195 392.404C546.867 332.088 487.173 290 418 290C328.157 290 256 362.157 256 452C256 461.033 256.748 469.887 258.179 478.5C214.762 497.476 184 540.728 184 591C184 658.32 238.68 713 306 713H680C749.32 713 804 658.32 804 591C804 547.831 779.054 510.325 742.5 491.5C734.321 503.137 728 516.893 728 532V536C728 567.48 755.482 593 789 593" stroke="white" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/><path d="M512 500V700M512 700L420 608M512 700L604 608" stroke="white" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/><defs><linearGradient id="storageGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#3b82f6"/><stop offset="1" stop-color="#1d4ed8"/></linearGradient></defs></svg>`;
// Clock icon (analog clock with gradient)
const clockSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#clockGrad)"/><circle cx="512" cy="512" r="280" stroke="white" stroke-width="40"/><circle cx="512" cy="512" r="20" fill="white"/><path d="M512 300V400" stroke="white" stroke-width="24" stroke-linecap="round"/><path d="M512 624V724" stroke="white" stroke-width="24" stroke-linecap="round"/><path d="M300 512H400" stroke="white" stroke-width="24" stroke-linecap="round"/><path d="M624 512H724" stroke="white" stroke-width="24" stroke-linecap="round"/><path d="M512 512V340" stroke="white" stroke-width="32" stroke-linecap="round"/><path d="M512 512L620 580" stroke="white" stroke-width="24" stroke-linecap="round"/><circle cx="662" cy="280" r="40" fill="white" fill-opacity="0.3"/><path d="M662 260V280L678 296" stroke="white" stroke-width="8" stroke-linecap="round"/><defs><linearGradient id="clockGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#f59e0b"/><stop offset="1" stop-color="#d97706"/></linearGradient></defs></svg>`;
// Todo icon (checkbox/task list with gradient)
const todoSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#todoGrad)"/><rect x="280" y="300" width="464" height="424" rx="24" fill="white"/><circle cx="360" cy="400" r="28" stroke="#8b5cf6" stroke-width="8"/><path d="M348 400L356 408L372 392" stroke="#8b5cf6" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><rect x="420" y="384" width="280" height="24" rx="6" fill="#8b5cf6" fill-opacity="0.3"/><circle cx="360" cy="512" r="28" stroke="#8b5cf6" stroke-width="8" fill="#8b5cf6"/><path d="M348 512L356 520L372 504" stroke="white" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/><rect x="420" y="496" width="200" height="24" rx="6" fill="#8b5cf6" fill-opacity="0.5"/><circle cx="360" cy="624" r="28" stroke="#8b5cf6" stroke-width="8"/><rect x="420" y="608" width="240" height="24" rx="6" fill="#8b5cf6" fill-opacity="0.3"/><defs><linearGradient id="todoGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#8b5cf6"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs></svg>`;
// Mail icon (envelope with gradient)
const mailSvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#mailGrad)"/><rect x="240" y="320" width="544" height="384" rx="24" fill="white"/><path d="M240 380L512 540L784 380" stroke="#6366f1" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/><rect x="320" y="520" width="200" height="16" rx="4" fill="#6366f1" fill-opacity="0.3"/><rect x="320" y="560" width="160" height="12" rx="3" fill="#6366f1" fill-opacity="0.2"/><rect x="320" y="592" width="240" height="12" rx="3" fill="#6366f1" fill-opacity="0.2"/><defs><linearGradient id="mailGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#6366f1"/><stop offset="1" stop-color="#4f46e5"/></linearGradient></defs></svg>`;
// Inventory icon (box/package with gradient)
const inventorySvg = `<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="130" y="130" width="764" height="764" rx="382" fill="url(#inventoryGrad)"/><path d="M280 380L512 260L744 380V644L512 764L280 644V380Z" fill="white"/><path d="M512 500V764M280 380L512 500L744 380" stroke="#14b8a6" stroke-width="24" stroke-linejoin="round"/><path d="M396 320L628 440" stroke="#14b8a6" stroke-width="16" stroke-linecap="round"/><rect x="460" y="560" width="104" height="80" rx="8" fill="#14b8a6" fill-opacity="0.3"/><defs><linearGradient id="inventoryGrad" x1="130" y1="130" x2="894" y2="894" gradientUnits="userSpaceOnUse"><stop stop-color="#14b8a6"/><stop offset="1" stop-color="#0d9488"/></linearGradient></defs></svg>`;
/**
* App icons as data URLs
* Use these directly in <img src={APP_ICONS.memoro}> or CSS background-image
@ -74,6 +86,10 @@ export const APP_ICONS = {
contacts: svgToDataUrl(contactsSvg),
calendar: svgToDataUrl(calendarSvg),
storage: svgToDataUrl(storageSvg),
clock: svgToDataUrl(clockSvg),
todo: svgToDataUrl(todoSvg),
mail: svgToDataUrl(mailSvg),
inventory: svgToDataUrl(inventorySvg),
} as const;
export type AppIconId = keyof typeof APP_ICONS;

View file

@ -157,6 +157,82 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
mail: {
id: 'mail',
name: 'Mail',
tagline: 'Smart Email Client',
primaryColor: '#6366f1',
secondaryColor: '#818cf8',
// Envelope icon
logoPath:
'M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
storage: {
id: 'storage',
name: 'Storage',
tagline: 'Cloud Storage',
primaryColor: '#3b82f6',
secondaryColor: '#60a5fa',
// Cloud/storage icon
logoPath:
'M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
clock: {
id: 'clock',
name: 'Clock',
tagline: 'Clocks & Alarms',
primaryColor: '#f59e0b',
secondaryColor: '#fbbf24',
// Clock icon
logoPath: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
todo: {
id: 'todo',
name: 'Todo',
tagline: 'Task Management',
primaryColor: '#8b5cf6',
secondaryColor: '#a78bfa',
// Checklist icon
logoPath: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
moodlit: {
id: 'moodlit',
name: 'Moodlit',
tagline: 'Ambient Lighting',
primaryColor: '#8b5cf6',
secondaryColor: '#a78bfa',
// Lightbulb/ambient light icon
logoPath:
'M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
inventory: {
id: 'inventory',
name: 'Inventory',
tagline: 'Inventory Management',
primaryColor: '#14b8a6',
secondaryColor: '#2dd4bf',
// Box/package icon
logoPath:
'M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9',
logoViewBox: '0 0 24 24',
logoStroke: true,
logoStrokeWidth: 1.5,
},
};
/**

View file

@ -26,6 +26,11 @@ export {
ZitareLogo,
ContactsLogo,
CalendarLogo,
StorageLogo,
TodoLogo,
MailLogo,
MoodlitLogo,
InventoryLogo,
} from './logos';
// Configuration

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="inventory" {size} {color} class={className} />

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="mail" {size} {color} class={className} />

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="moodlit" {size} {color} class={className} />

View file

@ -0,0 +1,13 @@
<script lang="ts">
import AppLogo from '../AppLogo.svelte';
interface Props {
size?: number;
color?: string;
class?: string;
}
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="todo" {size} {color} class={className} />

View file

@ -14,3 +14,7 @@ export { default as ZitareLogo } from './ZitareLogo.svelte';
export { default as ContactsLogo } from './ContactsLogo.svelte';
export { default as CalendarLogo } from './CalendarLogo.svelte';
export { default as StorageLogo } from './StorageLogo.svelte';
export { default as TodoLogo } from './TodoLogo.svelte';
export { default as MailLogo } from './MailLogo.svelte';
export { default as MoodlitLogo } from './MoodlitLogo.svelte';
export { default as InventoryLogo } from './InventoryLogo.svelte';

View file

@ -229,6 +229,86 @@ export const MANA_APPS: ManaApp[] = [
comingSoon: false,
status: 'development',
},
{
id: 'clock',
name: 'Clock',
description: {
de: 'Uhren & Wecker',
en: 'Clocks & Alarms',
},
longDescription: {
de: 'Weltzeituhr, Wecker, Timer und stilvolle Uhren-Widgets in einer App.',
en: 'World clock, alarms, timers, and stylish clock widgets in one app.',
},
icon: APP_ICONS.clock,
color: '#f59e0b',
comingSoon: false,
status: 'development',
},
{
id: 'todo',
name: 'Todo',
description: {
de: 'Aufgabenverwaltung',
en: 'Task Management',
},
longDescription: {
de: 'Verwalte Aufgaben mit Projekten, Labels, Subtasks und wiederkehrenden Terminen.',
en: 'Manage tasks with projects, labels, subtasks, and recurring schedules.',
},
icon: APP_ICONS.todo,
color: '#8b5cf6',
comingSoon: false,
status: 'development',
},
{
id: 'mail',
name: 'ManaMail',
description: {
de: 'Smart Email Client',
en: 'Smart Email Client',
},
longDescription: {
de: 'Intelligenter E-Mail-Client mit KI-Zusammenfassungen, Smart Reply und Multi-Account-Unterstützung.',
en: 'Intelligent email client with AI summaries, smart reply, and multi-account support.',
},
icon: APP_ICONS.mail,
color: '#6366f1',
comingSoon: false,
status: 'development',
},
{
id: 'moodlit',
name: 'Moodlit',
description: {
de: 'Ambient Lighting & Moods',
en: 'Ambient Lighting & Moods',
},
longDescription: {
de: 'Erstelle beruhigende Lichtstimmungen mit animierten Farbverläufen für entspannte Atmosphäre.',
en: 'Create calming ambient lighting with animated color gradients for a relaxed atmosphere.',
},
icon: APP_ICONS.moodlit,
color: '#8b5cf6',
comingSoon: false,
status: 'development',
},
{
id: 'inventory',
name: 'Inventory',
description: {
de: 'Besitz-Verwaltung',
en: 'Inventory Management',
},
longDescription: {
de: 'Verwalte deinen Besitz mit Fotos, Kaufbelegen, Garantie-Dokumenten, Kategorien und Standorten.',
en: 'Manage your belongings with photos, receipts, warranty documents, categories, and locations.',
},
icon: APP_ICONS.inventory,
color: '#14b8a6',
comingSoon: false,
status: 'development',
},
];
/**
@ -308,10 +388,14 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
nutriphi: { dev: 'http://localhost:5182', prod: 'https://nutriphi.manacore.app' },
manacore: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
mana: { dev: 'http://localhost:5173', prod: 'https://manacore.app' },
moodlit: { dev: 'http://localhost:5183', prod: 'https://moodlit.manacore.app' },
moodlit: { dev: 'http://localhost:5182', prod: 'https://moodlit.manacore.app' },
contacts: { dev: 'http://localhost:5184', prod: 'https://contacts.manacore.app' },
calendar: { dev: 'http://localhost:5179', prod: 'https://calendar.manacore.app' },
storage: { dev: 'http://localhost:5185', prod: 'https://storage.manacore.app' },
clock: { dev: 'http://localhost:5187', prod: 'https://clock.manacore.app' },
todo: { dev: 'http://localhost:5189', prod: 'https://todo.manacore.app' },
mail: { dev: 'http://localhost:5186', prod: 'https://mail.manacore.app' },
inventory: { dev: 'http://localhost:5188', prod: 'https://inventory.manacore.app' },
};
/**

View file

@ -13,7 +13,13 @@ export type AppId =
| 'zitare'
| 'picture'
| 'contacts'
| 'calendar';
| 'calendar'
| 'storage'
| 'clock'
| 'todo'
| 'mail'
| 'moodlit'
| 'inventory';
/**
* App branding configuration

View file

@ -1,5 +1,9 @@
<script lang="ts">
import type { FeedbackService, Feedback } from '@manacore/shared-feedback-service';
import type {
FeedbackService,
Feedback,
CreateFeedbackInput,
} from '@manacore/shared-feedback-service';
import FeedbackForm from './FeedbackForm.svelte';
import FeedbackList from './FeedbackList.svelte';
@ -66,7 +70,7 @@
}
}
async function handleSubmit(input: { title?: string; feedbackText: string; category?: string }) {
async function handleSubmit(input: CreateFeedbackInput) {
isSubmitting = true;
try {
await feedbackService.createFeedback(input);

View file

@ -130,3 +130,20 @@ export function createStorageStorage(publicUrl?: string): StorageClient {
publicUrl: publicUrl ?? process.env.STORAGE_S3_PUBLIC_URL,
});
}
/**
* Create a storage client for the Mail project
*/
export function createMailStorage(): StorageClient {
return createStorageClient({ name: BUCKETS.MAIL });
}
/**
* Create a storage client for the Inventory project
*/
export function createInventoryStorage(publicUrl?: string): StorageClient {
return createStorageClient({
name: BUCKETS.INVENTORY,
publicUrl: publicUrl ?? process.env.INVENTORY_S3_PUBLIC_URL,
});
}

View file

@ -13,6 +13,8 @@ export {
createCalendarStorage,
createContactsStorage,
createStorageStorage,
createMailStorage,
createInventoryStorage,
} from './factory';
// Utilities

View file

@ -84,6 +84,8 @@ export const BUCKETS = {
CALENDAR: 'calendar-storage',
CONTACTS: 'contacts-storage',
STORAGE: 'storage-storage',
MAIL: 'mail-storage',
INVENTORY: 'inventory-storage',
} as const;
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];

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,201 @@
/**
* App Routes Configuration
*
* Defines available start pages for each app in the ecosystem.
* Used by the start page selector in global settings.
*/
/**
* Route definition with i18n label
*/
export interface AppRoute {
/** Route path (e.g., '/stopwatch') */
path: string;
/** i18n key for the label (e.g., 'nav.stopwatch') */
labelKey: string;
/** Optional icon name */
icon?: string;
}
/**
* App route configuration
*/
export interface AppRouteConfig {
/** App identifier */
appId: string;
/** Default start route (used when no preference set) */
defaultRoute: string;
/** Available routes that can be set as start page */
availableRoutes: AppRoute[];
}
/**
* Route configurations for all apps
*/
export const APP_ROUTES: Record<string, AppRouteConfig> = {
clock: {
appId: 'clock',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.dashboard', icon: 'home' },
{ path: '/alarms', labelKey: 'nav.alarms', icon: 'alarm' },
{ path: '/timers', labelKey: 'nav.timers', icon: 'timer' },
{ path: '/stopwatch', labelKey: 'nav.stopwatch', icon: 'stopwatch' },
{ path: '/pomodoro', labelKey: 'nav.pomodoro', icon: 'target' },
{ path: '/world-clock', labelKey: 'nav.worldClock', icon: 'globe' },
{ path: '/life', labelKey: 'nav.lifeClock', icon: 'heart' },
],
},
calendar: {
appId: 'calendar',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.month', icon: 'calendar' },
{ path: '/agenda', labelKey: 'nav.agenda', icon: 'list' },
],
},
contacts: {
appId: 'contacts',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.contacts', icon: 'users' },
{ path: '/groups', labelKey: 'nav.groups', icon: 'folder' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
],
},
mail: {
appId: 'mail',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.inbox', icon: 'inbox' },
{ path: '/sent', labelKey: 'nav.sent', icon: 'send' },
{ path: '/drafts', labelKey: 'nav.drafts', icon: 'file' },
{ path: '/starred', labelKey: 'nav.starred', icon: 'star' },
],
},
todo: {
appId: 'todo',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.all', icon: 'list' },
{ path: '/today', labelKey: 'nav.today', icon: 'calendar' },
{ path: '/upcoming', labelKey: 'nav.upcoming', icon: 'clock' },
{ path: '/completed', labelKey: 'nav.completed', icon: 'check' },
],
},
storage: {
appId: 'storage',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.home', icon: 'home' },
{ path: '/files', labelKey: 'nav.files', icon: 'folder' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
{ path: '/shared', labelKey: 'nav.shared', icon: 'share' },
],
},
chat: {
appId: 'chat',
defaultRoute: '/chat',
availableRoutes: [
{ path: '/chat', labelKey: 'nav.chat', icon: 'message' },
{ path: '/spaces', labelKey: 'nav.spaces', icon: 'folder' },
{ path: '/templates', labelKey: 'nav.templates', icon: 'file' },
{ path: '/documents', labelKey: 'nav.documents', icon: 'document' },
],
},
picture: {
appId: 'picture',
defaultRoute: '/app/gallery',
availableRoutes: [
{ path: '/app/gallery', labelKey: 'nav.gallery', icon: 'image' },
{ path: '/app/generate', labelKey: 'nav.generate', icon: 'sparkle' },
{ path: '/app/board', labelKey: 'nav.board', icon: 'grid' },
{ path: '/app/explore', labelKey: 'nav.explore', icon: 'compass' },
],
},
manadeck: {
appId: 'manadeck',
defaultRoute: '/decks',
availableRoutes: [
{ path: '/decks', labelKey: 'nav.decks', icon: 'layers' },
{ path: '/explore', labelKey: 'nav.explore', icon: 'compass' },
{ path: '/progress', labelKey: 'nav.progress', icon: 'trending' },
],
},
zitare: {
appId: 'zitare',
defaultRoute: '/',
availableRoutes: [
{ path: '/', labelKey: 'nav.home', icon: 'home' },
{ path: '/quotes', labelKey: 'nav.quotes', icon: 'quote' },
{ path: '/favorites', labelKey: 'nav.favorites', icon: 'star' },
{ path: '/authors', labelKey: 'nav.authors', icon: 'users' },
{ path: '/lists', labelKey: 'nav.lists', icon: 'list' },
],
},
presi: {
appId: 'presi',
defaultRoute: '/',
availableRoutes: [{ path: '/', labelKey: 'nav.home', icon: 'home' }],
},
manacore: {
appId: 'manacore',
defaultRoute: '/',
availableRoutes: [{ path: '/', labelKey: 'nav.dashboard', icon: 'home' }],
},
};
/**
* Get the start page for a specific app
* @param appId The app identifier
* @param startPages User's start page preferences
* @returns The start page path (user preference or app default)
*/
export function getStartPage(appId: string, startPages: Record<string, string> = {}): string {
const config = APP_ROUTES[appId];
if (!config) {
return '/';
}
// Check if user has a preference for this app
const userPreference = startPages[appId];
if (userPreference) {
// Validate that the route is available
const isValid = config.availableRoutes.some((r) => r.path === userPreference);
if (isValid) {
return userPreference;
}
}
// Return app default
return config.defaultRoute;
}
/**
* Get available routes for a specific app
* @param appId The app identifier
* @returns Array of available routes or empty array if app not found
*/
export function getAvailableRoutes(appId: string): AppRoute[] {
return APP_ROUTES[appId]?.availableRoutes ?? [];
}
/**
* Get default route for a specific app
* @param appId The app identifier
* @returns The default route path or '/' if app not found
*/
export function getDefaultRoute(appId: string): string {
return APP_ROUTES[appId]?.defaultRoute ?? '/';
}

View file

@ -24,10 +24,14 @@ export type {
UserSettingsResponse,
UserSettingsStore,
UserSettingsStoreConfig,
// General Settings Types
StartPageConfig,
WeekStartDay,
GeneralSettings,
} from './types';
// User Settings Constants
export { DEFAULT_GLOBAL_SETTINGS } from './types';
export { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types';
// Constants
export {
@ -89,3 +93,7 @@ export {
loadA11yFromStorage,
saveA11yToStorage,
} from './a11y-utils';
// App Routes
export type { AppRoute, AppRouteConfig } from './app-routes';
export { APP_ROUTES, getStartPage, getAvailableRoutes, getDefaultRoute } from './app-routes';

View file

@ -223,6 +223,29 @@ export interface NavSettings {
sidebarCollapsed: boolean;
}
/**
* Start page configuration per app
* Keys are app IDs, values are route paths
*/
export type StartPageConfig = Record<string, string>;
/**
* Day of week for calendar/week starts
*/
export type WeekStartDay = 'monday' | 'sunday';
/**
* General settings (global preferences)
*/
export interface GeneralSettings {
/** Start page per app (e.g., { clock: '/stopwatch', calendar: '/week' }) */
startPages: StartPageConfig;
/** First day of week */
weekStartsOn: WeekStartDay;
/** Master toggle for all app sounds */
soundsEnabled: boolean;
}
/**
* Theme settings (synced to server)
*/
@ -240,6 +263,8 @@ export interface GlobalSettings {
nav: NavSettings;
theme: ThemeSettings;
locale: string;
/** General preferences (start pages, sounds, etc.) */
general: GeneralSettings;
}
/**
@ -258,6 +283,15 @@ export interface UserSettingsResponse {
appOverrides: Record<string, AppOverride>;
}
/**
* Default general settings
*/
export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
startPages: {}, // Empty = use app defaults
weekStartsOn: 'monday',
soundsEnabled: true,
};
/**
* Default global settings
*/
@ -265,6 +299,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
nav: { desktopPosition: 'top', sidebarCollapsed: false },
theme: { mode: 'system', colorScheme: 'ocean' },
locale: 'de',
general: DEFAULT_GENERAL_SETTINGS,
};
/**
@ -277,6 +312,10 @@ export interface UserSettingsStore {
readonly theme: ThemeSettings;
/** Current locale */
readonly locale: string;
/** Resolved general settings */
readonly general: GeneralSettings;
/** Start page for current app (resolved from settings or default) */
readonly startPage: string;
/** Raw global settings */
readonly globalSettings: GlobalSettings;
/** Whether current app has an override */
@ -294,6 +333,10 @@ export interface UserSettingsStore {
updateAppOverride: (settings: AppOverride) => Promise<void>;
/** Remove app override (revert to global) */
removeAppOverride: () => Promise<void>;
/** Set start page for a specific app */
setStartPage: (appId: string, path: string) => Promise<void>;
/** Update general settings */
updateGeneral: (settings: Partial<GeneralSettings>) => Promise<void>;
}
/**

View file

@ -6,9 +6,11 @@ import type {
NavSettings,
ThemeSettings,
UserSettingsResponse,
GeneralSettings,
} from './types';
import { DEFAULT_GLOBAL_SETTINGS } from './types';
import { DEFAULT_GLOBAL_SETTINGS, DEFAULT_GENERAL_SETTINGS } from './types';
import { isBrowser } from './utils';
import { getStartPage as getStartPageFromConfig } from './app-routes';
const STORAGE_KEY_PREFIX = 'manacore-user-settings';
@ -66,6 +68,15 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
// Derived: whether this app has an override
const hasAppOverride = $derived(!!appOverrides[appId]);
// Derived: resolved general settings (always from global)
const general = $derived<GeneralSettings>({
...DEFAULT_GENERAL_SETTINGS,
...globalSettings.general,
});
// Derived: start page for current app
const startPage = $derived(getStartPageFromConfig(appId, general.startPages));
/**
* Save current settings to localStorage (for offline fallback)
*/
@ -172,6 +183,14 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
nav: { ...globalSettings.nav, ...settings.nav },
theme: { ...globalSettings.theme, ...settings.theme },
locale: settings.locale ?? globalSettings.locale,
general: {
...globalSettings.general,
...settings.general,
startPages: {
...globalSettings.general?.startPages,
...settings.general?.startPages,
},
},
};
saveToStorage();
@ -234,6 +253,35 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
}
}
/**
* Update start page for a specific app
*/
async function setStartPage(targetAppId: string, path: string): Promise<void> {
await updateGlobal({
general: {
startPages: {
[targetAppId]: path,
},
},
} as Partial<GlobalSettings>);
}
/**
* Update general settings
*/
async function updateGeneral(settings: Partial<GeneralSettings>): Promise<void> {
await updateGlobal({
general: {
...globalSettings.general,
...settings,
startPages: {
...globalSettings.general?.startPages,
...settings.startPages,
},
},
});
}
/**
* Remove app override (revert to global settings)
*/
@ -276,6 +324,12 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
get locale() {
return locale;
},
get general() {
return general;
},
get startPage() {
return startPage;
},
get globalSettings() {
return globalSettings;
},
@ -293,5 +347,7 @@ export function createUserSettingsStore(config: UserSettingsStoreConfig): UserSe
updateGlobal,
updateAppOverride,
removeAppOverride,
setStartPage,
updateGeneral,
};
}

View file

@ -36,6 +36,7 @@
},
"dependencies": {
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-icons": "workspace:*"
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-theme": "workspace:*"
}
}

View file

@ -53,6 +53,8 @@
size?: HeaderSize;
/** Whether to show bottom border */
bordered?: boolean;
/** Center the title (with actions on the right, back on the left) */
centered?: boolean;
/** Back navigation href (shows back arrow button) */
backHref?: string;
/** Icon snippet (before title) */
@ -72,6 +74,7 @@
description,
size = 'md',
bordered = false,
centered = false,
backHref,
icon,
breadcrumb,
@ -103,18 +106,23 @@
>
<!-- Breadcrumb -->
{#if breadcrumb}
<div class="page-header__breadcrumb mb-2 text-sm text-theme-secondary">
<div
class="page-header__breadcrumb mb-2 text-sm text-theme-secondary {centered
? 'text-center'
: ''}"
>
{@render breadcrumb()}
</div>
{/if}
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<!-- Back Button -->
{#if centered}
<!-- Centered Layout -->
<div class="relative flex items-center justify-center min-h-[2.5rem]">
<!-- Back Button (left) -->
{#if backHref}
<a
href={backHref}
class="page-header__back flex-shrink-0 p-1.5 -ml-1.5 rounded-lg text-theme-secondary hover:text-theme hover:bg-theme-secondary/10 transition-colors"
class="absolute left-0 p-1.5 rounded-lg text-theme-secondary hover:text-theme hover:bg-theme-secondary/10 transition-colors"
aria-label="Zurück"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -128,16 +136,14 @@
</a>
{/if}
<!-- Icon -->
{#if icon}
<div class="page-header__icon flex-shrink-0 text-theme-secondary">
{@render icon()}
</div>
{/if}
<!-- Title & Description -->
<div class="min-w-0">
<h1 class="font-semibold text-theme {sizeClasses[size].title} truncate">
<!-- Centered Title & Description -->
<div class="text-center">
{#if icon}
<div class="page-header__icon inline-block text-theme-secondary mb-1">
{@render icon()}
</div>
{/if}
<h1 class="font-semibold text-theme {sizeClasses[size].title}">
{title}
</h1>
{#if description}
@ -146,15 +152,64 @@
</Text>
{/if}
</div>
</div>
<!-- Actions -->
{#if actions}
<div class="page-header__actions flex-shrink-0 flex items-center gap-2">
{@render actions()}
<!-- Actions (right) -->
{#if actions}
<div class="absolute right-0 flex items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
{:else}
<!-- Default Layout -->
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-3 min-w-0">
<!-- Back Button -->
{#if backHref}
<a
href={backHref}
class="page-header__back flex-shrink-0 p-1.5 -ml-1.5 rounded-lg text-theme-secondary hover:text-theme hover:bg-theme-secondary/10 transition-colors"
aria-label="Zurück"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
</a>
{/if}
<!-- Icon -->
{#if icon}
<div class="page-header__icon flex-shrink-0 text-theme-secondary">
{@render icon()}
</div>
{/if}
<!-- Title & Description -->
<div class="min-w-0">
<h1 class="font-semibold text-theme {sizeClasses[size].title} truncate">
{title}
</h1>
{#if description}
<Text variant="muted" class="mt-1">
{description}
</Text>
{/if}
</div>
</div>
{/if}
</div>
<!-- Actions -->
{#if actions}
<div class="page-header__actions flex-shrink-0 flex items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
{/if}
<!-- Tabs/Navigation -->
{#if tabs}

View file

@ -259,6 +259,23 @@
// Icon SVG paths
const icons: Record<string, string> = {
// Clock app icons
bell: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9',
clock: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
timer: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
stopwatch: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0zM9 3h6m-3-1v2',
activity: 'M13 10V3L4 14h7v7l9-11h-7z',
target:
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm0-14c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm0-6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
globe:
'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
// Todo app icons
inbox:
'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4',
check: 'M5 13l4 4L19 7',
checkCircle: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
plus: 'M12 4v16m8-8H4',
// Original icons
mic: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z',
calendar:
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',

View file

@ -1,32 +1,54 @@
<script lang="ts">
import type { UserSettingsStore, NavPosition, ThemeMode } from '@manacore/shared-theme';
import type {
UserSettingsStore,
NavPosition,
ThemeMode,
WeekStartDay,
} from '@manacore/shared-theme';
import { getAvailableRoutes, getDefaultRoute } from '@manacore/shared-theme';
import SettingsSection from './SettingsSection.svelte';
import SettingsCard from './SettingsCard.svelte';
interface Props {
/** User settings store instance */
userSettings: UserSettingsStore;
/** App ID for start page selection */
appId?: string;
/** Whether to show navigation settings */
showNavigation?: boolean;
/** Whether to show theme settings */
showTheme?: boolean;
/** Whether to show language settings */
showLanguage?: boolean;
/** Whether to show general settings (start page, sounds, week start) */
showGeneral?: boolean;
/** Section title */
title?: string;
/** Section description */
description?: string;
/** Translation function (optional, falls back to German) */
t?: (key: string) => string;
}
let {
userSettings,
appId,
showNavigation = true,
showTheme = true,
showLanguage = true,
showGeneral = true,
title = 'App-Einstellungen',
description = 'Diese Einstellungen gelten für alle Mana Apps',
t = (key: string) => key,
}: Props = $props();
// Available routes for start page selection
const availableRoutes = $derived(appId ? getAvailableRoutes(appId) : []);
const defaultRoute = $derived(appId ? getDefaultRoute(appId) : '/');
const currentStartPage = $derived(
appId ? userSettings.general?.startPages?.[appId] || defaultRoute : '/'
);
// Navigation position handler
async function handleNavPositionChange(position: NavPosition) {
await userSettings.updateGlobal({
@ -60,6 +82,24 @@
await userSettings.updateGlobal({ locale });
}
// Start page handler
async function handleStartPageChange(e: Event) {
const target = e.target as HTMLSelectElement;
if (appId) {
await userSettings.setStartPage(appId, target.value);
}
}
// Week start handler
async function handleWeekStartChange(day: WeekStartDay) {
await userSettings.updateGeneral({ weekStartsOn: day });
}
// Sounds handler
async function handleSoundsChange(enabled: boolean) {
await userSettings.updateGeneral({ soundsEnabled: enabled });
}
const colorSchemes = [
{ id: 'ocean', label: 'Ozean', color: 'bg-blue-500' },
{ id: 'nature', label: 'Natur', color: 'bg-green-500' },
@ -259,6 +299,104 @@
</div>
</div>
{/if}
{#if showGeneral}
<!-- General Settings -->
<div
class="space-y-4 {showLanguage || showTheme || showNavigation
? 'pt-4 border-t border-[hsl(var(--border))]'
: ''}"
>
<h3
class="text-xs font-semibold text-[hsl(var(--muted-foreground))] uppercase tracking-wider"
>
Allgemein
</h3>
{#if appId && availableRoutes.length > 1}
<!-- Start Page Selector -->
<div class="flex items-center justify-between py-2">
<div>
<p class="font-medium text-[hsl(var(--foreground))]">Startseite</p>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Welche Seite beim Öffnen der App angezeigt wird
</p>
</div>
<select
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-[hsl(var(--muted))] text-[hsl(var(--foreground))] border-none cursor-pointer appearance-none pr-8 bg-[url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23666%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E')] bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem]"
value={currentStartPage}
onchange={handleStartPageChange}
>
{#each availableRoutes as route}
<option value={route.path}>
{t(route.labelKey)}
</option>
{/each}
</select>
</div>
{/if}
<!-- Week Start Day -->
<div
class="flex items-center justify-between py-2 {appId && availableRoutes.length > 1
? 'border-t border-[hsl(var(--border))]'
: ''}"
>
<div>
<p class="font-medium text-[hsl(var(--foreground))]">Wochenstart</p>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Erster Tag der Woche in Kalendern
</p>
</div>
<div class="flex gap-2">
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {userSettings
.general?.weekStartsOn === 'monday'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] hover:bg-[hsl(var(--muted))]/80 text-[hsl(var(--foreground))]'}"
onclick={() => handleWeekStartChange('monday')}
>
Montag
</button>
<button
class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors {userSettings
.general?.weekStartsOn === 'sunday'
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'bg-[hsl(var(--muted))] hover:bg-[hsl(var(--muted))]/80 text-[hsl(var(--foreground))]'}"
onclick={() => handleWeekStartChange('sunday')}
>
Sonntag
</button>
</div>
</div>
<!-- Sounds Toggle -->
<div
class="flex items-center justify-between py-2 border-t border-[hsl(var(--border))]"
>
<div>
<p class="font-medium text-[hsl(var(--foreground))]">Sounds</p>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Sound-Effekte in allen Apps
</p>
</div>
<button
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors {(userSettings
.general?.soundsEnabled ?? true)
? 'bg-[hsl(var(--primary))]'
: 'bg-gray-200 dark:bg-gray-700'}"
onclick={() => handleSoundsChange(!(userSettings.general?.soundsEnabled ?? true))}
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform {(userSettings
.general?.soundsEnabled ?? true)
? 'translate-x-6'
: 'translate-x-1'}"
></span>
</button>
</div>
</div>
{/if}
</div>
{#if userSettings.syncing}

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