feat(manacore): Phase 3 — component-based split-screen system

Replace iFrame-based split-screen with native Svelte components:

Infrastructure (6 files):
- registry.ts: lazy-import registry for all 25 app modules
- store.svelte.ts: Svelte 5 runes state (app, position, localStorage)
- SplitPaneLayout.svelte: dual-panel layout with dynamic widths
- ResizeHandle.svelte: draggable divider with mouse/touch support
- PanelHeader.svelte: app name + close button
- index.ts: barrel exports

AppView.svelte for all 25 modules — compact self-contained views:
- todo: task list with filters + quick add
- calendar: mini week strip + today's events
- contacts: searchable list with avatars
- chat: conversation list with previews
- And 21 more...

Benefits over iFrame approach:
- Shared IndexedDB — both panels see the same data
- Svelte reactivity across panels
- No CORS/CSP issues
- Code-split via dynamic imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 20:58:34 +02:00
parent 9a6aa12c63
commit 6dc259d743
31 changed files with 2548 additions and 0 deletions

View file

@ -0,0 +1,95 @@
<!--
Calc — Split-Screen AppView
Simple calculator with expression input and history.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalCalculation } from './types';
let calculations = $state<LocalCalculation[]>([]);
let expression = $state('');
let result = $state('');
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCalculation>('calculations')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt));
}).subscribe((val) => {
calculations = val ?? [];
});
return () => sub.unsubscribe();
});
const recent = $derived(
[...calculations]
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
.slice(0, 10)
);
function evaluate() {
if (!expression.trim()) return;
try {
// Basic safe eval for simple math expressions
const sanitized = expression.replace(/[^0-9+\-*/().%\s]/g, '');
const evalResult = Function('"use strict"; return (' + sanitized + ')')();
result = String(evalResult);
} catch {
result = 'Fehler';
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
evaluate();
}
}
</script>
<div class="flex h-full flex-col gap-4 p-4">
<!-- Display -->
<div class="rounded-md bg-white/5 p-3 text-right">
<p class="text-xs text-white/40">{expression || ' '}</p>
<p class="text-2xl font-light text-white/90">{result || '0'}</p>
</div>
<!-- Input -->
<input
bind:value={expression}
onkeydown={handleKeydown}
placeholder="Ausdruck eingeben..."
class="rounded-md border border-white/10 bg-white/5 px-3 py-2 text-right text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<!-- Quick buttons -->
<div class="grid grid-cols-4 gap-1">
{#each ['7', '8', '9', '/', '4', '5', '6', '*', '1', '2', '3', '-', '0', '.', '=', '+'] as key}
<button
onclick={() => {
if (key === '=') evaluate();
else expression += key;
}}
class="rounded-md bg-white/5 py-2 text-sm text-white/70 transition-colors hover:bg-white/10
{key === '=' ? 'bg-blue-500/20 text-blue-300' : ''}"
>
{key}
</button>
{/each}
</div>
<!-- History -->
{#if recent.length > 0}
<div class="flex-1 overflow-auto">
<h3 class="mb-1 text-xs font-medium text-white/50">Verlauf</h3>
{#each recent as calc (calc.id)}
<div class="flex items-center justify-between py-1 text-xs">
<span class="text-white/40">{calc.expression}</span>
<span class="text-white/60">= {calc.result}</span>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,92 @@
<!--
Calendar — Split-Screen AppView
Mini week view with today's events.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalEvent } from './types';
let events = $state<LocalEvent[]>([]);
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const weekDays = $derived(() => {
const days: Date[] = [];
const start = new Date(now);
start.setDate(start.getDate() - start.getDay() + 1); // Monday
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(d.getDate() + i);
days.push(d);
}
return days;
});
const todayEvents = $derived(
events
.filter((e) => e.startDate.startsWith(todayStr))
.sort((a, b) => a.startDate.localeCompare(b.startDate))
);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalEvent>('events')
.toArray()
.then((all) => all.filter((e) => !e.deletedAt));
}).subscribe((val) => {
events = val ?? [];
});
return () => sub.unsubscribe();
});
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit' });
}
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
</script>
<div class="flex h-full flex-col gap-4 p-4">
<!-- Mini week strip -->
<div class="grid grid-cols-7 gap-1">
{#each weekDays() as day, i}
{@const isToday = day.toISOString().split('T')[0] === todayStr}
<div class="flex flex-col items-center gap-1">
<span class="text-[10px] text-white/40">{dayNames[i]}</span>
<span
class="flex h-7 w-7 items-center justify-center rounded-full text-xs
{isToday ? 'bg-blue-500 text-white' : 'text-white/60'}"
>
{day.getDate()}
</span>
</div>
{/each}
</div>
<!-- Today's events -->
<div class="flex-1 overflow-auto">
<h3 class="mb-2 text-xs font-medium text-white/50">Heute</h3>
{#each todayEvents as event (event.id)}
<div class="mb-2 rounded-md border border-white/10 bg-white/5 px-3 py-2">
<p class="text-sm font-medium text-white/80">{event.title}</p>
<p class="text-xs text-white/40">
{#if event.allDay}
Ganztägig
{:else}
{formatTime(event.startDate)}{formatTime(event.endDate)}
{/if}
</p>
{#if event.location}
<p class="text-xs text-white/30">{event.location}</p>
{/if}
</div>
{/each}
{#if todayEvents.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Termine heute</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,73 @@
<!--
Cards — Split-Screen AppView
Deck list with card counts and study info.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalDeck, LocalCard } from './types';
let decks = $state<LocalDeck[]>([]);
let cards = $state<LocalCard[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalDeck>('decks')
.toArray()
.then((all) => all.filter((d) => !d.deletedAt));
}).subscribe((val) => {
decks = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCard>('cards')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt));
}).subscribe((val) => {
cards = val ?? [];
});
return () => sub.unsubscribe();
});
const dueForReview = $derived(() => {
const now = new Date().toISOString();
return cards.filter((c) => c.nextReview && c.nextReview <= now).length;
});
function cardsInDeck(deckId: string): number {
return cards.filter((c) => c.deckId === deckId).length;
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex items-center justify-between">
<p class="text-xs text-white/40">{decks.length} Decks</p>
<p class="text-xs text-amber-400/70">{dueForReview()} fällig</p>
</div>
<div class="flex-1 overflow-auto">
{#each decks as deck (deck.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded" style="background: {deck.color}"></div>
<p class="flex-1 truncate text-sm font-medium text-white/80">{deck.name}</p>
<span class="text-xs text-white/40">{cardsInDeck(deck.id)}</span>
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-white/40">{deck.description}</p>
{/if}
</div>
{/each}
{#if decks.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Decks</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,77 @@
<!--
Chat — Split-Screen AppView
Recent conversations list.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalConversation, LocalMessage } from './types';
let conversations = $state<LocalConversation[]>([]);
let lastMessages = $state<Map<string, LocalMessage>>(new Map());
$effect(() => {
const sub = liveQuery(async () => {
const convs = await db.table<LocalConversation>('conversations').toArray();
return convs.filter((c) => !c.deletedAt && !c.isArchived);
}).subscribe((val) => {
conversations = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
const msgs = await db.table<LocalMessage>('messages').toArray();
const map = new Map<string, LocalMessage>();
for (const msg of msgs) {
if (msg.deletedAt) continue;
const existing = map.get(msg.conversationId);
if (!existing || (msg.createdAt ?? '') > (existing.createdAt ?? '')) {
map.set(msg.conversationId, msg);
}
}
return map;
}).subscribe((val) => {
lastMessages = val ?? new Map();
});
return () => sub.unsubscribe();
});
const sorted = $derived(
[...conversations].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
);
function truncate(text: string, max = 60): string {
return text.length > max ? text.slice(0, max) + '...' : text;
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<p class="text-xs text-white/40">{conversations.length} Unterhaltungen</p>
<div class="flex-1 overflow-auto">
{#each sorted as conv (conv.id)}
{@const lastMsg = lastMessages.get(conv.id)}
<div class="mb-1 rounded-md px-3 py-2.5 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{conv.title || 'Neue Unterhaltung'}
</p>
{#if conv.isPinned}
<span class="text-[10px] text-white/30">&#128204;</span>
{/if}
</div>
{#if lastMsg}
<p class="mt-0.5 truncate text-xs text-white/40">
{lastMsg.sender === 'user' ? 'Du: ' : ''}{truncate(lastMsg.messageText)}
</p>
{/if}
</div>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Unterhaltungen</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,86 @@
<!--
CityCorners — Split-Screen AppView
Locations list grouped by category with favorites.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalLocation, LocalFavorite } from './types';
import { CATEGORY_COLORS } from './types';
let locations = $state<LocalLocation[]>([]);
let favorites = $state<LocalFavorite[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalLocation>('cityLocations')
.toArray()
.then((all) => all.filter((l) => !l.deletedAt));
}).subscribe((val) => {
locations = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFavorite>('cityFavorites')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
favorites = val ?? [];
});
return () => sub.unsubscribe();
});
const favoriteIds = $derived(new Set(favorites.map((f) => f.locationId)));
const categoryLabels: Record<string, string> = {
sight: 'Sehenswürdigkeit',
restaurant: 'Restaurant',
shop: 'Laden',
museum: 'Museum',
cafe: 'Café',
bar: 'Bar',
park: 'Park',
beach: 'Strand',
hotel: 'Hotel',
event_venue: 'Veranstaltungsort',
viewpoint: 'Aussichtspunkt',
};
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{locations.length} Orte</span>
<span>{favorites.length} Favoriten</span>
</div>
<div class="flex-1 overflow-auto">
{#each locations as location (location.id)}
<div class="flex items-start gap-2 rounded-md px-2 py-2 transition-colors hover:bg-white/5">
<div
class="mt-0.5 h-2.5 w-2.5 shrink-0 rounded-full"
style="background: {CATEGORY_COLORS[location.category] ?? '#666'}"
></div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
<p class="truncate text-sm text-white/80">{location.name}</p>
{#if favoriteIds.has(location.id)}
<span class="text-xs text-yellow-400">&#9733;</span>
{/if}
</div>
<p class="text-xs text-white/40">
{categoryLabels[location.category] ?? location.category}
</p>
</div>
</div>
{/each}
{#if locations.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Orte</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,126 @@
<!--
Clock — Split-Screen AppView
World clocks and active timers.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalWorldClock, LocalTimer, LocalAlarm } from './types';
let worldClocks = $state<LocalWorldClock[]>([]);
let timers = $state<LocalTimer[]>([]);
let alarms = $state<LocalAlarm[]>([]);
let now = $state(new Date());
$effect(() => {
const interval = setInterval(() => {
now = new Date();
}, 1000);
return () => clearInterval(interval);
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalWorldClock>('worldClocks')
.orderBy('sortOrder')
.toArray()
.then((all) => all.filter((w) => !w.deletedAt));
}).subscribe((val) => {
worldClocks = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalTimer>('timers')
.toArray()
.then((all) => all.filter((t) => !t.deletedAt));
}).subscribe((val) => {
timers = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalAlarm>('alarms')
.toArray()
.then((all) => all.filter((a) => !a.deletedAt));
}).subscribe((val) => {
alarms = val ?? [];
});
return () => sub.unsubscribe();
});
function timeInZone(tz: string): string {
return now.toLocaleTimeString('de', { timeZone: tz, hour: '2-digit', minute: '2-digit' });
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
const activeTimers = $derived(
timers.filter((t) => t.status === 'running' || t.status === 'paused')
);
const enabledAlarms = $derived(alarms.filter((a) => a.enabled));
</script>
<div class="flex h-full flex-col gap-4 p-4">
<!-- Local time -->
<div class="text-center">
<p class="text-3xl font-light tracking-wider text-white/90">
{now.toLocaleTimeString('de', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</p>
<p class="text-xs text-white/40">
{now.toLocaleDateString('de', { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
</div>
<!-- World clocks -->
{#if worldClocks.length > 0}
<div>
<h3 class="mb-2 text-xs font-medium text-white/50">Weltuhr</h3>
{#each worldClocks as wc (wc.id)}
<div class="flex items-center justify-between py-1">
<span class="text-sm text-white/60">{wc.cityName}</span>
<span class="font-mono text-sm text-white/80">{timeInZone(wc.timezone)}</span>
</div>
{/each}
</div>
{/if}
<!-- Active timers -->
{#if activeTimers.length > 0}
<div>
<h3 class="mb-2 text-xs font-medium text-white/50">Timer</h3>
{#each activeTimers as timer (timer.id)}
<div class="flex items-center justify-between rounded-md bg-white/5 px-3 py-2">
<span class="text-sm text-white/60">{timer.label ?? 'Timer'}</span>
<span class="font-mono text-sm text-white/80">
{formatDuration(timer.remainingSeconds ?? timer.durationSeconds)}
</span>
</div>
{/each}
</div>
{/if}
<!-- Alarms summary -->
{#if enabledAlarms.length > 0}
<div>
<h3 class="mb-2 text-xs font-medium text-white/50">Wecker ({enabledAlarms.length})</h3>
{#each enabledAlarms.slice(0, 3) as alarm (alarm.id)}
<div class="flex items-center justify-between py-1">
<span class="text-sm text-white/60">{alarm.label ?? 'Wecker'}</span>
<span class="font-mono text-sm text-white/80">{alarm.time}</span>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,82 @@
<!--
Contacts — Split-Screen AppView
Contact list with search.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalContact } from './types';
let contacts = $state<LocalContact[]>([]);
let search = $state('');
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalContact>('contacts')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt && !c.isArchived));
}).subscribe((val) => {
contacts = val ?? [];
});
return () => sub.unsubscribe();
});
const filtered = $derived(() => {
if (!search.trim()) return contacts;
const q = search.toLowerCase();
return contacts.filter(
(c) =>
c.firstName?.toLowerCase().includes(q) ||
c.lastName?.toLowerCase().includes(q) ||
c.email?.toLowerCase().includes(q) ||
c.company?.toLowerCase().includes(q)
);
});
function displayName(c: LocalContact): string {
const parts = [c.firstName, c.lastName].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : (c.email ?? 'Unbenannt');
}
function initials(c: LocalContact): string {
const f = c.firstName?.[0] ?? '';
const l = c.lastName?.[0] ?? '';
return (f + l).toUpperCase() || '?';
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<input
bind:value={search}
placeholder="Kontakt suchen..."
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<p class="text-xs text-white/40">{filtered().length} Kontakte</p>
<div class="flex-1 overflow-auto">
{#each filtered() as contact (contact.id)}
<div class="flex items-center gap-3 rounded-md px-2 py-2 transition-colors hover:bg-white/5">
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white/10 text-xs font-medium text-white/60"
>
{initials(contact)}
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/80">{displayName(contact)}</p>
{#if contact.company}
<p class="truncate text-xs text-white/40">{contact.company}</p>
{/if}
</div>
{#if contact.isFavorite}
<span class="text-yellow-400 text-xs">&#9733;</span>
{/if}
</div>
{/each}
{#if filtered().length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Kontakte gefunden</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,88 @@
<!--
Context — Split-Screen AppView
Spaces and recent documents.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalContextSpace, LocalDocument } from './types';
let spaces = $state<LocalContextSpace[]>([]);
let documents = $state<LocalDocument[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalContextSpace>('contextSpaces')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
spaces = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalDocument>('documents')
.toArray()
.then((all) => all.filter((d) => !d.deletedAt));
}).subscribe((val) => {
documents = val ?? [];
});
return () => sub.unsubscribe();
});
const recentDocs = $derived(
[...documents].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')).slice(0, 15)
);
const typeIcons: Record<string, string> = {
text: '&#128196;',
context: '&#128218;',
prompt: '&#9889;',
};
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{spaces.length} Spaces</span>
<span>{documents.length} Dokumente</span>
</div>
<div class="flex-1 overflow-auto">
<!-- Pinned spaces -->
{#if spaces.filter((s) => s.pinned).length > 0}
<h3 class="mb-2 text-xs font-medium text-white/50">Angepinnte Spaces</h3>
{#each spaces.filter((s) => s.pinned) as space (space.id)}
<div class="mb-1 rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<p class="text-sm font-medium text-white/80">{space.name}</p>
{#if space.description}
<p class="truncate text-xs text-white/30">{space.description}</p>
{/if}
</div>
{/each}
{/if}
<!-- Recent documents -->
<h3 class="mb-2 mt-3 text-xs font-medium text-white/50">Zuletzt bearbeitet</h3>
{#each recentDocs as doc (doc.id)}
<div
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
>
<span class="text-sm">{@html typeIcons[doc.type] ?? '&#128196;'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/70">{doc.title || 'Unbenannt'}</p>
</div>
{#if doc.pinned}
<span class="text-xs text-white/30">&#128204;</span>
{/if}
</div>
{/each}
{#if recentDocs.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Dokumente</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,77 @@
<!--
Inventar — Split-Screen AppView
Collections and items overview.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalCollection, LocalItem } from './types';
let collections = $state<LocalCollection[]>([]);
let items = $state<LocalItem[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCollection>('inventarCollections')
.orderBy('order')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt));
}).subscribe((val) => {
collections = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalItem>('inventarItems')
.toArray()
.then((all) => all.filter((i) => !i.deletedAt));
}).subscribe((val) => {
items = val ?? [];
});
return () => sub.unsubscribe();
});
function itemsInCollection(collectionId: string): number {
return items.filter((i) => i.collectionId === collectionId).length;
}
const totalValue = $derived(() => {
return items.reduce((sum, i) => sum + (i.purchaseData?.price ?? 0), 0);
});
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{items.length} Gegenstände</span>
{#if totalValue() > 0}
<span>~{totalValue().toFixed(0)} EUR</span>
{/if}
</div>
<div class="flex-1 overflow-auto">
{#each collections as collection (collection.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<div class="flex items-center gap-2">
{#if collection.icon}
<span class="text-sm">{collection.icon}</span>
{/if}
<p class="flex-1 truncate text-sm font-medium text-white/80">{collection.name}</p>
<span class="text-xs text-white/40">{itemsInCollection(collection.id)}</span>
</div>
{#if collection.description}
<p class="mt-1 truncate text-xs text-white/30">{collection.description}</p>
{/if}
</div>
{/each}
{#if collections.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Sammlungen</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,88 @@
<!--
Memoro — Split-Screen AppView
Recent memos with transcription status.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalMemo } from './types';
let memos = $state<LocalMemo[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalMemo>('memos')
.toArray()
.then((all) => all.filter((m) => !m.deletedAt && !m.isArchived));
}).subscribe((val) => {
memos = val ?? [];
});
return () => sub.unsubscribe();
});
const sorted = $derived(
[...memos].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
);
const pinned = $derived(memos.filter((m) => m.isPinned));
function formatDuration(ms: number | null): string {
if (!ms) return '--:--';
const sec = Math.round(ms / 1000);
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
const statusColors: Record<string, string> = {
pending: 'bg-yellow-500/20 text-yellow-300',
processing: 'bg-blue-500/20 text-blue-300',
completed: 'bg-green-500/20 text-green-300',
failed: 'bg-red-500/20 text-red-300',
};
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{memos.length} Memos</span>
<span>{pinned.length} angepinnt</span>
</div>
<div class="flex-1 overflow-auto">
{#each sorted as memo (memo.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1">
{#if memo.isPinned}
<span class="text-xs text-white/30">&#128204;</span>
{/if}
<p class="truncate text-sm font-medium text-white/80">
{memo.title || 'Unbenanntes Memo'}
</p>
</div>
{#if memo.intro}
<p class="mt-0.5 truncate text-xs text-white/40">{memo.intro}</p>
{/if}
</div>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] {statusColors[
memo.processingStatus
] ?? ''}"
>
{memo.processingStatus === 'completed'
? formatDuration(memo.audioDurationMs)
: memo.processingStatus}
</span>
</div>
</div>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Memos</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,68 @@
<!--
Moodlit — Split-Screen AppView
Ambient mood selector with color preview.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalMood } from './types';
let moods = $state<LocalMood[]>([]);
let activeMoodId = $state<string | null>(null);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalMood>('moods')
.toArray()
.then((all) => all.filter((m) => !m.deletedAt));
}).subscribe((val) => {
moods = val ?? [];
});
return () => sub.unsubscribe();
});
const activeMood = $derived(moods.find((m) => m.id === activeMoodId));
function gradientStyle(colors: string[]): string {
if (colors.length === 0) return 'background: #333';
if (colors.length === 1) return `background: ${colors[0]}`;
return `background: linear-gradient(135deg, ${colors.join(', ')})`;
}
</script>
<div class="flex h-full flex-col gap-4 p-4">
<!-- Active mood preview -->
{#if activeMood}
<div
class="flex h-24 items-center justify-center rounded-lg"
style={gradientStyle(activeMood.colors)}
>
<p class="text-sm font-medium text-white drop-shadow">{activeMood.name}</p>
</div>
{:else}
<div class="flex h-24 items-center justify-center rounded-lg bg-white/5">
<p class="text-sm text-white/30">Kein Mood aktiv</p>
</div>
{/if}
<!-- Mood grid -->
<div class="flex-1 overflow-auto">
<div class="grid grid-cols-3 gap-2">
{#each moods as mood (mood.id)}
<button
onclick={() => (activeMoodId = activeMoodId === mood.id ? null : mood.id)}
class="group flex flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-white/5
{activeMoodId === mood.id ? 'ring-1 ring-white/30' : ''}"
>
<div class="h-10 w-10 rounded-full" style={gradientStyle(mood.colors)}></div>
<span class="text-[10px] text-white/50 group-hover:text-white/70">{mood.name}</span>
</button>
{/each}
</div>
{#if moods.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Moods</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,84 @@
<!--
Mukke — Split-Screen AppView
Song library with recent plays and playlists.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalSong, LocalPlaylist } from './types';
let songs = $state<LocalSong[]>([]);
let playlists = $state<LocalPlaylist[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalSong>('songs')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
songs = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalPlaylist>('playlists')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt));
}).subscribe((val) => {
playlists = val ?? [];
});
return () => sub.unsubscribe();
});
const recentlyPlayed = $derived(
[...songs]
.filter((s) => s.lastPlayedAt)
.sort((a, b) => (b.lastPlayedAt ?? '').localeCompare(a.lastPlayedAt ?? ''))
.slice(0, 10)
);
const favorites = $derived(songs.filter((s) => s.favorite));
function formatDuration(sec?: number | null): string {
if (!sec) return '--:--';
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{songs.length} Songs</span>
<span>{playlists.length} Playlists</span>
<span>{favorites.length} Favoriten</span>
</div>
<div class="flex-1 overflow-auto">
<h3 class="mb-2 text-xs font-medium text-white/50">Zuletzt gehört</h3>
{#each recentlyPlayed as song (song.id)}
<div
class="flex items-center gap-3 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-white/10 text-xs text-white/30"
>
&#9835;
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-white/80">{song.title}</p>
<p class="truncate text-xs text-white/40">{song.artist ?? 'Unbekannt'}</p>
</div>
<span class="text-xs text-white/30">{formatDuration(song.duration)}</span>
</div>
{/each}
{#if recentlyPlayed.length === 0}
<p class="py-8 text-center text-sm text-white/30">Noch nichts gehört</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,109 @@
<!--
NutriPhi — Split-Screen AppView
Today's nutrition progress with meal log.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalMeal, LocalGoal } from './types';
let meals = $state<LocalMeal[]>([]);
let goals = $state<LocalGoal[]>([]);
const todayStr = new Date().toISOString().split('T')[0];
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalMeal>('meals')
.toArray()
.then((all) => all.filter((m) => !m.deletedAt));
}).subscribe((val) => {
meals = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalGoal>('nutriphiGoals')
.toArray()
.then((all) => all.filter((g) => !g.deletedAt));
}).subscribe((val) => {
goals = val ?? [];
});
return () => sub.unsubscribe();
});
const todayMeals = $derived(meals.filter((m) => m.date === todayStr));
const goal = $derived(goals[0]);
const totalCalories = $derived(
todayMeals.reduce((sum, m) => sum + (m.nutrition?.calories ?? 0), 0)
);
const totalProtein = $derived(
todayMeals.reduce((sum, m) => sum + (m.nutrition?.protein ?? 0), 0)
);
const calorieProgress = $derived(
goal?.dailyCalories ? Math.min(100, (totalCalories / goal.dailyCalories) * 100) : 0
);
const mealTypeLabels: Record<string, string> = {
breakfast: 'Frühstück',
lunch: 'Mittagessen',
dinner: 'Abendessen',
snack: 'Snack',
};
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- Calorie progress -->
<div class="text-center">
<p class="text-2xl font-light text-white/90">{Math.round(totalCalories)}</p>
<p class="text-xs text-white/40">
{#if goal}
von {goal.dailyCalories} kcal
{:else}
kcal heute
{/if}
</p>
{#if goal}
<div class="mx-auto mt-2 h-1.5 w-32 rounded-full bg-white/10">
<div
class="h-full rounded-full transition-all {calorieProgress >= 100
? 'bg-green-400'
: 'bg-blue-400'}"
style="width: {calorieProgress}%"
></div>
</div>
{/if}
</div>
<!-- Macros -->
<div class="flex justify-center gap-4 text-xs text-white/40">
<span>{Math.round(totalProtein)}g Protein</span>
<span>{todayMeals.length} Mahlzeiten</span>
</div>
<!-- Today's meals -->
<div class="flex-1 overflow-auto">
{#each todayMeals as meal (meal.id)}
<div class="mb-1 rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{mealTypeLabels[meal.mealType] ?? meal.mealType}</span
>
{#if meal.nutrition}
<span class="text-xs text-white/50">{Math.round(meal.nutrition.calories)} kcal</span>
{/if}
</div>
<p class="truncate text-sm text-white/70">{meal.description}</p>
</div>
{/each}
{#if todayMeals.length === 0}
<p class="py-8 text-center text-sm text-white/30">Noch keine Mahlzeiten heute</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,64 @@
<!--
Photos — Split-Screen AppView
Photo albums and recent uploads.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalAlbum, LocalFavorite } from './types';
let albums = $state<LocalAlbum[]>([]);
let favorites = $state<LocalFavorite[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalAlbum>('albums')
.toArray()
.then((all) => all.filter((a) => !a.deletedAt));
}).subscribe((val) => {
albums = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFavorite>('photoFavorites')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
favorites = val ?? [];
});
return () => sub.unsubscribe();
});
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{albums.length} Alben</span>
<span>{favorites.length} Favoriten</span>
</div>
<div class="flex-1 overflow-auto">
<h3 class="mb-2 text-xs font-medium text-white/50">Alben</h3>
{#each albums as album (album.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<p class="truncate text-sm font-medium text-white/80">{album.name}</p>
{#if album.description}
<p class="mt-1 truncate text-xs text-white/30">{album.description}</p>
{/if}
<p class="mt-1 text-xs text-white/40">
{album.isAutoGenerated ? 'Auto-generiert' : 'Manuell'}
</p>
</div>
{/each}
{#if albums.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Alben</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,62 @@
<!--
Picture — Split-Screen AppView
Recent images grid with favorites.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalImage } from './types';
let images = $state<LocalImage[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalImage>('images').toArray();
return all.filter((i) => !i.deletedAt && !i.archivedAt);
}).subscribe((val) => {
images = val ?? [];
});
return () => sub.unsubscribe();
});
const sorted = $derived(
[...images].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')).slice(0, 20)
);
const favoriteCount = $derived(images.filter((i) => i.isFavorite).length);
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex items-center justify-between">
<p class="text-xs text-white/40">{images.length} Bilder</p>
<p class="text-xs text-white/40">{favoriteCount} Favoriten</p>
</div>
<div class="flex-1 overflow-auto">
<div class="grid grid-cols-3 gap-1.5">
{#each sorted as image (image.id)}
<div class="group relative aspect-square overflow-hidden rounded-md bg-white/5">
{#if image.publicUrl}
<img
src={image.publicUrl}
alt={image.prompt}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<div class="flex h-full items-center justify-center text-white/20 text-xs">
{image.format ?? 'img'}
</div>
{/if}
{#if image.isFavorite}
<span class="absolute right-1 top-1 text-xs text-yellow-400">&#9733;</span>
{/if}
</div>
{/each}
</div>
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Bilder</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,102 @@
<!--
Planta — Split-Screen AppView
Plant overview with watering schedule.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalPlant, LocalWateringSchedule } from './types';
let plants = $state<LocalPlant[]>([]);
let schedules = $state<LocalWateringSchedule[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalPlant>('plants')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt && p.isActive));
}).subscribe((val) => {
plants = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalWateringSchedule>('wateringSchedules')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
schedules = val ?? [];
});
return () => sub.unsubscribe();
});
function getSchedule(plantId: string): LocalWateringSchedule | undefined {
return schedules.find((s) => s.plantId === plantId);
}
function needsWater(schedule?: LocalWateringSchedule): boolean {
if (!schedule?.nextWateringAt) return false;
return new Date(schedule.nextWateringAt) <= new Date();
}
const needsAttention = $derived(
plants.filter((p) => p.healthStatus === 'needs_attention' || p.healthStatus === 'sick')
);
const dueForWatering = $derived(plants.filter((p) => needsWater(getSchedule(p.id))));
const healthIcons: Record<string, string> = {
healthy: '&#127793;',
needs_attention: '&#9888;',
sick: '&#129298;',
};
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{plants.length} Pflanzen</span>
{#if dueForWatering.length > 0}
<span class="text-blue-400">{dueForWatering.length} giessen</span>
{/if}
{#if needsAttention.length > 0}
<span class="text-amber-400">{needsAttention.length} brauchen Pflege</span>
{/if}
</div>
<div class="flex-1 overflow-auto">
{#each plants as plant (plant.id)}
{@const schedule = getSchedule(plant.id)}
{@const waterDue = needsWater(schedule)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<div class="flex items-center gap-2">
<span class="text-sm"
>{@html healthIcons[plant.healthStatus ?? 'healthy'] ?? '&#127793;'}</span
>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-white/80">{plant.name}</p>
{#if plant.scientificName}
<p class="truncate text-xs italic text-white/30">{plant.scientificName}</p>
{/if}
</div>
{#if waterDue}
<span class="text-xs text-blue-400">&#128167;</span>
{/if}
</div>
{#if schedule}
<p class="mt-1 text-xs text-white/30">
Alle {schedule.frequencyDays} Tage giessen
</p>
{/if}
</div>
{/each}
{#if plants.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Pflanzen</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,82 @@
<!--
Playground — Split-Screen AppView
Minimal LLM prompt interface with model selector.
-->
<script lang="ts">
import { PLAYGROUND_MODELS, type PlaygroundMessage } from './index';
let selectedModel = $state(PLAYGROUND_MODELS[0].id);
let prompt = $state('');
let messages = $state<PlaygroundMessage[]>([]);
let isLoading = $state(false);
const modelLabel = $derived(
PLAYGROUND_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel
);
function send() {
if (!prompt.trim() || isLoading) return;
messages = [...messages, { role: 'user', content: prompt, timestamp: Date.now() }];
// Placeholder — actual API integration happens in full app
messages = [
...messages,
{
role: 'assistant',
content: '(Playground-Antwort — verbinde mit mana-llm)',
timestamp: Date.now(),
},
];
prompt = '';
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- Model selector -->
<select
bind:value={selectedModel}
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white/70 focus:border-white/20 focus:outline-none"
>
{#each PLAYGROUND_MODELS as model}
<option value={model.id} class="bg-neutral-900">{model.label} ({model.provider})</option>
{/each}
</select>
<!-- Messages -->
<div class="flex-1 overflow-auto">
{#each messages as msg, i}
<div
class="mb-2 rounded-md px-3 py-2 {msg.role === 'user' ? 'bg-white/5' : 'bg-blue-500/10'}"
>
<p class="text-[10px] text-white/30">{msg.role === 'user' ? 'Du' : modelLabel}</p>
<p class="text-sm text-white/70">{msg.content}</p>
</div>
{/each}
{#if messages.length === 0}
<div class="flex h-full items-center justify-center">
<p class="text-sm text-white/30">Schreib einen Prompt...</p>
</div>
{/if}
</div>
<!-- Input -->
<form
onsubmit={(e) => {
e.preventDefault();
send();
}}
class="flex gap-2"
>
<input
bind:value={prompt}
placeholder="Prompt eingeben..."
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<button
type="submit"
disabled={isLoading}
class="rounded-md bg-white/10 px-3 py-1.5 text-sm text-white/70 transition-colors hover:bg-white/15 disabled:opacity-50"
>&#9654;</button
>
</form>
</div>

View file

@ -0,0 +1,67 @@
<!--
Presi — Split-Screen AppView
Presentation decks list with slide count.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalDeck, LocalSlide } from './types';
let decks = $state<LocalDeck[]>([]);
let slides = $state<LocalSlide[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalDeck>('presiDecks')
.toArray()
.then((all) => all.filter((d) => !d.deletedAt));
}).subscribe((val) => {
decks = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalSlide>('slides')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
slides = val ?? [];
});
return () => sub.unsubscribe();
});
function slideCount(deckId: string): number {
return slides.filter((s) => s.deckId === deckId).length;
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<p class="text-xs text-white/40">{decks.length} Präsentationen</p>
<div class="flex-1 overflow-auto">
{#each decks as deck (deck.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<p class="truncate text-sm font-medium text-white/80">{deck.title}</p>
<div class="mt-1 flex items-center gap-2 text-xs text-white/40">
<span>{slideCount(deck.id)} Folien</span>
{#if deck.isPublic}
<span class="rounded bg-white/10 px-1.5 py-0.5 text-[10px]">Öffentlich</span>
{/if}
</div>
{#if deck.description}
<p class="mt-1 truncate text-xs text-white/30">{deck.description}</p>
{/if}
</div>
{/each}
{#if decks.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Präsentationen</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,94 @@
<!--
Questions — Split-Screen AppView
Research questions list with status badges.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalQuestion, LocalCollection } from './types';
let questions = $state<LocalQuestion[]>([]);
let collections = $state<LocalCollection[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalQuestion>('questions')
.toArray()
.then((all) => all.filter((q) => !q.deletedAt));
}).subscribe((val) => {
questions = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalCollection>('questionCollections')
.toArray()
.then((all) => all.filter((c) => !c.deletedAt));
}).subscribe((val) => {
collections = val ?? [];
});
return () => sub.unsubscribe();
});
const statusColors: Record<string, string> = {
open: 'bg-blue-500/20 text-blue-300',
researching: 'bg-amber-500/20 text-amber-300',
answered: 'bg-green-500/20 text-green-300',
archived: 'bg-white/10 text-white/40',
};
const statusLabels: Record<string, string> = {
open: 'Offen',
researching: 'Recherche',
answered: 'Beantwortet',
archived: 'Archiviert',
};
const sorted = $derived(
[...questions]
.filter((q) => q.status !== 'archived')
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
);
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{questions.length} Fragen</span>
<span>{collections.length} Sammlungen</span>
</div>
<div class="flex-1 overflow-auto">
{#each sorted as question (question.id)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<div class="flex items-start justify-between gap-2">
<p class="text-sm font-medium text-white/80">{question.title}</p>
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] {statusColors[question.status] ?? ''}"
>
{statusLabels[question.status] ?? question.status}
</span>
</div>
{#if question.description}
<p class="mt-1 truncate text-xs text-white/30">{question.description}</p>
{/if}
{#if question.tags.length > 0}
<div class="mt-1 flex gap-1">
{#each question.tags.slice(0, 3) as tag}
<span class="rounded bg-white/5 px-1.5 py-0.5 text-[10px] text-white/40">{tag}</span>
{/each}
</div>
{/if}
</div>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine offenen Fragen</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,81 @@
<!--
SkillTree — Split-Screen AppView
Skills overview with XP and levels.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalSkill, LocalActivity } from './types';
import { LEVEL_NAMES, BRANCH_INFO, xpProgress, type SkillBranch } from './types';
let skills = $state<LocalSkill[]>([]);
let activities = $state<LocalActivity[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalSkill>('skills')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
}).subscribe((val) => {
skills = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalActivity>('activities')
.toArray()
.then((all) => all.filter((a) => !a.deletedAt));
}).subscribe((val) => {
activities = val ?? [];
});
return () => sub.unsubscribe();
});
const totalXp = $derived(skills.reduce((sum, s) => sum + s.totalXp, 0));
const highestLevel = $derived(Math.max(0, ...skills.map((s) => s.level)));
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- Stats -->
<div class="flex gap-3 text-xs text-white/40">
<span>{totalXp} XP</span>
<span>Level {highestLevel}</span>
<span>{skills.length} Skills</span>
</div>
<!-- Skills list -->
<div class="flex-1 overflow-auto">
{#each skills as skill (skill.id)}
{@const branch = BRANCH_INFO[skill.branch as SkillBranch]}
{@const progress = xpProgress(skill.currentXp, skill.level)}
<div
class="mb-2 rounded-md border border-white/10 px-3 py-2.5 transition-colors hover:bg-white/5"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-sm">{skill.icon}</span>
<p class="text-sm font-medium text-white/80">{skill.name}</p>
</div>
<span class="text-xs text-white/50">Lv. {skill.level}</span>
</div>
<div class="mt-1 flex items-center gap-2">
<div class="h-1 flex-1 rounded-full bg-white/10">
<div class="h-full rounded-full bg-white/30" style="width: {progress}%"></div>
</div>
<span class="text-[10px] text-white/30">{skill.currentXp} XP</span>
</div>
<p class="mt-0.5 text-[10px] text-white/30">
{branch?.name ?? skill.branch}{LEVEL_NAMES[skill.level] ?? 'Unbekannt'}
</p>
</div>
{/each}
{#if skills.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Skills angelegt</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,92 @@
<!--
Storage — Split-Screen AppView
File browser with recent files and folders.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalFile, LocalFolder } from './types';
let files = $state<LocalFile[]>([]);
let folders = $state<LocalFolder[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFile>('files')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt && !f.isDeleted));
}).subscribe((val) => {
files = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFolder>('folders')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt && !f.isDeleted));
}).subscribe((val) => {
folders = val ?? [];
});
return () => sub.unsubscribe();
});
const recentFiles = $derived(
[...files].sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')).slice(0, 15)
);
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
function fileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
return '&#128462;';
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{folders.length} Ordner</span>
<span>{files.length} Dateien</span>
</div>
<div class="flex-1 overflow-auto">
<!-- Root folders -->
{#if folders.filter((f) => !f.parentFolderId).length > 0}
<h3 class="mb-2 text-xs font-medium text-white/50">Ordner</h3>
{#each folders.filter((f) => !f.parentFolderId) as folder (folder.id)}
<div
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
>
<span class="text-sm" style="color: {folder.color ?? '#6b7280'}">&#128193;</span>
<span class="truncate text-sm text-white/70">{folder.name}</span>
</div>
{/each}
{/if}
<!-- Recent files -->
<h3 class="mb-2 mt-3 text-xs font-medium text-white/50">Zuletzt</h3>
{#each recentFiles as file (file.id)}
<div
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-white/5"
>
<span class="text-sm">{@html fileIcon(file.mimeType)}</span>
<span class="min-w-0 flex-1 truncate text-sm text-white/70">{file.name}</span>
<span class="shrink-0 text-xs text-white/30">{formatSize(file.size)}</span>
</div>
{/each}
{#if recentFiles.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Dateien</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,95 @@
<!--
Times — Split-Screen AppView
Today's time entries with running timer and daily total.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalTimeEntry, LocalProject } from './types';
let entries = $state<LocalTimeEntry[]>([]);
let projects = $state<LocalProject[]>([]);
const todayStr = new Date().toISOString().split('T')[0];
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalTimeEntry>('timeEntries')
.toArray()
.then((all) => all.filter((e) => !e.deletedAt));
}).subscribe((val) => {
entries = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalProject>('timesProjects')
.toArray()
.then((all) => all.filter((p) => !p.deletedAt));
}).subscribe((val) => {
projects = val ?? [];
});
return () => sub.unsubscribe();
});
const todayEntries = $derived(
entries
.filter((e) => e.date === todayStr)
.sort((a, b) => (b.startTime ?? '').localeCompare(a.startTime ?? ''))
);
const running = $derived(entries.find((e) => e.isRunning));
const totalToday = $derived(todayEntries.reduce((sum, e) => sum + e.duration, 0));
function projectName(projectId?: string | null): string {
if (!projectId) return 'Kein Projekt';
return projects.find((p) => p.id === projectId)?.name ?? 'Projekt';
}
function formatDuration(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- Running timer -->
{#if running}
<div class="rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2">
<div class="flex items-center gap-2">
<div class="h-2 w-2 animate-pulse rounded-full bg-green-400"></div>
<p class="text-sm font-medium text-white/80">{running.description || 'Timer läuft'}</p>
</div>
<p class="mt-0.5 text-xs text-white/40">{projectName(running.projectId)}</p>
</div>
{/if}
<!-- Today stats -->
<div class="flex items-center justify-between text-xs text-white/40">
<span>Heute: {todayEntries.length} Einträge</span>
<span class="font-medium text-white/60">{formatDuration(totalToday)}</span>
</div>
<!-- Entry list -->
<div class="flex-1 overflow-auto">
{#each todayEntries as entry (entry.id)}
<div class="mb-1 rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<p class="truncate text-sm text-white/80">{entry.description || 'Ohne Beschreibung'}</p>
<span class="shrink-0 text-xs text-white/50">{formatDuration(entry.duration)}</span>
</div>
<p class="text-xs text-white/30">{projectName(entry.projectId)}</p>
</div>
{/each}
{#if todayEntries.length === 0 && !running}
<p class="py-8 text-center text-sm text-white/30">Noch keine Zeiteinträge heute</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,127 @@
<!--
Todo — Split-Screen AppView
Compact task list with quick add, filter by inbox/today/overdue.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import {
useAllTasks,
filterIncomplete,
filterToday,
filterOverdue,
sortTasks,
getTaskStats,
} from './queries';
import { tasksStore } from './stores/tasks.svelte';
type ViewFilter = 'inbox' | 'today' | 'overdue';
let filter = $state<ViewFilter>('inbox');
let newTitle = $state('');
let tasks$ = useAllTasks();
let tasks = $state<import('./types').Task[]>([]);
$effect(() => {
const sub = tasks$.subscribe((val) => {
tasks = val ?? [];
});
return () => sub.unsubscribe();
});
const stats = $derived(getTaskStats(tasks));
const filtered = $derived(() => {
const incomplete = filterIncomplete(tasks);
switch (filter) {
case 'today':
return filterToday(tasks);
case 'overdue':
return filterOverdue(tasks);
default:
return sortTasks(incomplete, 'order');
}
});
async function addTask() {
const title = newTitle.trim();
if (!title) return;
await tasksStore.createTask({ title });
newTitle = '';
}
async function toggle(id: string) {
await tasksStore.toggleComplete(id);
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<!-- Stats -->
<div class="flex gap-3 text-xs text-white/50">
<span>{stats.total} gesamt</span>
<span>{stats.today} heute</span>
<span class:text-red-400={stats.overdue > 0}>{stats.overdue} überfällig</span>
</div>
<!-- Filter tabs -->
<div class="flex gap-1">
{#each ['inbox', 'today', 'overdue'] as f}
<button
onclick={() => (filter = f as ViewFilter)}
class="rounded-md px-2.5 py-1 text-xs transition-colors
{filter === f ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/70'}"
>
{f === 'inbox' ? 'Inbox' : f === 'today' ? 'Heute' : 'Überfällig'}
</button>
{/each}
</div>
<!-- Quick add -->
<form
onsubmit={(e) => {
e.preventDefault();
addTask();
}}
class="flex gap-2"
>
<input
bind:value={newTitle}
placeholder="Neue Aufgabe..."
class="flex-1 rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<button
type="submit"
class="rounded-md bg-white/10 px-3 py-1.5 text-sm text-white/70 transition-colors hover:bg-white/15"
>+</button
>
</form>
<!-- Task list -->
<div class="flex-1 overflow-auto">
{#each filtered() as task (task.id)}
<button
onclick={() => toggle(task.id)}
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-white/5"
>
<div
class="mt-0.5 h-4 w-4 shrink-0 rounded border transition-colors
{task.isCompleted ? 'border-green-500 bg-green-500/20' : 'border-white/20'}"
></div>
<div class="min-w-0 flex-1">
<p
class="truncate text-sm {task.isCompleted
? 'text-white/30 line-through'
: 'text-white/80'}"
>
{task.title}
</p>
{#if task.dueDate}
<p class="text-xs text-white/30">{new Date(task.dueDate).toLocaleDateString('de')}</p>
{/if}
</div>
</button>
{/each}
{#if filtered().length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Aufgaben</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,79 @@
<!--
uLoad — Split-Screen AppView
Short links list with click counts.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalLink, LocalFolder } from './types';
let links = $state<LocalLink[]>([]);
let folders = $state<LocalFolder[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalLink>('links')
.toArray()
.then((all) => all.filter((l) => !l.deletedAt && l.isActive));
}).subscribe((val) => {
links = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFolder>('linkFolders')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
folders = val ?? [];
});
return () => sub.unsubscribe();
});
const totalClicks = $derived(links.reduce((sum, l) => sum + l.clickCount, 0));
const sorted = $derived(
[...links].sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')).slice(0, 20)
);
function hostname(url: string): string {
try {
return new URL(url).hostname;
} catch {
return url;
}
}
</script>
<div class="flex h-full flex-col gap-3 p-4">
<div class="flex gap-3 text-xs text-white/40">
<span>{links.length} Links</span>
<span>{totalClicks} Klicks</span>
<span>{folders.length} Ordner</span>
</div>
<div class="flex-1 overflow-auto">
{#each sorted as link (link.id)}
<div class="mb-1 rounded-md px-3 py-2 transition-colors hover:bg-white/5">
<div class="flex items-center justify-between">
<p class="truncate text-sm font-medium text-white/80">
{link.title || link.shortCode}
</p>
<span class="shrink-0 text-xs text-white/40">{link.clickCount}</span>
</div>
<p class="truncate text-xs text-white/30">{hostname(link.originalUrl)}</p>
{#if link.customCode}
<p class="text-xs text-blue-400/60">/{link.customCode}</p>
{/if}
</div>
{/each}
{#if sorted.length === 0}
<p class="py-8 text-center text-sm text-white/30">Keine Links</p>
{/if}
</div>
</div>

View file

@ -0,0 +1,58 @@
<!--
Zitare — Split-Screen AppView
Quote of the day with favorites count.
-->
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalFavorite } from './types';
let favorites = $state<LocalFavorite[]>([]);
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFavorite>('zitareFavorites')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt));
}).subscribe((val) => {
favorites = val ?? [];
});
return () => sub.unsubscribe();
});
// Simple daily quote selection based on day of year
const quotes = [
{ text: 'Der Weg ist das Ziel.', author: 'Konfuzius' },
{ text: 'Wer nicht wagt, der nicht gewinnt.', author: 'Sprichwort' },
{
text: 'In der Mitte von Schwierigkeiten liegen die Möglichkeiten.',
author: 'Albert Einstein',
},
{ text: 'Es ist nicht genug zu wissen, man muss auch anwenden.', author: 'Goethe' },
{ text: 'Handle, ehe du denkst. Nein — denke, ehe du handelst.', author: 'Mark Twain' },
{
text: 'Die Zukunft gehört denen, die an die Schönheit ihrer Träume glauben.',
author: 'Eleanor Roosevelt',
},
{ text: 'Was immer du tun kannst oder träumst es zu können, fang damit an.', author: 'Goethe' },
];
const dayOfYear = Math.floor(
(Date.now() - new Date(new Date().getFullYear(), 0, 0).getTime()) / 86400000
);
const todayQuote = quotes[dayOfYear % quotes.length];
</script>
<div class="flex h-full flex-col items-center justify-center gap-6 p-6">
<div class="text-center">
<blockquote class="text-lg font-light italic leading-relaxed text-white/80">
&laquo;{todayQuote.text}&raquo;
</blockquote>
<p class="mt-3 text-sm text-white/40">{todayQuote.author}</p>
</div>
<div class="text-xs text-white/30">
{favorites.length} gespeicherte Zitate
</div>
</div>

View file

@ -0,0 +1,65 @@
<!--
PanelHeader — Header bar for the split-screen right panel.
Shows app name and close button.
-->
<script lang="ts">
import { SPLIT_APP_LABELS, type SplitAppId } from './registry';
let {
appId,
onClose,
onSwap,
}: {
appId: SplitAppId | null;
onClose: () => void;
onSwap?: () => void;
} = $props();
const label = $derived(appId ? SPLIT_APP_LABELS[appId] : '');
</script>
<div
class="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-white/5 px-3"
>
<span class="text-sm font-medium text-white/70">{label}</span>
<div class="flex items-center gap-1">
{#if onSwap}
<button
onclick={onSwap}
class="rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/70"
title="Panels tauschen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M224,48V152a8,8,0,0,1-13.66,5.66L180,127.31l-42.34,42.35a8,8,0,0,1-11.32,0L96,139.31,45.66,189.66a8,8,0,0,1-11.32-11.32l56-56a8,8,0,0,1,11.32,0L132,152.69,168.69,116,138.34,85.66A8,8,0,0,1,144,72H216A8,8,0,0,1,224,48Z"
/>
</svg>
</button>
{/if}
<button
onclick={onClose}
class="rounded p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/70"
title="Panel schliessen"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 256 256"
fill="currentColor"
>
<path
d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"
/>
</svg>
</button>
</div>
</div>

View file

@ -0,0 +1,69 @@
<!--
ResizeHandle — Draggable divider between split-screen panels.
On mousedown, tracks mouse X and emits percentage position.
-->
<script lang="ts">
let { onResize }: { onResize: (position: number) => void } = $props();
let isDragging = $state(false);
function handleMouseDown(e: MouseEvent) {
e.preventDefault();
isDragging = true;
const handleMouseMove = (moveEvent: MouseEvent) => {
const percentage = (moveEvent.clientX / window.innerWidth) * 100;
onResize(percentage);
};
const handleMouseUp = () => {
isDragging = false;
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
function handleTouchStart(e: TouchEvent) {
e.preventDefault();
isDragging = true;
const handleTouchMove = (moveEvent: TouchEvent) => {
const touch = moveEvent.touches[0];
const percentage = (touch.clientX / window.innerWidth) * 100;
onResize(percentage);
};
const handleTouchEnd = () => {
isDragging = false;
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
};
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group relative flex w-1.5 shrink-0 cursor-col-resize items-center justify-center
hover:bg-white/10 {isDragging ? 'bg-white/15' : 'bg-white/5'}"
onmousedown={handleMouseDown}
ontouchstart={handleTouchStart}
role="separator"
aria-orientation="vertical"
tabindex={0}
>
<div
class="h-8 w-0.5 rounded-full bg-white/20 transition-colors group-hover:bg-white/40
{isDragging ? 'bg-white/50' : ''}"
></div>
</div>
{#if isDragging}
<!-- Overlay to prevent pointer events on iframes/panels during drag -->
<div class="fixed inset-0 z-50 cursor-col-resize" style="background: transparent;"></div>
{/if}

View file

@ -0,0 +1,49 @@
<!--
SplitPaneLayout — Wraps the main content and optionally renders a split-screen panel.
Usage:
<SplitPaneLayout>
<YourMainContent />
</SplitPaneLayout>
-->
<script lang="ts">
import type { Snippet } from 'svelte';
import { splitStore } from './store.svelte';
import ResizeHandle from './ResizeHandle.svelte';
import PanelHeader from './PanelHeader.svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="flex h-full w-full overflow-hidden">
<!-- Main panel -->
<div
style="width: {splitStore.isActive ? splitStore.dividerPosition + '%' : '100%'}"
class="h-full overflow-auto transition-[width] duration-200 ease-out"
>
{@render children()}
</div>
{#if splitStore.isActive && splitStore.SplitComponent}
<ResizeHandle onResize={(pos) => splitStore.setDividerPosition(pos)} />
<!-- Split panel -->
<div
style="width: {100 - splitStore.dividerPosition}%"
class="flex h-full flex-col overflow-hidden border-l border-white/10"
>
<PanelHeader appId={splitStore.splitApp} onClose={() => splitStore.closeSplit()} />
<div class="flex-1 overflow-auto">
<splitStore.SplitComponent />
</div>
</div>
{/if}
{#if splitStore.isLoading}
<div class="flex w-64 items-center justify-center border-l border-white/10 bg-white/5">
<div
class="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white/60"
></div>
</div>
{/if}
</div>

View file

@ -0,0 +1,10 @@
/**
* Split-Screen barrel exports.
*/
export { splitStore } from './store.svelte';
export { loadAppComponent, SPLIT_APP_IDS, SPLIT_APP_LABELS } from './registry';
export type { SplitAppId } from './registry';
export { default as SplitPaneLayout } from './SplitPaneLayout.svelte';
export { default as ResizeHandle } from './ResizeHandle.svelte';
export { default as PanelHeader } from './PanelHeader.svelte';

View file

@ -0,0 +1,74 @@
/**
* Split-Screen App Registry
*
* Lazy-import registry for all app modules.
* Each app has an AppView.svelte component that renders in split-screen.
*/
const APP_COMPONENTS = {
todo: () => import('$lib/modules/todo/AppView.svelte'),
calendar: () => import('$lib/modules/calendar/AppView.svelte'),
contacts: () => import('$lib/modules/contacts/AppView.svelte'),
chat: () => import('$lib/modules/chat/AppView.svelte'),
picture: () => import('$lib/modules/picture/AppView.svelte'),
cards: () => import('$lib/modules/cards/AppView.svelte'),
zitare: () => import('$lib/modules/zitare/AppView.svelte'),
clock: () => import('$lib/modules/clock/AppView.svelte'),
mukke: () => import('$lib/modules/mukke/AppView.svelte'),
storage: () => import('$lib/modules/storage/AppView.svelte'),
presi: () => import('$lib/modules/presi/AppView.svelte'),
inventar: () => import('$lib/modules/inventar/AppView.svelte'),
photos: () => import('$lib/modules/photos/AppView.svelte'),
skilltree: () => import('$lib/modules/skilltree/AppView.svelte'),
citycorners: () => import('$lib/modules/citycorners/AppView.svelte'),
times: () => import('$lib/modules/times/AppView.svelte'),
context: () => import('$lib/modules/context/AppView.svelte'),
questions: () => import('$lib/modules/questions/AppView.svelte'),
nutriphi: () => import('$lib/modules/nutriphi/AppView.svelte'),
planta: () => import('$lib/modules/planta/AppView.svelte'),
uload: () => import('$lib/modules/uload/AppView.svelte'),
calc: () => import('$lib/modules/calc/AppView.svelte'),
moodlit: () => import('$lib/modules/moodlit/AppView.svelte'),
memoro: () => import('$lib/modules/memoro/AppView.svelte'),
playground: () => import('$lib/modules/playground/AppView.svelte'),
};
export type SplitAppId = keyof typeof APP_COMPONENTS;
export const SPLIT_APP_IDS = Object.keys(APP_COMPONENTS) as SplitAppId[];
/** Display names for each app (German UI). */
export const SPLIT_APP_LABELS: Record<SplitAppId, string> = {
todo: 'Todo',
calendar: 'Kalender',
contacts: 'Kontakte',
chat: 'Chat',
picture: 'Picture',
cards: 'Cards',
zitare: 'Zitare',
clock: 'Uhr',
mukke: 'Mukke',
storage: 'Storage',
presi: 'Presi',
inventar: 'Inventar',
photos: 'Fotos',
skilltree: 'SkillTree',
citycorners: 'CityCorners',
times: 'Times',
context: 'Context',
questions: 'Questions',
nutriphi: 'NutriPhi',
planta: 'Planta',
uload: 'uLoad',
calc: 'Calc',
moodlit: 'Moodlit',
memoro: 'Memoro',
playground: 'Playground',
};
export async function loadAppComponent(appId: string) {
const loader = APP_COMPONENTS[appId as SplitAppId];
if (!loader) return null;
const module = await loader();
return module.default;
}

View file

@ -0,0 +1,133 @@
/**
* Split-Screen Store Svelte 5 runes store
*
* Manages split-screen panel state: which app is shown, divider position,
* component loading, and localStorage persistence.
*/
import type { Component } from 'svelte';
import { loadAppComponent, type SplitAppId } from './registry';
const STORAGE_KEY = 'manacore:split-screen';
const MIN_POSITION = 20;
const MAX_POSITION = 80;
const DEFAULT_POSITION = 50;
const MIN_SCREEN_WIDTH = 1024;
interface PersistedState {
splitApp: SplitAppId | null;
dividerPosition: number;
}
function loadPersisted(): PersistedState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { splitApp: null, dividerPosition: DEFAULT_POSITION };
const parsed = JSON.parse(raw) as PersistedState;
return {
splitApp: parsed.splitApp ?? null,
dividerPosition: clamp(parsed.dividerPosition ?? DEFAULT_POSITION),
};
} catch {
return { splitApp: null, dividerPosition: DEFAULT_POSITION };
}
}
function savePersisted(state: PersistedState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// Storage full or unavailable — ignore
}
}
function clamp(pos: number): number {
return Math.min(MAX_POSITION, Math.max(MIN_POSITION, pos));
}
function createSplitStore() {
const persisted =
typeof window !== 'undefined'
? loadPersisted()
: { splitApp: null, dividerPosition: DEFAULT_POSITION };
let splitApp = $state<SplitAppId | null>(null);
let SplitComponent = $state<Component | null>(null);
let dividerPosition = $state(persisted.dividerPosition);
let isLoading = $state(false);
let isMobile = $state(
typeof window !== 'undefined' ? window.innerWidth < MIN_SCREEN_WIDTH : false
);
const isActive = $derived(!isMobile && splitApp !== null && SplitComponent !== null);
// Listen for resize
if (typeof window !== 'undefined') {
window.addEventListener('resize', () => {
isMobile = window.innerWidth < MIN_SCREEN_WIDTH;
});
// Restore persisted app on load
if (persisted.splitApp && !isMobile) {
isLoading = true;
loadAppComponent(persisted.splitApp).then((component) => {
if (component) {
SplitComponent = component;
splitApp = persisted.splitApp;
}
isLoading = false;
});
}
}
return {
get splitApp() {
return splitApp;
},
get SplitComponent() {
return SplitComponent;
},
get dividerPosition() {
return dividerPosition;
},
get isLoading() {
return isLoading;
},
get isActive() {
return isActive;
},
get isMobile() {
return isMobile;
},
async openSplit(appId: SplitAppId) {
if (isMobile) return;
if (splitApp === appId) return;
isLoading = true;
const component = await loadAppComponent(appId);
if (component) {
SplitComponent = component;
splitApp = appId;
savePersisted({ splitApp: appId, dividerPosition });
}
isLoading = false;
},
closeSplit() {
splitApp = null;
SplitComponent = null;
isLoading = false;
savePersisted({ splitApp: null, dividerPosition });
},
setDividerPosition(pos: number) {
dividerPosition = clamp(pos);
if (splitApp) {
savePersisted({ splitApp, dividerPosition });
}
},
};
}
export const splitStore = createSplitStore();