mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
9a6aa12c63
commit
6dc259d743
31 changed files with 2548 additions and 0 deletions
95
apps/manacore/apps/web/src/lib/modules/calc/AppView.svelte
Normal file
95
apps/manacore/apps/web/src/lib/modules/calc/AppView.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
73
apps/manacore/apps/web/src/lib/modules/cards/AppView.svelte
Normal file
73
apps/manacore/apps/web/src/lib/modules/cards/AppView.svelte
Normal 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>
|
||||
77
apps/manacore/apps/web/src/lib/modules/chat/AppView.svelte
Normal file
77
apps/manacore/apps/web/src/lib/modules/chat/AppView.svelte
Normal 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">📌</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>
|
||||
|
|
@ -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">★</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>
|
||||
126
apps/manacore/apps/web/src/lib/modules/clock/AppView.svelte
Normal file
126
apps/manacore/apps/web/src/lib/modules/clock/AppView.svelte
Normal 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>
|
||||
|
|
@ -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">★</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>
|
||||
|
|
@ -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: '📄',
|
||||
context: '📚',
|
||||
prompt: '⚡',
|
||||
};
|
||||
</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] ?? '📄'}</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">📌</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>
|
||||
|
|
@ -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>
|
||||
88
apps/manacore/apps/web/src/lib/modules/memoro/AppView.svelte
Normal file
88
apps/manacore/apps/web/src/lib/modules/memoro/AppView.svelte
Normal 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">📌</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>
|
||||
|
|
@ -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>
|
||||
84
apps/manacore/apps/web/src/lib/modules/mukke/AppView.svelte
Normal file
84
apps/manacore/apps/web/src/lib/modules/mukke/AppView.svelte
Normal 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"
|
||||
>
|
||||
♫
|
||||
</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>
|
||||
109
apps/manacore/apps/web/src/lib/modules/nutriphi/AppView.svelte
Normal file
109
apps/manacore/apps/web/src/lib/modules/nutriphi/AppView.svelte
Normal 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>
|
||||
64
apps/manacore/apps/web/src/lib/modules/photos/AppView.svelte
Normal file
64
apps/manacore/apps/web/src/lib/modules/photos/AppView.svelte
Normal 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>
|
||||
|
|
@ -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">★</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>
|
||||
102
apps/manacore/apps/web/src/lib/modules/planta/AppView.svelte
Normal file
102
apps/manacore/apps/web/src/lib/modules/planta/AppView.svelte
Normal 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: '🌱',
|
||||
needs_attention: '⚠',
|
||||
sick: '🤒',
|
||||
};
|
||||
</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'] ?? '🌱'}</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">💧</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>
|
||||
|
|
@ -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"
|
||||
>▶</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
67
apps/manacore/apps/web/src/lib/modules/presi/AppView.svelte
Normal file
67
apps/manacore/apps/web/src/lib/modules/presi/AppView.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 '📷';
|
||||
if (mimeType.startsWith('video/')) return '🎥';
|
||||
if (mimeType.startsWith('audio/')) return '🎵';
|
||||
if (mimeType.includes('pdf')) return '📄';
|
||||
return '🗎';
|
||||
}
|
||||
</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'}">📁</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>
|
||||
95
apps/manacore/apps/web/src/lib/modules/times/AppView.svelte
Normal file
95
apps/manacore/apps/web/src/lib/modules/times/AppView.svelte
Normal 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>
|
||||
127
apps/manacore/apps/web/src/lib/modules/todo/AppView.svelte
Normal file
127
apps/manacore/apps/web/src/lib/modules/todo/AppView.svelte
Normal 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>
|
||||
79
apps/manacore/apps/web/src/lib/modules/uload/AppView.svelte
Normal file
79
apps/manacore/apps/web/src/lib/modules/uload/AppView.svelte
Normal 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>
|
||||
58
apps/manacore/apps/web/src/lib/modules/zitare/AppView.svelte
Normal file
58
apps/manacore/apps/web/src/lib/modules/zitare/AppView.svelte
Normal 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">
|
||||
«{todayQuote.text}»
|
||||
</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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
10
apps/manacore/apps/web/src/lib/splitscreen/index.ts
Normal file
10
apps/manacore/apps/web/src/lib/splitscreen/index.ts
Normal 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';
|
||||
74
apps/manacore/apps/web/src/lib/splitscreen/registry.ts
Normal file
74
apps/manacore/apps/web/src/lib/splitscreen/registry.ts
Normal 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;
|
||||
}
|
||||
133
apps/manacore/apps/web/src/lib/splitscreen/store.svelte.ts
Normal file
133
apps/manacore/apps/web/src/lib/splitscreen/store.svelte.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue