diff --git a/apps/manacore/apps/web/src/lib/modules/context/collections.ts b/apps/manacore/apps/web/src/lib/modules/context/collections.ts new file mode 100644 index 000000000..16ff2334b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/context/collections.ts @@ -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('contextSpaces'); +export const documentTable = db.table('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'] }, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/context/index.ts b/apps/manacore/apps/web/src/lib/modules/context/index.ts new file mode 100644 index 000000000..e3774a443 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/context/index.ts @@ -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'; diff --git a/apps/manacore/apps/web/src/lib/modules/context/queries.ts b/apps/manacore/apps/web/src/lib/modules/context/queries.ts new file mode 100644 index 000000000..afcaa0701 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/context/queries.ts @@ -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('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('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('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(); + 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); +} diff --git a/apps/manacore/apps/web/src/lib/modules/context/types.ts b/apps/manacore/apps/web/src/lib/modules/context/types.ts new file mode 100644 index 000000000..61e88c4e4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/context/types.ts @@ -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 | 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 | 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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/nutriphi/collections.ts b/apps/manacore/apps/web/src/lib/modules/nutriphi/collections.ts new file mode 100644 index 000000000..113182c22 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/nutriphi/collections.ts @@ -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('meals'); +export const goalTable = db.table('goals'); +export const nutriFavoriteTable = db.table('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, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/nutriphi/constants.ts b/apps/manacore/apps/web/src/lib/modules/nutriphi/constants.ts new file mode 100644 index 000000000..7abe18c98 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/nutriphi/constants.ts @@ -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'; +} diff --git a/apps/manacore/apps/web/src/lib/modules/nutriphi/index.ts b/apps/manacore/apps/web/src/lib/modules/nutriphi/index.ts new file mode 100644 index 000000000..bee130a23 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/nutriphi/index.ts @@ -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'; diff --git a/apps/manacore/apps/web/src/lib/modules/nutriphi/queries.ts b/apps/manacore/apps/web/src/lib/modules/nutriphi/queries.ts new file mode 100644 index 000000000..1c89ad1e1 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/nutriphi/queries.ts @@ -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('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('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('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)); +} diff --git a/apps/manacore/apps/web/src/lib/modules/nutriphi/types.ts b/apps/manacore/apps/web/src/lib/modules/nutriphi/types.ts new file mode 100644 index 000000000..9f6aa4032 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/nutriphi/types.ts @@ -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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/presi/collections.ts b/apps/manacore/apps/web/src/lib/modules/presi/collections.ts new file mode 100644 index 000000000..5b8bcdbaa --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/presi/collections.ts @@ -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('presiDecks'); +export const slideTable = db.table('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', + ], + }, + }, + ], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/presi/index.ts b/apps/manacore/apps/web/src/lib/modules/presi/index.ts new file mode 100644 index 000000000..f093dabc4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/presi/index.ts @@ -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'; diff --git a/apps/manacore/apps/web/src/lib/modules/presi/queries.ts b/apps/manacore/apps/web/src/lib/modules/presi/queries.ts new file mode 100644 index 000000000..d548a415b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/presi/queries.ts @@ -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('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('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('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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/presi/stores/decks.svelte.ts b/apps/manacore/apps/web/src/lib/modules/presi/stores/decks.svelte.ts new file mode 100644 index 000000000..283a447ea --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/presi/stores/decks.svelte.ts @@ -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(null); + +function createDecksStore() { + async function createDeck(dto: CreateDeckDto): Promise { + 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 { + error = null; + try { + const localUpdates: Partial & { 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 { + 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 { + 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 { + error = null; + try { + const localUpdates: Partial & { 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 { + 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 { + 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(); diff --git a/apps/manacore/apps/web/src/lib/modules/presi/types.ts b/apps/manacore/apps/web/src/lib/modules/presi/types.ts new file mode 100644 index 000000000..88e96bb29 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/presi/types.ts @@ -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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/questions/collections.ts b/apps/manacore/apps/web/src/lib/modules/questions/collections.ts new file mode 100644 index 000000000..6d9455ed4 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/questions/collections.ts @@ -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('qCollections'); +export const questionTable = db.table('questions'); +export const answerTable = db.table('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>, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/questions/index.ts b/apps/manacore/apps/web/src/lib/modules/questions/index.ts new file mode 100644 index 000000000..69fddd446 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/questions/index.ts @@ -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'; diff --git a/apps/manacore/apps/web/src/lib/modules/questions/queries.ts b/apps/manacore/apps/web/src/lib/modules/questions/queries.ts new file mode 100644 index 000000000..685219bea --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/questions/queries.ts @@ -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('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('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('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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/questions/types.ts b/apps/manacore/apps/web/src/lib/modules/questions/types.ts new file mode 100644 index 000000000..a40dbd802 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/questions/types.ts @@ -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; +} diff --git a/apps/manacore/apps/web/src/lib/modules/uload/collections.ts b/apps/manacore/apps/web/src/lib/modules/uload/collections.ts new file mode 100644 index 000000000..62b4b2956 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/uload/collections.ts @@ -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('links'); +export const uloadTagTable = db.table('uloadTags'); +export const uloadFolderTable = db.table('uloadFolders'); +export const linkTagTable = db.table('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[], +}; diff --git a/apps/manacore/apps/web/src/lib/modules/uload/index.ts b/apps/manacore/apps/web/src/lib/modules/uload/index.ts new file mode 100644 index 000000000..0b33a1b52 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/uload/index.ts @@ -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'; diff --git a/apps/manacore/apps/web/src/lib/modules/uload/queries.ts b/apps/manacore/apps/web/src/lib/modules/uload/queries.ts new file mode 100644 index 000000000..cf83a114b --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/uload/queries.ts @@ -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('links').toArray(); + return locals.filter((l) => !l.deletedAt).map(toLink); + }); +} + +export function allTags$() { + return liveQuery(async () => { + const locals = await db.table('uloadTags').toArray(); + return locals.filter((t) => !t.deletedAt).map(toTag); + }); +} + +export function allFolders$() { + return liveQuery(async () => { + const locals = await db.table('uloadFolders').toArray(); + return locals.filter((f) => !f.deletedAt).map(toFolder); + }); +} + +export function allLinkTags$() { + return liveQuery(async () => { + const locals = await db.table('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('links').toArray(); + return locals.filter((l) => !l.deletedAt).map(toLink); + }, [] as Link[]); +} + +export function useAllTags() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('uloadTags').toArray(); + return locals.filter((t) => !t.deletedAt).map(toTag); + }, [] as Tag[]); +} + +export function useAllFolders() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('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('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('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, ''); +} diff --git a/apps/manacore/apps/web/src/lib/modules/uload/types.ts b/apps/manacore/apps/web/src/lib/modules/uload/types.ts new file mode 100644 index 000000000..dd123ec9a --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/uload/types.ts @@ -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; +} diff --git a/apps/manacore/apps/web/src/routes/(app)/context/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/context/+page.svelte new file mode 100644 index 000000000..b41fa9bed --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/context/+page.svelte @@ -0,0 +1,200 @@ + + + + Context - ManaCore + + +
+
+

Context

+

Dein Wissensmanagement Hub

+
+ + +
+
+
{spaces.length}
+
Spaces
+
+
+
{stats.total}
+
Dokumente
+
+
+
{stats.totalWords.toLocaleString()}
+
Woerter
+
+
+
{stats.text}/{stats.context}/{stats.prompt}
+
Text/Kontext/Prompt
+
+
+ + + + + + {#if pinnedSpaces.length > 0} +
+

Angeheftete Spaces

+ +
+ {/if} + + + {#if recentDocs.length > 0} +
+
+

Zuletzt bearbeitet

+ Alle anzeigen +
+ +
+ {:else} +
+ +

Noch keine Dokumente

+

+ Erstelle deinen ersten Space und beginne mit dem Schreiben. +

+ + + Ersten Space erstellen + +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/context/documents/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/context/documents/+page.svelte new file mode 100644 index 000000000..9dc0a4125 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/context/documents/+page.svelte @@ -0,0 +1,268 @@ + + + + Dokumente - Context - ManaCore + + +
+
+
+
+ ← Context +

Dokumente

+
+

+ {stats.total} Dokumente, {stats.totalWords.toLocaleString()} Woerter +

+
+ +
+ + +
+
+ {#each typeFilters as filter} + + {/each} +
+ +
+ + +
+
+ + + {#if allTags.length > 0} +
+ {#each allTags as tag} + + {/each} +
+ {/if} + + + {#if filteredDocuments.length > 0} +
+ {#each filteredDocuments as doc (doc.id)} +
+
+ +
+ + {doc.type} + + {#if doc.pinned} + Angeheftet + {/if} +
+

{doc.title}

+ {#if doc.content} +

+ {doc.content.slice(0, 100)} +

+ {/if} +
+
+ + +
+
+
+ {#if doc.metadata?.tags && doc.metadata.tags.length > 0} + {#each doc.metadata.tags.slice(0, 3) as tag} + {tag} + {/each} + {/if} + + {new Date(doc.updated_at).toLocaleDateString('de')} + +
+
+ {/each} +
+ {:else if searchQuery || typeFilter !== 'all' || tagFilter.length > 0} +
+

Keine Dokumente gefunden

+ +
+ {:else} +
+ +

Noch keine Dokumente

+

+ Dokumente enthalten dein Wissen, Kontext-Referenzen und AI-Prompts. +

+ +
+ {/if} +
+ + +{#if deleteTarget} +
(deleteTarget = null)} + > +
e.stopPropagation()} + > +

Dokument loeschen?

+

Das Dokument wird unwiderruflich geloescht.

+
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/context/documents/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/context/documents/[id]/+page.svelte new file mode 100644 index 000000000..97f667e18 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/context/documents/[id]/+page.svelte @@ -0,0 +1,232 @@ + + + + {doc?.title || 'Dokument'} - Context - ManaCore + + +
+ {#if !doc} +
Lade Dokument...
+ {:else} + +
+
+ {#if doc.space_id} + + + Zurueck zum Space + + {:else} + + + Alle Dokumente + + {/if} +
+ +
+ {#if saving} + Speichert... + {/if} + + +
+
+ + +
+ + + + +
+
+ {#each typeOptions as opt} + + {/each} +
+
+ +
+ + + +
+ + +
+ {#if doc.short_id} + ID: {doc.short_id} + {/if} + {#if doc.metadata?.word_count} + {doc.metadata.word_count} Woerter + {/if} + + Erstellt: {new Date(doc.created_at).toLocaleDateString('de')} + + + Aktualisiert: {new Date(doc.updated_at).toLocaleDateString('de')} + +
+ {/if} +
+ + +{#if showDeleteConfirm} +
(showDeleteConfirm = false)} + > +
e.stopPropagation()} + > +

Dokument loeschen?

+

Das Dokument wird unwiderruflich geloescht.

+
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/context/spaces/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/context/spaces/+page.svelte new file mode 100644 index 000000000..7df2c3110 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/context/spaces/+page.svelte @@ -0,0 +1,251 @@ + + + + Spaces - Context - ManaCore + + +
+
+
+ ← Context +

Spaces

+
+ +
+ + + {#if showCreateForm} +
+

Neuen Space erstellen

+
+
+ + e.key === 'Enter' && handleCreate()} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {/if} + + +
+ + +
+ + {#if filteredSpaces.length > 0} +
+ {#each filteredSpaces as space (space.id)} +
+
+ +
+ + {space.prefix || space.name[0]?.toUpperCase() || 'S'} + +
+

{space.name}

+ {#if space.description} +

{space.description}

+ {/if} +
+
+
+
+ + +
+
+
+ Erstellt: {new Date(space.created_at).toLocaleDateString('de')} +
+
+ {/each} +
+ {:else if searchQuery} +
+

Keine Spaces gefunden fuer "{searchQuery}"

+
+ {:else} +
+ +

Noch keine Spaces

+

+ Spaces helfen dir, dein Wissen zu organisieren. Erstelle deinen ersten Space, um loszulegen. +

+ +
+ {/if} +
+ + +{#if deleteTarget} +
(deleteTarget = null)} + > +
e.stopPropagation()} + > +

Space loeschen?

+

+ Alle Dokumente in diesem Space werden ebenfalls geloescht. Diese Aktion kann nicht + rueckgaengig gemacht werden. +

+
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/context/spaces/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/context/spaces/[id]/+page.svelte new file mode 100644 index 000000000..58d95715f --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/context/spaces/[id]/+page.svelte @@ -0,0 +1,277 @@ + + + + {space?.name || 'Space'} - Context - ManaCore + + +
+ +
+ + + Spaces + + / + {space?.name || '...'} +
+ + {#if !space} +
Lade...
+ {:else} + +
+ {#if editingName} +
+ + +
+ + +
+
+ {:else} +
+
+

{space.name}

+ {#if space.description} +

{space.description}

+ {/if} +
+ {stats.total} Dokumente + {stats.totalWords.toLocaleString()} Woerter +
+
+ +
+ {/if} +
+ + +
+
+ {#each typeFilters as filter} + + {/each} +
+ +
+
+ + +
+ +
+
+ + + {#if filteredDocuments.length > 0} +
+ {#each filteredDocuments as doc (doc.id)} +
+
+ +
+ + {doc.type} + + {#if doc.pinned} + Angeheftet + {/if} +
+

{doc.title}

+ {#if doc.content} +

+ {doc.content.slice(0, 100)} +

+ {/if} +
+
+ + +
+
+
+ {new Date(doc.updated_at).toLocaleDateString('de')} +
+
+ {/each} +
+ {:else} +
+

Keine Dokumente in diesem Space

+ +
+ {/if} + {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/nutriphi/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/nutriphi/+page.svelte new file mode 100644 index 000000000..d1958630f --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/nutriphi/+page.svelte @@ -0,0 +1,260 @@ + + + + NutriPhi - ManaCore + + +
+ +
+
+

Heute

+

+ {new Date().toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })} +

+
+ +
+ + +
+ +
+
+
+ Kalorien +
+

+ {progress.calories.current} +

+

+ / {progress.calories.target} kcal +

+
+
+
+
+ + +
+
+
+ Protein +
+

+ {progress.protein.current}g +

+

+ / {progress.protein.target}g +

+
+
+
+
+ + +
+
+
+ Kohlenhydrate +
+

+ {progress.carbs.current}g +

+

+ / {progress.carbs.target}g +

+
+
+
+
+ + +
+
+
+ Fett +
+

+ {progress.fat.current}g +

+

+ / {progress.fat.target}g +

+
+
+
+
+
+ + +
+
+

Heutige Mahlzeiten

+ + {todaysMeals.length} Eintraege + +
+ + {#if todaysMeals.length === 0} +
+ 🍽️ +

+ Noch keine Mahlzeiten +

+

+ Trage deine erste Mahlzeit ein. +

+ + Mahlzeit hinzufuegen + +
+ {:else} +
+ {#each todaysMeals as meal (meal.id)} +
+
+
+
+ + {getMealTypeLabel(meal.mealType)} + + + {formatTime(meal.createdAt)} + +
+

+ {meal.description} +

+ + {#if meal.nutrition} +
+ {meal.nutrition.calories} kcal + {meal.nutrition.protein}g Protein + {meal.nutrition.carbohydrates}g Carbs + {meal.nutrition.fat}g Fett +
+ {/if} +
+ + {#if meal.nutrition} + + {meal.nutrition.calories} + kcal + + {/if} +
+
+ {/each} +
+ {/if} +
+ + + +
diff --git a/apps/manacore/apps/web/src/routes/(app)/nutriphi/add/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/nutriphi/add/+page.svelte new file mode 100644 index 000000000..be608799d --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/nutriphi/add/+page.svelte @@ -0,0 +1,268 @@ + + + + Mahlzeit hinzufuegen - NutriPhi - ManaCore + + +
+ +
+ + + Zurueck + +

Mahlzeit hinzufuegen

+
+ + {#if error} +
+ {error} +
+ {/if} + + + {#if favorites.length > 0} +
+

Favoriten

+
+ {#each favorites as fav (fav.id)} + + {/each} +
+
+ {/if} + +
+ +
+ +
+ {#each mealTypes as type} + + {/each} +
+
+ + +
+ + +
+ + +
+

+ Naehrwerte (optional) +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ + Abbrechen + + +
+
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/nutriphi/goals/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/nutriphi/goals/+page.svelte new file mode 100644 index 000000000..062a48843 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/nutriphi/goals/+page.svelte @@ -0,0 +1,228 @@ + + + + Ziele - NutriPhi - ManaCore + + +
+ +
+ + + Zurueck + +

Tagesziele

+

+ Passe deine taeglichen Naehrwertziele an +

+
+ + {#if saved} +
+ Ziele gespeichert! +
+ {/if} + +
+ +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+
+ + +
+

+ Die Standardwerte basieren auf einer 2000 kcal Diaet. Passe sie an deine individuellen + Beduerfnisse an. Konsultiere bei Bedarf einen Ernaehrungsberater. +

+
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/nutriphi/history/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/nutriphi/history/+page.svelte new file mode 100644 index 000000000..928fcf546 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/nutriphi/history/+page.svelte @@ -0,0 +1,203 @@ + + + + Verlauf - NutriPhi - ManaCore + + +
+ +
+ + + Zurueck + +

Mahlzeiten-Verlauf

+

+ {meals.length} Eintraege insgesamt +

+
+ + +
+
+ + +
+ + {#if selectedDate} + + {/if} +
+ + + {#if groupedByDate.length === 0} +
+ 📋 +

Keine Eintraege

+

+ {searchQuery || selectedDate + ? 'Keine Ergebnisse fuer diese Filter.' + : 'Noch keine Mahlzeiten erfasst.'} +

+
+ {:else} +
+ {#each groupedByDate as group (group.date)} +
+
+

+ {formatDateHeader(group.date)} +

+ + {Math.round(group.totalCalories)} kcal + +
+ +
+ {#each group.meals as meal (meal.id)} +
+
+
+ + {getMealTypeLabel(meal.mealType)} + + + {formatTime(meal.createdAt)} + +
+

+ {meal.description} +

+
+ + {#if meal.nutrition} + + {meal.nutrition.calories} kcal + + {/if} + + +
+ {/each} +
+
+ {/each} +
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/presi/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/presi/+page.svelte new file mode 100644 index 000000000..4507ecb18 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/presi/+page.svelte @@ -0,0 +1,220 @@ + + + + Presi - Presentations + + +
+
+
+

My Presentations

+

Create and manage your slide decks

+
+ +
+ + {#if decks.length === 0} +
+
+ +
+

No presentations yet

+

Create your first deck to get started

+ +
+ {:else} +
+ {#each decks as deck (deck.id)} +
+ +
+ +
+
+

{deck.title}

+ {#if deck.description} +

+ {deck.description} +

+ {/if} +
+ + + {formatDate(deck.updatedAt)} + +
+
+
+
+ +
+
+ {/each} +
+ {/if} +
+ + +{#if showCreateModal} +
+
+
+
+

Create New Deck

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+{/if} + + +{#if showDeleteModal} +
+
+

Delete Deck

+

+ Are you sure you want to delete "{deckToDelete?.title}"? +

+
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/presi/deck/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/presi/deck/[id]/+page.svelte new file mode 100644 index 000000000..38ef6b2c3 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/presi/deck/[id]/+page.svelte @@ -0,0 +1,388 @@ + + + + {currentDeck?.title || 'Loading...'} - Presi + + +
+ {#if currentDeck} +
+
+ + + +
+

{currentDeck.title}

+ {#if currentDeck.description} +

{currentDeck.description}

+ {/if} +
+
+
+ + {#if currentSlides.length > 0} + + Present + + {/if} +
+
+ + {#if currentSlides.length === 0} +
+
+ +
+

No slides yet

+ +
+ {:else} +
+ {#each currentSlides as slide, index (slide.id)} +
+ +
+ Slide {index + 1} +
+ + + + +
+
+
+ {/each} +
+ {/if} + {/if} +
+ + +{#if showSlideModal} +
+
+
+
+

+ {editingSlide ? 'Edit Slide' : 'New Slide'} +

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ {#each slideBulletPoints as point, index} +
+ + 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..." + /> + +
+ {/each} + +
+
+
+
+ + +
+
+
+
+{/if} + + +{#if showDeleteModal} +
+
+

Delete Slide

+

+ Are you sure you want to delete this slide? +

+
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/presi/present/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/presi/present/[id]/+page.svelte new file mode 100644 index 000000000..e9ccc811d --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/presi/present/[id]/+page.svelte @@ -0,0 +1,231 @@ + + +Presenting: {currentDeck?.title || 'Loading...'} + +
+ {#if currentSlide} +
+
+

{currentDeck?.title}

+ Slide {currentSlideIndex + 1} of {currentSlides.length} +
+ +
+ +
+
+ {#if currentSlide.content.imageUrl} + {currentSlide.content.title + {:else} +
+ {#if currentSlide.content.title}

+ {currentSlide.content.title} +

{/if} + {#if currentSlide.content.body}

+ {currentSlide.content.body} +

{/if} + {#if currentSlide.content.bulletPoints?.length} +
    + {#each currentSlide.content.bulletPoints as point} +
  • + {point} +
  • + {/each} +
+ {/if} +
+ {/if} +
+
+ +
+
+
+ +
+ {formatTime(elapsedSeconds)} +
+
+
+ +
+ {#each currentSlides as _, index} + + {/each} +
+ +
+
+ + +
+
+
+ {:else} +
+

No slides in this deck

+
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/questions/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/questions/+page.svelte new file mode 100644 index 000000000..8267212f2 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/questions/+page.svelte @@ -0,0 +1,233 @@ + + + + Fragen - ManaCore + + +
+ +
+
+

+ {selectedCollection ? selectedCollection.name : 'Alle Fragen'} +

+

+ {filteredQuestions.length} Frage{filteredQuestions.length !== 1 ? 'n' : ''} +

+
+ +
+ + +
+
+ + +
+ + + + {#if collections.length > 0} + + {/if} +
+ + + {#if filteredQuestions.length === 0} +
+ 🔍 +

Keine Fragen

+

+ Stelle deine erste Frage und lass die KI recherchieren. +

+ + Neue Frage + +
+ {:else} + + {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/questions/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/questions/[id]/+page.svelte new file mode 100644 index 000000000..b041a4774 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/questions/[id]/+page.svelte @@ -0,0 +1,364 @@ + + + + {question?.title || 'Frage'} - ManaCore + + +{#if !question} +
+

Frage nicht gefunden

+ Zurueck +
+{:else} +
+ +
+ + + Zurueck zu Fragen + + +
+
+ {#if editing} + + +
+ + +
+ {:else} +

{question.title}

+ {#if question.description} +

{question.description}

+ {/if} + {/if} + +
+ + + {statusLabels[question.status]?.label} + + + + + {depthLabels[question.researchDepth] ?? question.researchDepth} + + + + {#if question.tags?.length} + {#each question.tags as tag} + + {tag} + + {/each} + {/if} + + + + {formatDate(question.createdAt)} + +
+
+ + + {#if !editing} +
+ + +
+ {/if} +
+
+ + +
+ {#each ['open', 'researching', 'answered', 'archived'] as status} + + {/each} +
+ + +
+

+ Antworten ({answers.length}) +

+ + {#if answers.length === 0} +
+ 📝 +

+ Noch keine Antworten. Fuege die erste Antwort hinzu. +

+
+ {:else} + {#each answers as answer (answer.id)} +
+ {#if answer.isAccepted} +
+ + Akzeptierte Antwort +
+ {/if} + +
+ {answer.content} +
+ +
+ + {formatDate(answer.createdAt)} + +
+ {#if !answer.isAccepted} + + {/if} + +
+
+
+ {/each} + {/if} + + +
+

+ Antwort hinzufuegen +

+ +
+ +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/questions/collections/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/questions/collections/+page.svelte new file mode 100644 index 000000000..ac0ab87a2 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/questions/collections/+page.svelte @@ -0,0 +1,290 @@ + + + + Sammlungen - Fragen - ManaCore + + +
+ +
+
+ + + Zurueck zu Fragen + +

Sammlungen

+

+ Organisiere deine Fragen in Sammlungen +

+
+ +
+ + + {#if collections.length === 0} +
+ 📁 +

Keine Sammlungen

+

+ Erstelle deine erste Sammlung, um Fragen zu organisieren. +

+ +
+ {:else} +
+ {#each collections as collection (collection.id)} +
+ +
+ +
+ + +
+
+

{collection.name}

+ {#if collection.isDefault} + + Standard + + {/if} +
+ {#if collection.description} +

+ {collection.description} +

+ {/if} +

+ {getQuestionCountByCollection(questions, collection.id)} Fragen +

+
+ + +
+ + + {#if deleteConfirm === collection.id} +
+ + +
+ {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ + +{#if showModal} +
+
+

+ {editingCollection ? 'Sammlung bearbeiten' : 'Neue Sammlung'} +

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/questions/new/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/questions/new/+page.svelte new file mode 100644 index 000000000..8365caea2 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/questions/new/+page.svelte @@ -0,0 +1,254 @@ + + + + Neue Frage - ManaCore + + +
+ +
+ + + Zurueck zu Fragen + +

Neue Frage

+

+ Stelle eine Frage und lass die KI recherchieren +

+
+ +
+ {#if error} +
+ {error} +
+ {/if} + + +
+ + +
+ + +
+ + +
+ + + {#if collections.length > 0} +
+ + +
+ {/if} + + +
+ +
+ {#each tags as tag} + + {tag} + + + {/each} +
+ 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))]" + /> +
+ + +
+ +
+ {#each depthOptions as option} + + {/each} +
+
+ + +
+ + +
+ + +
+ + Abbrechen + + +
+
+
diff --git a/apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte new file mode 100644 index 000000000..7f843daf2 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte @@ -0,0 +1,683 @@ + + + + uLoad - ManaCore + + +
+
+ +
+
+

uLoad

+

+ {filteredLinks.length} Links + {#if folders.length > 0} + · {folders.length} Ordner + {/if} +

+
+
+ + Alle Links + + +
+
+ + + {#if showCreateForm} +
+
+
+ + e.key === 'Enter' && createLink()} + /> +
+
+ + +
+
+ + +
+
+ + + + {#if showAdvanced} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + + + + {#if showUtm} +
+
+ + +
+
+ + +
+
+ + +
+
+ {/if} + +
+ +
+
+ {/if} + + +
+
+ + +
+ + {#if folders.length > 0} + + {/if} +
+ + + {#if allLinks.loading} +
+ {#each Array(3) as _} +
+ {/each} +
+ {:else if filteredLinks.length === 0} +
+ +

Noch keine Links

+

Erstelle deinen ersten gekuerzten Link!

+ +
+ {:else} +
+ {#each filteredLinks as link (link.id)} +
+
+
+
+ +

{link.title || link.shortCode}

+ + /{link.shortCode} + + {#if link.utmSource || link.utmMedium || link.utmCampaign} + UTM + {/if} + {#if link.password} + Passwort + {/if} + {#if link.expiresAt} + Ablauf + {/if} +
+

{link.originalUrl}

+ {#if getLinkTags(linkTags, tags, link.id).length > 0} +
+ {#each getLinkTags(linkTags, tags, link.id) as tag} + + {tag.name} + + {/each} +
+ {/if} +
+ +
+ + + {link.clickCount} + + + + + + +
+
+
+ {/each} +
+ {/if} +
+
+ + +{#if editingLink} +
(editingLink = null)} + > +
e.stopPropagation()} + > +
+

Link bearbeiten

+ +
+ +
+
+ + +
+
+ + +
+
+ +
+ /{editingLink.shortCode} + (nicht aenderbar) +
+
+ +
+

UTM-Parameter

+
+ + + +
+
+ +
+

Erweitert

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + +
+
+
+{/if} + + +{#if qrLink} +
(qrLink = null)} + > +
e.stopPropagation()} + > +
+

QR-Code

+ +
+ +
+
+ QR Code fuer {qrLink.shortCode} +
+

{getShortUrl(qrLink.shortCode)}

+
+ + +
+
+
+
+{/if} diff --git a/apps/manacore/apps/web/src/routes/(app)/uload/analytics/[id]/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/uload/analytics/[id]/+page.svelte new file mode 100644 index 000000000..14bced654 --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/uload/analytics/[id]/+page.svelte @@ -0,0 +1,189 @@ + + + + Analytics - uLoad - ManaCore + + +
+ +
+ + + +
+

Analytics

+ {#if link} +

+ /{link.shortCode} + → {link.originalUrl} +

+ {/if} +
+
+ + {#if !link} +
+ {#each Array(4) as _} +
+ {/each} +
+ {:else} + +
+
+

Clicks

+

{link.clickCount}

+
+
+

Status

+

+ {#if link.isActive} + Aktiv + {:else} + Inaktiv + {/if} +

+
+
+

Erstellt

+

+ {new Date(link.createdAt).toLocaleDateString('de')} +

+
+
+

Short URL

+

+ ulo.ad/{link.shortCode} +

+
+
+ + +
+

Link Details

+
+ + {#if link.title} +
+ Titel + {link.title} +
+ {/if} + {#if link.utmSource || link.utmMedium || link.utmCampaign} +
+

+ UTM-Parameter +

+
+ {#if link.utmSource} +
+ Source: + {link.utmSource} +
+ {/if} + {#if link.utmMedium} +
+ Medium: + {link.utmMedium} +
+ {/if} + {#if link.utmCampaign} +
+ Campaign: + {link.utmCampaign} +
+ {/if} +
+
+ {/if} + {#if link.expiresAt} +
+ Laeuft ab + {new Date(link.expiresAt).toLocaleDateString('de')} +
+ {/if} + {#if link.maxClicks} +
+ Max Klicks + {link.clickCount} / {link.maxClicks} +
+ {/if} + {#if link.password} +
+ Passwortgeschuetzt + Ja +
+ {/if} +
+
+ + +
+
+

Clicks ueber Zeit

+
+ {#each [7, 30, 90] as d} + + {/each} +
+
+
+

+ Detaillierte Analytics sind verfuegbar, wenn der uLoad-Server verbunden ist. +

+

+ Lokaler Click-Count: {link.clickCount} +

+
+
+ {/if} +
diff --git a/apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte new file mode 100644 index 000000000..79a20f97c --- /dev/null +++ b/apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte @@ -0,0 +1,330 @@ + + + + Alle Links - uLoad - ManaCore + + +
+
+ +
+
+ + + +
+

+ Alle Links + {#if filteredLinks.length > 0} + ({filteredLinks.length}) + {/if} +

+
+
+
+ +
+
+ + +
+
+ + +
+ + {#if folders.length > 0} + + {/if} +
+ + + {#if selectMode && selectedIds.size > 0} +
+ +
+ + +
+ {/if} + + + {#if allLinks.loading} +
+ {#each Array(5) as _} +
+ {/each} +
+ {:else if filteredLinks.length === 0} +
+ +

Keine Links gefunden

+ {#if searchQuery || selectedStatus !== 'all' || selectedFolderId} +

Versuche andere Filtereinstellungen.

+ {:else} +

Erstelle Links auf der uLoad-Hauptseite.

+ {/if} +
+ {:else} +
+ {#each filteredLinks as link (link.id)} +
+
+ {#if selectMode} + toggleSelect(link.id)} + class="mr-3 h-4 w-4 shrink-0 rounded" + /> + {/if} +
+
+ +

{link.title || link.shortCode}

+ + /{link.shortCode} + + {#if link.utmSource || link.utmMedium || link.utmCampaign} + UTM + {/if} +
+

{link.originalUrl}

+ {#if getLinkTags(linkTags, tags, link.id).length > 0} +
+ {#each getLinkTags(linkTags, tags, link.id) as tag} + + {tag.name} + + {/each} +
+ {/if} +
+ +
+ + + {link.clickCount} + + + + +
+
+
+ {/each} +
+ {/if} +
+