feat(manacore): implement mukke, presi, and context dashboard widgets

Add the 3 missing widget components that were registered but not implemented:

- MukkeLibraryWidget: shows library stats (songs, playlists, favorites),
  recent songs with artist/duration
- PresiDecksWidget: shows recent presentations with public/private
  indicator, descriptions, and update dates
- ContextDocsWidget: shows recent documents grouped by type (text,
  context, prompt) with space names, falls back to space list

All widgets follow the established pattern: APP_URLS for links,
WidgetSkeleton/WidgetError for loading/error, auto-retry on failure.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 22:34:56 +01:00
parent 7f7c6e6fde
commit 421ef55457
4 changed files with 425 additions and 0 deletions

View file

@ -26,6 +26,9 @@
import ManadeckProgressWidget from './widgets/ManadeckProgressWidget.svelte';
import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
import MukkeLibraryWidget from './widgets/MukkeLibraryWidget.svelte';
import PresiDecksWidget from './widgets/PresiDecksWidget.svelte';
import ContextDocsWidget from './widgets/ContextDocsWidget.svelte';
interface Props {
widget: WidgetConfig;
@ -67,6 +70,9 @@
'manadeck-progress': ManadeckProgressWidget,
'clock-timers': ClockTimersWidget,
'storage-usage': StorageUsageWidget,
'mukke-library': MukkeLibraryWidget,
'presi-decks': PresiDecksWidget,
'context-docs': ContextDocsWidget,
} as const;
const WidgetComponent = $derived(widgetComponents[widget.type]);

View file

@ -0,0 +1,163 @@
<script lang="ts">
/**
* ContextDocsWidget - Recent documents and spaces
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { contextService, type ContextDocument, type ContextSpace } from '$lib/api/services';
import { APP_URLS } from '@manacore/shared-branding';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const contextUrl = isDev ? APP_URLS.context.dev : APP_URLS.context.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let documents = $state<ContextDocument[]>([]);
let spaces = $state<ContextSpace[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const MAX_DISPLAY = 5;
const typeIcons: Record<string, string> = {
text: '📝',
context: '🧠',
prompt: '💬',
};
async function load() {
state = 'loading';
retrying = true;
const [docsResult, spacesResult] = await Promise.all([
contextService.getRecentDocuments(MAX_DISPLAY),
contextService.getSpaces(),
]);
if (docsResult.data || spacesResult.data) {
documents = docsResult.data || [];
spaces = spacesResult.data || [];
state = 'success';
retryCount = 0;
} else {
error = docsResult.error || spacesResult.error;
state = 'error';
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function getSpaceName(spaceId: string): string | null {
return spaces.find((s) => s.id === spaceId)?.name || null;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🧠</span>
{$_('dashboard.widgets.context.title')}
</h3>
{#if spaces.length > 0}
<span class="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{spaces.length} Spaces
</span>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if documents.length === 0 && spaces.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">📚</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.context.empty')}
</p>
<a
href={contextUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Space erstellen
</a>
</div>
{:else}
<!-- Recent documents -->
{#if documents.length > 0}
<div class="space-y-1">
{#each documents as doc}
<a
href="{contextUrl}/doc/{doc.shortId}"
target="_blank"
rel="noopener"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{typeIcons[doc.type] || '📄'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{doc.title}</p>
{@const spaceName = getSpaceName(doc.spaceId)}
{#if spaceName}
<p class="truncate text-xs text-muted-foreground">{spaceName}</p>
{/if}
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(doc.updatedAt)}
</span>
</a>
{/each}
</div>
{:else}
<!-- No documents but has spaces -->
<div class="mb-3 space-y-1">
{#each spaces.slice(0, 3) as space}
<a
href="{contextUrl}/space/{space.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{space.pinned ? '📌' : '📁'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{space.name}</p>
{#if space.description}
<p class="truncate text-xs text-muted-foreground">{space.description}</p>
{/if}
</div>
</a>
{/each}
</div>
{/if}
<div class="mt-3 text-center">
<a
href={contextUrl}
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
Alle Dokumente
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,132 @@
<script lang="ts">
/**
* MukkeLibraryWidget - Music library stats and recent songs
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { mukkeService, type MukkeStats, type Song } from '$lib/api/services';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const mukkeUrl = isDev ? 'http://localhost:5180' : 'https://mukke.mana.how';
let state = $state<'loading' | 'success' | 'error'>('loading');
let stats = $state<MukkeStats | null>(null);
let recentSongs = $state<Song[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
async function load() {
state = 'loading';
retrying = true;
const [statsResult, songsResult] = await Promise.all([
mukkeService.getStats(),
mukkeService.getRecentSongs(5),
]);
if (statsResult.data) {
stats = statsResult.data;
recentSongs = songsResult.data || [];
state = 'success';
retryCount = 0;
} else {
error = statsResult.error || songsResult.error;
state = 'error';
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>🎵</span>
{$_('dashboard.widgets.mukke.title')}
</h3>
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if !stats || stats.totalSongs === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🎶</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.mukke.empty')}
</p>
<a
href={mukkeUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Musik entdecken
</a>
</div>
{:else}
<!-- Stats row -->
<div class="mb-4 grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-primary">{stats.totalSongs}</div>
<div class="text-xs text-muted-foreground">Songs</div>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-orange-500">{stats.totalPlaylists}</div>
<div class="text-xs text-muted-foreground">Playlists</div>
</div>
<div class="rounded-lg bg-surface-hover p-2">
<div class="text-xl font-bold text-green-500">{stats.favoriteCount}</div>
<div class="text-xs text-muted-foreground">Favoriten</div>
</div>
</div>
<!-- Recent songs -->
{#if recentSongs.length > 0}
<div class="space-y-1">
{#each recentSongs as song}
<div
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{song.favorite ? '❤️' : '🎵'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{song.title}</p>
{#if song.artist}
<p class="truncate text-xs text-muted-foreground">{song.artist}</p>
{/if}
</div>
{#if song.duration}
<span class="flex-shrink-0 text-xs text-muted-foreground">
{mukkeService.formatDuration(song.duration)}
</span>
{/if}
</div>
{/each}
</div>
{/if}
<div class="mt-3 text-center">
<a
href={mukkeUrl}
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
Bibliothek öffnen
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,124 @@
<script lang="ts">
/**
* PresiDecksWidget - Recent presentations
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { presiService, type PresiDeck } from '$lib/api/services';
import { APP_URLS } from '@manacore/shared-branding';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const presiUrl = isDev ? APP_URLS.presi.dev : APP_URLS.presi.prod;
let state = $state<'loading' | 'success' | 'error'>('loading');
let decks = $state<PresiDeck[]>([]);
let error = $state<string | null>(null);
let retrying = $state(false);
let retryCount = $state(0);
const MAX_DISPLAY = 5;
async function load() {
state = 'loading';
retrying = true;
const result = await presiService.getRecentDecks(MAX_DISPLAY);
if (result.data) {
decks = result.data;
state = 'success';
retryCount = 0;
} else {
error = result.error;
state = 'error';
const isServiceUnavailable = error?.includes('nicht erreichbar');
if (!isServiceUnavailable && retryCount < 3) {
retryCount++;
setTimeout(load, 5000 * retryCount);
}
}
retrying = false;
}
onMount(load);
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<span>📊</span>
{$_('dashboard.widgets.presi.title')}
</h3>
{#if decks.length > 0}
<span class="rounded-full bg-primary/10 px-2.5 py-0.5 text-sm font-medium text-primary">
{decks.length}
</span>
{/if}
</div>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if decks.length === 0}
<div class="py-6 text-center">
<div class="mb-2 text-3xl">🎨</div>
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.presi.empty')}
</p>
<a
href={presiUrl}
target="_blank"
rel="noopener"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Präsentation erstellen
</a>
</div>
{:else}
<div class="space-y-1">
{#each decks as deck}
<a
href="{presiUrl}/deck/{deck.id}"
target="_blank"
rel="noopener"
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<span class="text-base">{deck.isPublic ? '🌐' : '📄'}</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">{deck.title}</p>
{#if deck.description}
<p class="truncate text-xs text-muted-foreground">{deck.description}</p>
{/if}
</div>
<span class="flex-shrink-0 text-xs text-muted-foreground">
{formatDate(deck.updatedAt)}
</span>
</a>
{/each}
</div>
<div class="mt-3 text-center">
<a
href={presiUrl}
target="_blank"
rel="noopener"
class="text-sm text-primary hover:underline"
>
Alle Präsentationen
</a>
</div>
{/if}
</div>