mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 08:59:39 +02:00
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:
parent
7f7c6e6fde
commit
421ef55457
4 changed files with 425 additions and 0 deletions
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue