feat(matrix): add PillNavigation and theming integration

- Add PillNavigation component from @manacore/shared-ui
- Create theme store with purple color scheme
- Add i18n support (DE/EN) with svelte-i18n
- Integrate theme switching, language selector, app switcher
- Add glassmorphic utility classes to app.css
- Update layouts to match other apps' navigation pattern

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 14:41:59 +01:00
parent 1e5175e522
commit cc130ccb24
10 changed files with 349 additions and 78 deletions

View file

@ -35,10 +35,12 @@
"buffer": "^6.0.3",
"events": "^3.3.0",
"@manacore/shared-auth": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"date-fns": "^4.1.0",
"svelte-i18n": "^4.0.1"
}

View file

@ -7,6 +7,7 @@
@source '../../../../../packages/shared-icons/src';
@source '../../../../../packages/shared-auth-ui/src';
@source '../../../../../packages/shared-theme-ui/src';
@source '../../../../../packages/shared-branding/src';
@layer base {
:root {

View file

@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { init, register, locale, waitLocale } from 'svelte-i18n';
// List of supported locales
export const supportedLocales = ['de', 'en'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Default locale
const defaultLocale = 'de';
// Register all available locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
// Get initial locale from browser or localStorage
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const stored = localStorage.getItem('matrix_locale');
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
return stored as SupportedLocale;
}
// Fall back to browser language
const browserLang = navigator.language.split('-')[0];
if (supportedLocales.includes(browserLang as SupportedLocale)) {
return browserLang as SupportedLocale;
}
}
return defaultLocale;
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: defaultLocale,
initialLocale: getInitialLocale(),
});
// Set locale and persist to localStorage
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('matrix_locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale };

View file

@ -0,0 +1,25 @@
{
"app": {
"name": "Mana Matrix",
"description": "Sicherer Matrix-Chat"
},
"nav": {
"chat": "Chat",
"settings": "Einstellungen"
},
"auth": {
"login": "Anmelden",
"logout": "Abmelden",
"connecting": "Verbinde mit Matrix...",
"connectionFailed": "Verbindung fehlgeschlagen",
"retry": "Erneut versuchen"
},
"chat": {
"newChat": "Neuer Chat",
"createRoom": "Raum erstellen",
"sendMessage": "Nachricht senden",
"typeMessage": "Nachricht schreiben...",
"noRooms": "Noch keine Räume",
"noMessages": "Noch keine Nachrichten"
}
}

View file

@ -0,0 +1,25 @@
{
"app": {
"name": "Mana Matrix",
"description": "Secure Matrix chat"
},
"nav": {
"chat": "Chat",
"settings": "Settings"
},
"auth": {
"login": "Sign in",
"logout": "Sign out",
"connecting": "Connecting to Matrix...",
"connectionFailed": "Connection failed",
"retry": "Retry"
},
"chat": {
"newChat": "New Chat",
"createRoom": "Create Room",
"sendMessage": "Send message",
"typeMessage": "Type a message...",
"noRooms": "No rooms yet",
"noMessages": "No messages yet"
}
}

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,10 @@
import { createThemeStore } from '@manacore/shared-theme';
export const theme = createThemeStore({
appId: 'matrix',
defaultVariant: 'purple',
primaryColor: {
light: '270 70% 60%', // Purple/violet
dark: '270 70% 60%',
},
});

View file

@ -1,9 +1,30 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount, onDestroy } from 'svelte';
import { locale } from 'svelte-i18n';
import type { Snippet } from 'svelte';
import { CircleNotch, WarningCircle, ArrowsClockwise } from '@manacore/shared-icons';
import { theme } from '$lib/stores/theme';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
EXTENDED_THEME_VARIANTS,
} from '@manacore/shared-theme';
import type { ThemeVariant } from '@manacore/shared-theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { getPillAppItems } from '@manacore/shared-branding';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { setLocale, supportedLocales } from '$lib/i18n';
// App switcher items
const appItems = getPillAppItems('matrix');
interface Props {
children: Snippet;
@ -13,14 +34,116 @@
let loading = $state(true);
let initError = $state<string | null>(null);
// Navigation state
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// Theme state
let isDark = $derived(theme.isDark);
// Theme variant dropdown items (default themes only for now)
let themeVariantItems = $derived<PillDropdownItem[]>([
...DEFAULT_THEME_VARIANTS.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
]);
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant]?.label || 'Theme');
// Language selector
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));
// Navigation items for Matrix
const navItems: PillNavItem[] = [
{ href: '/chat', label: 'Chat', icon: 'home' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
];
// User info from Matrix
let userEmail = $derived(matrixStore.userId || undefined);
// Keyboard shortcuts
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('matrix-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('matrix-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
function handleLogout() {
matrixStore.logout();
goto('/login');
}
onMount(async () => {
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('matrix-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('matrix-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
// Check if already initialized
if (matrixStore.isReady) {
loading = false;
return;
}
// Try to initialize
// Try to initialize Matrix
const success = await matrixStore.initialize();
if (!success) {
@ -38,7 +161,6 @@
onDestroy(() => {
// Don't destroy on navigation within app routes
// matrixStore.destroy();
});
async function retry() {
@ -50,20 +172,17 @@
}
loading = false;
}
function logout() {
matrixStore.logout();
goto('/login');
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if loading}
<!-- Loading State -->
<div class="flex h-screen flex-col items-center justify-center gap-4">
<div class="flex h-screen flex-col items-center justify-center gap-4 bg-background">
<CircleNotch class="h-12 w-12 animate-spin text-primary" />
<div class="text-center">
<p class="font-medium">Connecting to Matrix...</p>
<p class="text-sm text-base-content/60">
<p class="font-medium text-foreground">Connecting to Matrix...</p>
<p class="text-sm text-muted-foreground">
{#if matrixStore.syncState === 'PREPARED'}
Preparing sync...
{:else if matrixStore.syncState === 'SYNCING'}
@ -78,28 +197,97 @@
</div>
{:else if initError}
<!-- Error State -->
<div class="flex h-screen flex-col items-center justify-center gap-4 p-4">
<div class="rounded-full bg-error/10 p-4">
<WarningCircle class="h-12 w-12 text-error" />
<div class="flex h-screen flex-col items-center justify-center gap-4 p-4 bg-background">
<div class="rounded-full bg-red-500/10 p-4">
<WarningCircle class="h-12 w-12 text-red-500" />
</div>
<div class="text-center">
<h2 class="text-xl font-semibold">Connection Failed</h2>
<p class="mt-2 max-w-md text-base-content/60">{initError}</p>
<h2 class="text-xl font-semibold text-foreground">Connection Failed</h2>
<p class="mt-2 max-w-md text-muted-foreground">{initError}</p>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick={retry}>
<button
class="px-4 py-2 rounded-xl bg-gradient-to-r from-violet-500 to-purple-600 text-white font-medium shadow-md hover:shadow-lg transition-all flex items-center gap-2"
onclick={retry}
>
<ArrowsClockwise class="h-4 w-4" />
Retry
</button>
<button class="btn btn-ghost" onclick={logout}> Sign Out </button>
<button class="px-4 py-2 rounded-xl glass-button font-medium" onclick={handleLogout}>
Sign Out
</button>
</div>
</div>
{:else if matrixStore.isReady}
<!-- Ready - Render children -->
{@render children()}
<!-- Ready - Show navigation and content -->
<div class="layout-container">
<!-- PillNavigation -->
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Mana Matrix"
homeRoute="/chat"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition="bottom"
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={true}
onLogout={handleLogout}
primaryColor="#8b5cf6"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
allAppsHref="https://mana.how"
/>
<!-- Main Content -->
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
{@render children()}
</main>
</div>
{:else}
<!-- Unknown state - redirect to login -->
<div class="flex h-screen items-center justify-center">
<p class="text-base-content/60">Redirecting...</p>
<div class="flex h-screen items-center justify-center bg-background">
<p class="text-muted-foreground">Redirecting...</p>
</div>
{/if}
<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: 80px;
}
/* Sidebar mode - add left padding for sidebar nav */
.main-content.sidebar-mode {
padding-left: 180px;
}
</style>

View file

@ -3,8 +3,7 @@
import { RoomList, RoomHeader, Timeline, MessageInput } from '$lib/components/chat';
import CreateRoomDialog from '$lib/components/chat/CreateRoomDialog.svelte';
import RoomSettingsPanel from '$lib/components/chat/RoomSettingsPanel.svelte';
import { goto } from '$app/navigation';
import { Gear, SignOut, ChatCircle, Plus } from '@manacore/shared-icons';
import { ChatCircle, Plus } from '@manacore/shared-icons';
let sidebarOpen = $state(true);
let showCreateRoom = $state(false);
@ -18,11 +17,6 @@
sidebarOpen = !sidebarOpen;
}
function handleLogout() {
matrixStore.logout();
goto('/login');
}
function handleReply(message: SimpleMessage) {
editMessage = null;
replyTo = message;
@ -38,65 +32,25 @@
}
</script>
<div class="flex h-screen overflow-hidden bg-background">
<div class="flex h-full overflow-hidden bg-background">
<!-- Sidebar -->
<aside
class="flex w-80 flex-shrink-0 flex-col border-r border-black/10 dark:border-white/10 bg-white/50 dark:bg-white/5 backdrop-blur-sm transition-all duration-300 ease-in-out"
class:hidden={!sidebarOpen}
class:lg:flex={true}
>
<!-- Sidebar Header -->
<header
class="flex items-center justify-between border-b border-black/10 dark:border-white/10 p-4"
>
<div class="flex items-center gap-2">
<div class="p-1.5 rounded-lg bg-gradient-to-br from-violet-500 to-purple-600">
<ChatCircle class="h-5 w-5 text-white" weight="fill" />
</div>
<h1 class="text-lg font-bold">Mana Matrix</h1>
</div>
<div class="flex items-center gap-1">
<!-- User Info / Status Bar -->
<div class="border-b border-black/10 dark:border-white/10 px-4 py-3">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium">{matrixStore.userId}</p>
<button
class="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
class="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
title="Neuer Chat"
onclick={() => (showCreateRoom = true)}
>
<Plus class="h-5 w-5" />
<Plus class="h-4 w-4" />
</button>
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
>
<Gear class="h-5 w-5" />
</button>
<ul tabindex="0" class="dropdown-content z-50 w-52 rounded-xl glass p-2 shadow-xl mt-2">
<li>
<a
href="/settings"
class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 transition-colors"
>
<Gear class="h-4 w-4" />
Einstellungen
</a>
</li>
<li>
<button
onclick={handleLogout}
class="flex items-center gap-2 w-full px-3 py-2 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors"
>
<SignOut class="h-4 w-4" />
Abmelden
</button>
</li>
</ul>
</div>
</div>
</header>
<!-- User Info -->
<div class="border-b border-black/10 dark:border-white/10 px-4 py-3">
<p class="truncate text-sm font-medium">{matrixStore.userId}</p>
<p class="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span class="h-2 w-2 rounded-full bg-green-500"></span>
{matrixStore.syncState === 'SYNCING' ? 'Verbunden' : matrixStore.syncState}

View file

@ -1,12 +1,20 @@
<script lang="ts">
import '../app.css';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { theme } from '$lib/stores/theme';
import { ToastContainer } from '@manacore/shared-ui';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
onMount(() => {
const cleanup = theme.initialize();
return cleanup;
});
</script>
<svelte:head>
@ -14,4 +22,9 @@
<meta name="description" content="Self-hosted Matrix chat client" />
</svelte:head>
{@render children()}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
<!-- Global Toast notifications -->
<ToastContainer />