feat(manacore): migrate presi, uload, context, questions, nutriphi to unified app

Phase 2 continued — 5 more modules migrated (total: 17/25):

- Presi: deck list, slide editor, presentation mode with keyboard nav
- uLoad: link dashboard with folders/tags, bulk actions, analytics
- Context: spaces, document editor with auto-save, type/tag filters
- Questions: question list, collections, research depth, answers
- NutriPhi: daily dashboard with progress bars, meal tracker, goals

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 20:18:09 +02:00
parent ce3ed10b60
commit 1022d2f64c
41 changed files with 7201 additions and 0 deletions

View file

@ -0,0 +1,52 @@
/**
* Context module collection accessors and guest seed data.
*
* Uses table names from the unified DB: contextSpaces, documents.
*/
import { db } from '$lib/data/database';
import type { LocalContextSpace, LocalDocument } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const contextSpaceTable = db.table<LocalContextSpace>('contextSpaces');
export const documentTable = db.table<LocalDocument>('documents');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_SPACE_ID = 'demo-workspace';
export const CONTEXT_GUEST_SEED = {
contextSpaces: [
{
id: DEMO_SPACE_ID,
name: 'Mein Workspace',
description: 'Beispiel-Space zum Kennenlernen von Context.',
pinned: true,
prefix: 'W',
},
],
documents: [
{
id: 'doc-welcome',
spaceId: DEMO_SPACE_ID,
title: 'Willkommen bei Context',
content:
'Context ist dein KI-gestütztes Dokumenten-Management. Erstelle Texte, sammle Kontexte und nutze KI-Prompts.\n\nMelde dich an, um deine Dokumente zu synchronisieren.',
type: 'text' as const,
shortId: 'WD1',
pinned: true,
metadata: { tags: ['einführung'], wordCount: 22 },
},
{
id: 'doc-prompt',
spaceId: DEMO_SPACE_ID,
title: 'Beispiel-Prompt',
content: 'Fasse den folgenden Text in 3 Stichpunkten zusammen:\n\n{text}',
type: 'prompt' as const,
shortId: 'WP1',
pinned: false,
metadata: { tags: ['vorlage'] },
},
],
};

View file

@ -0,0 +1,14 @@
/**
* Context module barrel exports.
*/
export { contextSpaceTable, contextDocumentTable, CONTEXT_GUEST_SEED } from './collections';
export * from './queries';
export type {
LocalContextSpace,
LocalDocument,
DocumentType,
DocumentMetadata,
Space,
Document,
} from './types';

View file

@ -0,0 +1,151 @@
/**
* Reactive Queries & Pure Helpers for Context module.
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalContextSpace, LocalDocument, Space, Document, DocumentType } from './types';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalContextSpace (IndexedDB) to shared Space type. */
export function toSpace(local: LocalContextSpace): Space {
return {
id: local.id,
name: local.name,
description: local.description ?? null,
user_id: 'local',
created_at: local.createdAt ?? new Date().toISOString(),
settings: local.settings ?? null,
pinned: local.pinned,
prefix: local.prefix,
};
}
/** Convert LocalDocument (IndexedDB) to shared Document type. */
export function toDocument(local: LocalDocument): Document {
return {
id: local.id,
title: local.title,
content: local.content,
type: local.type,
space_id: local.spaceId ?? null,
user_id: 'local',
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
metadata: local.metadata ?? null,
short_id: local.shortId ?? undefined,
pinned: local.pinned,
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All spaces, sorted by name. Auto-updates on any change. */
export function useAllSpaces() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalContextSpace>('contextSpaces').toArray();
return locals
.filter((s) => !s.deletedAt)
.map(toSpace)
.sort((a, b) => a.name.localeCompare(b.name));
}, [] as Space[]);
}
/** All documents, sorted by updated_at desc. Auto-updates on any change. */
export function useAllDocuments() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalDocument>('documents').toArray();
return locals
.filter((d) => !d.deletedAt)
.map(toDocument)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}, [] as Document[]);
}
/** Documents for a specific space. Auto-updates on any change. */
export function useSpaceDocuments(spaceId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalDocument>('documents')
.where('spaceId')
.equals(spaceId)
.toArray();
return locals
.filter((d) => !d.deletedAt)
.map(toDocument)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}, [] as Document[]);
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Get pinned spaces from a list. */
export function getPinnedSpaces(spaces: Space[]): Space[] {
return spaces.filter((s) => s.pinned);
}
/** Filter documents by type, search query, and tags. */
export function filterDocuments(
documents: Document[],
options: {
typeFilter?: DocumentType | 'all';
searchQuery?: string;
tagFilter?: string[];
}
): Document[] {
let filtered = documents;
if (options.typeFilter && options.typeFilter !== 'all') {
filtered = filtered.filter((d) => d.type === options.typeFilter);
}
if (options.searchQuery?.trim()) {
const q = options.searchQuery.toLowerCase();
filtered = filtered.filter(
(d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q)
);
}
if (options.tagFilter && options.tagFilter.length > 0) {
filtered = filtered.filter((d) =>
options.tagFilter!.some((tag) => d.metadata?.tags?.includes(tag))
);
}
return filtered;
}
/** Compute document stats from a list. */
export function getDocumentStats(documents: Document[]) {
return {
total: documents.length,
text: documents.filter((d) => d.type === 'text').length,
context: documents.filter((d) => d.type === 'context').length,
prompt: documents.filter((d) => d.type === 'prompt').length,
totalWords: documents.reduce((sum, d) => sum + (d.metadata?.word_count || 0), 0),
};
}
/** Get all unique tags from documents. */
export function getAllDocumentTags(documents: Document[]): string[] {
const tags = new Set<string>();
documents.forEach((d) => {
d.metadata?.tags?.forEach((t) => tags.add(t));
});
return Array.from(tags).sort();
}
/** Find a space by ID. */
export function findSpaceById(spaces: Space[], id: string): Space | undefined {
return spaces.find((s) => s.id === id);
}
/** Find a document by ID. */
export function findDocumentById(documents: Document[], id: string): Document | undefined {
return documents.find((d) => d.id === id);
}

View file

@ -0,0 +1,79 @@
/**
* Context module types for the unified ManaCore app.
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Document Types ────────────────────────────────────────
export type DocumentType = 'text' | 'context' | 'prompt';
export interface DocumentMetadata {
tags?: string[];
word_count?: number;
token_count?: number;
parent_document?: string;
version?: number;
generation_type?: 'summary' | 'continuation' | 'rewrite' | 'ideas';
model_used?: string;
prompt_used?: string;
original_title?: string;
version_history?: Array<{
id: string;
title: string;
type: string;
created_at: string;
is_original: boolean;
}>;
[key: string]: unknown;
}
// ─── Local DB Types (IndexedDB) ────────────────────────────
export interface LocalContextSpace extends BaseRecord {
name: string;
description?: string | null;
settings?: Record<string, unknown> | null;
pinned: boolean;
prefix: string;
}
export interface LocalDocument extends BaseRecord {
spaceId?: string | null;
title: string;
content: string;
type: DocumentType;
shortId?: string | null;
pinned: boolean;
metadata?: {
tags?: string[];
wordCount?: number;
} | null;
}
// ─── Shared / View Types ───────────────────────────────────
export interface Space {
id: string;
name: string;
description: string | null;
user_id: string;
created_at: string;
settings: Record<string, unknown> | null;
pinned: boolean;
prefix?: string;
}
export interface Document {
id: string;
title: string;
content: string | null;
type: DocumentType;
space_id: string | null;
user_id: string;
created_at: string;
updated_at: string;
metadata: DocumentMetadata | null;
short_id?: string;
pinned?: boolean;
}

View file

@ -0,0 +1,65 @@
/**
* NutriPhi module collection accessors and guest seed data.
*
* Uses table names in the unified DB: meals, goals, nutriFavorites.
*/
import { db } from '$lib/data/database';
import type { LocalMeal, LocalGoal, LocalFavorite } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const mealTable = db.table<LocalMeal>('meals');
export const goalTable = db.table<LocalGoal>('goals');
export const nutriFavoriteTable = db.table<LocalFavorite>('nutriFavorites');
// ─── Guest Seed ────────────────────────────────────────────
const today = new Date().toISOString().split('T')[0];
export const NUTRIPHI_GUEST_SEED = {
meals: [
{
id: 'meal-breakfast',
date: today,
mealType: 'breakfast' as const,
inputType: 'text' as const,
description: 'Haferflocken mit Banane und Honig',
confidence: 0.9,
nutrition: {
calories: 380,
protein: 10,
carbohydrates: 68,
fat: 8,
fiber: 6,
sugar: 24,
},
},
{
id: 'meal-lunch',
date: today,
mealType: 'lunch' as const,
inputType: 'text' as const,
description: 'Vollkorn-Sandwich mit Avocado und Ei',
confidence: 0.85,
nutrition: {
calories: 520,
protein: 22,
carbohydrates: 45,
fat: 28,
fiber: 8,
sugar: 4,
},
},
],
goals: [
{
id: 'default-goals',
dailyCalories: 2000,
dailyProtein: 60,
dailyCarbs: 250,
dailyFat: 65,
dailyFiber: 30,
},
],
};

View file

@ -0,0 +1,44 @@
/**
* NutriPhi constants meal labels, nutrient info, default values.
*
* Inlined from @nutriphi/shared to avoid the cross-app dependency.
*/
// Default daily recommended values (based on 2000 kcal diet)
export const DEFAULT_DAILY_VALUES = {
calories: 2000,
protein: 50,
carbohydrates: 275,
fat: 78,
fiber: 28,
sugar: 50,
} as const;
// Meal type labels
export const MEAL_TYPE_LABELS = {
breakfast: { de: 'Fruhstuck', en: 'Breakfast' },
lunch: { de: 'Mittagessen', en: 'Lunch' },
dinner: { de: 'Abendessen', en: 'Dinner' },
snack: { de: 'Snack', en: 'Snack' },
} as const;
// Nutrient display info
export const NUTRIENT_INFO = {
calories: { label: 'Kalorien', unit: 'kcal', color: '#F59E0B' },
protein: { label: 'Protein', unit: 'g', color: '#EF4444' },
carbohydrates: { label: 'Kohlenhydrate', unit: 'g', color: '#3B82F6' },
fat: { label: 'Fett', unit: 'g', color: '#8B5CF6' },
fiber: { label: 'Ballaststoffe', unit: 'g', color: '#10B981' },
sugar: { label: 'Zucker', unit: 'g', color: '#EC4899' },
} as const;
/**
* Suggest meal type based on current time of day.
*/
export function suggestMealType(): 'breakfast' | 'lunch' | 'dinner' | 'snack' {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) return 'breakfast';
if (hour >= 11 && hour < 14) return 'lunch';
if (hour >= 17 && hour < 21) return 'dinner';
return 'snack';
}

View file

@ -0,0 +1,17 @@
/**
* NutriPhi module barrel exports.
*/
export { mealTable, goalTable, nutriFavoriteTable, NUTRIPHI_GUEST_SEED } from './collections';
export * from './queries';
export type {
LocalMeal,
LocalGoal,
LocalFavorite,
MealType,
InputType,
NutritionData,
NutritionProgress,
DailySummary,
MealWithNutrition,
} from './types';

View file

@ -0,0 +1,150 @@
/**
* Reactive queries & pure helpers for NutriPhi uses Dexie liveQuery on the unified DB.
*
* Uses table names: meals, goals, nutriFavorites.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type {
LocalMeal,
LocalGoal,
LocalFavorite,
MealWithNutrition,
NutritionData,
NutritionProgress,
DailySummary,
} from './types';
import { DEFAULT_DAILY_VALUES } from './constants';
// ─── Type Converters ───────────────────────────────────────
export function toMealWithNutrition(local: LocalMeal): MealWithNutrition {
return {
id: local.id,
date: local.date,
mealType: local.mealType,
inputType: local.inputType,
description: local.description,
portionSize: local.portionSize,
confidence: local.confidence,
nutrition: local.nutrition ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All meals, auto-updates on any change. */
export function useAllMeals() {
return liveQuery(async () => {
const locals = await db.table<LocalMeal>('meals').toArray();
return locals.filter((m) => !m.deletedAt).map(toMealWithNutrition);
});
}
/** All goals, auto-updates on any change. */
export function useAllGoals() {
return liveQuery(async () => {
const locals = await db.table<LocalGoal>('goals').toArray();
return locals.filter((g) => !g.deletedAt);
});
}
/** All favorites, auto-updates on any change. */
export function useAllFavorites() {
return liveQuery(async () => {
const locals = await db.table<LocalFavorite>('nutriFavorites').toArray();
return locals.filter((f) => !f.deletedAt);
});
}
// ─── Pure Filter/Helper Functions (for $derived) ──────────
/** Get today's date as YYYY-MM-DD string. */
export function getTodayStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Filter meals for a specific date string (YYYY-MM-DD). */
export function filterByDate(meals: MealWithNutrition[], dateStr: string): MealWithNutrition[] {
return meals.filter((m) => {
const mealDate = String(m.date).split('T')[0];
return mealDate === dateStr;
});
}
/** Filter meals for today, sorted by creation time. */
export function getTodaysMeals(meals: MealWithNutrition[]): MealWithNutrition[] {
const today = getTodayStr();
return filterByDate(meals, today).sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
}
/** Sum nutrition values across a set of meals. */
export function sumNutrition(meals: MealWithNutrition[]): NutritionData {
return meals.reduce(
(acc, m) => ({
calories: acc.calories + (m.nutrition?.calories || 0),
protein: acc.protein + (m.nutrition?.protein || 0),
carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0),
fat: acc.fat + (m.nutrition?.fat || 0),
fiber: acc.fiber + (m.nutrition?.fiber || 0),
sugar: acc.sugar + (m.nutrition?.sugar || 0),
}),
{ calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 }
);
}
/** Build a DailySummary from meals for a given date. */
export function getDailySummary(
meals: MealWithNutrition[],
date?: Date,
goals?: LocalGoal | null
): DailySummary {
const dateStr = (date || new Date()).toISOString().split('T')[0];
const dayMeals = filterByDate(meals, dateStr);
const totalNutrition = sumNutrition(dayMeals);
const calorieTarget = goals?.dailyCalories ?? DEFAULT_DAILY_VALUES.calories;
const proteinTarget = goals?.dailyProtein ?? DEFAULT_DAILY_VALUES.protein;
const carbsTarget = goals?.dailyCarbs ?? DEFAULT_DAILY_VALUES.carbohydrates;
const fatTarget = goals?.dailyFat ?? DEFAULT_DAILY_VALUES.fat;
const progress: NutritionProgress = {
calories: {
current: Math.round(totalNutrition.calories),
target: calorieTarget,
percentage: Math.min(Math.round((totalNutrition.calories / calorieTarget) * 100), 100),
},
protein: {
current: Math.round(totalNutrition.protein),
target: proteinTarget,
percentage: Math.min(Math.round((totalNutrition.protein / proteinTarget) * 100), 100),
},
carbs: {
current: Math.round(totalNutrition.carbohydrates),
target: carbsTarget,
percentage: Math.min(Math.round((totalNutrition.carbohydrates / carbsTarget) * 100), 100),
},
fat: {
current: Math.round(totalNutrition.fat),
target: fatTarget,
percentage: Math.min(Math.round((totalNutrition.fat / fatTarget) * 100), 100),
},
};
return {
date: new Date(dateStr),
meals: dayMeals,
totalNutrition,
progress,
};
}
/** Search meals by description. */
export function searchMeals(meals: MealWithNutrition[], query: string): MealWithNutrition[] {
const q = query.toLowerCase();
return meals.filter((m) => m.description?.toLowerCase().includes(q));
}

View file

@ -0,0 +1,69 @@
/**
* NutriPhi module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
export type InputType = 'photo' | 'text';
export interface NutritionData {
calories: number;
protein: number;
carbohydrates: number;
fat: number;
fiber: number;
sugar: number;
}
export interface LocalMeal extends BaseRecord {
date: string;
mealType: MealType;
inputType: InputType;
description: string;
portionSize?: string | null;
confidence: number;
nutrition?: NutritionData | null;
}
export interface LocalGoal extends BaseRecord {
dailyCalories: number;
dailyProtein?: number | null;
dailyCarbs?: number | null;
dailyFat?: number | null;
dailyFiber?: number | null;
}
export interface LocalFavorite extends BaseRecord {
name: string;
description: string;
mealType: MealType;
nutrition: NutritionData;
usageCount: number;
}
export interface NutritionProgress {
calories: { current: number; target: number; percentage: number };
protein: { current: number; target: number; percentage: number };
carbs: { current: number; target: number; percentage: number };
fat: { current: number; target: number; percentage: number };
}
export interface DailySummary {
date: Date;
meals: MealWithNutrition[];
totalNutrition: NutritionData;
progress: NutritionProgress;
}
export interface MealWithNutrition {
id: string;
date: string;
mealType: MealType;
inputType: InputType;
description: string;
portionSize?: string | null;
confidence: number;
nutrition: NutritionData | null;
createdAt: string;
}

View file

@ -0,0 +1,69 @@
/**
* Presi module collection accessors and guest seed data.
*
* Uses prefixed table names in the unified DB: presiDecks, slides.
*/
import { db } from '$lib/data/database';
import type { LocalDeck, LocalSlide } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const presiDeckTable = db.table<LocalDeck>('presiDecks');
export const slideTable = db.table<LocalSlide>('slides');
// ─── Guest Seed ────────────────────────────────────────────
const ONBOARDING_DECK_ID = 'onboarding-deck';
export const PRESI_GUEST_SEED = {
presiDecks: [
{
id: ONBOARDING_DECK_ID,
title: 'Willkommen bei Presi',
description: 'Eine kurze Einfuhrung in die Prasentations-App.',
isPublic: false,
},
],
slides: [
{
id: 'slide-1',
deckId: ONBOARDING_DECK_ID,
order: 1,
content: {
type: 'title' as const,
title: 'Willkommen bei Presi!',
subtitle: 'Erstelle Prasentationen direkt im Browser.',
},
},
{
id: 'slide-2',
deckId: ONBOARDING_DECK_ID,
order: 2,
content: {
type: 'content' as const,
title: 'So funktioniert es',
bulletPoints: [
'Erstelle Decks mit dem + Button',
'Fuge Slides mit Text, Bildern und Aufzahlungen hinzu',
'Starte die Prasentation mit dem Play-Button',
'Melde dich an, um zu synchronisieren',
],
},
},
{
id: 'slide-3',
deckId: ONBOARDING_DECK_ID,
order: 3,
content: {
type: 'content' as const,
title: 'Tastaturkurzel',
bulletPoints: [
'Pfeiltasten / A+D — Slides navigieren',
'F — Vollbild',
'ESC — Prasentation beenden',
],
},
},
],
};

View file

@ -0,0 +1,26 @@
/**
* Presi module barrel exports.
*/
export { decksStore } from './stores/decks.svelte';
export { presiDeckTable, slideTable, PRESI_GUEST_SEED } from './collections';
export {
useAllDecks,
useDeck,
useDeckSlides,
toDeck,
toSlide,
findDeckById,
getSlideCount,
} from './queries';
export type {
LocalDeck,
LocalSlide,
SlideContent,
Deck,
Slide,
CreateDeckDto,
UpdateDeckDto,
CreateSlideDto,
UpdateSlideDto,
} from './types';

View file

@ -0,0 +1,81 @@
/**
* Reactive queries & pure helpers for Presi uses Dexie liveQuery on the unified DB.
*
* Uses prefixed table names: presiDecks, slides.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalDeck, LocalSlide, Deck, Slide } from './types';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalDeck (IndexedDB) to shared Deck type. */
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalSlide (IndexedDB) to shared Slide type. */
export function toSlide(local: LocalSlide): Slide {
return {
id: local.id,
deckId: local.deckId,
order: local.order,
content: local.content,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ─────────────────────────────────────────
/** All decks, sorted by updatedAt descending. Auto-updates on any change. */
export function useAllDecks() {
return liveQuery(async () => {
const locals = await db.table<LocalDeck>('presiDecks').toArray();
return locals
.filter((d) => !d.deletedAt)
.map(toDeck)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
});
}
/** Slides for a specific deck, sorted by order. Auto-updates on any change. */
export function useDeckSlides(deckId: string) {
return liveQuery(async () => {
const locals = await db.table<LocalSlide>('slides').where('deckId').equals(deckId).toArray();
return locals
.filter((s) => !s.deletedAt)
.map(toSlide)
.sort((a, b) => a.order - b.order);
});
}
/** Single deck by ID. Auto-updates on any change. */
export function useDeck(id: string) {
return liveQuery(async () => {
const local = await db.table<LocalDeck>('presiDecks').get(id);
if (!local || local.deletedAt) return null;
return toDeck(local);
});
}
// ─── Pure Helper Functions ────────────────────────────────
/** Find a deck by ID from a list. */
export function findDeckById(decks: Deck[], id: string): Deck | undefined {
return decks.find((d) => d.id === id);
}
/** Get slide count for a deck. */
export function getSlideCount(slides: Slide[], deckId: string): number {
return slides.filter((s) => s.deckId === deckId).length;
}

View file

@ -0,0 +1,170 @@
/**
* Decks Store Mutation-Only
*
* Reads are handled by liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { presiDeckTable, slideTable } from '../collections';
import { toDeck, toSlide } from '../queries';
import type {
LocalDeck,
LocalSlide,
Deck,
Slide,
CreateDeckDto,
UpdateDeckDto,
CreateSlideDto,
UpdateSlideDto,
} from '../types';
let isLoading = $state(false);
let error = $state<string | null>(null);
function createDecksStore() {
async function createDeck(dto: CreateDeckDto): Promise<Deck | null> {
isLoading = true;
error = null;
try {
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
title: dto.title,
description: dto.description || null,
themeId: dto.themeId || null,
isPublic: false,
};
await presiDeckTable.add(newLocal);
return toDeck(newLocal);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create deck';
console.error('Failed to create deck:', e);
return null;
} finally {
isLoading = false;
}
}
async function updateDeck(id: string, dto: UpdateDeckDto): Promise<boolean> {
error = null;
try {
const localUpdates: Partial<LocalDeck> & { updatedAt: string } = {
updatedAt: new Date().toISOString(),
};
if (dto.title !== undefined) localUpdates.title = dto.title;
if (dto.description !== undefined) localUpdates.description = dto.description;
if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId;
if (dto.isPublic !== undefined) localUpdates.isPublic = dto.isPublic;
await presiDeckTable.update(id, localUpdates);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update deck';
console.error('Failed to update deck:', e);
return false;
}
}
async function deleteDeck(id: string): Promise<boolean> {
error = null;
try {
const now = new Date().toISOString();
// Soft-delete all slides belonging to this deck
const slides = await slideTable.where('deckId').equals(id).toArray();
for (const slide of slides) {
await slideTable.update(slide.id, { deletedAt: now, updatedAt: now });
}
// Soft-delete the deck
await presiDeckTable.update(id, { deletedAt: now, updatedAt: now });
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete deck';
console.error('Failed to delete deck:', e);
return false;
}
}
async function createSlide(deckId: string, dto: CreateSlideDto): Promise<Slide | null> {
error = null;
try {
const existingSlides = await slideTable.where('deckId').equals(deckId).toArray();
const activeSlides = existingSlides.filter((s) => !s.deletedAt);
const order = dto.order ?? activeSlides.length + 1;
const newLocal: LocalSlide = {
id: crypto.randomUUID(),
deckId,
order,
content: dto.content,
};
await slideTable.add(newLocal);
return toSlide(newLocal);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create slide';
console.error('Failed to create slide:', e);
return null;
}
}
async function updateSlide(id: string, dto: UpdateSlideDto): Promise<boolean> {
error = null;
try {
const localUpdates: Partial<LocalSlide> & { updatedAt: string } = {
updatedAt: new Date().toISOString(),
};
if (dto.content !== undefined) localUpdates.content = dto.content;
if (dto.order !== undefined) localUpdates.order = dto.order;
await slideTable.update(id, localUpdates);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update slide';
console.error('Failed to update slide:', e);
return false;
}
}
async function deleteSlide(id: string): Promise<boolean> {
error = null;
try {
const now = new Date().toISOString();
await slideTable.update(id, { deletedAt: now, updatedAt: now });
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete slide';
console.error('Failed to delete slide:', e);
return false;
}
}
async function reorderSlides(slides: { id: string; order: number }[]): Promise<boolean> {
error = null;
try {
const now = new Date().toISOString();
for (const { id, order } of slides) {
await slideTable.update(id, { order, updatedAt: now });
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder slides';
console.error('Failed to reorder slides:', e);
return false;
}
}
return {
get isLoading() {
return isLoading;
},
get error() {
return error;
},
createDeck,
updateDeck,
deleteDeck,
createSlide,
updateSlide,
deleteSlide,
reorderSlides,
};
}
export const decksStore = createDecksStore();

View file

@ -0,0 +1,73 @@
/**
* Presi module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalDeck extends BaseRecord {
title: string;
description?: string | null;
themeId?: string | null;
isPublic: boolean;
}
export interface LocalSlide extends BaseRecord {
deckId: string;
order: number;
content: SlideContent;
}
export interface SlideContent {
type: 'title' | 'content' | 'image' | 'split';
title?: string;
subtitle?: string;
body?: string;
imageUrl?: string;
bulletPoints?: string[];
}
// ─── Shared Types (inline to avoid @presi/shared dependency) ───
export interface Deck {
id: string;
userId: string;
title: string;
description?: string;
themeId?: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}
export interface Slide {
id: string;
deckId: string;
order: number;
content: SlideContent;
createdAt: string;
}
// ─── DTOs ─────────────────────────────────────────────────
export interface CreateDeckDto {
title: string;
description?: string;
themeId?: string;
}
export interface UpdateDeckDto {
title?: string;
description?: string;
themeId?: string;
isPublic?: boolean;
}
export interface CreateSlideDto {
content: SlideContent;
order?: number;
}
export interface UpdateSlideDto {
content?: SlideContent;
order?: number;
}

View file

@ -0,0 +1,55 @@
/**
* Questions module collection accessors and guest seed data.
*
* Uses prefixed table names in the unified DB: qCollections, questions, answers.
*/
import { db } from '$lib/data/database';
import type { LocalCollection, LocalQuestion, LocalAnswer } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const qCollectionTable = db.table<LocalCollection>('qCollections');
export const questionTable = db.table<LocalQuestion>('questions');
export const answerTable = db.table<LocalAnswer>('answers');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_COLLECTION_ID = 'demo-research';
export const QUESTIONS_GUEST_SEED = {
qCollections: [
{
id: DEMO_COLLECTION_ID,
name: 'Erste Recherche',
description: 'Beispiel-Sammlung zum Kennenlernen.',
color: '#6366f1',
icon: 'search',
isDefault: true,
sortOrder: 0,
},
],
questions: [
{
id: 'q-1',
collectionId: DEMO_COLLECTION_ID,
title: 'Was ist Local-First Software?',
description: 'Wie funktioniert der Ansatz und welche Vorteile hat er?',
status: 'open' as const,
priority: 'normal' as const,
tags: ['tech', 'architektur'],
researchDepth: 'standard' as const,
},
{
id: 'q-2',
collectionId: DEMO_COLLECTION_ID,
title: 'Welche Datenbanken eignen sich für Offline-First Apps?',
description: null,
status: 'open' as const,
priority: 'normal' as const,
tags: ['tech', 'datenbank'],
researchDepth: 'quick' as const,
},
],
answers: [] as Array<Record<string, unknown>>,
};

View file

@ -0,0 +1,23 @@
/**
* Questions module barrel exports.
*/
export {
questionCollectionTable,
questionTable,
answerTable,
QUESTIONS_GUEST_SEED,
} from './collections';
export * from './queries';
export type {
LocalCollection,
LocalQuestion,
LocalAnswer,
QuestionStatus,
QuestionPriority,
ResearchDepth,
CreateQuestionDto,
UpdateQuestionDto,
CreateCollectionDto,
UpdateCollectionDto,
} from './types';

View file

@ -0,0 +1,163 @@
/**
* Reactive queries & pure helpers for Questions uses Dexie liveQuery on the unified DB.
*
* Uses table names: qCollections, questions, answers.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalCollection, LocalQuestion, LocalAnswer } from './types';
// ─── Shared Types (inline to avoid cross-app dependency) ───
export interface Collection {
id: string;
name: string;
description?: string;
color: string;
icon: string;
isDefault: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
questionCount?: number;
}
export interface Question {
id: string;
collectionId?: string;
title: string;
description?: string;
status: 'open' | 'researching' | 'answered' | 'archived';
priority: 'low' | 'normal' | 'high' | 'urgent';
tags: string[];
researchDepth: 'quick' | 'standard' | 'deep';
createdAt: string;
updatedAt: string;
}
export interface Answer {
id: string;
questionId: string;
researchResultId?: string;
content: string;
citations: Array<{ sourceId: string; text: string }>;
rating?: number;
isAccepted: boolean;
createdAt: string;
updatedAt: string;
}
export type QuestionStatus = Question['status'];
export type QuestionPriority = Question['priority'];
export type ResearchDepth = Question['researchDepth'];
// ─── Type Converters ───────────────────────────────────────
export function toCollection(local: LocalCollection): Collection {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
color: local.color,
icon: local.icon,
isDefault: local.isDefault,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toQuestion(local: LocalQuestion): Question {
return {
id: local.id,
collectionId: local.collectionId ?? undefined,
title: local.title,
description: local.description ?? undefined,
status: local.status,
priority: local.priority,
tags: local.tags ?? [],
researchDepth: local.researchDepth,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toAnswer(local: LocalAnswer): Answer {
return {
id: local.id,
questionId: local.questionId,
researchResultId: local.researchResultId ?? undefined,
content: local.content,
citations: local.citations ?? [],
rating: local.rating ?? undefined,
isAccepted: local.isAccepted,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All collections, sorted by sortOrder. Auto-updates on any change. */
export function useAllCollections() {
return liveQuery(async () => {
const locals = await db.table<LocalCollection>('qCollections').toArray();
return locals
.filter((c) => !c.deletedAt)
.sort((a, b) => a.sortOrder - b.sortOrder)
.map(toCollection);
});
}
/** All questions. Auto-updates on any change. */
export function useAllQuestions() {
return liveQuery(async () => {
const locals = await db.table<LocalQuestion>('questions').toArray();
return locals.filter((q) => !q.deletedAt).map(toQuestion);
});
}
/** All answers for a given question. */
export function useAnswersByQuestion(questionId: string) {
return liveQuery(async () => {
const locals = await db.table<LocalAnswer>('answers').toArray();
return locals.filter((a) => !a.deletedAt && a.questionId === questionId).map(toAnswer);
});
}
// ─── Pure Filter Functions (for $derived) ───────────────────
/** Filter questions by collection ID. */
export function filterByCollection(questions: Question[], collectionId: string | null): Question[] {
if (!collectionId) return questions;
return questions.filter((q) => q.collectionId === collectionId);
}
/** Filter questions by status. */
export function filterByStatus(questions: Question[], status: string): Question[] {
if (!status) return questions;
return questions.filter((q) => q.status === status);
}
/** Filter questions by search query across title, description, and tags. */
export function searchQuestions(questions: Question[], query: string): Question[] {
if (!query.trim()) return questions;
const search = query.toLowerCase().trim();
return questions.filter(
(q) =>
q.title.toLowerCase().includes(search) ||
q.description?.toLowerCase().includes(search) ||
q.tags?.some((t: string) => t.toLowerCase().includes(search))
);
}
/** Get a question by ID. */
export function getQuestionById(questions: Question[], id: string): Question | undefined {
return questions.find((q) => q.id === id);
}
/** Get questions count per collection. */
export function getQuestionCountByCollection(questions: Question[], collectionId: string): number {
return questions.filter((q) => q.collectionId === collectionId).length;
}

View file

@ -0,0 +1,73 @@
/**
* Questions module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalCollection extends BaseRecord {
name: string;
description?: string | null;
color: string;
icon: string;
isDefault: boolean;
sortOrder: number;
}
export interface LocalQuestion extends BaseRecord {
collectionId?: string | null;
title: string;
description?: string | null;
status: 'open' | 'researching' | 'answered' | 'archived';
priority: 'low' | 'normal' | 'high' | 'urgent';
tags: string[];
researchDepth: 'quick' | 'standard' | 'deep';
}
export interface LocalAnswer extends BaseRecord {
questionId: string;
researchResultId?: string | null;
content: string;
citations: Array<{ sourceId: string; text: string }>;
rating?: number | null;
isAccepted: boolean;
}
export type QuestionStatus = LocalQuestion['status'];
export type QuestionPriority = LocalQuestion['priority'];
export type ResearchDepth = LocalQuestion['researchDepth'];
export interface CreateQuestionDto {
title: string;
description?: string;
collectionId?: string;
tags?: string[];
priority?: QuestionPriority;
researchDepth?: ResearchDepth;
}
export interface UpdateQuestionDto {
title?: string;
description?: string;
collectionId?: string;
tags?: string[];
priority?: QuestionPriority;
status?: QuestionStatus;
researchDepth?: ResearchDepth;
}
export interface CreateCollectionDto {
name: string;
description?: string;
color?: string;
icon?: string;
isDefault?: boolean;
}
export interface UpdateCollectionDto {
name?: string;
description?: string;
color?: string;
icon?: string;
isDefault?: boolean;
sortOrder?: number;
}

View file

@ -0,0 +1,118 @@
/**
* uLoad module collection accessors and guest seed data.
*
* Uses table names in the unified DB: links, uloadTags, uloadFolders, linkTags.
*/
import { db } from '$lib/data/database';
import type { LocalLink, LocalTag, LocalFolder, LocalLinkTag } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const linkTable = db.table<LocalLink>('links');
export const uloadTagTable = db.table<LocalTag>('uloadTags');
export const uloadFolderTable = db.table<LocalFolder>('uloadFolders');
export const linkTagTable = db.table<LocalLinkTag>('linkTags');
// ─── Guest Seed ────────────────────────────────────────────
export const ULOAD_GUEST_SEED = {
uloadFolders: [
{
id: 'folder-personal',
name: 'Persoenlich',
color: '#3b82f6',
order: 0,
},
{
id: 'folder-work',
name: 'Arbeit',
color: '#10b981',
order: 1,
},
] satisfies LocalFolder[],
uloadTags: [
{
id: 'tag-social',
name: 'Social Media',
slug: 'social-media',
color: '#8b5cf6',
icon: null,
isPublic: false,
usageCount: 2,
},
{
id: 'tag-docs',
name: 'Dokumentation',
slug: 'dokumentation',
color: '#f59e0b',
icon: null,
isPublic: false,
usageCount: 1,
},
{
id: 'tag-marketing',
name: 'Marketing',
slug: 'marketing',
color: '#ef4444',
icon: null,
isPublic: false,
usageCount: 1,
},
] satisfies LocalTag[],
links: [
{
id: 'link-welcome',
shortCode: 'welcome',
originalUrl: 'https://ulo.ad',
title: 'Willkommen bei uLoad!',
description: 'Dein erster gekuerzter Link.',
isActive: true,
clickCount: 42,
folderId: 'folder-personal',
order: 0,
},
{
id: 'link-github',
shortCode: 'gh-demo',
originalUrl: 'https://github.com',
title: 'GitHub',
description: 'Beispiel-Link mit Tags',
isActive: true,
clickCount: 15,
folderId: 'folder-work',
order: 0,
},
{
id: 'link-docs',
shortCode: 'docs',
originalUrl: 'https://docs.example.com/getting-started',
title: 'Dokumentation',
description: 'Link mit UTM-Tracking',
isActive: true,
clickCount: 8,
utmSource: 'newsletter',
utmMedium: 'email',
utmCampaign: 'onboarding',
folderId: 'folder-work',
order: 1,
},
{
id: 'link-expired',
shortCode: 'old-promo',
originalUrl: 'https://example.com/promo',
title: 'Abgelaufene Promotion',
description: 'Dieser Link ist deaktiviert.',
isActive: false,
clickCount: 234,
folderId: 'folder-personal',
order: 1,
},
] satisfies LocalLink[],
linkTags: [
{ id: 'lt-1', linkId: 'link-github', tagId: 'tag-social' },
{ id: 'lt-2', linkId: 'link-docs', tagId: 'tag-docs' },
{ id: 'lt-3', linkId: 'link-welcome', tagId: 'tag-social' },
{ id: 'lt-4', linkId: 'link-expired', tagId: 'tag-marketing' },
] satisfies LocalLinkTag[],
};

View file

@ -0,0 +1,33 @@
/**
* uLoad module barrel exports.
*/
export {
linkTable,
uloadTagTable,
uloadFolderTable,
linkTagTable,
ULOAD_GUEST_SEED,
} from './collections';
export {
useAllLinks,
useAllTags,
useAllFolders,
useAllLinkTags,
useLinkById,
toLink,
toTag,
toFolder,
toLinkTag,
getFilteredLinks,
getSortedLinks,
getLinkById,
getTagById,
getFolderById,
getTagUsageCount,
getLinkTags,
generateShortCode,
slugify,
} from './queries';
export type { LocalLink, LocalTag, LocalFolder, LocalLinkTag } from './types';
export type { Link, Tag, Folder, LinkTag, StatusFilter, LinkFilterCriteria } from './queries';

View file

@ -0,0 +1,263 @@
/**
* Reactive Queries & Pure Helpers for uLoad module.
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs).
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalLink, LocalTag, LocalFolder, LocalLinkTag } from './types';
// ─── Shared View Types ────────────────────────────────────
export interface Link {
id: string;
shortCode: string;
customCode?: string;
originalUrl: string;
title?: string;
description?: string;
isActive: boolean;
password?: string;
maxClicks?: number;
expiresAt?: string;
clickCount: number;
qrCodeUrl?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
folderId?: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface Tag {
id: string;
name: string;
slug: string;
color?: string;
icon?: string;
isPublic: boolean;
usageCount: number;
createdAt: string;
updatedAt: string;
}
export interface Folder {
id: string;
name: string;
color?: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface LinkTag {
id: string;
linkId: string;
tagId: string;
}
export type StatusFilter = 'all' | 'active' | 'inactive';
export interface LinkFilterCriteria {
search?: string;
status?: StatusFilter;
folderId?: string | null;
}
// ─── Type Converters ───────────────────────────────────────
export function toLink(local: LocalLink): Link {
return {
id: local.id,
shortCode: local.shortCode,
customCode: local.customCode ?? undefined,
originalUrl: local.originalUrl,
title: local.title ?? undefined,
description: local.description ?? undefined,
isActive: local.isActive,
password: local.password ?? undefined,
maxClicks: local.maxClicks ?? undefined,
expiresAt: local.expiresAt ?? undefined,
clickCount: local.clickCount,
qrCodeUrl: local.qrCodeUrl ?? undefined,
utmSource: local.utmSource ?? undefined,
utmMedium: local.utmMedium ?? undefined,
utmCampaign: local.utmCampaign ?? undefined,
folderId: local.folderId ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTag(local: LocalTag): Tag {
return {
id: local.id,
name: local.name,
slug: local.slug,
color: local.color ?? undefined,
icon: local.icon ?? undefined,
isPublic: local.isPublic,
usageCount: local.usageCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toFolder(local: LocalFolder): Folder {
return {
id: local.id,
name: local.name,
color: local.color ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toLinkTag(local: LocalLinkTag): LinkTag {
return {
id: local.id,
linkId: local.linkId,
tagId: local.tagId,
};
}
// ─── Raw Observable Queries ───────────────────────────────
export function allLinks$() {
return liveQuery(async () => {
const locals = await db.table<LocalLink>('links').toArray();
return locals.filter((l) => !l.deletedAt).map(toLink);
});
}
export function allTags$() {
return liveQuery(async () => {
const locals = await db.table<LocalTag>('uloadTags').toArray();
return locals.filter((t) => !t.deletedAt).map(toTag);
});
}
export function allFolders$() {
return liveQuery(async () => {
const locals = await db.table<LocalFolder>('uloadFolders').toArray();
return locals.filter((f) => !f.deletedAt).map(toFolder);
});
}
export function allLinkTags$() {
return liveQuery(async () => {
const locals = await db.table<LocalLinkTag>('linkTags').toArray();
return locals.filter((lt) => !lt.deletedAt).map(toLinkTag);
});
}
// ─── Svelte 5 Reactive Hooks ──────────────────────────────
export function useAllLinks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalLink>('links').toArray();
return locals.filter((l) => !l.deletedAt).map(toLink);
}, [] as Link[]);
}
export function useAllTags() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTag>('uloadTags').toArray();
return locals.filter((t) => !t.deletedAt).map(toTag);
}, [] as Tag[]);
}
export function useAllFolders() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalFolder>('uloadFolders').orderBy('order').toArray();
return locals.filter((f) => !f.deletedAt).map(toFolder);
}, [] as Folder[]);
}
export function useAllLinkTags() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalLinkTag>('linkTags').toArray();
return locals.filter((lt) => !lt.deletedAt).map(toLinkTag);
}, [] as LinkTag[]);
}
export function useLinkById(id: string) {
return useLiveQueryWithDefault(
async () => {
if (!id) return null;
const local = await db.table<LocalLink>('links').get(id);
if (!local || local.deletedAt) return null;
return toLink(local);
},
null as Link | null
);
}
// ─── Pure Filter / Sort Helpers ───────────────────────────
export function getFilteredLinks(links: Link[], filters: LinkFilterCriteria): Link[] {
let result = links;
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter(
(l) =>
l.title?.toLowerCase().includes(q) ||
l.originalUrl.toLowerCase().includes(q) ||
l.shortCode.toLowerCase().includes(q)
);
}
if (filters.status === 'active') result = result.filter((l) => l.isActive);
if (filters.status === 'inactive') result = result.filter((l) => !l.isActive);
if (filters.folderId) result = result.filter((l) => l.folderId === filters.folderId);
return result;
}
export function getSortedLinks(links: Link[]): Link[] {
return [...links].sort((a, b) => a.order - b.order);
}
export function getLinkById(links: Link[], id: string): Link | undefined {
return links.find((l) => l.id === id);
}
export function getTagById(tags: Tag[], id: string): Tag | undefined {
return tags.find((t) => t.id === id);
}
export function getFolderById(folders: Folder[], id: string): Folder | undefined {
return folders.find((f) => f.id === id);
}
export function getTagUsageCount(linkTags: LinkTag[], tagId: string): number {
return linkTags.filter((lt) => lt.tagId === tagId).length;
}
export function getLinkTags(linkTags: LinkTag[], tags: Tag[], linkId: string): Tag[] {
const tagIds = linkTags.filter((lt) => lt.linkId === linkId).map((lt) => lt.tagId);
return tags.filter((t) => tagIds.includes(t.id));
}
export function generateShortCode(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let code = '';
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
export function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}

View file

@ -0,0 +1,44 @@
/**
* uLoad module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalLink extends BaseRecord {
shortCode: string;
customCode?: string | null;
originalUrl: string;
title?: string | null;
description?: string | null;
isActive: boolean;
password?: string | null;
maxClicks?: number | null;
expiresAt?: string | null;
clickCount: number;
qrCodeUrl?: string | null;
utmSource?: string | null;
utmMedium?: string | null;
utmCampaign?: string | null;
folderId?: string | null;
order: number;
}
export interface LocalTag extends BaseRecord {
name: string;
slug: string;
color?: string | null;
icon?: string | null;
isPublic: boolean;
usageCount: number;
}
export interface LocalFolder extends BaseRecord {
name: string;
color?: string | null;
order: number;
}
export interface LocalLinkTag extends BaseRecord {
linkId: string;
tagId: string;
}

View file

@ -0,0 +1,200 @@
<script lang="ts">
import { Folder, FileText, Plus } from '@manacore/shared-icons';
import {
useAllSpaces,
useAllDocuments,
getPinnedSpaces,
getDocumentStats,
} from '$lib/modules/context/queries';
import { documentTable } from '$lib/modules/context/collections';
const allSpaces = useAllSpaces();
const allDocuments = useAllDocuments();
const spaces = $derived(allSpaces.value);
const documents = $derived(allDocuments.value);
const pinnedSpaces = $derived(getPinnedSpaces(spaces));
const stats = $derived(getDocumentStats(documents));
const recentDocs = $derived(documents.slice(0, 6));
async function handleDeleteDoc(id: string) {
if (!confirm('Dokument wirklich loeschen?')) return;
await documentTable.delete(id);
}
async function handleTogglePinDoc(id: string) {
const doc = documents.find((d) => d.id === id);
if (doc) {
await documentTable.update(id, { pinned: !doc.pinned });
}
}
</script>
<svelte:head>
<title>Context - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-5xl">
<header class="mb-8">
<h1 class="text-2xl font-bold">Context</h1>
<p class="mt-1 text-sm opacity-60">Dein Wissensmanagement Hub</p>
</header>
<!-- Stats -->
<div class="mb-8 grid grid-cols-2 gap-4 md:grid-cols-4">
<div
class="rounded-xl border border-gray-200 bg-white p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<div class="text-2xl font-bold">{spaces.length}</div>
<div class="mt-1 text-xs opacity-60">Spaces</div>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<div class="text-2xl font-bold">{stats.total}</div>
<div class="mt-1 text-xs opacity-60">Dokumente</div>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<div class="text-2xl font-bold">{stats.totalWords.toLocaleString()}</div>
<div class="mt-1 text-xs opacity-60">Woerter</div>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-4 text-center dark:border-gray-700 dark:bg-gray-800"
>
<div class="text-2xl font-bold">{stats.text}/{stats.context}/{stats.prompt}</div>
<div class="mt-1 text-xs opacity-60">Text/Kontext/Prompt</div>
</div>
</div>
<!-- Quick Actions -->
<div class="mb-8 flex gap-3">
<a
href="/context/spaces"
class="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
>
<Folder size={16} />
Spaces
</a>
<a
href="/context/documents"
class="flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium transition-colors hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
<FileText size={16} />
Alle Dokumente
</a>
</div>
<!-- Pinned Spaces -->
{#if pinnedSpaces.length > 0}
<section class="mb-8">
<h2 class="mb-4 text-lg font-semibold">Angeheftete Spaces</h2>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{#each pinnedSpaces as space}
<a
href="/context/spaces/{space.id}"
class="rounded-xl border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center gap-3">
<span
class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-100 text-lg font-bold text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
>
{space.prefix || space.name[0]?.toUpperCase() || 'S'}
</span>
<div>
<h3 class="font-semibold">{space.name}</h3>
{#if space.description}
<p class="text-xs opacity-60 line-clamp-1">{space.description}</p>
{/if}
</div>
</div>
</a>
{/each}
</div>
</section>
{/if}
<!-- Recent Documents -->
{#if recentDocs.length > 0}
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Zuletzt bearbeitet</h2>
<a href="/context/documents" class="text-sm text-indigo-600 hover:underline"
>Alle anzeigen</a
>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each recentDocs as doc}
<a
href="/context/documents/{doc.id}"
class="group rounded-xl border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase {doc.type ===
'text'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: doc.type === 'context'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'}"
>
{doc.type}
</span>
{#if doc.pinned}
<span class="text-xs opacity-40">Angeheftet</span>
{/if}
</div>
<h3 class="mt-1 truncate font-semibold">{doc.title}</h3>
{#if doc.content}
<p class="mt-0.5 truncate text-xs opacity-50">
{doc.content.slice(0, 100)}
</p>
{/if}
</div>
<button
onclick={(e) => {
e.preventDefault();
handleTogglePinDoc(doc.id);
}}
class="ml-2 rounded p-1 opacity-0 transition-opacity hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title={doc.pinned ? 'Loslassen' : 'Anheften'}
>
{doc.pinned ? '&#9733;' : '&#9734;'}
</button>
</div>
<div class="mt-2 flex items-center gap-3 text-xs opacity-40">
{#if doc.metadata?.tags && doc.metadata.tags.length > 0}
{#each doc.metadata.tags.slice(0, 3) as tag}
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">{tag}</span>
{/each}
{/if}
<span class="ml-auto">
{new Date(doc.updated_at).toLocaleDateString('de')}
</span>
</div>
</a>
{/each}
</div>
</section>
{:else}
<div
class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-600"
>
<FileText size={48} class="mx-auto mb-4 opacity-20" />
<h3 class="text-lg font-medium opacity-60">Noch keine Dokumente</h3>
<p class="mt-1 text-sm opacity-40">
Erstelle deinen ersten Space und beginne mit dem Schreiben.
</p>
<a
href="/context/spaces"
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
<Plus size={16} />
Ersten Space erstellen
</a>
</div>
{/if}
</div>

View file

@ -0,0 +1,268 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Plus, MagnifyingGlass, FileText } from '@manacore/shared-icons';
import {
useAllDocuments,
filterDocuments,
getDocumentStats,
getAllDocumentTags,
} from '$lib/modules/context/queries';
import { documentTable } from '$lib/modules/context/collections';
import type { DocumentType } from '$lib/modules/context/types';
let searchQuery = $state('');
let typeFilter = $state<DocumentType | 'all'>('all');
let tagFilter = $state<string[]>([]);
let deleteTarget = $state<string | null>(null);
const allDocuments = useAllDocuments();
const documents = $derived(allDocuments.value);
const stats = $derived(getDocumentStats(documents));
const allTags = $derived(getAllDocumentTags(documents));
const filteredDocuments = $derived(
filterDocuments(documents, { typeFilter, searchQuery, tagFilter })
);
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'text', label: 'Text' },
{ value: 'context', label: 'Kontext' },
{ value: 'prompt', label: 'Prompt' },
];
async function handleCreateDocument() {
const id = crypto.randomUUID();
await documentTable.add({
id,
spaceId: null,
title: 'Neues Dokument',
content: '# Neues Dokument\n\n',
type: 'text',
shortId: null,
pinned: false,
metadata: null,
});
goto(`/context/documents/${id}`);
}
async function handleDeleteDoc(id: string) {
await documentTable.delete(id);
deleteTarget = null;
}
async function handleTogglePin(id: string) {
const doc = documents.find((d) => d.id === id);
if (doc) {
await documentTable.update(id, { pinned: !doc.pinned });
}
}
function toggleTag(tag: string) {
if (tagFilter.includes(tag)) {
tagFilter = tagFilter.filter((t) => t !== tag);
} else {
tagFilter = [...tagFilter, tag];
}
}
function resetFilters() {
searchQuery = '';
typeFilter = 'all';
tagFilter = [];
}
</script>
<svelte:head>
<title>Dokumente - Context - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<a href="/context" class="text-sm opacity-60 hover:opacity-100">&larr; Context</a>
<h1 class="text-2xl font-bold">Dokumente</h1>
</div>
<p class="mt-1 text-sm opacity-60">
{stats.total} Dokumente, {stats.totalWords.toLocaleString()} Woerter
</p>
</div>
<button
class="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
onclick={handleCreateDocument}
>
<Plus size={16} />
Neues Dokument
</button>
</div>
<!-- Filters -->
<div class="mb-6 flex items-center gap-4">
<div class="flex gap-2">
{#each typeFilters as filter}
<button
class="rounded-lg px-3 py-1.5 text-sm transition-colors {typeFilter === filter.value
? 'bg-indigo-600 text-white'
: 'opacity-60 hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => (typeFilter = filter.value)}
>
{filter.label}
{#if filter.value === 'all'}
<span class="ml-1 opacity-60">{stats.total}</span>
{:else if filter.value === 'text'}
<span class="ml-1 opacity-60">{stats.text}</span>
{:else if filter.value === 'context'}
<span class="ml-1 opacity-60">{stats.context}</span>
{:else if filter.value === 'prompt'}
<span class="ml-1 opacity-60">{stats.prompt}</span>
{/if}
</button>
{/each}
</div>
<div class="relative ml-auto max-w-xs flex-1">
<MagnifyingGlass size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-40" />
<input
type="text"
bind:value={searchQuery}
placeholder="Dokumente durchsuchen..."
class="w-full rounded-lg border border-gray-300 bg-white py-2 pl-8 pr-3 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700"
/>
</div>
</div>
<!-- Tags filter -->
{#if allTags.length > 0}
<div class="mb-4 flex flex-wrap gap-2">
{#each allTags as tag}
<button
class="rounded-full px-2 py-1 text-xs transition-colors {tagFilter.includes(tag)
? 'bg-indigo-600 text-white'
: 'bg-gray-100 opacity-60 hover:opacity-100 dark:bg-gray-700'}"
onclick={() => toggleTag(tag)}
>
{tag}
</button>
{/each}
</div>
{/if}
<!-- Document list -->
{#if filteredDocuments.length > 0}
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each filteredDocuments as doc (doc.id)}
<div
class="group rounded-xl border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between">
<a href="/context/documents/{doc.id}" class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase {doc.type ===
'text'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: doc.type === 'context'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'}"
>
{doc.type}
</span>
{#if doc.pinned}
<span class="text-xs opacity-40">Angeheftet</span>
{/if}
</div>
<h3 class="mt-1 truncate font-semibold">{doc.title}</h3>
{#if doc.content}
<p class="mt-0.5 truncate text-xs opacity-50">
{doc.content.slice(0, 100)}
</p>
{/if}
</a>
<div
class="ml-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={() => handleTogglePin(doc.id)}
class="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
title={doc.pinned ? 'Loslassen' : 'Anheften'}
>
{doc.pinned ? '&#9733;' : '&#9734;'}
</button>
<button
onclick={() => (deleteTarget = doc.id)}
class="rounded p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
title="Loeschen"
>
&times;
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-3 text-xs opacity-40">
{#if doc.metadata?.tags && doc.metadata.tags.length > 0}
{#each doc.metadata.tags.slice(0, 3) as tag}
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">{tag}</span>
{/each}
{/if}
<span class="ml-auto">
{new Date(doc.updated_at).toLocaleDateString('de')}
</span>
</div>
</div>
{/each}
</div>
{:else if searchQuery || typeFilter !== 'all' || tagFilter.length > 0}
<div class="py-12 text-center">
<p class="opacity-60">Keine Dokumente gefunden</p>
<button class="mt-2 text-sm text-indigo-600 hover:underline" onclick={resetFilters}>
Filter zuruecksetzen
</button>
</div>
{:else}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-300 py-16 text-center dark:border-gray-600"
>
<FileText size={48} class="mb-4 opacity-20" />
<h2 class="text-lg font-medium opacity-60">Noch keine Dokumente</h2>
<p class="mt-1 max-w-md text-sm opacity-40">
Dokumente enthalten dein Wissen, Kontext-Referenzen und AI-Prompts.
</p>
<button
class="mt-4 flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
onclick={handleCreateDocument}
>
<Plus size={16} />
Erstes Dokument erstellen
</button>
</div>
{/if}
</div>
<!-- Delete Confirmation -->
{#if deleteTarget}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (deleteTarget = null)}
>
<div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
>
<h3 class="text-lg font-semibold">Dokument loeschen?</h3>
<p class="mt-2 text-sm opacity-60">Das Dokument wird unwiderruflich geloescht.</p>
<div class="mt-4 flex justify-end gap-2">
<button
onclick={() => (deleteTarget = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={() => deleteTarget && handleDeleteDoc(deleteTarget)}
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Loeschen
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,232 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { ArrowLeft, Trash } from '@manacore/shared-icons';
import { useAllDocuments, findDocumentById } from '$lib/modules/context/queries';
import { documentTable } from '$lib/modules/context/collections';
import type { DocumentType } from '$lib/modules/context/types';
let showDeleteConfirm = $state(false);
let saving = $state(false);
let docId = $derived($page.params.id || '');
const allDocuments = useAllDocuments();
const doc = $derived(findDocumentById(allDocuments.value, docId) ?? null);
// Local editing state
let editTitle = $state('');
let editContent = $state('');
let editType = $state<DocumentType>('text');
let editTags = $state('');
let initialized = $state(false);
// Initialize edit state from document
$effect(() => {
if (doc && !initialized) {
editTitle = doc.title;
editContent = doc.content || '';
editType = doc.type;
editTags = doc.metadata?.tags?.join(', ') || '';
initialized = true;
}
});
// Reset when navigating to a different document
$effect(() => {
if (docId) {
initialized = false;
}
});
async function handleSave() {
if (!doc) return;
saving = true;
const tags = editTags
.split(',')
.map((t) => t.trim())
.filter(Boolean);
const wordCount = editContent.split(/\s+/).filter(Boolean).length;
await documentTable.update(docId, {
title: editTitle,
content: editContent,
type: editType,
metadata: {
...(doc.metadata || {}),
tags,
word_count: wordCount,
},
});
saving = false;
}
async function handleDelete() {
await documentTable.delete(docId);
if (doc?.space_id) {
goto(`/context/spaces/${doc.space_id}`);
} else {
goto('/context/documents');
}
}
// Auto-save on content change (debounced)
let saveTimeout: ReturnType<typeof setTimeout> | undefined;
function scheduleAutoSave() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(handleSave, 1500);
}
const typeOptions: { value: DocumentType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'context', label: 'Kontext' },
{ value: 'prompt', label: 'Prompt' },
];
</script>
<svelte:head>
<title>{doc?.title || 'Dokument'} - Context - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl pb-24">
{#if !doc}
<div class="py-12 text-center opacity-60">Lade Dokument...</div>
{:else}
<!-- Breadcrumb -->
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2 text-sm">
{#if doc.space_id}
<a
href="/context/spaces/{doc.space_id}"
class="flex items-center gap-1 opacity-60 hover:opacity-100"
>
<ArrowLeft size={14} />
Zurueck zum Space
</a>
{:else}
<a href="/context/documents" class="flex items-center gap-1 opacity-60 hover:opacity-100">
<ArrowLeft size={14} />
Alle Dokumente
</a>
{/if}
</div>
<div class="flex items-center gap-2">
{#if saving}
<span class="text-xs opacity-40">Speichert...</span>
{/if}
<button
onclick={handleSave}
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
Speichern
</button>
<button
class="rounded-lg p-2 opacity-60 transition-colors hover:bg-red-50 hover:text-red-600 hover:opacity-100 dark:hover:bg-red-900/20"
onclick={() => (showDeleteConfirm = true)}
title="Dokument loeschen"
>
<Trash size={18} />
</button>
</div>
</div>
<!-- Editor -->
<div
class="rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<!-- Title -->
<input
type="text"
bind:value={editTitle}
oninput={scheduleAutoSave}
placeholder="Dokumenttitel"
class="mb-4 w-full border-none bg-transparent text-2xl font-bold outline-none placeholder:opacity-30"
/>
<!-- Type + Tags bar -->
<div
class="mb-4 flex flex-wrap items-center gap-3 border-b border-gray-100 pb-4 dark:border-gray-700"
>
<div class="flex gap-1">
{#each typeOptions as opt}
<button
class="rounded-md px-2.5 py-1 text-xs font-medium transition-colors {editType ===
opt.value
? 'bg-indigo-600 text-white'
: 'bg-gray-100 opacity-60 hover:opacity-100 dark:bg-gray-700'}"
onclick={() => {
editType = opt.value;
scheduleAutoSave();
}}
>
{opt.label}
</button>
{/each}
</div>
<div class="h-4 w-px bg-gray-200 dark:bg-gray-600"></div>
<input
type="text"
bind:value={editTags}
oninput={scheduleAutoSave}
placeholder="Tags (komma-getrennt)"
class="flex-1 border-none bg-transparent text-sm outline-none placeholder:opacity-30"
/>
</div>
<!-- Content -->
<textarea
bind:value={editContent}
oninput={scheduleAutoSave}
rows="20"
placeholder="Schreibe hier..."
class="w-full resize-none border-none bg-transparent font-mono text-sm leading-relaxed outline-none placeholder:opacity-30"
></textarea>
</div>
<!-- Document metadata -->
<div class="mt-4 flex items-center gap-4 text-xs opacity-40">
{#if doc.short_id}
<span>ID: {doc.short_id}</span>
{/if}
{#if doc.metadata?.word_count}
<span>{doc.metadata.word_count} Woerter</span>
{/if}
<span>
Erstellt: {new Date(doc.created_at).toLocaleDateString('de')}
</span>
<span>
Aktualisiert: {new Date(doc.updated_at).toLocaleDateString('de')}
</span>
</div>
{/if}
</div>
<!-- Delete Confirmation -->
{#if showDeleteConfirm}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (showDeleteConfirm = false)}
>
<div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
>
<h3 class="text-lg font-semibold">Dokument loeschen?</h3>
<p class="mt-2 text-sm opacity-60">Das Dokument wird unwiderruflich geloescht.</p>
<div class="mt-4 flex justify-end gap-2">
<button
onclick={() => (showDeleteConfirm = false)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleDelete}
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Loeschen
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,251 @@
<script lang="ts">
import { Plus, MagnifyingGlass } from '@manacore/shared-icons';
import { useAllSpaces } from '$lib/modules/context/queries';
import { contextSpaceTable } from '$lib/modules/context/collections';
import type { LocalContextSpace } from '$lib/modules/context/types';
let searchQuery = $state('');
let showCreateForm = $state(false);
let newName = $state('');
let newDescription = $state('');
let newPrefix = $state('');
let deleteTarget = $state<string | null>(null);
const allSpaces = useAllSpaces();
const spaces = $derived(allSpaces.value);
const filteredSpaces = $derived(
searchQuery.trim()
? spaces.filter(
(s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: spaces
);
async function handleCreate() {
if (!newName.trim()) return;
const prefix = newPrefix.trim() || newName.trim()[0]?.toUpperCase() || 'S';
await contextSpaceTable.add({
id: crypto.randomUUID(),
name: newName.trim(),
description: newDescription.trim() || null,
pinned: false,
prefix,
} satisfies LocalContextSpace);
newName = '';
newDescription = '';
newPrefix = '';
showCreateForm = false;
}
async function handleTogglePin(id: string) {
const space = spaces.find((s) => s.id === id);
if (space) {
await contextSpaceTable.update(id, { pinned: !space.pinned });
}
}
async function handleDelete(id: string) {
await contextSpaceTable.delete(id);
deleteTarget = null;
}
const inputClass =
'w-full rounded-lg border border-gray-300 bg-white px-4 py-3 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700';
</script>
<svelte:head>
<title>Spaces - Context - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<a href="/context" class="text-sm opacity-60 hover:opacity-100">&larr; Context</a>
<h1 class="text-2xl font-bold">Spaces</h1>
</div>
<button
class="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
onclick={() => (showCreateForm = !showCreateForm)}
>
<Plus size={16} />
Neuer Space
</button>
</div>
<!-- Create Form -->
{#if showCreateForm}
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<h3 class="mb-4 text-lg font-semibold">Neuen Space erstellen</h3>
<div class="space-y-4">
<div>
<label for="space-name" class="mb-1 block text-sm font-medium">Name</label>
<input
id="space-name"
type="text"
bind:value={newName}
placeholder="Mein Workspace"
class={inputClass}
onkeydown={(e) => e.key === 'Enter' && handleCreate()}
/>
</div>
<div>
<label for="space-desc" class="mb-1 block text-sm font-medium"
>Beschreibung (optional)</label
>
<textarea
id="space-desc"
bind:value={newDescription}
rows="2"
placeholder="Worum geht es in diesem Space?"
class="{inputClass} resize-none"
></textarea>
</div>
<div>
<label for="space-prefix" class="mb-1 block text-sm font-medium">Prefix (optional)</label>
<input
id="space-prefix"
type="text"
bind:value={newPrefix}
placeholder="W"
maxlength="3"
class="{inputClass} max-w-[100px]"
/>
</div>
<div class="flex justify-end gap-2">
<button
onclick={() => (showCreateForm = false)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={handleCreate}
disabled={!newName.trim()}
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
</div>
{/if}
<!-- Search -->
<div class="relative mb-6">
<MagnifyingGlass size={16} class="absolute left-3 top-1/2 -translate-y-1/2 opacity-40" />
<input
type="text"
bind:value={searchQuery}
placeholder="Spaces durchsuchen..."
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-9 pr-4 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700"
/>
</div>
{#if filteredSpaces.length > 0}
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each filteredSpaces as space (space.id)}
<div
class="group rounded-xl border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between">
<a href="/context/spaces/{space.id}" class="min-w-0 flex-1">
<div class="flex items-center gap-3">
<span
class="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-100 text-lg font-bold text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
>
{space.prefix || space.name[0]?.toUpperCase() || 'S'}
</span>
<div>
<h3 class="font-semibold">{space.name}</h3>
{#if space.description}
<p class="text-xs opacity-60 line-clamp-2">{space.description}</p>
{/if}
</div>
</div>
</a>
<div
class="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={() => handleTogglePin(space.id)}
class="rounded p-1.5 hover:bg-gray-100 dark:hover:bg-gray-700"
title={space.pinned ? 'Loslassen' : 'Anheften'}
>
{space.pinned ? '&#9733;' : '&#9734;'}
</button>
<button
onclick={() => (deleteTarget = space.id)}
class="rounded p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
title="Loeschen"
>
&times;
</button>
</div>
</div>
<div class="mt-3 text-xs opacity-40">
Erstellt: {new Date(space.created_at).toLocaleDateString('de')}
</div>
</div>
{/each}
</div>
{:else if searchQuery}
<div class="py-12 text-center">
<p class="opacity-60">Keine Spaces gefunden fuer "{searchQuery}"</p>
</div>
{:else}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-300 py-16 text-center dark:border-gray-600"
>
<Plus size={48} class="mb-4 opacity-20" />
<h2 class="text-lg font-medium opacity-60">Noch keine Spaces</h2>
<p class="mt-1 max-w-md text-sm opacity-40">
Spaces helfen dir, dein Wissen zu organisieren. Erstelle deinen ersten Space, um loszulegen.
</p>
<button
class="mt-4 flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
onclick={() => (showCreateForm = true)}
>
<Plus size={16} />
Ersten Space erstellen
</button>
</div>
{/if}
</div>
<!-- Delete Confirmation -->
{#if deleteTarget}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (deleteTarget = null)}
>
<div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
>
<h3 class="text-lg font-semibold">Space loeschen?</h3>
<p class="mt-2 text-sm opacity-60">
Alle Dokumente in diesem Space werden ebenfalls geloescht. Diese Aktion kann nicht
rueckgaengig gemacht werden.
</p>
<div class="mt-4 flex justify-end gap-2">
<button
onclick={() => (deleteTarget = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={() => deleteTarget && handleDelete(deleteTarget)}
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Loeschen
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,277 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Plus, ArrowLeft, PencilSimple, Check, X, MagnifyingGlass } from '@manacore/shared-icons';
import {
useAllSpaces,
useSpaceDocuments,
filterDocuments,
getDocumentStats,
findSpaceById,
} from '$lib/modules/context/queries';
import { contextSpaceTable, documentTable } from '$lib/modules/context/collections';
import type { DocumentType } from '$lib/modules/context/types';
let editingName = $state(false);
let editName = $state('');
let editDescription = $state('');
let deleteTarget = $state<string | null>(null);
let searchQuery = $state('');
let typeFilter = $state<DocumentType | 'all'>('all');
let spaceId = $derived($page.params.id || '');
const allSpaces = useAllSpaces();
const spaceDocs = $derived(useSpaceDocuments(spaceId));
const space = $derived(findSpaceById(allSpaces.value, spaceId) ?? null);
const documents = $derived(spaceDocs.value);
const stats = $derived(getDocumentStats(documents));
const filteredDocuments = $derived(filterDocuments(documents, { typeFilter, searchQuery }));
$effect(() => {
if (space && !editingName) {
editName = space.name;
editDescription = space.description || '';
}
});
async function handleCreateDocument() {
const id = crypto.randomUUID();
await documentTable.add({
id,
spaceId,
title: 'Neues Dokument',
content: '# Neues Dokument\n\n',
type: 'text',
shortId: null,
pinned: false,
metadata: null,
});
goto(`/context/documents/${id}`);
}
function startEdit() {
editingName = true;
editName = space?.name || '';
editDescription = space?.description || '';
}
async function saveEdit() {
if (!space) return;
await contextSpaceTable.update(space.id, {
name: editName,
description: editDescription || null,
});
editingName = false;
}
function cancelEdit() {
editingName = false;
editName = space?.name || '';
editDescription = space?.description || '';
}
async function handleDeleteDoc(id: string) {
if (!confirm('Dokument wirklich loeschen?')) return;
await documentTable.delete(id);
}
async function handleTogglePinDoc(id: string) {
const doc = documents.find((d) => d.id === id);
if (doc) {
await documentTable.update(id, { pinned: !doc.pinned });
}
}
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'text', label: 'Text' },
{ value: 'context', label: 'Kontext' },
{ value: 'prompt', label: 'Prompt' },
];
</script>
<svelte:head>
<title>{space?.name || 'Space'} - Context - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<!-- Breadcrumb -->
<div class="mb-4 flex items-center gap-2 text-sm">
<a href="/context/spaces" class="flex items-center gap-1 opacity-60 hover:opacity-100">
<ArrowLeft size={14} />
Spaces
</a>
<span class="opacity-40">/</span>
<span class="font-medium">{space?.name || '...'}</span>
</div>
{#if !space}
<div class="py-12 text-center opacity-60">Lade...</div>
{:else}
<!-- Space Header -->
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
{#if editingName}
<div class="space-y-3">
<input
type="text"
bind:value={editName}
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-xl font-bold focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700"
/>
<textarea
bind:value={editDescription}
rows="2"
placeholder="Beschreibung..."
class="w-full resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700"
></textarea>
<div class="flex gap-2">
<button
class="flex items-center gap-1 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
onclick={saveEdit}
>
<Check size={14} /> Speichern
</button>
<button
class="flex items-center gap-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
onclick={cancelEdit}
>
<X size={14} /> Abbrechen
</button>
</div>
</div>
{:else}
<div class="flex items-start justify-between">
<div>
<h1 class="text-xl font-bold">{space.name}</h1>
{#if space.description}
<p class="mt-1 text-sm opacity-60">{space.description}</p>
{/if}
<div class="mt-3 flex gap-4 text-xs opacity-50">
<span>{stats.total} Dokumente</span>
<span>{stats.totalWords.toLocaleString()} Woerter</span>
</div>
</div>
<button
class="rounded-lg p-2 opacity-60 transition-colors hover:bg-gray-100 hover:opacity-100 dark:hover:bg-gray-700"
onclick={startEdit}
title="Bearbeiten"
>
<PencilSimple size={18} />
</button>
</div>
{/if}
</div>
<!-- Toolbar -->
<div class="mb-4 flex items-center justify-between gap-4">
<div class="flex gap-2">
{#each typeFilters as filter}
<button
class="rounded-lg px-3 py-1.5 text-sm transition-colors {typeFilter === filter.value
? 'bg-indigo-600 text-white'
: 'opacity-60 hover:bg-gray-100 dark:hover:bg-gray-700'}"
onclick={() => (typeFilter = filter.value)}
>
{filter.label}
</button>
{/each}
</div>
<div class="flex items-center gap-2">
<div class="relative">
<MagnifyingGlass
size={14}
class="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-40"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="Suchen..."
class="w-48 rounded-lg border border-gray-300 bg-white py-1.5 pl-8 pr-3 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700"
/>
</div>
<button
class="flex items-center gap-1 rounded-lg bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
onclick={handleCreateDocument}
>
<Plus size={14} />
Neues Dokument
</button>
</div>
</div>
<!-- Documents -->
{#if filteredDocuments.length > 0}
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each filteredDocuments as doc (doc.id)}
<div
class="group rounded-xl border border-gray-200 bg-white p-4 transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between">
<a href="/context/documents/{doc.id}" class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium uppercase {doc.type ===
'text'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
: doc.type === 'context'
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300'}"
>
{doc.type}
</span>
{#if doc.pinned}
<span class="text-xs opacity-40">Angeheftet</span>
{/if}
</div>
<h3 class="mt-1 truncate font-semibold">{doc.title}</h3>
{#if doc.content}
<p class="mt-0.5 truncate text-xs opacity-50">
{doc.content.slice(0, 100)}
</p>
{/if}
</a>
<div
class="ml-2 flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<button
onclick={() => handleTogglePinDoc(doc.id)}
class="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
title={doc.pinned ? 'Loslassen' : 'Anheften'}
>
{doc.pinned ? '&#9733;' : '&#9734;'}
</button>
<button
onclick={() => handleDeleteDoc(doc.id)}
class="rounded p-1 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
title="Loeschen"
>
&times;
</button>
</div>
</div>
<div class="mt-2 text-xs opacity-40">
{new Date(doc.updated_at).toLocaleDateString('de')}
</div>
</div>
{/each}
</div>
{:else}
<div
class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-600"
>
<p class="opacity-60">Keine Dokumente in diesem Space</p>
<button
class="mt-4 flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 mx-auto"
onclick={handleCreateDocument}
>
<Plus size={16} />
Erstes Dokument erstellen
</button>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,260 @@
<script lang="ts">
import {
useAllMeals,
useAllGoals,
getTodaysMeals,
getDailySummary,
} from '$lib/modules/nutriphi/queries';
import {
MEAL_TYPE_LABELS,
NUTRIENT_INFO,
suggestMealType,
} from '$lib/modules/nutriphi/constants';
import type { MealWithNutrition, NutritionProgress } from '$lib/modules/nutriphi/types';
import { Plus, Clock, Fire } from '@manacore/shared-icons';
const allMeals = useAllMeals();
const allGoals = useAllGoals();
let meals = $derived(allMeals.current ?? []);
let goals = $derived((allGoals.current ?? [])[0] ?? null);
let todaysMeals = $derived(getTodaysMeals(meals));
let dailySummary = $derived(getDailySummary(meals, undefined, goals));
let progress = $derived(dailySummary.progress);
function getMealTypeLabel(type: string): string {
return MEAL_TYPE_LABELS[type as keyof typeof MEAL_TYPE_LABELS]?.de ?? type;
}
function formatTime(dateString: string): string {
return new Date(dateString).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
function getProgressColor(percentage: number): string {
if (percentage >= 100) return 'bg-green-500';
if (percentage >= 75) return 'bg-blue-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-gray-400';
}
</script>
<svelte:head>
<title>NutriPhi - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Heute</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
</div>
<div class="flex gap-2">
<a
href="/nutriphi/history"
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm font-medium text-[hsl(var(--foreground))] transition-colors hover:bg-[hsl(var(--muted))]"
>
Verlauf
</a>
<a
href="/nutriphi/add"
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
<Plus size={16} />
Mahlzeit
</a>
</div>
</div>
<!-- Progress Cards -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<!-- Calories -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="flex items-center gap-2">
<div
class="h-2 w-2 rounded-full"
style="background-color: {NUTRIENT_INFO.calories.color}"
></div>
<span class="text-xs font-medium text-[hsl(var(--muted-foreground))]">Kalorien</span>
</div>
<p class="mt-2 text-2xl font-bold text-[hsl(var(--foreground))]">
{progress.calories.current}
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
/ {progress.calories.target} kcal
</p>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {getProgressColor(
progress.calories.percentage
)}"
style="width: {Math.min(progress.calories.percentage, 100)}%"
></div>
</div>
</div>
<!-- Protein -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="flex items-center gap-2">
<div
class="h-2 w-2 rounded-full"
style="background-color: {NUTRIENT_INFO.protein.color}"
></div>
<span class="text-xs font-medium text-[hsl(var(--muted-foreground))]">Protein</span>
</div>
<p class="mt-2 text-2xl font-bold text-[hsl(var(--foreground))]">
{progress.protein.current}g
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
/ {progress.protein.target}g
</p>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {getProgressColor(progress.protein.percentage)}"
style="width: {Math.min(progress.protein.percentage, 100)}%"
></div>
</div>
</div>
<!-- Carbs -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="flex items-center gap-2">
<div
class="h-2 w-2 rounded-full"
style="background-color: {NUTRIENT_INFO.carbohydrates.color}"
></div>
<span class="text-xs font-medium text-[hsl(var(--muted-foreground))]">Kohlenhydrate</span>
</div>
<p class="mt-2 text-2xl font-bold text-[hsl(var(--foreground))]">
{progress.carbs.current}g
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
/ {progress.carbs.target}g
</p>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {getProgressColor(progress.carbs.percentage)}"
style="width: {Math.min(progress.carbs.percentage, 100)}%"
></div>
</div>
</div>
<!-- Fat -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full" style="background-color: {NUTRIENT_INFO.fat.color}"></div>
<span class="text-xs font-medium text-[hsl(var(--muted-foreground))]">Fett</span>
</div>
<p class="mt-2 text-2xl font-bold text-[hsl(var(--foreground))]">
{progress.fat.current}g
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
/ {progress.fat.target}g
</p>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {getProgressColor(progress.fat.percentage)}"
style="width: {Math.min(progress.fat.percentage, 100)}%"
></div>
</div>
</div>
</div>
<!-- Today's Meals -->
<div>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">Heutige Mahlzeiten</h2>
<span class="text-sm text-[hsl(var(--muted-foreground))]">
{todaysMeals.length} Eintraege
</span>
</div>
{#if todaysMeals.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-12"
>
<span class="mb-4 text-5xl">🍽️</span>
<h3 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">
Noch keine Mahlzeiten
</h3>
<p class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
Trage deine erste Mahlzeit ein.
</p>
<a
href="/nutriphi/add"
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Mahlzeit hinzufuegen
</a>
</div>
{:else}
<div class="space-y-3">
{#each todaysMeals as meal (meal.id)}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs font-medium text-[hsl(var(--muted-foreground))]"
>
{getMealTypeLabel(meal.mealType)}
</span>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatTime(meal.createdAt)}
</span>
</div>
<p class="mt-1 font-medium text-[hsl(var(--foreground))]">
{meal.description}
</p>
{#if meal.nutrition}
<div
class="mt-2 flex flex-wrap gap-3 text-xs text-[hsl(var(--muted-foreground))]"
>
<span>{meal.nutrition.calories} kcal</span>
<span>{meal.nutrition.protein}g Protein</span>
<span>{meal.nutrition.carbohydrates}g Carbs</span>
<span>{meal.nutrition.fat}g Fett</span>
</div>
{/if}
</div>
{#if meal.nutrition}
<span class="text-lg font-bold text-[hsl(var(--foreground))]">
{meal.nutrition.calories}
<span class="text-xs font-normal text-[hsl(var(--muted-foreground))]">kcal</span>
</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Quick Links -->
<div class="flex gap-3">
<a
href="/nutriphi/goals"
class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-center transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<span class="text-2xl">🎯</span>
<p class="mt-1 text-sm font-medium text-[hsl(var(--foreground))]">Ziele</p>
</a>
<a
href="/nutriphi/history"
class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-center transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<span class="text-2xl">📊</span>
<p class="mt-1 text-sm font-medium text-[hsl(var(--foreground))]">Verlauf</p>
</a>
</div>
</div>

View file

@ -0,0 +1,268 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { db } from '$lib/data/database';
import { useAllFavorites } from '$lib/modules/nutriphi/queries';
import { MEAL_TYPE_LABELS, suggestMealType } from '$lib/modules/nutriphi/constants';
import type { MealType, NutritionData } from '$lib/modules/nutriphi/types';
import { ArrowLeft } from '@manacore/shared-icons';
const allFavorites = useAllFavorites();
let favorites = $derived(allFavorites.current ?? []);
let mealType = $state<MealType>(suggestMealType());
let description = $state('');
let calories = $state<number | null>(null);
let protein = $state<number | null>(null);
let carbohydrates = $state<number | null>(null);
let fat = $state<number | null>(null);
let fiber = $state<number | null>(null);
let sugar = $state<number | null>(null);
let saving = $state(false);
let error = $state('');
const mealTypes: MealType[] = ['breakfast', 'lunch', 'dinner', 'snack'];
function applyFavorite(fav: {
description: string;
mealType: MealType;
nutrition: NutritionData;
}) {
description = fav.description;
mealType = fav.mealType;
calories = fav.nutrition.calories;
protein = fav.nutrition.protein;
carbohydrates = fav.nutrition.carbohydrates;
fat = fav.nutrition.fat;
fiber = fav.nutrition.fiber;
sugar = fav.nutrition.sugar;
}
async function handleSubmit() {
if (!description.trim()) {
error = 'Bitte beschreibe die Mahlzeit';
return;
}
saving = true;
error = '';
try {
const now = new Date().toISOString();
const today = new Date().toISOString().split('T')[0];
const nutrition: NutritionData | null =
calories !== null
? {
calories: calories ?? 0,
protein: protein ?? 0,
carbohydrates: carbohydrates ?? 0,
fat: fat ?? 0,
fiber: fiber ?? 0,
sugar: sugar ?? 0,
}
: null;
await db.table('meals').add({
id: crypto.randomUUID(),
date: today,
mealType,
inputType: 'text' as const,
description: description.trim(),
portionSize: null,
confidence: nutrition ? 0.8 : 0,
nutrition,
createdAt: now,
updatedAt: now,
});
goto('/nutriphi');
} catch {
error = 'Mahlzeit konnte nicht gespeichert werden';
saving = false;
}
}
</script>
<svelte:head>
<title>Mahlzeit hinzufuegen - NutriPhi - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div>
<a
href="/nutriphi"
class="mb-4 inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<ArrowLeft class="h-4 w-4" />
Zurueck
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Mahlzeit hinzufuegen</h1>
</div>
{#if error}
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
{/if}
<!-- Favorites -->
{#if favorites.length > 0}
<div>
<h3 class="mb-2 text-sm font-medium text-[hsl(var(--foreground))]">Favoriten</h3>
<div class="flex flex-wrap gap-2">
{#each favorites as fav (fav.id)}
<button
onclick={() => applyFavorite(fav)}
class="rounded-full border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--foreground))] transition-colors hover:bg-[hsl(var(--muted))]"
>
{fav.name}
</button>
{/each}
</div>
</div>
{/if}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 space-y-5">
<!-- Meal Type -->
<div>
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Mahlzeittyp
</label>
<div class="grid grid-cols-4 gap-2">
{#each mealTypes as type}
<button
type="button"
onclick={() => (mealType = type)}
class="rounded-lg border-2 px-3 py-2 text-sm transition-all
{mealType === type
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.05)] font-medium'
: 'border-[hsl(var(--border))] hover:border-[hsl(var(--primary)/0.3)]'}"
>
{MEAL_TYPE_LABELS[type].de}
</button>
{/each}
</div>
</div>
<!-- Description -->
<div>
<label for="meal-desc" class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Beschreibung
</label>
<textarea
id="meal-desc"
bind:value={description}
placeholder="Was hast du gegessen?"
rows="3"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
</div>
<!-- Nutrition -->
<div>
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
Naehrwerte <span class="text-[hsl(var(--muted-foreground))]">(optional)</span>
</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div>
<label for="n-cal" class="mb-1 block text-xs text-[hsl(var(--muted-foreground))]">
Kalorien (kcal)
</label>
<input
id="n-cal"
type="number"
bind:value={calories}
min="0"
placeholder="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label for="n-prot" class="mb-1 block text-xs text-[hsl(var(--muted-foreground))]">
Protein (g)
</label>
<input
id="n-prot"
type="number"
bind:value={protein}
min="0"
placeholder="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label for="n-carbs" class="mb-1 block text-xs text-[hsl(var(--muted-foreground))]">
Kohlenhydrate (g)
</label>
<input
id="n-carbs"
type="number"
bind:value={carbohydrates}
min="0"
placeholder="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label for="n-fat" class="mb-1 block text-xs text-[hsl(var(--muted-foreground))]">
Fett (g)
</label>
<input
id="n-fat"
type="number"
bind:value={fat}
min="0"
placeholder="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label for="n-fiber" class="mb-1 block text-xs text-[hsl(var(--muted-foreground))]">
Ballaststoffe (g)
</label>
<input
id="n-fiber"
type="number"
bind:value={fiber}
min="0"
placeholder="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label for="n-sugar" class="mb-1 block text-xs text-[hsl(var(--muted-foreground))]">
Zucker (g)
</label>
<input
id="n-sugar"
type="number"
bind:value={sugar}
min="0"
placeholder="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
</div>
</div>
<!-- Submit -->
<div class="flex gap-3 pt-2">
<a
href="/nutriphi"
class="flex-1 rounded-lg border border-[hsl(var(--border))] px-4 py-3 text-center text-sm font-medium text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
Abbrechen
</a>
<button
type="button"
onclick={handleSubmit}
disabled={saving || !description.trim()}
class="flex-1 rounded-lg bg-[hsl(var(--primary))] px-4 py-3 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{saving ? 'Speichert...' : 'Speichern'}
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,228 @@
<script lang="ts">
import { db } from '$lib/data/database';
import { useAllGoals } from '$lib/modules/nutriphi/queries';
import { DEFAULT_DAILY_VALUES, NUTRIENT_INFO } from '$lib/modules/nutriphi/constants';
import { ArrowLeft } from '@manacore/shared-icons';
const allGoals = useAllGoals();
let currentGoals = $derived((allGoals.current ?? [])[0] ?? null);
let dailyCalories = $state(DEFAULT_DAILY_VALUES.calories);
let dailyProtein = $state(DEFAULT_DAILY_VALUES.protein);
let dailyCarbs = $state(DEFAULT_DAILY_VALUES.carbohydrates);
let dailyFat = $state(DEFAULT_DAILY_VALUES.fat);
let dailyFiber = $state(DEFAULT_DAILY_VALUES.fiber);
let saving = $state(false);
let saved = $state(false);
// Load current goals when they become available
$effect(() => {
if (currentGoals) {
dailyCalories = currentGoals.dailyCalories ?? DEFAULT_DAILY_VALUES.calories;
dailyProtein = currentGoals.dailyProtein ?? DEFAULT_DAILY_VALUES.protein;
dailyCarbs = currentGoals.dailyCarbs ?? DEFAULT_DAILY_VALUES.carbohydrates;
dailyFat = currentGoals.dailyFat ?? DEFAULT_DAILY_VALUES.fat;
dailyFiber = currentGoals.dailyFiber ?? DEFAULT_DAILY_VALUES.fiber;
}
});
async function handleSave() {
saving = true;
saved = false;
try {
const now = new Date().toISOString();
const goalData = {
dailyCalories,
dailyProtein,
dailyCarbs,
dailyFat,
dailyFiber,
updatedAt: now,
};
if (currentGoals) {
await db.table('goals').update(currentGoals.id, goalData);
} else {
await db.table('goals').add({
id: crypto.randomUUID(),
...goalData,
createdAt: now,
});
}
saved = true;
setTimeout(() => (saved = false), 2000);
} finally {
saving = false;
}
}
function resetToDefaults() {
dailyCalories = DEFAULT_DAILY_VALUES.calories;
dailyProtein = DEFAULT_DAILY_VALUES.protein;
dailyCarbs = DEFAULT_DAILY_VALUES.carbohydrates;
dailyFat = DEFAULT_DAILY_VALUES.fat;
dailyFiber = DEFAULT_DAILY_VALUES.fiber;
}
</script>
<svelte:head>
<title>Ziele - NutriPhi - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div>
<a
href="/nutriphi"
class="mb-4 inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<ArrowLeft class="h-4 w-4" />
Zurueck
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Tagesziele</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">
Passe deine taeglichen Naehrwertziele an
</p>
</div>
{#if saved}
<div
class="rounded-lg bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/20 dark:text-green-400"
>
Ziele gespeichert!
</div>
{/if}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 space-y-5">
<!-- Calories -->
<div>
<label
for="g-cal"
class="mb-1 flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
>
<div
class="h-3 w-3 rounded-full"
style="background-color: {NUTRIENT_INFO.calories.color}"
></div>
Kalorien (kcal)
</label>
<input
id="g-cal"
type="number"
bind:value={dailyCalories}
min="0"
step="50"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Protein -->
<div>
<label
for="g-prot"
class="mb-1 flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
>
<div
class="h-3 w-3 rounded-full"
style="background-color: {NUTRIENT_INFO.protein.color}"
></div>
Protein (g)
</label>
<input
id="g-prot"
type="number"
bind:value={dailyProtein}
min="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Carbs -->
<div>
<label
for="g-carbs"
class="mb-1 flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
>
<div
class="h-3 w-3 rounded-full"
style="background-color: {NUTRIENT_INFO.carbohydrates.color}"
></div>
Kohlenhydrate (g)
</label>
<input
id="g-carbs"
type="number"
bind:value={dailyCarbs}
min="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Fat -->
<div>
<label
for="g-fat"
class="mb-1 flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
>
<div class="h-3 w-3 rounded-full" style="background-color: {NUTRIENT_INFO.fat.color}"></div>
Fett (g)
</label>
<input
id="g-fat"
type="number"
bind:value={dailyFat}
min="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Fiber -->
<div>
<label
for="g-fiber"
class="mb-1 flex items-center gap-2 text-sm font-medium text-[hsl(var(--foreground))]"
>
<div
class="h-3 w-3 rounded-full"
style="background-color: {NUTRIENT_INFO.fiber.color}"
></div>
Ballaststoffe (g)
</label>
<input
id="g-fiber"
type="number"
bind:value={dailyFiber}
min="0"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Actions -->
<div class="flex gap-3 pt-2">
<button
onclick={resetToDefaults}
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
Standardwerte
</button>
<button
onclick={handleSave}
disabled={saving}
class="flex-1 rounded-lg bg-[hsl(var(--primary))] px-4 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{saving ? 'Speichert...' : 'Ziele speichern'}
</button>
</div>
</div>
<!-- Info -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--muted)/0.3)] p-4">
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Die Standardwerte basieren auf einer 2000 kcal Diaet. Passe sie an deine individuellen
Beduerfnisse an. Konsultiere bei Bedarf einen Ernaehrungsberater.
</p>
</div>
</div>

View file

@ -0,0 +1,203 @@
<script lang="ts">
import {
useAllMeals,
useAllGoals,
filterByDate,
sumNutrition,
getDailySummary,
searchMeals,
} from '$lib/modules/nutriphi/queries';
import { MEAL_TYPE_LABELS } from '$lib/modules/nutriphi/constants';
import { db } from '$lib/data/database';
import type { MealWithNutrition } from '$lib/modules/nutriphi/types';
import { ArrowLeft, MagnifyingGlass, Trash } from '@manacore/shared-icons';
const allMeals = useAllMeals();
const allGoals = useAllGoals();
let meals = $derived(allMeals.current ?? []);
let goals = $derived((allGoals.current ?? [])[0] ?? null);
let searchQuery = $state('');
let selectedDate = $state('');
// Group meals by date, sorted descending
let filteredMeals = $derived.by(() => {
let result = meals;
if (searchQuery) result = searchMeals(result, searchQuery);
if (selectedDate) result = filterByDate(result, selectedDate);
return result;
});
let groupedByDate = $derived.by(() => {
const groups: Record<string, MealWithNutrition[]> = {};
for (const meal of filteredMeals) {
const dateKey = String(meal.date).split('T')[0];
if (!groups[dateKey]) groups[dateKey] = [];
groups[dateKey].push(meal);
}
return Object.entries(groups)
.sort(([a], [b]) => b.localeCompare(a))
.map(([date, meals]) => ({
date,
meals: meals.sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
),
totalCalories: sumNutrition(meals).calories,
}));
});
function formatDateHeader(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const yesterdayStr = yesterday.toISOString().split('T')[0];
if (dateStr === todayStr) return 'Heute';
if (dateStr === yesterdayStr) return 'Gestern';
return date.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
});
}
function getMealTypeLabel(type: string): string {
return MEAL_TYPE_LABELS[type as keyof typeof MEAL_TYPE_LABELS]?.de ?? type;
}
function formatTime(dateString: string): string {
return new Date(dateString).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
async function deleteMeal(id: string) {
if (!confirm('Mahlzeit loeschen?')) return;
await db.table('meals').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
</script>
<svelte:head>
<title>Verlauf - NutriPhi - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div>
<a
href="/nutriphi"
class="mb-4 inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<ArrowLeft class="h-4 w-4" />
Zurueck
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Mahlzeiten-Verlauf</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">
{meals.length} Eintraege insgesamt
</p>
</div>
<!-- Filters -->
<div class="flex gap-3">
<div class="relative flex-1">
<MagnifyingGlass
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="Mahlzeiten durchsuchen..."
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] py-2 pl-10 pr-4 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<input
type="date"
bind:value={selectedDate}
class="rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
{#if selectedDate}
<button
onclick={() => (selectedDate = '')}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]"
>
Reset
</button>
{/if}
</div>
<!-- Grouped Meals -->
{#if groupedByDate.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<span class="mb-4 text-5xl">📋</span>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">Keine Eintraege</h2>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{searchQuery || selectedDate
? 'Keine Ergebnisse fuer diese Filter.'
: 'Noch keine Mahlzeiten erfasst.'}
</p>
</div>
{:else}
<div class="space-y-6">
{#each groupedByDate as group (group.date)}
<div>
<div class="mb-2 flex items-center justify-between">
<h3 class="font-semibold text-[hsl(var(--foreground))]">
{formatDateHeader(group.date)}
</h3>
<span class="text-sm text-[hsl(var(--muted-foreground))]">
{Math.round(group.totalCalories)} kcal
</span>
</div>
<div class="space-y-2">
{#each group.meals as meal (meal.id)}
<div
class="group flex items-center gap-4 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-3"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] font-medium text-[hsl(var(--muted-foreground))]"
>
{getMealTypeLabel(meal.mealType)}
</span>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatTime(meal.createdAt)}
</span>
</div>
<p class="mt-1 text-sm text-[hsl(var(--foreground))] truncate">
{meal.description}
</p>
</div>
{#if meal.nutrition}
<span class="whitespace-nowrap text-sm font-medium text-[hsl(var(--foreground))]">
{meal.nutrition.calories} kcal
</span>
{/if}
<button
onclick={() => deleteMeal(meal.id)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
title="Loeschen"
>
<Trash size={16} />
</button>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,220 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { decksStore } from '$lib/modules/presi/stores/decks.svelte';
import { useAllDecks } from '$lib/modules/presi/queries';
import { Plus, Presentation, Trash, Clock } from '@manacore/shared-icons';
let showCreateModal = $state(false);
let showDeleteModal = $state(false);
let deckToDelete = $state<{ id: string; title: string } | null>(null);
let newDeckTitle = $state('');
let newDeckDescription = $state('');
let isCreating = $state(false);
const allDecks = useAllDecks();
let decks = $derived(allDecks.value ?? []);
async function handleCreateDeck(e: SubmitEvent) {
e.preventDefault();
if (!newDeckTitle.trim()) return;
isCreating = true;
const deck = await decksStore.createDeck({
title: newDeckTitle.trim(),
description: newDeckDescription.trim() || undefined,
});
if (deck) {
showCreateModal = false;
newDeckTitle = '';
newDeckDescription = '';
goto(`/presi/deck/${deck.id}`);
}
isCreating = false;
}
function confirmDelete(deck: { id: string; title: string }) {
deckToDelete = deck;
showDeleteModal = true;
}
async function handleDelete() {
if (!deckToDelete) return;
await decksStore.deleteDeck(deckToDelete.id);
showDeleteModal = false;
deckToDelete = null;
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>Presi - Presentations</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">My Presentations</h1>
<p class="text-slate-600 dark:text-slate-400 mt-1">Create and manage your slide decks</p>
</div>
<button
onclick={() => (showCreateModal = true)}
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" />
New Deck
</button>
</div>
{#if decks.length === 0}
<div class="text-center py-16">
<div
class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"
>
<Presentation class="w-8 h-8 text-slate-400" />
</div>
<h2 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No presentations yet</h2>
<p class="text-slate-600 dark:text-slate-400 mb-4">Create your first deck to get started</p>
<button
onclick={() => (showCreateModal = true)}
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" />
Create Deck
</button>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decks as deck (deck.id)}
<div
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-md transition-shadow"
>
<a href="/presi/deck/{deck.id}" class="block">
<div
class="aspect-video bg-gradient-to-br from-primary-500 to-primary-700 flex items-center justify-center"
>
<Presentation class="w-12 h-12 text-white/80" />
</div>
<div class="p-4">
<h3 class="font-semibold text-slate-900 dark:text-white truncate">{deck.title}</h3>
{#if deck.description}
<p class="text-sm text-slate-600 dark:text-slate-400 mt-1 line-clamp-2">
{deck.description}
</p>
{/if}
<div class="flex items-center gap-4 mt-3 text-xs text-slate-500 dark:text-slate-400">
<span class="flex items-center gap-1">
<Clock class="w-3.5 h-3.5" />
{formatDate(deck.updatedAt)}
</span>
</div>
</div>
</a>
<div class="px-4 pb-4 flex justify-end">
<button
onclick={(e) => {
e.preventDefault();
confirmDelete({ id: deck.id, title: deck.title });
}}
class="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
>
<Trash class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Deck Modal -->
{#if showCreateModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md">
<form onsubmit={handleCreateDeck}>
<div class="p-6">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-4">Create New Deck</h2>
<div class="space-y-4">
<div>
<label
for="title"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>Title</label
>
<input
type="text"
id="title"
bind:value={newDeckTitle}
required
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="My Presentation"
/>
</div>
<div>
<label
for="description"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>Description (optional)</label
>
<textarea
id="description"
bind:value={newDeckDescription}
rows="3"
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent resize-none"
placeholder="What is this presentation about?"
></textarea>
</div>
</div>
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-xl">
<button
type="button"
onclick={() => (showCreateModal = false)}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>Cancel</button
>
<button
type="submit"
disabled={isCreating || !newDeckTitle.trim()}
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50"
>{isCreating ? 'Creating...' : 'Create'}</button
>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Modal -->
{#if showDeleteModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-2">Delete Deck</h2>
<p class="text-slate-600 dark:text-slate-400 mb-6">
Are you sure you want to delete "{deckToDelete?.title}"?
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => {
showDeleteModal = false;
deckToDelete = null;
}}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>Cancel</button
>
<button
onclick={handleDelete}
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
>Delete</button
>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,388 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/modules/presi/stores/decks.svelte';
import { useDeck, useDeckSlides } from '$lib/modules/presi/queries';
import type { Slide, SlideContent } from '$lib/modules/presi/types';
import {
ArrowLeft,
Play,
Plus,
Trash,
CaretUp,
CaretDown,
Image,
TextT,
List,
PencilSimple,
X,
FloppyDisk,
} from '@manacore/shared-icons';
let showSlideModal = $state(false);
let editingSlide = $state<Slide | null>(null);
let showDeleteModal = $state(false);
let slideToDelete = $state<Slide | null>(null);
let slideTitle = $state('');
let slideBody = $state('');
let slideBulletPoints = $state<string[]>(['']);
let slideImageUrl = $state('');
let isSaving = $state(false);
const deckId = $page.params.id as string;
const deckQuery = useDeck(deckId);
const slidesQuery = useDeckSlides(deckId);
let currentDeck = $derived(deckQuery.value);
let currentSlides = $derived(slidesQuery.value ?? []);
function openCreateSlide() {
editingSlide = null;
slideTitle = '';
slideBody = '';
slideBulletPoints = [''];
slideImageUrl = '';
showSlideModal = true;
}
function openEditSlide(slide: Slide) {
editingSlide = slide;
slideTitle = slide.content.title || '';
slideBody = slide.content.body || '';
slideBulletPoints = slide.content.bulletPoints?.length ? [...slide.content.bulletPoints] : [''];
slideImageUrl = slide.content.imageUrl || '';
showSlideModal = true;
}
async function handleSaveSlide(e: SubmitEvent) {
e.preventDefault();
isSaving = true;
const content: SlideContent = {
type: slideImageUrl
? 'image'
: slideBulletPoints.filter((b) => b.trim()).length > 0
? 'content'
: 'title',
title: slideTitle || undefined,
body: slideBody || undefined,
bulletPoints: slideBulletPoints.filter((b) => b.trim()),
imageUrl: slideImageUrl || undefined,
};
if (editingSlide) {
await decksStore.updateSlide(editingSlide.id, { content });
} else {
await decksStore.createSlide(deckId, { content });
}
isSaving = false;
showSlideModal = false;
}
function confirmDeleteSlide(slide: Slide) {
slideToDelete = slide;
showDeleteModal = true;
}
async function handleDeleteSlide() {
if (!slideToDelete) return;
await decksStore.deleteSlide(slideToDelete.id);
showDeleteModal = false;
slideToDelete = null;
}
async function moveSlide(slide: Slide, direction: 'up' | 'down') {
const slides = currentSlides;
const currentIndex = slides.findIndex((s) => s.id === slide.id);
if (currentIndex === -1) return;
const newSlides = slides.map((s, i) => ({ id: s.id, order: i + 1 }));
if (direction === 'up' && currentIndex > 0) {
[newSlides[currentIndex], newSlides[currentIndex - 1]] = [
newSlides[currentIndex - 1],
newSlides[currentIndex],
];
} else if (direction === 'down' && currentIndex < slides.length - 1) {
[newSlides[currentIndex], newSlides[currentIndex + 1]] = [
newSlides[currentIndex + 1],
newSlides[currentIndex],
];
}
newSlides.forEach((s, i) => (s.order = i + 1));
await decksStore.reorderSlides(newSlides);
}
function addBulletPoint() {
slideBulletPoints = [...slideBulletPoints, ''];
}
function removeBulletPoint(index: number) {
slideBulletPoints = slideBulletPoints.filter((_, i) => i !== index);
if (slideBulletPoints.length === 0) slideBulletPoints = [''];
}
function updateBulletPoint(index: number, value: string) {
slideBulletPoints[index] = value;
}
</script>
<svelte:head>
<title>{currentDeck?.title || 'Loading...'} - Presi</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if currentDeck}
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
<a
href="/presi"
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
>
<ArrowLeft class="w-5 h-5 text-slate-600 dark:text-slate-400" />
</a>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">{currentDeck.title}</h1>
{#if currentDeck.description}
<p class="text-slate-600 dark:text-slate-400 mt-1">{currentDeck.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-3">
<button
onclick={openCreateSlide}
class="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" /> Add Slide
</button>
{#if currentSlides.length > 0}
<a
href="/presi/present/{deckId}"
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Play class="w-5 h-5" /> Present
</a>
{/if}
</div>
</div>
{#if currentSlides.length === 0}
<div class="text-center py-16">
<div
class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"
>
<TextT class="w-8 h-8 text-slate-400" />
</div>
<h2 class="text-lg font-medium text-slate-900 dark:text-white mb-2">No slides yet</h2>
<button
onclick={openCreateSlide}
class="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
>
<Plus class="w-5 h-5" /> Add Slide
</button>
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each currentSlides as slide, index (slide.id)}
<div
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
>
<button
onclick={() => openEditSlide(slide)}
class="w-full aspect-video bg-slate-100 dark:bg-slate-700 p-4 flex flex-col items-center justify-center text-left"
>
{#if slide.content.imageUrl}
<img
src={slide.content.imageUrl}
alt={slide.content.title || 'Slide'}
class="w-full h-full object-cover"
/>
{:else}
<div class="w-full h-full flex flex-col items-center justify-center p-4">
{#if slide.content.title}<h3
class="text-lg font-semibold text-slate-900 dark:text-white text-center line-clamp-2"
>
{slide.content.title}
</h3>{/if}
{#if slide.content.bulletPoints?.length}
<ul class="mt-2 text-sm text-slate-600 dark:text-slate-400 space-y-1">
{#each slide.content.bulletPoints.slice(0, 3) as point}<li class="truncate">
{point}
</li>{/each}
</ul>
{/if}
</div>
{/if}
</button>
<div
class="p-3 flex items-center justify-between border-t border-slate-200 dark:border-slate-700"
>
<span class="text-sm text-slate-500">Slide {index + 1}</span>
<div
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
onclick={() => moveSlide(slide, 'up')}
disabled={index === 0}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-30"
><CaretUp class="w-4 h-4 text-slate-600 dark:text-slate-400" /></button
>
<button
onclick={() => moveSlide(slide, 'down')}
disabled={index === currentSlides.length - 1}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-30"
><CaretDown class="w-4 h-4 text-slate-600 dark:text-slate-400" /></button
>
<button
onclick={() => openEditSlide(slide)}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded"
><PencilSimple class="w-4 h-4 text-slate-600 dark:text-slate-400" /></button
>
<button
onclick={() => confirmDeleteSlide(slide)}
class="p-1.5 hover:bg-red-50 dark:hover:bg-red-900/30 rounded"
><Trash class="w-4 h-4 text-red-500" /></button
>
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<!-- Slide Editor Modal -->
{#if showSlideModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 overflow-y-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-2xl my-8">
<form onsubmit={handleSaveSlide}>
<div
class="p-6 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between"
>
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">
{editingSlide ? 'Edit Slide' : 'New Slide'}
</h2>
<button
type="button"
onclick={() => (showSlideModal = false)}
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
><X class="w-5 h-5 text-slate-600 dark:text-slate-400" /></button
>
</div>
<div class="p-6 space-y-6 max-h-[60vh] overflow-y-auto">
<div>
<label
for="slideTitle"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title</label
>
<input
type="text"
id="slideTitle"
bind:value={slideTitle}
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Slide title"
/>
</div>
<div>
<label
for="slideImage"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
><span class="flex items-center gap-2"
><Image class="w-4 h-4" /> Image URL (optional)</span
></label
>
<input
type="url"
id="slideImage"
bind:value={slideImageUrl}
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="https://example.com/image.jpg"
/>
</div>
<div>
<label
for="slideBody"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>Body Text (optional)</label
>
<textarea
id="slideBody"
bind:value={slideBody}
rows="3"
class="w-full px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 resize-none"
placeholder="Main content text..."
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
><span class="flex items-center gap-2"><List class="w-4 h-4" /> Bullet Points</span
></label
>
<div class="space-y-2">
{#each slideBulletPoints as point, index}
<div class="flex items-center gap-2">
<span class="text-slate-400"></span>
<input
type="text"
value={point}
oninput={(e) => updateBulletPoint(index, (e.target as HTMLInputElement).value)}
class="flex-1 px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-700 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500"
placeholder="Add a point..."
/>
<button
type="button"
onclick={() => removeBulletPoint(index)}
class="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg"
><X class="w-4 h-4 text-red-500" /></button
>
</div>
{/each}
<button
type="button"
onclick={addBulletPoint}
class="flex items-center gap-2 px-3 py-2 text-sm text-primary-600 hover:bg-primary-50 dark:hover:bg-primary-900/30 rounded-lg"
><Plus class="w-4 h-4" /> Add bullet point</button
>
</div>
</div>
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 flex justify-end gap-3 rounded-b-xl">
<button
type="button"
onclick={() => (showSlideModal = false)}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>Cancel</button
>
<button
type="submit"
disabled={isSaving}
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors disabled:opacity-50"
><FloppyDisk class="w-4 h-4" /> {isSaving ? 'Saving...' : 'Save'}</button
>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Slide Modal -->
{#if showDeleteModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-md p-6">
<h2 class="text-xl font-semibold text-slate-900 dark:text-white mb-2">Delete Slide</h2>
<p class="text-slate-600 dark:text-slate-400 mb-6">
Are you sure you want to delete this slide?
</p>
<div class="flex justify-end gap-3">
<button
onclick={() => {
showDeleteModal = false;
slideToDelete = null;
}}
class="px-4 py-2 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
>Cancel</button
>
<button
onclick={handleDeleteSlide}
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
>Delete</button
>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,231 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { useDeck, useDeckSlides } from '$lib/modules/presi/queries';
import {
X,
CaretLeft,
CaretRight,
Play,
Pause,
Eye,
EyeSlash,
ArrowsOut,
ArrowsIn,
Clock,
} from '@manacore/shared-icons';
let currentSlideIndex = $state(0);
let isFullscreen = $state(false);
let showNotes = $state(false);
let isTimerRunning = $state(false);
let elapsedSeconds = $state(0);
let showControls = $state(true);
let hideControlsTimeout: ReturnType<typeof setTimeout> | null = null;
let timerInterval: ReturnType<typeof setInterval> | null = null;
const deckId = $page.params.id as string;
const deckQuery = useDeck(deckId);
const slidesQuery = useDeckSlides(deckId);
let currentDeck = $derived(deckQuery.value);
let currentSlides = $derived(slidesQuery.value ?? []);
const currentSlide = $derived(currentSlides[currentSlideIndex]);
onMount(() => {
window.addEventListener('keydown', handleKeydown);
window.addEventListener('mousemove', handleMouseMove);
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
if (timerInterval) clearInterval(timerInterval);
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
};
});
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowLeft':
case 'a':
prevSlide();
break;
case 'ArrowRight':
case 'd':
case ' ':
nextSlide();
break;
case 'Escape':
exitPresentation();
break;
case 'f':
toggleFullscreen();
break;
}
resetHideControlsTimer();
}
function handleMouseMove() {
showControls = true;
resetHideControlsTimer();
}
function resetHideControlsTimer() {
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
hideControlsTimeout = setTimeout(() => {
showControls = false;
}, 3000);
}
function handleFullscreenChange() {
isFullscreen = !!document.fullscreenElement;
}
function prevSlide() {
if (currentSlideIndex > 0) currentSlideIndex--;
}
function nextSlide() {
if (currentSlideIndex < currentSlides.length - 1) currentSlideIndex++;
}
function goToSlide(index: number) {
currentSlideIndex = index;
}
function toggleFullscreen() {
if (!document.fullscreenElement) document.documentElement.requestFullscreen();
else document.exitFullscreen();
}
function toggleTimer() {
isTimerRunning = !isTimerRunning;
if (isTimerRunning) {
timerInterval = setInterval(() => {
elapsedSeconds++;
}, 1000);
} else if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function exitPresentation() {
if (document.fullscreenElement) document.exitFullscreen();
goto(`/presi/deck/${deckId}`);
}
</script>
<svelte:head><title>Presenting: {currentDeck?.title || 'Loading...'}</title></svelte:head>
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
{#if currentSlide}
<div
class="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
>
<div class="flex items-center gap-4">
<h1 class="text-lg font-medium truncate max-w-xs">{currentDeck?.title}</h1>
<span class="text-sm text-slate-400"
>Slide {currentSlideIndex + 1} of {currentSlides.length}</span
>
</div>
<button onclick={exitPresentation} class="p-2 hover:bg-white/10 rounded-lg transition-colors"
><X class="w-6 h-6" /></button
>
</div>
<div class="flex-1 flex items-center justify-center p-8 pt-20 pb-32">
<div
class="w-full max-w-6xl aspect-video bg-slate-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col items-center justify-center p-12"
>
{#if currentSlide.content.imageUrl}
<img
src={currentSlide.content.imageUrl}
alt={currentSlide.content.title || 'Slide'}
class="max-w-full max-h-full object-contain"
/>
{:else}
<div class="text-center max-w-4xl">
{#if currentSlide.content.title}<h2
class="text-4xl md:text-5xl lg:text-6xl font-bold mb-8"
>
{currentSlide.content.title}
</h2>{/if}
{#if currentSlide.content.body}<p class="text-xl md:text-2xl text-slate-300 mb-8">
{currentSlide.content.body}
</p>{/if}
{#if currentSlide.content.bulletPoints?.length}
<ul class="text-left text-xl md:text-2xl space-y-4 mx-auto max-w-2xl">
{#each currentSlide.content.bulletPoints as point}
<li class="flex items-start gap-4">
<span class="text-primary-400 mt-1"></span><span>{point}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</div>
<div
class="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/50 to-transparent transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
>
<div class="max-w-4xl mx-auto flex items-center justify-between">
<div class="flex items-center gap-4">
<button onclick={toggleTimer} class="p-2 hover:bg-white/10 rounded-lg transition-colors">
{#if isTimerRunning}<Pause class="w-5 h-5" />{:else}<Play class="w-5 h-5" />{/if}
</button>
<div class="flex items-center gap-2 text-slate-300">
<Clock class="w-4 h-4" /><span class="font-mono">{formatTime(elapsedSeconds)}</span>
</div>
</div>
<div class="flex items-center gap-2">
<button
onclick={prevSlide}
disabled={currentSlideIndex === 0}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
><CaretLeft class="w-6 h-6" /></button
>
<div class="flex items-center gap-2 px-4">
{#each currentSlides as _, index}
<button
onclick={() => goToSlide(index)}
class="w-2 h-2 rounded-full transition-all"
class:bg-primary-500={index === currentSlideIndex}
class:w-4={index === currentSlideIndex}
class:bg-slate-500={index !== currentSlideIndex}
></button>
{/each}
</div>
<button
onclick={nextSlide}
disabled={currentSlideIndex === currentSlides.length - 1}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
><CaretRight class="w-6 h-6" /></button
>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => (showNotes = !showNotes)}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
{#if showNotes}<EyeSlash class="w-5 h-5" />{:else}<Eye class="w-5 h-5" />{/if}
</button>
<button
onclick={toggleFullscreen}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
>
{#if isFullscreen}<ArrowsIn class="w-5 h-5" />{:else}<ArrowsOut class="w-5 h-5" />{/if}
</button>
</div>
</div>
</div>
{:else}
<div class="flex-1 flex items-center justify-center">
<p class="text-slate-400">No slides in this deck</p>
</div>
{/if}
</div>

View file

@ -0,0 +1,233 @@
<script lang="ts">
import {
useAllQuestions,
useAllCollections,
filterByCollection,
filterByStatus,
searchQuestions,
} from '$lib/modules/questions/queries';
import type { QuestionStatus, ResearchDepth } from '$lib/modules/questions/types';
import {
MagnifyingGlass,
Clock,
CheckCircle,
CircleNotch,
Archive,
} from '@manacore/shared-icons';
const allQuestions = useAllQuestions();
const allCollections = useAllCollections();
let searchQuery = $state('');
let statusFilter = $state<QuestionStatus | ''>('');
let selectedCollectionId = $state<string | null>(null);
let filteredQuestions = $derived.by(() => {
let result = allQuestions.current ?? [];
result = filterByCollection(result, selectedCollectionId);
if (statusFilter) result = filterByStatus(result, statusFilter);
if (searchQuery) result = searchQuestions(result, searchQuery);
return result;
});
let collections = $derived(allCollections.current ?? []);
let selectedCollection = $derived(
selectedCollectionId ? collections.find((c) => c.id === selectedCollectionId) : null
);
const statusIcons = {
open: { icon: Clock, color: 'text-gray-500' },
researching: { icon: CircleNotch, color: 'text-blue-500' },
answered: { icon: CheckCircle, color: 'text-green-500' },
archived: { icon: Archive, color: 'text-gray-400' },
};
const depthLabels: Record<ResearchDepth, string> = {
quick: 'Quick',
standard: 'Standard',
deep: 'Deep',
};
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return 'Heute';
if (days === 1) return 'Gestern';
if (days < 7) return `Vor ${days} Tagen`;
return date.toLocaleDateString('de-DE', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
});
}
</script>
<svelte:head>
<title>Fragen - ManaCore</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">
{selectedCollection ? selectedCollection.name : 'Alle Fragen'}
</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">
{filteredQuestions.length} Frage{filteredQuestions.length !== 1 ? 'n' : ''}
</p>
</div>
<div class="flex gap-2">
<a
href="/questions/collections"
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm font-medium text-[hsl(var(--foreground))] transition-colors hover:bg-[hsl(var(--muted))]"
>
Sammlungen
</a>
<a
href="/questions/new"
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] transition-colors hover:opacity-90"
>
Neue Frage
</a>
</div>
</div>
<!-- Search and Filters -->
<div class="flex gap-3">
<div class="relative flex-1">
<MagnifyingGlass
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
/>
<input
type="text"
bind:value={searchQuery}
placeholder="Fragen durchsuchen..."
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] py-2 pl-10 pr-4 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<select
bind:value={statusFilter}
class="rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
>
<option value="">Alle Status</option>
<option value="open">Offen</option>
<option value="researching">Recherche</option>
<option value="answered">Beantwortet</option>
<option value="archived">Archiviert</option>
</select>
{#if collections.length > 0}
<select
bind:value={selectedCollectionId}
class="rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
>
<option value={null}>Alle Sammlungen</option>
{#each collections as collection}
<option value={collection.id}>{collection.name}</option>
{/each}
</select>
{/if}
</div>
<!-- Questions List -->
{#if filteredQuestions.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<span class="mb-4 text-5xl">🔍</span>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">Keine Fragen</h2>
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
Stelle deine erste Frage und lass die KI recherchieren.
</p>
<a
href="/questions/new"
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Neue Frage
</a>
</div>
{:else}
<div class="space-y-3">
{#each filteredQuestions as question (question.id)}
{@const StatusIcon = statusIcons[question.status]?.icon || Clock}
{@const statusColor = statusIcons[question.status]?.color || 'text-gray-500'}
<a
href="/questions/{question.id}"
class="block rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-start gap-4">
<div class="mt-1">
<StatusIcon
class="h-5 w-5 {statusColor} {question.status === 'researching'
? 'animate-spin'
: ''}"
/>
</div>
<div class="min-w-0 flex-1">
<h3 class="font-medium text-[hsl(var(--foreground))] line-clamp-2">
{question.title}
</h3>
{#if question.description}
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))] line-clamp-2">
{question.description}
</p>
{/if}
<div class="mt-3 flex flex-wrap items-center gap-3">
{#if question.tags?.length}
<div class="flex gap-1">
{#each question.tags.slice(0, 3) as tag}
<span
class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs text-[hsl(var(--foreground))]"
>
{tag}
</span>
{/each}
{#if question.tags.length > 3}
<span class="text-xs text-[hsl(var(--muted-foreground))]"
>+{question.tags.length - 3}</span
>
{/if}
</div>
{/if}
<span
class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs text-[hsl(var(--muted-foreground))]"
>
{depthLabels[question.researchDepth]}
</span>
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatDate(question.createdAt)}
</span>
</div>
</div>
{#if question.priority !== 'normal'}
<span
class="rounded-full px-2 py-0.5 text-xs font-medium
{question.priority === 'urgent'
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: question.priority === 'high'
? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'}"
>
{question.priority}
</span>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,364 @@
<script lang="ts">
import { page } from '$app/stores';
import { db } from '$lib/data/database';
import {
useAllQuestions,
useAnswersByQuestion,
getQuestionById,
} from '$lib/modules/questions/queries';
import type { Question, Answer } from '$lib/modules/questions/queries';
import {
ArrowLeft,
Clock,
CircleNotch,
CheckCircle,
Archive,
PencilSimple,
Trash,
} from '@manacore/shared-icons';
const allQuestions = useAllQuestions();
let questionId = $derived($page.params.id);
let question = $derived(getQuestionById(allQuestions.current ?? [], questionId));
// Answers live query — we call it reactively via the id
let answersQuery = $derived(useAnswersByQuestion(questionId));
let answers = $derived(answersQuery?.current ?? []);
let editing = $state(false);
let editTitle = $state('');
let editDescription = $state('');
let newAnswer = $state('');
let savingAnswer = $state(false);
const statusLabels: Record<string, { label: string; color: string }> = {
open: {
label: 'Offen',
color: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
},
researching: {
label: 'Recherche',
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
},
answered: {
label: 'Beantwortet',
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
archived: {
label: 'Archiviert',
color: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
},
};
const depthLabels: Record<string, string> = {
quick: 'Schnell',
standard: 'Standard',
deep: 'Tiefgehend',
};
function startEditing() {
if (!question) return;
editTitle = question.title;
editDescription = question.description || '';
editing = true;
}
async function saveEdit() {
if (!question || !editTitle.trim()) return;
await db.table('questions').update(question.id, {
title: editTitle.trim(),
description: editDescription.trim() || null,
updatedAt: new Date().toISOString(),
});
editing = false;
}
async function updateStatus(status: string) {
if (!question) return;
await db.table('questions').update(question.id, {
status,
updatedAt: new Date().toISOString(),
});
}
async function deleteQuestion() {
if (!question || !confirm('Frage wirklich loeschen?')) return;
await db.table('questions').update(question.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
window.location.href = '/questions';
}
async function addAnswer() {
if (!question || !newAnswer.trim()) return;
savingAnswer = true;
try {
const now = new Date().toISOString();
await db.table('answers').add({
id: crypto.randomUUID(),
questionId: question.id,
researchResultId: null,
content: newAnswer.trim(),
citations: [],
rating: null,
isAccepted: false,
createdAt: now,
updatedAt: now,
});
newAnswer = '';
// Mark question as answered if it was open
if (question.status === 'open') {
await updateStatus('answered');
}
} finally {
savingAnswer = false;
}
}
async function acceptAnswer(answerId: string) {
// Unaccept all others first
for (const a of answers) {
if (a.isAccepted) {
await db.table('answers').update(a.id, {
isAccepted: false,
updatedAt: new Date().toISOString(),
});
}
}
await db.table('answers').update(answerId, {
isAccepted: true,
updatedAt: new Date().toISOString(),
});
}
async function deleteAnswer(answerId: string) {
await db.table('answers').update(answerId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<svelte:head>
<title>{question?.title || 'Frage'} - ManaCore</title>
</svelte:head>
{#if !question}
<div class="py-16 text-center">
<p class="text-[hsl(var(--muted-foreground))]">Frage nicht gefunden</p>
<a href="/questions" class="mt-4 inline-block text-[hsl(var(--primary))]">Zurueck</a>
</div>
{:else}
<div class="mx-auto max-w-3xl space-y-6">
<!-- Header -->
<div>
<a
href="/questions"
class="mb-4 inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<ArrowLeft class="h-4 w-4" />
Zurueck zu Fragen
</a>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
{#if editing}
<input
type="text"
bind:value={editTitle}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-xl font-bold text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
<textarea
bind:value={editDescription}
placeholder="Beschreibung..."
rows="2"
class="mt-2 w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
<div class="mt-2 flex gap-2">
<button
onclick={saveEdit}
class="rounded-lg bg-[hsl(var(--primary))] px-3 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Speichern
</button>
<button
onclick={() => (editing = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--foreground))]"
>
Abbrechen
</button>
</div>
{:else}
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{question.title}</h1>
{#if question.description}
<p class="mt-2 text-[hsl(var(--muted-foreground))]">{question.description}</p>
{/if}
{/if}
<div class="mt-4 flex flex-wrap items-center gap-3">
<!-- Status Badge -->
<span
class="rounded-full px-3 py-1 text-sm font-medium {statusLabels[question.status]
?.color}"
>
{statusLabels[question.status]?.label}
</span>
<!-- Depth -->
<span
class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs text-[hsl(var(--muted-foreground))]"
>
{depthLabels[question.researchDepth] ?? question.researchDepth}
</span>
<!-- Tags -->
{#if question.tags?.length}
{#each question.tags as tag}
<span
class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs text-[hsl(var(--foreground))]"
>
{tag}
</span>
{/each}
{/if}
<!-- Date -->
<span class="text-sm text-[hsl(var(--muted-foreground))]">
{formatDate(question.createdAt)}
</span>
</div>
</div>
<!-- Actions -->
{#if !editing}
<div class="flex gap-2">
<button
onclick={startEditing}
class="rounded-lg border border-[hsl(var(--border))] p-2 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
title="Bearbeiten"
>
<PencilSimple class="h-4 w-4" />
</button>
<button
onclick={deleteQuestion}
class="rounded-lg border border-red-300 p-2 text-red-500 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
title="Loeschen"
>
<Trash class="h-4 w-4" />
</button>
</div>
{/if}
</div>
</div>
<!-- Status Actions -->
<div class="flex gap-2">
{#each ['open', 'researching', 'answered', 'archived'] as status}
<button
onclick={() => updateStatus(status)}
disabled={question.status === status}
class="rounded-lg border px-3 py-1.5 text-sm transition-colors
{question.status === status
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))]'}"
>
{statusLabels[status]?.label}
</button>
{/each}
</div>
<!-- Answers -->
<div class="space-y-4">
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">
Antworten ({answers.length})
</h2>
{#if answers.length === 0}
<div class="rounded-xl border-2 border-dashed border-[hsl(var(--border))] p-8 text-center">
<span class="mb-2 block text-4xl">📝</span>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
Noch keine Antworten. Fuege die erste Antwort hinzu.
</p>
</div>
{:else}
{#each answers as answer (answer.id)}
<div
class="rounded-xl border bg-[hsl(var(--card))] p-5
{answer.isAccepted ? 'border-green-300 dark:border-green-800' : 'border-[hsl(var(--border))]'}"
>
{#if answer.isAccepted}
<div
class="mb-3 flex items-center gap-2 text-sm font-medium text-green-600 dark:text-green-400"
>
<CheckCircle class="h-4 w-4" />
Akzeptierte Antwort
</div>
{/if}
<div class="whitespace-pre-wrap text-[hsl(var(--foreground))]">
{answer.content}
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-xs text-[hsl(var(--muted-foreground))]">
{formatDate(answer.createdAt)}
</span>
<div class="flex gap-2">
{#if !answer.isAccepted}
<button
onclick={() => acceptAnswer(answer.id)}
class="rounded-lg border border-green-300 px-3 py-1 text-xs text-green-600 hover:bg-green-50 dark:border-green-800 dark:text-green-400 dark:hover:bg-green-900/20"
>
Akzeptieren
</button>
{/if}
<button
onclick={() => deleteAnswer(answer.id)}
class="rounded-lg border border-red-300 px-3 py-1 text-xs text-red-500 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
>
Loeschen
</button>
</div>
</div>
</div>
{/each}
{/if}
<!-- Add Answer -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5">
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
Antwort hinzufuegen
</h3>
<textarea
bind:value={newAnswer}
placeholder="Deine Antwort..."
rows="4"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
<div class="mt-3 flex justify-end">
<button
onclick={addAnswer}
disabled={savingAnswer || !newAnswer.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{savingAnswer ? 'Speichert...' : 'Antwort senden'}
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,290 @@
<script lang="ts">
import { db } from '$lib/data/database';
import {
useAllCollections,
useAllQuestions,
getQuestionCountByCollection,
} from '$lib/modules/questions/queries';
import type { Collection } from '$lib/modules/questions/queries';
import type { CreateCollectionDto, UpdateCollectionDto } from '$lib/modules/questions/types';
import { ArrowLeft, Plus, PencilSimple, Trash, FolderOpen } from '@manacore/shared-icons';
const allCollections = useAllCollections();
const allQuestions = useAllQuestions();
let collections = $derived(allCollections.current ?? []);
let questions = $derived(allQuestions.current ?? []);
let showModal = $state(false);
let editingCollection = $state<Collection | null>(null);
let deleteConfirm = $state<string | null>(null);
// Modal form state
let formName = $state('');
let formDescription = $state('');
let formColor = $state('#6366f1');
let formIcon = $state('folder');
function openCreateModal() {
editingCollection = null;
formName = '';
formDescription = '';
formColor = '#6366f1';
formIcon = 'folder';
showModal = true;
}
function openEditModal(collection: Collection) {
editingCollection = collection;
formName = collection.name;
formDescription = collection.description || '';
formColor = collection.color;
formIcon = collection.icon;
showModal = true;
}
function closeModal() {
showModal = false;
editingCollection = null;
}
async function handleSave() {
if (!formName.trim()) return;
const now = new Date().toISOString();
if (editingCollection) {
await db.table('qCollections').update(editingCollection.id, {
name: formName.trim(),
description: formDescription.trim() || null,
color: formColor,
icon: formIcon,
updatedAt: now,
});
} else {
await db.table('qCollections').add({
id: crypto.randomUUID(),
name: formName.trim(),
description: formDescription.trim() || null,
color: formColor,
icon: formIcon,
isDefault: false,
sortOrder: collections.length,
createdAt: now,
updatedAt: now,
});
}
closeModal();
}
async function handleDelete(id: string) {
await db.table('qCollections').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
deleteConfirm = null;
}
</script>
<svelte:head>
<title>Sammlungen - Fragen - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<a
href="/questions"
class="mb-4 inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<ArrowLeft class="h-4 w-4" />
Zurueck zu Fragen
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Sammlungen</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">
Organisiere deine Fragen in Sammlungen
</p>
</div>
<button
onclick={openCreateModal}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90"
>
<Plus class="h-5 w-5" />
Neue Sammlung
</button>
</div>
<!-- Collections List -->
{#if collections.length === 0}
<div
class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[hsl(var(--border))] py-16"
>
<span class="mb-4 text-4xl">📁</span>
<h2 class="mb-2 text-lg font-semibold text-[hsl(var(--foreground))]">Keine Sammlungen</h2>
<p class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
Erstelle deine erste Sammlung, um Fragen zu organisieren.
</p>
<button
onclick={openCreateModal}
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
<Plus class="h-5 w-5" />
Sammlung erstellen
</button>
</div>
{:else}
<div class="space-y-3">
{#each collections as collection (collection.id)}
<div
class="flex items-center gap-4 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<!-- Icon & Color -->
<div
class="flex h-10 w-10 items-center justify-center rounded-lg"
style="background-color: {collection.color}20"
>
<FolderOpen class="h-5 w-5" style="color: {collection.color}" />
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="font-medium text-[hsl(var(--foreground))]">{collection.name}</h3>
{#if collection.isDefault}
<span
class="rounded-full bg-[hsl(var(--primary)/0.1)] px-2 py-0.5 text-xs text-[hsl(var(--primary))]"
>
Standard
</span>
{/if}
</div>
{#if collection.description}
<p class="mt-0.5 truncate text-sm text-[hsl(var(--muted-foreground))]">
{collection.description}
</p>
{/if}
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{getQuestionCountByCollection(questions, collection.id)} Fragen
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-2">
<button
onclick={() => openEditModal(collection)}
class="rounded-lg p-2 text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--muted))] hover:text-[hsl(var(--foreground))]"
title="Bearbeiten"
>
<PencilSimple class="h-4 w-4" />
</button>
{#if deleteConfirm === collection.id}
<div class="flex items-center gap-1">
<button
onclick={() => handleDelete(collection.id)}
class="rounded-lg bg-red-500 px-3 py-1 text-sm text-white hover:bg-red-600"
>
Loeschen
</button>
<button
onclick={() => (deleteConfirm = null)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1 text-sm text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
Abbrechen
</button>
</div>
{:else}
<button
onclick={() => (deleteConfirm = collection.id)}
class="rounded-lg p-2 text-[hsl(var(--muted-foreground))] hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
title="Loeschen"
>
<Trash class="h-4 w-4" />
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Modal -->
{#if showModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div
class="mx-4 w-full max-w-md rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6"
>
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">
{editingCollection ? 'Sammlung bearbeiten' : 'Neue Sammlung'}
</h2>
<div class="space-y-4">
<div>
<label
for="collection-name"
class="mb-1 block text-sm font-medium text-[hsl(var(--foreground))]"
>
Name
</label>
<input
id="collection-name"
type="text"
bind:value={formName}
placeholder="Sammlungsname"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<div>
<label
for="collection-desc"
class="mb-1 block text-sm font-medium text-[hsl(var(--foreground))]"
>
Beschreibung
</label>
<textarea
id="collection-desc"
bind:value={formDescription}
placeholder="Optionale Beschreibung"
rows="2"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
</div>
<div>
<label
for="collection-color"
class="mb-1 block text-sm font-medium text-[hsl(var(--foreground))]"
>
Farbe
</label>
<input
id="collection-color"
type="color"
bind:value={formColor}
class="h-10 w-20 cursor-pointer rounded-lg border border-[hsl(var(--border))]"
/>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button
onclick={closeModal}
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
Abbrechen
</button>
<button
onclick={handleSave}
disabled={!formName.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{editingCollection ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,254 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { db } from '$lib/data/database';
import { useAllCollections } from '$lib/modules/questions/queries';
import type { ResearchDepth, QuestionPriority } from '$lib/modules/questions/types';
import { ArrowLeft, Lightning, Clock, Sparkle } from '@manacore/shared-icons';
const allCollections = useAllCollections();
let collections = $derived(allCollections.current ?? []);
let title = $state('');
let description = $state('');
let collectionId = $state<string | undefined>(undefined);
let tags = $state<string[]>([]);
let tagInput = $state('');
let priority = $state<QuestionPriority>('normal');
let researchDepth = $state<ResearchDepth>('standard');
let loading = $state(false);
let error = $state<string | null>(null);
const depthOptions: {
value: ResearchDepth;
label: string;
description: string;
icon: typeof Lightning;
}[] = [
{
value: 'quick',
label: 'Schnell',
description: '5 Quellen, schnelle Ergebnisse',
icon: Lightning,
},
{ value: 'standard', label: 'Standard', description: '15 Quellen, ausgewogen', icon: Clock },
{ value: 'deep', label: 'Tiefgehend', description: '30+ Quellen, umfassend', icon: Sparkle },
];
function addTag() {
const tag = tagInput.trim().toLowerCase();
if (tag && !tags.includes(tag)) {
tags = [...tags, tag];
}
tagInput = '';
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag);
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) {
error = 'Bitte gib eine Frage ein';
return;
}
loading = true;
error = null;
try {
const now = new Date().toISOString();
const id = crypto.randomUUID();
await db.table('questions').add({
id,
collectionId: collectionId || null,
title: title.trim(),
description: description.trim() || null,
status: 'open' as const,
priority,
tags,
researchDepth,
createdAt: now,
updatedAt: now,
});
goto(`/questions/${id}`);
} catch (e) {
error = 'Frage konnte nicht erstellt werden';
loading = false;
}
}
</script>
<svelte:head>
<title>Neue Frage - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div>
<a
href="/questions"
class="mb-4 inline-flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<ArrowLeft class="h-4 w-4" />
Zurueck zu Fragen
</a>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Neue Frage</h1>
<p class="mt-1 text-sm text-[hsl(var(--muted-foreground))]">
Stelle eine Frage und lass die KI recherchieren
</p>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
{#if error}
<div
class="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400"
>
{error}
</div>
{/if}
<!-- Question Title -->
<div>
<label for="title" class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Deine Frage
</label>
<input
type="text"
id="title"
bind:value={title}
placeholder="Was moechtest du wissen?"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-lg text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Description -->
<div>
<label for="description" class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Kontext <span class="text-[hsl(var(--muted-foreground))]">(optional)</span>
</label>
<textarea
id="description"
bind:value={description}
placeholder="Zusaetzliche Details oder Kontext..."
rows="3"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
></textarea>
</div>
<!-- Collection -->
{#if collections.length > 0}
<div>
<label
for="collection"
class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]"
>
Sammlung
</label>
<select
id="collection"
bind:value={collectionId}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
>
<option value={undefined}>Keine Sammlung</option>
{#each collections as collection}
<option value={collection.id}>{collection.name}</option>
{/each}
</select>
</div>
{/if}
<!-- Tags -->
<div>
<label for="tags" class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Tags
</label>
<div class="mb-2 flex flex-wrap gap-2">
{#each tags as tag}
<span
class="inline-flex items-center gap-1 rounded-full bg-[hsl(var(--muted))] px-3 py-1 text-sm text-[hsl(var(--foreground))]"
>
{tag}
<button
type="button"
onclick={() => removeTag(tag)}
class="ml-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
&times;
</button>
</span>
{/each}
</div>
<input
type="text"
id="tags"
bind:value={tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Tag eingeben und Enter druecken"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Research Depth -->
<div>
<label class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Recherchetiefe
</label>
<div class="grid grid-cols-3 gap-3">
{#each depthOptions as option}
<button
type="button"
onclick={() => (researchDepth = option.value)}
class="rounded-lg border-2 p-4 text-left transition-all {researchDepth === option.value
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.05)]'
: 'border-[hsl(var(--border))] hover:border-[hsl(var(--primary)/0.3)]'}"
>
<svelte:component this={option.icon} class="mb-2 h-5 w-5 text-[hsl(var(--primary))]" />
<div class="font-medium text-[hsl(var(--foreground))]">{option.label}</div>
<div class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{option.description}
</div>
</button>
{/each}
</div>
</div>
<!-- Priority -->
<div>
<label for="priority" class="mb-2 block text-sm font-medium text-[hsl(var(--foreground))]">
Prioritaet
</label>
<select
id="priority"
bind:value={priority}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
>
<option value="low">Niedrig</option>
<option value="normal">Normal</option>
<option value="high">Hoch</option>
<option value="urgent">Dringend</option>
</select>
</div>
<!-- Submit -->
<div class="flex gap-3">
<a
href="/questions"
class="flex-1 rounded-lg border border-[hsl(var(--border))] px-4 py-3 text-center text-sm font-medium text-[hsl(var(--foreground))] hover:bg-[hsl(var(--muted))]"
>
Abbrechen
</a>
<button
type="submit"
disabled={loading || !title.trim()}
class="flex-1 rounded-lg bg-[hsl(var(--primary))] px-4 py-3 text-sm font-medium text-[hsl(var(--primary-foreground))] hover:opacity-90 disabled:opacity-50"
>
{loading ? 'Erstelle...' : 'Frage stellen'}
</button>
</div>
</form>
</div>

View file

@ -0,0 +1,683 @@
<script lang="ts">
import {
useAllLinks,
useAllTags,
useAllFolders,
useAllLinkTags,
getFilteredLinks,
getSortedLinks,
getLinkTags,
generateShortCode,
type Link,
type StatusFilter,
} from '$lib/modules/uload/queries';
import { linkTable, uloadFolderTable } from '$lib/modules/uload/collections';
import type { LocalLink } from '$lib/modules/uload/types';
import {
CaretRight,
ChartBar,
Copy,
QrCode,
PencilSimple,
Lightning,
Trash,
X,
Link as LinkIcon,
FolderSimple,
MagnifyingGlass,
} from '@manacore/shared-icons';
import { toast } from 'svelte-sonner';
const QR_API = 'https://api.qrserver.com/v1/create-qr-code';
// Reactive live queries
const allLinks = useAllLinks();
const allTags = useAllTags();
const allFolders = useAllFolders();
const allLinkTags = useAllLinkTags();
// Filter state
let searchQuery = $state('');
let selectedStatus = $state<StatusFilter>('all');
let selectedFolderId = $state<string | null>(null);
// Create form state
let showCreateForm = $state(false);
let newUrl = $state('');
let newTitle = $state('');
let newCustomCode = $state('');
let showUtm = $state(false);
let newUtmSource = $state('');
let newUtmMedium = $state('');
let newUtmCampaign = $state('');
let showAdvanced = $state(false);
let newExpiresAt = $state('');
let newPassword = $state('');
let newMaxClicks = $state('');
// Edit modal state
let editingLink = $state<Link | null>(null);
let editUrl = $state('');
let editTitle = $state('');
let editUtmSource = $state('');
let editUtmMedium = $state('');
let editUtmCampaign = $state('');
let editExpiresAt = $state('');
let editPassword = $state('');
let editMaxClicks = $state('');
// QR modal state
let qrLink = $state<Link | null>(null);
// Derived
const links = $derived(allLinks.value);
const folders = $derived(allFolders.value);
const tags = $derived(allTags.value);
const linkTags = $derived(allLinkTags.value);
const filteredLinks = $derived(
getSortedLinks(
getFilteredLinks(links, {
search: searchQuery,
status: selectedStatus,
folderId: selectedFolderId ?? undefined,
})
)
);
function getShortUrl(code: string): string {
return `https://ulo.ad/${code}`;
}
async function createLink() {
if (!newUrl) return;
const shortCode = newCustomCode || generateShortCode();
await linkTable.add({
id: crypto.randomUUID(),
shortCode,
customCode: newCustomCode || null,
originalUrl: newUrl,
title: newTitle || null,
description: null,
isActive: true,
password: newPassword || null,
maxClicks: newMaxClicks ? parseInt(newMaxClicks) : null,
expiresAt: newExpiresAt || null,
clickCount: 0,
qrCodeUrl: null,
utmSource: newUtmSource || null,
utmMedium: newUtmMedium || null,
utmCampaign: newUtmCampaign || null,
folderId: selectedFolderId,
order: links.length,
} satisfies LocalLink);
toast.success(`Link erstellt: ${shortCode}`);
newUrl = '';
newTitle = '';
newCustomCode = '';
newUtmSource = '';
newUtmMedium = '';
newUtmCampaign = '';
newExpiresAt = '';
newPassword = '';
newMaxClicks = '';
showUtm = false;
showAdvanced = false;
}
function openEdit(link: Link) {
editingLink = link;
editUrl = link.originalUrl;
editTitle = link.title ?? '';
editUtmSource = link.utmSource ?? '';
editUtmMedium = link.utmMedium ?? '';
editUtmCampaign = link.utmCampaign ?? '';
editExpiresAt = link.expiresAt ?? '';
editPassword = link.password ?? '';
editMaxClicks = link.maxClicks?.toString() ?? '';
}
async function saveEdit() {
if (!editingLink || !editUrl) return;
await linkTable.update(editingLink.id, {
originalUrl: editUrl,
title: editTitle || null,
utmSource: editUtmSource || null,
utmMedium: editUtmMedium || null,
utmCampaign: editUtmCampaign || null,
expiresAt: editExpiresAt || null,
password: editPassword || null,
maxClicks: editMaxClicks ? parseInt(editMaxClicks) : null,
});
toast.success('Link aktualisiert');
editingLink = null;
}
async function toggleActive(link: Link) {
await linkTable.update(link.id, { isActive: !link.isActive });
}
async function deleteLink(link: Link) {
if (!confirm(`"${link.title || link.shortCode}" wirklich loeschen?`)) return;
await linkTable.delete(link.id);
toast.success('Link geloescht');
}
function copyShortUrl(code: string) {
navigator.clipboard.writeText(getShortUrl(code));
toast.success('Link kopiert!');
}
function downloadQr(code: string) {
const url = `${QR_API}/?size=400x400&data=${encodeURIComponent(getShortUrl(code))}`;
const a = document.createElement('a');
a.href = url;
a.download = `qr-${code}.png`;
a.click();
}
const inputClass =
'w-full rounded-lg border border-gray-300 bg-white px-4 py-3 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-600 dark:bg-gray-700';
const inputSmClass =
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700';
</script>
<svelte:head>
<title>uLoad - ManaCore</title>
</svelte:head>
<div class="min-h-screen">
<div class="mx-auto max-w-7xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold">uLoad</h1>
<p class="mt-1 text-sm opacity-60">
{filteredLinks.length} Links
{#if folders.length > 0}
&middot; {folders.length} Ordner
{/if}
</p>
</div>
<div class="flex items-center gap-2">
<a
href="/uload/links"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Alle Links
</a>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white shadow-lg transition-all hover:scale-105 hover:bg-indigo-700"
>
{showCreateForm ? '- Ausblenden' : '+ Neuer Link'}
</button>
</div>
</div>
<!-- Create Form -->
{#if showCreateForm}
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-800"
>
<div class="grid gap-4 md:grid-cols-2">
<div class="md:col-span-2">
<label for="url" class="mb-1 block text-sm font-medium">URL</label>
<input
id="url"
type="url"
bind:value={newUrl}
placeholder="https://example.com/long-url-here"
class={inputClass}
onkeydown={(e) => e.key === 'Enter' && createLink()}
/>
</div>
<div>
<label for="title" class="mb-1 block text-sm font-medium">Titel (optional)</label>
<input
id="title"
type="text"
bind:value={newTitle}
placeholder="Mein Link"
class={inputClass}
/>
</div>
<div>
<label for="code" class="mb-1 block text-sm font-medium">Custom Code (optional)</label>
<input
id="code"
type="text"
bind:value={newCustomCode}
placeholder="mein-link"
class={inputClass}
/>
</div>
</div>
<!-- Advanced Options -->
<button
onclick={() => (showAdvanced = !showAdvanced)}
class="mt-2 flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-700"
>
<span class="transition-transform {showAdvanced ? 'rotate-90' : ''}"
><CaretRight size={16} /></span
>
Erweitert
</button>
{#if showAdvanced}
<div class="mt-3 grid gap-3 md:grid-cols-3">
<div>
<label for="expires" class="mb-1 block text-xs font-medium opacity-70"
>Ablaufdatum</label
>
<input
id="expires"
type="datetime-local"
bind:value={newExpiresAt}
class={inputSmClass}
/>
</div>
<div>
<label for="password" class="mb-1 block text-xs font-medium opacity-70"
>Passwort</label
>
<input
id="password"
type="text"
bind:value={newPassword}
placeholder="Optional"
class={inputSmClass}
/>
</div>
<div>
<label for="maxclicks" class="mb-1 block text-xs font-medium opacity-70"
>Max Klicks</label
>
<input
id="maxclicks"
type="number"
bind:value={newMaxClicks}
placeholder="Unbegrenzt"
min="1"
class={inputSmClass}
/>
</div>
</div>
{/if}
<!-- UTM Parameters -->
<button
onclick={() => (showUtm = !showUtm)}
class="mt-3 flex items-center gap-1 text-sm text-indigo-600 hover:text-indigo-700"
>
<span class="transition-transform {showUtm ? 'rotate-90' : ''}"
><CaretRight size={16} /></span
>
UTM-Parameter
</button>
{#if showUtm}
<div class="mt-3 grid gap-3 md:grid-cols-3">
<div>
<label for="utm-source" class="mb-1 block text-xs font-medium opacity-70"
>Source</label
>
<input
id="utm-source"
type="text"
bind:value={newUtmSource}
placeholder="newsletter"
class={inputSmClass}
/>
</div>
<div>
<label for="utm-medium" class="mb-1 block text-xs font-medium opacity-70"
>Medium</label
>
<input
id="utm-medium"
type="text"
bind:value={newUtmMedium}
placeholder="email"
class={inputSmClass}
/>
</div>
<div>
<label for="utm-campaign" class="mb-1 block text-xs font-medium opacity-70"
>Campaign</label
>
<input
id="utm-campaign"
type="text"
bind:value={newUtmCampaign}
placeholder="spring-2026"
class={inputSmClass}
/>
</div>
</div>
{/if}
<div class="mt-4 flex justify-end">
<button
onclick={createLink}
disabled={!newUrl}
class="rounded-lg bg-indigo-600 px-6 py-2.5 font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Link erstellen
</button>
</div>
</div>
{/if}
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="relative">
<MagnifyingGlass size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-40" />
<input
type="text"
bind:value={searchQuery}
placeholder="Links durchsuchen..."
class="w-60 rounded-lg border border-gray-300 bg-white py-2 pl-8 pr-3 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700"
/>
</div>
<select bind:value={selectedStatus} class={inputSmClass} style="max-width: 140px">
<option value="all">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
{#if folders.length > 0}
<select bind:value={selectedFolderId} class={inputSmClass} style="max-width: 160px">
<option value={null}>Alle Ordner</option>
{#each folders as folder}
<option value={folder.id}>{folder.name}</option>
{/each}
</select>
{/if}
</div>
<!-- Links List -->
{#if allLinks.loading}
<div class="space-y-3">
{#each Array(3) as _}
<div class="h-20 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800"></div>
{/each}
</div>
{:else if filteredLinks.length === 0}
<div
class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-600"
>
<LinkIcon size={48} class="mx-auto mb-4 opacity-20" />
<p class="text-lg font-medium opacity-60">Noch keine Links</p>
<p class="mt-1 text-sm opacity-40">Erstelle deinen ersten gekuerzten Link!</p>
<button
onclick={() => (showCreateForm = true)}
class="mt-4 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
+ Neuer Link
</button>
</div>
{:else}
<div class="space-y-3">
{#each filteredLinks as link (link.id)}
<div
class="group rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-block h-2 w-2 shrink-0 rounded-full {link.isActive
? 'bg-green-500'
: 'bg-gray-400'}"
></span>
<h3 class="truncate font-semibold">{link.title || link.shortCode}</h3>
<span
class="shrink-0 rounded bg-indigo-100 px-2 py-0.5 font-mono text-xs text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
>
/{link.shortCode}
</span>
{#if link.utmSource || link.utmMedium || link.utmCampaign}
<span
class="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300"
>UTM</span
>
{/if}
{#if link.password}
<span
class="shrink-0 rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700 dark:bg-red-900 dark:text-red-300"
>Passwort</span
>
{/if}
{#if link.expiresAt}
<span
class="shrink-0 rounded bg-orange-100 px-1.5 py-0.5 text-xs text-orange-700 dark:bg-orange-900 dark:text-orange-300"
title="Laeuft ab: {new Date(link.expiresAt).toLocaleDateString('de')}"
>Ablauf</span
>
{/if}
</div>
<p class="mt-1 truncate text-sm opacity-60">{link.originalUrl}</p>
{#if getLinkTags(linkTags, tags, link.id).length > 0}
<div class="mt-1 flex gap-1">
{#each getLinkTags(linkTags, tags, link.id) as tag}
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium"
style="background: {tag.color ?? '#6b7280'}20; color: {tag.color ??
'#6b7280'}"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<div class="ml-4 flex items-center gap-1">
<a
href="/uload/analytics/{link.id}"
class="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm font-medium opacity-60 transition-all hover:bg-gray-100 hover:opacity-100 dark:hover:bg-gray-700"
title="Analytics"
>
<ChartBar size={16} />
{link.clickCount}
</a>
<button
onclick={() => copyShortUrl(link.shortCode)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="Link kopieren"
>
<Copy size={16} />
</button>
<button
onclick={() => (qrLink = link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="QR-Code"
>
<QrCode size={16} />
</button>
<button
onclick={() => openEdit(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="Bearbeiten"
>
<PencilSimple size={16} />
</button>
<button
onclick={() => toggleActive(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title={link.isActive ? 'Deaktivieren' : 'Aktivieren'}
>
<Lightning size={16} class={link.isActive ? 'text-green-500' : 'text-gray-400'} />
</button>
<button
onclick={() => deleteLink(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
title="Loeschen"
>
<Trash size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
<!-- Edit Modal -->
{#if editingLink}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (editingLink = null)}
>
<div
class="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">Link bearbeiten</h3>
<button
onclick={() => (editingLink = null)}
class="rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X size={20} />
</button>
</div>
<div class="space-y-4">
<div>
<label for="edit-url" class="mb-1 block text-sm font-medium">URL</label>
<input id="edit-url" type="url" bind:value={editUrl} class={inputClass} />
</div>
<div>
<label for="edit-title" class="mb-1 block text-sm font-medium">Titel</label>
<input id="edit-title" type="text" bind:value={editTitle} class={inputClass} />
</div>
<div>
<label for="edit-code" class="mb-1 block text-sm font-medium">Short Code</label>
<div class="flex items-center gap-2">
<span class="text-sm opacity-50">/{editingLink.shortCode}</span>
<span class="text-xs opacity-30">(nicht aenderbar)</span>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<p class="mb-2 text-sm font-medium opacity-70">UTM-Parameter</p>
<div class="grid gap-3 md:grid-cols-3">
<input
type="text"
bind:value={editUtmSource}
placeholder="Source"
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmMedium}
placeholder="Medium"
class={inputSmClass}
/>
<input
type="text"
bind:value={editUtmCampaign}
placeholder="Campaign"
class={inputSmClass}
/>
</div>
</div>
<div class="border-t border-gray-200 pt-4 dark:border-gray-700">
<p class="mb-2 text-sm font-medium opacity-70">Erweitert</p>
<div class="grid gap-3 md:grid-cols-3">
<div>
<label class="mb-1 block text-xs opacity-50">Ablaufdatum</label>
<input type="datetime-local" bind:value={editExpiresAt} class={inputSmClass} />
</div>
<div>
<label class="mb-1 block text-xs opacity-50">Passwort</label>
<input
type="text"
bind:value={editPassword}
placeholder="Optional"
class={inputSmClass}
/>
</div>
<div>
<label class="mb-1 block text-xs opacity-50">Max Klicks</label>
<input
type="number"
bind:value={editMaxClicks}
placeholder="Unbegrenzt"
min="1"
class={inputSmClass}
/>
</div>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={() => (editingLink = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Abbrechen
</button>
<button
onclick={saveEdit}
disabled={!editUrl}
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50"
>
Speichern
</button>
</div>
</div>
</div>
{/if}
<!-- QR Code Modal -->
{#if qrLink}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={() => (qrLink = null)}
>
<div
class="w-full max-w-sm rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold">QR-Code</h3>
<button
onclick={() => (qrLink = null)}
class="rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<X size={20} />
</button>
</div>
<div class="flex flex-col items-center gap-4">
<div class="rounded-lg bg-white p-4">
<img
src="{QR_API}/?size=200x200&data={encodeURIComponent(getShortUrl(qrLink.shortCode))}"
alt="QR Code fuer {qrLink.shortCode}"
class="h-48 w-48"
/>
</div>
<p class="font-mono text-sm text-indigo-600">{getShortUrl(qrLink.shortCode)}</p>
<div class="flex w-full gap-2">
<button
onclick={() => copyShortUrl(qrLink!.shortCode)}
class="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700"
>
Link kopieren
</button>
<button
onclick={() => downloadQr(qrLink!.shortCode)}
class="flex-1 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
QR herunterladen
</button>
</div>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,189 @@
<script lang="ts">
import { page } from '$app/stores';
import { CaretLeft } from '@manacore/shared-icons';
import { useLinkById } from '$lib/modules/uload/queries';
let linkId = $derived($page.params.id ?? '');
const linkQuery = $derived(useLinkById(linkId));
const link = $derived(linkQuery.value);
// Analytics are server-side only; in the unified app we show local data
// and a placeholder for server analytics when available.
let days = $state(30);
function changeDays(d: number) {
days = d;
}
</script>
<svelte:head>
<title>Analytics - uLoad - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-4xl">
<!-- Header -->
<div class="mb-6 flex items-center gap-4">
<a
href="/uload"
class="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Zurueck"
>
<CaretLeft size={20} />
</a>
<div>
<h1 class="text-3xl font-bold">Analytics</h1>
{#if link}
<p class="mt-1 text-sm opacity-60">
<span class="font-mono text-indigo-600">/{link.shortCode}</span>
&rarr; <span class="truncate">{link.originalUrl}</span>
</p>
{/if}
</div>
</div>
{#if !link}
<div class="space-y-4">
{#each Array(4) as _}
<div class="h-32 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800"></div>
{/each}
</div>
{:else}
<!-- Stats Overview -->
<div class="mb-6 grid gap-4 sm:grid-cols-4">
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Clicks</p>
<p class="mt-1 text-3xl font-bold">{link.clickCount}</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Status</p>
<p class="mt-1 text-3xl font-bold">
{#if link.isActive}
<span class="text-green-500">Aktiv</span>
{:else}
<span class="text-gray-400">Inaktiv</span>
{/if}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Erstellt</p>
<p class="mt-1 text-lg font-bold">
{new Date(link.createdAt).toLocaleDateString('de')}
</p>
</div>
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<p class="text-xs font-medium uppercase tracking-wider opacity-50">Short URL</p>
<p class="mt-1 truncate font-mono text-sm text-indigo-600">
ulo.ad/{link.shortCode}
</p>
</div>
</div>
<!-- Link Details -->
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<h2 class="mb-4 text-lg font-semibold">Link Details</h2>
<div class="space-y-3">
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Ziel-URL</span>
<a
href={link.originalUrl}
target="_blank"
rel="noopener noreferrer"
class="max-w-md truncate text-indigo-600 hover:underline"
>
{link.originalUrl}
</a>
</div>
{#if link.title}
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Titel</span>
<span>{link.title}</span>
</div>
{/if}
{#if link.utmSource || link.utmMedium || link.utmCampaign}
<div class="border-t border-gray-100 pt-3 dark:border-gray-700">
<p class="mb-2 text-xs font-medium uppercase tracking-wider opacity-50">
UTM-Parameter
</p>
<div class="grid gap-2 sm:grid-cols-3">
{#if link.utmSource}
<div class="text-sm">
<span class="opacity-50">Source:</span>
{link.utmSource}
</div>
{/if}
{#if link.utmMedium}
<div class="text-sm">
<span class="opacity-50">Medium:</span>
{link.utmMedium}
</div>
{/if}
{#if link.utmCampaign}
<div class="text-sm">
<span class="opacity-50">Campaign:</span>
{link.utmCampaign}
</div>
{/if}
</div>
</div>
{/if}
{#if link.expiresAt}
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Laeuft ab</span>
<span>{new Date(link.expiresAt).toLocaleDateString('de')}</span>
</div>
{/if}
{#if link.maxClicks}
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Max Klicks</span>
<span>{link.clickCount} / {link.maxClicks}</span>
</div>
{/if}
{#if link.password}
<div class="flex items-center justify-between text-sm">
<span class="opacity-60">Passwortgeschuetzt</span>
<span>Ja</span>
</div>
{/if}
</div>
</div>
<!-- Timeline Placeholder -->
<div
class="mb-6 rounded-xl border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Clicks ueber Zeit</h2>
<div class="flex gap-1">
{#each [7, 30, 90] as d}
<button
onclick={() => changeDays(d)}
class="rounded-md px-3 py-1 text-xs font-medium transition-colors {days === d
? 'bg-indigo-600 text-white'
: 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'}"
>
{d}T
</button>
{/each}
</div>
</div>
<div class="py-8 text-center">
<p class="text-sm opacity-40">
Detaillierte Analytics sind verfuegbar, wenn der uLoad-Server verbunden ist.
</p>
<p class="mt-1 text-xs opacity-30">
Lokaler Click-Count: {link.clickCount}
</p>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,330 @@
<script lang="ts">
import {
useAllLinks,
useAllTags,
useAllFolders,
useAllLinkTags,
getFilteredLinks,
getSortedLinks,
getLinkTags,
generateShortCode,
type Link,
type StatusFilter,
} from '$lib/modules/uload/queries';
import { linkTable } from '$lib/modules/uload/collections';
import type { LocalLink } from '$lib/modules/uload/types';
import {
ArrowLeft,
ChartBar,
Copy,
QrCode,
PencilSimple,
Lightning,
Trash,
MagnifyingGlass,
Link as LinkIcon,
} from '@manacore/shared-icons';
import { toast } from 'svelte-sonner';
const QR_API = 'https://api.qrserver.com/v1/create-qr-code';
// Reactive live queries
const allLinks = useAllLinks();
const allTags = useAllTags();
const allFolders = useAllFolders();
const allLinkTags = useAllLinkTags();
// Filter state
let searchQuery = $state('');
let selectedStatus = $state<StatusFilter>('all');
let selectedFolderId = $state<string | null>(null);
// Bulk selection
let selectMode = $state(false);
let selectedIds = $state<Set<string>>(new Set());
// Derived
const links = $derived(allLinks.value);
const folders = $derived(allFolders.value);
const tags = $derived(allTags.value);
const linkTags = $derived(allLinkTags.value);
const filteredLinks = $derived(
getSortedLinks(
getFilteredLinks(links, {
search: searchQuery,
status: selectedStatus,
folderId: selectedFolderId ?? undefined,
})
)
);
function getShortUrl(code: string): string {
return `https://ulo.ad/${code}`;
}
function copyShortUrl(code: string) {
navigator.clipboard.writeText(getShortUrl(code));
toast.success('Link kopiert!');
}
async function toggleActive(link: Link) {
await linkTable.update(link.id, { isActive: !link.isActive });
}
async function deleteLink(link: Link) {
if (!confirm(`"${link.title || link.shortCode}" wirklich loeschen?`)) return;
await linkTable.delete(link.id);
toast.success('Link geloescht');
}
// Bulk actions
function toggleSelect(id: string) {
if (selectedIds.has(id)) {
selectedIds.delete(id);
} else {
selectedIds.add(id);
}
selectedIds = selectedIds;
}
function toggleSelectAll() {
if (selectedIds.size === filteredLinks.length) {
selectedIds.clear();
} else {
selectedIds = new Set(filteredLinks.map((l) => l.id));
}
selectedIds = selectedIds;
}
async function bulkDelete() {
if (!confirm(`${selectedIds.size} Link(s) loeschen?`)) return;
for (const id of selectedIds) {
await linkTable.delete(id);
}
toast.success(`${selectedIds.size} Links geloescht`);
selectedIds.clear();
selectedIds = selectedIds;
selectMode = false;
}
async function bulkToggleActive() {
for (const id of selectedIds) {
const link = filteredLinks.find((l) => l.id === id);
if (link) await linkTable.update(id, { isActive: !link.isActive });
}
toast.success(`${selectedIds.size} Links aktualisiert`);
selectedIds.clear();
selectedIds = selectedIds;
selectMode = false;
}
const inputSmClass =
'w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700';
</script>
<svelte:head>
<title>Alle Links - uLoad - ManaCore</title>
</svelte:head>
<div class="min-h-screen">
<div class="mx-auto max-w-7xl">
<!-- Header -->
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/uload"
class="rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-gray-700"
title="Zurueck"
>
<ArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold">
Alle Links
{#if filteredLinks.length > 0}
<span class="ml-1 text-xl opacity-50">({filteredLinks.length})</span>
{/if}
</h1>
</div>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => {
selectMode = !selectMode;
if (!selectMode) {
selectedIds.clear();
selectedIds = selectedIds;
}
}}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium transition-colors {selectMode
? 'bg-indigo-600 text-white'
: 'hover:bg-gray-50 dark:border-gray-600 dark:hover:bg-gray-700'}"
>
{selectMode ? 'Fertig' : 'Auswaehlen'}
</button>
</div>
</div>
<!-- Filters -->
<div class="mb-4 flex flex-wrap items-center gap-3">
<div class="relative">
<MagnifyingGlass size={14} class="absolute left-2.5 top-1/2 -translate-y-1/2 opacity-40" />
<input
type="text"
bind:value={searchQuery}
placeholder="Links durchsuchen..."
class="w-60 rounded-lg border border-gray-300 bg-white py-2 pl-8 pr-3 text-sm focus:border-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700"
/>
</div>
<select bind:value={selectedStatus} class={inputSmClass} style="max-width: 140px">
<option value="all">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
{#if folders.length > 0}
<select bind:value={selectedFolderId} class={inputSmClass} style="max-width: 160px">
<option value={null}>Alle Ordner</option>
{#each folders as folder}
<option value={folder.id}>{folder.name}</option>
{/each}
</select>
{/if}
</div>
<!-- Bulk Actions Bar -->
{#if selectMode && selectedIds.size > 0}
<div
class="mb-4 flex items-center gap-3 rounded-lg border border-indigo-200 bg-indigo-50 p-3 dark:border-indigo-800 dark:bg-indigo-900/20"
>
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={selectedIds.size === filteredLinks.length}
onchange={toggleSelectAll}
class="h-4 w-4 rounded"
/>
<span class="text-sm font-medium">{selectedIds.size} ausgewaehlt</span>
</label>
<div class="h-4 w-px bg-indigo-300 dark:bg-indigo-700"></div>
<button
onclick={bulkToggleActive}
class="rounded px-3 py-1 text-sm font-medium hover:bg-indigo-100 dark:hover:bg-indigo-800"
>Aktivieren/Deaktivieren</button
>
<button
onclick={bulkDelete}
class="rounded px-3 py-1 text-sm font-medium text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>Loeschen</button
>
</div>
{/if}
<!-- Links List -->
{#if allLinks.loading}
<div class="space-y-3">
{#each Array(5) as _}
<div class="h-20 animate-pulse rounded-xl bg-gray-100 dark:bg-gray-800"></div>
{/each}
</div>
{:else if filteredLinks.length === 0}
<div
class="rounded-xl border-2 border-dashed border-gray-300 p-12 text-center dark:border-gray-600"
>
<LinkIcon size={48} class="mx-auto mb-4 opacity-20" />
<p class="text-lg font-medium opacity-60">Keine Links gefunden</p>
{#if searchQuery || selectedStatus !== 'all' || selectedFolderId}
<p class="mt-1 text-sm opacity-40">Versuche andere Filtereinstellungen.</p>
{:else}
<p class="mt-1 text-sm opacity-40">Erstelle Links auf der uLoad-Hauptseite.</p>
{/if}
</div>
{:else}
<div class="space-y-3">
{#each filteredLinks as link (link.id)}
<div
class="group rounded-xl border border-gray-200 bg-white p-4 shadow-sm transition-all hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-center justify-between">
{#if selectMode}
<input
type="checkbox"
checked={selectedIds.has(link.id)}
onchange={() => toggleSelect(link.id)}
class="mr-3 h-4 w-4 shrink-0 rounded"
/>
{/if}
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
class="inline-block h-2 w-2 shrink-0 rounded-full {link.isActive
? 'bg-green-500'
: 'bg-gray-400'}"
></span>
<h3 class="truncate font-semibold">{link.title || link.shortCode}</h3>
<span
class="shrink-0 rounded bg-indigo-100 px-2 py-0.5 font-mono text-xs text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300"
>
/{link.shortCode}
</span>
{#if link.utmSource || link.utmMedium || link.utmCampaign}
<span
class="shrink-0 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300"
>UTM</span
>
{/if}
</div>
<p class="mt-1 truncate text-sm opacity-60">{link.originalUrl}</p>
{#if getLinkTags(linkTags, tags, link.id).length > 0}
<div class="mt-1 flex gap-1">
{#each getLinkTags(linkTags, tags, link.id) as tag}
<span
class="rounded px-1.5 py-0.5 text-[10px] font-medium"
style="background: {tag.color ?? '#6b7280'}20; color: {tag.color ??
'#6b7280'}"
>
{tag.name}
</span>
{/each}
</div>
{/if}
</div>
<div class="ml-4 flex items-center gap-1">
<a
href="/uload/analytics/{link.id}"
class="flex items-center gap-1 rounded-lg px-2 py-1.5 text-sm font-medium opacity-60 transition-all hover:bg-gray-100 hover:opacity-100 dark:hover:bg-gray-700"
title="Analytics"
>
<ChartBar size={16} />
{link.clickCount}
</a>
<button
onclick={() => copyShortUrl(link.shortCode)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title="Link kopieren"
>
<Copy size={16} />
</button>
<button
onclick={() => toggleActive(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-gray-100 group-hover:opacity-100 dark:hover:bg-gray-700"
title={link.isActive ? 'Deaktivieren' : 'Aktivieren'}
>
<Lightning size={16} class={link.isActive ? 'text-green-500' : 'text-gray-400'} />
</button>
<button
onclick={() => deleteLink(link)}
class="rounded-lg p-2 opacity-0 transition-all hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
title="Loeschen"
>
<Trash size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>