mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
ce3ed10b60
commit
1022d2f64c
41 changed files with 7201 additions and 0 deletions
|
|
@ -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'] },
|
||||
},
|
||||
],
|
||||
};
|
||||
14
apps/manacore/apps/web/src/lib/modules/context/index.ts
Normal file
14
apps/manacore/apps/web/src/lib/modules/context/index.ts
Normal 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';
|
||||
151
apps/manacore/apps/web/src/lib/modules/context/queries.ts
Normal file
151
apps/manacore/apps/web/src/lib/modules/context/queries.ts
Normal 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);
|
||||
}
|
||||
79
apps/manacore/apps/web/src/lib/modules/context/types.ts
Normal file
79
apps/manacore/apps/web/src/lib/modules/context/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
44
apps/manacore/apps/web/src/lib/modules/nutriphi/constants.ts
Normal file
44
apps/manacore/apps/web/src/lib/modules/nutriphi/constants.ts
Normal 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';
|
||||
}
|
||||
17
apps/manacore/apps/web/src/lib/modules/nutriphi/index.ts
Normal file
17
apps/manacore/apps/web/src/lib/modules/nutriphi/index.ts
Normal 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';
|
||||
150
apps/manacore/apps/web/src/lib/modules/nutriphi/queries.ts
Normal file
150
apps/manacore/apps/web/src/lib/modules/nutriphi/queries.ts
Normal 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));
|
||||
}
|
||||
69
apps/manacore/apps/web/src/lib/modules/nutriphi/types.ts
Normal file
69
apps/manacore/apps/web/src/lib/modules/nutriphi/types.ts
Normal 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;
|
||||
}
|
||||
69
apps/manacore/apps/web/src/lib/modules/presi/collections.ts
Normal file
69
apps/manacore/apps/web/src/lib/modules/presi/collections.ts
Normal 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',
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
26
apps/manacore/apps/web/src/lib/modules/presi/index.ts
Normal file
26
apps/manacore/apps/web/src/lib/modules/presi/index.ts
Normal 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';
|
||||
81
apps/manacore/apps/web/src/lib/modules/presi/queries.ts
Normal file
81
apps/manacore/apps/web/src/lib/modules/presi/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
73
apps/manacore/apps/web/src/lib/modules/presi/types.ts
Normal file
73
apps/manacore/apps/web/src/lib/modules/presi/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>>,
|
||||
};
|
||||
23
apps/manacore/apps/web/src/lib/modules/questions/index.ts
Normal file
23
apps/manacore/apps/web/src/lib/modules/questions/index.ts
Normal 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';
|
||||
163
apps/manacore/apps/web/src/lib/modules/questions/queries.ts
Normal file
163
apps/manacore/apps/web/src/lib/modules/questions/queries.ts
Normal 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;
|
||||
}
|
||||
73
apps/manacore/apps/web/src/lib/modules/questions/types.ts
Normal file
73
apps/manacore/apps/web/src/lib/modules/questions/types.ts
Normal 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;
|
||||
}
|
||||
118
apps/manacore/apps/web/src/lib/modules/uload/collections.ts
Normal file
118
apps/manacore/apps/web/src/lib/modules/uload/collections.ts
Normal 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[],
|
||||
};
|
||||
33
apps/manacore/apps/web/src/lib/modules/uload/index.ts
Normal file
33
apps/manacore/apps/web/src/lib/modules/uload/index.ts
Normal 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';
|
||||
263
apps/manacore/apps/web/src/lib/modules/uload/queries.ts
Normal file
263
apps/manacore/apps/web/src/lib/modules/uload/queries.ts
Normal 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, '');
|
||||
}
|
||||
44
apps/manacore/apps/web/src/lib/modules/uload/types.ts
Normal file
44
apps/manacore/apps/web/src/lib/modules/uload/types.ts
Normal 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;
|
||||
}
|
||||
200
apps/manacore/apps/web/src/routes/(app)/context/+page.svelte
Normal file
200
apps/manacore/apps/web/src/routes/(app)/context/+page.svelte
Normal 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 ? '★' : '☆'}
|
||||
</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>
|
||||
|
|
@ -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">← 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 ? '★' : '☆'}
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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">← 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 ? '★' : '☆'}
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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}
|
||||
|
|
@ -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 ? '★' : '☆'}
|
||||
</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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
260
apps/manacore/apps/web/src/routes/(app)/nutriphi/+page.svelte
Normal file
260
apps/manacore/apps/web/src/routes/(app)/nutriphi/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
220
apps/manacore/apps/web/src/routes/(app)/presi/+page.svelte
Normal file
220
apps/manacore/apps/web/src/routes/(app)/presi/+page.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
233
apps/manacore/apps/web/src/routes/(app)/questions/+page.svelte
Normal file
233
apps/manacore/apps/web/src/routes/(app)/questions/+page.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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))]"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
683
apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte
Normal file
683
apps/manacore/apps/web/src/routes/(app)/uload/+page.svelte
Normal 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}
|
||||
· {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}
|
||||
|
|
@ -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>
|
||||
→ <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>
|
||||
330
apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte
Normal file
330
apps/manacore/apps/web/src/routes/(app)/uload/links/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue