feat(dashboard): add StorageUsageWidget

Add new dashboard widget showing storage usage stats:
- Total storage used and file count
- Recent files list with icons
- Link to open Storage app

Backend changes:
- Add GET /api/v1/files/stats endpoint to storage backend
- Returns totalFiles, totalSize, favoriteCount, recentFiles

Frontend changes:
- Add storageService API client
- Add StorageUsageWidget component
- Add i18n translations (de/en)
- Register widget in dashboard types and container

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-13 22:34:10 +01:00
parent ce4e982651
commit 7d1b4a40d2
10 changed files with 310 additions and 4 deletions

View file

@ -114,9 +114,9 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
### 3. ✅ Dashboard-Widgets erweitern (GRÖSSTENTEILS ERLEDIGT)
**Status:** 10 von 13 Widgets implementiert
**Status:** 14 von 16 Widgets implementiert (Finance + Mail fehlen)
**Existierende Widgets (13 Typen):**
**Existierende Widgets (14 Typen):**
| Widget | App | Status |
| ----------------------- | -------------- | ------ |
@ -133,12 +133,16 @@ Archivierte Apps (memoro, storyteller) wurden bereits entfernt.
| PictureRecentWidget | picture | ✅ |
| ManadeckProgressWidget | manadeck | ✅ |
| ClockTimersWidget | clock | ✅ |
| StorageUsageWidget | storage | ✅ |
**Neue Widgets (2026-02-13):**
- [x] StorageUsageWidget - Speichernutzung und letzte Dateien
**Noch offen (Backend fehlt noch):**
- [ ] FinanceBalanceWidget (finance Backend nötig)
- [ ] MailInboxWidget (mail Backend nötig)
- [ ] StorageUsageWidget (storage Backend nötig)
---

View file

@ -12,3 +12,4 @@ export { zitareService, type Favorite, type Quote, type QuoteList } from './zita
export { pictureService, type GeneratedImage, type GenerationStats } from './picture';
export { manadeckService, type Deck, type Card, type LearningProgress } from './manadeck';
export { clockService, type Timer, type Alarm, type ClockStats } from './clock';
export { storageService, type StorageFile, type StorageStats } from './storage';

View file

@ -0,0 +1,102 @@
/**
* Storage API Service
*
* Fetches file storage stats from the Storage backend for dashboard widgets.
*/
import { browser } from '$app/environment';
import { createApiClient, type ApiResult } from '../base-client';
// Get Storage API URL dynamically at runtime
function getStorageApiUrl(): string {
if (browser && typeof window !== 'undefined') {
// Client-side: use injected window variable (set by hooks.server.ts)
const injectedUrl = (window as unknown as { __PUBLIC_STORAGE_API_URL__?: string })
.__PUBLIC_STORAGE_API_URL__;
if (injectedUrl) {
return `${injectedUrl}/api/v1`;
}
}
// Fallback for local development
return 'http://localhost:3016/api/v1';
}
// Lazy-initialized client to ensure we get the correct URL at runtime
let _client: ReturnType<typeof createApiClient> | null = null;
function getClient() {
if (!_client) {
_client = createApiClient(getStorageApiUrl());
}
return _client;
}
/**
* File entity from Storage backend
*/
export interface StorageFile {
id: string;
name: string;
originalName: string;
mimeType: string;
size: number;
storagePath: string;
storageKey: string;
parentFolderId?: string | null;
isFavorite: boolean;
currentVersion: number;
createdAt: string;
updatedAt: string;
}
/**
* Storage stats from backend
*/
export interface StorageStats {
totalFiles: number;
totalSize: number;
favoriteCount: number;
recentFiles: StorageFile[];
}
/**
* Storage service for dashboard widgets
*/
export const storageService = {
/**
* Get storage statistics
*/
async getStats(): Promise<ApiResult<StorageStats>> {
const result = await getClient().get<StorageStats>('/files/stats');
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data, error: null };
},
/**
* Get recent files
*/
async getRecentFiles(limit: number = 5): Promise<ApiResult<StorageFile[]>> {
const result = await this.getStats();
if (result.error || !result.data) {
return { data: null, error: result.error };
}
return { data: result.data.recentFiles.slice(0, limit), error: null };
},
/**
* Format file size for display
*/
formatSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
};

View file

@ -26,6 +26,7 @@
import PictureRecentWidget from './widgets/PictureRecentWidget.svelte';
import ManadeckProgressWidget from './widgets/ManadeckProgressWidget.svelte';
import ClockTimersWidget from './widgets/ClockTimersWidget.svelte';
import StorageUsageWidget from './widgets/StorageUsageWidget.svelte';
interface Props {
widget: WidgetConfig;
@ -67,6 +68,7 @@
'picture-recent': PictureRecentWidget,
'manadeck-progress': ManadeckProgressWidget,
'clock-timers': ClockTimersWidget,
'storage-usage': StorageUsageWidget,
} as const;
const WidgetComponent = $derived(widgetComponents[widget.type]);

View file

@ -0,0 +1,111 @@
<script lang="ts">
/**
* StorageUsageWidget - Displays storage usage and recent files
*/
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { storageService, type StorageStats } from '$lib/api/services/storage';
import WidgetSkeleton from '../WidgetSkeleton.svelte';
import WidgetError from '../WidgetError.svelte';
let state = $state<'loading' | 'success' | 'error'>('loading');
let data = $state<StorageStats | null>(null);
let error = $state<string | null>(null);
let retrying = $state(false);
async function load() {
state = 'loading';
retrying = true;
try {
const result = await storageService.getStats();
if (result.error) {
throw new Error(result.error);
}
data = result.data;
state = 'success';
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load storage stats';
state = 'error';
} finally {
retrying = false;
}
}
onMount(load);
function formatSize(bytes: number): string {
return storageService.formatSize(bytes);
}
function getFileIcon(mimeType: string): string {
if (mimeType.startsWith('image/')) return '🖼️';
if (mimeType.startsWith('video/')) return '🎬';
if (mimeType.startsWith('audio/')) return '🎵';
if (mimeType.includes('pdf')) return '📄';
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar'))
return '📦';
if (mimeType.includes('text') || mimeType.includes('document')) return '📝';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
return '📁';
}
</script>
<div>
<h3 class="mb-3 flex items-center gap-2 text-lg font-semibold">
<span>💾</span>
{$_('dashboard.widgets.storage.title')}
</h3>
{#if state === 'loading'}
<WidgetSkeleton lines={4} />
{:else if state === 'error'}
<WidgetError {error} onRetry={load} {retrying} />
{:else if data}
<div class="space-y-4">
<!-- Storage Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="rounded-lg bg-muted/50 p-3">
<p class="text-muted-foreground text-xs">{$_('dashboard.widgets.storage.total_size')}</p>
<p class="text-xl font-bold">{formatSize(data.totalSize)}</p>
</div>
<div class="rounded-lg bg-muted/50 p-3">
<p class="text-muted-foreground text-xs">{$_('dashboard.widgets.storage.files')}</p>
<p class="text-xl font-bold">{data.totalFiles}</p>
</div>
</div>
<!-- Recent Files -->
{#if data.recentFiles && data.recentFiles.length > 0}
<div>
<p class="text-muted-foreground mb-2 text-sm font-medium">
{$_('dashboard.widgets.storage.recent')}
</p>
<ul class="space-y-2">
{#each data.recentFiles.slice(0, 3) as file}
<li class="flex items-center gap-2 text-sm">
<span>{getFileIcon(file.mimeType)}</span>
<span class="flex-1 truncate">{file.name}</span>
<span class="text-muted-foreground text-xs">{formatSize(file.size)}</span>
</li>
{/each}
</ul>
</div>
{:else}
<p class="text-muted-foreground text-sm">{$_('dashboard.widgets.storage.empty')}</p>
{/if}
<a
href="https://storage.mana.how"
target="_blank"
rel="noopener"
class="mt-2 block w-full rounded-lg bg-primary/10 py-2 text-center text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.storage.open')}
</a>
</div>
{/if}
</div>

View file

@ -92,6 +92,19 @@
"open": "Clock öffnen",
"active_timers": "Aktive Timer",
"alarms": "Wecker"
},
"storage": {
"title": "Speicher",
"description": "Dein Cloud-Speicher",
"total_size": "Genutzt",
"files": "Dateien",
"recent": "Kürzlich",
"empty": "Keine Dateien",
"open": "Storage öffnen"
},
"referral": {
"title": "Empfehlungen",
"description": "Teile und verdiene Credits"
}
}
},

View file

@ -92,6 +92,19 @@
"open": "Open Clock",
"active_timers": "Active Timers",
"alarms": "Alarms"
},
"storage": {
"title": "Storage",
"description": "Your cloud storage",
"total_size": "Used",
"files": "Files",
"recent": "Recent",
"empty": "No files",
"open": "Open Storage"
},
"referral": {
"title": "Referrals",
"description": "Share and earn credits"
}
}
},

View file

@ -20,7 +20,8 @@ export type WidgetType =
| 'zitare-quote' // Zitare API: daily inspiration quote
| 'picture-recent' // Picture API: recent generations
| 'manadeck-progress' // ManaDeck API: learning progress
| 'clock-timers'; // Clock: active timers and alarms
| 'clock-timers' // Clock: active timers and alarms
| 'storage-usage'; // Storage: file storage stats
/**
* Widget size - maps to CSS Grid columns
@ -114,6 +115,7 @@ export interface WidgetMeta {
| 'picture'
| 'manadeck'
| 'clock'
| 'storage'
| 'mana-core-auth';
}
@ -237,6 +239,15 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
requiredBackend: 'clock',
},
{
type: 'storage-usage',
nameKey: 'dashboard.widgets.storage.title',
descriptionKey: 'dashboard.widgets.storage.description',
icon: '💾',
defaultSize: 'medium',
allowMultiple: false,
requiredBackend: 'storage',
},
];
/**

View file

@ -37,6 +37,11 @@ export class FileController {
return this.fileService.findAll(user.userId, parentFolderId);
}
@Get('stats')
async getStats(@CurrentUser() user: CurrentUserData) {
return this.fileService.getStats(user.userId);
}
@Get(':id')
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
return this.fileService.findOne(user.userId, id);

View file

@ -163,4 +163,48 @@ export class FileService {
const file = await this.findOne(userId, id);
return this.storageService.getDownloadUrl(file.storageKey);
}
async getStats(userId: string): Promise<{
totalFiles: number;
totalSize: number;
favoriteCount: number;
recentFiles: File[];
}> {
const { count, sum } = await import('drizzle-orm');
// Get total files and size
const [stats] = await this.db
.select({
totalFiles: count(files.id),
totalSize: sum(files.size),
})
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false)));
// Get favorite count
const [favStats] = await this.db
.select({
count: count(files.id),
})
.from(files)
.where(
and(eq(files.userId, userId), eq(files.isDeleted, false), eq(files.isFavorite, true))
);
// Get recent files (last 5)
const { desc } = await import('drizzle-orm');
const recentFiles = await this.db
.select()
.from(files)
.where(and(eq(files.userId, userId), eq(files.isDeleted, false)))
.orderBy(desc(files.updatedAt))
.limit(5);
return {
totalFiles: Number(stats.totalFiles) || 0,
totalSize: Number(stats.totalSize) || 0,
favoriteCount: Number(favStats.count) || 0,
recentFiles,
};
}
}