refactor(finance): reorganize routes into (app) layout group

Move all authenticated routes into (app) layout group for better
code organization and layout management. Add missing stores.

🤖 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 04:20:02 +01:00
parent 4a41b45efb
commit ebec369a57
14 changed files with 346 additions and 173 deletions

View file

@ -5,3 +5,6 @@ export { transactionsStore } from './transactions.svelte';
export { budgetsStore } from './budgets.svelte';
export { dashboardStore } from './dashboard.svelte';
export { settingsStore } from './settings.svelte';
export { theme } from './theme';
export { isSidebarMode, isNavCollapsed } from './navigation';
export { userSettings } from './user-settings.svelte';

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,6 @@
import { createTheme, type ThemeStore } from '@manacore/shared-theme';
export const theme: ThemeStore = createTheme({
storagePrefix: 'finance',
variants: ['default', 'blue', 'green', 'purple', 'orange', 'pink'],
});

View file

@ -0,0 +1,66 @@
/**
* User Settings Store for Finance
* Manages user preferences and settings
*/
interface UserSettings {
currency: string;
locale: string;
dateFormat: string;
nav: {
desktopPosition: 'left' | 'center' | 'right';
};
}
const defaultSettings: UserSettings = {
currency: 'EUR',
locale: 'de',
dateFormat: 'dd.MM.yyyy',
nav: {
desktopPosition: 'center',
},
};
let settings = $state<UserSettings>({ ...defaultSettings });
let isLoaded = $state(false);
export const userSettings = {
get currency() {
return settings.currency;
},
get locale() {
return settings.locale;
},
get dateFormat() {
return settings.dateFormat;
},
get nav() {
return settings.nav;
},
get isLoaded() {
return isLoaded;
},
async load() {
if (typeof window === 'undefined') return;
// Load from localStorage
const saved = localStorage.getItem('finance-user-settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
settings = { ...defaultSettings, ...parsed };
} catch {
// Ignore parse errors
}
}
isLoaded = true;
},
update(updates: Partial<UserSettings>) {
settings = { ...settings, ...updates };
if (typeof localStorage !== 'undefined') {
localStorage.setItem('finance-user-settings', JSON.stringify(settings));
}
},
};

View file

@ -0,0 +1,246 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import {
authStore,
theme,
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
userSettings,
} from '$lib/stores';
// App switcher items
const appItems = getPillAppItems('finance');
let { children } = $props();
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Finance
const navItems: PillNavItem[] = [
{ href: '/', label: 'Dashboard', icon: 'home' },
{ href: '/transactions', label: 'Transaktionen', icon: 'list' },
{ href: '/accounts', label: 'Konten', icon: 'wallet' },
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
{ href: '/budgets', label: 'Budgets', icon: 'pie-chart' },
{ href: '/reports', label: 'Berichte', icon: 'bar-chart' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-7)
const navRoutes = navItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('finance-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('finance-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
authStore.logout();
goto('/login');
}
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Load user settings
await userSettings.load();
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('finance-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('finance-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Navigation Layout -->
<div class="layout-container">
<!-- Floating/Sidebar Pill Navigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Finance"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition={userSettings.nav.desktopPosition}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#10b981"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<!-- Main Content with dynamic padding based on nav mode -->
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
flex: 1;
transition: all 300ms ease;
}
/* Floating nav mode - add top padding for fixed nav */
.main-content.floating-mode {
padding-top: 100px;
}
/* Sidebar mode - add left padding for sidebar nav */
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 80rem;
margin-left: auto;
margin-right: auto;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.content-wrapper {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding-left: 2rem;
padding-right: 2rem;
}
}
</style>

View file

@ -2,186 +2,34 @@
import '../app.css';
import '$lib/i18n';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { authStore } from '$lib/stores';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore, theme } from '$lib/stores';
let { children } = $props();
let isAppSliderOpen = $state(false);
let isDark = $state(false);
let loading = $state(true);
const navItems = [
{ href: '/', label: 'Dashboard', icon: 'home' },
{ href: '/transactions', label: 'Transaktionen', icon: 'list' },
{ href: '/accounts', label: 'Konten', icon: 'wallet' },
{ href: '/categories', label: 'Kategorien', icon: 'tag' },
{ href: '/budgets', label: 'Budgets', icon: 'pie-chart' },
{ href: '/reports', label: 'Berichte', icon: 'bar-chart' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
];
onMount(async () => {
// Initialize theme
theme.initialize();
onMount(() => {
authStore.init();
// Check for dark mode preference
isDark =
document.documentElement.classList.contains('dark') ||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
}
// Initialize auth
await authStore.init();
loading = false;
});
function toggleTheme() {
isDark = !isDark;
if (isDark) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}
function isActive(href: string) {
if (href === '/') {
return $page.url.pathname === '/';
}
return $page.url.pathname.startsWith(href);
}
</script>
<div class="min-h-screen bg-background text-foreground">
<!-- Header -->
<header class="sticky top-0 z-50 border-b border-border bg-card">
<div class="container mx-auto flex h-16 items-center justify-between px-4">
<div class="flex items-center gap-4">
<button
onclick={() => (isAppSliderOpen = true)}
class="flex items-center gap-2 rounded-lg p-2 hover:bg-accent"
aria-label="Open app menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</button>
<a href="/" class="flex items-center gap-2 font-semibold">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-primary"
>
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
<span>Finance</span>
</a>
</div>
<nav class="hidden md:flex items-center gap-1">
{#each navItems as item}
<a
href={item.href}
class="px-3 py-2 rounded-md text-sm font-medium transition-colors {isActive(item.href)
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground'}"
>
{item.label}
</a>
{/each}
</nav>
<div class="flex items-center gap-2">
<LanguageSelector />
<button
onclick={toggleTheme}
class="rounded-lg p-2 hover:bg-accent"
aria-label="Toggle theme"
>
{#if isDark}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
{/if}
</button>
</div>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</header>
<!-- Mobile Navigation -->
<nav class="md:hidden fixed bottom-0 left-0 right-0 z-50 border-t border-border bg-card">
<div class="flex justify-around py-2">
{#each navItems.slice(0, 5) as item}
<a
href={item.href}
class="flex flex-col items-center p-2 text-xs {isActive(item.href)
? 'text-primary'
: 'text-muted-foreground'}"
>
<span class="mb-1">{item.label}</span>
</a>
{/each}
</div>
</nav>
<!-- Main Content -->
<main class="container mx-auto px-4 py-6 pb-20 md:pb-6">
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</main>
</div>
<AppSlider bind:isOpen={isAppSliderOpen} />
</div>
{/if}