mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
✨ 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:
parent
ce4e982651
commit
7d1b4a40d2
10 changed files with 310 additions and 4 deletions
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
102
apps/manacore/apps/web/src/lib/api/services/storage.ts
Normal file
102
apps/manacore/apps/web/src/lib/api/services/storage.ts
Normal 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];
|
||||
},
|
||||
};
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue