feat(manacore): migrate storage, cards, playground, guides to unified app

Phase 2 continued — 4 more modules migrated (total: 21/25):

- Storage: file browser with folders, favorites, search, trash (7 routes)
- Cards: deck/card management with study progress (6 routes)
- Playground: LLM chat interface with model selector (stateless)
- Guides: guide listing with category filters (static content)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 20:28:00 +02:00
parent 7def9c9977
commit 990ade352f
31 changed files with 3284 additions and 0 deletions

View file

@ -0,0 +1,59 @@
/**
* Cards module collection accessors and guest seed data.
*
* Uses table names from the unified DB: cardDecks, cards.
*/
import { db } from '$lib/data/database';
import type { LocalDeck, LocalCard } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const cardDeckTable = db.table<LocalDeck>('cardDecks');
export const cardTable = db.table<LocalCard>('cards');
// ─── Guest Seed ────────────────────────────────────────────
const ONBOARDING_DECK_ID = 'onboarding-deck';
export const CARDS_GUEST_SEED = {
cardDecks: [
{
id: ONBOARDING_DECK_ID,
name: 'Erste Schritte',
description: 'Lerne Cards kennen mit diesen Beispiel-Karteikarten.',
color: '#6366f1',
cardCount: 3,
isPublic: false,
},
],
cards: [
{
id: 'card-1',
deckId: ONBOARDING_DECK_ID,
front: 'Was ist Cards?',
back: 'Cards ist eine Karteikarten-App zum effizienten Lernen mit Spaced Repetition.',
difficulty: 1,
reviewCount: 0,
order: 0,
},
{
id: 'card-2',
deckId: ONBOARDING_DECK_ID,
front: 'Wie funktioniert Spaced Repetition?',
back: 'Karten, die du gut kennst, werden seltener gezeigt. Schwierige Karten erscheinen haufiger, bis du sie beherrschst.',
difficulty: 2,
reviewCount: 0,
order: 1,
},
{
id: 'card-3',
deckId: ONBOARDING_DECK_ID,
front: 'Wie erstelle ich ein neues Deck?',
back: 'Klicke auf den + Button auf der Decks-Seite, um ein neues Deck mit eigenen Karteikarten zu erstellen.',
difficulty: 1,
reviewCount: 0,
order: 2,
},
],
};

View file

@ -0,0 +1,127 @@
<script lang="ts">
import { deckStore } from '../stores/decks.svelte';
interface Props {
open?: boolean;
onClose?: () => void;
}
let { open = $bindable(false), onClose }: Props = $props();
let title = $state('');
let description = $state('');
let isPublic = $state(false);
let submitting = $state(false);
async function handleSubmit() {
if (!title.trim()) return;
submitting = true;
const deck = await deckStore.createDeck({
title: title.trim(),
description: description.trim() || undefined,
isPublic,
});
submitting = false;
if (deck) {
title = '';
description = '';
isPublic = false;
open = false;
onClose?.();
}
}
function handleClose() {
open = false;
onClose?.();
}
</script>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={handleClose}
>
<div
class="mx-4 w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<h2 class="mb-4 text-xl font-semibold text-foreground">Neues Deck erstellen</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<div>
<label for="deck-title" class="mb-1 block text-sm font-medium text-foreground">
Titel
</label>
<input
id="deck-title"
type="text"
bind:value={title}
placeholder="z.B. Spanisch Vokabeln"
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
required
/>
</div>
<div>
<label for="deck-desc" class="mb-1 block text-sm font-medium text-foreground">
Beschreibung
</label>
<textarea
id="deck-desc"
bind:value={description}
placeholder="Worum geht es in diesem Deck?"
class="min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
></textarea>
</div>
<div class="flex items-center gap-2">
<input
type="checkbox"
id="deck-public"
bind:checked={isPublic}
class="h-4 w-4 rounded border-border"
/>
<label for="deck-public" class="cursor-pointer text-sm text-foreground">
Offentlich machen
</label>
</div>
{#if deckStore.error}
<div class="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{deckStore.error}
</div>
{/if}
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
onclick={handleClose}
>
Abbrechen
</button>
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm text-white disabled:opacity-50"
disabled={submitting || !title.trim()}
>
{submitting ? 'Erstelle...' : 'Deck erstellen'}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { Deck } from '../types';
interface Props {
deck: Deck;
onclick?: () => void;
}
let { deck, onclick }: Props = $props();
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<button
class="w-full rounded-xl border border-border bg-card p-4 text-left transition-all hover:border-primary/50 hover:shadow-lg"
{onclick}
>
<div class="space-y-3">
<!-- Color strip -->
<div class="h-1 w-12 rounded-full" style="background: {deck.color}"></div>
<!-- Title -->
<h3 class="text-lg font-semibold text-foreground line-clamp-2">{deck.title}</h3>
<!-- Description -->
{#if deck.description}
<p class="text-sm text-muted-foreground line-clamp-2">
{deck.description}
</p>
{/if}
<!-- Footer -->
<div
class="flex items-center justify-between border-t border-border pt-2 text-sm text-muted-foreground"
>
<div class="flex items-center gap-2">
<span>{deck.cardCount || 0} Karten</span>
{#if deck.isPublic}
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
Offentlich
</span>
{/if}
</div>
<span>{formatDate(deck.updatedAt)}</span>
</div>
</div>
</button>

View file

@ -0,0 +1,28 @@
/**
* Cards module barrel exports.
*/
export { deckStore } from './stores/decks.svelte';
export { cardStore } from './stores/cards.svelte';
export {
useAllDecks,
useDeck,
useCardsByDeck,
toDeck,
toCard,
getDeckById,
getPublicDecks,
getCardCountForDeck,
getDueCards,
} from './queries';
export { cardDeckTable, cardTable, CARDS_GUEST_SEED } from './collections';
export type {
LocalDeck,
LocalCard,
Deck,
Card,
CreateDeckInput,
UpdateDeckInput,
CreateCardInput,
UpdateCardInput,
} from './types';

View file

@ -0,0 +1,90 @@
/**
* Reactive queries & pure helpers for Cards uses Dexie liveQuery on the unified DB.
*
* Uses table names: cardDecks, cards.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalDeck, LocalCard, Deck, Card } from './types';
// ─── Type Converters ───────────────────────────────────────
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.name,
description: local.description ?? undefined,
color: local.color,
isPublic: local.isPublic,
tags: [],
cardCount: local.cardCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toCard(local: LocalCard): Card {
return {
id: local.id,
deckId: local.deckId,
front: local.front,
back: local.back,
difficulty: local.difficulty,
nextReview: local.nextReview ?? undefined,
reviewCount: local.reviewCount,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All decks, auto-updates on any change. */
export function useAllDecks() {
return liveQuery(async () => {
const locals = await db.table<LocalDeck>('cardDecks').toArray();
return locals.filter((d) => !d.deletedAt).map(toDeck);
});
}
/** Single deck by ID. Auto-updates on any change. */
export function useDeck(deckId: string) {
return liveQuery(async () => {
const local = await db.table<LocalDeck>('cardDecks').get(deckId);
return local && !local.deletedAt ? toDeck(local) : null;
});
}
/** All cards for a specific deck, sorted by order. Auto-updates on any change. */
export function useCardsByDeck(deckId: string) {
return liveQuery(async () => {
const locals = await db
.table<LocalCard>('cards')
.where('deckId')
.equals(deckId)
.sortBy('order');
return locals.filter((c) => !c.deletedAt).map(toCard);
});
}
// ─── Pure Helper Functions ─────────────────────────────────
export function getDeckById(decks: Deck[], id: string): Deck | undefined {
return decks.find((d) => d.id === id);
}
export function getPublicDecks(decks: Deck[]): Deck[] {
return decks.filter((d) => d.isPublic);
}
export function getCardCountForDeck(cards: Card[], deckId: string): number {
return cards.filter((c) => c.deckId === deckId).length;
}
export function getDueCards(cards: Card[]): Card[] {
const now = new Date().toISOString();
return cards.filter((c) => c.nextReview && c.nextReview <= now);
}

View file

@ -0,0 +1,108 @@
/**
* Card Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { cardTable, cardDeckTable } from '../collections';
import { toCard } from '../queries';
import type { LocalCard, Card, CreateCardInput, UpdateCardInput } from '../types';
let error = $state<string | null>(null);
export const cardStore = {
get error() {
return error;
},
async createCard(input: CreateCardInput, currentCardCount: number = 0): Promise<Card | null> {
error = null;
try {
const newLocal: LocalCard = {
id: crypto.randomUUID(),
deckId: input.deckId,
front: input.front,
back: input.back,
difficulty: 1,
reviewCount: 0,
order: currentCardCount,
};
await cardTable.add(newLocal);
// Update deck card count
const deck = await cardDeckTable.get(input.deckId);
if (deck) {
await cardDeckTable.update(input.deckId, {
cardCount: (deck.cardCount || 0) + 1,
updatedAt: new Date().toISOString(),
});
}
return toCard(newLocal);
} catch (err: any) {
error = err.message || 'Failed to create card';
console.error('Create card error:', err);
return null;
}
},
async updateCard(id: string, updates: UpdateCardInput) {
error = null;
try {
const localUpdates: Partial<LocalCard> = {};
if (updates.front !== undefined) localUpdates.front = updates.front;
if (updates.back !== undefined) localUpdates.back = updates.back;
if (updates.difficulty !== undefined) localUpdates.difficulty = updates.difficulty;
if (updates.order !== undefined) localUpdates.order = updates.order;
await cardTable.update(id, {
...localUpdates,
updatedAt: new Date().toISOString(),
});
} catch (err: any) {
error = err.message || 'Failed to update card';
console.error('Update card error:', err);
}
},
async deleteCard(id: string, deckId?: string) {
error = null;
try {
const now = new Date().toISOString();
await cardTable.update(id, { deletedAt: now, updatedAt: now });
// Update deck card count
if (deckId) {
const deck = await cardDeckTable.get(deckId);
if (deck) {
await cardDeckTable.update(deckId, {
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
updatedAt: now,
});
}
}
} catch (err: any) {
error = err.message || 'Failed to delete card';
console.error('Delete card error:', err);
}
},
async reorderCards(cardIds: string[]) {
error = null;
try {
const now = new Date().toISOString();
for (let i = 0; i < cardIds.length; i++) {
await cardTable.update(cardIds[i], { order: i, updatedAt: now });
}
} catch (err: any) {
error = err.message || 'Failed to reorder cards';
console.error('Reorder cards error:', err);
}
},
clearError() {
error = null;
},
};

View file

@ -0,0 +1,81 @@
/**
* Deck Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { cardDeckTable, cardTable } from '../collections';
import { toDeck } from '../queries';
import type { LocalDeck } from '../types';
import type { Deck, CreateDeckInput, UpdateDeckInput } from '../types';
let error = $state<string | null>(null);
export const deckStore = {
get error() {
return error;
},
async createDeck(input: CreateDeckInput): Promise<Deck | null> {
error = null;
try {
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
name: input.title,
description: input.description ?? null,
color: '#6366f1',
cardCount: 0,
isPublic: input.isPublic ?? false,
};
await cardDeckTable.add(newLocal);
return toDeck(newLocal);
} catch (err: any) {
error = err.message || 'Failed to create deck';
console.error('Create deck error:', err);
return null;
}
},
async updateDeck(id: string, updates: UpdateDeckInput) {
error = null;
try {
const localUpdates: Partial<LocalDeck> = {};
if (updates.title !== undefined) localUpdates.name = updates.title;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.isPublic !== undefined) localUpdates.isPublic = updates.isPublic;
await cardDeckTable.update(id, {
...localUpdates,
updatedAt: new Date().toISOString(),
});
} catch (err: any) {
error = err.message || 'Failed to update deck';
console.error('Update deck error:', err);
}
},
async deleteDeck(id: string) {
error = null;
try {
const now = new Date().toISOString();
// Soft-delete all cards belonging to this deck
const cards = await cardTable.where('deckId').equals(id).toArray();
for (const card of cards) {
await cardTable.update(card.id, { deletedAt: now, updatedAt: now });
}
// Soft-delete the deck
await cardDeckTable.update(id, { deletedAt: now, updatedAt: now });
} catch (err: any) {
error = err.message || 'Failed to delete deck';
console.error('Delete deck error:', err);
}
},
clearError() {
error = null;
},
};

View file

@ -0,0 +1,77 @@
/**
* Cards module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalDeck extends BaseRecord {
name: string;
description?: string | null;
color: string;
cardCount: number;
lastStudied?: string | null;
isPublic: boolean;
}
export interface LocalCard extends BaseRecord {
deckId: string;
front: string;
back: string;
difficulty: number; // 1-5
nextReview?: string | null;
reviewCount: number;
order: number;
}
// ─── View Types (inline to avoid @cards/shared dependency) ──
export interface Deck {
id: string;
userId: string;
title: string;
description?: string;
color: string;
isPublic: boolean;
tags: string[];
cardCount: number;
createdAt: string;
updatedAt: string;
}
export interface Card {
id: string;
deckId: string;
front: string;
back: string;
difficulty: number;
nextReview?: string;
reviewCount: number;
order: number;
createdAt: string;
updatedAt: string;
}
export interface CreateDeckInput {
title: string;
description?: string;
isPublic?: boolean;
}
export interface UpdateDeckInput {
title?: string;
description?: string;
isPublic?: boolean;
}
export interface CreateCardInput {
deckId: string;
front: string;
back: string;
}
export interface UpdateCardInput {
front?: string;
back?: string;
difficulty?: number;
order?: number;
}

View file

@ -0,0 +1,75 @@
/**
* Guides module barrel exports.
*
* Interactive guides and tutorials for the ManaCore ecosystem.
* No local-first collections needed yet (static content).
*/
export interface Guide {
id: string;
title: string;
description: string;
category: GuideCategory;
difficulty: 'beginner' | 'intermediate' | 'advanced';
estimatedMinutes: number;
}
export type GuideCategory = 'getting-started' | 'productivity' | 'advanced' | 'integrations';
export const GUIDE_CATEGORIES: Record<GuideCategory, { label: string; color: string }> = {
'getting-started': { label: 'Erste Schritte', color: 'bg-emerald-500' },
productivity: { label: 'Produktivität', color: 'bg-blue-500' },
advanced: { label: 'Fortgeschritten', color: 'bg-violet-500' },
integrations: { label: 'Integrationen', color: 'bg-amber-500' },
};
export const GUIDES: Guide[] = [
{
id: 'welcome',
title: 'Willkommen bei ManaCore',
description: 'Ein Überblick über das ManaCore-Ökosystem und seine Apps.',
category: 'getting-started',
difficulty: 'beginner',
estimatedMinutes: 5,
},
{
id: 'local-first',
title: 'Offline-First verstehen',
description: 'Wie ManaCore lokal arbeitet und im Hintergrund synchronisiert.',
category: 'getting-started',
difficulty: 'beginner',
estimatedMinutes: 8,
},
{
id: 'keyboard-shortcuts',
title: 'Tastaturkürzel',
description: 'Navigiere schneller mit Tastaturkürzeln durch alle Apps.',
category: 'productivity',
difficulty: 'beginner',
estimatedMinutes: 5,
},
{
id: 'todo-workflows',
title: 'Todo-Workflows',
description: 'Projekte, Labels und Fokus-Modus effektiv nutzen.',
category: 'productivity',
difficulty: 'intermediate',
estimatedMinutes: 10,
},
{
id: 'ai-features',
title: 'KI-Funktionen nutzen',
description: 'Chat, Playground und KI-gestützte Features in ManaCore.',
category: 'advanced',
difficulty: 'intermediate',
estimatedMinutes: 12,
},
{
id: 'sync-setup',
title: 'Sync einrichten',
description: 'Geräteübergreifende Synchronisation konfigurieren.',
category: 'integrations',
difficulty: 'intermediate',
estimatedMinutes: 8,
},
];

View file

@ -0,0 +1,23 @@
/**
* Playground module barrel exports.
*
* Stateless LLM playground for testing prompts against different models.
* No local-first collections needed (no persistent data model).
*/
export const PLAYGROUND_MODELS = [
{ id: 'claude-sonnet', label: 'Claude Sonnet', provider: 'Anthropic' },
{ id: 'claude-haiku', label: 'Claude Haiku', provider: 'Anthropic' },
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'OpenAI' },
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'OpenAI' },
{ id: 'gemini-pro', label: 'Gemini Pro', provider: 'Google' },
{ id: 'gemini-flash', label: 'Gemini Flash', provider: 'Google' },
] as const;
export type PlaygroundModel = (typeof PLAYGROUND_MODELS)[number]['id'];
export interface PlaygroundMessage {
role: 'user' | 'assistant';
content: string;
timestamp: number;
}

View file

@ -0,0 +1,69 @@
/**
* Storage module collection accessors and guest seed data.
*
* Uses table names from the unified DB: files, storageFolders, storageTags, fileTags.
*/
import { db } from '$lib/data/database';
import type { LocalFile, LocalFolder, LocalTag, LocalFileTag } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const fileTable = db.table<LocalFile>('files');
export const storageFolderTable = db.table<LocalFolder>('storageFolders');
export const storageTagTable = db.table<LocalTag>('storageTags');
export const fileTagTable = db.table<LocalFileTag>('fileTags');
// ─── Guest Seed ────────────────────────────────────────────
export const STORAGE_GUEST_SEED = {
storageFolders: [
{
id: 'folder-documents',
name: 'Dokumente',
description: 'Wichtige Dokumente',
color: '#3b82f6',
path: '/folder-documents',
depth: 0,
isFavorite: false,
isDeleted: false,
},
{
id: 'folder-photos',
name: 'Fotos',
description: 'Fotosammlung',
color: '#22c55e',
path: '/folder-photos',
depth: 0,
isFavorite: true,
isDeleted: false,
},
{
id: 'folder-music',
name: 'Musik',
description: 'Audio-Dateien',
color: '#a855f7',
path: '/folder-music',
depth: 0,
isFavorite: false,
isDeleted: false,
},
],
storageTags: [
{
id: 'tag-important',
name: 'Wichtig',
color: '#ef4444',
},
{
id: 'tag-work',
name: 'Arbeit',
color: '#3b82f6',
},
{
id: 'tag-personal',
name: 'Privat',
color: '#22c55e',
},
],
};

View file

@ -0,0 +1,32 @@
/**
* Storage module barrel exports.
*/
export { filesStore } from './stores/files.svelte';
export { storageTagStore } from './stores/tags.svelte';
export {
useAllFiles,
useAllFolders,
useAllStorageTags,
toFile,
toFolder,
toTag,
getFilesInFolder,
getFoldersInFolder,
getFavoriteFiles,
getFavoriteFolders,
findFolderById,
getDeletedFiles,
getDeletedFolders,
searchItems,
formatFileSize,
} from './queries';
export type { StorageFile, StorageFolder, StorageTag } from './queries';
export {
fileTable,
storageFolderTable,
storageTagTable,
fileTagTable,
STORAGE_GUEST_SEED,
} from './collections';
export type { LocalFile, LocalFolder, LocalTag, LocalFileTag } from './types';

View file

@ -0,0 +1,199 @@
/**
* Reactive queries & pure helpers for Storage uses Dexie liveQuery on the unified DB.
*
* Uses table names: files, storageFolders, storageTags, fileTags.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalFile, LocalFolder, LocalTag, LocalFileTag } from './types';
// ─── Shared Types (inline to avoid @storage/shared dependency) ───
export interface StorageFile {
id: string;
userId: string;
name: string;
originalName: string;
mimeType: string;
size: number;
storagePath: string;
storageKey: string;
parentFolderId: string | null;
currentVersion: number;
isFavorite: boolean;
isDeleted: boolean;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface StorageFolder {
id: string;
userId: string;
name: string;
description: string | null;
color: string | null;
parentFolderId: string | null;
path: string;
depth: number;
isFavorite: boolean;
isDeleted: boolean;
deletedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface StorageTag {
id: string;
userId: string;
name: string;
color: string | null;
createdAt: string;
}
// ─── Type Converters ───────────────────────────────────────
export function toFile(local: LocalFile): StorageFile {
return {
id: local.id,
userId: 'local',
name: local.name,
originalName: local.originalName,
mimeType: local.mimeType,
size: local.size,
storagePath: local.storagePath,
storageKey: local.storageKey,
parentFolderId: local.parentFolderId ?? null,
currentVersion: local.currentVersion,
isFavorite: local.isFavorite,
isDeleted: local.isDeleted,
deletedAt: null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toFolder(local: LocalFolder): StorageFolder {
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? null,
color: local.color ?? null,
parentFolderId: local.parentFolderId ?? null,
path: local.path,
depth: local.depth,
isFavorite: local.isFavorite,
isDeleted: local.isDeleted,
deletedAt: null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTag(local: LocalTag): StorageTag {
return {
id: local.id,
userId: 'local',
name: local.name,
color: local.color ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All non-deleted files, sorted by name. Auto-updates on any change. */
export function useAllFiles() {
return liveQuery(async () => {
const locals = await db.table<LocalFile>('files').toArray();
return locals
.filter((f) => !f.isDeleted && !f.deletedAt)
.map(toFile)
.sort((a, b) => a.name.localeCompare(b.name));
});
}
/** All non-deleted folders, sorted by name. Auto-updates on any change. */
export function useAllFolders() {
return liveQuery(async () => {
const locals = await db.table<LocalFolder>('storageFolders').toArray();
return locals
.filter((f) => !f.isDeleted && !f.deletedAt)
.map(toFolder)
.sort((a, b) => a.name.localeCompare(b.name));
});
}
/** All tags, sorted by name. Auto-updates on any change. */
export function useAllStorageTags() {
return liveQuery(async () => {
const locals = await db.table<LocalTag>('storageTags').toArray();
return locals
.filter((t) => !t.deletedAt)
.map(toTag)
.sort((a, b) => a.name.localeCompare(b.name));
});
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Get files in a specific folder (null = root). */
export function getFilesInFolder(files: StorageFile[], folderId: string | null): StorageFile[] {
return files.filter((f) => (f.parentFolderId ?? null) === folderId);
}
/** Get subfolders of a specific folder (null = root). */
export function getFoldersInFolder(
folders: StorageFolder[],
parentFolderId: string | null
): StorageFolder[] {
return folders.filter((f) => (f.parentFolderId ?? null) === parentFolderId);
}
/** Get favorite files. */
export function getFavoriteFiles(files: StorageFile[]): StorageFile[] {
return files.filter((f) => f.isFavorite);
}
/** Get favorite folders. */
export function getFavoriteFolders(folders: StorageFolder[]): StorageFolder[] {
return folders.filter((f) => f.isFavorite);
}
/** Find a folder by ID. */
export function findFolderById(folders: StorageFolder[], id: string): StorageFolder | undefined {
return folders.find((f) => f.id === id);
}
/** Get deleted files. */
export function getDeletedFiles(files: StorageFile[]): StorageFile[] {
return files.filter((f) => f.isDeleted);
}
/** Get deleted folders. */
export function getDeletedFolders(folders: StorageFolder[]): StorageFolder[] {
return folders.filter((f) => f.isDeleted);
}
/** Search files and folders by name query. */
export function searchItems(
files: StorageFile[],
folders: StorageFolder[],
query: string
): { files: StorageFile[]; folders: StorageFolder[] } {
const q = query.toLowerCase();
return {
files: files.filter((f) => f.name.toLowerCase().includes(q)),
folders: folders.filter((f) => f.name.toLowerCase().includes(q)),
};
}
/** Format file size for display. */
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}

View file

@ -0,0 +1,224 @@
/**
* Files Store Mutation-Only (Local-First reads via queries.ts)
*
* Reads are handled by liveQuery hooks in queries.ts.
* This store handles writes, selection state, and view mode.
* Server-side operations (upload, download, share) are not available in the unified app.
*/
import { fileTable, storageFolderTable } from '../collections';
import { toFile, toFolder } from '../queries';
import type { StorageFile, StorageFolder } from '../queries';
import type { LocalFile, LocalFolder } from '../types';
let viewMode = $state<'grid' | 'list'>('grid');
let selectedFileIds = $state<Set<string>>(new Set());
let selectedFolderIds = $state<Set<string>>(new Set());
let currentFolderId = $state<string | null>(null);
export const filesStore = {
get viewMode() {
return viewMode;
},
get selectedFileIds() {
return selectedFileIds;
},
get selectedFolderIds() {
return selectedFolderIds;
},
get selectionCount() {
return selectedFileIds.size + selectedFolderIds.size;
},
get currentFolderId() {
return currentFolderId;
},
toggleFileSelection(id: string) {
const next = new Set(selectedFileIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedFileIds = next;
},
toggleFolderSelection(id: string) {
const next = new Set(selectedFolderIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedFolderIds = next;
},
selectAllFromLists(files: StorageFile[], folders: StorageFolder[]) {
selectedFileIds = new Set(files.map((f) => f.id));
selectedFolderIds = new Set(folders.map((f) => f.id));
},
clearSelection() {
selectedFileIds = new Set();
selectedFolderIds = new Set();
},
setViewMode(mode: 'grid' | 'list') {
viewMode = mode;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('storage-view-mode', mode);
}
},
initViewMode() {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('storage-view-mode');
if (saved === 'grid' || saved === 'list') {
viewMode = saved;
}
}
},
setCurrentFolder(folderId: string | null) {
currentFolderId = folderId;
selectedFileIds = new Set();
selectedFolderIds = new Set();
},
// ─── Local-First write operations ────────────────────────
async createFolder(name: string, color?: string) {
const now = new Date().toISOString();
const parentId = currentFolderId;
// Build path
let path = `/${crypto.randomUUID().slice(0, 8)}`;
let depth = 0;
if (parentId) {
const parent = await storageFolderTable.get(parentId);
if (parent) {
path = `${parent.path}/${name.toLowerCase().replace(/\s+/g, '-')}`;
depth = parent.depth + 1;
}
}
const newFolder: LocalFolder = {
id: crypto.randomUUID(),
name,
description: null,
color: color ?? null,
parentFolderId: parentId,
path,
depth,
isFavorite: false,
isDeleted: false,
};
await storageFolderTable.add(newFolder);
return toFolder(newFolder);
},
async renameFile(id: string, name: string) {
await fileTable.update(id, {
name,
updatedAt: new Date().toISOString(),
});
},
async renameFolder(id: string, name: string) {
await storageFolderTable.update(id, {
name,
updatedAt: new Date().toISOString(),
});
},
async toggleFileFavorite(id: string) {
const file = await fileTable.get(id);
if (file) {
const newFav = !file.isFavorite;
await fileTable.update(id, {
isFavorite: newFav,
updatedAt: new Date().toISOString(),
});
return newFav;
}
return false;
},
async toggleFolderFavorite(id: string) {
const folder = await storageFolderTable.get(id);
if (folder) {
const newFav = !folder.isFavorite;
await storageFolderTable.update(id, {
isFavorite: newFav,
updatedAt: new Date().toISOString(),
});
return newFav;
}
return false;
},
async deleteFile(id: string) {
await fileTable.update(id, {
isDeleted: true,
updatedAt: new Date().toISOString(),
});
},
async deleteFolder(id: string) {
await storageFolderTable.update(id, {
isDeleted: true,
updatedAt: new Date().toISOString(),
});
},
async restoreFile(id: string) {
await fileTable.update(id, {
isDeleted: false,
updatedAt: new Date().toISOString(),
});
},
async restoreFolder(id: string) {
await storageFolderTable.update(id, {
isDeleted: false,
updatedAt: new Date().toISOString(),
});
},
async permanentDeleteFile(id: string) {
await fileTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async permanentDeleteFolder(id: string) {
await storageFolderTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async moveFile(id: string, targetFolderId: string) {
await fileTable.update(id, {
parentFolderId: targetFolderId,
updatedAt: new Date().toISOString(),
});
},
async moveFolder(id: string, targetFolderId: string) {
await storageFolderTable.update(id, {
parentFolderId: targetFolderId,
updatedAt: new Date().toISOString(),
});
},
async deleteSelected() {
const now = new Date().toISOString();
for (const id of selectedFileIds) {
await fileTable.update(id, { isDeleted: true, updatedAt: now });
}
for (const id of selectedFolderIds) {
await storageFolderTable.update(id, { isDeleted: true, updatedAt: now });
}
const count = selectedFileIds.size + selectedFolderIds.size;
selectedFileIds = new Set();
selectedFolderIds = new Set();
return count;
},
};

View file

@ -0,0 +1,56 @@
/**
* Storage Tag Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { storageTagTable, fileTagTable } from '../collections';
import type { LocalTag, LocalFileTag } from '../types';
export const storageTagStore = {
async create(name: string, color?: string) {
const newTag: LocalTag = {
id: crypto.randomUUID(),
name,
color: color ?? null,
};
await storageTagTable.add(newTag);
return newTag;
},
async update(id: string, data: Partial<Pick<LocalTag, 'name' | 'color'>>) {
await storageTagTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async delete(id: string) {
await storageTagTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async tagFile(fileId: string, tagId: string) {
const existing = await fileTagTable.where('[fileId+tagId]').equals([fileId, tagId]).first();
if (existing) return;
const newFileTag: LocalFileTag = {
id: crypto.randomUUID(),
fileId,
tagId,
};
await fileTagTable.add(newFileTag);
},
async untagFile(fileId: string, tagId: string) {
const existing = await fileTagTable.where('[fileId+tagId]').equals([fileId, tagId]).first();
if (existing) {
await fileTagTable.update(existing.id, {
deletedAt: new Date().toISOString(),
});
}
},
};

View file

@ -0,0 +1,41 @@
/**
* Storage module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalFile extends BaseRecord {
name: string;
originalName: string;
mimeType: string;
size: number;
storagePath: string;
storageKey: string;
parentFolderId?: string | null;
currentVersion: number;
isFavorite: boolean;
isDeleted: boolean;
checksum?: string | null;
thumbnailPath?: string | null;
}
export interface LocalFolder extends BaseRecord {
name: string;
description?: string | null;
color?: string | null;
parentFolderId?: string | null;
path: string;
depth: number;
isFavorite: boolean;
isDeleted: boolean;
}
export interface LocalTag extends BaseRecord {
name: string;
color?: string | null;
}
export interface LocalFileTag extends BaseRecord {
fileId: string;
tagId: string;
}

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllDecks } from '$lib/modules/cards/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allDecks = useAllDecks();
// Provide data to child components via Svelte context
setContext('cardDecks', allDecks);
</script>
{@render children()}

View file

@ -0,0 +1,75 @@
<script lang="ts">
import { Cards, Books, ChartBar, MagnifyingGlass } from '@manacore/shared-icons';
const quickLinks = [
{
href: '/cards/decks',
icon: Books,
label: 'Meine Decks',
description: 'Alle Kartendecks',
color: 'bg-indigo-500',
},
{
href: '/cards/explore',
icon: MagnifyingGlass,
label: 'Entdecken',
description: 'Offentliche Decks',
color: 'bg-green-500',
},
{
href: '/cards/progress',
icon: ChartBar,
label: 'Fortschritt',
description: 'Lernstatistiken',
color: 'bg-amber-500',
},
];
</script>
<svelte:head>
<title>Cards - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Cards</h1>
<p class="text-muted-foreground mt-1 text-sm">Karteikarten & Spaced Repetition</p>
</header>
<!-- Cards Icon -->
<div class="mb-8 rounded-xl border border-border bg-card p-6">
<div class="flex items-center gap-4">
<div class="rounded-full bg-indigo-500/10 p-3">
<Cards size={32} class="text-indigo-500" />
</div>
<div>
<div class="text-xl font-bold text-foreground">Lerne effizienter</div>
<div class="text-muted-foreground text-sm">
Karteikarten erstellen, organisieren und mit Spaced Repetition lernen
</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
{#each quickLinks as link}
<a
href={link.href}
class="rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-lg group"
>
<div class="flex flex-col items-center gap-3 text-center">
<div
class="{link.color} rounded-full p-3 text-white transition-transform group-hover:scale-110"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/modules/cards/stores/decks.svelte';
import DeckCard from '$lib/modules/cards/components/DeckCard.svelte';
import CreateDeckModal from '$lib/modules/cards/components/CreateDeckModal.svelte';
import type { Deck } from '$lib/modules/cards/types';
// Get live query data from layout context
const allDecks: { readonly value: Deck[] } = getContext('cardDecks');
let showCreateModal = $state(false);
function handleDeckClick(deckId: string) {
goto(`/cards/decks/${deckId}`);
}
</script>
<svelte:head>
<title>Meine Decks - Cards - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Meine Decks</h1>
<p class="text-muted-foreground mt-1 text-sm">Organisiere deine Lernmaterialien in Decks</p>
</div>
<button
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showCreateModal = true)}
>
<span>+</span>
Neues Deck
</button>
</div>
<!-- Error State -->
{#if deckStore.error}
<div class="rounded-lg bg-destructive/10 p-4 text-destructive">
<p class="font-medium">Fehler beim Laden</p>
<p class="mt-1 text-sm">{deckStore.error}</p>
</div>
{:else if (allDecks?.value ?? []).length === 0}
<!-- Empty State -->
<div class="py-16 text-center">
<div class="mb-4 text-6xl">📚</div>
<h3 class="mb-2 text-xl font-semibold text-foreground">Noch keine Decks</h3>
<p class="mb-6 text-muted-foreground">
Erstelle dein erstes Deck, um mit dem Lernen zu beginnen.
</p>
<button
class="rounded-lg bg-primary px-6 py-2 text-sm text-white"
onclick={() => (showCreateModal = true)}
>
Erstes Deck erstellen
</button>
</div>
{:else}
<!-- Decks Grid -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each allDecks.value as deck (deck.id)}
<DeckCard {deck} onclick={() => handleDeckClick(deck.id)} />
{/each}
</div>
{/if}
</div>
<!-- Create Deck Modal -->
<CreateDeckModal bind:open={showCreateModal} />

View file

@ -0,0 +1,278 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/modules/cards/stores/decks.svelte';
import { cardStore } from '$lib/modules/cards/stores/cards.svelte';
import { useDeck, useCardsByDeck } from '$lib/modules/cards/queries';
import type { Deck, Card } from '$lib/modules/cards/types';
import { ArrowLeft, Trash, Plus } from '@manacore/shared-icons';
let deckId = $derived($page.params.id);
let showDeleteConfirm = $state(false);
let deleting = $state(false);
// New card form
let showNewCardForm = $state(false);
let newCardFront = $state('');
let newCardBack = $state('');
// Live queries for this deck's data
const currentDeck = useDeck(deckId);
const deckCards = useCardsByDeck(deckId);
// Reactively read values
let deck = $derived(($currentDeck as Deck | null | undefined) ?? null);
let cards = $derived(($deckCards as Card[] | undefined) ?? []);
async function handleDelete() {
if (!deckId) return;
deleting = true;
await deckStore.deleteDeck(deckId);
deleting = false;
goto('/cards/decks');
}
async function handleCreateCard() {
if (!newCardFront.trim() || !newCardBack.trim()) return;
await cardStore.createCard(
{
deckId,
front: newCardFront.trim(),
back: newCardBack.trim(),
},
cards.length
);
newCardFront = '';
newCardBack = '';
showNewCardForm = false;
}
async function handleDeleteCard(cardId: string) {
if (!confirm('Karte wirklich loschen?')) return;
await cardStore.deleteCard(cardId, deckId);
}
</script>
<svelte:head>
<title>{deck?.title || 'Deck'} - Cards - ManaCore</title>
</svelte:head>
{#if deck}
<div class="mx-auto max-w-5xl space-y-6">
<!-- Back Button -->
<button
onclick={() => goto('/cards/decks')}
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft size={16} />
Zuruck zu Decks
</button>
<!-- Deck Header -->
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="mb-2 flex items-center gap-3">
<div class="h-3 w-3 rounded-full" style="background: {deck.color}"></div>
<h1 class="text-2xl font-bold text-foreground">{deck.title}</h1>
</div>
{#if deck.description}
<p class="text-muted-foreground">{deck.description}</p>
{/if}
</div>
<div class="flex items-center gap-2">
{#if deck.isPublic}
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs text-primary">
Offentlich
</span>
{/if}
<button
class="rounded-lg border border-destructive/30 p-2 text-destructive transition-colors hover:bg-destructive/10"
onclick={() => (showDeleteConfirm = true)}
aria-label="Deck loschen"
>
<Trash size={16} />
</button>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-foreground">{cards.length}</div>
<div class="text-sm text-muted-foreground">Karten gesamt</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-green-500">
{cards.filter((c) => c.difficulty <= 2).length}
</div>
<div class="text-sm text-muted-foreground">Einfach</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-orange-500">
{cards.filter((c) => c.difficulty >= 4).length}
</div>
<div class="text-sm text-muted-foreground">Schwierig</div>
</div>
</div>
<!-- Add Card Button -->
<div class="flex items-center gap-3">
<button
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showNewCardForm = true)}
>
<Plus size={16} />
Neue Karte
</button>
</div>
<!-- New Card Form -->
{#if showNewCardForm}
<div class="rounded-xl border border-primary bg-card p-4">
<h3 class="mb-3 font-medium text-foreground">Neue Karte</h3>
<div class="space-y-3">
<div>
<label for="card-front" class="mb-1 block text-sm text-muted-foreground">
Vorderseite
</label>
<input
id="card-front"
type="text"
bind:value={newCardFront}
placeholder="Frage oder Begriff..."
class="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
autofocus
/>
</div>
<div>
<label for="card-back" class="mb-1 block text-sm text-muted-foreground">
Ruckseite
</label>
<textarea
id="card-back"
bind:value={newCardBack}
placeholder="Antwort oder Erklarung..."
class="min-h-[80px] w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none focus:border-primary"
></textarea>
</div>
<div class="flex justify-end gap-2">
<button
class="rounded-lg px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
onclick={() => {
showNewCardForm = false;
newCardFront = '';
newCardBack = '';
}}
>
Abbrechen
</button>
<button
class="rounded-lg bg-primary px-4 py-1.5 text-sm text-white disabled:opacity-50"
onclick={handleCreateCard}
disabled={!newCardFront.trim() || !newCardBack.trim()}
>
Karte erstellen
</button>
</div>
</div>
</div>
{/if}
<!-- Cards List -->
<div class="rounded-xl border border-border bg-card">
<h2 class="border-b border-border p-4 text-lg font-semibold text-foreground">
Karten ({cards.length})
</h2>
{#if cards.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-4xl">📝</div>
<p class="text-muted-foreground">Noch keine Karten. Erstelle deine erste Karte!</p>
<button
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showNewCardForm = true)}
>
Karte hinzufugen
</button>
</div>
{:else}
<div class="divide-y divide-border">
{#each cards as card, i (card.id)}
<div class="flex items-start gap-4 p-4">
<span class="mt-1 text-xs text-muted-foreground">{i + 1}.</span>
<div class="min-w-0 flex-1">
<div class="font-medium text-foreground">{card.front}</div>
<div class="mt-1 text-sm text-muted-foreground">{card.back}</div>
</div>
<div class="flex items-center gap-2">
<span
class="rounded-full px-2 py-0.5 text-xs {card.difficulty < 3
? 'bg-green-500/10 text-green-600'
: card.difficulty === 3
? 'bg-amber-500/10 text-amber-600'
: 'bg-red-500/10 text-red-600'}"
>
{card.difficulty}/5
</span>
<button
class="rounded p-1 text-muted-foreground hover:text-destructive"
onclick={() => handleDeleteCard(card.id)}
aria-label="Karte loschen"
>
<Trash size={14} />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Delete Confirmation Modal -->
{#if showDeleteConfirm}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onclick={() => (showDeleteConfirm = false)}
>
<div
class="mx-4 w-full max-w-md rounded-xl border border-border bg-card p-6 shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<h3 class="mb-2 text-xl font-semibold text-foreground">Deck loschen?</h3>
<p class="mb-6 text-muted-foreground">
Mochtest du "{deck.title}" wirklich loschen? Diese Aktion kann nicht ruckgangig gemacht
werden und loscht auch alle Karten in diesem Deck.
</p>
<div class="flex justify-end gap-3">
<button
class="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
onclick={() => (showDeleteConfirm = false)}
>
Abbrechen
</button>
<button
class="rounded-lg bg-destructive px-4 py-2 text-sm text-white disabled:opacity-50"
disabled={deleting}
onclick={handleDelete}
>
{deleting ? 'Losche...' : 'Deck loschen'}
</button>
</div>
</div>
</div>
{/if}
</div>
{:else}
<div class="py-16 text-center">
<p class="text-muted-foreground">Deck nicht gefunden</p>
<button
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => goto('/cards/decks')}
>
Zuruck zu Decks
</button>
</div>
{/if}

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { MagnifyingGlass } from '@manacore/shared-icons';
</script>
<svelte:head>
<title>Entdecken - Cards - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Entdecken</h1>
<p class="text-muted-foreground mt-1 text-sm">Offentliche Decks aus der Community entdecken</p>
</div>
<div class="rounded-xl border border-border bg-card">
<div class="py-16 text-center">
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-primary/10"
>
<MagnifyingGlass size={32} class="text-primary" />
</div>
<h3 class="mb-2 text-xl font-semibold text-foreground">Entdecken-Feature</h3>
<p class="text-muted-foreground">Offentliche Decks durchsuchen und entdecken — kommt bald!</p>
</div>
</div>
</div>

View file

@ -0,0 +1,79 @@
<script lang="ts">
import { getContext } from 'svelte';
import { ChartBar } from '@manacore/shared-icons';
import type { Deck } from '$lib/modules/cards/types';
// Get live query data from layout context
const allDecks: { readonly value: Deck[] } = getContext('cardDecks');
let decks = $derived(allDecks?.value ?? []);
let totalCards = $derived(decks.reduce((sum, d) => sum + (d.cardCount || 0), 0));
</script>
<svelte:head>
<title>Fortschritt - Cards - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-foreground">Fortschritt</h1>
<p class="text-muted-foreground mt-1 text-sm">Verfolge deinen Lernfortschritt</p>
</div>
<!-- Stats Overview -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-foreground">{decks.length}</div>
<div class="text-sm text-muted-foreground">Decks</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-foreground">{totalCards}</div>
<div class="text-sm text-muted-foreground">Karten gesamt</div>
</div>
<div class="rounded-xl border border-border bg-card p-4 text-center">
<div class="text-3xl font-bold text-orange-500">0</div>
<div class="text-sm text-muted-foreground">Fallig zur Wiederholung</div>
</div>
</div>
<!-- Decks Breakdown -->
<div class="rounded-xl border border-border bg-card">
<h2 class="border-b border-border p-4 text-lg font-semibold text-foreground">
<span class="flex items-center gap-2">
<ChartBar size={20} />
Decks Ubersicht
</span>
</h2>
{#if decks.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-4xl">🎯</div>
<p class="text-muted-foreground">Noch keine Lernsitzungen.</p>
<p class="mt-2 text-sm text-muted-foreground">Erstelle ein Deck und beginne zu lernen!</p>
</div>
{:else}
<div class="divide-y divide-border">
{#each decks as deck (deck.id)}
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-3">
<div class="h-3 w-3 rounded-full" style="background: {deck.color}"></div>
<div>
<div class="font-medium text-foreground">{deck.title}</div>
<div class="text-sm text-muted-foreground">
{deck.cardCount || 0} Karten
</div>
</div>
</div>
<div class="text-right">
<div class="text-sm text-muted-foreground">
{new Date(deck.updatedAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
})}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,114 @@
<script lang="ts">
import { GUIDES, GUIDE_CATEGORIES, type GuideCategory } from '$lib/modules/guides';
import { BookOpen, Clock, ArrowRight } from '@manacore/shared-icons';
let selectedCategory: GuideCategory | 'all' = $state('all');
const filteredGuides = $derived(
selectedCategory === 'all' ? GUIDES : GUIDES.filter((g) => g.category === selectedCategory)
);
const categories = Object.entries(GUIDE_CATEGORIES) as [
GuideCategory,
{ label: string; color: string },
][];
function difficultyLabel(d: string) {
switch (d) {
case 'beginner':
return 'Einsteiger';
case 'intermediate':
return 'Mittel';
case 'advanced':
return 'Fortgeschritten';
default:
return d;
}
}
</script>
<svelte:head>
<title>Guides - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Guides</h1>
<p class="text-muted-foreground mt-1 text-sm">
Tutorials & Anleitungen für das ManaCore-Ökosystem
</p>
</header>
<!-- Category Filter -->
<div class="mb-6 flex flex-wrap gap-2">
<button
onclick={() => (selectedCategory = 'all')}
class="rounded-full px-4 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
'all'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
>
Alle
</button>
{#each categories as [key, cat]}
<button
onclick={() => (selectedCategory = key)}
class="rounded-full px-4 py-1.5 text-sm font-medium transition-colors {selectedCategory ===
key
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
>
{cat.label}
</button>
{/each}
</div>
<!-- Guide Cards -->
{#if filteredGuides.length === 0}
<div class="flex flex-col items-center py-16 text-center">
<BookOpen size={40} class="mb-4 text-muted-foreground" />
<p class="text-muted-foreground">Keine Guides in dieser Kategorie.</p>
</div>
{:else}
<div class="space-y-3">
{#each filteredGuides as guide}
{@const cat = GUIDE_CATEGORIES[guide.category]}
<div
class="group flex items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
>
<div
class="{cat.color} flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-white"
>
<BookOpen size={20} />
</div>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-foreground">{guide.title}</h3>
<p class="text-sm text-muted-foreground">{guide.description}</p>
<div class="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span class="flex items-center gap-1">
<Clock size={12} />
{guide.estimatedMinutes} Min.
</span>
<span class="rounded-full bg-muted px-2 py-0.5">
{difficultyLabel(guide.difficulty)}
</span>
</div>
</div>
<ArrowRight
size={20}
class="shrink-0 text-muted-foreground transition-transform group-hover:translate-x-1 group-hover:text-primary"
/>
</div>
{/each}
</div>
{/if}
<!-- Coming Soon Note -->
<div class="mt-8 rounded-xl border border-dashed border-border p-6 text-center">
<p class="text-sm text-muted-foreground">
Weitere Guides werden laufend hinzugefügt. Die Inhalte sind aktuell noch Platzhalter.
</p>
</div>
</div>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import {
PLAYGROUND_MODELS,
type PlaygroundModel,
type PlaygroundMessage,
} from '$lib/modules/playground';
import { PaperPlaneRight, Trash, Robot } from '@manacore/shared-icons';
let selectedModel: PlaygroundModel = $state('claude-sonnet');
let systemPrompt = $state('');
let userInput = $state('');
let messages: PlaygroundMessage[] = $state([]);
let isLoading = $state(false);
let temperature = $state(0.7);
function handleSend() {
if (!userInput.trim() || isLoading) return;
const userMessage: PlaygroundMessage = {
role: 'user',
content: userInput.trim(),
timestamp: Date.now(),
};
messages = [...messages, userMessage];
userInput = '';
// Simulate response (real API integration comes later)
isLoading = true;
setTimeout(() => {
const assistantMessage: PlaygroundMessage = {
role: 'assistant',
content: `[${selectedModel}] Playground ist noch nicht mit dem Backend verbunden. Konfiguriere MANA_LLM_URL um Antworten zu erhalten.`,
timestamp: Date.now(),
};
messages = [...messages, assistantMessage];
isLoading = false;
}, 800);
}
function handleClear() {
messages = [];
systemPrompt = '';
userInput = '';
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}
const currentModelLabel = $derived(
PLAYGROUND_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel
);
</script>
<svelte:head>
<title>Playground - ManaCore</title>
</svelte:head>
<div class="mx-auto flex h-full max-w-4xl flex-col">
<!-- Header -->
<header class="mb-4 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Playground</h1>
<p class="text-muted-foreground mt-1 text-sm">LLM-Modelle testen & vergleichen</p>
</div>
<button
onclick={handleClear}
class="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted"
>
<Trash size={16} />
Leeren
</button>
</header>
<!-- Config Bar -->
<div class="mb-4 flex flex-wrap items-center gap-4 rounded-xl border border-border bg-card p-4">
<div class="flex flex-col gap-1">
<label for="model-select" class="text-xs font-medium text-muted-foreground">Modell</label>
<select
id="model-select"
bind:value={selectedModel}
class="rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground"
>
{#each PLAYGROUND_MODELS as model}
<option value={model.id}>{model.label} ({model.provider})</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label for="temperature" class="text-xs font-medium text-muted-foreground">
Temperatur: {temperature.toFixed(1)}
</label>
<input
id="temperature"
type="range"
min="0"
max="2"
step="0.1"
bind:value={temperature}
class="w-32 accent-primary"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<label for="system-prompt" class="text-xs font-medium text-muted-foreground">
System Prompt
</label>
<input
id="system-prompt"
type="text"
bind:value={systemPrompt}
placeholder="Optional: System-Anweisung..."
class="rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
<!-- Messages -->
<div class="flex-1 space-y-4 overflow-y-auto pb-4">
{#if messages.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 rounded-full bg-primary/10 p-4">
<Robot size={40} class="text-primary" />
</div>
<h2 class="text-lg font-semibold text-foreground">Bereit zum Testen</h2>
<p class="mt-1 max-w-md text-sm text-muted-foreground">
Wähle ein Modell, schreibe einen Prompt und teste verschiedene LLMs. Aktuell: <span
class="font-medium text-foreground">{currentModelLabel}</span
>
</p>
</div>
{:else}
{#each messages as message}
<div
class="rounded-xl border border-border p-4 {message.role === 'user'
? 'ml-8 bg-primary/5'
: 'mr-8 bg-card'}"
>
<div class="mb-1 text-xs font-medium text-muted-foreground">
{message.role === 'user' ? 'Du' : currentModelLabel}
</div>
<div class="whitespace-pre-wrap text-sm text-foreground">{message.content}</div>
</div>
{/each}
{#if isLoading}
<div class="mr-8 rounded-xl border border-border bg-card p-4">
<div class="mb-1 text-xs font-medium text-muted-foreground">{currentModelLabel}</div>
<div class="flex gap-1">
<span
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:0ms]"
></span>
<span
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:150ms]"
></span>
<span
class="inline-block h-2 w-2 animate-bounce rounded-full bg-muted-foreground [animation-delay:300ms]"
></span>
</div>
</div>
{/if}
{/if}
</div>
<!-- Input -->
<div class="sticky bottom-0 border-t border-border bg-background pt-4">
<div class="flex gap-3">
<textarea
bind:value={userInput}
onkeydown={handleKeydown}
placeholder="Prompt eingeben... (Enter zum Senden, Shift+Enter für neue Zeile)"
rows={2}
class="flex-1 resize-none rounded-xl border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
></textarea>
<button
onclick={handleSend}
disabled={!userInput.trim() || isLoading}
class="flex items-center gap-2 self-end rounded-xl bg-primary px-4 py-3 text-sm font-medium text-primary-foreground transition-opacity disabled:opacity-50"
>
<PaperPlaneRight size={18} />
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllFiles, useAllFolders, useAllStorageTags } from '$lib/modules/storage/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allFiles = useAllFiles();
const allFolders = useAllFolders();
const allStorageTags = useAllStorageTags();
// Provide data to child components via Svelte context
setContext('storageFiles', allFiles);
setContext('storageFolders', allFolders);
setContext('storageTags', allStorageTags);
</script>
{@render children()}

View file

@ -0,0 +1,87 @@
<script lang="ts">
import {
Folder,
Heart,
MagnifyingGlass,
Trash,
HardDrives,
FolderPlus,
} from '@manacore/shared-icons';
const quickLinks = [
{
href: '/storage/files',
icon: Folder,
label: 'Meine Dateien',
description: 'Dateien & Ordner',
color: 'bg-blue-500',
},
{
href: '/storage/favorites',
icon: Heart,
label: 'Favoriten',
description: 'Markierte Dateien',
color: 'bg-red-500',
},
{
href: '/storage/search',
icon: MagnifyingGlass,
label: 'Suche',
description: 'Dateien finden',
color: 'bg-green-500',
},
{
href: '/storage/trash',
icon: Trash,
label: 'Papierkorb',
description: 'Geloschte Dateien',
color: 'bg-amber-500',
},
];
</script>
<svelte:head>
<title>Storage - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-3xl">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Storage</h1>
<p class="text-muted-foreground mt-1 text-sm">Dein Cloud-Speicher</p>
</header>
<!-- Storage Icon -->
<div class="mb-8 rounded-xl border border-border bg-card p-6">
<div class="flex items-center gap-4">
<div class="rounded-full bg-primary/10 p-3">
<HardDrives size={32} class="text-primary" />
</div>
<div>
<div class="text-xl font-bold text-foreground">Cloud-Dateiverwaltung</div>
<div class="text-muted-foreground text-sm">Dateien hochladen, organisieren und teilen</div>
</div>
</div>
</div>
<!-- Quick Links Grid -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
{#each quickLinks as link}
<a
href={link.href}
class="rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-lg group"
>
<div class="flex flex-col items-center gap-3 text-center">
<div
class="{link.color} rounded-full p-3 text-white transition-transform group-hover:scale-110"
>
<link.icon size={24} />
</div>
<div>
<div class="font-medium text-foreground">{link.label}</div>
<div class="text-xs text-muted-foreground">{link.description}</div>
</div>
</div>
</a>
{/each}
</div>
</div>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext, onMount } from 'svelte';
import { Heart, GridFour, List } from '@manacore/shared-icons';
import { filesStore } from '$lib/modules/storage/stores/files.svelte';
import {
getFavoriteFiles,
getFavoriteFolders,
formatFileSize,
} from '$lib/modules/storage/queries';
import type { StorageFile, StorageFolder } from '$lib/modules/storage/queries';
// Get live query data from layout context
const allFiles: { readonly value: StorageFile[] } = getContext('storageFiles');
const allFolders: { readonly value: StorageFolder[] } = getContext('storageFolders');
let favoriteFiles = $derived(getFavoriteFiles(allFiles?.value ?? []));
let favoriteFolders = $derived(getFavoriteFolders(allFolders?.value ?? []));
onMount(() => {
filesStore.initViewMode();
});
function handleFolderClick(folder: StorageFolder) {
goto(`/storage/files/${folder.id}`);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Favoriten - Storage - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<Heart size={24} class="text-red-500" />
<h1 class="text-2xl font-bold text-foreground">Favoriten</h1>
</div>
<div class="flex rounded-lg border border-border bg-card p-0.5">
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'grid'}
class:text-white={filesStore.viewMode === 'grid'}
class:text-muted-foreground={filesStore.viewMode !== 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<GridFour size={18} />
</button>
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'list'}
class:text-white={filesStore.viewMode === 'list'}
class:text-muted-foreground={filesStore.viewMode !== 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
</div>
{#if favoriteFiles.length === 0 && favoriteFolders.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 text-5xl"></div>
<h3 class="mb-2 text-lg font-semibold text-foreground">Keine Favoriten</h3>
<p class="text-sm text-muted-foreground">
Markiere Dateien und Ordner als Favoriten, um sie hier schnell zu finden.
</p>
</div>
{:else if filesStore.viewMode === 'grid'}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each favoriteFolders as folder (folder.id)}
<button
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
onclick={() => handleFolderClick(folder)}
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg text-2xl"
style="background: {folder.color ?? '#3b82f6'}20"
>
📁
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{folder.name}
</span>
<span class="text-xs text-amber-500"></span>
</button>
{/each}
{#each favoriteFiles as file (file.id)}
<div
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted text-2xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{file.name}
</span>
<span class="text-xs text-muted-foreground">{formatFileSize(file.size)}</span>
</div>
{/each}
</div>
{:else}
<div class="flex flex-col gap-1">
{#each favoriteFolders as folder (folder.id)}
<button
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted text-left w-full"
onclick={() => handleFolderClick(folder)}
>
<span class="text-xl">📁</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{folder.name}</div>
</div>
<span class="text-xs text-amber-500"></span>
<span class="text-xs text-muted-foreground">{formatDate(folder.updatedAt)}</span>
</button>
{/each}
{#each favoriteFiles as file (file.id)}
<div
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted"
>
<span class="text-xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{file.name}</div>
<div class="text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
</div>
<span class="text-xs text-amber-500"></span>
<span class="text-xs text-muted-foreground">{formatDate(file.updatedAt)}</span>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,244 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext, onMount } from 'svelte';
import { GridFour, List, FolderPlus } from '@manacore/shared-icons';
import { filesStore } from '$lib/modules/storage/stores/files.svelte';
import {
getFilesInFolder,
getFoldersInFolder,
formatFileSize,
} from '$lib/modules/storage/queries';
import type { StorageFile, StorageFolder } from '$lib/modules/storage/queries';
// Get live query data from layout context
const allFiles: { readonly value: StorageFile[] } = getContext('storageFiles');
const allFolders: { readonly value: StorageFolder[] } = getContext('storageFolders');
// Root-level files and folders (no parent)
let files = $derived(getFilesInFolder(allFiles?.value ?? [], null));
let folders = $derived(getFoldersInFolder(allFolders?.value ?? [], null));
let showNewFolderInput = $state(false);
let newFolderName = $state('');
onMount(() => {
filesStore.initViewMode();
filesStore.setCurrentFolder(null);
});
function handleFolderClick(folder: StorageFolder) {
goto(`/storage/files/${folder.id}`);
}
async function handleCreateFolder() {
if (!newFolderName.trim()) return;
await filesStore.createFolder(newFolderName.trim());
newFolderName = '';
showNewFolderInput = false;
}
async function handleToggleFavorite(type: 'file' | 'folder', id: string) {
if (type === 'file') {
await filesStore.toggleFileFavorite(id);
} else {
await filesStore.toggleFolderFavorite(id);
}
}
async function handleDelete(type: 'file' | 'folder', id: string) {
if (type === 'file') {
await filesStore.deleteFile(id);
} else {
await filesStore.deleteFolder(id);
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Meine Dateien - Storage - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Meine Dateien</h1>
<p class="text-muted-foreground mt-1 text-sm">Alle Dateien und Ordner im Stammverzeichnis</p>
</div>
<div class="flex items-center gap-3">
<div class="flex rounded-lg border border-border bg-card p-0.5">
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'grid'}
class:text-white={filesStore.viewMode === 'grid'}
class:text-muted-foreground={filesStore.viewMode !== 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<GridFour size={18} />
</button>
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'list'}
class:text-white={filesStore.viewMode === 'list'}
class:text-muted-foreground={filesStore.viewMode !== 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
<button
class="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted"
onclick={() => (showNewFolderInput = true)}
>
<FolderPlus size={18} />
<span class="hidden sm:inline">Neuer Ordner</span>
</button>
</div>
</div>
<!-- New Folder Input -->
{#if showNewFolderInput}
<div class="mb-4 flex items-center gap-2 rounded-lg border border-primary bg-card p-3">
<FolderPlus size={20} class="text-primary" />
<input
type="text"
bind:value={newFolderName}
placeholder="Ordnername..."
class="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
onkeydown={(e) => e.key === 'Enter' && handleCreateFolder()}
autofocus
/>
<button
class="rounded-md bg-primary px-3 py-1 text-sm text-white"
onclick={handleCreateFolder}
>
Erstellen
</button>
<button
class="rounded-md px-3 py-1 text-sm text-muted-foreground hover:text-foreground"
onclick={() => {
showNewFolderInput = false;
newFolderName = '';
}}
>
Abbrechen
</button>
</div>
{/if}
{#if folders.length === 0 && files.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 text-5xl">📂</div>
<h3 class="mb-2 text-lg font-semibold text-foreground">Noch keine Dateien</h3>
<p class="mb-6 text-sm text-muted-foreground">
Erstelle einen Ordner, um deine Dateien zu organisieren.
</p>
<button
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showNewFolderInput = true)}
>
<FolderPlus size={18} />
Neuer Ordner
</button>
</div>
{:else if filesStore.viewMode === 'grid'}
<!-- Grid View -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each folders as folder (folder.id)}
<button
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
onclick={() => handleFolderClick(folder)}
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg text-2xl"
style="background: {folder.color ?? '#3b82f6'}20"
>
📁
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{folder.name}
</span>
{#if folder.isFavorite}
<span class="text-xs text-amber-500"></span>
{/if}
</button>
{/each}
{#each files as file (file.id)}
<div
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted text-2xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{file.name}
</span>
<span class="text-xs text-muted-foreground">{formatFileSize(file.size)}</span>
</div>
{/each}
</div>
{:else}
<!-- List View -->
<div class="flex flex-col gap-1">
{#each folders as folder (folder.id)}
<button
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted text-left w-full"
onclick={() => handleFolderClick(folder)}
>
<span class="text-xl">📁</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{folder.name}</div>
{#if folder.description}
<div class="text-xs text-muted-foreground truncate">{folder.description}</div>
{/if}
</div>
<div class="flex items-center gap-2">
{#if folder.isFavorite}
<span class="text-amber-500"></span>
{/if}
<span class="text-xs text-muted-foreground">{formatDate(folder.updatedAt)}</span>
</div>
</button>
{/each}
{#each files as file (file.id)}
<div
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted"
>
<span class="text-xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{file.name}</div>
<div class="text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
</div>
<div class="flex items-center gap-2">
{#if file.isFavorite}
<span class="text-amber-500"></span>
{/if}
<span class="text-xs text-muted-foreground">{formatDate(file.updatedAt)}</span>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,239 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getContext, onMount } from 'svelte';
import { GridFour, List, FolderPlus, ArrowLeft } from '@manacore/shared-icons';
import { filesStore } from '$lib/modules/storage/stores/files.svelte';
import {
getFilesInFolder,
getFoldersInFolder,
findFolderById,
formatFileSize,
} from '$lib/modules/storage/queries';
import type { StorageFile, StorageFolder } from '$lib/modules/storage/queries';
// Get live query data from layout context
const allFiles: { readonly value: StorageFile[] } = getContext('storageFiles');
const allFolders: { readonly value: StorageFolder[] } = getContext('storageFolders');
let folderId = $derived($page.params.folderId);
// Current folder and its contents
let currentFolder = $derived(findFolderById(allFolders?.value ?? [], folderId));
let files = $derived(getFilesInFolder(allFiles?.value ?? [], folderId));
let folders = $derived(getFoldersInFolder(allFolders?.value ?? [], folderId));
let showNewFolderInput = $state(false);
let newFolderName = $state('');
$effect(() => {
if (folderId) {
filesStore.setCurrentFolder(folderId);
}
});
onMount(() => {
filesStore.initViewMode();
});
function handleFolderClick(folder: StorageFolder) {
goto(`/storage/files/${folder.id}`);
}
function goBack() {
const parentId = currentFolder?.parentFolderId ?? null;
if (parentId) {
goto(`/storage/files/${parentId}`);
} else {
goto('/storage/files');
}
}
async function handleCreateFolder() {
if (!newFolderName.trim()) return;
await filesStore.createFolder(newFolderName.trim());
newFolderName = '';
showNewFolderInput = false;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>{currentFolder?.name || 'Ordner'} - Storage - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<button
class="rounded-lg border border-border bg-card p-2 text-muted-foreground transition-colors hover:text-foreground"
onclick={goBack}
aria-label="Zuruck"
>
<ArrowLeft size={20} />
</button>
<div>
<h1 class="text-2xl font-bold text-foreground">{currentFolder?.name || 'Ordner'}</h1>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex rounded-lg border border-border bg-card p-0.5">
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'grid'}
class:text-white={filesStore.viewMode === 'grid'}
class:text-muted-foreground={filesStore.viewMode !== 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<GridFour size={18} />
</button>
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'list'}
class:text-white={filesStore.viewMode === 'list'}
class:text-muted-foreground={filesStore.viewMode !== 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
<button
class="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-muted"
onclick={() => (showNewFolderInput = true)}
>
<FolderPlus size={18} />
<span class="hidden sm:inline">Neuer Ordner</span>
</button>
</div>
</div>
<!-- New Folder Input -->
{#if showNewFolderInput}
<div class="mb-4 flex items-center gap-2 rounded-lg border border-primary bg-card p-3">
<FolderPlus size={20} class="text-primary" />
<input
type="text"
bind:value={newFolderName}
placeholder="Ordnername..."
class="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
onkeydown={(e) => e.key === 'Enter' && handleCreateFolder()}
autofocus
/>
<button
class="rounded-md bg-primary px-3 py-1 text-sm text-white"
onclick={handleCreateFolder}
>
Erstellen
</button>
<button
class="rounded-md px-3 py-1 text-sm text-muted-foreground hover:text-foreground"
onclick={() => {
showNewFolderInput = false;
newFolderName = '';
}}
>
Abbrechen
</button>
</div>
{/if}
{#if folders.length === 0 && files.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 text-5xl">📂</div>
<h3 class="mb-2 text-lg font-semibold text-foreground">Leerer Ordner</h3>
<p class="mb-6 text-sm text-muted-foreground">
Dieser Ordner ist leer. Erstelle Unterordner, um Dateien zu organisieren.
</p>
<button
class="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm text-white"
onclick={() => (showNewFolderInput = true)}
>
<FolderPlus size={18} />
Neuer Ordner
</button>
</div>
{:else if filesStore.viewMode === 'grid'}
<!-- Grid View -->
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each folders as folder (folder.id)}
<button
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
onclick={() => handleFolderClick(folder)}
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg text-2xl"
style="background: {folder.color ?? '#3b82f6'}20"
>
📁
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{folder.name}
</span>
</button>
{/each}
{#each files as file (file.id)}
<div
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted text-2xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{file.name}
</span>
<span class="text-xs text-muted-foreground">{formatFileSize(file.size)}</span>
</div>
{/each}
</div>
{:else}
<!-- List View -->
<div class="flex flex-col gap-1">
{#each folders as folder (folder.id)}
<button
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted text-left w-full"
onclick={() => handleFolderClick(folder)}
>
<span class="text-xl">📁</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{folder.name}</div>
</div>
<span class="text-xs text-muted-foreground">{formatDate(folder.updatedAt)}</span>
</button>
{/each}
{#each files as file (file.id)}
<div
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted"
>
<span class="text-xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{file.name}</div>
<div class="text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
</div>
<span class="text-xs text-muted-foreground">{formatDate(file.updatedAt)}</span>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,198 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext, onMount } from 'svelte';
import { MagnifyingGlass, GridFour, List } from '@manacore/shared-icons';
import { filesStore } from '$lib/modules/storage/stores/files.svelte';
import { searchItems, formatFileSize } from '$lib/modules/storage/queries';
import type { StorageFile, StorageFolder } from '$lib/modules/storage/queries';
// Get live query data from layout context
const allFiles: { readonly value: StorageFile[] } = getContext('storageFiles');
const allFolders: { readonly value: StorageFolder[] } = getContext('storageFolders');
let query = $state('');
let searched = $state(false);
let results = $derived(
query.trim()
? searchItems(allFiles?.value ?? [], allFolders?.value ?? [], query.trim())
: { files: [], folders: [] }
);
onMount(() => {
filesStore.initViewMode();
});
function handleSearch() {
if (!query.trim()) return;
searched = true;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
handleSearch();
}
}
function handleFolderClick(folder: StorageFolder) {
goto(`/storage/files/${folder.id}`);
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Suche - Storage - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<MagnifyingGlass size={24} class="text-primary" />
<h1 class="text-2xl font-bold text-foreground">Suche</h1>
</div>
<div class="flex rounded-lg border border-border bg-card p-0.5">
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'grid'}
class:text-white={filesStore.viewMode === 'grid'}
class:text-muted-foreground={filesStore.viewMode !== 'grid'}
onclick={() => filesStore.setViewMode('grid')}
aria-label="Rasteransicht"
>
<GridFour size={18} />
</button>
<button
class="rounded-md p-1.5 transition-colors"
class:bg-primary={filesStore.viewMode === 'list'}
class:text-white={filesStore.viewMode === 'list'}
class:text-muted-foreground={filesStore.viewMode !== 'list'}
onclick={() => filesStore.setViewMode('list')}
aria-label="Listenansicht"
>
<List size={18} />
</button>
</div>
</div>
<!-- Search Bar -->
<div class="mb-6 flex items-center gap-3 rounded-xl border border-border bg-card p-3">
<MagnifyingGlass size={20} class="text-muted-foreground" />
<input
type="search"
bind:value={query}
onkeydown={handleKeydown}
oninput={() => (searched = true)}
placeholder="Dateien und Ordner durchsuchen..."
class="flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Dateien und Ordner durchsuchen"
autofocus
/>
<button
class="rounded-md bg-primary px-4 py-1.5 text-sm text-white disabled:opacity-50"
onclick={handleSearch}
disabled={!query.trim()}
>
Suchen
</button>
</div>
{#if searched && query.trim() && results.files.length === 0 && results.folders.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 text-5xl">🔍</div>
<h3 class="mb-2 text-lg font-semibold text-foreground">Keine Ergebnisse</h3>
<p class="text-sm text-muted-foreground">
Keine Dateien oder Ordner fur "{query}" gefunden.
</p>
</div>
{:else if searched && query.trim()}
<div class="mb-4 text-sm text-muted-foreground">
{results.files.length + results.folders.length} Ergebnis(se) fur "{query}"
</div>
{#if filesStore.viewMode === 'grid'}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each results.folders as folder (folder.id)}
<button
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
onclick={() => handleFolderClick(folder)}
>
<div
class="flex h-12 w-12 items-center justify-center rounded-lg text-2xl bg-blue-500/10"
>
📁
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{folder.name}
</span>
</button>
{/each}
{#each results.files as file (file.id)}
<div
class="group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50 hover:shadow-md"
>
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted text-2xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</div>
<span class="text-sm font-medium text-foreground line-clamp-2 text-center">
{file.name}
</span>
<span class="text-xs text-muted-foreground">{formatFileSize(file.size)}</span>
</div>
{/each}
</div>
{:else}
<div class="flex flex-col gap-1">
{#each results.folders as folder (folder.id)}
<button
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted text-left w-full"
onclick={() => handleFolderClick(folder)}
>
<span class="text-xl">📁</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{folder.name}</div>
</div>
<span class="text-xs text-muted-foreground">{formatDate(folder.updatedAt)}</span>
</button>
{/each}
{#each results.files as file (file.id)}
<div
class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:bg-muted"
>
<span class="text-xl">
{#if file.mimeType.startsWith('image/')}📷
{:else if file.mimeType.startsWith('audio/')}🎵
{:else if file.mimeType.startsWith('video/')}🎬
{:else if file.mimeType === 'application/pdf'}📕
{:else}📄{/if}
</span>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-foreground truncate">{file.name}</div>
<div class="text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
</div>
<span class="text-xs text-muted-foreground">{formatDate(file.updatedAt)}</span>
</div>
{/each}
</div>
{/if}
{:else}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 text-5xl">🔍</div>
<h3 class="mb-2 text-lg font-semibold text-foreground">Dateien durchsuchen</h3>
<p class="text-sm text-muted-foreground">
Gib einen Suchbegriff ein, um Dateien und Ordner zu finden.
</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,156 @@
<script lang="ts">
import { Trash, ArrowCounterClockwise, Warning } from '@manacore/shared-icons';
import { filesStore } from '$lib/modules/storage/stores/files.svelte';
import { fileTable, storageFolderTable } from '$lib/modules/storage/collections';
import type { LocalFile, LocalFolder } from '$lib/modules/storage/types';
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
// Live query for deleted items (not permanently deleted)
const deletedFiles = liveQuery(async () => {
const all = await db.table<LocalFile>('files').toArray();
return all.filter((f) => f.isDeleted && !f.deletedAt);
});
const deletedFolders = liveQuery(async () => {
const all = await db.table<LocalFolder>('storageFolders').toArray();
return all.filter((f) => f.isDeleted && !f.deletedAt);
});
async function handleRestore(id: string, type: 'file' | 'folder') {
if (type === 'file') {
await filesStore.restoreFile(id);
} else {
await filesStore.restoreFolder(id);
}
}
async function handlePermanentDelete(id: string, type: 'file' | 'folder') {
if (!confirm('Endgultig loschen? Dies kann nicht ruckgangig gemacht werden.')) return;
if (type === 'file') {
await filesStore.permanentDeleteFile(id);
} else {
await filesStore.permanentDeleteFolder(id);
}
}
async function handleEmptyTrash() {
if (!confirm('Papierkorb leeren? Alle Elemente werden endgultig geloscht.')) return;
const now = new Date().toISOString();
const files = ($deletedFiles as LocalFile[] | undefined) ?? [];
const folders = ($deletedFolders as LocalFolder[] | undefined) ?? [];
for (const f of files) {
await fileTable.update(f.id, { deletedAt: now, updatedAt: now });
}
for (const f of folders) {
await storageFolderTable.update(f.id, { deletedAt: now, updatedAt: now });
}
}
function formatDate(dateStr: string | undefined): string {
if (!dateStr) return '--';
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
// Reactively read the live query values
let files = $derived(($deletedFiles as LocalFile[] | undefined) ?? []);
let folders = $derived(($deletedFolders as LocalFolder[] | undefined) ?? []);
</script>
<svelte:head>
<title>Papierkorb - Storage - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<Trash size={24} class="text-muted-foreground" />
<h1 class="text-2xl font-bold text-foreground">Papierkorb</h1>
</div>
{#if files.length > 0 || folders.length > 0}
<button
class="flex items-center gap-2 rounded-lg bg-destructive px-3 py-1.5 text-sm text-white"
onclick={handleEmptyTrash}
>
<Warning size={16} />
Papierkorb leeren
</button>
{/if}
</div>
{#if files.length === 0 && folders.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="mb-4 text-5xl">🗑️</div>
<h3 class="mb-2 text-lg font-semibold text-foreground">Papierkorb ist leer</h3>
<p class="text-sm text-muted-foreground">Geloschte Dateien und Ordner erscheinen hier.</p>
</div>
{:else}
<div class="flex flex-col gap-2">
{#each folders as folder (folder.id)}
<div
class="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3"
>
<div class="flex items-center gap-3">
<span class="text-xl">📁</span>
<div>
<div class="text-sm font-medium text-foreground">{folder.name}</div>
<div class="text-xs text-muted-foreground">
Geloscht am {formatDate(folder.updatedAt)}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1 rounded-md border border-border px-3 py-1 text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
onclick={() => handleRestore(folder.id, 'folder')}
>
<ArrowCounterClockwise size={14} />
Wiederherstellen
</button>
<button
class="rounded-md px-2 py-1 text-xs text-destructive hover:underline"
onclick={() => handlePermanentDelete(folder.id, 'folder')}
>
Endgultig loschen
</button>
</div>
</div>
{/each}
{#each files as file (file.id)}
<div
class="flex items-center justify-between rounded-lg border border-border bg-card px-4 py-3"
>
<div class="flex items-center gap-3">
<span class="text-xl">📄</span>
<div>
<div class="text-sm font-medium text-foreground">{file.name}</div>
<div class="text-xs text-muted-foreground">
Geloscht am {formatDate(file.updatedAt)}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1 rounded-md border border-border px-3 py-1 text-xs text-foreground transition-colors hover:border-primary hover:text-primary"
onclick={() => handleRestore(file.id, 'file')}
>
<ArrowCounterClockwise size={14} />
Wiederherstellen
</button>
<button
class="rounded-md px-2 py-1 text-xs text-destructive hover:underline"
onclick={() => handlePermanentDelete(file.id, 'file')}
>
Endgultig loschen
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>