mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 23:21:08 +02:00
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:
parent
7def9c9977
commit
990ade352f
31 changed files with 3284 additions and 0 deletions
59
apps/manacore/apps/web/src/lib/modules/cards/collections.ts
Normal file
59
apps/manacore/apps/web/src/lib/modules/cards/collections.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
28
apps/manacore/apps/web/src/lib/modules/cards/index.ts
Normal file
28
apps/manacore/apps/web/src/lib/modules/cards/index.ts
Normal 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';
|
||||
90
apps/manacore/apps/web/src/lib/modules/cards/queries.ts
Normal file
90
apps/manacore/apps/web/src/lib/modules/cards/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
77
apps/manacore/apps/web/src/lib/modules/cards/types.ts
Normal file
77
apps/manacore/apps/web/src/lib/modules/cards/types.ts
Normal 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;
|
||||
}
|
||||
75
apps/manacore/apps/web/src/lib/modules/guides/index.ts
Normal file
75
apps/manacore/apps/web/src/lib/modules/guides/index.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
23
apps/manacore/apps/web/src/lib/modules/playground/index.ts
Normal file
23
apps/manacore/apps/web/src/lib/modules/playground/index.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
};
|
||||
32
apps/manacore/apps/web/src/lib/modules/storage/index.ts
Normal file
32
apps/manacore/apps/web/src/lib/modules/storage/index.ts
Normal 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';
|
||||
199
apps/manacore/apps/web/src/lib/modules/storage/queries.ts
Normal file
199
apps/manacore/apps/web/src/lib/modules/storage/queries.ts
Normal 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]}`;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
41
apps/manacore/apps/web/src/lib/modules/storage/types.ts
Normal file
41
apps/manacore/apps/web/src/lib/modules/storage/types.ts
Normal 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;
|
||||
}
|
||||
15
apps/manacore/apps/web/src/routes/(app)/cards/+layout.svelte
Normal file
15
apps/manacore/apps/web/src/routes/(app)/cards/+layout.svelte
Normal 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()}
|
||||
75
apps/manacore/apps/web/src/routes/(app)/cards/+page.svelte
Normal file
75
apps/manacore/apps/web/src/routes/(app)/cards/+page.svelte
Normal 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>
|
||||
|
|
@ -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} />
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
114
apps/manacore/apps/web/src/routes/(app)/guides/+page.svelte
Normal file
114
apps/manacore/apps/web/src/routes/(app)/guides/+page.svelte
Normal 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>
|
||||
188
apps/manacore/apps/web/src/routes/(app)/playground/+page.svelte
Normal file
188
apps/manacore/apps/web/src/routes/(app)/playground/+page.svelte
Normal 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>
|
||||
|
|
@ -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()}
|
||||
87
apps/manacore/apps/web/src/routes/(app)/storage/+page.svelte
Normal file
87
apps/manacore/apps/web/src/routes/(app)/storage/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue