feat(manacore): migrate skilltree, inventar, times, planta, citycorners, photos to unified app

Phase 2 continued — 6 more modules migrated (total: 12/25):

- Skilltree: complete routes (main, tree visualization, achievements)
- Inventar: full module (stores, components, schema editor) + 9 routes
- Times: full module (timer, entry parser, export) + 8 routes
- Planta: full module (watering logic, mutations) + 4 routes
- CityCorners: full module (geocoding, opening hours) + 13 routes
- Photos: full module (gallery, albums, upload) + 6 routes

All modules follow unified patterns:
- Imports from $lib/modules/{app}/...
- Internal links with /{app}/ prefix
- Dexie liveQuery for reactive data
- Soft-delete for sync compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-01 17:21:20 +02:00
parent 6d51d3eefb
commit e7999fb7cf
101 changed files with 14322 additions and 0 deletions

View file

@ -4,6 +4,42 @@
> Eine IndexedDB, ein SyncEngine, ein Build, ein Deploy.
> Games und Matrix bleiben separat.
## Status (Stand: 2026-04-01)
### Abgeschlossene Phasen
- **Phase 0:** Vorbereitung abgeschlossen — Modul-Struktur definiert, Route-Namespaces geplant
- **Phase 1:** Fundament steht — Unified Dexie-Datenbank mit 120+ Collections, Table-Name-Kollisionen aufgelöst, `SYNC_APP_MAP` definiert, SvelteKit-App unter `apps/manacore/apps/web/` existiert
### Phase 2: Module migrieren — Fortschritt
| # | App | Modul | Routen | Status |
|---|-----|-------|--------|--------|
| 1 | **calc** | collections, components (5 skins), engine, stores, queries | `/calc`, `/calc/standard` | **Done** |
| 2 | **zitare** | collections, stores (5), components (2), queries | `/zitare` + 6 Sub-Routen | **Done** |
| 3 | **clock** | collections, stores (6), components (2), queries | `/clock`, `/clock/alarms` | **Done** |
| 4 | **skilltree** | collections, stores (2), components (9), queries | Ordner-Struktur, keine .svelte-Dateien | **Modul done, Routen fehlen** |
| 5 | **moodlit** | collections, stores (2), components (3), queries | `/moodlit`, `/moodlit/moods`, `/moodlit/sequences` | **Done** |
| 6 | **inventar** | collections, queries, types (kein stores/components-Ordner) | Ordner-Struktur, keine .svelte-Dateien | **Modul teilweise, Routen fehlen** |
| 7-25 | times, planta, citycorners, photos, presi, uload, context, questions, nutriphi, storage, cards, contacts, todo, calendar, picture, chat, mukke, memoro, playground | Leere Modul-Ordner existieren | — | **Nicht begonnen** |
### Offene Phasen
- **Phase 3:** Split-Screen ohne iFrame — nicht begonnen
- **Phase 4:** Dashboard-Widgets — nicht begonnen
- **Phase 5:** Infrastruktur-Anpassungen (Docker, Cloudflare, CORS) — nicht begonnen
- **Phase 6:** Aufräumen (alte Apps archivieren) — nicht begonnen
- **Phase 7:** local-store Package anpassen — nicht begonnen
### Nächste Schritte
1. Skilltree + Inventar Route-Dateien erstellen (.svelte)
2. Weiter mit **times** (#7), **planta** (#8), **citycorners** (#9) — alle ohne Backend
3. Danach mittlere Apps: photos, presi, uload, context, questions
4. Zuletzt komplexe Apps: contacts, todo, calendar, picture, chat, mukke, memoro
---
## Scope: Was rein kommt, was draußen bleibt
### IN SCOPE — Unified App (22 Apps → 1)

View file

@ -0,0 +1,120 @@
/**
* CityCorners module collection accessors and guest seed data.
*
* Uses prefixed table names in the unified DB: cities, ccLocations, ccFavorites.
*/
import { db } from '$lib/data/database';
import type { LocalCity, LocalLocation, LocalFavorite } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const cityTable = db.table<LocalCity>('cities');
export const ccLocationTable = db.table<LocalLocation>('ccLocations');
export const ccFavoriteTable = db.table<LocalFavorite>('ccFavorites');
// ─── Guest Seed ────────────────────────────────────────────
export const CITYCORNERS_GUEST_SEED = {
cities: [
{
id: 'city-konstanz',
name: 'Konstanz',
slug: 'konstanz',
country: 'Deutschland',
state: 'Baden-Württemberg',
description:
'Universitätsstadt am Bodensee mit mittelalterlicher Altstadt, direkt an der Schweizer Grenze.',
latitude: 47.6603,
longitude: 9.1757,
},
{
id: 'city-zuerich',
name: 'Zürich',
slug: 'zuerich',
country: 'Schweiz',
state: 'Zürich',
description:
'Größte Stadt der Schweiz am Zürichsee, bekannt für Kultur, Finanzen und hohe Lebensqualität.',
latitude: 47.3769,
longitude: 8.5417,
},
{
id: 'city-berlin',
name: 'Berlin',
slug: 'berlin',
country: 'Deutschland',
state: 'Berlin',
description: 'Hauptstadt Deutschlands mit vielfältiger Kultur, Geschichte und Nachtleben.',
latitude: 52.52,
longitude: 13.405,
},
],
ccLocations: [
{
id: 'loc-muenster',
cityId: 'city-konstanz',
name: 'Konstanzer Münster',
category: 'sight' as const,
description:
'Das Münster Unserer Lieben Frau ist die ehemalige Bischofskirche des Bistums Konstanz und Wahrzeichen der Stadt.',
address: 'Münsterplatz 1, 78462 Konstanz',
latitude: 47.6603,
longitude: 9.1752,
},
{
id: 'loc-imperia',
cityId: 'city-konstanz',
name: 'Imperia',
category: 'sight' as const,
description:
'Die 9 Meter hohe Statue im Hafen von Konstanz dreht sich einmal in 4 Minuten um ihre Achse.',
address: 'Hafen, 78462 Konstanz',
latitude: 47.6596,
longitude: 9.1789,
},
{
id: 'loc-insel',
cityId: 'city-konstanz',
name: 'Mainau Blumeninsel',
category: 'park' as const,
description:
'Die Blumeninsel Mainau im Bodensee ist bekannt für ihre Gärten, das Schmetterlingshaus und das Barockschloss.',
address: 'Mainau 1, 78465 Konstanz',
latitude: 47.7051,
longitude: 9.1919,
},
{
id: 'loc-strandbad',
cityId: 'city-konstanz',
name: 'Strandbad Horn',
category: 'beach' as const,
description: 'Beliebtes Freibad am Bodensee mit Sandstrand und Blick auf die Alpen.',
address: 'Eichhornstraße 100, 78464 Konstanz',
latitude: 47.6753,
longitude: 9.2001,
},
{
id: 'loc-grossmuenster',
cityId: 'city-zuerich',
name: 'Grossmünster',
category: 'sight' as const,
description:
'Romanische Kirche aus dem 12. Jahrhundert, Wahrzeichen Zürichs mit Aussichtsturm über die Altstadt.',
address: 'Grossmünsterplatz, 8001 Zürich',
latitude: 47.3701,
longitude: 8.5441,
},
{
id: 'loc-brandenburger-tor',
cityId: 'city-berlin',
name: 'Brandenburger Tor',
category: 'sight' as const,
description:
'Das bekannteste Wahrzeichen Berlins und Symbol der deutschen Wiedervereinigung.',
address: 'Pariser Platz, 10117 Berlin',
latitude: 52.5163,
longitude: 13.3777,
},
],
};

View file

@ -0,0 +1,25 @@
/**
* CityCorners module barrel exports.
*/
export { favoritesStore } from './stores/favorites.svelte';
export {
useAllCities,
useAllLocations,
useAllFavorites,
getFavoriteIds,
isFavorite,
filterByCity,
filterByCategory,
searchLocations,
searchCities,
findCityBySlug,
getLocationCountByCity,
getCityStats,
getPlatformStats,
} from './queries';
export type { CityStats, PlatformStats } from './queries';
export { cityTable, ccLocationTable, ccFavoriteTable, CITYCORNERS_GUEST_SEED } from './collections';
export type { LocalCity, LocalLocation, LocalFavorite } from './types';
export { CATEGORY_KEYS, CATEGORY_COLORS } from './types';
export { isOpenNow, haversine, formatDistance } from './utils/opening-hours';

View file

@ -0,0 +1,169 @@
/**
* Reactive Queries & Pure Filter Helpers for CityCorners
*
* Uses Dexie liveQuery on the unified DB. Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalCity, LocalLocation, LocalFavorite } from './types';
// ─── Live Query Hooks ─────────────────────────────────────
/** All cities, sorted by name. Auto-updates on any change. */
export function useAllCities() {
return liveQuery(async () => {
const all = await db.table<LocalCity>('cities').toArray();
return all.filter((c) => !c.deletedAt).sort((a, b) => a.name.localeCompare(b.name));
});
}
/** All locations, sorted by name. Auto-updates on any change. */
export function useAllLocations() {
return liveQuery(async () => {
const all = await db.table<LocalLocation>('ccLocations').toArray();
return all.filter((l) => !l.deletedAt).sort((a, b) => a.name.localeCompare(b.name));
});
}
/** All favorites. Auto-updates on any change. */
export function useAllFavorites() {
return liveQuery(async () => {
const all = await db.table<LocalFavorite>('ccFavorites').toArray();
return all.filter((f) => !f.deletedAt);
});
}
// ─── Pure Filter Functions (for $derived) ───────────────────
/** Get a Set of favorite location IDs for quick lookup. */
export function getFavoriteIds(favorites: LocalFavorite[]): Set<string> {
return new Set(favorites.map((f) => f.locationId));
}
/** Check if a location is favorited. */
export function isFavorite(favorites: LocalFavorite[], locationId: string): boolean {
return favorites.some((f) => f.locationId === locationId);
}
/** Filter locations by city. */
export function filterByCity(locations: LocalLocation[], cityId: string): LocalLocation[] {
return locations.filter((l) => l.cityId === cityId);
}
/** Filter locations by category. */
export function filterByCategory(
locations: LocalLocation[],
category: string | null
): LocalLocation[] {
if (!category) return locations;
return locations.filter((l) => l.category === category);
}
/** Filter locations by search query across name, description, address. */
export function searchLocations(locations: LocalLocation[], query: string): LocalLocation[] {
if (!query.trim()) return locations;
const search = query.toLowerCase().trim();
return locations.filter(
(l) =>
l.name.toLowerCase().includes(search) ||
l.description?.toLowerCase().includes(search) ||
l.address?.toLowerCase().includes(search)
);
}
/** Filter cities by search query across name, country, state, description. */
export function searchCities(cities: LocalCity[], query: string): LocalCity[] {
if (!query.trim()) return cities;
const search = query.toLowerCase().trim();
return cities.filter(
(c) =>
c.name.toLowerCase().includes(search) ||
c.country.toLowerCase().includes(search) ||
c.state?.toLowerCase().includes(search) ||
c.description?.toLowerCase().includes(search)
);
}
/** Find a city by slug. */
export function findCityBySlug(cities: LocalCity[], slug: string): LocalCity | undefined {
return cities.find((c) => c.slug === slug);
}
/** Count locations per city. */
export function getLocationCountByCity(locations: LocalLocation[]): Map<string, number> {
const counts = new Map<string, number>();
for (const loc of locations) {
counts.set(loc.cityId, (counts.get(loc.cityId) || 0) + 1);
}
return counts;
}
/** Stats for a single city. */
export interface CityStats {
locationCount: number;
categoryCounts: Record<string, number>;
topCategories: { category: string; count: number }[];
contributorCount: number;
hasCoordinates: number;
recentLocations: LocalLocation[];
}
/** Compute stats for a city's locations. */
export function getCityStats(locations: LocalLocation[]): CityStats {
const categoryCounts: Record<string, number> = {};
const contributors = new Set<string>();
let hasCoordinates = 0;
for (const loc of locations) {
categoryCounts[loc.category] = (categoryCounts[loc.category] || 0) + 1;
if ((loc as any).createdBy) contributors.add((loc as any).createdBy);
if (loc.latitude && loc.longitude) hasCoordinates++;
}
const topCategories = Object.entries(categoryCounts)
.map(([category, count]) => ({ category, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
const recentLocations = [...locations]
.sort((a, b) => {
const aTime = (a as any).createdAt ? new Date((a as any).createdAt).getTime() : 0;
const bTime = (b as any).createdAt ? new Date((b as any).createdAt).getTime() : 0;
return bTime - aTime;
})
.slice(0, 3);
return {
locationCount: locations.length,
categoryCounts,
topCategories,
contributorCount: contributors.size,
hasCoordinates,
recentLocations,
};
}
/** Stats summary for the city discovery page. */
export interface PlatformStats {
totalCities: number;
totalLocations: number;
totalContributors: number;
}
/** Compute platform-wide stats. */
export function getPlatformStats(cities: LocalCity[], locations: LocalLocation[]): PlatformStats {
const contributors = new Set<string>();
for (const loc of locations) {
if ((loc as any).createdBy) contributors.add((loc as any).createdBy);
}
for (const city of cities) {
if (city.createdBy) contributors.add(city.createdBy);
}
return {
totalCities: cities.length,
totalLocations: locations.length,
totalContributors: contributors.size,
};
}

View file

@ -0,0 +1,48 @@
/**
* Favorites Store Mutation-Only
*
* All reads are handled by liveQuery (see queries.ts).
* This store only exposes mutations that write to IndexedDB.
*/
import { db } from '$lib/data/database';
import type { LocalFavorite } from '../types';
let loading = $state(false);
export const favoritesStore = {
get loading() {
return loading;
},
/**
* Toggle a favorite writes to / removes from IndexedDB instantly.
*/
async toggle(locationId: string) {
loading = true;
try {
const all = await db.table<LocalFavorite>('ccFavorites').toArray();
const existing = all.find((f) => f.locationId === locationId && !f.deletedAt);
if (existing) {
await db.table('ccFavorites').update(existing.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
} else {
const newFav: LocalFavorite = {
id: crypto.randomUUID(),
locationId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await db.table('ccFavorites').add(newFav);
}
} catch (err) {
console.error('Failed to toggle favorite:', err);
} finally {
loading = false;
}
},
};

View file

@ -0,0 +1,72 @@
/**
* CityCorners module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalCity extends BaseRecord {
name: string;
slug: string;
country: string;
state?: string | null;
description?: string | null;
latitude: number;
longitude: number;
imageUrl?: string | null;
createdBy?: string | null;
}
export interface LocalLocation extends BaseRecord {
cityId: string;
name: string;
category:
| 'sight'
| 'restaurant'
| 'shop'
| 'museum'
| 'cafe'
| 'bar'
| 'park'
| 'beach'
| 'hotel'
| 'event_venue'
| 'viewpoint';
description?: string | null;
address?: string | null;
latitude?: number | null;
longitude?: number | null;
imageUrl?: string | null;
timeline?: Array<{ year: number; event: string }> | null;
}
export interface LocalFavorite extends BaseRecord {
locationId: string;
}
export const CATEGORY_KEYS = [
'sight',
'restaurant',
'shop',
'museum',
'cafe',
'bar',
'park',
'beach',
'hotel',
'event_venue',
'viewpoint',
] as const;
export const CATEGORY_COLORS: Record<string, string> = {
sight: '#2563eb',
restaurant: '#dc2626',
shop: '#16a34a',
museum: '#9333ea',
cafe: '#b45309',
bar: '#ea580c',
park: '#15803d',
beach: '#0891b2',
hotel: '#4f46e5',
event_venue: '#db2777',
viewpoint: '#0ea5e9',
};

View file

@ -0,0 +1,52 @@
const DAY_KEYS = ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa'] as const;
/**
* Check if a location is currently open based on its opening hours.
* Returns null if no opening hours are provided.
*/
export function isOpenNow(openingHours?: Record<string, string> | null): boolean | null {
if (!openingHours || Object.keys(openingHours).length === 0) return null;
const now = new Date();
const dayKey = DAY_KEYS[now.getDay()];
const hours = openingHours[dayKey];
if (!hours || hours === 'closed') return false;
// Parse "HH:MM - HH:MM" format
const match = hours.match(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/);
if (!match) return null;
const [, openH, openM, closeH, closeM] = match;
const currentMinutes = now.getHours() * 60 + now.getMinutes();
const openMinutes = parseInt(openH) * 60 + parseInt(openM);
const closeMinutes = parseInt(closeH) * 60 + parseInt(closeM);
// Handle overnight hours (e.g., 22:00 - 03:00)
if (closeMinutes < openMinutes) {
return currentMinutes >= openMinutes || currentMinutes < closeMinutes;
}
return currentMinutes >= openMinutes && currentMinutes < closeMinutes;
}
/**
* Haversine formula distance between two lat/lng points in meters.
*/
export function haversine(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Format distance in meters to human-readable string.
*/
export function formatDistance(meters: number): string {
if (meters < 1000) return `${meters} m`;
return `${(meters / 1000).toFixed(1)} km`;
}

View file

@ -0,0 +1,34 @@
<script lang="ts">
import type { ItemStatus } from '../queries';
interface Props {
status: ItemStatus;
size?: 'sm' | 'md';
}
let { status, size = 'sm' }: Props = $props();
const statusLabels: Record<ItemStatus, string> = {
owned: 'Besitzt',
lent: 'Verliehen',
stored: 'Eingelagert',
for_sale: 'Zu verkaufen',
disposed: 'Entsorgt',
};
const statusColors: Record<ItemStatus, string> = {
owned: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400',
lent: 'bg-amber-100 text-amber-800 dark:bg-amber-900/20 dark:text-amber-400',
stored: 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400',
for_sale: 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-400',
disposed: 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400',
};
</script>
<span
class="inline-block rounded-full font-medium {statusColors[status]} {size === 'sm'
? 'px-2 py-0.5 text-xs'
: 'px-3 py-1 text-sm'}"
>
{statusLabels[status]}
</span>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { ViewMode } from '../queries';
interface Props {
current: ViewMode;
onchange: (mode: ViewMode) => void;
}
let { current, onchange }: Props = $props();
const modes: { value: ViewMode; icon: string; label: string }[] = [
{ value: 'list', icon: 'M4 6h16M4 10h16M4 14h16M4 18h16', label: 'Liste' },
{
value: 'grid',
icon: 'M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z',
label: 'Raster',
},
{
value: 'table',
icon: 'M3 10h18M3 14h18M3 18h18M3 6h18M3 6v12M21 6v12M9 6v12M15 6v12',
label: 'Tabelle',
},
];
</script>
<div class="flex rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))]">
{#each modes as mode}
<button
type="button"
onclick={() => onchange(mode.value)}
class="p-2 transition-colors {current === mode.value
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'} {mode.value ===
'list'
? 'rounded-l-lg'
: mode.value === 'table'
? 'rounded-r-lg'
: ''}"
title={mode.label}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={mode.icon} />
</svg>
</button>
{/each}
</div>

View file

@ -0,0 +1,134 @@
<script lang="ts">
interface FieldDef {
id: string;
name: string;
type: string;
options?: string[];
currencyCode?: string;
placeholder?: string;
}
interface Props {
field: FieldDef;
value: unknown;
onchange: (value: unknown) => void;
}
let { field, value, onchange }: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
switch (field.type) {
case 'number':
case 'currency':
onchange(target.value ? Number(target.value) : undefined);
break;
case 'checkbox':
onchange(target.checked);
break;
default:
onchange(target.value || undefined);
}
}
function handleSelectChange(e: Event) {
const target = e.target as HTMLSelectElement;
onchange(target.value || undefined);
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))] focus:ring-offset-1';
</script>
{#if field.type === 'text'}
<input
type="text"
value={String(value || '')}
placeholder={field.placeholder || field.name}
class={inputClass}
oninput={handleInput}
/>
{:else if field.type === 'number'}
<input
type="number"
value={value !== undefined && value !== null ? Number(value) : ''}
placeholder={field.placeholder || field.name}
class={inputClass}
oninput={handleInput}
/>
{:else if field.type === 'currency'}
<div class="flex gap-2">
<input
type="number"
step="0.01"
value={value !== undefined && value !== null ? Number(value) : ''}
placeholder="0.00"
class="{inputClass} flex-1"
oninput={handleInput}
/>
<span class="flex items-center text-sm text-[hsl(var(--muted-foreground))]">
{field.currencyCode || 'EUR'}
</span>
</div>
{:else if field.type === 'date'}
<input type="date" value={String(value || '')} class={inputClass} oninput={handleInput} />
{:else if field.type === 'checkbox'}
<label class="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={!!value}
class="h-4 w-4 rounded border-[hsl(var(--border))] text-[hsl(var(--primary))]"
onchange={handleInput}
/>
<span class="text-sm text-[hsl(var(--foreground))]">{field.name}</span>
</label>
{:else if field.type === 'select'}
<select value={String(value || '')} class={inputClass} onchange={handleSelectChange}>
<option value="">-- Auswahl --</option>
{#each field.options || [] as option}
<option value={option}>{option}</option>
{/each}
</select>
{:else if field.type === 'url'}
<input
type="url"
value={String(value || '')}
placeholder={field.placeholder || 'https://...'}
class={inputClass}
oninput={handleInput}
/>
{:else if field.type === 'tags'}
{@const currentTags = Array.isArray(value) ? (value as string[]) : []}
<div class="space-y-2">
<div class="flex flex-wrap gap-1">
{#each currentTags as tag, i}
<span
class="inline-flex items-center gap-1 rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
>
{tag}
<button
type="button"
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
onclick={() => onchange(currentTags.filter((_, idx) => idx !== i))}>x</button
>
</span>
{/each}
</div>
<input
type="text"
placeholder="Tag eingeben + Enter"
class={inputClass}
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const target = e.target as HTMLInputElement;
const newTag = target.value.trim();
if (newTag && !currentTags.includes(newTag)) {
onchange([...currentTags, newTag]);
target.value = '';
}
}
}}
/>
</div>
{/if}

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
interface FieldDef {
id: string;
name: string;
type: string;
currencyCode?: string;
}
interface Props {
field: FieldDef;
value: unknown;
}
let { field, value }: Props = $props();
function formatCurrency(val: unknown, code?: string): string {
const num = Number(val);
if (isNaN(num)) return String(val || '');
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: code || 'EUR' }).format(
num
);
}
function formatDate(val: unknown): string {
if (!val) return '';
try {
return format(new Date(String(val)), 'dd.MM.yyyy', { locale: de });
} catch {
return String(val);
}
}
</script>
{#if value === undefined || value === null || value === ''}
<span class="text-[hsl(var(--muted-foreground))] italic">--</span>
{:else if field.type === 'checkbox'}
{#if value}
<span class="text-green-500">&#10003;</span>
{:else}
<span class="text-[hsl(var(--muted-foreground))]">&#10007;</span>
{/if}
{:else if field.type === 'currency'}
<span>{formatCurrency(value, field.currencyCode)}</span>
{:else if field.type === 'date'}
<span>{formatDate(value)}</span>
{:else if field.type === 'url'}
<a
href={String(value)}
target="_blank"
rel="noopener noreferrer"
class="text-[hsl(var(--primary))] underline hover:no-underline"
>
{String(value)
.replace(/^https?:\/\//, '')
.slice(0, 40)}
</a>
{:else if field.type === 'select'}
<span
class="inline-block rounded-full bg-[hsl(var(--accent)/0.15)] px-2 py-0.5 text-xs font-medium text-[hsl(var(--accent-foreground))]"
>
{String(value)}
</span>
{:else if field.type === 'tags'}
<div class="flex flex-wrap gap-1">
{#each Array.isArray(value) ? value : [] as tag}
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs">{tag}</span>
{/each}
</div>
{:else if field.type === 'number'}
<span>{Number(value).toLocaleString('de-DE')}</span>
{:else}
<span>{String(value)}</span>
{/if}

View file

@ -0,0 +1,226 @@
<script lang="ts">
import { Plus, Trash } from '@manacore/shared-icons';
type FieldType = 'text' | 'number' | 'date' | 'select' | 'tags' | 'checkbox' | 'url' | 'currency';
interface FieldDefinition {
id: string;
name: string;
type: FieldType;
required?: boolean;
defaultValue?: unknown;
options?: string[];
currencyCode?: string;
placeholder?: string;
order: number;
}
interface Props {
fields: FieldDefinition[];
onchange: (fields: FieldDefinition[]) => void;
}
let { fields, onchange }: Props = $props();
const fieldTypes: { value: FieldType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Zahl' },
{ value: 'date', label: 'Datum' },
{ value: 'select', label: 'Auswahl' },
{ value: 'tags', label: 'Tags' },
{ value: 'checkbox', label: 'Checkbox' },
{ value: 'url', label: 'URL' },
{ value: 'currency', label: 'Wahrung' },
];
function addField() {
const newField: FieldDefinition = {
id: crypto.randomUUID(),
name: '',
type: 'text',
order: fields.length,
};
onchange([...fields, newField]);
}
function updateField(id: string, updates: Partial<FieldDefinition>) {
onchange(fields.map((f) => (f.id === id ? { ...f, ...updates } : f)));
}
function removeField(id: string) {
onchange(fields.filter((f) => f.id !== id).map((f, i) => ({ ...f, order: i })));
}
function moveField(id: string, direction: 'up' | 'down') {
const index = fields.findIndex((f) => f.id === id);
if (index === -1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= fields.length) return;
const newFields = [...fields];
[newFields[index], newFields[newIndex]] = [newFields[newIndex], newFields[index]];
onchange(newFields.map((f, i) => ({ ...f, order: i })));
}
let newOption = $state('');
function addOption(fieldId: string) {
if (!newOption.trim()) return;
const field = fields.find((f) => f.id === fieldId);
if (!field) return;
updateField(fieldId, { options: [...(field.options || []), newOption.trim()] });
newOption = '';
}
function removeOption(fieldId: string, index: number) {
const field = fields.find((f) => f.id === fieldId);
if (!field) return;
updateField(fieldId, { options: field.options?.filter((_, i) => i !== index) });
}
const inputClass =
'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))]';
</script>
<div class="space-y-3">
{#each fields.sort((a, b) => a.order - b.order) as field, index (field.id)}
<div class="rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-3">
<div class="flex items-start gap-2">
<!-- Reorder buttons -->
<div class="flex flex-col gap-0.5 pt-1">
<button
type="button"
onclick={() => moveField(field.id, 'up')}
disabled={index === 0}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))] disabled:opacity-30"
>&#9650;</button
>
<button
type="button"
onclick={() => moveField(field.id, 'down')}
disabled={index === fields.length - 1}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))] disabled:opacity-30"
>&#9660;</button
>
</div>
<!-- Field config -->
<div class="flex-1 space-y-2">
<div class="flex gap-2">
<input
type="text"
value={field.name}
placeholder="Feldname"
class="{inputClass} flex-1"
oninput={(e) => updateField(field.id, { name: (e.target as HTMLInputElement).value })}
/>
<select
value={field.type}
class="{inputClass} w-32"
onchange={(e) =>
updateField(field.id, { type: (e.target as HTMLSelectElement).value as FieldType })}
>
{#each fieldTypes as ft}
<option value={ft.value}>{ft.label}</option>
{/each}
</select>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-1 text-xs text-[hsl(var(--muted-foreground))]">
<input
type="checkbox"
checked={field.required || false}
onchange={(e) =>
updateField(field.id, { required: (e.target as HTMLInputElement).checked })}
class="h-3 w-3"
/>
Pflichtfeld
</label>
<input
type="text"
value={field.placeholder || ''}
placeholder="Platzhalter (optional)"
class="{inputClass} flex-1 text-xs"
oninput={(e) =>
updateField(field.id, {
placeholder: (e.target as HTMLInputElement).value || undefined,
})}
/>
</div>
<!-- Currency code for currency fields -->
{#if field.type === 'currency'}
<input
type="text"
value={field.currencyCode || 'EUR'}
placeholder="Wahrungscode (z.B. EUR)"
class="{inputClass} text-xs"
oninput={(e) =>
updateField(field.id, { currencyCode: (e.target as HTMLInputElement).value })}
/>
{/if}
<!-- Options for select fields -->
{#if field.type === 'select'}
<div class="space-y-1">
<div class="flex flex-wrap gap-1">
{#each field.options || [] as option, i}
<span
class="inline-flex items-center gap-1 rounded bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
>
{option}
<button
type="button"
onclick={() => removeOption(field.id, i)}
class="text-[hsl(var(--muted-foreground))] hover:text-red-500">x</button
>
</span>
{/each}
</div>
<div class="flex gap-1">
<input
type="text"
bind:value={newOption}
placeholder="Neue Option"
class="{inputClass} flex-1 text-xs"
onkeydown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addOption(field.id);
}
}}
/>
<button
type="button"
onclick={() => addOption(field.id)}
class="rounded bg-[hsl(var(--primary))] px-2 py-1 text-xs text-[hsl(var(--primary-foreground))]"
>+</button
>
</div>
</div>
{/if}
</div>
<!-- Delete button -->
<button
type="button"
onclick={() => removeField(field.id)}
class="mt-1 text-[hsl(var(--muted-foreground))] hover:text-red-500"
title="Feld entfernen"
>
<Trash size={20} />
</button>
</div>
</div>
{/each}
<button
type="button"
onclick={addField}
class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-[hsl(var(--border))] py-3 text-sm text-[hsl(var(--muted-foreground))] transition-colors hover:border-[hsl(var(--primary))] hover:text-[hsl(var(--primary))]"
>
<Plus size={20} />
Feld hinzufugen
</button>
</div>

View file

@ -0,0 +1,245 @@
/**
* Inventar constants templates and status definitions.
*
* Inlined from @inventar/shared since the unified app does not depend on it.
*/
import type { ItemStatus } from './queries';
export type FieldType =
| 'text'
| 'number'
| 'date'
| 'select'
| 'tags'
| 'checkbox'
| 'url'
| 'currency';
export interface FieldDefinition {
id: string;
name: string;
type: FieldType;
required?: boolean;
defaultValue?: unknown;
options?: string[];
currencyCode?: string;
placeholder?: string;
order: number;
}
export interface CollectionSchema {
fields: FieldDefinition[];
}
export interface Template {
id: string;
name: string;
description: string;
icon: string;
schema: CollectionSchema;
category: string;
}
export const ITEM_STATUSES: {
value: ItemStatus;
label: string;
color: string;
}[] = [
{ value: 'owned', label: 'Besitzt', color: '#22c55e' },
{ value: 'lent', label: 'Verliehen', color: '#f59e0b' },
{ value: 'stored', label: 'Eingelagert', color: '#3b82f6' },
{ value: 'for_sale', label: 'Zu verkaufen', color: '#a855f7' },
{ value: 'disposed', label: 'Entsorgt', color: '#6b7280' },
];
export const DEFAULT_TEMPLATES: Template[] = [
{
id: 'electronics',
name: 'Elektronik',
description: 'Computer, Smartphones, Gadgets',
icon: '💻',
category: 'tech',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
{ id: 'serial_number', name: 'Seriennummer', type: 'text', order: 2 },
{ id: 'purchase_date', name: 'Kaufdatum', type: 'date', order: 3 },
{ id: 'warranty_until', name: 'Garantie bis', type: 'date', order: 4 },
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
{
id: 'condition',
name: 'Zustand',
type: 'select',
options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Defekt'],
order: 6,
},
],
},
},
{
id: 'books',
name: 'Bucher',
description: 'Bucher, E-Books, Horbucher',
icon: '📚',
category: 'media',
schema: {
fields: [
{ id: 'author', name: 'Autor', type: 'text', order: 0 },
{ id: 'isbn', name: 'ISBN', type: 'text', order: 1 },
{ id: 'publisher', name: 'Verlag', type: 'text', order: 2 },
{ id: 'genre', name: 'Genre', type: 'text', order: 3 },
{ id: 'pages', name: 'Seiten', type: 'number', order: 4 },
{ id: 'read', name: 'Gelesen', type: 'checkbox', order: 5 },
{
id: 'rating',
name: 'Bewertung',
type: 'select',
options: ['1', '2', '3', '4', '5'],
order: 6,
},
],
},
},
{
id: 'furniture',
name: 'Mobel',
description: 'Tische, Stuhle, Regale',
icon: '🪑',
category: 'home',
schema: {
fields: [
{ id: 'material', name: 'Material', type: 'text', order: 0 },
{
id: 'dimensions',
name: 'Masse',
type: 'text',
placeholder: 'B x H x T in cm',
order: 1,
},
{ id: 'color', name: 'Farbe', type: 'text', order: 2 },
{ id: 'room', name: 'Raum', type: 'text', order: 3 },
{
id: 'condition',
name: 'Zustand',
type: 'select',
options: ['Neu', 'Sehr gut', 'Gut', 'Gebraucht', 'Reparaturbedurftig'],
order: 4,
},
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
],
},
},
{
id: 'clothing',
name: 'Kleidung',
description: 'Kleidung, Schuhe, Accessoires',
icon: '👕',
category: 'fashion',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'size', name: 'Grosse', type: 'text', order: 1 },
{ id: 'color', name: 'Farbe', type: 'text', order: 2 },
{ id: 'material', name: 'Material', type: 'text', order: 3 },
{
id: 'season',
name: 'Saison',
type: 'select',
options: ['Fruhling', 'Sommer', 'Herbst', 'Winter', 'Ganzjahrig'],
order: 4,
},
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 5 },
],
},
},
{
id: 'tools',
name: 'Werkzeug',
description: 'Handwerkzeug, Elektrowerkzeug',
icon: '🔧',
category: 'home',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'model', name: 'Modell', type: 'text', order: 1 },
{
id: 'type',
name: 'Typ',
type: 'select',
options: ['Handwerkzeug', 'Elektrowerkzeug', 'Messwerkzeug', 'Sonstiges'],
order: 2,
},
{
id: 'condition',
name: 'Zustand',
type: 'select',
options: ['Neu', 'Gut', 'Gebraucht', 'Defekt'],
order: 3,
},
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 },
],
},
},
{
id: 'kitchen',
name: 'Kuche',
description: 'Kuchengerate, Geschirr, Besteck',
icon: '🍳',
category: 'home',
schema: {
fields: [
{ id: 'brand', name: 'Marke', type: 'text', order: 0 },
{ id: 'material', name: 'Material', type: 'text', order: 1 },
{
id: 'category',
name: 'Kategorie',
type: 'select',
options: ['Gerat', 'Geschirr', 'Besteck', 'Topf/Pfanne', 'Sonstiges'],
order: 2,
},
{ id: 'dishwasher_safe', name: 'Spulmaschinenfest', type: 'checkbox', order: 3 },
{ id: 'price', name: 'Preis', type: 'currency', currencyCode: 'EUR', order: 4 },
],
},
},
{
id: 'media',
name: 'Medien',
description: 'Filme, Musik, Spiele',
icon: '🎬',
category: 'media',
schema: {
fields: [
{
id: 'format',
name: 'Format',
type: 'select',
options: ['DVD', 'Blu-ray', 'CD', 'Vinyl', 'Digital', 'Kassette'],
order: 0,
},
{ id: 'artist', name: 'Kunstler/Regisseur', type: 'text', order: 1 },
{ id: 'genre', name: 'Genre', type: 'text', order: 2 },
{ id: 'year', name: 'Erscheinungsjahr', type: 'number', order: 3 },
{
id: 'rating',
name: 'Bewertung',
type: 'select',
options: ['1', '2', '3', '4', '5'],
order: 4,
},
],
},
},
{
id: 'custom',
name: 'Benutzerdefiniert',
description: 'Leere Sammlung, eigene Felder definieren',
icon: '✨',
category: 'other',
schema: {
fields: [],
},
},
];

View file

@ -0,0 +1,54 @@
/**
* Inventar module barrel exports.
*/
export { collectionsStore } from './stores/collections.svelte';
export { itemsStore } from './stores/items.svelte';
export { locationsStore } from './stores/locations.svelte';
export { categoriesStore } from './stores/categories.svelte';
export { viewStore } from './stores/view.svelte';
export {
useAllCollections,
useAllItems,
useAllLocations,
useAllCategories,
toCollection,
toItem,
toLocation,
toCategory,
getCollectionById,
getSortedCollections,
getItemById,
getItemsByCollection,
getItemCountByCollection,
getTotalItemCount,
getFilteredItems,
getSortedItems,
getLocationById,
getRootLocations,
getLocationChildren,
getLocationTree,
getLocationFullPath,
getCategoryById,
getRootCategories,
getCategoryChildren,
getCategoryTree,
} from './queries';
export type {
Collection,
Item,
ItemStatus,
Location,
Category,
ViewMode,
SortOption,
FilterCriteria,
} from './queries';
export {
invCollectionTable,
invItemTable,
invLocationTable,
invCategoryTable,
INVENTAR_GUEST_SEED,
} from './collections';
export type { LocalCollection, LocalItem, LocalLocation, LocalCategory } from './types';

View file

@ -0,0 +1,51 @@
/**
* Categories Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { invCategoryTable } from '../collections';
import { toCategory } from '../queries';
import type { LocalCategory } from '../types';
export const categoriesStore = {
async create(data: { name: string; icon?: string; color?: string; parentId?: string }) {
const all = await invCategoryTable.toArray();
const active = all.filter((c) => !c.deletedAt);
const siblings = active.filter((c) => c.parentId === data.parentId);
const newLocal: LocalCategory = {
id: crypto.randomUUID(),
parentId: data.parentId ?? null,
name: data.name,
icon: data.icon ?? null,
color: data.color ?? null,
order: siblings.length,
};
await invCategoryTable.add(newLocal);
return toCategory(newLocal);
},
async update(id: string, data: Partial<Pick<LocalCategory, 'name' | 'icon' | 'color'>>) {
await invCategoryTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async delete(id: string) {
const all = await invCategoryTable.toArray();
const active = all.filter((c) => !c.deletedAt);
const idsToDelete = new Set<string>();
const collectIds = (parentId: string) => {
idsToDelete.add(parentId);
active.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id));
};
collectIds(id);
const now = new Date().toISOString();
for (const deleteId of idsToDelete) {
await invCategoryTable.update(deleteId, { deletedAt: now, updatedAt: now });
}
},
};

View file

@ -0,0 +1,68 @@
/**
* Collections Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { invCollectionTable } from '../collections';
import { toCollection } from '../queries';
import type { LocalCollection } from '../types';
export const collectionsStore = {
async create(data: {
name: string;
description?: string;
icon?: string;
color?: string;
schema: LocalCollection['schema'];
templateId?: string;
}) {
const all = await invCollectionTable.toArray();
const active = all.filter((c) => !c.deletedAt);
const newLocal: LocalCollection = {
id: crypto.randomUUID(),
name: data.name,
description: data.description ?? null,
icon: data.icon ?? null,
color: data.color ?? null,
schema: data.schema,
templateId: data.templateId ?? null,
order: active.length,
itemCount: 0,
};
await invCollectionTable.add(newLocal);
return toCollection(newLocal);
},
async update(
id: string,
data: Partial<Pick<LocalCollection, 'name' | 'description' | 'icon' | 'color' | 'schema'>>
) {
await invCollectionTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async delete(id: string) {
await invCollectionTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async reorder(orderedIds: string[]) {
const now = new Date().toISOString();
for (let i = 0; i < orderedIds.length; i++) {
await invCollectionTable.update(orderedIds[i], { order: i, updatedAt: now });
}
},
async updateItemCount(collectionId: string, count: number) {
await invCollectionTable.update(collectionId, {
itemCount: count,
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,109 @@
/**
* Items Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { invItemTable } from '../collections';
import { toItem } from '../queries';
import type { LocalItem } from '../types';
import type { ItemStatus } from '../queries';
export const itemsStore = {
async create(data: {
collectionId: string;
name: string;
description?: string;
status?: ItemStatus;
quantity?: number;
locationId?: string;
categoryId?: string;
fieldValues?: Record<string, unknown>;
purchaseData?: LocalItem['purchaseData'];
tags?: string[];
}) {
const existing = await invItemTable.toArray();
const collectionItems = existing.filter(
(i) => !i.deletedAt && i.collectionId === data.collectionId
);
const newLocal: LocalItem = {
id: crypto.randomUUID(),
collectionId: data.collectionId,
name: data.name,
description: data.description ?? null,
status: data.status || 'owned',
quantity: data.quantity || 1,
locationId: data.locationId ?? null,
categoryId: data.categoryId ?? null,
fieldValues: data.fieldValues || {},
purchaseData: data.purchaseData ?? null,
photos: [],
notes: [],
tags: data.tags || [],
order: collectionItems.length,
};
await invItemTable.add(newLocal);
return toItem(newLocal);
},
async update(
id: string,
data: Partial<
Pick<
LocalItem,
| 'name'
| 'description'
| 'status'
| 'quantity'
| 'locationId'
| 'categoryId'
| 'fieldValues'
| 'purchaseData'
| 'tags'
>
>
) {
await invItemTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async delete(id: string) {
await invItemTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async deleteByCollection(collectionId: string) {
const all = await invItemTable.toArray();
const toDelete = all.filter((i) => !i.deletedAt && i.collectionId === collectionId);
const now = new Date().toISOString();
for (const item of toDelete) {
await invItemTable.update(item.id, { deletedAt: now, updatedAt: now });
}
},
async addNote(itemId: string, content: string) {
const item = await invItemTable.get(itemId);
if (!item) return;
const now = new Date().toISOString();
const note = { id: crypto.randomUUID(), content, createdAt: now };
await invItemTable.update(itemId, {
notes: [...item.notes, note],
updatedAt: now,
});
},
async deleteNote(itemId: string, noteId: string) {
const item = await invItemTable.get(itemId);
if (!item) return;
await invItemTable.update(itemId, {
notes: item.notes.filter((n) => n.id !== noteId),
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,68 @@
/**
* Locations Store Mutations Only
*
* Reads come from liveQuery hooks in queries.ts.
* This store only handles writes to IndexedDB via the unified database.
*/
import { invLocationTable } from '../collections';
import { toLocation } from '../queries';
import type { LocalLocation } from '../types';
function buildPath(locations: LocalLocation[], parentId?: string): string {
if (!parentId) return '';
const parent = locations.find((l) => l.id === parentId);
if (!parent) return '';
return parent.path ? `${parent.path}/${parent.name}` : parent.name;
}
function getDepth(locations: LocalLocation[], parentId?: string): number {
if (!parentId) return 0;
const parent = locations.find((l) => l.id === parentId);
return parent ? parent.depth + 1 : 0;
}
export const locationsStore = {
async create(data: { name: string; description?: string; icon?: string; parentId?: string }) {
const all = await invLocationTable.toArray();
const active = all.filter((l) => !l.deletedAt);
const path = buildPath(active, data.parentId);
const depth = getDepth(active, data.parentId);
const siblings = active.filter((l) => l.parentId === data.parentId);
const newLocal: LocalLocation = {
id: crypto.randomUUID(),
parentId: data.parentId ?? null,
name: data.name,
description: data.description ?? null,
icon: data.icon ?? null,
path,
depth,
order: siblings.length,
};
await invLocationTable.add(newLocal);
return toLocation(newLocal);
},
async update(id: string, data: Partial<Pick<LocalLocation, 'name' | 'description' | 'icon'>>) {
await invLocationTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async delete(id: string) {
const all = await invLocationTable.toArray();
const active = all.filter((l) => !l.deletedAt);
const idsToDelete = new Set<string>();
const collectIds = (parentId: string) => {
idsToDelete.add(parentId);
active.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id));
};
collectIds(id);
const now = new Date().toISOString();
for (const deleteId of idsToDelete) {
await invLocationTable.update(deleteId, { deletedAt: now, updatedAt: now });
}
},
};

View file

@ -0,0 +1,117 @@
/**
* View Store Client-side view preferences persisted to localStorage.
*/
import { browser } from '$app/environment';
import type { ViewMode, SortOption, FilterCriteria } from '../queries';
const VIEW_KEY = 'inventar_view_mode';
const SORT_KEY = 'inventar_sort';
interface SavedFilter {
id: string;
name: string;
criteria: FilterCriteria;
createdAt: string;
}
const FILTERS_KEY = 'inventar_saved_filters';
function load<T>(key: string, fallback: T): T {
if (!browser) return fallback;
try {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : fallback;
} catch {
return fallback;
}
}
function save(key: string, value: unknown) {
if (!browser) return;
localStorage.setItem(key, JSON.stringify(value));
}
let viewMode = $state<ViewMode>('list');
let sort = $state<SortOption>({ field: 'name', direction: 'asc' });
let activeFilters = $state<FilterCriteria>({});
let savedFilters = $state<SavedFilter[]>([]);
let initialized = $state(false);
export const viewStore = {
get viewMode() {
return viewMode;
},
get sort() {
return sort;
},
get activeFilters() {
return activeFilters;
},
get savedFilters() {
return savedFilters;
},
get hasActiveFilters() {
return !!(
activeFilters.search ||
activeFilters.status?.length ||
activeFilters.locationId ||
activeFilters.categoryId ||
activeFilters.tagIds?.length ||
activeFilters.collectionId
);
},
initialize() {
if (initialized) return;
viewMode = load<ViewMode>(VIEW_KEY, 'list');
sort = load<SortOption>(SORT_KEY, { field: 'name', direction: 'asc' });
savedFilters = load<SavedFilter[]>(FILTERS_KEY, []);
initialized = true;
},
setViewMode(mode: ViewMode) {
viewMode = mode;
save(VIEW_KEY, mode);
},
setSort(newSort: SortOption) {
sort = newSort;
save(SORT_KEY, newSort);
},
setFilters(filters: FilterCriteria) {
activeFilters = filters;
},
updateFilter<K extends keyof FilterCriteria>(key: K, value: FilterCriteria[K]) {
activeFilters = { ...activeFilters, [key]: value };
},
clearFilters() {
activeFilters = {};
},
saveFilter(name: string) {
const filter: SavedFilter = {
id: crypto.randomUUID(),
name,
criteria: { ...activeFilters },
createdAt: new Date().toISOString(),
};
savedFilters = [...savedFilters, filter];
save(FILTERS_KEY, savedFilters);
},
loadFilter(id: string) {
const filter = savedFilters.find((f) => f.id === id);
if (filter) {
activeFilters = { ...filter.criteria };
}
},
deleteSavedFilter(id: string) {
savedFilters = savedFilters.filter((f) => f.id !== id);
save(FILTERS_KEY, savedFilters);
},
};

View file

@ -0,0 +1,46 @@
/**
* Photos module collection accessors and guest seed data.
*
* Uses table names in the unified DB: albums, albumItems, photoFavorites, photoTags, photoMediaTags.
*/
import { db } from '$lib/data/database';
import type { LocalAlbum, LocalAlbumItem, LocalFavorite, LocalTag, LocalPhotoTag } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const albumTable = db.table<LocalAlbum>('albums');
export const albumItemTable = db.table<LocalAlbumItem>('albumItems');
export const photoFavoriteTable = db.table<LocalFavorite>('photoFavorites');
export const photoTagTable = db.table<LocalTag>('photoTags');
export const photoMediaTagTable = db.table<LocalPhotoTag>('photoMediaTags');
// ─── Guest Seed ────────────────────────────────────────────
export const PHOTOS_GUEST_SEED = {
albums: [
{
id: 'album-favorites',
name: 'Favoriten',
description: 'Deine Lieblingsfotos an einem Ort.',
isAutoGenerated: false,
},
],
photoTags: [
{
id: 'tag-nature',
name: 'Natur',
color: '#22c55e',
},
{
id: 'tag-people',
name: 'Menschen',
color: '#3b82f6',
},
{
id: 'tag-travel',
name: 'Reisen',
color: '#f59e0b',
},
],
};

View file

@ -0,0 +1,100 @@
<script lang="ts">
import type { Album } from '$lib/modules/photos/types';
import { Folder } from '@manacore/shared-icons';
interface Props {
album: Album;
onClick: () => void;
}
let { album, onClick }: Props = $props();
</script>
<button class="album-card" onclick={onClick} type="button">
{#if album.coverUrl}
<img src={album.coverUrl} alt={album.name} class="album-cover" />
{:else}
<div class="album-placeholder">
<Folder size={20} />
</div>
{/if}
<div class="album-overlay">
<h3 class="album-name">{album.name}</h3>
{#if album.description}
<p class="album-description">{album.description}</p>
{/if}
<span class="album-count">{album.itemCount ?? 0} Fotos</span>
</div>
</button>
<style>
.album-card {
position: relative;
aspect-ratio: 4/3;
overflow: hidden;
border-radius: 0.75rem;
background-color: var(--color-muted, #f1f5f9);
cursor: pointer;
border: none;
padding: 0;
transition:
transform 200ms ease,
box-shadow 200ms ease;
width: 100%;
}
.album-card:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
.album-cover {
width: 100%;
height: 100%;
object-fit: cover;
}
.album-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground, #64748b);
background: linear-gradient(
135deg,
var(--color-muted, #f1f5f9) 0%,
var(--color-accent, #e2e8f0) 100%
);
}
.album-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 60%);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 1rem;
color: white;
text-align: left;
}
.album-name {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.album-description {
font-size: 0.875rem;
opacity: 0.8;
margin-bottom: 0.25rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.album-count {
font-size: 0.75rem;
opacity: 0.7;
}
</style>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { Album } from '$lib/modules/photos/types';
import AlbumCard from './AlbumCard.svelte';
interface Props {
albums: Album[];
loading: boolean;
onAlbumClick: (album: Album) => void;
}
let { albums, loading, onAlbumClick }: Props = $props();
</script>
<div class="album-grid">
{#each albums as album (album.id)}
<AlbumCard {album} onClick={() => onAlbumClick(album)} />
{/each}
</div>
{#if loading}
<div class="loading-indicator">
<div class="spinner"></div>
</div>
{/if}
<style>
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.loading-indicator {
display: flex;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border, #e2e8f0);
border-top-color: var(--color-primary, #6366f1);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -0,0 +1,100 @@
<script lang="ts">
import { X } from '@manacore/shared-icons';
interface Props {
onClose: () => void;
onCreate: (data: { name: string; description?: string }) => void;
}
let { onClose, onCreate }: Props = $props();
let name = $state('');
let description = $state('');
let loading = $state(false);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
function handleBackdropClick(e: Event) {
if (e.target === e.currentTarget) onClose();
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!name.trim() || loading) return;
loading = true;
await onCreate({
name: name.trim(),
description: description.trim() || undefined,
});
loading = false;
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onclick={handleBackdropClick}
>
<div class="w-full max-w-md rounded-xl border border-border bg-background-card p-6">
<header class="mb-6 flex items-center justify-between">
<h2 class="text-lg font-semibold text-foreground">Album erstellen</h2>
<button
class="rounded-full p-1 text-foreground-secondary hover:bg-background-card-hover"
onclick={onClose}
type="button"
>
<X size={20} />
</button>
</header>
<form onsubmit={handleSubmit}>
<div class="mb-4">
<label for="name" class="mb-1 block text-sm font-medium text-foreground">Name</label>
<input
id="name"
type="text"
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
bind:value={name}
placeholder="Mein Album"
required
/>
</div>
<div class="mb-4">
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
>Beschreibung</label
>
<textarea
id="description"
class="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
bind:value={description}
placeholder="Optionale Beschreibung..."
rows="3"
></textarea>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-lg border border-border bg-background px-4 py-2 text-sm text-foreground-secondary hover:bg-background-card-hover"
onclick={onClose}
disabled={loading}
>
Abbrechen
</button>
<button
type="submit"
class="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
disabled={!name.trim() || loading}
>
{loading ? 'Erstelle...' : 'Erstellen'}
</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,120 @@
<script lang="ts">
import { photoStore } from '$lib/modules/photos/stores/photos.svelte';
const apps = ['picture', 'chat', 'contacts', 'nutriphi'];
let selectedApps = $state<string[]>([]);
let dateFrom = $state('');
let dateTo = $state('');
let hasLocation = $state<boolean | undefined>(undefined);
let sortBy = $state<'dateTaken' | 'createdAt' | 'size'>('dateTaken');
let sortOrder = $state<'asc' | 'desc'>('desc');
function toggleApp(app: string) {
if (selectedApps.includes(app)) {
selectedApps = selectedApps.filter((a) => a !== app);
} else {
selectedApps = [...selectedApps, app];
}
}
async function applyFilters() {
await photoStore.setFilters({
apps: selectedApps.length > 0 ? selectedApps : undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
hasLocation,
sortBy,
sortOrder,
});
}
async function clearFilters() {
selectedApps = [];
dateFrom = '';
dateTo = '';
hasLocation = undefined;
sortBy = 'dateTaken';
sortOrder = 'desc';
await photoStore.setFilters({});
}
</script>
<div class="mb-6 flex flex-wrap gap-4 rounded-xl border border-border bg-background-card p-4">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase text-foreground-secondary">App</span>
<div class="flex flex-wrap gap-1">
{#each apps as app}
<button
class="rounded-full px-3 py-1 text-xs transition-colors {selectedApps.includes(app)
? 'bg-primary text-white'
: 'bg-background text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => toggleApp(app)}
>
{app}
</button>
{/each}
</div>
</div>
<div class="flex flex-col gap-1.5">
<span class="text-xs font-medium uppercase text-foreground-secondary">Zeitraum</span>
<div class="flex items-center gap-2">
<input
type="date"
class="rounded-lg border border-border bg-background px-2 py-1 text-sm text-foreground"
bind:value={dateFrom}
/>
<span class="text-foreground-secondary">-</span>
<input
type="date"
class="rounded-lg border border-border bg-background px-2 py-1 text-sm text-foreground"
bind:value={dateTo}
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="sort-by" class="text-xs font-medium uppercase text-foreground-secondary"
>Sortierung</label
>
<select
id="sort-by"
class="rounded-lg border border-border bg-background px-2 py-1 text-sm text-foreground"
bind:value={sortBy}
>
<option value="dateTaken">Datum</option>
<option value="createdAt">Erstellt</option>
<option value="size">Größe</option>
</select>
</div>
<div class="flex flex-col gap-1.5">
<label for="sort-order" class="text-xs font-medium uppercase text-foreground-secondary"
>Reihenfolge</label
>
<select
id="sort-order"
class="rounded-lg border border-border bg-background px-2 py-1 text-sm text-foreground"
bind:value={sortOrder}
>
<option value="desc">Neueste zuerst</option>
<option value="asc">Älteste zuerst</option>
</select>
</div>
<div class="flex items-end gap-2 ml-auto">
<button
class="rounded-lg border border-border bg-background px-3 py-1.5 text-sm text-foreground-secondary hover:bg-background-card-hover"
onclick={clearFilters}
>
Zurücksetzen
</button>
<button
class="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-white hover:bg-primary/90"
onclick={applyFilters}
>
Anwenden
</button>
</div>
</div>

View file

@ -0,0 +1,157 @@
<script lang="ts">
import type { Photo } from '$lib/modules/photos/types';
import { photoStore } from '$lib/modules/photos/stores/photos.svelte';
import { Heart, Image } from '@manacore/shared-icons';
interface Props {
photo: Photo;
onClick: () => void;
}
let { photo, onClick }: Props = $props();
let loaded = $state(false);
let error = $state(false);
function handleFavoriteClick(e: Event) {
e.stopPropagation();
photoStore.toggleFavorite(photo.id);
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="photo-card"
onclick={onClick}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && onClick()}
role="button"
tabindex="0"
>
{#if !loaded && !error}
<div class="placeholder"></div>
{/if}
<img
src={photo.thumbnailUrl || photo.url}
alt=""
class="photo-image"
class:loaded
onload={() => (loaded = true)}
onerror={() => (error = true)}
/>
{#if error}
<div class="error-placeholder">
<Image size={20} />
</div>
{/if}
<div class="photo-overlay">
<button
type="button"
class="favorite-btn"
class:favorited={photo.isFavorited}
onclick={handleFavoriteClick}
title={photo.isFavorited ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart size={20} />
</button>
</div>
</div>
<style>
.photo-card {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: var(--radius-md, 0.5rem);
background-color: var(--color-muted, #f1f5f9);
cursor: pointer;
border: none;
padding: 0;
transition:
transform 200ms ease,
box-shadow 200ms ease;
}
.photo-card:hover {
transform: scale(1.02);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
z-index: 10;
}
.placeholder {
position: absolute;
inset: 0;
background: linear-gradient(
135deg,
var(--color-muted, #f1f5f9) 0%,
var(--color-accent, #e2e8f0) 100%
);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.photo-image {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 200ms;
}
.photo-image.loaded {
opacity: 1;
}
.error-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-muted-foreground, #64748b);
}
.photo-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.5) 0%, transparent 40%);
opacity: 0;
transition: opacity 150ms;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 0.5rem;
}
.photo-card:hover .photo-overlay {
opacity: 1;
}
.favorite-btn {
padding: 0.5rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: var(--color-muted-foreground, #64748b);
border: none;
cursor: pointer;
transition: all 150ms;
}
.favorite-btn:hover {
transform: scale(1.1);
}
.favorite-btn.favorited {
color: #ef4444;
}
</style>

View file

@ -0,0 +1,296 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import type { Photo } from '$lib/modules/photos/types';
import { photoStore } from '$lib/modules/photos/stores/photos.svelte';
import { CaretRight, DownloadSimple, Heart, X } from '@manacore/shared-icons';
interface Props {
photo: Photo;
onClose: () => void;
}
let { photo, onClose }: Props = $props();
let showInfo = $state(true);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
function handleBackdropClick(e: Event) {
if (e.target === e.currentTarget) onClose();
}
function handleFavorite() {
photoStore.toggleFavorite(photo.id);
}
function formatDate(date: string | Date | null | undefined) {
if (!date) return '-';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatSize(bytes: number | null | undefined) {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="lightbox-backdrop" onclick={handleBackdropClick}>
<div class="lightbox-container">
<button class="close-btn" onclick={onClose}>
<X size={20} />
</button>
<div class="lightbox-main">
<img src={photo.url} alt="" class="lightbox-image" />
</div>
{#if showInfo}
<div class="info-panel">
<div class="info-header">
<h3 class="font-medium">Details</h3>
<button class="icon-btn" onclick={() => (showInfo = false)}>
<CaretRight size={20} />
</button>
</div>
<div class="info-actions">
<button class="action-btn" class:favorited={photo.isFavorited} onclick={handleFavorite}>
<Heart size={20} />
{photo.isFavorited ? 'Favorit entfernen' : 'Favorit'}
</button>
<a class="action-btn" href={photo.url} download target="_blank" rel="noopener noreferrer">
<DownloadSimple size={20} />
Download
</a>
</div>
<div class="info-section">
<h4 class="info-label">Auflösung</h4>
<p class="info-value">
{photo.width && photo.height ? `${photo.width} x ${photo.height}` : '-'}
</p>
</div>
<div class="info-section">
<h4 class="info-label">Größe</h4>
<p class="info-value">{formatSize(photo.size)}</p>
</div>
<div class="info-section">
<h4 class="info-label">Datum</h4>
<p class="info-value">{formatDate(photo.exif?.dateTaken || photo.createdAt)}</p>
</div>
{#if photo.exif}
{#if photo.exif.cameraMake || photo.exif.cameraModel}
<div class="info-section">
<h4 class="info-label">Kamera</h4>
<p class="info-value">
{[photo.exif.cameraMake, photo.exif.cameraModel].filter(Boolean).join(' ')}
</p>
</div>
{/if}
{#if photo.exif.focalLength}
<div class="info-section">
<h4 class="info-label">Brennweite</h4>
<p class="info-value">{photo.exif.focalLength}</p>
</div>
{/if}
{#if photo.exif.aperture}
<div class="info-section">
<h4 class="info-label">Blende</h4>
<p class="info-value">f/{photo.exif.aperture}</p>
</div>
{/if}
{#if photo.exif.iso}
<div class="info-section">
<h4 class="info-label">ISO</h4>
<p class="info-value">ISO {photo.exif.iso}</p>
</div>
{/if}
{#if photo.exif.gpsLatitude && photo.exif.gpsLongitude}
<div class="info-section">
<h4 class="info-label">Standort</h4>
<a
class="info-value text-primary hover:underline"
href={`https://www.google.com/maps?q=${photo.exif.gpsLatitude},${photo.exif.gpsLongitude}`}
target="_blank"
rel="noopener noreferrer"
>
Auf Karte anzeigen
</a>
</div>
{/if}
{/if}
{#if photo.tags && photo.tags.length > 0}
<div class="info-section">
<h4 class="info-label">Tags</h4>
<div class="flex flex-wrap gap-2">
{#each photo.tags as tag}
<span
class="rounded-full px-2 py-0.5 text-xs"
style="background-color: {tag.color}20; color: {tag.color}"
>
{tag.name}
</span>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.lightbox-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
}
.close-btn {
position: absolute;
top: 1rem;
left: 1rem;
padding: 0.5rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: white;
border: none;
cursor: pointer;
z-index: 10;
transition: background 150ms;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.lightbox-main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.lightbox-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.info-panel {
width: 320px;
background: var(--color-card, #ffffff);
color: var(--color-foreground, #0f172a);
overflow-y: auto;
padding: 1.5rem;
}
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.info-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 0.5rem;
background: var(--color-background, #ffffff);
color: var(--color-foreground, #0f172a);
cursor: pointer;
transition: all 150ms;
text-decoration: none;
font-size: 0.875rem;
}
.action-btn:hover {
background: var(--color-accent, #f1f5f9);
}
.action-btn.favorited {
color: #ef4444;
border-color: #ef4444;
}
.info-section {
margin-bottom: 1rem;
}
.info-label {
font-size: 0.75rem;
color: var(--color-muted-foreground, #64748b);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.info-value {
font-size: 0.875rem;
}
.icon-btn {
padding: 0.25rem;
border-radius: 50%;
background: transparent;
color: var(--color-foreground, #0f172a);
border: none;
cursor: pointer;
}
@media (max-width: 768px) {
.info-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
max-height: 50%;
border-radius: 1rem 1rem 0 0;
}
}
</style>

View file

@ -0,0 +1,105 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Photo } from '$lib/modules/photos/types';
import PhotoCard from './PhotoCard.svelte';
interface Props {
photos: Photo[];
loading: boolean;
hasMore: boolean;
onPhotoClick: (photo: Photo) => void;
onLoadMore: () => void;
}
let { photos, loading, hasMore, onPhotoClick, onLoadMore }: Props = $props();
let loadMoreRef = $state<HTMLDivElement | null>(null);
let observer: IntersectionObserver;
onMount(() => {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
onLoadMore();
}
},
{ threshold: 0.1 }
);
if (loadMoreRef) {
observer.observe(loadMoreRef);
}
return () => {
observer.disconnect();
};
});
$effect(() => {
if (loadMoreRef && observer) {
observer.disconnect();
observer.observe(loadMoreRef);
}
});
</script>
<div class="photo-grid">
{#each photos as photo (photo.id)}
<PhotoCard {photo} onClick={() => onPhotoClick(photo)} />
{/each}
</div>
{#if loading}
<div class="loading-indicator">
<div class="spinner"></div>
</div>
{/if}
{#if hasMore}
<div bind:this={loadMoreRef} class="load-more-trigger"></div>
{/if}
<style>
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 4px;
}
@media (min-width: 768px) {
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
}
}
@media (min-width: 1200px) {
.photo-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
.loading-indicator {
display: flex;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-border, #e2e8f0);
border-top-color: var(--color-primary, #6366f1);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.load-more-trigger {
height: 1px;
}
</style>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { DownloadSimple } from '@manacore/shared-icons';
interface Props {
onFilesSelected: (files: File[]) => void;
}
let { onFilesSelected }: Props = $props();
let dragActive = $state(false);
let fileInput: HTMLInputElement;
function handleDragOver(e: DragEvent) {
e.preventDefault();
dragActive = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
dragActive = false;
}
function handleDrop(e: DragEvent) {
e.preventDefault();
dragActive = false;
if (e.dataTransfer?.files) {
const files = Array.from(e.dataTransfer.files);
onFilesSelected(files);
}
}
function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files) {
const files = Array.from(input.files);
onFilesSelected(files);
input.value = '';
}
}
function openFilePicker() {
fileInput?.click();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex cursor-pointer flex-col items-center rounded-xl border-2 border-dashed p-12 text-center transition-all {dragActive
? 'border-primary bg-primary/5 border-solid'
: 'border-border bg-background-card hover:border-primary'}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
role="button"
tabindex="0"
onclick={openFilePicker}
onkeydown={(e) => e.key === 'Enter' && openFilePicker()}
>
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
class="hidden"
onchange={handleFileSelect}
/>
<div class="mb-4 text-foreground-secondary" class:text-primary={dragActive}>
<DownloadSimple size={48} />
</div>
<p class="text-foreground-secondary">Fotos hierher ziehen oder klicken zum Auswählen</p>
<button
type="button"
class="mt-4 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90"
onclick={(e) => {
e.stopPropagation();
openFilePicker();
}}
>
Dateien auswählen
</button>
</div>

View file

@ -0,0 +1,46 @@
/**
* Photos module barrel exports.
*/
export { photoStore } from './stores/photos.svelte';
export { albumMutations } from './stores/albums.svelte';
export {
useAllPhotoTags,
getTagById,
getTagsByIds,
tagMutations,
photoTagOps,
} from './stores/tags.svelte';
export {
useAllAlbums,
useAllAlbumItems,
useAllFavorites,
toAlbum,
toAlbumItem,
getAlbumById,
getAlbumItemsForAlbum,
getAlbumItemCount,
enrichAlbumsWithCounts,
isFavorited,
getFavoritedMediaIds,
} from './queries';
export {
albumTable,
albumItemTable,
photoFavoriteTable,
photoTagTable,
photoMediaTagTable,
PHOTOS_GUEST_SEED,
} from './collections';
export type {
LocalAlbum,
LocalAlbumItem,
LocalFavorite,
LocalTag,
LocalPhotoTag,
Photo,
PhotoFilters,
PhotoStats,
Album,
AlbumItem,
} from './types';

View file

@ -0,0 +1,101 @@
/**
* Reactive Queries & Pure Helpers for Photos
*
* Uses Dexie liveQuery on the unified DB.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalAlbum, LocalAlbumItem, LocalFavorite, Album, AlbumItem } from './types';
// ─── Type Converters ───────────────────────────────────────
/** Convert a LocalAlbum (IndexedDB) to the Album type. */
export function toAlbum(local: LocalAlbum): Album {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
coverMediaId: local.coverMediaId ?? undefined,
isAutoGenerated: local.isAutoGenerated,
autoGenerateType: local.autoGenerateType ?? undefined,
autoGenerateValue: local.autoGenerateValue ?? undefined,
itemCount: 0,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert a LocalAlbumItem (IndexedDB) to the AlbumItem type. */
export function toAlbumItem(local: LocalAlbumItem): AlbumItem {
return {
id: local.id,
albumId: local.albumId,
mediaId: local.mediaId,
sortOrder: local.sortOrder,
addedAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All albums. Auto-updates on any change. */
export function useAllAlbums() {
return liveQuery(async () => {
const locals = await db.table<LocalAlbum>('albums').toArray();
return locals.filter((a) => !a.deletedAt).map(toAlbum);
});
}
/** All album items. Auto-updates on any change. */
export function useAllAlbumItems() {
return liveQuery(async () => {
const locals = await db.table<LocalAlbumItem>('albumItems').toArray();
return locals.filter((i) => !i.deletedAt).map(toAlbumItem);
});
}
/** All favorites. Auto-updates on any change. */
export function useAllFavorites() {
return liveQuery(async () => {
const locals = await db.table<LocalFavorite>('photoFavorites').toArray();
return locals.filter((f) => !f.deletedAt);
});
}
// ─── Pure Album Helpers ────────────────────────────────────
/** Get an album by ID. */
export function getAlbumById(albums: Album[], id: string): Album | undefined {
return albums.find((a) => a.id === id);
}
/** Get album items for a specific album, sorted by sortOrder. */
export function getAlbumItemsForAlbum(allItems: AlbumItem[], albumId: string): AlbumItem[] {
return allItems.filter((i) => i.albumId === albumId).sort((a, b) => a.sortOrder - b.sortOrder);
}
/** Get the count of items in an album. */
export function getAlbumItemCount(allItems: AlbumItem[], albumId: string): number {
return allItems.filter((i) => i.albumId === albumId).length;
}
/** Enrich albums with item counts from album items. */
export function enrichAlbumsWithCounts(albums: Album[], allItems: AlbumItem[]): Album[] {
return albums.map((album) => ({
...album,
itemCount: getAlbumItemCount(allItems, album.id),
}));
}
// ─── Pure Favorite Helpers ─────────────────────────────────
/** Check if a media ID is favorited. */
export function isFavorited(favorites: LocalFavorite[], mediaId: string): boolean {
return favorites.some((f) => f.mediaId === mediaId);
}
/** Get the set of favorited media IDs. */
export function getFavoritedMediaIds(favorites: LocalFavorite[]): Set<string> {
return new Set(favorites.map((f) => f.mediaId));
}

View file

@ -0,0 +1,124 @@
/**
* Albums Store Mutation-Only
*
* Reads are handled by live queries in queries.ts.
* This store handles mutations (create, update, delete, add/remove items).
*/
import { db } from '$lib/data/database';
import type { LocalAlbum, LocalAlbumItem, Album } from '../types';
import { toAlbum } from '../queries';
export const albumMutations = {
async createAlbum(data: { name: string; description?: string }): Promise<Album | null> {
try {
const now = new Date().toISOString();
const newLocal: LocalAlbum = {
id: crypto.randomUUID(),
name: data.name,
description: data.description ?? null,
coverMediaId: null,
isAutoGenerated: false,
autoGenerateType: null,
autoGenerateValue: null,
createdAt: now,
updatedAt: now,
};
await db.table('albums').add(newLocal);
return toAlbum(newLocal);
} catch (e) {
console.error('Failed to create album:', e);
return null;
}
},
async updateAlbum(
id: string,
data: { name?: string; description?: string }
): Promise<Album | null> {
try {
const updateData: Record<string, unknown> = { updatedAt: new Date().toISOString() };
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description ?? null;
await db.table('albums').update(id, updateData);
const updated = await db.table<LocalAlbum>('albums').get(id);
return updated ? toAlbum(updated) : null;
} catch (e) {
console.error('Failed to update album:', e);
return null;
}
},
async deleteAlbum(id: string): Promise<boolean> {
try {
const now = new Date().toISOString();
// Soft-delete album items first
const items = await db.table<LocalAlbumItem>('albumItems').toArray();
for (const item of items.filter((i) => i.albumId === id)) {
await db.table('albumItems').update(item.id, { deletedAt: now, updatedAt: now });
}
await db.table('albums').update(id, { deletedAt: now, updatedAt: now });
return true;
} catch (e) {
console.error('Failed to delete album:', e);
return false;
}
},
async addPhotosToAlbum(albumId: string, mediaIds: string[]): Promise<boolean> {
try {
const existing = await db.table<LocalAlbumItem>('albumItems').toArray();
const existingInAlbum = existing.filter((i) => i.albumId === albumId && !i.deletedAt);
let nextOrder = existingInAlbum.length;
const now = new Date().toISOString();
for (const mediaId of mediaIds) {
if (existingInAlbum.some((i) => i.mediaId === mediaId)) continue;
await db.table('albumItems').add({
id: crypto.randomUUID(),
albumId,
mediaId,
sortOrder: nextOrder++,
createdAt: now,
updatedAt: now,
});
}
return true;
} catch (e) {
console.error('Failed to add photos to album:', e);
return false;
}
},
async removePhotoFromAlbum(albumId: string, mediaId: string): Promise<boolean> {
try {
const items = await db.table<LocalAlbumItem>('albumItems').toArray();
const item = items.find(
(i) => i.albumId === albumId && i.mediaId === mediaId && !i.deletedAt
);
if (item) {
const now = new Date().toISOString();
await db.table('albumItems').update(item.id, { deletedAt: now, updatedAt: now });
}
return true;
} catch (e) {
console.error('Failed to remove photo from album:', e);
return false;
}
},
async setCover(albumId: string, mediaId: string): Promise<boolean> {
try {
await db.table('albums').update(albumId, {
coverMediaId: mediaId,
updatedAt: new Date().toISOString(),
});
return true;
} catch (e) {
console.error('Failed to set album cover:', e);
return false;
}
},
};

View file

@ -0,0 +1,198 @@
/**
* Photos Store Server-fetched photos from mana-media + local-first mutations.
*
* Photo files live on mana-media (server-side, not in Dexie).
* Favorites are local-first via Dexie.
*/
import { db } from '$lib/data/database';
import type { LocalFavorite, Photo, PhotoFilters, PhotoStats } from '../types';
const MEDIA_URL = () =>
(typeof window !== 'undefined'
? (window as unknown as { __PUBLIC_MANA_MEDIA_URL__?: string }).__PUBLIC_MANA_MEDIA_URL__
: null) ||
import.meta.env.PUBLIC_MANA_MEDIA_URL ||
'http://localhost:3015';
async function mediaFetch<T>(
path: string,
token: string | null,
options: RequestInit = {}
): Promise<T | null> {
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${MEDIA_URL()}/api/v1${path}`, { ...options, headers });
if (!response.ok) return null;
return response.json();
}
// State — server-fetched photos (not local-first)
let photos = $state<Photo[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let hasMore = $state(true);
let filters = $state<PhotoFilters>({
limit: 50,
offset: 0,
sortBy: 'dateTaken',
sortOrder: 'desc',
});
let stats = $state<PhotoStats | null>(null);
let selectedPhoto = $state<Photo | null>(null);
export const photoStore = {
get photos() {
return photos;
},
get loading() {
return loading;
},
get error() {
return error;
},
get hasMore() {
return hasMore;
},
get filters() {
return filters;
},
get stats() {
return stats;
},
get selectedPhoto() {
return selectedPhoto;
},
async loadPhotos(reset = false, token: string | null = null) {
if (loading) return;
if (reset) {
photos = [];
filters = { ...filters, offset: 0 };
hasMore = true;
}
loading = true;
error = null;
try {
const params = new URLSearchParams();
if (filters.apps?.length) params.set('apps', filters.apps.join(','));
if (filters.mimeType) params.set('mimeType', filters.mimeType);
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom);
if (filters.dateTo) params.set('dateTo', filters.dateTo);
if (filters.hasLocation !== undefined) params.set('hasLocation', String(filters.hasLocation));
params.set('limit', String(filters.limit || 50));
params.set('offset', String(filters.offset || 0));
params.set('sortBy', filters.sortBy || 'dateTaken');
params.set('sortOrder', filters.sortOrder || 'desc');
const result = await mediaFetch<{ items: Photo[]; total: number; hasMore: boolean }>(
`/media/list/all?${params.toString()}`,
token
);
if (result) {
photos = reset ? result.items : [...photos, ...result.items];
hasMore = result.hasMore;
filters = { ...filters, offset: (filters.offset || 0) + result.items.length };
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load photos';
} finally {
loading = false;
}
},
async loadMore(token: string | null = null) {
if (!hasMore || loading) return;
await this.loadPhotos(false, token);
},
async setFilters(newFilters: Partial<PhotoFilters>, token: string | null = null) {
filters = { ...filters, ...newFilters, offset: 0 };
await this.loadPhotos(true, token);
},
async loadStats(token: string | null = null) {
try {
const result = await mediaFetch<PhotoStats>('/media/stats', token);
if (result) stats = result;
} catch (e) {
console.error('Failed to load stats:', e);
}
},
selectPhoto(photo: Photo | null) {
selectedPhoto = photo;
},
/** Toggle favorite — local-first via Dexie. */
async toggleFavorite(mediaId: string) {
try {
const existing = await db.table<LocalFavorite>('photoFavorites').toArray();
const fav = existing.find((f) => f.mediaId === mediaId && !f.deletedAt);
if (fav) {
await db.table('photoFavorites').update(fav.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
} else {
await db.table('photoFavorites').add({
id: crypto.randomUUID(),
mediaId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
// Update server-fetched photos in-memory for immediate UI feedback
const isFav = !fav;
photos = photos.map((p) => (p.id === mediaId ? { ...p, isFavorited: isFav } : p));
if (selectedPhoto?.id === mediaId) {
selectedPhoto = { ...selectedPhoto, isFavorited: isFav };
}
} catch (e) {
console.error('Failed to toggle favorite:', e);
}
},
async deletePhoto(mediaId: string, token: string | null = null) {
try {
const response = await fetch(`${MEDIA_URL()}/api/v1/media/${mediaId}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) {
error = 'Failed to delete photo';
return false;
}
photos = photos.filter((p) => p.id !== mediaId);
if (selectedPhoto?.id === mediaId) selectedPhoto = null;
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete photo';
return false;
}
},
reset() {
photos = [];
loading = false;
error = null;
hasMore = true;
filters = { limit: 50, offset: 0, sortBy: 'dateTaken', sortOrder: 'desc' };
stats = null;
selectedPhoto = null;
},
};

View file

@ -0,0 +1,146 @@
/**
* Photo Tag Store Local-First via Dexie
*
* Tag CRUD and photo-tag junction table operations.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalTag, LocalPhotoTag } from '../types';
// ─── Tag CRUD ─────────────────────────────────────────────
export function useAllPhotoTags() {
return liveQuery(async () => {
const all = await db.table<LocalTag>('photoTags').toArray();
return all.filter((t) => !t.deletedAt);
});
}
export function getTagById(tags: LocalTag[], id: string): LocalTag | undefined {
return tags.find((t) => t.id === id);
}
export function getTagsByIds(tags: LocalTag[], ids: string[]): LocalTag[] {
const idSet = new Set(ids);
return tags.filter((t) => idSet.has(t.id));
}
export const tagMutations = {
async createTag(data: { name: string; color?: string }): Promise<LocalTag | null> {
try {
const now = new Date().toISOString();
const tag: LocalTag = {
id: crypto.randomUUID(),
name: data.name,
color: data.color ?? null,
createdAt: now,
updatedAt: now,
};
await db.table('photoTags').add(tag);
return tag;
} catch (e) {
console.error('Failed to create tag:', e);
return null;
}
},
async deleteTag(id: string): Promise<boolean> {
try {
const now = new Date().toISOString();
await db.table('photoTags').update(id, { deletedAt: now, updatedAt: now });
// Also soft-delete photo-tag associations
const associations = await db.table<LocalPhotoTag>('photoMediaTags').toArray();
for (const a of associations.filter((pt) => pt.tagId === id)) {
await db.table('photoMediaTags').update(a.id, { deletedAt: now, updatedAt: now });
}
return true;
} catch (e) {
console.error('Failed to delete tag:', e);
return false;
}
},
};
// ─── Photo-Tag Junction ───────────────────────────────────
export const photoTagOps = {
/** Get tags for a photo */
async getPhotoTags(mediaId: string): Promise<string[]> {
try {
const all = await db.table<LocalPhotoTag>('photoMediaTags').toArray();
return all.filter((pt) => pt.mediaId === mediaId && !pt.deletedAt).map((pt) => pt.tagId);
} catch (e) {
console.error('Failed to get photo tags:', e);
return [];
}
},
/** Add tag to photo */
async addTagToPhoto(mediaId: string, tagId: string) {
try {
const all = await db.table<LocalPhotoTag>('photoMediaTags').toArray();
const exists = all.some(
(pt) => pt.mediaId === mediaId && pt.tagId === tagId && !pt.deletedAt
);
if (exists) return true;
const now = new Date().toISOString();
await db.table('photoMediaTags').add({
id: crypto.randomUUID(),
mediaId,
tagId,
createdAt: now,
updatedAt: now,
});
return true;
} catch (e) {
console.error('Failed to add tag to photo:', e);
return false;
}
},
/** Remove tag from photo */
async removeTagFromPhoto(mediaId: string, tagId: string) {
try {
const all = await db.table<LocalPhotoTag>('photoMediaTags').toArray();
const item = all.find((pt) => pt.mediaId === mediaId && pt.tagId === tagId && !pt.deletedAt);
if (item) {
const now = new Date().toISOString();
await db.table('photoMediaTags').update(item.id, { deletedAt: now, updatedAt: now });
}
return true;
} catch (e) {
console.error('Failed to remove tag from photo:', e);
return false;
}
},
/** Set all tags for a photo (replace) */
async setPhotoTags(mediaId: string, tagIds: string[]) {
try {
const now = new Date().toISOString();
// Soft-delete existing tags for this photo
const all = await db.table<LocalPhotoTag>('photoMediaTags').toArray();
const existing = all.filter((pt) => pt.mediaId === mediaId && !pt.deletedAt);
for (const item of existing) {
await db.table('photoMediaTags').update(item.id, { deletedAt: now, updatedAt: now });
}
// Add new tags
for (const tagId of tagIds) {
await db.table('photoMediaTags').add({
id: crypto.randomUUID(),
mediaId,
tagId,
createdAt: now,
updatedAt: now,
});
}
return true;
} catch (e) {
console.error('Failed to set photo tags:', e);
return false;
}
},
};

View file

@ -0,0 +1,99 @@
/**
* Photos module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
export interface LocalAlbum extends BaseRecord {
name: string;
description?: string | null;
coverMediaId?: string | null;
isAutoGenerated: boolean;
autoGenerateType?: 'date' | 'location' | 'camera' | null;
autoGenerateValue?: string | null;
}
export interface LocalAlbumItem extends BaseRecord {
albumId: string;
mediaId: string;
sortOrder: number;
}
export interface LocalFavorite extends BaseRecord {
mediaId: string;
}
export interface LocalTag extends BaseRecord {
name: string;
color?: string | null;
}
export interface LocalPhotoTag extends BaseRecord {
mediaId: string;
tagId: string;
}
/** Server-fetched photo (from mana-media, not local-first). */
export interface Photo {
id: string;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
size?: number;
mimeType?: string;
isFavorited?: boolean;
createdAt?: string;
tags?: Array<{ id: string; name: string; color?: string }>;
exif?: {
dateTaken?: string;
cameraMake?: string;
cameraModel?: string;
focalLength?: string;
aperture?: string;
iso?: number;
exposureTime?: string;
gpsLatitude?: number;
gpsLongitude?: number;
};
}
export interface PhotoFilters {
apps?: string[];
mimeType?: string;
dateFrom?: string;
dateTo?: string;
hasLocation?: boolean;
limit?: number;
offset?: number;
sortBy?: 'dateTaken' | 'createdAt' | 'size';
sortOrder?: 'asc' | 'desc';
}
export interface PhotoStats {
totalCount: number;
totalSize?: number;
}
/** Album type for UI consumption. */
export interface Album {
id: string;
name: string;
description?: string;
coverMediaId?: string;
coverUrl?: string;
isAutoGenerated: boolean;
autoGenerateType?: string;
autoGenerateValue?: string;
itemCount: number;
createdAt: string;
updatedAt: string;
}
export interface AlbumItem {
id: string;
albumId: string;
mediaId: string;
sortOrder: number;
addedAt: string;
}

View file

@ -0,0 +1,77 @@
/**
* Planta module collection accessors and guest seed data.
*
* Tables are already defined in the unified database (database.ts):
* plants, plantPhotos, wateringSchedules, wateringLogs
*/
import { db } from '$lib/data/database';
import type { LocalPlant, LocalPlantPhoto, LocalWateringSchedule, LocalWateringLog } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const plantTable = db.table<LocalPlant>('plants');
export const plantPhotoTable = db.table<LocalPlantPhoto>('plantPhotos');
export const wateringScheduleTable = db.table<LocalWateringSchedule>('wateringSchedules');
export const wateringLogTable = db.table<LocalWateringLog>('wateringLogs');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_PLANT_ID = 'demo-monstera';
export const PLANTA_GUEST_SEED = {
plants: [
{
id: DEMO_PLANT_ID,
name: 'Monstera',
scientificName: 'Monstera deliciosa',
commonName: 'Fensterblatt',
species: null,
lightRequirements: 'bright' as const,
wateringFrequencyDays: 7,
humidity: 'medium' as const,
temperature: '18-24\u00b0C',
soilType: null,
careNotes: 'Mag indirektes Licht. Erde zwischen dem Giessen leicht antrocknen lassen.',
isActive: true,
healthStatus: 'healthy' as const,
acquiredAt: null,
},
{
id: 'demo-cactus',
name: 'Kaktus',
scientificName: 'Echinocactus grusonii',
commonName: 'Schwiegermutterstuhl',
species: null,
lightRequirements: 'direct' as const,
wateringFrequencyDays: 21,
humidity: 'low' as const,
temperature: '15-30\u00b0C',
soilType: null,
careNotes: 'Selten giessen, mag viel Sonne.',
isActive: true,
healthStatus: 'healthy' as const,
acquiredAt: null,
},
],
wateringSchedules: [
{
id: 'schedule-monstera',
plantId: DEMO_PLANT_ID,
frequencyDays: 7,
lastWateredAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
nextWateringAt: new Date(Date.now() + 4 * 24 * 60 * 60 * 1000).toISOString(),
reminderEnabled: true,
reminderHoursBefore: 24,
},
{
id: 'schedule-cactus',
plantId: 'demo-cactus',
frequencyDays: 21,
lastWateredAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
nextWateringAt: new Date(Date.now() + 11 * 24 * 60 * 60 * 1000).toISOString(),
reminderEnabled: true,
reminderHoursBefore: 24,
},
],
};

View file

@ -0,0 +1,64 @@
/**
* Planta module barrel exports.
*/
// Collections & seed data
export {
plantTable,
plantPhotoTable,
wateringScheduleTable,
wateringLogTable,
PLANTA_GUEST_SEED,
} from './collections';
// Types
export type {
LocalPlant,
LocalPlantPhoto,
LocalWateringSchedule,
LocalWateringLog,
Plant,
PlantPhoto,
WateringSchedule,
WateringLog,
CreatePlantDto,
UpdatePlantDto,
LightLevel,
HumidityLevel,
HealthStatus,
HealthAssessment,
} from './types';
// Queries
export {
useAllPlants,
useAllPlantPhotos,
useAllWateringSchedules,
useAllWateringLogs,
toPlant,
toPlantPhoto,
toWateringSchedule,
toWateringLog,
getPlantById,
getActivePlants,
getPhotosForPlant,
getPrimaryPhoto,
getScheduleForPlant,
getLogsForPlant,
getDaysUntilWatering,
isWateringOverdue,
} from './queries';
// Mutations
export { plantMutations, wateringMutations } from './mutations';
// Utils
export {
parsePlantInput,
resolvePlantData,
formatParsedPlantPreview,
type CareAction,
type ParsedPlant,
type ParsedPlantWithIds,
} from './utils/plant-parser';
export { PLANTA_SYNTAX } from './utils/syntax-help';

View file

@ -0,0 +1,164 @@
/**
* Planta Mutation Helpers (Local-First)
*
* All writes go to IndexedDB first, sync handles the rest.
*/
import { db } from '$lib/data/database';
import { toPlant, toWateringSchedule } from './queries';
import { trackEvent } from '@manacore/shared-utils/analytics';
import type {
LocalPlant,
LocalWateringSchedule,
LocalWateringLog,
Plant,
CreatePlantDto,
UpdatePlantDto,
} from './types';
export const plantMutations = {
async create(dto: CreatePlantDto): Promise<Plant | null> {
try {
const now = new Date().toISOString();
const newLocal: LocalPlant = {
id: crypto.randomUUID(),
name: dto.name,
scientificName: dto.scientificName ?? null,
commonName: dto.commonName ?? null,
species: null,
lightRequirements: null,
wateringFrequencyDays: null,
humidity: null,
temperature: null,
soilType: null,
careNotes: null,
isActive: true,
healthStatus: null,
acquiredAt: dto.acquiredAt ?? null,
createdAt: now,
updatedAt: now,
};
await db.table('plants').add(newLocal);
trackEvent('plant_created');
return toPlant(newLocal);
} catch (e) {
console.error('Failed to create plant:', e);
return null;
}
},
async update(id: string, dto: UpdatePlantDto): Promise<Plant | null> {
try {
const updateData: Record<string, unknown> = {
updatedAt: new Date().toISOString(),
};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.scientificName !== undefined) updateData.scientificName = dto.scientificName ?? null;
if (dto.commonName !== undefined) updateData.commonName = dto.commonName ?? null;
if (dto.careNotes !== undefined) updateData.careNotes = dto.careNotes ?? null;
if (dto.isActive !== undefined) updateData.isActive = dto.isActive;
if (dto.lightRequirements !== undefined)
updateData.lightRequirements = dto.lightRequirements ?? null;
if (dto.wateringFrequencyDays !== undefined)
updateData.wateringFrequencyDays = dto.wateringFrequencyDays ?? null;
if (dto.humidity !== undefined) updateData.humidity = dto.humidity ?? null;
await db.table('plants').update(id, updateData);
const updated = await db.table<LocalPlant>('plants').get(id);
return updated ? toPlant(updated) : null;
} catch (e) {
console.error('Failed to update plant:', e);
return null;
}
},
async delete(id: string): Promise<boolean> {
try {
await db.table('plants').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
trackEvent('plant_deleted');
return true;
} catch (e) {
console.error('Failed to delete plant:', e);
return false;
}
},
};
export const wateringMutations = {
async logWatering(plantId: string, notes?: string): Promise<boolean> {
try {
const now = new Date().toISOString();
// Create watering log entry
const logEntry: LocalWateringLog = {
id: crypto.randomUUID(),
plantId,
wateredAt: now,
notes: notes ?? null,
createdAt: now,
updatedAt: now,
};
await db.table('wateringLogs').add(logEntry);
// Update watering schedule
const schedules = await db.table<LocalWateringSchedule>('wateringSchedules').toArray();
const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt);
if (schedule) {
const nextDate = new Date();
nextDate.setDate(nextDate.getDate() + schedule.frequencyDays);
await db.table('wateringSchedules').update(schedule.id, {
lastWateredAt: now,
nextWateringAt: nextDate.toISOString(),
updatedAt: now,
});
}
trackEvent('plant_watered');
return true;
} catch (e) {
console.error('Failed to log watering:', e);
return false;
}
},
async updateSchedule(plantId: string, frequencyDays: number): Promise<boolean> {
try {
const now = new Date().toISOString();
const schedules = await db.table<LocalWateringSchedule>('wateringSchedules').toArray();
const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt);
if (schedule) {
const nextDate = schedule.lastWateredAt
? new Date(new Date(schedule.lastWateredAt).getTime() + frequencyDays * 86400000)
: new Date(Date.now() + frequencyDays * 86400000);
await db.table('wateringSchedules').update(schedule.id, {
frequencyDays,
nextWateringAt: nextDate.toISOString(),
updatedAt: now,
});
} else {
const nextDate = new Date(Date.now() + frequencyDays * 86400000);
await db.table('wateringSchedules').add({
id: crypto.randomUUID(),
plantId,
frequencyDays,
lastWateredAt: null,
nextWateringAt: nextDate.toISOString(),
reminderEnabled: false,
reminderHoursBefore: 0,
createdAt: now,
updatedAt: now,
});
}
return true;
} catch (e) {
console.error('Failed to update watering schedule:', e);
return false;
}
},
};

View file

@ -0,0 +1,180 @@
/**
* Reactive Queries & Pure Helpers for Planta
*
* 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 { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type {
LocalPlant,
LocalPlantPhoto,
LocalWateringSchedule,
LocalWateringLog,
Plant,
PlantPhoto,
WateringSchedule,
WateringLog,
} from './types';
// ─── Type Converters ───────────────────────────────────────
/** Convert a LocalPlant (IndexedDB) to the shared Plant type. */
export function toPlant(local: LocalPlant): Plant {
return {
id: local.id,
userId: 'local',
name: local.name,
scientificName: local.scientificName ?? undefined,
commonName: local.commonName ?? undefined,
species: local.species ?? undefined,
lightRequirements: local.lightRequirements ?? undefined,
wateringFrequencyDays: local.wateringFrequencyDays ?? undefined,
humidity: local.humidity ?? undefined,
temperature: local.temperature ?? undefined,
soilType: local.soilType ?? undefined,
careNotes: local.careNotes ?? undefined,
isActive: local.isActive,
healthStatus: local.healthStatus ?? undefined,
acquiredAt: local.acquiredAt ? new Date(local.acquiredAt) : undefined,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
updatedAt: new Date(local.updatedAt ?? new Date().toISOString()),
};
}
/** Convert a LocalPlantPhoto (IndexedDB) to the shared PlantPhoto type. */
export function toPlantPhoto(local: LocalPlantPhoto): PlantPhoto {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
storagePath: local.storagePath,
publicUrl: local.publicUrl ?? undefined,
filename: local.filename,
mimeType: local.mimeType ?? undefined,
fileSize: local.fileSize ?? undefined,
width: local.width ?? undefined,
height: local.height ?? undefined,
isPrimary: local.isPrimary,
isAnalyzed: local.isAnalyzed,
takenAt: local.takenAt ? new Date(local.takenAt) : undefined,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
};
}
/** Convert a LocalWateringSchedule (IndexedDB) to the shared WateringSchedule type. */
export function toWateringSchedule(local: LocalWateringSchedule): WateringSchedule {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
frequencyDays: local.frequencyDays,
lastWateredAt: local.lastWateredAt ? new Date(local.lastWateredAt) : undefined,
nextWateringAt: local.nextWateringAt ? new Date(local.nextWateringAt) : undefined,
reminderEnabled: local.reminderEnabled,
reminderHoursBefore: local.reminderHoursBefore,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
updatedAt: new Date(local.updatedAt ?? new Date().toISOString()),
};
}
/** Convert a LocalWateringLog (IndexedDB) to the shared WateringLog type. */
export function toWateringLog(local: LocalWateringLog): WateringLog {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
wateredAt: new Date(local.wateredAt),
notes: local.notes ?? undefined,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
};
}
// ─── Live Queries ──────────────────────────────────────────
/** All plants. Auto-updates on any change. */
export function useAllPlants() {
return liveQuery(async () => {
const locals = await db.table<LocalPlant>('plants').toArray();
return locals.filter((p) => !p.deletedAt).map(toPlant);
});
}
/** All plant photos. Auto-updates on any change. */
export function useAllPlantPhotos() {
return liveQuery(async () => {
const locals = await db.table<LocalPlantPhoto>('plantPhotos').toArray();
return locals.filter((p) => !p.deletedAt).map(toPlantPhoto);
});
}
/** All watering schedules. Auto-updates on any change. */
export function useAllWateringSchedules() {
return liveQuery(async () => {
const locals = await db.table<LocalWateringSchedule>('wateringSchedules').toArray();
return locals.filter((s) => !s.deletedAt).map(toWateringSchedule);
});
}
/** All watering logs. Auto-updates on any change. */
export function useAllWateringLogs() {
return liveQuery(async () => {
const locals = await db.table<LocalWateringLog>('wateringLogs').toArray();
return locals.filter((l) => !l.deletedAt).map(toWateringLog);
});
}
// ─── Pure Plant Helpers ────────────────────────────────────
/** Get a plant by ID. */
export function getPlantById(plants: Plant[], id: string): Plant | undefined {
return plants.find((p) => p.id === id);
}
/** Get active plants only. */
export function getActivePlants(plants: Plant[]): Plant[] {
return plants.filter((p) => p.isActive);
}
/** Get photos for a specific plant. */
export function getPhotosForPlant(photos: PlantPhoto[], plantId: string): PlantPhoto[] {
return photos.filter((p) => p.plantId === plantId);
}
/** Get the primary photo for a plant. */
export function getPrimaryPhoto(photos: PlantPhoto[], plantId: string): PlantPhoto | undefined {
return photos.find((p) => p.plantId === plantId && p.isPrimary);
}
// ─── Pure Watering Helpers ─────────────────────────────────
/** Get watering schedule for a specific plant. */
export function getScheduleForPlant(
schedules: WateringSchedule[],
plantId: string
): WateringSchedule | undefined {
return schedules.find((s) => s.plantId === plantId);
}
/** Get watering logs for a specific plant, sorted by date (newest first). */
export function getLogsForPlant(logs: WateringLog[], plantId: string): WateringLog[] {
return logs
.filter((l) => l.plantId === plantId)
.sort((a, b) => new Date(b.wateredAt).getTime() - new Date(a.wateredAt).getTime());
}
/** Calculate days until next watering. Negative means overdue. */
export function getDaysUntilWatering(schedule: WateringSchedule | undefined): number | null {
if (!schedule?.nextWateringAt) return null;
const now = new Date();
const next = new Date(schedule.nextWateringAt);
return Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
/** Check if a plant's watering is overdue. */
export function isWateringOverdue(schedule: WateringSchedule | undefined): boolean {
const days = getDaysUntilWatering(schedule);
return days !== null && days < 0;
}

View file

@ -0,0 +1,13 @@
/**
* Tag Store Local-First via Shared Tag Store
* Tags are stored in shared IndexedDB ('manacore-tags'), accessible across all apps.
* Use context ('tags') for reads, tagMutations for writes.
*/
export {
tagMutations,
useAllTags,
getTagById,
getTagsByIds,
getTagColor,
getTagsByGroup,
} from '@manacore/shared-stores';

View file

@ -0,0 +1,140 @@
/**
* Planta module types for the unified app.
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Enums ─────────────────────────────────────────────────
export type LightLevel = 'low' | 'medium' | 'bright' | 'direct';
export type HumidityLevel = 'low' | 'medium' | 'high';
export type HealthStatus = 'healthy' | 'needs_attention' | 'sick';
export type HealthAssessment = 'healthy' | 'minor_issues' | 'needs_care' | 'critical';
// ─── Local Record Types ────────────────────────────────────
export interface LocalPlant extends BaseRecord {
name: string;
scientificName?: string | null;
commonName?: string | null;
species?: string | null;
lightRequirements?: LightLevel | null;
wateringFrequencyDays?: number | null;
humidity?: HumidityLevel | null;
temperature?: string | null;
soilType?: string | null;
careNotes?: string | null;
isActive: boolean;
healthStatus?: HealthStatus | null;
acquiredAt?: string | null;
}
export interface LocalPlantPhoto extends BaseRecord {
plantId: string;
storagePath: string;
publicUrl?: string | null;
filename: string;
mimeType?: string | null;
fileSize?: number | null;
width?: number | null;
height?: number | null;
isPrimary: boolean;
isAnalyzed: boolean;
takenAt?: string | null;
}
export interface LocalWateringSchedule extends BaseRecord {
plantId: string;
frequencyDays: number;
lastWateredAt?: string | null;
nextWateringAt?: string | null;
reminderEnabled: boolean;
reminderHoursBefore: number;
}
export interface LocalWateringLog extends BaseRecord {
plantId: string;
wateredAt: string;
notes?: string | null;
}
// ─── Shared Domain Types ───────────────────────────────────
export interface Plant {
id: string;
userId: string;
name: string;
scientificName?: string;
commonName?: string;
species?: string;
lightRequirements?: LightLevel;
wateringFrequencyDays?: number;
humidity?: HumidityLevel;
temperature?: string;
soilType?: string;
careNotes?: string;
isActive: boolean;
healthStatus?: HealthStatus;
acquiredAt?: Date;
createdAt: Date;
updatedAt: Date;
}
export interface PlantPhoto {
id: string;
plantId: string;
userId: string;
storagePath: string;
publicUrl?: string;
filename: string;
mimeType?: string;
fileSize?: number;
width?: number;
height?: number;
isPrimary: boolean;
isAnalyzed: boolean;
takenAt?: Date;
createdAt: Date;
}
export interface WateringSchedule {
id: string;
plantId: string;
userId: string;
frequencyDays: number;
lastWateredAt?: Date;
nextWateringAt?: Date;
reminderEnabled: boolean;
reminderHoursBefore: number;
createdAt: Date;
updatedAt: Date;
}
export interface WateringLog {
id: string;
plantId: string;
userId: string;
wateredAt: Date;
notes?: string;
createdAt: Date;
}
// ─── DTOs ──────────────────────────────────────────────────
export interface CreatePlantDto {
name: string;
scientificName?: string;
commonName?: string;
acquiredAt?: string;
}
export interface UpdatePlantDto {
name?: string;
scientificName?: string;
commonName?: string;
careNotes?: string;
isActive?: boolean;
lightRequirements?: LightLevel;
wateringFrequencyDays?: number;
humidity?: HumidityLevel;
}

View file

@ -0,0 +1,210 @@
/**
* Plant Parser for Planta Module
*
* Extends the base parser with plant-specific patterns:
* - Scientific names (italic Latin names)
* - Acquisition date
* - Tags for categories
*
* Examples:
* - "Monstera deliciosa #tropisch"
* - "Basilikum heute gekauft #kraeuter"
* - "Ficus benjamina morgen #zimmerpflanze"
*/
import {
parseBaseInput,
extractTags,
combineDateAndTime,
formatDatePreview,
type ParserLocale,
} from '@manacore/shared-utils';
export type CareAction = 'watered' | 'repotted' | 'fertilized' | 'pruned';
export interface ParsedPlant {
name: string;
acquiredAt?: Date;
tagNames: string[];
action?: CareAction;
}
export interface ParsedPlantWithIds {
name: string;
acquiredAt?: string;
}
// Care action patterns per locale
const CARE_ACTION_PATTERNS_BY_LOCALE: Record<
ParserLocale,
{ action: CareAction; pattern: RegExp }[]
> = {
de: [
{ action: 'watered', pattern: /\b(?:gegossen|gewaessert)\b/i },
{ action: 'repotted', pattern: /\bumgetopft\b/i },
{ action: 'fertilized', pattern: /\bgeduengt\b/i },
{ action: 'pruned', pattern: /\b(?:geschnitten|gestutzt)\b/i },
],
en: [
{ action: 'watered', pattern: /\bwatered\b/i },
{ action: 'repotted', pattern: /\brepotted\b/i },
{ action: 'fertilized', pattern: /\bfertilized\b/i },
{ action: 'pruned', pattern: /\b(?:pruned|trimmed)\b/i },
],
fr: [
{ action: 'watered', pattern: /\barros\u00e9\b/i },
{ action: 'repotted', pattern: /\brempot\u00e9\b/i },
{ action: 'fertilized', pattern: /\bfertilis\u00e9\b/i },
{ action: 'pruned', pattern: /\btaill\u00e9\b/i },
],
es: [
{ action: 'watered', pattern: /\bregado\b/i },
{ action: 'repotted', pattern: /\btrasplantado\b/i },
{ action: 'fertilized', pattern: /\bfertilizado\b/i },
{ action: 'pruned', pattern: /\bpodado\b/i },
],
it: [
{ action: 'watered', pattern: /\bannaffiato\b/i },
{ action: 'repotted', pattern: /\brinvasato\b/i },
{ action: 'fertilized', pattern: /\bfertilizzato\b/i },
{ action: 'pruned', pattern: /\bpotato\b/i },
],
};
const ACTION_LABELS: Record<CareAction, Record<ParserLocale, string>> = {
watered: { de: 'Gegossen', en: 'Watered', fr: 'Arros\u00e9', es: 'Regado', it: 'Annaffiato' },
repotted: {
de: 'Umgetopft',
en: 'Repotted',
fr: 'Rempot\u00e9',
es: 'Trasplantado',
it: 'Rinvasato',
},
fertilized: {
de: 'Ged\u00fcngt',
en: 'Fertilized',
fr: 'Fertilis\u00e9',
es: 'Fertilizado',
it: 'Fertilizzato',
},
pruned: { de: 'Geschnitten', en: 'Pruned', fr: 'Taill\u00e9', es: 'Podado', it: 'Potato' },
};
const ACTION_EMOJIS: Record<CareAction, string> = {
watered: '\ud83d\udca7',
repotted: '\ud83c\udf31',
fertilized: '\ud83e\uddea',
pruned: '\u2702\ufe0f',
};
function extractCareAction(
text: string,
locale: ParserLocale = 'de'
): { action?: CareAction; remaining: string } {
const patterns = CARE_ACTION_PATTERNS_BY_LOCALE[locale];
for (const { action, pattern } of patterns) {
if (pattern.test(text)) {
return {
action,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { action: undefined, remaining: text };
}
// Acquisition keywords per locale
const ACQUIRED_PATTERNS_BY_LOCALE: Record<ParserLocale, RegExp[]> = {
de: [/\bgekauft\b/i, /\bbekommen\b/i, /\berhalten\b/i, /\bgepflanzt\b/i],
en: [/\bbought\b/i, /\breceived\b/i, /\bgot\b/i, /\bplanted\b/i],
fr: [/\bachet\u00e9\b/i, /\bre\u00e7u\b/i, /\bplant\u00e9\b/i],
es: [/\bcomprado\b/i, /\brecibido\b/i, /\bplantado\b/i],
it: [/\bcomprato\b/i, /\bricevuto\b/i, /\bpiantato\b/i],
};
function extractAcquiredKeyword(
text: string,
locale: ParserLocale = 'de'
): { found: boolean; remaining: string } {
const patterns = ACQUIRED_PATTERNS_BY_LOCALE[locale];
for (const pattern of patterns) {
if (pattern.test(text)) {
return {
found: true,
remaining: text.replace(pattern, '').trim(),
};
}
}
return { found: false, remaining: text };
}
/**
* Parse natural language plant input
*
* Examples:
* - "Monstera #tropisch"
* - "Basilikum heute gekauft #kraeuter"
* - "Ficus benjamina"
*/
export function parsePlantInput(input: string, locale: ParserLocale = 'de'): ParsedPlant {
let text = input.trim();
// Extract care action BEFORE base parser so the action word is removed from title
const careResult = extractCareAction(text, locale);
text = careResult.remaining;
// Check for acquisition keywords
const acquiredResult = extractAcquiredKeyword(text, locale);
text = acquiredResult.remaining;
// Use base parser for date, time, tags
const base = parseBaseInput(text, locale);
// If we found a date (or acquisition keyword implies today)
let acquiredAt: Date | undefined;
if (base.date) {
acquiredAt = combineDateAndTime(base.date, base.time);
} else if (acquiredResult.found) {
acquiredAt = new Date(); // "gekauft" without date = today
}
return {
name: base.title,
acquiredAt,
tagNames: base.tagNames,
action: careResult.action,
};
}
/**
* Resolve to API-ready format
*/
export function resolvePlantData(parsed: ParsedPlant): ParsedPlantWithIds {
return {
name: parsed.name,
acquiredAt: parsed.acquiredAt?.toISOString(),
};
}
/**
* Format parsed plant for preview display
*/
export function formatParsedPlantPreview(parsed: ParsedPlant, locale: ParserLocale = 'de'): string {
const parts: string[] = [];
if (parsed.action) {
const emoji = ACTION_EMOJIS[parsed.action];
const label = ACTION_LABELS[parsed.action][locale];
parts.push(`${emoji} ${label}`);
}
if (parsed.acquiredAt) {
parts.push(`\ud83d\udcc5 ${formatDatePreview(parsed.acquiredAt, locale)}`);
}
if (parsed.tagNames.length > 0) {
parts.push(`\ud83c\udff7\ufe0f ${parsed.tagNames.join(', ')}`);
}
return parts.join(' \u00b7 ');
}

View file

@ -0,0 +1,24 @@
/**
* Planta-specific syntax help patterns
*/
import type { SyntaxGroup } from '@manacore/shared-ui';
export const PLANTA_SYNTAX: SyntaxGroup[] = [
{
title: 'Pflanzen',
items: [
{
pattern: 'Pflege',
description: 'Pflege-Aktion loggen',
examples: ['Monstera gegossen', 'Ficus umgetopft', 'Rose geduengt'],
color: 'success',
},
{
pattern: 'Erworben',
description: 'Erwerbsdatum angeben',
examples: ['gekauft', 'gepflanzt', 'bekommen'],
color: 'accent',
},
],
},
];

View file

@ -0,0 +1,195 @@
/**
* Times module collection accessors and guest seed data.
*
* Tables are defined in the unified database.ts as:
* timeClients, timeProjects, timeEntries, timeTags, timeTemplates, timeSettings
*/
import { db } from '$lib/data/database';
import type {
LocalClient,
LocalProject,
LocalTimeEntry,
LocalTag,
LocalTemplate,
LocalSettings,
} from './types';
// ─── Collection Accessors ──────────────────────────────────
export const clientTable = db.table<LocalClient>('timeClients');
export const projectTable = db.table<LocalProject>('timeProjects');
export const timeEntryTable = db.table<LocalTimeEntry>('timeEntries');
export const tagTable = db.table<LocalTag>('timeTags');
export const templateTable = db.table<LocalTemplate>('timeTemplates');
export const settingsTable = db.table<LocalSettings>('timeSettings');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_CLIENT_ID = 'demo-client-acme';
const DEMO_PROJECT_ID = 'demo-project-redesign';
const DEMO_INTERNAL_PROJECT_ID = 'demo-project-internal';
function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
function yesterdayStr(): string {
const d = new Date();
d.setDate(d.getDate() - 1);
return d.toISOString().split('T')[0];
}
export const TIMES_GUEST_SEED = {
timeClients: [
{
id: DEMO_CLIENT_ID,
name: 'Acme Corp',
shortCode: 'ACME',
email: 'kontakt@acme.de',
color: '#3b82f6',
isArchived: false,
billingRate: { amount: 95, currency: 'EUR', per: 'hour' as const },
order: 0,
},
{
id: 'demo-client-startup',
name: 'TechStartup GmbH',
shortCode: 'TS',
color: '#8b5cf6',
isArchived: false,
billingRate: { amount: 85, currency: 'EUR', per: 'hour' as const },
order: 1,
},
],
timeProjects: [
{
id: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
name: 'Website Redesign',
description: 'Kompletter Relaunch der Unternehmenswebsite',
color: '#3b82f6',
isArchived: false,
isBillable: true,
billingRate: { amount: 95, currency: 'EUR', per: 'hour' as const },
budget: { type: 'hours' as const, amount: 120 },
visibility: 'private' as const,
order: 0,
},
{
id: DEMO_INTERNAL_PROJECT_ID,
name: 'Intern / Meetings',
description: 'Interne Meetings, Orga, Admin',
color: '#6b7280',
isArchived: false,
isBillable: false,
visibility: 'private' as const,
order: 1,
},
{
id: 'demo-project-app',
clientId: 'demo-client-startup',
name: 'Mobile App',
description: 'React Native App Entwicklung',
color: '#8b5cf6',
isArchived: false,
isBillable: true,
budget: { type: 'hours' as const, amount: 200 },
visibility: 'private' as const,
order: 2,
},
],
timeEntries: [
{
id: 'times-entry-1',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'Homepage Layout erstellen',
date: todayStr(),
startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(),
duration: 9000,
isBillable: true,
isRunning: false,
tags: ['design'],
visibility: 'private' as const,
source: { app: 'manual' as const },
},
{
id: 'times-entry-2',
projectId: DEMO_INTERNAL_PROJECT_ID,
description: 'Sprint Planning',
date: todayStr(),
startTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(12, 15, 0, 0)).toISOString(),
duration: 2700,
isBillable: false,
isRunning: false,
tags: ['meeting'],
visibility: 'private' as const,
source: { app: 'manual' as const },
},
{
id: 'times-entry-3',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'API Integration',
date: todayStr(),
startTime: new Date(new Date().setHours(13, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(15, 0, 0, 0)).toISOString(),
duration: 7200,
isBillable: true,
isRunning: false,
tags: ['development'],
visibility: 'private' as const,
source: { app: 'timer' as const },
},
{
id: 'times-entry-4',
projectId: 'demo-project-app',
clientId: 'demo-client-startup',
description: 'Login Screen implementieren',
date: yesterdayStr(),
startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(12, 0, 0, 0)).toISOString(),
duration: 10800,
isBillable: true,
isRunning: false,
tags: ['development'],
visibility: 'private' as const,
source: { app: 'timer' as const },
},
{
id: 'times-entry-5',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'Code Review & Testing',
date: yesterdayStr(),
duration: 5400,
isBillable: true,
isRunning: false,
tags: ['review'],
visibility: 'private' as const,
source: { app: 'manual' as const },
},
],
timeTags: [
{ id: 'times-tag-design', name: 'design', color: '#f59e0b', order: 0 },
{ id: 'times-tag-dev', name: 'development', color: '#3b82f6', order: 1 },
{ id: 'times-tag-meeting', name: 'meeting', color: '#6b7280', order: 2 },
{ id: 'times-tag-review', name: 'review', color: '#22c55e', order: 3 },
],
timeSettings: [
{
id: 'times-default-settings',
workingHoursPerDay: 8,
workingDaysPerWeek: 5,
roundingIncrement: 0,
roundingMethod: 'none' as const,
defaultVisibility: 'private' as const,
weekStartsOn: 1 as const,
timerReminderMinutes: 0,
autoStopTimerHours: 0,
},
],
};

View file

@ -0,0 +1,329 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timeEntryTable } from '$lib/modules/times/collections';
import { X, CurrencyDollar } from '@manacore/shared-icons';
import type { Project, Client } from '$lib/modules/times/types';
import {
parseMultiEntryInput,
resolveEntryIds,
formatParsedEntryPreview,
} from '$lib/modules/times/utils/entry-parser';
let {
visible = false,
onClose,
}: {
visible: boolean;
onClose: () => void;
} = $props();
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
const allTags = getContext<{ value: { id: string; name: string }[] }>('tags');
let description = $state('');
let projectId = $state('');
let date = $state(new Date().toISOString().split('T')[0]);
let durationHours = $state(1);
let durationMinutes = $state(0);
let isBillable = $state(false);
// Quick-input state
let quickInput = $state('');
let quickPreview = $state('');
let quickEntryCount = $state(0);
let activeProjects = $derived(
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
);
function handleQuickInput(e: Event) {
const text = (e.target as HTMLInputElement).value;
quickInput = text;
if (!text.trim()) {
quickPreview = '';
quickEntryCount = 0;
return;
}
const entries = parseMultiEntryInput(text);
quickEntryCount = entries.length;
const previews = entries.map((e) => formatParsedEntryPreview(e)).filter(Boolean);
if (entries.length > 1) previews.unshift(`${entries.length} Einträge`);
quickPreview = previews.join(' · ');
}
async function handleQuickSubmit() {
if (!quickInput.trim()) return;
const entries = parseMultiEntryInput(quickInput);
const projects = allProjects.value.map((p) => ({ id: p.id, name: p.name }));
const tags = allTags?.value?.map((t) => ({ id: t.id, name: t.name })) ?? [];
for (const parsed of entries) {
const resolved = resolveEntryIds(parsed, projects, tags);
const totalSeconds = resolved.duration || durationHours * 3600 + durationMinutes * 60;
if (totalSeconds <= 0) continue;
const project = resolved.projectId
? allProjects.value.find((p) => p.id === resolved.projectId)
: null;
await timeEntryTable.add({
id: crypto.randomUUID(),
projectId: resolved.projectId || null,
clientId: project?.clientId ?? null,
description: resolved.description,
date: resolved.date ? new Date(resolved.date).toISOString().split('T')[0] : date,
startTime: resolved.startTime || null,
endTime: resolved.endTime || null,
duration: totalSeconds,
isBillable: resolved.isBillable ?? isBillable,
isRunning: false,
tags: resolved.tagIds,
billingRate: null,
visibility: 'private',
guildId: null,
source: { app: 'manual' },
});
}
quickInput = '';
quickPreview = '';
quickEntryCount = 0;
resetForm();
onClose();
}
function handleQuickKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
handleQuickSubmit();
}
}
function resetForm() {
description = '';
projectId = '';
date = new Date().toISOString().split('T')[0];
durationHours = 1;
durationMinutes = 0;
isBillable = false;
}
async function handleSubmit() {
const totalSeconds = durationHours * 3600 + durationMinutes * 60;
if (totalSeconds <= 0) return;
const project = projectId ? allProjects.value.find((p) => p.id === projectId) : null;
await timeEntryTable.add({
id: crypto.randomUUID(),
projectId: projectId || null,
clientId: project?.clientId ?? null,
description,
date,
startTime: null,
endTime: null,
duration: totalSeconds,
isBillable,
isRunning: false,
tags: [],
billingRate: null,
visibility: 'private',
guildId: null,
source: { app: 'manual' },
});
resetForm();
onClose();
}
function handleProjectChange(id: string) {
projectId = id;
const project = allProjects.value.find((p) => p.id === id);
if (project) {
isBillable = project.isBillable;
}
}
// Quick duration buttons
const quickDurations = [
{ label: '15m', h: 0, m: 15 },
{ label: '30m', h: 0, m: 30 },
{ label: '1h', h: 1, m: 0 },
{ label: '1.5h', h: 1, m: 30 },
{ label: '2h', h: 2, m: 0 },
{ label: '4h', h: 4, m: 0 },
];
</script>
{#if visible}
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 backdrop-blur-sm sm:items-center"
role="dialog"
aria-modal="true"
>
<div
class="w-full max-w-lg rounded-t-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 shadow-xl sm:rounded-2xl"
>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-[hsl(var(--foreground))]">
{$_('entry.manual')}
</h2>
<button
onclick={onClose}
class="rounded-lg p-1 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<X size={20} />
</button>
</div>
<!-- Quick Input Bar -->
<div class="mb-4">
<input
type="text"
value={quickInput}
oninput={handleQuickInput}
onkeydown={handleQuickKeydown}
placeholder="Schnelleingabe: Meeting 2h @Projekt $; Review 1h"
class="w-full rounded-lg border border-dashed border-[hsl(var(--border))] bg-[hsl(var(--muted)/0.3)] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] placeholder:text-xs placeholder:text-[hsl(var(--muted-foreground))] focus:border-solid focus:border-[hsl(var(--primary)/0.5)] focus:bg-[hsl(var(--input))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary)/0.1)]"
/>
{#if quickPreview}
<div class="mt-1 px-1 text-[0.7rem] text-[hsl(var(--muted-foreground))]">
{quickPreview}
</div>
{/if}
</div>
<div class="mb-3 flex items-center gap-2">
<div class="h-px flex-1 bg-[hsl(var(--border))]"></div>
<span class="text-[0.65rem] text-[hsl(var(--muted-foreground))]">oder manuell</span>
<div class="h-px flex-1 bg-[hsl(var(--border))]"></div>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-4"
>
<!-- Description -->
<input
type="text"
bind:value={description}
placeholder={$_('entry.description')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<!-- Project -->
<select
value={projectId}
onchange={(e) => handleProjectChange((e.target as HTMLSelectElement).value)}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.internal')}</option>
{#each activeProjects as proj}
<option value={proj.id}>
{proj.name}
{#if proj.clientId}
{@const client = allClients.value.find((c) => c.id === proj.clientId)}
{#if client}
· {client.name}{/if}
{/if}
</option>
{/each}
</select>
<!-- Date -->
<input
type="date"
bind:value={date}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))]"
/>
<!-- Quick Duration Buttons -->
<div>
<label class="mb-1.5 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
{$_('entry.duration')}
</label>
<div class="mb-2 flex flex-wrap gap-2">
{#each quickDurations as qd}
<button
type="button"
onclick={() => {
durationHours = qd.h;
durationMinutes = qd.m;
}}
class="rounded-lg border px-3 py-1.5 text-xs transition-colors {durationHours ===
qd.h && durationMinutes === qd.m
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))] hover:border-[hsl(var(--primary)/0.5)]'}"
>
{qd.label}
</button>
{/each}
</div>
<!-- Custom Duration -->
<div class="flex items-center gap-2">
<input
type="number"
bind:value={durationHours}
min="0"
max="24"
class="w-16 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">h</span>
<input
type="number"
bind:value={durationMinutes}
min="0"
max="59"
step="5"
class="w-16 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">min</span>
</div>
</div>
<!-- Billable -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={() => (isBillable = !isBillable)}
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors {isBillable
? 'bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'text-[hsl(var(--muted-foreground))]'}"
>
<CurrencyDollar size={16} />
{isBillable ? $_('entry.billable') : $_('entry.notBillable')}
</button>
</div>
<!-- Submit -->
<div class="flex gap-2">
<button
type="button"
onclick={onClose}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2.5 text-sm text-[hsl(var(--muted-foreground))]"
>
{$_('common.cancel')}
</button>
<button
type="submit"
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
{$_('common.create')}
</button>
</div>
</form>
</div>
</div>
{/if}

View file

@ -0,0 +1,209 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timeEntryTable } from '$lib/modules/times/collections';
import { formatDurationCompact } from '$lib/modules/times/queries';
import { CurrencyDollar } from '@manacore/shared-icons';
import type { TimeEntry, Project, Client } from '$lib/modules/times/types';
import { ConfirmationModal } from '@manacore/shared-ui';
let {
entry,
isExpanded = false,
onExpand,
onCollapse,
}: {
entry: TimeEntry;
isExpanded?: boolean;
onExpand?: () => void;
onCollapse?: () => void;
} = $props();
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let editDescription = $state(entry.description);
let editProjectId = $state(entry.projectId ?? '');
let editIsBillable = $state(entry.isBillable);
let editDurationMinutes = $state(Math.round(entry.duration / 60));
// Sync when entry changes
$effect(() => {
editDescription = entry.description;
editProjectId = entry.projectId ?? '';
editIsBillable = entry.isBillable;
editDurationMinutes = Math.round(entry.duration / 60);
});
let project = $derived(
entry.projectId ? allProjects.value.find((p) => p.id === entry.projectId) : undefined
);
let client = $derived(
entry.clientId ? allClients.value.find((c) => c.id === entry.clientId) : undefined
);
let showDeleteConfirm = $state(false);
let saveDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSave(updates: Record<string, unknown>) {
if (saveDebounce) clearTimeout(saveDebounce);
saveDebounce = setTimeout(async () => {
await timeEntryTable.update(entry.id, updates);
}, 500);
}
function handleDescriptionChange(value: string) {
editDescription = value;
autoSave({ description: value });
}
function handleProjectChange(projectId: string) {
editProjectId = projectId;
const proj = allProjects.value.find((p) => p.id === projectId);
autoSave({
projectId: projectId || null,
clientId: proj?.clientId ?? null,
});
}
function handleBillableToggle() {
editIsBillable = !editIsBillable;
autoSave({ isBillable: editIsBillable });
}
function handleDurationChange(minutes: number) {
editDurationMinutes = minutes;
autoSave({ duration: minutes * 60 });
}
async function handleDelete() {
showDeleteConfirm = true;
}
async function confirmDelete() {
await timeEntryTable.delete(entry.id);
showDeleteConfirm = false;
onCollapse?.();
}
let startTimeStr = $derived(
entry.startTime
? new Date(entry.startTime).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})
: ''
);
let endTimeStr = $derived(
entry.endTime
? new Date(entry.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: ''
);
</script>
<div
class="entry-item rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] transition-all {isExpanded
? 'ring-1 ring-[hsl(var(--primary)/0.3)]'
: ''}"
>
<!-- Compact row (always visible) -->
<button
class="flex w-full items-center gap-3 px-4 py-3 text-left"
onclick={() => (isExpanded ? onCollapse?.() : onExpand?.())}
>
{#if project}
<div class="project-dot" style="background-color: {project.color}"></div>
{:else}
<div class="project-dot" style="background-color: #9ca3af"></div>
{/if}
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-[hsl(var(--foreground))]">
{entry.description || $_('timer.noDescription')}
</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{project?.name || $_('project.internal')}
{#if client}· {client.name}{/if}
{#if startTimeStr && endTimeStr}
· {startTimeStr} {endTimeStr}
{/if}
</p>
</div>
<div class="text-right shrink-0">
<p class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(entry.duration)}
</p>
{#if entry.isBillable}
<span class="text-xs text-[hsl(var(--primary))]">$</span>
{/if}
</div>
</button>
<!-- Expanded edit form -->
{#if isExpanded}
<div class="border-t border-[hsl(var(--border))] px-4 py-3 space-y-3">
<!-- Description -->
<input
type="text"
value={editDescription}
oninput={(e) => handleDescriptionChange((e.target as HTMLInputElement).value)}
placeholder={$_('entry.description')}
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:border-[hsl(var(--primary))] focus:outline-none"
/>
<!-- Project + Duration row -->
<div class="flex gap-2">
<select
value={editProjectId}
onchange={(e) => handleProjectChange((e.target as HTMLSelectElement).value)}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.internal')}</option>
{#each allProjects.value.filter((p) => !p.isArchived) as proj}
<option value={proj.id}>{proj.name}</option>
{/each}
</select>
<div class="flex items-center gap-1">
<input
type="number"
value={editDurationMinutes}
oninput={(e) =>
handleDurationChange(parseInt((e.target as HTMLInputElement).value) || 0)}
min="0"
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">min</span>
</div>
</div>
<!-- Billable + Delete row -->
<div class="flex items-center justify-between">
<button
onclick={handleBillableToggle}
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs transition-colors {editIsBillable
? 'bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]'}"
>
<CurrencyDollar size={14} />
{editIsBillable ? $_('entry.billable') : $_('entry.notBillable')}
</button>
<button
onclick={handleDelete}
class="rounded-lg px-3 py-1.5 text-xs text-red-500 transition-colors hover:bg-red-500/10"
>
{$_('common.delete')}
</button>
</div>
</div>
{/if}
</div>
<ConfirmationModal
visible={showDeleteConfirm}
title={$_('common.delete')}
message={$_('entry.deleteConfirm')}
onConfirm={confirmDelete}
onClose={() => (showDeleteConfirm = false)}
/>

View file

@ -0,0 +1,72 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import EntryItem from './EntryItem.svelte';
import {
groupEntriesByDate,
getTotalDuration,
formatDurationCompact,
} from '$lib/modules/times/queries';
import type { TimeEntry } from '$lib/modules/times/types';
import { Clock } from '@manacore/shared-icons';
let { entries }: { entries: TimeEntry[] } = $props();
let expandedEntryId = $state<string | null>(null);
let groupedEntries = $derived(() => {
const groups = groupEntriesByDate(entries);
// Sort dates descending (newest first)
return [...groups.entries()].sort(([a], [b]) => b.localeCompare(a));
});
function formatDateHeader(dateStr: string): string {
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
if (dateStr === today) return $_('entry.today');
if (dateStr === yesterday) return 'Gestern';
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
});
}
</script>
{#if entries.length === 0}
<div
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
>
<Clock size={20} class="mx-auto mb-3 opacity-50" />
<p>{$_('entry.noEntries')}</p>
</div>
{:else}
<div class="space-y-6">
{#each groupedEntries() as [date, dayEntries]}
<div>
<!-- Day Header -->
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-medium text-[hsl(var(--muted-foreground))]">
{formatDateHeader(date)}
</h3>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(getTotalDuration(dayEntries))}
</span>
</div>
<!-- Entries for this day -->
<div class="space-y-2">
{#each dayEntries as entry (entry.id)}
<EntryItem
{entry}
isExpanded={expandedEntryId === entry.id}
onExpand={() => (expandedEntryId = entry.id)}
onCollapse={() => (expandedEntryId = null)}
/>
{/each}
</div>
</div>
{/each}
</div>
{/if}

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { timerStore } from '$lib/modules/times/stores/timer.svelte';
let {
onNewEntry,
}: {
onNewEntry?: () => void;
} = $props();
function handleKeydown(e: KeyboardEvent) {
// Don't trigger when typing in inputs
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT'
) {
// Escape still works in inputs
if (e.key === 'Escape') {
(target as HTMLInputElement).blur();
}
return;
}
switch (e.key) {
case 's':
e.preventDefault();
if (timerStore.isRunning) {
timerStore.stop();
} else {
timerStore.start();
}
break;
case 'n':
e.preventDefault();
onNewEntry?.();
break;
}
}
onMount(() => {
window.addEventListener('keydown', handleKeydown);
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown);
});
</script>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/modules/times/stores/timer.svelte';
import type { TimeEntry, Project } from '$lib/modules/times/types';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
const allProjects = getContext<{ value: Project[] }>('projects');
// Get unique recent entries (by description+project, deduplicated)
let recentEntries = $derived(() => {
const seen = new Set<string>();
return allTimeEntries.value
.filter((e) => !e.isRunning && e.description)
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))
.filter((e) => {
const key = `${e.description}|${e.projectId}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, 5);
});
async function startFromEntry(entry: TimeEntry) {
await timerStore.start({
projectId: entry.projectId,
clientId: entry.clientId,
description: entry.description,
isBillable: entry.isBillable,
tags: entry.tags,
});
}
</script>
{#if recentEntries().length > 0}
<div>
<h3 class="mb-2 text-xs font-medium text-[hsl(var(--muted-foreground))]">Quick Start</h3>
<div class="flex flex-wrap gap-2">
{#each recentEntries() as entry}
{@const project = entry.projectId
? allProjects.value.find((p) => p.id === entry.projectId)
: undefined}
<button
onclick={() => startFromEntry(entry)}
disabled={timerStore.isRunning}
class="flex items-center gap-1.5 rounded-full border border-[hsl(var(--border))] px-3 py-1.5 text-xs transition-colors hover:border-[hsl(var(--primary)/0.5)] hover:bg-[hsl(var(--primary)/0.05)] disabled:opacity-50"
>
{#if project}
<div class="h-2 w-2 rounded-full" style="background-color: {project.color}"></div>
{/if}
<span class="max-w-[150px] truncate text-[hsl(var(--foreground))]"
>{entry.description}</span
>
</button>
{/each}
</div>
</div>
{/if}

View file

@ -0,0 +1,188 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/modules/times/stores/timer.svelte';
import { formatDuration } from '$lib/modules/times/queries';
import { CurrencyDollar, Pause, Play } from '@manacore/shared-icons';
import type { Project, Client } from '$lib/modules/times/types';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let description = $state('');
let selectedProjectId = $state<string | null>(null);
let isBillable = $state(false);
// Sync description with running entry
$effect(() => {
if (timerStore.runningEntry) {
description = timerStore.runningEntry.description || '';
selectedProjectId = timerStore.runningEntry.projectId ?? null;
isBillable = timerStore.runningEntry.isBillable;
}
});
let activeProjects = $derived(
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
);
let selectedProject = $derived(
selectedProjectId ? allProjects.value.find((p) => p.id === selectedProjectId) : null
);
let selectedClient = $derived(
selectedProject?.clientId
? allClients.value.find((c) => c.id === selectedProject!.clientId)
: null
);
let formattedTime = $derived(formatDuration(timerStore.elapsedSeconds));
async function handleStartStop() {
if (timerStore.isRunning) {
await timerStore.stop();
description = '';
selectedProjectId = null;
isBillable = false;
} else {
const clientId = selectedProject?.clientId ?? undefined;
await timerStore.start({
description,
projectId: selectedProjectId ?? undefined,
clientId,
isBillable,
});
}
}
let descriptionDebounce: ReturnType<typeof setTimeout> | null = null;
function handleDescriptionChange(value: string) {
description = value;
if (!timerStore.isRunning) return;
if (descriptionDebounce) clearTimeout(descriptionDebounce);
descriptionDebounce = setTimeout(() => {
timerStore.updateRunning({ description: value });
}, 500);
}
async function handleProjectChange(projectId: string | null) {
selectedProjectId = projectId;
if (!timerStore.isRunning) return;
const project = projectId ? allProjects.value.find((p) => p.id === projectId) : null;
await timerStore.updateRunning({
projectId: projectId ?? undefined,
clientId: project?.clientId ?? undefined,
isBillable: project?.isBillable ?? isBillable,
});
if (project) {
isBillable = project.isBillable;
}
}
async function handleBillableToggle() {
isBillable = !isBillable;
if (timerStore.isRunning) {
await timerStore.updateRunning({ isBillable });
}
}
</script>
<div
class="rounded-2xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-6 {timerStore.isRunning
? 'timer-active'
: ''}"
>
<!-- Timer Display -->
<div class="mb-4 text-center">
<div
class="duration-display text-5xl font-bold {timerStore.isRunning
? 'text-[hsl(var(--primary))]'
: 'text-[hsl(var(--foreground))]'}"
>
{formattedTime}
</div>
</div>
<!-- Description Input -->
<div class="mb-4">
<input
type="text"
value={description}
oninput={(e) => handleDescriptionChange((e.target as HTMLInputElement).value)}
placeholder={$_('timer.whatAreYouWorkingOn')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:border-[hsl(var(--primary))] focus:outline-none focus:ring-1 focus:ring-[hsl(var(--primary))]"
/>
</div>
<!-- Project & Billable Row -->
<div class="mb-4 flex items-center gap-3">
<!-- Project Selector -->
<div class="flex-1">
<select
value={selectedProjectId ?? ''}
onchange={(e) => {
const val = (e.target as HTMLSelectElement).value;
handleProjectChange(val || null);
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.noProjects')}</option>
{#each activeProjects as project}
<option value={project.id}>
{project.name}
{#if project.clientId}
{@const client = allClients.value.find((c) => c.id === project.clientId)}
{#if client}
· {client.name}{/if}
{/if}
</option>
{/each}
</select>
</div>
<!-- Billable Toggle -->
<button
onclick={handleBillableToggle}
class="flex h-9 w-9 items-center justify-center rounded-lg border transition-colors {isBillable
? 'border-[hsl(var(--primary))] bg-[hsl(var(--primary)/0.1)] text-[hsl(var(--primary))]'
: 'border-[hsl(var(--border))] text-[hsl(var(--muted-foreground))]'}"
title={isBillable ? $_('entry.billable') : $_('entry.notBillable')}
>
<CurrencyDollar size={16} />
</button>
</div>
<!-- Start/Stop Button -->
<button
onclick={handleStartStop}
class="w-full rounded-xl py-3 text-lg font-medium transition-all {timerStore.isRunning
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90'}"
>
{#if timerStore.isRunning}
<span class="flex items-center justify-center gap-2">
<Pause size={20} weight="fill" />
{$_('timer.stop')}
</span>
{:else}
<span class="flex items-center justify-center gap-2">
<Play size={20} weight="fill" />
{$_('timer.start')}
</span>
{/if}
</button>
<!-- Running info -->
{#if timerStore.isRunning && selectedProject}
<div
class="mt-3 flex items-center justify-center gap-2 text-xs text-[hsl(var(--muted-foreground))]"
>
<div class="project-dot" style="background-color: {selectedProject.color}"></div>
<span>{selectedProject.name}</span>
{#if selectedClient}
<span>· {selectedClient.name}</span>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,61 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timerStore } from '$lib/modules/times/stores/timer.svelte';
import { formatDuration } from '$lib/modules/times/queries';
import { Stop } from '@manacore/shared-icons';
import type { Project } from '$lib/modules/times/types';
const allProjects = getContext<{ value: Project[] }>('projects');
let project = $derived(
timerStore.runningEntry?.projectId
? allProjects.value.find((p) => p.id === timerStore.runningEntry!.projectId)
: undefined
);
let formattedTime = $derived(formatDuration(timerStore.elapsedSeconds));
async function handleStop() {
await timerStore.stop();
}
</script>
{#if timerStore.isRunning}
<div
class="flex items-center gap-2 rounded-lg bg-[hsl(var(--primary)/0.1)] px-3 py-1.5 border border-[hsl(var(--primary)/0.2)]"
>
<!-- Pulsing dot -->
<div class="relative flex h-2 w-2">
<span
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[hsl(var(--primary))] opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full bg-[hsl(var(--primary))]"></span>
</div>
<!-- Project dot + Description -->
{#if project}
<div
class="h-2.5 w-2.5 rounded-full shrink-0"
style="background-color: {project.color}"
></div>
{/if}
<span class="hidden sm:inline max-w-[120px] truncate text-xs text-[hsl(var(--foreground))]">
{timerStore.runningEntry?.description || $_('timer.running')}
</span>
<!-- Elapsed time -->
<span class="duration-display text-xs font-medium text-[hsl(var(--primary))]">
{formattedTime}
</span>
<!-- Stop button -->
<button
onclick={handleStop}
class="flex h-5 w-5 items-center justify-center rounded bg-red-500 text-white transition-colors hover:bg-red-600"
title={$_('timer.stop')}
>
<Stop size={10} weight="fill" />
</button>
</div>
{/if}

View file

@ -0,0 +1,70 @@
/**
* Times module barrel exports.
*/
export { timerStore } from './stores/timer.svelte';
export { viewStore } from './stores/view.svelte';
export {
useAllClients,
useAllProjects,
useAllTimeEntries,
useAllTags,
useAllTemplates,
useSettings,
toClient,
toProject,
toTimeEntry,
toTag,
toTemplate,
toSettings,
formatDuration,
formatDurationCompact,
formatDurationDecimal,
getEntriesByDate,
getEntriesByDateRange,
getTotalDuration,
getBillableDuration,
groupEntriesByDate,
groupEntriesByProject,
getFilteredEntries,
getSortedEntries,
getActiveProjects,
getActiveClients,
getProjectById,
getClientById,
getProjectsByClient,
} from './queries';
export {
clientTable,
projectTable,
timeEntryTable,
tagTable,
templateTable,
settingsTable,
TIMES_GUEST_SEED,
} from './collections';
export { roundDuration } from './utils/rounding';
export { exportEntriesToCSV } from './utils/export';
export { PROJECT_COLORS } from './types';
export type {
LocalClient,
LocalProject,
LocalTimeEntry,
LocalTag,
LocalTemplate,
LocalSettings,
BillingRate,
ProjectVisibility,
EntrySourceRef,
Client,
Project,
TimeEntry,
Tag,
EntryTemplate,
TimesSettings,
FilterCriteria,
SortOption,
SavedFilter,
ViewMode,
RoundingMethod,
} from './types';

View file

@ -0,0 +1,328 @@
/**
* Reactive Queries & Pure Helpers for Times module.
*
* Uses Dexie liveQuery on the unified database.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type {
LocalClient,
LocalProject,
LocalTimeEntry,
LocalTag,
LocalTemplate,
LocalSettings,
Client,
Project,
TimeEntry,
Tag,
EntryTemplate,
TimesSettings,
FilterCriteria,
SortOption,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toClient(local: LocalClient): Client {
return {
id: local.id,
name: local.name,
shortCode: local.shortCode ?? undefined,
contactId: local.contactId ?? undefined,
email: local.email ?? undefined,
color: local.color,
isArchived: local.isArchived,
billingRate: local.billingRate ?? undefined,
notes: local.notes ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toProject(local: LocalProject): Project {
return {
id: local.id,
clientId: local.clientId ?? undefined,
name: local.name,
description: local.description ?? undefined,
color: local.color,
isArchived: local.isArchived,
isBillable: local.isBillable,
billingRate: local.billingRate ?? undefined,
budget: local.budget ?? undefined,
visibility: local.visibility,
guildId: local.guildId ?? undefined,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTimeEntry(local: LocalTimeEntry): TimeEntry {
return {
id: local.id,
projectId: local.projectId ?? undefined,
clientId: local.clientId ?? undefined,
description: local.description,
date: local.date,
startTime: local.startTime ?? undefined,
endTime: local.endTime ?? undefined,
duration: local.duration,
isBillable: local.isBillable,
isRunning: local.isRunning,
tags: local.tags,
billingRate: local.billingRate ?? undefined,
visibility: local.visibility,
guildId: local.guildId ?? undefined,
source: local.source ?? undefined,
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,
color: local.color,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTemplate(local: LocalTemplate): EntryTemplate {
return {
id: local.id,
name: local.name,
projectId: local.projectId ?? undefined,
clientId: local.clientId ?? undefined,
description: local.description,
isBillable: local.isBillable,
tags: local.tags,
usageCount: local.usageCount,
lastUsedAt: local.lastUsedAt ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toSettings(local: LocalSettings): TimesSettings {
return {
id: local.id,
defaultBillingRate: local.defaultBillingRate ?? undefined,
workingHoursPerDay: local.workingHoursPerDay,
workingDaysPerWeek: local.workingDaysPerWeek,
roundingIncrement: local.roundingIncrement,
roundingMethod: local.roundingMethod,
defaultVisibility: local.defaultVisibility,
weekStartsOn: local.weekStartsOn,
timerReminderMinutes: local.timerReminderMinutes,
autoStopTimerHours: local.autoStopTimerHours,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllClients() {
return liveQuery(async () => {
const locals = await db.table<LocalClient>('timeClients').toArray();
return locals.filter((c) => !c.deletedAt).map(toClient);
});
}
export function useAllProjects() {
return liveQuery(async () => {
const locals = await db.table<LocalProject>('timeProjects').toArray();
return locals.filter((p) => !p.deletedAt).map(toProject);
});
}
export function useAllTimeEntries() {
return liveQuery(async () => {
const locals = await db.table<LocalTimeEntry>('timeEntries').toArray();
return locals.filter((e) => !e.deletedAt).map(toTimeEntry);
});
}
export function useAllTags() {
return liveQuery(async () => {
const locals = await db.table<LocalTag>('timeTags').toArray();
return locals.filter((t) => !t.deletedAt).map(toTag);
});
}
export function useAllTemplates() {
return liveQuery(async () => {
const locals = await db.table<LocalTemplate>('timeTemplates').toArray();
return locals.filter((t) => !t.deletedAt).map(toTemplate);
});
}
export function useSettings() {
return liveQuery(async () => {
const locals = await db.table<LocalSettings>('timeSettings').toArray();
const active = locals.filter((s) => !s.deletedAt);
return active.length > 0 ? toSettings(active[0]) : null;
});
}
// ─── Pure Helpers ──────────────────────────────────────────
/** Format duration in seconds to HH:MM:SS */
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
/** Format duration in seconds to compact form (e.g., "2h 30m") */
export function formatDurationCompact(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
/** Format duration in seconds to decimal hours (e.g., "2.50") */
export function formatDurationDecimal(seconds: number): string {
return (seconds / 3600).toFixed(2);
}
/** Get entries for a specific date */
export function getEntriesByDate(entries: TimeEntry[], date: string): TimeEntry[] {
return entries
.filter((e) => e.date === date)
.sort((a, b) => {
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime);
return 0;
});
}
/** Get entries for a date range */
export function getEntriesByDateRange(entries: TimeEntry[], from: string, to: string): TimeEntry[] {
return entries.filter((e) => e.date >= from && e.date <= to);
}
/** Get total duration for a list of entries */
export function getTotalDuration(entries: TimeEntry[]): number {
return entries.reduce((sum, e) => sum + e.duration, 0);
}
/** Get billable duration for a list of entries */
export function getBillableDuration(entries: TimeEntry[]): number {
return entries.filter((e) => e.isBillable).reduce((sum, e) => sum + e.duration, 0);
}
/** Group entries by date */
export function groupEntriesByDate(entries: TimeEntry[]): Map<string, TimeEntry[]> {
const groups = new Map<string, TimeEntry[]>();
for (const entry of entries) {
const existing = groups.get(entry.date) || [];
existing.push(entry);
groups.set(entry.date, existing);
}
return groups;
}
/** Group entries by project */
export function groupEntriesByProject(entries: TimeEntry[]): Map<string, TimeEntry[]> {
const groups = new Map<string, TimeEntry[]>();
for (const entry of entries) {
const key = entry.projectId || 'no-project';
const existing = groups.get(key) || [];
existing.push(entry);
groups.set(key, existing);
}
return groups;
}
/** Filter entries by criteria */
export function getFilteredEntries(entries: TimeEntry[], filters: FilterCriteria): TimeEntry[] {
let result = entries;
if (filters.projectId) {
result = result.filter((e) => e.projectId === filters.projectId);
}
if (filters.clientId) {
result = result.filter((e) => e.clientId === filters.clientId);
}
if (filters.isBillable !== undefined) {
result = result.filter((e) => e.isBillable === filters.isBillable);
}
if (filters.tagIds?.length) {
result = result.filter((e) => filters.tagIds!.some((t) => e.tags.includes(t)));
}
if (filters.dateFrom) {
result = result.filter((e) => e.date >= filters.dateFrom!);
}
if (filters.dateTo) {
result = result.filter((e) => e.date <= filters.dateTo!);
}
if (filters.search) {
const q = filters.search.toLowerCase();
result = result.filter((e) => e.description.toLowerCase().includes(q));
}
return result;
}
/** Sort entries */
export function getSortedEntries(entries: TimeEntry[], sort: SortOption): TimeEntry[] {
return [...entries].sort((a, b) => {
let cmp = 0;
switch (sort.field) {
case 'date':
cmp = a.date.localeCompare(b.date);
if (cmp === 0 && a.startTime && b.startTime) {
cmp = a.startTime.localeCompare(b.startTime);
}
break;
case 'duration':
cmp = a.duration - b.duration;
break;
case 'project':
cmp = (a.projectId || '').localeCompare(b.projectId || '');
break;
case 'client':
cmp = (a.clientId || '').localeCompare(b.clientId || '');
break;
case 'createdAt':
cmp = (a.createdAt || '').localeCompare(b.createdAt || '');
break;
}
return sort.direction === 'desc' ? -cmp : cmp;
});
}
/** Get active projects (not archived) */
export function getActiveProjects(projects: Project[]): Project[] {
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
}
/** Get active clients (not archived) */
export function getActiveClients(clients: Client[]): Client[] {
return clients.filter((c) => !c.isArchived).sort((a, b) => a.order - b.order);
}
/** Get project by ID */
export function getProjectById(projects: Project[], id: string): Project | undefined {
return projects.find((p) => p.id === id);
}
/** Get client by ID */
export function getClientById(clients: Client[], id: string): Client | undefined {
return clients.find((c) => c.id === id);
}
/** Get projects for a client */
export function getProjectsByClient(projects: Project[], clientId: string): Project[] {
return projects.filter((p) => p.clientId === clientId);
}

View file

@ -0,0 +1,173 @@
/**
* Timer Store manages the active time tracking timer.
*
* The timer state persists in IndexedDB via the timeEntries table.
* When a timer is running, there's a timeEntry with isRunning=true.
* This store provides reactive access to the running entry and elapsed time.
*/
import { browser } from '$app/environment';
import { timeEntryTable, settingsTable } from '$lib/modules/times/collections';
import { roundDuration } from '$lib/modules/times/utils/rounding';
import type { LocalTimeEntry } from '$lib/modules/times/types';
let runningEntry = $state<LocalTimeEntry | null>(null);
let elapsedSeconds = $state(0);
let tickInterval: ReturnType<typeof setInterval> | null = null;
let autoSaveInterval: ReturnType<typeof setInterval> | null = null;
function startTicking() {
stopTicking();
tickInterval = setInterval(() => {
if (runningEntry?.startTime) {
elapsedSeconds = Math.floor((Date.now() - new Date(runningEntry.startTime).getTime()) / 1000);
}
}, 1000);
}
function stopTicking() {
if (tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
if (autoSaveInterval) {
clearInterval(autoSaveInterval);
autoSaveInterval = null;
}
}
function startAutoSave() {
if (autoSaveInterval) clearInterval(autoSaveInterval);
autoSaveInterval = setInterval(async () => {
if (runningEntry) {
await timeEntryTable.update(runningEntry.id, {
duration: elapsedSeconds,
});
}
}, 10000); // Auto-save every 10 seconds
}
export const timerStore = {
get runningEntry() {
return runningEntry;
},
get elapsedSeconds() {
return elapsedSeconds;
},
get isRunning() {
return runningEntry !== null;
},
/** Initialize: check for any running entry in IndexedDB */
async initialize() {
if (!browser) return;
const entries = await timeEntryTable.toArray();
const running = entries.find((e) => e.isRunning && !e.deletedAt);
if (running) {
runningEntry = running;
if (running.startTime) {
elapsedSeconds = Math.floor((Date.now() - new Date(running.startTime).getTime()) / 1000);
}
startTicking();
startAutoSave();
}
},
/** Start a new timer */
async start(options?: {
projectId?: string;
clientId?: string;
description?: string;
isBillable?: boolean;
tags?: string[];
}) {
// Stop any existing timer first
if (runningEntry) {
await timerStore.stop();
}
const now = new Date();
const entry: LocalTimeEntry = {
id: crypto.randomUUID(),
projectId: options?.projectId ?? null,
clientId: options?.clientId ?? null,
description: options?.description ?? '',
date: now.toISOString().split('T')[0],
startTime: now.toISOString(),
endTime: null,
duration: 0,
isBillable: options?.isBillable ?? false,
isRunning: true,
tags: options?.tags ?? [],
billingRate: null,
visibility: 'private',
guildId: null,
source: { app: 'timer' },
};
await timeEntryTable.add(entry);
runningEntry = entry;
elapsedSeconds = 0;
startTicking();
startAutoSave();
},
/** Stop the running timer */
async stop(): Promise<LocalTimeEntry | null> {
if (!runningEntry) return null;
const now = new Date();
const finalDuration = runningEntry.startTime
? Math.floor((now.getTime() - new Date(runningEntry.startTime).getTime()) / 1000)
: elapsedSeconds;
// Apply rounding from settings
const settings = await settingsTable.toArray();
const s = settings.find((s) => !s.deletedAt);
const roundedDuration = s
? roundDuration(finalDuration, s.roundingIncrement, s.roundingMethod)
: finalDuration;
await timeEntryTable.update(runningEntry.id, {
isRunning: false,
endTime: now.toISOString(),
duration: roundedDuration,
});
const stoppedEntry = {
...runningEntry,
isRunning: false,
endTime: now.toISOString(),
duration: roundedDuration,
};
stopTicking();
runningEntry = null;
elapsedSeconds = 0;
return stoppedEntry as LocalTimeEntry;
},
/** Discard the running timer without saving */
async discard() {
if (!runningEntry) return;
await timeEntryTable.delete(runningEntry.id);
stopTicking();
runningEntry = null;
elapsedSeconds = 0;
},
/** Update the running entry's metadata (project, description, etc.) */
async updateRunning(
updates: Partial<
Pick<LocalTimeEntry, 'projectId' | 'clientId' | 'description' | 'isBillable' | 'tags'>
>
) {
if (!runningEntry) return;
await timeEntryTable.update(runningEntry.id, updates);
runningEntry = { ...runningEntry, ...updates };
},
/** Cleanup on unmount */
destroy() {
stopTicking();
},
};

View file

@ -0,0 +1,110 @@
/**
* View Store manages view mode, sort, and filter state for Times.
*/
import { browser } from '$app/environment';
import type { ViewMode, SortOption, FilterCriteria, SavedFilter } from '$lib/modules/times/types';
const VIEW_KEY = 'times_view_mode';
const SORT_KEY = 'times_sort';
const FILTERS_KEY = 'times_saved_filters';
function load<T>(key: string, fallback: T): T {
if (!browser) return fallback;
try {
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : fallback;
} catch {
return fallback;
}
}
function save(key: string, value: unknown) {
if (!browser) return;
localStorage.setItem(key, JSON.stringify(value));
}
let viewMode = $state<ViewMode>('week');
let sort = $state<SortOption>({ field: 'date', direction: 'desc' });
let activeFilters = $state<FilterCriteria>({});
let savedFilters = $state<SavedFilter[]>([]);
let initialized = $state(false);
export const viewStore = {
get viewMode() {
return viewMode;
},
get sort() {
return sort;
},
get activeFilters() {
return activeFilters;
},
get savedFilters() {
return savedFilters;
},
get hasActiveFilters() {
return !!(
activeFilters.search ||
activeFilters.projectId ||
activeFilters.clientId ||
activeFilters.tagIds?.length ||
activeFilters.isBillable !== undefined ||
activeFilters.dateFrom ||
activeFilters.dateTo
);
},
initialize() {
if (initialized) return;
viewMode = load<ViewMode>(VIEW_KEY, 'week');
sort = load<SortOption>(SORT_KEY, { field: 'date', direction: 'desc' });
savedFilters = load<SavedFilter[]>(FILTERS_KEY, []);
initialized = true;
},
setViewMode(mode: ViewMode) {
viewMode = mode;
save(VIEW_KEY, mode);
},
setSort(newSort: SortOption) {
sort = newSort;
save(SORT_KEY, newSort);
},
setFilters(filters: FilterCriteria) {
activeFilters = filters;
},
updateFilter<K extends keyof FilterCriteria>(key: K, value: FilterCriteria[K]) {
activeFilters = { ...activeFilters, [key]: value };
},
clearFilters() {
activeFilters = {};
},
saveFilter(name: string) {
const filter: SavedFilter = {
id: crypto.randomUUID(),
name,
criteria: { ...activeFilters },
createdAt: new Date().toISOString(),
};
savedFilters = [...savedFilters, filter];
save(FILTERS_KEY, savedFilters);
},
loadFilter(id: string) {
const filter = savedFilters.find((f) => f.id === id);
if (filter) {
activeFilters = { ...filter.criteria };
}
},
deleteSavedFilter(id: string) {
savedFilters = savedFilters.filter((f) => f.id !== id);
save(FILTERS_KEY, savedFilters);
},
};

View file

@ -0,0 +1,246 @@
/**
* Times module types for the unified app.
*
* Mirrors @times/shared types but uses BaseRecord for local-first storage.
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Shared Types (inlined from @times/shared) ────────────
export interface BillingRate {
amount: number;
currency: string;
per: 'hour' | 'day';
}
export type ProjectVisibility = 'private' | 'guild';
export type EntrySource = 'todo' | 'calendar' | 'manual' | 'timer';
export type RoundingMethod = 'none' | 'up' | 'down' | 'nearest';
export type ViewMode = 'day' | 'week' | 'month';
export type SortField = 'date' | 'duration' | 'project' | 'client' | 'createdAt';
export type SortDirection = 'asc' | 'desc';
export interface EntrySourceRef {
app: EntrySource;
refId?: string;
}
export interface ProjectBudget {
type: 'hours' | 'fixed';
amount: number;
currency?: string;
}
export interface SortOption {
field: SortField;
direction: SortDirection;
}
export interface FilterCriteria {
search?: string;
projectId?: string;
clientId?: string;
tagIds?: string[];
isBillable?: boolean;
dateFrom?: string;
dateTo?: string;
}
export interface SavedFilter {
id: string;
name: string;
criteria: FilterCriteria;
createdAt: string;
}
// ─── Domain Types ─────────────────────────────────────────
export interface Client {
id: string;
name: string;
shortCode?: string;
contactId?: string;
email?: string;
color: string;
isArchived: boolean;
billingRate?: BillingRate;
notes?: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
clientId?: string;
name: string;
description?: string;
color: string;
isArchived: boolean;
isBillable: boolean;
billingRate?: BillingRate;
budget?: ProjectBudget;
visibility: ProjectVisibility;
guildId?: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface TimeEntry {
id: string;
projectId?: string;
clientId?: string;
description: string;
date: string;
startTime?: string;
endTime?: string;
duration: number;
isBillable: boolean;
isRunning: boolean;
tags: string[];
billingRate?: BillingRate;
visibility: ProjectVisibility;
guildId?: string;
source?: EntrySourceRef;
createdAt: string;
updatedAt: string;
}
export interface Tag {
id: string;
name: string;
color: string;
order: number;
createdAt: string;
updatedAt: string;
}
export interface EntryTemplate {
id: string;
name: string;
projectId?: string;
clientId?: string;
description: string;
isBillable: boolean;
tags: string[];
usageCount: number;
lastUsedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface TimesSettings {
id: string;
defaultBillingRate?: BillingRate;
workingHoursPerDay: number;
workingDaysPerWeek: number;
roundingIncrement: number;
roundingMethod: RoundingMethod;
defaultVisibility: ProjectVisibility;
weekStartsOn: 0 | 1;
timerReminderMinutes: number;
autoStopTimerHours: number;
createdAt: string;
updatedAt: string;
}
// ─── Local Record Types (Dexie) ───────────────────────────
export interface LocalClient extends BaseRecord {
name: string;
shortCode?: string | null;
contactId?: string | null;
email?: string | null;
color: string;
isArchived: boolean;
billingRate?: BillingRate | null;
notes?: string | null;
order: number;
}
export interface LocalProject extends BaseRecord {
clientId?: string | null;
name: string;
description?: string | null;
color: string;
isArchived: boolean;
isBillable: boolean;
billingRate?: BillingRate | null;
budget?: {
type: 'hours' | 'fixed';
amount: number;
currency?: string;
} | null;
visibility: ProjectVisibility;
guildId?: string | null;
order: number;
}
export interface LocalTimeEntry extends BaseRecord {
projectId?: string | null;
clientId?: string | null;
description: string;
date: string;
startTime?: string | null;
endTime?: string | null;
duration: number;
isBillable: boolean;
isRunning: boolean;
tags: string[];
billingRate?: BillingRate | null;
visibility: ProjectVisibility;
guildId?: string | null;
source?: EntrySourceRef | null;
}
export interface LocalTag extends BaseRecord {
name: string;
color: string;
order: number;
}
export interface LocalTemplate extends BaseRecord {
name: string;
projectId?: string | null;
clientId?: string | null;
description: string;
isBillable: boolean;
tags: string[];
usageCount: number;
lastUsedAt?: string | null;
}
export interface LocalSettings extends BaseRecord {
defaultBillingRate?: BillingRate | null;
workingHoursPerDay: number;
workingDaysPerWeek: number;
roundingIncrement: number;
roundingMethod: 'none' | 'up' | 'down' | 'nearest';
defaultVisibility: ProjectVisibility;
weekStartsOn: 0 | 1;
timerReminderMinutes: number;
autoStopTimerHours: number;
}
// ─── Constants ────────────────────────────────────────────
export const PROJECT_COLORS: string[] = [
'#ef4444',
'#f97316',
'#f59e0b',
'#eab308',
'#84cc16',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#0ea5e9',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#d946ef',
'#ec4899',
'#f43f5e',
];

View file

@ -0,0 +1,316 @@
/**
* Time Entry Parser for Times module
*
* Parses natural language time tracking input:
* - Duration: 2h, 30min, 1.5h, 1h30m
* - Project: @ProjectName
* - Tags: #tag1 #tag2
* - Billable: $, billable, abrechenbar
* - Date: heute, morgen, gestern, montag (via shared base parser)
* - Time range: 9-12, 14:00-16:30
*
* Examples:
* - "Meeting 2h @ClientX #billable"
* - "Code Review 1.5h @Projekt-A"
* - "9-12 Workshop @Schulung; 13-15 Nachbereitung"
*/
import {
parseBaseInput,
extractAtReference,
extractTags,
combineDateAndTime,
formatDatePreview,
type ParserLocale,
} from '@manacore/shared-utils';
export interface ParsedEntry {
description: string;
duration?: number; // seconds
date?: Date;
startTime?: string; // HH:mm
endTime?: string; // HH:mm
projectName?: string;
tagNames: string[];
isBillable?: boolean;
}
interface Project {
id: string;
name: string;
}
interface Tag {
id: string;
name: string;
}
export interface ParsedEntryWithIds {
description: string;
duration?: number;
date?: string; // ISO
startTime?: string;
endTime?: string;
projectId?: string;
tagIds: string[];
isBillable?: boolean;
}
// ─── Duration Extraction ───────────────────────────────────
const DURATION_PATTERNS: { pattern: RegExp; getSeconds: (m: RegExpMatchArray) => number }[] = [
// 2h30m, 1h 30min
{
pattern: /\b(\d+)\s*h\s*(\d+)\s*(?:m(?:in)?)\b/i,
getSeconds: (m) => parseInt(m[1]) * 3600 + parseInt(m[2]) * 60,
},
// 1.5h, 2,5h
{
pattern: /\b(\d+(?:[.,]\d+)?)\s*h\b/i,
getSeconds: (m) => Math.round(parseFloat(m[1].replace(',', '.')) * 3600),
},
// 30min, 45 Minuten
{
pattern: /\b(\d+)\s*min(?:uten?)?\b/i,
getSeconds: (m) => parseInt(m[1]) * 60,
},
// 1.5 Stunden
{
pattern: /\b(\d+(?:[.,]\d+)?)\s*(?:stunden?)\b/i,
getSeconds: (m) => Math.round(parseFloat(m[1].replace(',', '.')) * 3600),
},
];
function extractDuration(text: string): { duration?: number; remaining: string } {
for (const { pattern, getSeconds } of DURATION_PATTERNS) {
const match = text.match(pattern);
if (match) {
const seconds = getSeconds(match);
if (seconds > 0) {
return {
duration: seconds,
remaining: text
.replace(match[0], '')
.replace(/\s{2,}/g, ' ')
.trim(),
};
}
}
}
return { remaining: text };
}
// ─── Time Range Extraction ─────────────────────────────────
const TIME_RANGE_PATTERN =
/\b(?:um\s*)?(\d{1,2})(?::(\d{2}))?\s*[-]\s*(\d{1,2})(?::(\d{2}))?\s*(?:uhr)?\b/i;
function extractTimeRange(text: string): {
startTime?: string;
endTime?: string;
duration?: number;
remaining: string;
} {
const match = text.match(TIME_RANGE_PATTERN);
if (match) {
const sh = parseInt(match[1]);
const sm = match[2] ? parseInt(match[2]) : 0;
const eh = parseInt(match[3]);
const em = match[4] ? parseInt(match[4]) : 0;
if (sh >= 0 && sh <= 23 && eh >= 0 && eh <= 23) {
const startMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
const durationSeconds = (endMinutes - startMinutes) * 60;
return {
startTime: `${String(sh).padStart(2, '0')}:${String(sm).padStart(2, '0')}`,
endTime: `${String(eh).padStart(2, '0')}:${String(em).padStart(2, '0')}`,
duration: durationSeconds > 0 ? durationSeconds : undefined,
remaining: text.replace(TIME_RANGE_PATTERN, '').trim(),
};
}
}
return { remaining: text };
}
// ─── Billable Detection ────────────────────────────────────
const BILLABLE_PATTERNS = [/\$/, /\bbillable\b/i, /\babrechenbar\b/i];
function extractBillable(text: string): { isBillable?: boolean; remaining: string } {
for (const pattern of BILLABLE_PATTERNS) {
if (pattern.test(text)) {
return {
isBillable: true,
remaining: text
.replace(pattern, '')
.replace(/\s{2,}/g, ' ')
.trim(),
};
}
}
return { remaining: text };
}
// ─── Multi-Entry Splitting ─────────────────────────────────
const ENTRY_SPLITTERS =
/\s*(?:,\s*(?:danach|dann|und dann|anschließend|außerdem)\s+|;\s*|\s+(?:danach|dann|und dann|anschließend)\s+)/i;
// ─── Main Parser ───────────────────────────────────────────
export function parseEntryInput(input: string, locale: ParserLocale = 'de'): ParsedEntry {
let text = input.trim();
// Extract billable flag
const billableResult = extractBillable(text);
text = billableResult.remaining;
const isBillable = billableResult.isBillable;
// Extract time range (before duration, since "9-12" could conflict)
const timeRangeResult = extractTimeRange(text);
text = timeRangeResult.remaining;
// Extract duration (if no time range gave us one)
let duration = timeRangeResult.duration;
let startTime = timeRangeResult.startTime;
let endTime = timeRangeResult.endTime;
if (!duration) {
const durationResult = extractDuration(text);
text = durationResult.remaining;
duration = durationResult.duration;
}
// Extract @project
const projectResult = extractAtReference(text);
text = projectResult.remaining;
const projectName = projectResult.value;
// Extract #tags
const tagsResult = extractTags(text);
text = tagsResult.remaining;
const tagNames = tagsResult.value || [];
// Use base parser for date extraction
const base = parseBaseInput(text, locale);
const date = base.date ? combineDateAndTime(base.date, undefined) : undefined;
return {
description: base.title,
duration,
date,
startTime,
endTime,
projectName,
tagNames,
isBillable,
};
}
/**
* Parse input with multiple entries separated by keywords/semicolons.
* Subsequent entries inherit date and project from the first.
*/
export function parseMultiEntryInput(input: string, locale: ParserLocale = 'de'): ParsedEntry[] {
const parts = input.split(ENTRY_SPLITTERS).filter((s) => s.trim().length > 0);
if (parts.length <= 1) {
return [parseEntryInput(input, locale)];
}
const results: ParsedEntry[] = [];
let contextDate: Date | undefined;
let contextProject: string | undefined;
for (let i = 0; i < parts.length; i++) {
const parsed = parseEntryInput(parts[i].trim(), locale);
if (i === 0) {
contextDate = parsed.date;
contextProject = parsed.projectName;
} else {
if (!parsed.date && contextDate) parsed.date = contextDate;
if (!parsed.projectName && contextProject) parsed.projectName = contextProject;
}
results.push(parsed);
}
return results;
}
// ─── ID Resolution ─────────────────────────────────────────
export function resolveEntryIds(
parsed: ParsedEntry,
projects: Project[],
tags: Tag[]
): ParsedEntryWithIds {
let projectId: string | undefined;
const tagIds: string[] = [];
if (parsed.projectName) {
const project = projects.find(
(p) => p.name.toLowerCase() === parsed.projectName!.toLowerCase()
);
if (project) projectId = project.id;
}
for (const tagName of parsed.tagNames) {
const tag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase());
if (tag) tagIds.push(tag.id);
}
return {
description: parsed.description,
duration: parsed.duration,
date: parsed.date?.toISOString(),
startTime: parsed.startTime,
endTime: parsed.endTime,
projectId,
tagIds,
isBillable: parsed.isBillable,
};
}
// ─── Preview Formatting ────────────────────────────────────
export function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0 && m > 0) return `${h}h ${m}min`;
if (h > 0) return `${h}h`;
return `${m}min`;
}
export function formatParsedEntryPreview(parsed: ParsedEntry): string {
const parts: string[] = [];
if (parsed.date) {
parts.push(formatDatePreview(parsed.date));
}
if (parsed.startTime && parsed.endTime) {
parts.push(`${parsed.startTime}--${parsed.endTime}`);
}
if (parsed.duration) {
parts.push(formatDuration(parsed.duration));
}
if (parsed.projectName) {
parts.push(parsed.projectName);
}
if (parsed.isBillable) {
parts.push('$');
}
if (parsed.tagNames.length > 0) {
parts.push(parsed.tagNames.join(', '));
}
return parts.join(' · ');
}

View file

@ -0,0 +1,61 @@
/**
* CSV Export utility for time entries
*/
import type { TimeEntry, Project, Client } from '$lib/modules/times/types';
export function exportEntriesToCSV(
entries: TimeEntry[],
projects: Project[],
clients: Client[]
): void {
const projectMap = new Map(projects.map((p) => [p.id, p]));
const clientMap = new Map(clients.map((c) => [c.id, c]));
const headers = [
'Datum',
'Beschreibung',
'Projekt',
'Kunde',
'Dauer (h)',
'Dauer (min)',
'Abrechenbar',
'Tags',
'Startzeit',
'Endzeit',
];
const rows = entries.map((e) => {
const project = e.projectId ? projectMap.get(e.projectId) : undefined;
const client = e.clientId ? clientMap.get(e.clientId) : undefined;
const hours = Math.floor(e.duration / 3600);
const minutes = Math.floor((e.duration % 3600) / 60);
return [
e.date,
`"${(e.description || '').replace(/"/g, '""')}"`,
`"${(project?.name || '').replace(/"/g, '""')}"`,
`"${(client?.name || '').replace(/"/g, '""')}"`,
hours.toString(),
(hours * 60 + minutes).toString(),
e.isBillable ? 'Ja' : 'Nein',
`"${e.tags.join(', ')}"`,
e.startTime
? new Date(e.startTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: '',
e.endTime
? new Date(e.endTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
: '',
];
});
const csv = [headers.join(';'), ...rows.map((r) => r.join(';'))].join('\n');
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `times-export-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}

View file

@ -0,0 +1,36 @@
/**
* Duration rounding utility
*
* Applies rounding based on user settings (increment + method).
*/
import type { RoundingMethod } from '$lib/modules/times/types';
/**
* Round a duration in seconds based on settings.
* @param seconds - Duration in seconds
* @param increment - Rounding increment in minutes (0 = no rounding)
* @param method - Rounding method: 'none' | 'up' | 'down' | 'nearest'
* @returns Rounded duration in seconds
*/
export function roundDuration(seconds: number, increment: number, method: RoundingMethod): number {
if (increment <= 0 || method === 'none') return seconds;
const incrementSeconds = increment * 60;
const remainder = seconds % incrementSeconds;
if (remainder === 0) return seconds;
switch (method) {
case 'up':
return seconds - remainder + incrementSeconds;
case 'down':
return seconds - remainder;
case 'nearest':
return remainder >= incrementSeconds / 2
? seconds - remainder + incrementSeconds
: seconds - remainder;
default:
return seconds;
}
}

View file

@ -0,0 +1,169 @@
<script lang="ts">
import { Plus, MapPin, User } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import {
useAllCities,
useAllLocations,
searchCities,
getLocationCountByCity,
getPlatformStats,
filterByCity,
getCityStats,
} from '$lib/modules/citycorners/queries';
const allCities = useAllCities();
const allLocations = useAllLocations();
let searchQuery = $state('');
let locationCounts = $derived(getLocationCountByCity(allLocations.value));
let platformStats = $derived(getPlatformStats(allCities.value, allLocations.value));
let filtered = $derived(searchCities(allCities.value, searchQuery));
</script>
<svelte:head>
<title>{$_('app.name')} - {$_('app.tagline')}</title>
</svelte:head>
<header class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('cities.title')}</h1>
<p class="text-foreground-secondary">{$_('cities.subtitle')}</p>
</div>
{#if authStore.isAuthenticated}
<a
href="/citycorners/add-city"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-md transition-all hover:bg-primary/90 hover:shadow-lg"
title={$_('cities.add')}
>
<Plus size={20} weight="bold" />
</a>
{/if}
</header>
<!-- Platform stats -->
{#if platformStats.totalCities > 0}
<div class="mb-6 flex flex-wrap gap-4 rounded-xl border border-border bg-background-card p-4">
<div class="flex items-center gap-2">
<span class="text-lg">🏙️</span>
<div>
<p class="text-lg font-semibold text-foreground">{platformStats.totalCities}</p>
<p class="text-xs text-foreground-secondary">{$_('nav.cities')}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-lg">📍</span>
<div>
<p class="text-lg font-semibold text-foreground">{platformStats.totalLocations}</p>
<p class="text-xs text-foreground-secondary">{$_('home.title')}</p>
</div>
</div>
{#if platformStats.totalContributors > 0}
<div class="flex items-center gap-2">
<span class="text-lg">👥</span>
<div>
<p class="text-lg font-semibold text-foreground">{platformStats.totalContributors}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.totalContributors', {
values: { count: platformStats.totalContributors },
})}
</p>
</div>
</div>
{/if}
</div>
{/if}
<!-- Search -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder={$_('cities.search')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{#if filtered.length === 0}
<div class="py-12 text-center">
<span class="mb-2 block text-4xl">🏙️</span>
<p class="text-foreground-secondary">{$_('cities.empty')}</p>
{#if authStore.isAuthenticated}
<a
href="/citycorners/add-city"
class="mt-3 inline-block text-sm text-primary hover:underline"
>
{$_('cities.add')}
</a>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as city}
{@const count = locationCounts.get(city.id) || 0}
{@const cityStats = getCityStats(filterByCity(allLocations.value, city.id))}
<a
href="/citycorners/cities/{city.slug}"
class="group relative overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-lg"
>
{#if city.imageUrl}
<img
src={city.imageUrl}
alt={city.name}
loading="lazy"
class="h-40 w-full object-cover"
/>
{:else}
<div
class="flex h-40 items-center justify-center bg-gradient-to-br from-primary/10 to-primary/5"
>
<span class="text-5xl">🏙️</span>
</div>
{/if}
<div class="p-4">
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{city.name}
</h2>
<p class="text-sm text-foreground-secondary">
{#if city.state}
{city.state}, {city.country}
{:else}
{city.country}
{/if}
</p>
{#if city.description}
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary/80">
{city.description}
</p>
{/if}
<div class="mt-2 flex flex-wrap items-center gap-2">
<span
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary"
>
<MapPin size={12} />
{count}
</span>
{#if cityStats.contributorCount > 0}
<span
class="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-400"
>
<User size={12} />
{cityStats.contributorCount}
</span>
{/if}
{#each cityStats.topCategories.slice(0, 2) as { category }}
<span
class="rounded-full bg-background-card-hover px-2 py-0.5 text-xs text-foreground-secondary"
>
{$_(`categories.${category}`)}
</span>
{/each}
</div>
</div>
</a>
{/each}
</div>
{/if}

View file

@ -0,0 +1,254 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CaretLeft } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { cityTable, useAllCities } from '$lib/modules/citycorners';
import type { LocalCity } from '$lib/modules/citycorners/types';
const allCities = useAllCities();
let name = $state('');
let country = $state('');
let stateRegion = $state('');
let description = $state('');
let imageUrl = $state('');
let latitude = $state<number | undefined>(undefined);
let longitude = $state<number | undefined>(undefined);
let submitting = $state(false);
let error = $state('');
let geocoding = $state(false);
let imageError = $state(false);
let slug = $derived(
name
.trim()
.toLowerCase()
.replace(/[äÄ]/g, 'ae')
.replace(/[öÖ]/g, 'oe')
.replace(/[üÜ]/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
);
let slugExists = $derived(allCities.value.some((c) => c.slug === slug));
let isValid = $derived(name.trim().length > 0 && country.trim().length > 0 && !slugExists);
async function geocodeCityName() {
const q = name.trim();
if (!q) return;
geocoding = true;
try {
const searchQ = country.trim() ? `${q}, ${country.trim()}` : q;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQ)}&limit=1`,
{ headers: { 'User-Agent': 'CityCorners/1.0' } }
);
const results = await res.json();
if (results.length > 0) {
latitude = parseFloat(results[0].lat);
longitude = parseFloat(results[0].lon);
}
} catch {
// best-effort
} finally {
geocoding = false;
}
}
let geocodeTimeout: ReturnType<typeof setTimeout> | undefined;
function handleNameInput() {
clearTimeout(geocodeTimeout);
geocodeTimeout = setTimeout(() => {
if (name.trim().length > 2) {
geocodeCityName();
}
}, 1000);
}
async function handleSubmit() {
if (!isValid || submitting) return;
submitting = true;
error = '';
try {
// Geocode if we don't have coordinates yet
if (latitude === undefined || longitude === undefined) {
await geocodeCityName();
}
if (latitude === undefined || longitude === undefined) {
// Default to 0,0 — user can update later
latitude = 0;
longitude = 0;
}
const cityData: Omit<LocalCity, 'createdAt' | 'updatedAt' | 'deletedAt'> = {
id: `city-${slug}-${Date.now()}`,
name: name.trim(),
slug,
country: country.trim(),
state: stateRegion.trim() || null,
description: description.trim() || null,
latitude,
longitude,
imageUrl: imageUrl.trim() && !imageError ? imageUrl.trim() : null,
createdBy: authStore.user?.id || null,
};
await cityTable.add(cityData);
goto(`/citycorners/cities/${slug}`);
} catch {
error = $_('cityAdd.error');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$_('cityAdd.title')} - CityCorners</title>
</svelte:head>
<header class="mb-6">
<div class="flex items-center gap-2 mb-1">
<a href="/citycorners" class="text-foreground-secondary hover:text-primary transition-colors">
<CaretLeft size={16} />
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('cityAdd.title')}</h1>
</div>
<p class="text-foreground-secondary">{$_('cityAdd.subtitle')}</p>
</header>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">🏙️</span>
<p class="mb-4 text-foreground-secondary">{$_('cityAdd.loginRequired')}</p>
<a
href="/login?redirectTo=/citycorners/add-city"
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
{$_('settings.login')}
</a>
</div>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-5"
>
{#if error}
<div class="rounded-lg bg-red-500/10 p-3 text-sm text-red-500">{error}</div>
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.name')}</label
>
<input
id="name"
type="text"
bind:value={name}
oninput={handleNameInput}
placeholder={$_('cityAdd.namePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if slug && slugExists}
<p class="mt-1 text-xs text-red-500">{$_('cityAdd.slugExists')}</p>
{:else if slug}
<p class="mt-1 text-xs text-foreground-secondary/60">/{slug}</p>
{/if}
</div>
<div>
<label for="country" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.country')}</label
>
<input
id="country"
type="text"
bind:value={country}
placeholder={$_('cityAdd.countryPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="state" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.state')}</label
>
<input
id="state"
type="text"
bind:value={stateRegion}
placeholder={$_('cityAdd.statePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.description')}</label
>
<textarea
id="description"
bind:value={description}
placeholder={$_('cityAdd.descriptionPlaceholder')}
rows="3"
class="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
></textarea>
</div>
<div>
<label for="imageUrl" class="mb-1 block text-sm font-medium text-foreground"
>{$_('cityAdd.imageUrl')}</label
>
<input
id="imageUrl"
type="url"
bind:value={imageUrl}
oninput={() => (imageError = false)}
placeholder={$_('cityAdd.imageUrlPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if imageUrl.trim() && !imageError}
<div class="mt-2 overflow-hidden rounded-lg border border-border">
<img
src={imageUrl}
alt="Preview"
class="h-40 w-full object-cover"
onerror={() => (imageError = true)}
/>
</div>
{/if}
</div>
{#if geocoding}
<p class="text-xs text-foreground-secondary/60">{$_('cityAdd.geocoding')}</p>
{:else if latitude !== undefined && longitude !== undefined}
<p class="text-xs text-green-600 dark:text-green-400">{$_('cityAdd.coordinatesFound')}</p>
{/if}
<div class="flex gap-3">
<a
href="/citycorners"
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('edit.cancel')}
</a>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('cityAdd.submitting') : $_('cityAdd.submit')}
</button>
</div>
</form>
{/if}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// Old route — redirect to city discovery
onMount(() => {
goto('/citycorners', { replaceState: true });
});
</script>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { page } from '$app/stores';
import { setContext } from 'svelte';
import { useAllCities, findCityBySlug } from '$lib/modules/citycorners/queries';
import type { LocalCity } from '$lib/modules/citycorners/types';
let { children } = $props();
const allCities = useAllCities();
let currentCity = $derived(findCityBySlug(allCities.value, $page.params.slug ?? ''));
setContext('currentCity', {
get value() {
return currentCity;
},
});
</script>
{#if currentCity}
{@render children()}
{:else if allCities.value.length > 0}
<div class="py-12 text-center">
<span class="mb-2 block text-4xl">🔍</span>
<p class="text-foreground-secondary">Stadt nicht gefunden.</p>
<a href="/citycorners" class="mt-3 inline-block text-sm text-primary hover:underline">
Zurück zu allen Städten
</a>
</div>
{:else}
<!-- Still loading -->
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{/if}

View file

@ -0,0 +1,250 @@
<script lang="ts">
import { getContext } from 'svelte';
import { CaretLeft, Plus, MapPin, MapTrifold, UsersThree, Heart } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import {
favoritesStore,
useAllLocations,
useAllFavorites,
getFavoriteIds,
filterByCity,
filterByCategory,
getCityStats,
CATEGORY_KEYS,
} from '$lib/modules/citycorners';
import { isOpenNow } from '$lib/modules/citycorners/utils/opening-hours';
import type { LocalCity } from '$lib/modules/citycorners/types';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
const allLocations = useAllLocations();
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
let selectedCategory = $state<string | null>(null);
// Filter locations by city
let cityLocations = $derived(city ? filterByCity(allLocations.value, city.id) : []);
let categoryCounts = $derived(
CATEGORY_KEYS.reduce(
(acc, cat) => {
acc[cat] = cityLocations.filter((l) => l.category === cat).length;
return acc;
},
{} as Record<string, number>
)
);
let filtered = $derived(filterByCategory(cityLocations, selectedCategory));
let stats = $derived(getCityStats(cityLocations));
let citySlug = $derived($page.params.slug);
function handleFavoriteToggle(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
favoritesStore.toggle(locationId);
}
</script>
<svelte:head>
<title>{city?.name || ''} - CityCorners</title>
</svelte:head>
<header class="mb-6 flex items-start justify-between">
<div>
<div class="mb-1 flex items-center gap-2">
<a href="/citycorners" class="text-foreground-secondary hover:text-primary transition-colors">
<CaretLeft size={16} />
</a>
<h1 class="text-2xl font-bold text-foreground">{city?.name}</h1>
</div>
<p class="text-foreground-secondary">
{#if city?.state}
{city.state}, {city.country}
{:else}
{city?.country}
{/if}
</p>
{#if city?.description}
<p class="mt-1 text-sm text-foreground-secondary/80">{city.description}</p>
{/if}
</div>
<a
href="/citycorners/cities/{citySlug}/add"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary text-white shadow-md transition-all hover:bg-primary/90 hover:shadow-lg"
title={$_('add.title')}
>
<Plus size={20} weight="bold" />
</a>
</header>
<!-- City stats -->
{#if stats.locationCount > 0}
<div class="mb-6 rounded-xl border border-border bg-background-card p-4">
<div class="flex flex-wrap gap-6">
<!-- Location count -->
<div class="flex items-center gap-2">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
<MapPin size={20} />
</div>
<div>
<p class="text-lg font-semibold text-foreground">{stats.locationCount}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.locationsCount', { values: { count: stats.locationCount } })}
</p>
</div>
</div>
<!-- On map -->
{#if stats.hasCoordinates > 0}
<div class="flex items-center gap-2">
<div
class="flex h-9 w-9 items-center justify-center rounded-lg bg-green-500/10 text-green-600 dark:text-green-400"
>
<MapTrifold size={20} />
</div>
<div>
<p class="text-lg font-semibold text-foreground">{stats.hasCoordinates}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.onMap', { values: { count: stats.hasCoordinates } })}
</p>
</div>
</div>
{/if}
<!-- Contributors -->
{#if stats.contributorCount > 0}
<div class="flex items-center gap-2">
<div
class="flex h-9 w-9 items-center justify-center rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400"
>
<UsersThree size={20} />
</div>
<div>
<p class="text-lg font-semibold text-foreground">{stats.contributorCount}</p>
<p class="text-xs text-foreground-secondary">
{$_('cities.contributors', { values: { count: stats.contributorCount } })}
</p>
</div>
</div>
{/if}
</div>
<!-- Top categories breakdown -->
{#if stats.topCategories.length > 1}
<div class="mt-3 flex flex-wrap gap-1.5 border-t border-border pt-3">
{#each stats.topCategories as { category, count }}
<span
class="inline-flex items-center gap-1 rounded-full bg-background px-2.5 py-1 text-xs text-foreground-secondary"
>
{$_(`categories.${category}`)}
<span class="font-medium text-foreground">{count}</span>
</span>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Category filter pills -->
<div class="mb-6 flex flex-wrap gap-2">
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === null
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = null)}
>
{$_('home.all')} ({cityLocations.length})
</button>
{#each CATEGORY_KEYS as cat}
{@const count = categoryCounts[cat] || 0}
{#if count > 0}
<button
class="rounded-full px-4 py-2 text-sm transition-colors {selectedCategory === cat
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover'}"
onclick={() => (selectedCategory = cat)}
>
{$_(`categories.${cat}`)} ({count})
</button>
{/if}
{/each}
</div>
{#if filtered.length === 0}
<div class="py-12 text-center">
<span class="mb-2 block text-4xl">📍</span>
<p class="text-foreground-secondary">
{#if selectedCategory}
{$_('home.noResultsCategory', {
values: { category: $_(`categories.${selectedCategory}`) },
})}
{:else}
{$_('home.noResults')}
{/if}
</p>
<a
href="/citycorners/cities/{citySlug}/add"
class="mt-3 inline-block text-sm text-primary hover:underline"
>
{$_('home.addFirst')}
</a>
</div>
{:else}
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each filtered as location}
<a
href="/citycorners/cities/{citySlug}/locations/{location.id}"
class="group relative overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
<img
src={location.imageUrl}
alt={location.name}
loading="lazy"
class="h-48 w-full object-cover"
/>
{:else}
<div class="flex h-48 items-center justify-center bg-background-card-hover">
<span class="text-4xl">📍</span>
</div>
{/if}
{#if authStore.isAuthenticated}
<button
class="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={(e) => handleFavoriteToggle(e, location.id)}
title={favoriteIds.has(location.id) ? $_('favorites.remove') : $_('favorites.add')}
>
{#if favoriteIds.has(location.id)}
<Heart size={20} weight="fill" class="text-red-500" />
{:else}
<Heart size={20} class="text-white" />
{/if}
</button>
{/if}
<div class="p-4">
<div class="mb-1 flex flex-wrap items-center gap-1.5">
<span class="inline-block rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
{$_(`categories.${location.category}`)}
</span>
</div>
<h2 class="text-lg font-semibold text-foreground group-hover:text-primary">
{location.name}
</h2>
{#if location.description}
<p class="mt-1 line-clamp-2 text-sm text-foreground-secondary">
{location.description}
</p>
{/if}
</div>
</a>
{/each}
</div>
{/if}

View file

@ -0,0 +1,304 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { CaretLeft } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { ccLocationTable, CATEGORY_KEYS } from '$lib/modules/citycorners';
import type { LocalCity, LocalLocation } from '$lib/modules/citycorners/types';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
// Lookup state (skip lookup — no backend)
let lookupDone = $state(true);
// Form state
let name = $state('');
let category = $state<string>('sight');
let description = $state('');
let address = $state('');
let imageUrl = $state('');
let latitude = $state<number | undefined>(undefined);
let longitude = $state<number | undefined>(undefined);
let website = $state('');
let phone = $state('');
let submitting = $state(false);
let error = $state('');
let geocoding = $state(false);
let imageError = $state(false);
const categories = [
{ value: 'sight', labelKey: 'category.sight' },
{ value: 'restaurant', labelKey: 'category.restaurant' },
{ value: 'shop', labelKey: 'category.shop' },
{ value: 'museum', labelKey: 'category.museum' },
{ value: 'cafe', labelKey: 'category.cafe' },
{ value: 'bar', labelKey: 'category.bar' },
{ value: 'park', labelKey: 'category.park' },
{ value: 'beach', labelKey: 'category.beach' },
{ value: 'hotel', labelKey: 'category.hotel' },
{ value: 'event_venue', labelKey: 'category.event_venue' },
{ value: 'viewpoint', labelKey: 'category.viewpoint' },
];
let isValid = $derived(name.trim().length > 0 && description.trim().length > 10);
async function geocodeAddress() {
const addr = address.trim();
if (!addr) return;
geocoding = true;
try {
const cityName = city?.name || '';
const q =
cityName && !addr.toLowerCase().includes(cityName.toLowerCase())
? `${addr}, ${cityName}`
: addr;
const res = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(q)}&limit=1`,
{ headers: { 'User-Agent': 'CityCorners/1.0' } }
);
const results = await res.json();
if (results.length > 0) {
latitude = parseFloat(results[0].lat);
longitude = parseFloat(results[0].lon);
}
} catch {
// Geocoding is best-effort
} finally {
geocoding = false;
}
}
let geocodeTimeout: ReturnType<typeof setTimeout> | undefined;
function handleAddressInput() {
clearTimeout(geocodeTimeout);
geocodeTimeout = setTimeout(() => {
if (address.trim().length > 5) {
geocodeAddress();
}
}, 1000);
}
async function handleSubmit() {
if (!isValid || submitting || !city) return;
submitting = true;
error = '';
try {
const locId = `loc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const locData: Omit<LocalLocation, 'createdAt' | 'updatedAt' | 'deletedAt'> = {
id: locId,
cityId: city.id,
name: name.trim(),
category: category as LocalLocation['category'],
description: description.trim(),
address: address.trim() || null,
imageUrl: imageUrl.trim() && !imageError ? imageUrl.trim() : null,
latitude: latitude ?? null,
longitude: longitude ?? null,
timeline: null,
};
await ccLocationTable.add(locData);
goto(`/citycorners/cities/${citySlug}/locations/${locId}`);
} catch {
error = $_('add.error');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$_('add.title')} - {city?.name || 'CityCorners'}</title>
</svelte:head>
<header class="mb-6">
<div class="flex items-center gap-2 mb-1">
<a
href="/citycorners/cities/{citySlug}"
class="text-foreground-secondary hover:text-primary transition-colors"
>
<CaretLeft size={16} />
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('add.title')}</h1>
</div>
<p class="text-foreground-secondary">{$_('add.subtitle')}{city?.name}</p>
</header>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">📍</span>
<p class="mb-4 text-foreground-secondary">{$_('add.loginRequired')}</p>
<a
href="/login?redirectTo=/citycorners/cities/{citySlug}/add"
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
{$_('settings.login')}
</a>
</div>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-5"
>
{#if error}
<div class="rounded-lg bg-red-500/10 p-3 text-sm text-red-500">{error}</div>
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.name')}</label
>
<input
id="name"
type="text"
bind:value={name}
placeholder={$_('add.namePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="category" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.category')}</label
>
<div class="flex flex-wrap gap-2">
{#each categories as cat}
<button
type="button"
class="rounded-full px-4 py-2 text-sm transition-colors {category === cat.value
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (category = cat.value)}
>
{$_(cat.labelKey)}
</button>
{/each}
</div>
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.description')}</label
>
<textarea
id="description"
bind:value={description}
placeholder={$_('add.descriptionPlaceholder')}
rows="4"
class="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
></textarea>
<p class="mt-1 text-xs text-foreground-secondary/60">{$_('add.minChars')}</p>
</div>
<div>
<label for="address" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.address')}</label
>
<input
id="address"
type="text"
bind:value={address}
oninput={handleAddressInput}
placeholder={$_('add.addressPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if geocoding}
<p class="mt-1 text-xs text-foreground-secondary/60">{$_('add.geocoding')}</p>
{:else if latitude !== undefined && longitude !== undefined}
<p class="mt-1 text-xs text-green-600 dark:text-green-400">
{$_('add.coordinatesFound')}
</p>
{/if}
</div>
<div>
<label for="imageUrl" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.imageUrl')}</label
>
<input
id="imageUrl"
type="url"
bind:value={imageUrl}
oninput={() => (imageError = false)}
placeholder={$_('add.imageUrlPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if imageUrl.trim() && !imageError}
<div class="mt-2 overflow-hidden rounded-lg border border-border">
<img
src={imageUrl}
alt={$_('add.imagePreview')}
class="h-40 w-full object-cover"
onerror={() => (imageError = true)}
/>
</div>
{:else if imageError}
<div class="mt-2 flex items-center gap-2 rounded-lg bg-red-500/10 p-3">
<p class="flex-1 text-xs text-red-500">{$_('add.imageLoadError')}</p>
<button
type="button"
onclick={() => {
imageError = false;
}}
class="text-xs font-medium text-red-500 hover:text-red-400"
>
{$_('add.imageRetry')}
</button>
</div>
{/if}
</div>
<div>
<label for="website" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.website')}</label
>
<input
id="website"
type="url"
bind:value={website}
placeholder={$_('add.websitePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.phone')}</label
>
<input
id="phone"
type="tel"
bind:value={phone}
placeholder={$_('add.phonePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3">
<a
href="/citycorners/cities/{citySlug}"
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('edit.cancel')}
</a>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('add.submitting') : $_('add.submit')}
</button>
</div>
</form>
{/if}

View file

@ -0,0 +1,530 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import {
CaretLeft,
Check,
ShareNetwork,
Heart,
Plus,
MapPin,
PencilSimple,
Trash,
MapTrifold,
NavigationArrow,
ArrowSquareOut,
Star,
} from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import {
favoritesStore,
useAllFavorites,
useAllLocations,
getFavoriteIds,
filterByCity,
ccLocationTable,
CATEGORY_COLORS,
} from '$lib/modules/citycorners';
import {
isOpenNow,
haversine,
formatDistance,
} from '$lib/modules/citycorners/utils/opening-hours';
import type { LocalCity } from '$lib/modules/citycorners/types';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
interface TimelineEntry {
year: string;
event: string;
}
interface NearbyLocation {
id: string;
name: string;
category: string;
imageUrl?: string;
distance: number;
}
interface Location {
id: string;
name: string;
category: string;
description: string;
address?: string;
latitude?: number;
longitude?: number;
imageUrl?: string;
timeline?: TimelineEntry[];
website?: string;
phone?: string;
openingHours?: Record<string, string>;
createdBy?: string;
}
let location = $state<Location | null>(null);
let nearbyLocations = $state<NearbyLocation[]>([]);
let loading = $state(true);
let mapContainer: HTMLDivElement;
let shareSuccess = $state(false);
let showDeleteConfirm = $state(false);
let deleting = $state(false);
let selectedImageIndex = $state(0);
let isOwner = $derived(
location?.createdBy != null &&
authStore.isAuthenticated &&
authStore.user?.id === location.createdBy
);
let allImages = $derived(() => {
if (!location) return [];
const imgs: string[] = [];
if (location.imageUrl) imgs.push(location.imageUrl);
return imgs;
});
const allLocs = useAllLocations();
onMount(async () => {
try {
const locId = $page.params.id;
const loc = await ccLocationTable.get(locId);
if (loc) {
location = {
id: loc.id,
name: loc.name,
category: loc.category,
description: loc.description || '',
address: loc.address || undefined,
latitude: loc.latitude || undefined,
longitude: loc.longitude || undefined,
imageUrl: loc.imageUrl || undefined,
timeline: loc.timeline?.map((t) => ({ year: String(t.year), event: t.event })),
};
// Find nearby locations from the same city
if (city && loc.latitude && loc.longitude) {
const cityLocs = filterByCity(allLocs.value, city.id).filter(
(l) => l.id !== locId && l.latitude && l.longitude
);
nearbyLocations = cityLocs
.map((l) => {
const dist = Math.round(
haversine(loc.latitude!, loc.longitude!, l.latitude!, l.longitude!)
);
return {
id: l.id,
name: l.name,
category: l.category,
imageUrl: l.imageUrl || undefined,
distance: dist,
};
})
.sort((a, b) => a.distance - b.distance)
.slice(0, 5);
}
}
} catch (err) {
console.error('Failed to load location:', err);
} finally {
loading = false;
}
});
$effect(() => {
if (!browser || !location || !location.latitude || !location.longitude || !mapContainer) return;
const initMap = async () => {
const L = await import('leaflet');
const map = L.map(mapContainer, {
zoomControl: false,
attributionControl: false,
}).setView([location!.latitude!, location!.longitude!], 16);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
}).addTo(map);
const color = CATEGORY_COLORS[location!.category] || '#6b7280';
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:${color};width:32px;height:32px;border-radius:50%;border:3px solid white;box-shadow:0 2px 8px rgba(0,0,0,0.3);"></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
L.marker([location!.latitude!, location!.longitude!], { icon }).addTo(map);
};
initMap();
});
async function handleShare() {
const url = window.location.href;
const title = location?.name || 'CityCorners';
if (navigator.share) {
try {
await navigator.share({ title, url });
} catch {
// User cancelled
}
} else {
await navigator.clipboard.writeText(url);
shareSuccess = true;
setTimeout(() => (shareSuccess = false), 2000);
}
}
async function handleDelete() {
if (!location || deleting) return;
deleting = true;
try {
await ccLocationTable.update(location.id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
goto(`/citycorners/cities/${citySlug}`);
} catch {
// ignore
} finally {
deleting = false;
showDeleteConfirm = false;
}
}
</script>
<svelte:head>
<title>{location?.name || 'Location'} - {city?.name || 'CityCorners'}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
</svelte:head>
{#if loading}
<div class="flex items-center justify-center py-20">
<div
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if !location}
<div class="py-20 text-center">
<span class="mb-4 block text-5xl">🔍</span>
<p class="text-foreground-secondary">{$_('detail.notFound')}</p>
<a
href="/citycorners/cities/{citySlug}"
class="mt-4 inline-block text-sm text-primary hover:underline">{$_('detail.back')}</a
>
</div>
{:else}
{@const images = allImages()}
<!-- Hero image / Gallery -->
<div class="relative -mx-4 -mt-4 mb-6 sm:-mx-6 sm:-mt-6 lg:-mx-8 lg:-mt-8">
{#if images.length > 0}
<img
src={images[selectedImageIndex]}
alt={location.name}
class="h-72 w-full object-cover sm:h-80"
/>
{:else}
<div
class="flex h-72 items-center justify-center bg-gradient-to-br from-primary/20 to-primary/5 sm:h-80"
>
<span class="text-7xl">📍</span>
</div>
{/if}
<!-- Back button overlay -->
<div class="absolute left-4 top-4">
<a
href="/citycorners/cities/{citySlug}"
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 text-white backdrop-blur-sm transition-colors hover:bg-black/50"
>
<CaretLeft size={20} />
</a>
</div>
<!-- Share + Favorite buttons overlay -->
<div class="absolute right-4 top-4 flex gap-2">
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 text-white backdrop-blur-sm transition-all hover:bg-black/50"
onclick={handleShare}
title={$_('detail.share')}
>
{#if shareSuccess}
<Check size={20} class="text-green-400" />
{:else}
<ShareNetwork size={20} />
{/if}
</button>
{#if authStore.isAuthenticated}
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={() => favoritesStore.toggle(location!.id)}
title={favoriteIds.has(location.id) ? $_('favorites.remove') : $_('favorites.add')}
>
{#if favoriteIds.has(location.id)}
<Heart size={20} weight="fill" class="text-red-500" />
{:else}
<Heart size={20} class="text-white" />
{/if}
</button>
{/if}
</div>
{#if images.length > 1}
<div
class="absolute bottom-4 right-4 rounded-full bg-black/50 px-2.5 py-1 text-xs text-white backdrop-blur-sm"
>
{selectedImageIndex + 1} / {images.length}
</div>
{/if}
<div class="absolute bottom-4 left-4 flex items-center gap-2">
<span
class="rounded-full px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
style="background: {CATEGORY_COLORS[location.category] || '#6b7280'}cc"
>
{$_(`category.${location.category}`)}
</span>
{#if isOpenNow(location.openingHours) === true}
<span
class="rounded-full bg-green-500/90 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
>
{$_('detail.openNow')}
</span>
{:else if isOpenNow(location.openingHours) === false}
<span
class="rounded-full bg-red-500/80 px-3 py-1 text-sm font-medium text-white backdrop-blur-sm"
>
{$_('detail.closedNow')}
</span>
{/if}
</div>
</div>
<!-- Content -->
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold text-foreground">{location.name}</h1>
{#if location.address}
<p class="mt-2 flex items-center gap-1.5 text-foreground-secondary">
<MapPin size={16} class="flex-shrink-0" />
{location.address}
</p>
{/if}
</div>
<p class="text-base leading-relaxed text-foreground">{location.description}</p>
<!-- Contact info -->
{#if location.website || location.phone}
<div class="space-y-2">
{#if location.website}
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-foreground-secondary">{$_('detail.website')}:</span>
<a
href={location.website}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline truncate"
>
{location.website.replace(/^https?:\/\//, '')}
</a>
</div>
{/if}
{#if location.phone}
<div class="flex items-center gap-2 text-sm">
<span class="font-medium text-foreground-secondary">{$_('detail.phone')}:</span>
<a href="tel:{location.phone}" class="text-primary hover:underline">
{location.phone}
</a>
</div>
{/if}
</div>
{/if}
<!-- Opening hours -->
{#if location.openingHours && Object.keys(location.openingHours).length > 0}
<div>
<h2 class="mb-3 text-lg font-semibold text-foreground">{$_('detail.openingHours')}</h2>
<div class="rounded-xl border border-border bg-background-card overflow-hidden">
<table class="w-full text-sm">
<tbody>
{#each ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'] as day}
{#if location.openingHours[day]}
<tr class="border-b border-border last:border-b-0">
<td class="px-4 py-2 font-medium text-foreground">{$_(`days.${day}`)}</td>
<td class="px-4 py-2 text-right text-foreground-secondary">
{location.openingHours[day] === 'closed'
? $_('detail.closed')
: location.openingHours[day]}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Owner actions -->
{#if isOwner}
<div class="flex gap-3">
<a
href="/citycorners/cities/{citySlug}/locations/{location.id}/edit"
class="flex items-center gap-2 rounded-lg border border-border bg-background-card px-4 py-2.5 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover hover:text-foreground"
>
<PencilSimple size={16} />
{$_('detail.edit')}
</a>
<button
onclick={() => (showDeleteConfirm = true)}
class="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400 dark:hover:bg-red-950/50"
>
<Trash size={16} />
{$_('detail.delete')}
</button>
</div>
{/if}
<!-- Delete confirmation -->
{#if showDeleteConfirm}
<div
class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30"
>
<p class="mb-3 text-sm text-red-700 dark:text-red-300">{$_('detail.deleteConfirm')}</p>
<div class="flex gap-2">
<button
onclick={() => (showDeleteConfirm = false)}
class="rounded-lg border border-border bg-background px-4 py-2 text-sm text-foreground-secondary hover:bg-background-card-hover"
>
{$_('detail.cancel')}
</button>
<button
onclick={handleDelete}
disabled={deleting}
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{deleting ? $_('detail.deleting') : $_('detail.confirmDelete')}
</button>
</div>
</div>
{/if}
<!-- Map + Directions -->
{#if location.latitude && location.longitude}
<div class="overflow-hidden rounded-xl border border-border">
<div bind:this={mapContainer} class="h-52 w-full"></div>
<div class="flex divide-x divide-border border-t border-border">
<a
href="/citycorners/cities/{citySlug}/map"
class="flex flex-1 items-center justify-center gap-2 bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<MapTrifold size={16} />
{$_('detail.showOnMap')}
</a>
<a
href="https://www.google.com/maps/dir/?api=1&destination={location.latitude},{location.longitude}"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 items-center justify-center gap-2 bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<NavigationArrow size={16} />
{$_('detail.directions')}
</a>
<a
href="https://www.openstreetmap.org/?mlat={location.latitude}&mlon={location.longitude}#map=17/{location.latitude}/{location.longitude}"
target="_blank"
rel="noopener noreferrer"
class="flex flex-1 items-center justify-center gap-2 bg-background-card px-4 py-2.5 text-sm text-foreground-secondary transition-colors hover:text-primary"
>
<ArrowSquareOut size={16} />
{$_('detail.openInMaps')}
</a>
</div>
</div>
{/if}
<!-- Timeline -->
{#if location.timeline && location.timeline.length > 0}
<div>
<h2 class="mb-4 text-xl font-semibold text-foreground">{$_('detail.history')}</h2>
<div class="relative space-y-0">
{#each location.timeline as entry, i}
<div class="relative flex gap-4 pb-6">
{#if i < location.timeline!.length - 1}
<div class="absolute left-[11px] top-6 h-full w-0.5 bg-border"></div>
{/if}
<div
class="relative z-10 mt-1.5 h-6 w-6 flex-shrink-0 rounded-full border-2 border-primary bg-background flex items-center justify-center"
>
<div class="h-2 w-2 rounded-full bg-primary"></div>
</div>
<div>
<span class="font-mono text-sm font-bold text-primary">{entry.year}</span>
<p class="mt-0.5 text-sm text-foreground-secondary">{entry.event}</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Nearby locations -->
{#if nearbyLocations.length > 0}
<div>
<h2 class="mb-4 text-xl font-semibold text-foreground">{$_('detail.nearby')}</h2>
<div class="flex gap-3 overflow-x-auto pb-1">
{#each nearbyLocations as nearby}
<a
href="/citycorners/cities/{citySlug}/locations/{nearby.id}"
class="flex-shrink-0 w-40 overflow-hidden rounded-xl border border-border bg-background-card transition-shadow hover:shadow-md"
>
{#if nearby.imageUrl}
<img
src={nearby.imageUrl}
alt={nearby.name}
loading="lazy"
class="h-24 w-full object-cover"
/>
{:else}
<div class="flex h-24 items-center justify-center bg-background-card-hover">
<span class="text-2xl">📍</span>
</div>
{/if}
<div class="p-2.5">
<p class="text-sm font-medium text-foreground line-clamp-1">
{nearby.name}
</p>
<p class="mt-0.5 text-xs text-foreground-secondary">
{$_(`category.${nearby.category}`)} · {formatDistance(nearby.distance)}
</p>
</div>
</a>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<style>
:global(.custom-marker) {
background: transparent !important;
border: none !important;
}
</style>

View file

@ -0,0 +1,269 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { CaretLeft } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { ccLocationTable } from '$lib/modules/citycorners';
import type { LocalCity } from '$lib/modules/citycorners/types';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
let loading = $state(true);
let name = $state('');
let category = $state('sight');
let description = $state('');
let address = $state('');
let imageUrl = $state('');
let website = $state('');
let phone = $state('');
let imageError = $state(false);
let submitting = $state(false);
let error = $state('');
let forbidden = $state(false);
const categories = [
{ value: 'sight', labelKey: 'category.sight' },
{ value: 'restaurant', labelKey: 'category.restaurant' },
{ value: 'shop', labelKey: 'category.shop' },
{ value: 'museum', labelKey: 'category.museum' },
{ value: 'cafe', labelKey: 'category.cafe' },
{ value: 'bar', labelKey: 'category.bar' },
{ value: 'park', labelKey: 'category.park' },
{ value: 'beach', labelKey: 'category.beach' },
{ value: 'hotel', labelKey: 'category.hotel' },
{ value: 'event_venue', labelKey: 'category.event_venue' },
{ value: 'viewpoint', labelKey: 'category.viewpoint' },
];
let isValid = $derived(name.trim().length > 0 && description.trim().length > 10);
onMount(async () => {
try {
const loc = await ccLocationTable.get($page.params.id);
if (!loc) {
error = $_('edit.loadError');
return;
}
name = loc.name || '';
category = loc.category || 'sight';
description = loc.description || '';
address = loc.address || '';
imageUrl = loc.imageUrl || '';
} catch {
error = $_('edit.loadError');
} finally {
loading = false;
}
});
async function handleSubmit() {
if (!isValid || submitting) return;
submitting = true;
error = '';
try {
await ccLocationTable.update($page.params.id, {
name: name.trim(),
category: category as any,
description: description.trim(),
address: address.trim() || null,
imageUrl: imageUrl.trim() || null,
});
goto(`/citycorners/cities/${citySlug}/locations/${$page.params.id}`);
} catch {
error = $_('edit.error');
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{$_('edit.title')} - {city?.name || 'CityCorners'}</title>
</svelte:head>
<header class="mb-6">
<div class="flex items-center gap-2 mb-1">
<a
href="/citycorners/cities/{citySlug}/locations/{$page.params.id}"
class="text-foreground-secondary hover:text-primary transition-colors"
>
<CaretLeft size={16} />
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('edit.title')}</h1>
</div>
<p class="text-foreground-secondary">{$_('edit.subtitle')}</p>
</header>
{#if loading}
<div class="flex items-center justify-center py-12">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if forbidden}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">🔒</span>
<p class="text-foreground-secondary">{$_('edit.forbidden')}</p>
<a
href="/citycorners/cities/{citySlug}/locations/{$page.params.id}"
class="mt-4 inline-block text-sm text-primary hover:underline"
>
{$_('detail.back')}
</a>
</div>
{:else}
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-5"
>
{#if error}
<div class="rounded-lg bg-red-500/10 p-3 text-sm text-red-500">{error}</div>
{/if}
<div>
<label for="name" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.name')}</label
>
<input
id="name"
type="text"
bind:value={name}
placeholder={$_('add.namePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="category" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.category')}</label
>
<div class="flex flex-wrap gap-2">
{#each categories as cat}
<button
type="button"
class="rounded-full px-4 py-2 text-sm transition-colors {category === cat.value
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (category = cat.value)}
>
{$_(cat.labelKey)}
</button>
{/each}
</div>
</div>
<div>
<label for="description" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.description')}</label
>
<textarea
id="description"
bind:value={description}
placeholder={$_('add.descriptionPlaceholder')}
rows="4"
class="w-full resize-none rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
></textarea>
<p class="mt-1 text-xs text-foreground-secondary/60">{$_('add.minChars')}</p>
</div>
<div>
<label for="address" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.address')}</label
>
<input
id="address"
type="text"
bind:value={address}
placeholder={$_('add.addressPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="imageUrl" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.imageUrl')}</label
>
<input
id="imageUrl"
type="url"
bind:value={imageUrl}
oninput={() => (imageError = false)}
placeholder={$_('add.imageUrlPlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
{#if imageUrl.trim() && !imageError}
<div class="mt-2 overflow-hidden rounded-lg border border-border">
<img
src={imageUrl}
alt={$_('add.imagePreview')}
class="h-40 w-full object-cover"
onerror={() => (imageError = true)}
/>
</div>
{:else if imageError}
<div class="mt-2 flex items-center gap-2 rounded-lg bg-red-500/10 p-3">
<p class="flex-1 text-xs text-red-500">{$_('add.imageLoadError')}</p>
<button
type="button"
onclick={() => (imageError = false)}
class="text-xs font-medium text-red-500 hover:text-red-400"
>
{$_('add.imageRetry')}
</button>
</div>
{/if}
</div>
<div>
<label for="website" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.website')}</label
>
<input
id="website"
type="url"
bind:value={website}
placeholder={$_('add.websitePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label for="phone" class="mb-1 block text-sm font-medium text-foreground"
>{$_('add.phone')}</label
>
<input
id="phone"
type="tel"
bind:value={phone}
placeholder={$_('add.phonePlaceholder')}
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground placeholder:text-foreground-secondary/50 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div class="flex gap-3">
<a
href="/citycorners/cities/{citySlug}/locations/{$page.params.id}"
class="rounded-lg border border-border bg-background px-4 py-3 text-sm font-medium text-foreground-secondary transition-colors hover:bg-background-card-hover"
>
{$_('edit.cancel')}
</a>
<button
type="submit"
disabled={!isValid || submitting}
class="flex-1 rounded-lg bg-primary px-6 py-3 text-sm font-medium text-white transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? $_('edit.saving') : $_('edit.save')}
</button>
</div>
</form>
{/if}

View file

@ -0,0 +1,255 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { browser } from '$app/environment';
import { CaretLeft, GlobeSimple } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { page } from '$app/stores';
import {
useAllLocations,
filterByCity,
CATEGORY_KEYS,
CATEGORY_COLORS,
} from '$lib/modules/citycorners';
import type { LocalCity } from '$lib/modules/citycorners/types';
const cityCtx = getContext<{ value: LocalCity | undefined }>('currentCity');
let city = $derived(cityCtx.value);
let citySlug = $derived($page.params.slug);
const allLocations = useAllLocations();
let cityLocations = $derived(city ? filterByCity(allLocations.value, city.id) : []);
let mapContainer: HTMLDivElement;
let map: any = null;
let locating = $state(false);
let locationError = $state('');
let selectedCategory = $state<string | null>(null);
let allMarkers: any[] = [];
let markerLayer: any = null;
let leafletLib: any = null;
function updateMarkers() {
if (!map || !leafletLib) return;
const L = leafletLib;
if (markerLayer) {
map.removeLayer(markerLayer);
}
for (const m of allMarkers) {
map.removeLayer(m);
}
allMarkers = [];
const filtered = selectedCategory
? cityLocations.filter((l) => l.category === selectedCategory)
: cityLocations;
const useCluster = filtered.length >= 10;
if (useCluster && (L as any).markerClusterGroup) {
markerLayer = (L as any).markerClusterGroup();
} else {
markerLayer = null;
}
for (const loc of filtered) {
if (loc.latitude && loc.longitude) {
const color = CATEGORY_COLORS[loc.category] || '#6b7280';
const icon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:${color};width:28px;height:28px;border-radius:50%;border:3px solid white;box-shadow:0 2px 6px rgba(0,0,0,0.3);"></div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
});
const marker = L.marker([loc.latitude, loc.longitude], { icon });
marker.bindPopup(`
<div style="min-width:180px">
<strong style="font-size:14px">${loc.name}</strong>
<div style="color:${color};font-size:12px;margin:4px 0">${$_(`category.${loc.category}`)}</div>
<p style="font-size:12px;color:#666;margin:4px 0">${(loc.description || '').substring(0, 100)}...</p>
<a href="/citycorners/cities/${citySlug}/locations/${loc.id}" style="color:${color};font-size:12px;font-weight:600">${$_('detail.showDetails')} &rarr;</a>
</div>
`);
if (useCluster && markerLayer) {
markerLayer.addLayer(marker);
} else {
marker.addTo(map);
allMarkers.push(marker);
}
}
}
if (useCluster && markerLayer) {
map.addLayer(markerLayer);
}
}
onMount(async () => {
if (!browser) return;
leafletLib = await import('leaflet');
const L = leafletLib;
await import('leaflet/dist/leaflet.css');
const lat = city?.latitude ?? 47.6603;
const lng = city?.longitude ?? 9.1757;
map = L.map(mapContainer).setView([lat, lng], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
}).addTo(map);
try {
await import('leaflet.markercluster');
} catch {
// clustering not available
}
updateMarkers();
});
$effect(() => {
// Re-render markers when category filter or locations change
const _cat = selectedCategory;
const _locs = cityLocations;
if (map && leafletLib) {
updateMarkers();
}
});
function handleLocateMe() {
if (!browser || !map || !navigator.geolocation) {
locationError = $_('map.geolocationNotSupported');
return;
}
locating = true;
locationError = '';
navigator.geolocation.getCurrentPosition(
async (pos) => {
const { latitude, longitude } = pos.coords;
const L = await import('leaflet');
map.setView([latitude, longitude], 16);
const userIcon = L.divIcon({
className: 'custom-marker',
html: `<div style="background:#3b82f6;width:16px;height:16px;border-radius:50%;border:3px solid white;box-shadow:0 0 0 4px rgba(59,130,246,0.3),0 2px 6px rgba(0,0,0,0.3);"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
});
L.marker([latitude, longitude], { icon: userIcon })
.addTo(map)
.bindPopup($_('map.yourLocation'))
.openPopup();
locating = false;
},
() => {
locationError = $_('map.geolocationError');
locating = false;
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}
</script>
<svelte:head>
<title>{$_('map.title')} - {city?.name || 'CityCorners'}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
crossorigin=""
/>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
crossorigin=""
/>
</svelte:head>
<div class="map-page">
<header class="mb-4 flex items-start justify-between">
<div>
<div class="flex items-center gap-2">
<a
href="/citycorners/cities/{citySlug}"
class="text-foreground-secondary hover:text-primary transition-colors"
>
<CaretLeft size={16} />
</a>
<h1 class="text-2xl font-bold text-foreground">{$_('map.title')}</h1>
</div>
<p class="text-foreground-secondary">{city?.name} - {$_('map.subtitle')}</p>
</div>
<button
onclick={handleLocateMe}
disabled={locating}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-background-card border border-border text-foreground-secondary shadow-sm transition-all hover:text-primary hover:border-primary disabled:opacity-50"
title={$_('map.locateMe')}
>
{#if locating}
<div
class="h-5 w-5 border-2 border-primary border-t-transparent rounded-full animate-spin"
></div>
{:else}
<GlobeSimple size={20} />
{/if}
</button>
</header>
{#if locationError}
<div class="mb-3 rounded-lg bg-red-500/10 p-2 text-xs text-red-500">{locationError}</div>
{/if}
<div class="mb-4 flex flex-wrap gap-2">
<button
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
null
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (selectedCategory = null)}
>
{$_('map.filterAll')}
</button>
{#each CATEGORY_KEYS as cat}
<button
class="flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors {selectedCategory ===
cat
? 'bg-primary text-white'
: 'bg-background-card text-foreground-secondary hover:bg-background-card-hover border border-border'}"
onclick={() => (selectedCategory = selectedCategory === cat ? null : cat)}
>
<div class="w-2.5 h-2.5 rounded-full" style="background:{CATEGORY_COLORS[cat]}"></div>
{$_(`category.${cat}`)}
</button>
{/each}
</div>
<div
bind:this={mapContainer}
class="map-container rounded-xl overflow-hidden border border-border"
></div>
</div>
<style>
.map-container {
width: 100%;
height: calc(100vh - 300px);
min-height: 400px;
}
:global(.custom-marker) {
background: transparent !important;
border: none !important;
}
</style>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { Heart } from '@manacore/shared-icons';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import {
favoritesStore,
useAllFavorites,
useAllLocations,
getFavoriteIds,
} from '$lib/modules/citycorners';
// Live query for favorites — auto-updates on IndexedDB changes
const allFavorites = useAllFavorites();
const allLocations = useAllLocations();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
let favoriteLocations = $derived(allLocations.value.filter((l) => favoriteIds.has(l.id)));
function handleRemove(e: MouseEvent, locationId: string) {
e.preventDefault();
e.stopPropagation();
favoritesStore.toggle(locationId);
}
</script>
<svelte:head>
<title>{$_('favorites.title')} - CityCorners</title>
</svelte:head>
<header class="mb-6">
<h1 class="text-2xl font-bold text-foreground">{$_('favorites.title')}</h1>
<p class="text-foreground-secondary">{$_('favorites.subtitle')}</p>
</header>
{#if !authStore.isAuthenticated}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<p class="mb-4 text-foreground-secondary">{$_('favorites.loginRequired')}</p>
<a
href="/login"
class="inline-block rounded-lg bg-primary px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-primary/90"
>
{$_('settings.login')}
</a>
</div>
{:else if favoriteLocations.length === 0}
<div class="rounded-xl border border-border bg-background-card p-8 text-center">
<span class="mb-2 block text-4xl">&#x1F499;</span>
<p class="text-foreground-secondary">{$_('favorites.empty')}</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each favoriteLocations as location}
<a
href="/citycorners/locations/{location.id}"
class="group relative flex items-center gap-4 overflow-hidden rounded-xl border border-border bg-background-card p-4 transition-shadow hover:shadow-lg"
>
{#if location.imageUrl}
<img
src={location.imageUrl}
alt={location.name}
class="h-16 w-16 flex-shrink-0 rounded-lg object-cover"
/>
{:else}
<div
class="flex h-16 w-16 flex-shrink-0 items-center justify-center rounded-lg bg-background-card-hover"
>
<span class="text-2xl">&#x1F4CD;</span>
</div>
{/if}
<div class="min-w-0 flex-1">
<span class="text-xs text-primary">{$_(`category.${location.category}`)}</span>
<h3 class="truncate font-semibold text-foreground group-hover:text-primary">
{location.name}
</h3>
</div>
<button
class="flex-shrink-0 p-1 text-red-500 transition-colors hover:text-red-600"
onclick={(e) => handleRemove(e, location.id)}
title={$_('favorites.remove')}
>
<Heart size={20} weight="fill" />
</button>
</a>
{/each}
</div>
{/if}

View file

@ -0,0 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// Old route — redirect to city discovery
// Locations are now at /citycorners/cities/[slug]/locations/[id]
onMount(() => {
goto('/citycorners', { replaceState: true });
});
</script>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
goto('/citycorners', { replaceState: true });
});
</script>

View file

@ -0,0 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// Old route — redirect to city discovery
onMount(() => {
goto('/citycorners', { replaceState: true });
});
</script>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import {
useAllCollections,
useAllItems,
useAllLocations,
useAllCategories,
} from '$lib/modules/inventar/queries';
import { viewStore } from '$lib/modules/inventar/stores/view.svelte';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allCollections = useAllCollections();
const allItems = useAllItems();
const allLocations = useAllLocations();
const allCategories = useAllCategories();
// Provide data to child components via Svelte context
setContext('collections', allCollections);
setContext('items', allItems);
setContext('locations', allLocations);
setContext('categories', allCategories);
// Initialize view preferences
viewStore.initialize();
</script>
{@render children()}

View file

@ -0,0 +1,133 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { collectionsStore } from '$lib/modules/inventar/stores/collections.svelte';
import { itemsStore } from '$lib/modules/inventar/stores/items.svelte';
import {
getSortedCollections,
getItemCountByCollection,
getTotalItemCount,
} from '$lib/modules/inventar/queries';
import type { Collection, Item } from '$lib/modules/inventar/queries';
import { Plus, Trash } from '@manacore/shared-icons';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
function getItemCount(collectionId: string): number {
return getItemCountByCollection(itemsCtx.value, collectionId);
}
function handleCollectionClick(collection: Collection) {
goto(`/inventar/collections/${collection.id}`);
}
function handleDelete(e: Event, id: string) {
e.stopPropagation();
if (confirm('Sammlung und alle Items loschen?')) {
itemsStore.deleteByCollection(id);
collectionsStore.delete(id);
}
}
// Stats
let totalItems = $derived(getTotalItemCount(itemsCtx.value));
let totalCollections = $derived(collectionsCtx.value.length);
let sortedCollections = $derived(getSortedCollections(collectionsCtx.value));
</script>
<svelte:head>
<title>Inventar - 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))]">Sammlungen</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{totalCollections} Sammlungen &middot; {totalItems} Items
</p>
</div>
<a
href="/inventar/collections/new"
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={20} />
Neue Sammlung
</a>
</div>
<!-- Collections grid -->
{#if sortedCollections.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 Sammlungen</h2>
<p class="mb-6 text-sm text-[hsl(var(--muted-foreground))]">
Erstelle deine erste Sammlung, um loszulegen.
</p>
<a
href="/inventar/collections/new"
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
Neue Sammlung
</a>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each sortedCollections as collection (collection.id)}
<div
role="button"
tabindex="0"
onclick={() => handleCollectionClick(collection)}
onkeydown={(e) => e.key === 'Enter' && handleCollectionClick(collection)}
class="group rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5 text-left transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">{collection.icon || '📁'}</span>
<div>
<h3 class="font-semibold text-[hsl(var(--foreground))]">{collection.name}</h3>
{#if collection.description}
<p class="mt-0.5 text-xs text-[hsl(var(--muted-foreground))] line-clamp-1">
{collection.description}
</p>
{/if}
</div>
</div>
<button
onclick={(e) => handleDelete(e, collection.id)}
class="rounded p-1 text-[hsl(var(--muted-foreground))] opacity-0 transition-opacity hover:text-red-500 group-hover:opacity-100"
>
<Trash size={20} />
</button>
</div>
<div class="mt-4 flex items-center justify-between">
<span class="text-sm text-[hsl(var(--muted-foreground))]">
{getItemCount(collection.id)} Items
</span>
<div class="flex gap-1">
{#each collection.schema.fields.slice(0, 3) as field}
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
{field.name}
</span>
{/each}
{#if collection.schema.fields.length > 3}
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
+{collection.schema.fields.length - 3}
</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,145 @@
<script lang="ts">
import { Plus } from '@manacore/shared-icons';
import { getContext } from 'svelte';
import { categoriesStore } from '$lib/modules/inventar/stores/categories.svelte';
import type { Category } from '$lib/modules/inventar/queries';
const categoriesCtx: { readonly value: Category[] } = getContext('categories');
let showForm = $state(false);
let editingId = $state<string | null>(null);
let name = $state('');
let icon = $state('');
let color = $state('');
function startCreate() {
name = '';
icon = '';
color = '';
editingId = null;
showForm = true;
}
function startEdit(category: Category) {
editingId = category.id;
name = category.name;
icon = category.icon || '';
color = category.color || '';
showForm = true;
}
async function save() {
if (!name.trim()) return;
if (editingId) {
await categoriesStore.update(editingId, {
name: name.trim(),
icon: icon || undefined,
color: color || undefined,
});
} else {
await categoriesStore.create({
name: name.trim(),
icon: icon || undefined,
color: color || undefined,
});
}
showForm = false;
}
async function deleteCategory(id: string) {
if (confirm('Kategorie loschen?')) {
await categoriesStore.delete(id);
}
}
let sortedCategories = $derived([...categoriesCtx.value].sort((a, b) => a.order - b.order));
const inputClass =
'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))]';
</script>
<svelte:head>
<title>Kategorien - Inventar - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Kategorien</h1>
<button
onclick={startCreate}
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 size={16} />
Neue Kategorie
</button>
</div>
{#if showForm}
<div class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4">
<div class="flex gap-2">
<input
type="text"
bind:value={icon}
placeholder="🏷️"
class="{inputClass} w-12 text-center text-lg"
maxlength="2"
/>
<input
type="text"
bind:value={name}
placeholder="Kategoriename"
class="{inputClass} flex-1"
onkeydown={(e) => e.key === 'Enter' && save()}
/>
<input
type="color"
bind:value={color}
class="h-9 w-9 cursor-pointer rounded-lg border border-[hsl(var(--border))]"
/>
<button
onclick={save}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>Speichern</button
>
<button
onclick={() => (showForm = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 text-sm">Abbrechen</button
>
</div>
</div>
{/if}
{#if sortedCategories.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>
<p class="text-[hsl(var(--muted-foreground))]">Keine Kategorien vorhanden</p>
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2">
{#each sortedCategories as category (category.id)}
<div
class="group flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3"
>
<span class="text-xl">{category.icon || '🏷️'}</span>
{#if category.color}
<span class="h-3 w-3 rounded-full" style="background-color: {category.color}"></span>
{/if}
<span class="flex-1 font-medium text-[hsl(var(--foreground))]">{category.name}</span>
<button
onclick={() => startEdit(category)}
class="text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-[hsl(var(--foreground))] group-hover:opacity-100"
>&#9998;</button
>
<button
onclick={() => deleteCategory(category.id)}
class="text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>&times;</button
>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,292 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { CaretLeft, PencilSimple, Plus, Trash } from '@manacore/shared-icons';
import { getContext } from 'svelte';
import { itemsStore } from '$lib/modules/inventar/stores/items.svelte';
import { viewStore } from '$lib/modules/inventar/stores/view.svelte';
import {
getCollectionById,
getItemsByCollection,
getSortedItems,
} from '$lib/modules/inventar/queries';
import type { Collection, Item, ItemStatus } from '$lib/modules/inventar/queries';
import FieldRenderer from '$lib/modules/inventar/components/fields/FieldRenderer.svelte';
import FieldEditor from '$lib/modules/inventar/components/fields/FieldEditor.svelte';
import StatusBadge from '$lib/modules/inventar/components/StatusBadge.svelte';
import ViewModeToggle from '$lib/modules/inventar/components/ViewModeToggle.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
let collectionId = $derived($page.params.id);
let collection = $derived(getCollectionById(collectionsCtx.value, collectionId));
let items = $derived(getItemsByCollection(itemsCtx.value, collectionId));
let sortedItems = $derived(getSortedItems(items, viewStore.sort));
// Item creation
let showNewItem = $state(false);
let newItemName = $state('');
let newItemFields = $state<Record<string, unknown>>({});
let newItemStatus = $state<ItemStatus>('owned');
async function createItem() {
if (!newItemName.trim() || !collection) return;
await itemsStore.create({
collectionId: collection.id,
name: newItemName.trim(),
status: newItemStatus,
fieldValues: newItemFields,
});
newItemName = '';
newItemFields = {};
newItemStatus = 'owned';
showNewItem = false;
}
function deleteItem(e: Event, id: string) {
e.stopPropagation();
if (confirm('Item loschen?')) {
itemsStore.delete(id);
}
}
</script>
<svelte:head>
<title>{collection?.name || 'Sammlung'} - Inventar - ManaCore</title>
</svelte:head>
{#if !collection}
<div class="text-center py-16">
<p class="text-[hsl(var(--muted-foreground))]">Sammlung nicht gefunden</p>
<a href="/inventar" class="mt-4 text-[hsl(var(--primary))]">Zuruck</a>
</div>
{:else}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/inventar"
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<CaretLeft size={20} />
</a>
<span class="text-2xl">{collection.icon || '📁'}</span>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{collection.name}</h1>
{#if collection.description}
<p class="text-sm text-[hsl(var(--muted-foreground))]">{collection.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<ViewModeToggle current={viewStore.viewMode} onchange={(m) => viewStore.setViewMode(m)} />
<a
href="/inventar/collections/{collection.id}/edit"
class="rounded-lg border border-[hsl(var(--border))] p-2 text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<PencilSimple size={16} />
</a>
<button
onclick={() => (showNewItem = !showNewItem)}
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 size={16} />
Neues Item
</button>
</div>
</div>
<!-- New Item Form -->
{#if showNewItem}
<div
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
bind:value={newItemName}
placeholder="Item-Name"
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
onkeydown={(e) => e.key === 'Enter' && createItem()}
/>
<!-- Custom fields -->
{#if collection.schema.fields.length > 0}
<div class="grid gap-3 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]">
{field.name}{field.required ? ' *' : ''}
</label>
<FieldEditor
{field}
value={newItemFields[field.id]}
onchange={(v) => (newItemFields = { ...newItemFields, [field.id]: v })}
/>
</div>
{/each}
</div>
{/if}
<div class="flex justify-end gap-2">
<button
onclick={() => (showNewItem = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm"
>
Abbrechen
</button>
<button
onclick={createItem}
disabled={!newItemName.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
{/if}
<!-- Items -->
{#if sortedItems.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>
<p class="text-[hsl(var(--muted-foreground))]">Keine Items vorhanden</p>
<button
onclick={() => (showNewItem = true)}
class="mt-4 rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm text-[hsl(var(--primary-foreground))]"
>
Neues Item
</button>
</div>
{:else if viewStore.viewMode === 'grid'}
<!-- Grid View -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each sortedItems as item (item.id)}
<div
onclick={() => goto(`/inventar/items/${item.id}`)}
onkeydown={(e) => e.key === 'Enter' && goto(`/inventar/items/${item.id}`)}
role="button"
tabindex="0"
class="group cursor-pointer rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left"
>
<div class="flex items-start justify-between">
<h3 class="font-semibold text-[hsl(var(--foreground))]">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
{#if collection.schema.fields.length > 0}
<div class="mt-3 space-y-1">
{#each collection.schema.fields.slice(0, 3) as field}
{#if item.fieldValues[field.id] !== undefined}
<div class="flex items-baseline gap-2 text-xs">
<span class="text-[hsl(var(--muted-foreground))]">{field.name}:</span>
<FieldRenderer {field} value={item.fieldValues[field.id]} />
</div>
{/if}
{/each}
</div>
{/if}
<button
onclick={(e) => deleteItem(e, item.id)}
class="mt-2 text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>
Loschen
</button>
</div>
{/each}
</div>
{:else if viewStore.viewMode === 'table'}
<!-- Table View -->
<div class="overflow-x-auto rounded-xl border border-[hsl(var(--border))]">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-[hsl(var(--border))] bg-[hsl(var(--muted))]">
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
>Name</th
>
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
>Status</th
>
{#each collection.schema.fields as field}
<th class="px-4 py-2.5 text-left font-medium text-[hsl(var(--muted-foreground))]"
>{field.name}</th
>
{/each}
<th class="w-10"></th>
</tr>
</thead>
<tbody>
{#each sortedItems as item (item.id)}
<tr
class="cursor-pointer border-b border-[hsl(var(--border))] transition-colors hover:bg-[hsl(var(--accent)/0.05)]"
onclick={() => goto(`/inventar/items/${item.id}`)}
>
<td class="px-4 py-2.5 font-medium text-[hsl(var(--foreground))]">{item.name}</td>
<td class="px-4 py-2.5"><StatusBadge status={item.status} /></td>
{#each collection.schema.fields as field}
<td class="px-4 py-2.5"
><FieldRenderer {field} value={item.fieldValues[field.id]} /></td
>
{/each}
<td class="px-4 py-2.5">
<button
onclick={(e) => deleteItem(e, item.id)}
class="text-[hsl(var(--muted-foreground))] hover:text-red-500"
>
<Trash size={16} />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<!-- List View -->
<div class="space-y-2">
{#each sortedItems as item (item.id)}
<div
onclick={() => goto(`/inventar/items/${item.id}`)}
onkeydown={(e) => e.key === 'Enter' && goto(`/inventar/items/${item.id}`)}
role="button"
tabindex="0"
class="group flex w-full cursor-pointer items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<h3 class="font-medium text-[hsl(var(--foreground))] truncate">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
{#if collection.schema.fields.length > 0}
<div class="mt-1 flex flex-wrap gap-3 text-xs text-[hsl(var(--muted-foreground))]">
{#each collection.schema.fields.slice(0, 4) as field}
{#if item.fieldValues[field.id] !== undefined}
<span
>{field.name}: <FieldRenderer
{field}
value={item.fieldValues[field.id]}
/></span
>
{/if}
{/each}
</div>
{/if}
</div>
{#if item.quantity > 1}
<span class="text-sm text-[hsl(var(--muted-foreground))]">&times;{item.quantity}</span
>
{/if}
<button
onclick={(e) => deleteItem(e, item.id)}
class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>
<Trash size={16} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,112 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { CaretLeft } from '@manacore/shared-icons';
import { getContext } from 'svelte';
import { collectionsStore } from '$lib/modules/inventar/stores/collections.svelte';
import { getCollectionById } from '$lib/modules/inventar/queries';
import type { Collection } from '$lib/modules/inventar/queries';
import type { CollectionSchema } from '$lib/modules/inventar/constants';
import SchemaEditor from '$lib/modules/inventar/components/fields/SchemaEditor.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
let collectionId = $derived($page.params.id);
let collection = $derived(getCollectionById(collectionsCtx.value, collectionId));
let name = $state('');
let description = $state('');
let icon = $state('');
let schema = $state<CollectionSchema>({ fields: [] });
let loaded = $state(false);
$effect(() => {
if (collection && !loaded) {
name = collection.name;
description = collection.description || '';
icon = collection.icon || '';
schema = { fields: [...collection.schema.fields] };
loaded = true;
}
});
async function handleSave() {
if (!collection || !name.trim()) return;
await collectionsStore.update(collection.id, {
name: name.trim(),
description: description.trim() || undefined,
icon: icon || undefined,
schema,
});
goto(`/inventar/collections/${collection.id}`);
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<svelte:head>
<title>Sammlung bearbeiten - Inventar - ManaCore</title>
</svelte:head>
{#if !collection}
<p class="text-[hsl(var(--muted-foreground))]">Sammlung nicht gefunden</p>
{:else}
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center gap-3">
<button
onclick={() => goto(`/inventar/collections/${collection.id}`)}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<CaretLeft size={20} />
</button>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Sammlung bearbeiten</h1>
</div>
<div class="space-y-4">
<div class="flex gap-3">
<input
type="text"
bind:value={icon}
placeholder="📁"
class="h-12 w-12 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] text-center text-2xl"
maxlength="2"
/>
<input
type="text"
bind:value={name}
placeholder="Sammlungsname"
class="{inputClass} flex-1"
/>
</div>
<textarea
bind:value={description}
placeholder="Beschreibung (optional)"
rows="2"
class={inputClass}
></textarea>
<div>
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">Eigene Felder</h3>
<SchemaEditor fields={schema.fields} onchange={(fields) => (schema = { fields })} />
</div>
<div class="flex justify-end gap-3 pt-4">
<button
onclick={() => goto(`/inventar/collections/${collection.id}`)}
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
>
Abbrechen
</button>
<button
onclick={handleSave}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
Speichern
</button>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,145 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CaretLeft } from '@manacore/shared-icons';
import { collectionsStore } from '$lib/modules/inventar/stores/collections.svelte';
import {
DEFAULT_TEMPLATES,
type Template,
type CollectionSchema,
} from '$lib/modules/inventar/constants';
import SchemaEditor from '$lib/modules/inventar/components/fields/SchemaEditor.svelte';
let step = $state<'template' | 'details'>('template');
let selectedTemplate = $state<Template | null>(null);
let name = $state('');
let description = $state('');
let icon = $state('');
let schema = $state<CollectionSchema>({ fields: [] });
function selectTemplate(template: Template) {
selectedTemplate = template;
if (template.id !== 'custom') {
name = template.name;
icon = template.icon;
}
schema = { fields: [...template.schema.fields] };
step = 'details';
}
async function handleCreate() {
if (!name.trim()) return;
await collectionsStore.create({
name: name.trim(),
description: description.trim() || undefined,
icon: icon || undefined,
schema,
templateId: selectedTemplate?.id,
});
goto('/inventar');
}
const inputClass =
'w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-3 text-[hsl(var(--foreground))] placeholder:text-[hsl(var(--muted-foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]';
</script>
<svelte:head>
<title>Neue Sammlung - Inventar - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center gap-3">
<button
onclick={() =>
step === 'details' && selectedTemplate ? (step = 'template') : goto('/inventar')}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<CaretLeft size={20} />
</button>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Neue Sammlung</h1>
</div>
{#if step === 'template'}
<!-- Template Selection -->
<div>
<h2 class="mb-4 text-lg font-semibold text-[hsl(var(--foreground))]">Vorlage wahlen</h2>
<div class="grid gap-3 sm:grid-cols-2">
{#each DEFAULT_TEMPLATES as template}
<button
onclick={() => selectTemplate(template)}
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 text-left transition-all hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex items-center gap-3">
<span class="text-2xl">{template.icon}</span>
<div>
<h3 class="font-semibold text-[hsl(var(--foreground))]">{template.name}</h3>
<p class="text-xs text-[hsl(var(--muted-foreground))]">{template.description}</p>
</div>
</div>
{#if template.schema.fields.length > 0}
<div class="mt-3 flex flex-wrap gap-1">
{#each template.schema.fields as field}
<span
class="rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-[10px] text-[hsl(var(--muted-foreground))]"
>
{field.name}
</span>
{/each}
</div>
{/if}
</button>
{/each}
</div>
</div>
{:else}
<!-- Collection Details -->
<div class="space-y-4">
<div class="flex gap-3">
<div class="flex-shrink-0">
<input
type="text"
bind:value={icon}
placeholder="📁"
class="h-12 w-12 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] text-center text-2xl"
maxlength="2"
/>
</div>
<input
type="text"
bind:value={name}
placeholder="Sammlungsname"
class="{inputClass} flex-1"
/>
</div>
<textarea
bind:value={description}
placeholder="Beschreibung (optional)"
rows="2"
class={inputClass}
></textarea>
<!-- Schema Editor -->
<div>
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">Eigene Felder</h3>
<SchemaEditor fields={schema.fields} onchange={(fields) => (schema = { fields })} />
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<button
onclick={() => goto('/inventar')}
class="rounded-lg border border-[hsl(var(--border))] px-4 py-2 text-sm text-[hsl(var(--foreground))]"
>
Abbrechen
</button>
<button
onclick={handleCreate}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-6 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
Erstellen
</button>
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,327 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { CaretLeft } from '@manacore/shared-icons';
import { getContext } from 'svelte';
import { itemsStore } from '$lib/modules/inventar/stores/items.svelte';
import {
getItemById,
getCollectionById,
getLocationById,
getLocationFullPath,
getCategoryById,
} from '$lib/modules/inventar/queries';
import type {
Collection,
Item,
Location,
Category,
ItemStatus,
} from '$lib/modules/inventar/queries';
import FieldRenderer from '$lib/modules/inventar/components/fields/FieldRenderer.svelte';
import FieldEditor from '$lib/modules/inventar/components/fields/FieldEditor.svelte';
import StatusBadge from '$lib/modules/inventar/components/StatusBadge.svelte';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
const locationsCtx: { readonly value: Location[] } = getContext('locations');
const categoriesCtx: { readonly value: Category[] } = getContext('categories');
let itemId = $derived($page.params.id);
let item = $derived(getItemById(itemsCtx.value, itemId));
let collection = $derived(
item ? getCollectionById(collectionsCtx.value, item.collectionId) : undefined
);
let editing = $state(false);
let editName = $state('');
let editDescription = $state('');
let editStatus = $state<ItemStatus>('owned');
let editQuantity = $state(1);
let editFields = $state<Record<string, unknown>>({});
let editLocationId = $state<string | undefined>();
let editCategoryId = $state<string | undefined>();
// Notes
let newNote = $state('');
const statuses: ItemStatus[] = ['owned', 'lent', 'stored', 'for_sale', 'disposed'];
const statusLabels: Record<ItemStatus, string> = {
owned: 'Besitzt',
lent: 'Verliehen',
stored: 'Eingelagert',
for_sale: 'Zu verkaufen',
disposed: 'Entsorgt',
};
function startEditing() {
if (!item) return;
editName = item.name;
editDescription = item.description || '';
editStatus = item.status;
editQuantity = item.quantity;
editFields = { ...item.fieldValues };
editLocationId = item.locationId;
editCategoryId = item.categoryId;
editing = true;
}
async function saveEdit() {
if (!item || !editName.trim()) return;
await itemsStore.update(item.id, {
name: editName.trim(),
description: editDescription.trim() || undefined,
status: editStatus,
quantity: editQuantity,
fieldValues: editFields,
locationId: editLocationId,
categoryId: editCategoryId,
});
editing = false;
}
async function addNote() {
if (!item || !newNote.trim()) return;
await itemsStore.addNote(item.id, newNote.trim());
newNote = '';
}
async function deleteItem() {
if (!item || !confirm('Item endgultig loschen?')) return;
await itemsStore.delete(item.id);
goto(collection ? `/inventar/collections/${collection.id}` : '/inventar');
}
const inputClass =
'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))]';
</script>
<svelte:head>
<title>{item?.name || 'Item'} - Inventar - ManaCore</title>
</svelte:head>
{#if !item}
<div class="text-center py-16">
<p class="text-[hsl(var(--muted-foreground))]">Item nicht gefunden</p>
<a href="/inventar" class="mt-4 text-[hsl(var(--primary))]">Zuruck</a>
</div>
{:else}
<div class="mx-auto max-w-2xl space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<button
onclick={() => goto(collection ? `/inventar/collections/${collection.id}` : '/inventar')}
class="text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<CaretLeft size={20} />
</button>
{#if !editing}
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{item.name}</h1>
{#if collection}
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{collection.icon}
{collection.name}
</p>
{/if}
</div>
{/if}
</div>
<div class="flex gap-2">
{#if editing}
<button
onclick={() => (editing = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm"
>Abbrechen</button
>
<button
onclick={saveEdit}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-1.5 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>Speichern</button
>
{:else}
<button
onclick={startEditing}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--foreground))]"
>Bearbeiten</button
>
<button
onclick={deleteItem}
class="rounded-lg border border-red-300 px-3 py-1.5 text-sm text-red-500 hover:bg-red-50 dark:border-red-800 dark:hover:bg-red-900/20"
>Loschen</button
>
{/if}
</div>
</div>
{#if editing}
<!-- Edit Mode -->
<div
class="space-y-4 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-5"
>
<input type="text" bind:value={editName} placeholder="Name" class={inputClass} />
<textarea
bind:value={editDescription}
placeholder="Beschreibung"
rows="2"
class={inputClass}
></textarea>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>Status</label
>
<select bind:value={editStatus} class={inputClass}>
{#each statuses as s}<option value={s}>{statusLabels[s]}</option>{/each}
</select>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>Menge</label
>
<input type="number" bind:value={editQuantity} min="1" class={inputClass} />
</div>
{#if locationsCtx.value.length > 0}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>Standort</label
>
<select bind:value={editLocationId} class={inputClass}>
<option value={undefined}>-- Kein Standort --</option>
{#each locationsCtx.value as loc}
<option value={loc.id}>{loc.path ? `${loc.path}/` : ''}{loc.name}</option>
{/each}
</select>
</div>
{/if}
{#if categoriesCtx.value.length > 0}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>Kategorie</label
>
<select bind:value={editCategoryId} class={inputClass}>
<option value={undefined}>-- Keine Kategorie --</option>
{#each categoriesCtx.value as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
{/if}
</div>
{#if collection}
<div>
<h3 class="mb-2 text-sm font-semibold text-[hsl(var(--foreground))]">Eigene Felder</h3>
<div class="grid gap-3 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
<div>
<label class="mb-1 block text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{field.name}</label
>
<FieldEditor
{field}
value={editFields[field.id]}
onchange={(v) => (editFields = { ...editFields, [field.id]: v })}
/>
</div>
{/each}
</div>
</div>
{/if}
</div>
{:else}
<!-- View Mode -->
<div class="space-y-4">
<!-- Status & Meta -->
<div class="flex flex-wrap items-center gap-3">
<StatusBadge status={item.status} size="md" />
{#if item.quantity > 1}
<span class="rounded-full bg-[hsl(var(--muted))] px-3 py-1 text-sm"
>&times;{item.quantity}</span
>
{/if}
{#if item.locationId}
{@const loc = getLocationById(locationsCtx.value, item.locationId)}
{#if loc}
<span class="flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))]">
📍 {getLocationFullPath(locationsCtx.value, loc.id)}
</span>
{/if}
{/if}
{#if item.categoryId}
{@const cat = getCategoryById(categoriesCtx.value, item.categoryId)}
{#if cat}
<span class="rounded-full bg-[hsl(var(--muted))] px-2 py-0.5 text-xs"
>{cat.icon || '🏷️'} {cat.name}</span
>
{/if}
{/if}
</div>
{#if item.description}
<p class="text-[hsl(var(--foreground))]">{item.description}</p>
{/if}
<!-- Custom Fields -->
{#if collection && collection.schema.fields.length > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">Details</h3>
<div class="grid gap-2 sm:grid-cols-2">
{#each collection.schema.fields.sort((a, b) => a.order - b.order) as field}
<div class="flex items-baseline gap-2">
<span class="text-xs font-medium text-[hsl(var(--muted-foreground))]"
>{field.name}:</span
>
<FieldRenderer {field} value={item.fieldValues[field.id]} />
</div>
{/each}
</div>
</div>
{/if}
<!-- Notes -->
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-semibold text-[hsl(var(--foreground))]">
Notizen ({item.notes.length})
</h3>
<div class="space-y-2">
{#each item.notes as note (note.id)}
<div
class="group flex items-start justify-between rounded-lg bg-[hsl(var(--muted))] p-3"
>
<div>
<p class="text-sm text-[hsl(var(--foreground))]">{note.content}</p>
<p class="mt-1 text-xs text-[hsl(var(--muted-foreground))]">
{new Date(note.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
<button
onclick={() => itemsStore.deleteNote(item.id, note.id)}
class="text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
>&times;</button
>
</div>
{/each}
</div>
<div class="mt-3 flex gap-2">
<input
type="text"
bind:value={newNote}
placeholder="Notiz hinzufugen..."
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
onkeydown={(e) => e.key === 'Enter' && addNote()}
/>
<button
onclick={addNote}
disabled={!newNote.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-3 py-2 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>+</button
>
</div>
</div>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,155 @@
<script lang="ts">
import { getContext } from 'svelte';
import { locationsStore } from '$lib/modules/inventar/stores/locations.svelte';
import { getLocationTree } from '$lib/modules/inventar/queries';
import type { Location } from '$lib/modules/inventar/queries';
import { Plus } from '@manacore/shared-icons';
const locationsCtx: { readonly value: Location[] } = getContext('locations');
let showForm = $state(false);
let editingId = $state<string | null>(null);
let parentId = $state<string | undefined>();
let name = $state('');
let icon = $state('');
function startCreate(parent?: string) {
parentId = parent;
name = '';
icon = '';
editingId = null;
showForm = true;
}
function startEdit(location: Location) {
editingId = location.id;
name = location.name;
icon = location.icon || '';
parentId = location.parentId;
showForm = true;
}
async function save() {
if (!name.trim()) return;
if (editingId) {
await locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined });
} else {
await locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId });
}
showForm = false;
name = '';
icon = '';
editingId = null;
}
async function deleteLocation(id: string) {
if (confirm('Standort und alle Unterstandorte loschen?')) {
await locationsStore.delete(id);
}
}
let tree = $derived(getLocationTree(locationsCtx.value));
const inputClass =
'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))]';
</script>
<svelte:head>
<title>Standorte - Inventar - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Standorte</h1>
<button
onclick={() => startCreate()}
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 size={20} />
Neuer Standort
</button>
</div>
{#if showForm}
<div class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-semibold">
{editingId ? 'Standort bearbeiten' : 'Neuer Standort'}
</h3>
<div class="flex gap-2">
<input
type="text"
bind:value={icon}
placeholder="📍"
class="{inputClass} w-12 text-center text-lg"
maxlength="2"
/>
<input
type="text"
bind:value={name}
placeholder="Standortname"
class="{inputClass} flex-1"
onkeydown={(e) => e.key === 'Enter' && save()}
/>
<button
onclick={save}
disabled={!name.trim()}
class="rounded-lg bg-[hsl(var(--primary))] px-4 text-sm text-[hsl(var(--primary-foreground))] disabled:opacity-50"
>
Speichern
</button>
<button
onclick={() => (showForm = false)}
class="rounded-lg border border-[hsl(var(--border))] px-3 text-sm"
>
Abbrechen
</button>
</div>
</div>
{/if}
{#if tree.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>
<p class="text-[hsl(var(--muted-foreground))]">Keine Standorte vorhanden</p>
</div>
{:else}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))]">
{#snippet renderTree(locations: Location[], depth: number)}
{#each locations as location (location.id)}
<div class="border-b border-[hsl(var(--border))] last:border-b-0">
<div
class="flex items-center gap-2 px-4 py-2.5"
style="padding-left: {16 + depth * 24}px"
>
<span class="text-lg">{location.icon || '📍'}</span>
<span class="flex-1 text-sm font-medium text-[hsl(var(--foreground))]"
>{location.name}</span
>
<button
onclick={() => startCreate(location.id)}
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--primary))]"
title="Unterstandort hinzufugen">+</button
>
<button
onclick={() => startEdit(location)}
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>&#9998;</button
>
<button
onclick={() => deleteLocation(location.id)}
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-red-500"
>&times;</button
>
</div>
{#if location.children?.length}
{@render renderTree(location.children, depth + 1)}
{/if}
</div>
{/each}
{/snippet}
{@render renderTree(tree, 0)}
</div>
{/if}
</div>

View file

@ -0,0 +1,62 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { getFilteredItems, getCollectionById } from '$lib/modules/inventar/queries';
import type { Collection, Item } from '$lib/modules/inventar/queries';
import StatusBadge from '$lib/modules/inventar/components/StatusBadge.svelte';
import { MagnifyingGlass } from '@manacore/shared-icons';
const collectionsCtx: { readonly value: Collection[] } = getContext('collections');
const itemsCtx: { readonly value: Item[] } = getContext('items');
let query = $state('');
let results = $derived(
query.length >= 2 ? getFilteredItems(itemsCtx.value, { search: query }) : []
);
</script>
<svelte:head>
<title>Suche - Inventar - ManaCore</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">Suche</h1>
<div class="relative">
<MagnifyingGlass
size={20}
class="absolute left-3 top-1/2 -translate-y-1/2 text-[hsl(var(--muted-foreground))]"
/>
<input
type="text"
bind:value={query}
placeholder="In allen Items suchen..."
class="w-full rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--input))] py-3 pl-11 pr-4 text-lg text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]"
autofocus
/>
</div>
{#if query.length >= 2}
<p class="text-sm text-[hsl(var(--muted-foreground))]">{results.length} Ergebnisse</p>
<div class="space-y-2">
{#each results as item (item.id)}
<button
onclick={() => goto(`/inventar/items/${item.id}`)}
class="flex w-full items-center gap-4 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 text-left transition-colors hover:border-[hsl(var(--primary)/0.3)]"
>
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-medium text-[hsl(var(--foreground))]">{item.name}</h3>
<StatusBadge status={item.status} />
</div>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{getCollectionById(collectionsCtx.value, item.collectionId)?.name || ''}
</p>
</div>
</button>
{/each}
</div>
{:else if query.length > 0}
<p class="text-sm text-[hsl(var(--muted-foreground))]">Mindestens 2 Zeichen eingeben...</p>
{/if}
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllAlbums, useAllAlbumItems, useAllFavorites } from '$lib/modules/photos/queries';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allAlbums = useAllAlbums();
const allAlbumItems = useAllAlbumItems();
const allFavorites = useAllFavorites();
// Provide data to child components via Svelte context
setContext('albums', allAlbums);
setContext('albumItems', allAlbumItems);
setContext('favorites', allFavorites);
</script>
{@render children()}

View file

@ -0,0 +1,118 @@
<script lang="ts">
import { onMount } from 'svelte';
import { photoStore } from '$lib/modules/photos/stores/photos.svelte';
import PhotoGrid from '$lib/modules/photos/components/gallery/PhotoGrid.svelte';
import PhotoDetailModal from '$lib/modules/photos/components/gallery/PhotoDetailModal.svelte';
import FilterBar from '$lib/modules/photos/components/filters/FilterBar.svelte';
import { Image } from '@manacore/shared-icons';
let showFilters = $state(false);
onMount(async () => {
await photoStore.loadPhotos(true);
});
function handlePhotoClick(photo: any) {
photoStore.selectPhoto(photo);
}
function handleCloseModal() {
photoStore.selectPhoto(null);
}
function handleLoadMore() {
photoStore.loadMore();
}
</script>
<svelte:head>
<title>Gallery | Photos - ManaCore</title>
</svelte:head>
<div class="gallery-page">
<header class="page-header">
<h1 class="text-2xl font-bold">Gallery</h1>
<div class="flex items-center gap-2">
{#if photoStore.stats}
<span class="text-sm text-muted-foreground">
{photoStore.stats.totalCount}
{photoStore.stats.totalCount === 1 ? 'photo' : 'photos'}
</span>
{/if}
<button class="icon-btn" onclick={() => (showFilters = !showFilters)} title="Filters">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
</button>
</div>
</header>
{#if showFilters}
<FilterBar />
{/if}
{#if photoStore.error}
<div class="error-message">
<p>{photoStore.error}</p>
</div>
{:else if photoStore.photos.length === 0 && !photoStore.loading}
<div class="empty-state">
<Image size={20} class="text-muted-foreground" />
<h2 class="text-lg font-medium mt-4">No photos yet</h2>
<p class="text-muted-foreground">Upload some photos to get started.</p>
</div>
{:else}
<PhotoGrid
photos={photoStore.photos}
loading={photoStore.loading}
hasMore={photoStore.hasMore}
onPhotoClick={handlePhotoClick}
onLoadMore={handleLoadMore}
/>
{/if}
{#if photoStore.selectedPhoto}
<PhotoDetailModal photo={photoStore.selectedPhoto} onClose={handleCloseModal} />
{/if}
</div>
<style>
.gallery-page {
max-width: 1600px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
.error-message {
padding: 1rem;
background-color: var(--color-destructive);
color: var(--color-destructive-foreground);
border-radius: var(--radius-md);
margin-bottom: 1rem;
}
</style>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { albumMutations } from '$lib/modules/photos/stores/albums.svelte';
import { enrichAlbumsWithCounts } from '$lib/modules/photos/queries';
import AlbumGrid from '$lib/modules/photos/components/albums/AlbumGrid.svelte';
import CreateAlbumModal from '$lib/modules/photos/components/albums/CreateAlbumModal.svelte';
import type { Album, AlbumItem } from '$lib/modules/photos/types';
import { Folder, Plus } from '@manacore/shared-icons';
const allAlbums: { readonly value: Album[] } = getContext('albums');
const allAlbumItems: { readonly value: AlbumItem[] } = getContext('albumItems');
let albums = $derived(enrichAlbumsWithCounts(allAlbums.value, allAlbumItems.value));
let showCreateModal = $state(false);
function handleAlbumClick(album: any) {
goto(`/photos/albums/${album.id}`);
}
async function handleCreateAlbum(data: { name: string; description?: string }) {
const album = await albumMutations.createAlbum(data);
if (album) {
showCreateModal = false;
goto(`/photos/albums/${album.id}`);
}
}
</script>
<svelte:head>
<title>Albums | Photos - ManaCore</title>
</svelte:head>
<div class="albums-page">
<header class="page-header">
<h1 class="text-2xl font-bold">Albums</h1>
<button class="btn btn-primary" onclick={() => (showCreateModal = true)}>
<Plus size={20} class="mr-1" />
Create Album
</button>
</header>
{#if albums.length === 0}
<div class="empty-state">
<Folder size={20} class="text-muted-foreground" />
<h2 class="text-lg font-medium mt-4">No albums yet</h2>
<p class="text-muted-foreground">Create an album to organize your photos.</p>
<button class="btn btn-primary mt-4" onclick={() => (showCreateModal = true)}>
Create Album
</button>
</div>
{:else}
<AlbumGrid {albums} loading={false} onAlbumClick={handleAlbumClick} />
{/if}
{#if showCreateModal}
<CreateAlbumModal onClose={() => (showCreateModal = false)} onCreate={handleCreateAlbum} />
{/if}
</div>
<style>
.albums-page {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
</style>

View file

@ -0,0 +1,111 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { albumMutations } from '$lib/modules/photos/stores/albums.svelte';
import { photoStore } from '$lib/modules/photos/stores/photos.svelte';
import { getAlbumById, getAlbumItemsForAlbum } from '$lib/modules/photos/queries';
import PhotoGrid from '$lib/modules/photos/components/gallery/PhotoGrid.svelte';
import PhotoDetailModal from '$lib/modules/photos/components/gallery/PhotoDetailModal.svelte';
import type { Album, AlbumItem, Photo } from '$lib/modules/photos/types';
import { CaretLeft, Trash } from '@manacore/shared-icons';
const allAlbums: { readonly value: Album[] } = getContext('albums');
const allAlbumItems: { readonly value: AlbumItem[] } = getContext('albumItems');
const albumId = $derived($page.params.id);
let currentAlbum = $derived(getAlbumById(allAlbums.value, albumId));
let albumItems = $derived(getAlbumItemsForAlbum(allAlbumItems.value, albumId));
let albumPhotos = $derived(albumItems.map((item) => ({ id: item.mediaId }) as Photo));
function handlePhotoClick(photo: any) {
photoStore.selectPhoto(photo);
}
function handleCloseModal() {
photoStore.selectPhoto(null);
}
async function handleDeleteAlbum() {
if (confirm('Delete this album?')) {
const success = await albumMutations.deleteAlbum(albumId);
if (success) {
goto('/photos/albums');
}
}
}
</script>
<svelte:head>
<title>{currentAlbum?.name || 'Album'} | Photos - ManaCore</title>
</svelte:head>
<div class="album-detail-page">
{#if !currentAlbum}
<div class="loading-state">
<div class="animate-pulse text-muted-foreground">Loading...</div>
</div>
{:else}
<header class="page-header">
<div class="flex items-center gap-3">
<button class="icon-btn" onclick={() => goto('/photos/albums')} title="Back">
<CaretLeft size={20} />
</button>
<div>
<h1 class="text-2xl font-bold">{currentAlbum.name}</h1>
{#if currentAlbum.description}
<p class="text-sm text-muted-foreground">{currentAlbum.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{albumPhotos.length} items
</span>
<button class="icon-btn text-destructive" onclick={handleDeleteAlbum} title="Delete album">
<Trash size={20} />
</button>
</div>
</header>
{#if albumPhotos.length === 0}
<div class="empty-state">
<p class="text-muted-foreground">No photos in this album yet.</p>
</div>
{:else}
<PhotoGrid
photos={albumPhotos}
loading={false}
hasMore={false}
onPhotoClick={handlePhotoClick}
onLoadMore={() => {}}
/>
{/if}
{/if}
{#if photoStore.selectedPhoto}
<PhotoDetailModal photo={photoStore.selectedPhoto} onClose={handleCloseModal} />
{/if}
</div>
<style>
.album-detail-page {
max-width: 1600px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.loading-state,
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
</style>

View file

@ -0,0 +1,80 @@
<script lang="ts">
import { getContext } from 'svelte';
import { photoStore } from '$lib/modules/photos/stores/photos.svelte';
import PhotoGrid from '$lib/modules/photos/components/gallery/PhotoGrid.svelte';
import PhotoDetailModal from '$lib/modules/photos/components/gallery/PhotoDetailModal.svelte';
import type { Photo, LocalFavorite } from '$lib/modules/photos/types';
import { Heart } from '@manacore/shared-icons';
const allFavorites: { readonly value: LocalFavorite[] } = getContext('favorites');
// Derive favorite photos from live query (auto-updates when favorites change)
let favorites = $derived<Photo[]>(
allFavorites.value.map((f) => ({ id: f.mediaId, isFavorited: true }) as Photo)
);
function handlePhotoClick(photo: Photo) {
photoStore.selectPhoto(photo);
}
function handleCloseModal() {
photoStore.selectPhoto(null);
}
</script>
<svelte:head>
<title>Favorites | Photos - ManaCore</title>
</svelte:head>
<div class="favorites-page">
<header class="page-header">
<h1 class="text-2xl font-bold">Favorites</h1>
<span class="text-sm text-muted-foreground">
{favorites.length}
{favorites.length === 1 ? 'photo' : 'photos'}
</span>
</header>
{#if favorites.length === 0}
<div class="empty-state">
<Heart size={20} class="text-muted-foreground" />
<h2 class="text-lg font-medium mt-4">No favorites yet</h2>
<p class="text-muted-foreground">Heart a photo to add it to your favorites.</p>
</div>
{:else}
<PhotoGrid
photos={favorites}
loading={false}
hasMore={false}
onPhotoClick={handlePhotoClick}
onLoadMore={() => {}}
/>
{/if}
{#if photoStore.selectedPhoto}
<PhotoDetailModal photo={photoStore.selectedPhoto} onClose={handleCloseModal} />
{/if}
</div>
<style>
.favorites-page {
max-width: 1600px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
text-align: center;
}
</style>

View file

@ -0,0 +1,294 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UploadDropzone from '$lib/modules/photos/components/upload/UploadDropzone.svelte';
import { X } from '@manacore/shared-icons';
const MEDIA_URL = import.meta.env.PUBLIC_MANA_MEDIA_URL || 'http://localhost:3015';
interface UploadFile {
file: File;
preview: string;
progress: number;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
let files = $state<UploadFile[]>([]);
let uploading = $state(false);
function handleFilesSelected(selectedFiles: File[]) {
const newFiles = selectedFiles
.filter((file) => file.type.startsWith('image/'))
.map((file) => ({
file,
preview: URL.createObjectURL(file),
progress: 0,
status: 'pending' as const,
}));
files = [...files, ...newFiles];
}
function removeFile(index: number) {
URL.revokeObjectURL(files[index].preview);
files = files.filter((_, i) => i !== index);
}
async function uploadAll() {
if (files.length === 0 || uploading) return;
uploading = true;
for (let i = 0; i < files.length; i++) {
if (files[i].status !== 'pending') continue;
files[i].status = 'uploading';
files[i].progress = 0;
try {
const formData = new FormData();
formData.append('file', files[i].file);
formData.append('app', 'photos');
const response = await fetch(`${MEDIA_URL}/api/v1/media/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) throw new Error('Upload failed');
files[i].status = 'success';
files[i].progress = 100;
} catch (e) {
files[i].status = 'error';
files[i].error = e instanceof Error ? e.message : 'Upload failed';
}
}
uploading = false;
// If all successful, redirect to gallery after a delay
const allSuccess = files.every((f) => f.status === 'success');
if (allSuccess) {
setTimeout(() => goto('/photos'), 1500);
}
}
function clearCompleted() {
files.filter((f) => f.status === 'success').forEach((f) => URL.revokeObjectURL(f.preview));
files = files.filter((f) => f.status !== 'success');
}
</script>
<svelte:head>
<title>Upload | Photos - ManaCore</title>
</svelte:head>
<div class="upload-page">
<header class="page-header">
<h1 class="text-2xl font-bold">Upload Photos</h1>
</header>
<UploadDropzone onFilesSelected={handleFilesSelected} />
{#if files.length > 0}
<div class="upload-list">
<div class="flex justify-between items-center mb-4">
<span class="text-sm text-muted-foreground">
{files.length}
{files.length === 1 ? 'file' : 'files'}
</span>
<div class="flex gap-2">
{#if files.some((f) => f.status === 'success')}
<button class="btn btn-ghost" onclick={clearCompleted}> Clear completed </button>
{/if}
<button
class="btn btn-primary"
onclick={uploadAll}
disabled={uploading || files.every((f) => f.status !== 'pending')}
>
{#if uploading}
Uploading...
{:else}
Upload All
{/if}
</button>
</div>
</div>
<div class="file-grid">
{#each files as file, index}
<div
class="file-item"
class:success={file.status === 'success'}
class:error={file.status === 'error'}
>
<img src={file.preview} alt="" class="file-preview" />
<div class="file-overlay">
{#if file.status === 'pending'}
<button class="remove-btn" onclick={() => removeFile(index)}>
<X size={20} />
</button>
{:else if file.status === 'uploading'}
<div class="progress-ring">
<svg viewBox="0 0 36 36">
<path
class="progress-bg"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path
class="progress-bar"
stroke-dasharray="{file.progress}, 100"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
/>
</svg>
</div>
{:else if file.status === 'success'}
<div class="status-icon success">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
{:else if file.status === 'error'}
<div class="status-icon error" title={file.error}>
<X size={20} />
</div>
{/if}
</div>
<div class="file-name">{file.file.name}</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
<style>
.upload-page {
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 1.5rem;
}
.upload-list {
margin-top: 2rem;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.file-item {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-muted);
}
.file-item.success {
outline: 2px solid var(--color-success, #22c55e);
}
.file-item.error {
outline: 2px solid var(--color-destructive);
}
.file-preview {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 150ms;
}
.file-item:hover .file-overlay {
opacity: 1;
}
.file-item.success .file-overlay,
.file-item.error .file-overlay {
opacity: 1;
background: rgba(0, 0, 0, 0.5);
}
.remove-btn {
padding: 0.5rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: var(--color-foreground);
border: none;
cursor: pointer;
}
.progress-ring {
width: 40px;
height: 40px;
}
.progress-ring svg {
transform: rotate(-90deg);
}
.progress-bg {
fill: none;
stroke: rgba(255, 255, 255, 0.3);
stroke-width: 3;
}
.progress-bar {
fill: none;
stroke: white;
stroke-width: 3;
stroke-linecap: round;
transition: stroke-dasharray 300ms;
}
.status-icon {
padding: 0.5rem;
border-radius: 50%;
color: white;
}
.status-icon.success {
background: var(--color-success, #22c55e);
}
.status-icon.error {
background: var(--color-destructive);
}
.file-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.5rem;
font-size: 0.75rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -0,0 +1,135 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { trackEvent } from '@manacore/shared-utils/analytics';
import { wateringMutations } from '$lib/modules/planta/mutations';
import {
getActivePlants,
getPrimaryPhoto,
getScheduleForPlant,
getDaysUntilWatering,
isWateringOverdue,
} from '$lib/modules/planta/queries';
import type { Plant, PlantPhoto, WateringSchedule } from '$lib/modules/planta/types';
const allPlants: { readonly value: Plant[] } = getContext('plants');
const allPlantPhotos: { readonly value: PlantPhoto[] } = getContext('plantPhotos');
const allWateringSchedules: { readonly value: WateringSchedule[] } =
getContext('wateringSchedules');
// Derived reactive data from live queries
let plants = $derived(getActivePlants(allPlants.value));
function getWateringClass(plantId: string): string {
const schedule = getScheduleForPlant(allWateringSchedules.value, plantId);
if (!schedule) return '';
if (isWateringOverdue(schedule)) return 'overdue';
const days = getDaysUntilWatering(schedule);
if (days !== null && days <= 1) return 'soon';
return 'ok';
}
function getWateringText(plantId: string): string {
const schedule = getScheduleForPlant(allWateringSchedules.value, plantId);
if (!schedule) return '';
const days = getDaysUntilWatering(schedule);
if (days === null) return '';
if (days < 0) return 'Ueberfaellig!';
if (days === 0) return 'Heute giessen';
if (days === 1) return 'Morgen giessen';
return `In ${days} Tagen`;
}
function shouldShowWaterButton(plantId: string): boolean {
const schedule = getScheduleForPlant(allWateringSchedules.value, plantId);
if (!schedule) return false;
const days = getDaysUntilWatering(schedule);
return days !== null && days <= 1;
}
async function handleWater(plantId: string, e: Event) {
e.stopPropagation();
const success = await wateringMutations.logWatering(plantId);
if (success) {
trackEvent('plant_watered');
}
}
</script>
<svelte:head>
<title>Meine Pflanzen - Planta</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Meine Pflanzen</h1>
<a href="/planta/add" class="btn btn-success"> + Pflanze hinzufuegen </a>
</div>
{#if plants.length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4">🌱</div>
<h2 class="text-xl font-semibold mb-2">Noch keine Pflanzen</h2>
<p class="text-muted-foreground mb-4">
Fuege deine erste Pflanze hinzu und lass sie von der KI analysieren.
</p>
<a href="/planta/add" class="btn btn-success"> Erste Pflanze hinzufuegen </a>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#each plants as plant (plant.id)}
{@const primaryPhoto = getPrimaryPhoto(allPlantPhotos.value, plant.id)}
<button
type="button"
class="card plant-card cursor-pointer text-left"
onclick={() => goto(`/planta/${plant.id}`)}
>
{#if primaryPhoto?.publicUrl}
<img src={primaryPhoto.publicUrl} alt={plant.name} />
{:else}
<div class="flex h-full w-full items-center justify-center bg-muted text-4xl">🌿</div>
{/if}
<div
class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3"
>
<h3 class="font-semibold text-white truncate">{plant.name}</h3>
{#if plant.commonName}
<p class="text-xs text-white/70 truncate">{plant.commonName}</p>
{/if}
{#if getWateringText(plant.id)}
<div class="water-status {getWateringClass(plant.id)} mt-1">
<span>💧</span>
<span>{getWateringText(plant.id)}</span>
</div>
{/if}
</div>
{#if shouldShowWaterButton(plant.id)}
<button
type="button"
class="absolute top-2 right-2 rounded-full bg-blue-500 p-2 text-white hover:bg-blue-600"
onclick={(e) => handleWater(plant.id, e)}
title="Als gegossen markieren"
>
💧
</button>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.plant-card {
position: relative;
overflow: hidden;
aspect-ratio: 1;
padding: 0;
}
.plant-card img {
width: 100%;
height: 100%;
object-fit: cover;
}
</style>

View file

@ -0,0 +1,221 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { trackEvent } from '@manacore/shared-utils/analytics';
import { plantMutations, wateringMutations } from '$lib/modules/planta/mutations';
import {
getPlantById,
getPhotosForPlant,
getScheduleForPlant,
getLogsForPlant,
} from '$lib/modules/planta/queries';
import type { Plant, PlantPhoto, WateringSchedule, WateringLog } from '$lib/modules/planta/types';
const allPlants: { readonly value: Plant[] } = getContext('plants');
const allPlantPhotos: { readonly value: PlantPhoto[] } = getContext('plantPhotos');
const allWateringSchedules: { readonly value: WateringSchedule[] } =
getContext('wateringSchedules');
const allWateringLogs: { readonly value: WateringLog[] } = getContext('wateringLogs');
const plantId = $derived($page.params.id);
// Derived reactive data from live queries (auto-updates on any change)
let plant = $derived(getPlantById(allPlants.value, plantId));
let photos = $derived(getPhotosForPlant(allPlantPhotos.value, plantId));
let wateringSchedule = $derived(getScheduleForPlant(allWateringSchedules.value, plantId));
let wateringHistory = $derived(getLogsForPlant(allWateringLogs.value, plantId));
let watering = $state(false);
async function handleWater() {
if (!plant) return;
watering = true;
const success = await wateringMutations.logWatering(plant.id);
if (success) {
trackEvent('plant_watered');
}
watering = false;
}
async function handleDelete() {
if (!plant) return;
if (!confirm(`Moechtest du "${plant.name}" wirklich loeschen?`)) return;
const success = await plantMutations.delete(plant.id);
if (success) {
trackEvent('plant_deleted');
goto('/planta');
}
}
function formatDate(date: Date | string | undefined | null): string {
if (!date) return '-';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function getHealthBadgeClass(status: string | null | undefined): string {
if (!status) return 'healthy';
if (status === 'needs_attention') return 'needs_attention';
if (status === 'sick') return 'sick';
return 'healthy';
}
function getHealthText(status: string | null | undefined): string {
const map: Record<string, string> = {
healthy: 'Gesund',
needs_attention: 'Braucht Aufmerksamkeit',
sick: 'Krank',
};
return map[status || ''] || 'Gesund';
}
function getLightText(light: string | null | undefined): string {
const map: Record<string, string> = {
low: 'Wenig Licht',
medium: 'Mittleres Licht',
bright: 'Helles Licht',
direct: 'Direkte Sonne',
};
return map[light || ''] || '-';
}
function getHumidityText(humidity: string | null | undefined): string {
const map: Record<string, string> = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
};
return map[humidity || ''] || '-';
}
</script>
<svelte:head>
<title>{plant?.name || 'Pflanze'} - Planta</title>
</svelte:head>
{#if !plant}
<div class="text-center py-12">
<p class="text-lg">Pflanze nicht gefunden</p>
<a href="/planta" class="btn btn-primary mt-4">Zurueck zur Uebersicht</a>
</div>
{:else}
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold">{plant.name}</h1>
{#if plant.scientificName}
<p class="text-muted-foreground italic">{plant.scientificName}</p>
{/if}
{#if plant.commonName && plant.commonName !== plant.name}
<p class="text-muted-foreground">{plant.commonName}</p>
{/if}
</div>
<span class="health-badge {getHealthBadgeClass(plant.healthStatus)}">
{getHealthText(plant.healthStatus)}
</span>
</div>
<!-- Photo Gallery -->
{#if photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each photos as photo (photo.id)}
<img
src={photo.publicUrl}
alt={plant.name}
class="w-full aspect-square object-cover rounded-lg"
class:ring-2={photo.isPrimary}
class:ring-primary={photo.isPrimary}
/>
{/each}
</div>
{/if}
<!-- Care Info -->
<div class="card">
<h2 class="font-semibold mb-4">Pflege</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-muted-foreground">Licht</p>
<p class="font-medium">☀️ {getLightText(plant.lightRequirements)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Giessen</p>
<p class="font-medium">
💧 {plant.wateringFrequencyDays ? `Alle ${plant.wateringFrequencyDays} Tage` : '-'}
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Luftfeuchtigkeit</p>
<p class="font-medium">💨 {getHumidityText(plant.humidity)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Temperatur</p>
<p class="font-medium">🌡️ {plant.temperature || '-'}</p>
</div>
</div>
{#if plant.careNotes}
<div class="mt-4 pt-4 border-t">
<p class="text-sm text-muted-foreground mb-1">Pflegehinweise</p>
<p class="text-sm whitespace-pre-line">{plant.careNotes}</p>
</div>
{/if}
</div>
<!-- Watering Schedule -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold">Giessplan</h2>
<button type="button" class="btn btn-success" onclick={handleWater} disabled={watering}>
{#if watering}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
💧 Jetzt giessen
{/if}
</button>
</div>
{#if wateringSchedule}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm text-muted-foreground">Zuletzt gegossen</p>
<p class="font-medium">{formatDate(wateringSchedule.lastWateredAt)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Naechstes Giessen</p>
<p class="font-medium">{formatDate(wateringSchedule.nextWateringAt)}</p>
</div>
</div>
{/if}
{#if wateringHistory.length > 0}
<div class="border-t pt-4">
<p class="text-sm text-muted-foreground mb-2">Letzte Giessvorgaenge</p>
<ul class="space-y-1">
{#each wateringHistory.slice(0, 5) as log (log.id)}
<li class="text-sm flex justify-between">
<span>💧 Gegossen</span>
<span class="text-muted-foreground">{formatDate(log.wateredAt)}</span>
</li>
{/each}
</ul>
</div>
{/if}
</div>
<!-- Actions -->
<div class="flex gap-4">
<a href="/planta" class="btn flex-1 bg-muted text-foreground"> Zurueck </a>
<button type="button" class="btn bg-destructive text-white" onclick={handleDelete}>
Loeschen
</button>
</div>
</div>
{/if}

View file

@ -0,0 +1,103 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { plantMutations } from '$lib/modules/planta/mutations';
import { trackEvent } from '@manacore/shared-utils/analytics';
let plantName = $state('');
let scientificName = $state('');
let commonName = $state('');
let error = $state('');
let saving = $state(false);
async function savePlant() {
if (!plantName.trim()) {
error = 'Bitte gib einen Namen fuer die Pflanze ein';
return;
}
saving = true;
error = '';
const plant = await plantMutations.create({
name: plantName.trim(),
scientificName: scientificName.trim() || undefined,
commonName: commonName.trim() || undefined,
});
if (!plant) {
error = 'Pflanze konnte nicht gespeichert werden';
saving = false;
return;
}
trackEvent('plant_created');
goto(`/planta/${plant.id}`);
}
</script>
<svelte:head>
<title>Pflanze hinzufuegen - Planta</title>
</svelte:head>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold">Pflanze hinzufuegen</h1>
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
{/if}
<div class="card p-6 space-y-4">
<div>
<label for="plant-name" class="block text-sm font-medium mb-2"> Name * </label>
<input
id="plant-name"
type="text"
bind:value={plantName}
class="input w-full"
placeholder="z.B. Meine Monstera"
/>
</div>
<div>
<label for="scientific-name" class="block text-sm font-medium mb-2">
Wissenschaftlicher Name
</label>
<input
id="scientific-name"
type="text"
bind:value={scientificName}
class="input w-full"
placeholder="z.B. Monstera deliciosa"
/>
</div>
<div>
<label for="common-name" class="block text-sm font-medium mb-2"> Allgemeiner Name </label>
<input
id="common-name"
type="text"
bind:value={commonName}
class="input w-full"
placeholder="z.B. Fensterblatt"
/>
</div>
<button type="button" class="btn btn-success w-full mt-4" onclick={savePlant} disabled={saving}>
{#if saving}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
Pflanze speichern
{/if}
</button>
</div>
<div class="text-center">
<a href="/planta" class="text-sm text-muted-foreground hover:text-foreground">
Zurueck zur Uebersicht
</a>
</div>
</div>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
const tagsCtx: { readonly value: Tag[] } = getContext('tags');
</script>
<svelte:head>
<title>Tags | Planta</title>
</svelte:head>
<div class="tags-page">
<h1>Tags verwalten</h1>
<p class="text-sm text-muted-foreground mb-4">
Tags sind app-uebergreifend -- Aenderungen gelten in allen ManaCore-Apps.
</p>
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
{:else}
<div class="grid gap-2">
{#each tagsCtx.value as tag}
<div class="flex items-center gap-2 p-2 rounded-lg bg-card">
<span class="w-3 h-3 rounded-full" style="background-color: {tag.color}"></span>
<span>{tag.name}</span>
</div>
{/each}
</div>
{/if}
</div>
<style>
.tags-page {
padding: 1.5rem;
max-width: 600px;
margin: 0 auto;
}
h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
</style>

View file

@ -0,0 +1,403 @@
<script lang="ts">
import { skillStore } from '$lib/modules/skilltree/stores/skills.svelte';
import {
achievementStore,
buildAchievementStatus,
getAchievementStats,
} from '$lib/modules/skilltree/stores/achievements.svelte';
import {
useAllSkills,
useAllActivities,
useAllAchievements,
filterByBranch,
getRecentActivities,
getSkillById,
computeUserStats,
} from '$lib/modules/skilltree/queries';
import { BRANCH_INFO } from '$lib/modules/skilltree/types';
import type { Skill, SkillBranch, AchievementUnlockResult } from '$lib/modules/skilltree/types';
import SkillCard from '$lib/modules/skilltree/components/SkillCard.svelte';
import AddSkillModal from '$lib/modules/skilltree/components/AddSkillModal.svelte';
import AddXpModal from '$lib/modules/skilltree/components/AddXpModal.svelte';
import EditSkillModal from '$lib/modules/skilltree/components/EditSkillModal.svelte';
import LevelUpCelebration from '$lib/modules/skilltree/components/LevelUpCelebration.svelte';
import AchievementCelebration from '$lib/modules/skilltree/components/AchievementCelebration.svelte';
import StatsOverview from '$lib/modules/skilltree/components/StatsOverview.svelte';
import SkillTemplates from '$lib/modules/skilltree/components/SkillTemplates.svelte';
import {
Plus,
Tree,
Lightning,
DownloadSimple,
UploadSimple,
Sparkle,
Graph,
Trophy,
} from '@manacore/shared-icons';
// Reactive live queries — auto-update when IndexedDB changes
const allSkills = useAllSkills();
const allActivities = useAllActivities();
const allAchievementsRaw = useAllAchievements();
// Derived values from live queries
const skills = $derived(allSkills.value);
const activities = $derived(allActivities.value);
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
const achievementStats = $derived(getAchievementStats(achievements));
const userStats = $derived(computeUserStats(skills, activities));
// Modal states
let showAddSkillModal = $state(false);
let showAddXpModal = $state(false);
let showEditSkillModal = $state(false);
let showTemplatesModal = $state(false);
let selectedSkill = $state<Skill | null>(null);
let selectedBranch = $state<SkillBranch | 'all'>('all');
// Level up celebration
let showLevelUp = $state(false);
let levelUpSkillName = $state('');
let levelUpNewLevel = $state(0);
// Achievement celebration
let showAchievementCelebration = $state(false);
let currentAchievementUnlock = $state<AchievementUnlockResult | null>(null);
const filteredSkills = $derived(filterByBranch(skills, selectedBranch));
function openAddXpModal(skill: Skill) {
selectedSkill = skill;
showAddXpModal = true;
}
function openEditModal(skill: Skill) {
selectedSkill = skill;
showEditSkillModal = true;
}
function closeModals() {
showAddXpModal = false;
showEditSkillModal = false;
selectedSkill = null;
}
function triggerLevelUp(skillName: string, newLevel: number) {
levelUpSkillName = skillName;
levelUpNewLevel = newLevel;
showLevelUp = true;
}
function showNextAchievement() {
const next = achievementStore.popUnlockQueue();
if (next) {
currentAchievementUnlock = next;
showAchievementCelebration = true;
} else {
showAchievementCelebration = false;
currentAchievementUnlock = null;
}
}
async function checkAchievementsLocal(lastActivityXp?: number) {
await achievementStore.checkLocal({
skills,
activities,
userStats,
lastActivityXp,
});
showNextAchievement();
}
async function handleAddXp(xp: number, description: string, duration?: number) {
if (!selectedSkill) return;
const skillName = selectedSkill.name;
const result = await skillStore.addXp(selectedSkill.id, xp, description, duration);
closeModals();
if (result.leveledUp) {
triggerLevelUp(skillName, result.newLevel);
}
await checkAchievementsLocal(xp);
}
async function handleExport() {
const { skillTable, activityTable, achievementTable } = await import(
'$lib/modules/skilltree/collections'
);
const [allSkillsData, allActivitiesData, allAchievementsData] = await Promise.all([
skillTable.toArray(),
activityTable.toArray(),
achievementTable.toArray(),
]);
const data = {
skills: allSkillsData,
activities: allActivitiesData,
achievements: allAchievementsData,
exportedAt: new Date().toISOString(),
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `skilltree-backup-${new Date().toISOString().split('T')[0]}.json`;
a.click();
URL.revokeObjectURL(url);
}
async function handleImport() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
const { skillTable, activityTable, achievementTable } = await import(
'$lib/modules/skilltree/collections'
);
if (data.skills) await skillTable.bulkPut(data.skills);
if (data.activities) await activityTable.bulkPut(data.activities);
if (data.achievements) await achievementTable.bulkPut(data.achievements);
window.location.reload();
} catch (error) {
console.error('Import failed:', error);
alert('Import fehlgeschlagen. Bitte überprüfe die Datei.');
}
};
input.click();
}
</script>
<svelte:head>
<title>SkillTree</title>
</svelte:head>
<div class="min-h-screen">
<!-- Header -->
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
<div class="mx-auto max-w-7xl px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Tree class="h-8 w-8 text-emerald-500" />
<h1 class="text-2xl font-bold text-white">SkillTree</h1>
</div>
<div class="flex items-center gap-2">
<!-- Achievements -->
<a
href="/skilltree/achievements"
class="relative rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-yellow-400"
title="Achievements"
>
<Trophy class="h-5 w-5" />
{#if achievementStats.unlocked > 0}
<span
class="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-yellow-500 text-[10px] font-bold text-gray-900"
>
{achievementStats.unlocked}
</span>
{/if}
</a>
<!-- Tree View -->
<a
href="/skilltree/tree"
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-emerald-400"
title="Skill-Tree Ansicht"
>
<Graph class="h-5 w-5" />
</a>
<!-- Templates -->
<button
onclick={() => (showTemplatesModal = true)}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-yellow-500"
title="Skill-Vorlagen"
>
<Sparkle class="h-5 w-5" />
</button>
<!-- Export/Import -->
<button
onclick={handleExport}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
title="Daten exportieren"
>
<DownloadSimple class="h-5 w-5" />
</button>
<button
onclick={handleImport}
class="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
title="Daten importieren"
>
<UploadSimple class="h-5 w-5" />
</button>
<!-- Add Skill -->
<button
onclick={() => (showAddSkillModal = true)}
class="flex items-center gap-2 rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white transition-colors hover:bg-emerald-500"
>
<Plus class="h-5 w-5" />
<span class="hidden sm:inline">Skill hinzufügen</span>
</button>
</div>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-8">
<!-- Stats Overview -->
<StatsOverview />
<!-- Branch Filter -->
<div class="mb-6 mt-8">
<div class="flex flex-wrap gap-2">
<button
onclick={() => (selectedBranch = 'all')}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch ===
'all'
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
Alle ({skills.length})
</button>
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skills.filter((s) => s.branch === branch).length}
{#if count > 0 || branch !== 'custom'}
<button
onclick={() => (selectedBranch = branch as SkillBranch)}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedBranch ===
branch
? 'bg-emerald-600 text-white'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
{info.name} ({count})
</button>
{/if}
{/each}
</div>
</div>
<!-- Skills Grid -->
{#if filteredSkills.length === 0}
<div class="mt-16 text-center">
<div
class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800"
>
<Tree class="h-12 w-12 text-gray-600" />
</div>
<h2 class="mb-2 text-xl font-semibold text-gray-300">Noch keine Skills</h2>
<p class="mb-6 text-gray-500">Füge deinen ersten Skill hinzu und beginne dein Abenteuer!</p>
<button
onclick={() => (showAddSkillModal = true)}
class="inline-flex items-center gap-2 rounded-lg bg-emerald-600 px-6 py-3 font-medium text-white transition-colors hover:bg-emerald-500"
>
<Plus class="h-5 w-5" />
Ersten Skill erstellen
</button>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredSkills as skill (skill.id)}
<SkillCard
{skill}
onAddXp={() => openAddXpModal(skill)}
onEdit={() => openEditModal(skill)}
onDelete={() => skillStore.deleteSkill(skill.id)}
/>
{/each}
</div>
{/if}
<!-- Recent Activity -->
{#if getRecentActivities(activities).length > 0}
<div class="mt-12">
<h2 class="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<Lightning class="h-5 w-5 text-yellow-500" />
Letzte Aktivitäten
</h2>
<div class="space-y-2">
{#each getRecentActivities(activities).slice(0, 5) as activity}
{@const skill = getSkillById(skills, activity.skillId)}
{#if skill}
<div class="flex items-center justify-between rounded-lg bg-gray-800/50 px-4 py-3">
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-900/50 text-sm font-medium text-emerald-400"
>
+{activity.xpEarned}
</div>
<div>
<span class="font-medium text-white">{skill.name}</span>
<span class="text-gray-400"> - {activity.description}</span>
</div>
</div>
<span class="text-sm text-gray-500">
{new Date(activity.timestamp).toLocaleDateString('de-DE')}
</span>
</div>
{/if}
{/each}
</div>
</div>
{/if}
</main>
</div>
<!-- Modals -->
{#if showAddSkillModal}
<AddSkillModal
onClose={() => (showAddSkillModal = false)}
onSave={async (skill) => {
await skillStore.addSkill(skill);
showAddSkillModal = false;
await checkAchievementsLocal();
}}
/>
{/if}
{#if showAddXpModal && selectedSkill}
<AddXpModal skill={selectedSkill} onClose={closeModals} onSave={handleAddXp} />
{/if}
{#if showEditSkillModal && selectedSkill}
<EditSkillModal
skill={selectedSkill}
onClose={closeModals}
onSave={async (updates) => {
if (selectedSkill) {
await skillStore.updateSkill(selectedSkill.id, updates);
}
}}
onDelete={() => {
if (selectedSkill) {
skillStore.deleteSkill(selectedSkill.id);
}
}}
/>
{/if}
{#if showLevelUp}
<LevelUpCelebration
skillName={levelUpSkillName}
newLevel={levelUpNewLevel}
onClose={() => (showLevelUp = false)}
/>
{/if}
{#if showTemplatesModal}
<SkillTemplates
onClose={() => (showTemplatesModal = false)}
onAddSkill={async (skill) => {
await skillStore.addSkill(skill);
await checkAchievementsLocal();
}}
/>
{/if}
{#if showAchievementCelebration && currentAchievementUnlock}
<AchievementCelebration result={currentAchievementUnlock} onClose={showNextAchievement} />
{/if}

View file

@ -0,0 +1,156 @@
<script lang="ts">
import { useAllAchievements } from '$lib/modules/skilltree/queries';
import {
buildAchievementStatus,
getAchievementStats,
getCompletionPercentage,
} from '$lib/modules/skilltree/stores/achievements.svelte';
import { ACHIEVEMENT_CATEGORY_INFO, RARITY_INFO } from '$lib/modules/skilltree/types';
import type { AchievementCategory } from '$lib/modules/skilltree/types';
import AchievementCard from '$lib/modules/skilltree/components/AchievementCard.svelte';
import { ArrowLeft, Trophy, Star } from '@manacore/shared-icons';
// Reactive live query
const allAchievementsRaw = useAllAchievements();
const achievements = $derived(buildAchievementStatus(allAchievementsRaw.value));
const stats = $derived(getAchievementStats(achievements));
const completion = $derived(getCompletionPercentage(achievements));
let selectedCategory = $state<AchievementCategory | 'all'>('all');
let showOnlyUnlocked = $state(false);
const filteredAchievements = $derived(() => {
let list = achievements;
if (selectedCategory !== 'all') {
list = list.filter((a) => a.category === selectedCategory);
}
if (showOnlyUnlocked) {
list = list.filter((a) => a.unlocked);
}
return list.sort((a, b) => a.sortOrder - b.sortOrder);
});
const categoryEntries = Object.entries(ACHIEVEMENT_CATEGORY_INFO) as [
AchievementCategory,
{ name: string; icon: string },
][];
</script>
<svelte:head>
<title>Achievements - SkillTree</title>
</svelte:head>
<div class="min-h-screen">
<!-- Header -->
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
<div class="mx-auto max-w-7xl px-4 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<a
href="/skilltree"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
>
<ArrowLeft class="h-5 w-5" />
</a>
<Trophy class="h-7 w-7 text-yellow-400" />
<h1 class="text-2xl font-bold text-white">Achievements</h1>
</div>
<!-- Stats badge -->
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-yellow-500/10 px-4 py-2">
<Trophy class="h-4 w-4 text-yellow-400" />
<span class="font-semibold text-yellow-400">
{stats.unlocked} / {stats.total}
</span>
</div>
</div>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-8">
<!-- Progress overview -->
<div class="mb-8 rounded-xl border border-gray-700 bg-gray-800/50 p-6">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-white">Fortschritt</h2>
<span class="text-2xl font-bold text-yellow-400">{completion}%</span>
</div>
<div class="h-3 overflow-hidden rounded-full bg-gray-700">
<div
class="h-full rounded-full bg-gradient-to-r from-yellow-500 to-yellow-400 transition-all duration-500"
style="width: {completion}%"
></div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-sm">
{#each Object.entries(RARITY_INFO) as [rarity, info]}
{@const count = achievements.filter((a) => a.rarity === rarity && a.unlocked).length}
{@const total = achievements.filter((a) => a.rarity === rarity).length}
<span class="flex items-center gap-1.5 {info.color}">
<Star class="h-3 w-3" />
{info.name}: {count}/{total}
</span>
{/each}
</div>
</div>
<!-- Filters -->
<div class="mb-6 flex flex-wrap items-center gap-2">
<button
onclick={() => (selectedCategory = 'all')}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===
'all'
? 'bg-yellow-500 text-gray-900'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
Alle ({achievements.length})
</button>
{#each categoryEntries as [category, info]}
{@const count = achievements.filter((a) => a.category === category).length}
<button
onclick={() => (selectedCategory = category)}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {selectedCategory ===
category
? 'bg-yellow-500 text-gray-900'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
{info.name} ({count})
</button>
{/each}
<div class="ml-auto">
<button
onclick={() => (showOnlyUnlocked = !showOnlyUnlocked)}
class="rounded-full px-4 py-2 text-sm font-medium transition-colors {showOnlyUnlocked
? 'bg-yellow-500/20 text-yellow-400'
: 'bg-gray-800 text-gray-300 hover:bg-gray-700'}"
>
{showOnlyUnlocked ? 'Nur freigeschaltete' : 'Alle zeigen'}
</button>
</div>
</div>
<!-- Achievement grid -->
{#if filteredAchievements().length === 0}
<div class="mt-16 text-center">
<div
class="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-gray-800"
>
<Trophy class="h-12 w-12 text-gray-600" />
</div>
<h2 class="mb-2 text-xl font-semibold text-gray-300">Keine Achievements gefunden</h2>
<p class="text-gray-500">
{showOnlyUnlocked
? 'Du hast in dieser Kategorie noch keine Achievements freigeschaltet.'
: 'Keine Achievements in dieser Kategorie.'}
</p>
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each filteredAchievements() as achievement (achievement.id)}
<AchievementCard {achievement} />
{/each}
</div>
{/if}
</main>
</div>

View file

@ -0,0 +1,273 @@
<script lang="ts">
import { useAllSkills } from '$lib/modules/skilltree/queries';
import { BRANCH_INFO, LEVEL_NAMES } from '$lib/modules/skilltree/types';
import type { SkillBranch } from '$lib/modules/skilltree/types';
import { ArrowLeft, Star } from '@manacore/shared-icons';
// Reactive live query
const allSkills = useAllSkills();
const skills = $derived(allSkills.value);
// Group skills by branch for radial layout
const branches = Object.keys(BRANCH_INFO) as SkillBranch[];
// Calculate position for each branch (radial layout)
function getBranchPosition(branchIndex: number, total: number) {
const angle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
const radius = 280;
return {
x: 400 + Math.cos(angle) * radius,
y: 400 + Math.sin(angle) * radius,
angle: (angle * 180) / Math.PI,
};
}
// Calculate skill position within a branch
function getSkillPosition(
branchIndex: number,
skillIndex: number,
skillCount: number,
total: number
) {
const branchAngle = (branchIndex / total) * 2 * Math.PI - Math.PI / 2;
const spreadAngle = 0.3;
const baseRadius = 180;
const radiusStep = 60;
const skillAngle =
branchAngle +
(skillIndex - (skillCount - 1) / 2) * (spreadAngle / Math.max(skillCount - 1, 1));
const radius = baseRadius + skillIndex * radiusStep * 0.3;
return {
x: 400 + Math.cos(skillAngle) * radius,
y: 400 + Math.sin(skillAngle) * radius,
};
}
function getLevelColor(level: number): string {
const colors = ['#6b7280', '#3b82f6', '#8b5cf6', '#ec4899', '#f97316', '#fbbf24'];
return colors[level] ?? colors[0];
}
function getNodeSize(level: number): number {
return 24 + level * 6;
}
</script>
<svelte:head>
<title>Skill Tree View - SkillTree</title>
</svelte:head>
<div class="min-h-screen bg-gray-900 text-white">
<!-- Header -->
<header class="border-b border-gray-800 bg-gray-900/80 backdrop-blur-sm sticky top-0 z-40">
<div class="mx-auto max-w-7xl px-4 py-4">
<div class="flex items-center gap-4">
<a
href="/skilltree"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-gray-400 transition-colors hover:bg-gray-800 hover:text-white"
>
<ArrowLeft class="h-5 w-5" />
Zurück
</a>
<h1 class="text-xl font-bold">Skill Tree Visualisierung</h1>
</div>
</div>
</header>
<main class="p-4">
{#if skills.length === 0}
<div class="mt-16 text-center">
<p class="text-gray-400">Noch keine Skills vorhanden. Erstelle zuerst einige Skills!</p>
<a
href="/skilltree"
class="mt-4 inline-block rounded-lg bg-emerald-600 px-4 py-2 font-medium text-white hover:bg-emerald-500"
>
Skills erstellen
</a>
</div>
{:else}
<!-- Legend -->
<div class="mb-6 flex flex-wrap justify-center gap-4">
{#each Object.entries(BRANCH_INFO) as [branch, info]}
{@const count = skills.filter((s) => s.branch === branch).length}
{#if count > 0}
<div class="flex items-center gap-2 rounded-full bg-gray-800 px-3 py-1.5 text-sm">
<span class="h-3 w-3 rounded-full" style="background-color: {info.color}"></span>
{info.name} ({count})
</div>
{/if}
{/each}
</div>
<!-- Tree SVG -->
<div class="flex justify-center overflow-auto">
<svg
viewBox="0 0 800 800"
class="max-w-full"
style="min-width: 600px; height: auto; max-height: 80vh;"
>
<!-- Background circles -->
<circle
cx="400"
cy="400"
r="120"
fill="none"
stroke="#374151"
stroke-width="1"
stroke-dasharray="4"
/>
<circle
cx="400"
cy="400"
r="200"
fill="none"
stroke="#374151"
stroke-width="1"
stroke-dasharray="4"
/>
<circle
cx="400"
cy="400"
r="280"
fill="none"
stroke="#374151"
stroke-width="1"
stroke-dasharray="4"
/>
<!-- Center node -->
<circle cx="400" cy="400" r="50" fill="#10b981" opacity="0.2" />
<circle cx="400" cy="400" r="40" fill="#10b981" opacity="0.4" />
<circle cx="400" cy="400" r="30" fill="#10b981" />
<text x="400" y="405" text-anchor="middle" fill="white" font-size="12" font-weight="bold">
YOU
</text>
<!-- Branch lines and labels -->
{#each branches as branch, i}
{@const pos = getBranchPosition(i, branches.length)}
{@const branchSkills = skills.filter((s) => s.branch === branch)}
{#if branchSkills.length > 0}
<!-- Line from center to branch -->
<line
x1="400"
y1="400"
x2={pos.x}
y2={pos.y}
stroke={BRANCH_INFO[branch].color}
stroke-width="2"
opacity="0.3"
/>
<!-- Branch label -->
<text
x={pos.x}
y={pos.y}
text-anchor="middle"
fill={BRANCH_INFO[branch].color}
font-size="14"
font-weight="bold"
dy="-20"
>
{BRANCH_INFO[branch].name}
</text>
<!-- Skills in this branch -->
{#each branchSkills as skill, j}
{@const skillPos = getSkillPosition(i, j, branchSkills.length, branches.length)}
{@const size = getNodeSize(skill.level)}
<!-- Connection line -->
<line
x1="400"
y1="400"
x2={skillPos.x}
y2={skillPos.y}
stroke={BRANCH_INFO[branch].color}
stroke-width="1"
opacity="0.2"
/>
<!-- Skill node -->
<g
class="tree-node cursor-pointer"
transform="translate({skillPos.x}, {skillPos.y})"
>
<!-- Glow effect for high level -->
{#if skill.level >= 4}
<circle
r={size + 8}
fill={getLevelColor(skill.level)}
opacity="0.2"
class="animate-pulse"
/>
{/if}
<!-- Node background -->
<circle
r={size}
fill="#1f2937"
stroke={getLevelColor(skill.level)}
stroke-width="3"
/>
<!-- Level indicator -->
<text
text-anchor="middle"
dy="5"
fill={getLevelColor(skill.level)}
font-size="14"
font-weight="bold"
>
{skill.level}
</text>
<!-- Skill name (on hover/always for important skills) -->
<title>{skill.name} (Level {skill.level} - {skill.totalXp} XP)</title>
</g>
<!-- Skill label -->
<text
x={skillPos.x}
y={skillPos.y + size + 16}
text-anchor="middle"
fill="#9ca3af"
font-size="10"
class="pointer-events-none"
>
{skill.name.length > 12 ? skill.name.slice(0, 12) + '...' : skill.name}
</text>
{/each}
{/if}
{/each}
</svg>
</div>
<!-- Level Legend -->
<div class="mt-8 flex flex-wrap justify-center gap-4">
{#each LEVEL_NAMES as name, level}
<div class="flex items-center gap-2 text-sm">
<div
class="flex h-6 w-6 items-center justify-center rounded-full border-2 text-xs font-bold"
style="border-color: {getLevelColor(level)}; color: {getLevelColor(level)}"
>
{level}
</div>
<span class="text-gray-400">{name}</span>
</div>
{/each}
</div>
{/if}
</main>
</div>
<style>
.tree-node {
transition: transform 0.2s ease;
}
.tree-node:hover {
transform: scale(1.15);
}
</style>

View file

@ -0,0 +1,77 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry } from '$lib/modules/times/types';
import {
getEntriesByDate,
getTotalDuration,
getBillableDuration,
formatDurationCompact,
} from '$lib/modules/times/queries';
import TimerCard from '$lib/modules/times/components/TimerCard.svelte';
import EntryList from '$lib/modules/times/components/EntryList.svelte';
import EntryForm from '$lib/modules/times/components/EntryForm.svelte';
import QuickStart from '$lib/modules/times/components/QuickStart.svelte';
import KeyboardShortcuts from '$lib/modules/times/components/KeyboardShortcuts.svelte';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
const today = new Date().toISOString().split('T')[0];
let todayEntries = $derived(
getEntriesByDate(allTimeEntries.value, today).filter((e) => !e.isRunning)
);
let todayTotal = $derived(getTotalDuration(todayEntries));
let todayBillable = $derived(getBillableDuration(todayEntries));
let showEntryForm = $state(false);
</script>
<svelte:head>
<title>Timer | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Timer Card -->
<TimerCard />
<!-- Today's Summary -->
<div class="flex gap-4">
<div class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('common.total')}</p>
<p class="duration-display text-2xl font-bold text-[hsl(var(--foreground))]">
{formatDurationCompact(todayTotal)}
</p>
</div>
<div class="flex-1 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('entry.billable')}</p>
<p class="duration-display text-2xl font-bold text-[hsl(var(--primary))]">
{formatDurationCompact(todayBillable)}
</p>
</div>
</div>
<!-- Quick Start -->
<QuickStart />
<!-- Today's Entries -->
<div>
<div class="mb-3 flex items-center justify-between">
<h2 class="text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('entry.today')} ({formatDurationCompact(todayTotal)})
</h2>
<button
onclick={() => (showEntryForm = true)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-xs text-[hsl(var(--muted-foreground))] transition-colors hover:border-[hsl(var(--primary)/0.5)] hover:text-[hsl(var(--foreground))]"
>
+ {$_('entry.manual')}
</button>
</div>
<EntryList entries={todayEntries} />
</div>
</div>
<!-- Manual Entry Form -->
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />
<KeyboardShortcuts onNewEntry={() => (showEntryForm = true)} />

View file

@ -0,0 +1,357 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { clientTable } from '$lib/modules/times/collections';
import { getTotalDuration, formatDurationCompact } from '$lib/modules/times/queries';
import type { Client, Project, TimeEntry } from '$lib/modules/times/types';
import { PROJECT_COLORS } from '$lib/modules/times/types';
import { ConfirmationModal } from '@manacore/shared-ui';
import { CaretRight } from '@manacore/shared-icons';
const allClients = getContext<{ value: Client[] }>('clients');
const allProjects = getContext<{ value: Project[] }>('projects');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let showCreateForm = $state(false);
let editingClientId = $state<string | null>(null);
let showArchived = $state(false);
let deleteConfirmId = $state<string | null>(null);
let newName = $state('');
let newShortCode = $state('');
let newEmail = $state('');
let newColor = $state(PROJECT_COLORS[4]);
let newRate = $state(0);
let activeClients = $derived(
allClients.value.filter((c) => !c.isArchived).sort((a, b) => a.order - b.order)
);
let archivedClients = $derived(allClients.value.filter((c) => c.isArchived));
function getClientProjects(clientId: string): Project[] {
return allProjects.value.filter((p) => p.clientId === clientId && !p.isArchived);
}
function getClientHours(clientId: string): number {
return getTotalDuration(
allTimeEntries.value.filter((e) => e.clientId === clientId && !e.isRunning)
);
}
async function handleCreate() {
if (!newName.trim()) return;
await clientTable.add({
id: crypto.randomUUID(),
name: newName.trim(),
shortCode: newShortCode || null,
contactId: null,
email: newEmail || null,
color: newColor,
isArchived: false,
billingRate: newRate > 0 ? { amount: newRate, currency: 'EUR', per: 'hour' } : null,
notes: null,
order: activeClients.length,
});
newName = '';
newShortCode = '';
newEmail = '';
newRate = 0;
showCreateForm = false;
}
async function handleArchive(id: string, archive: boolean) {
await clientTable.update(id, { isArchived: archive });
}
function handleDelete(id: string) {
deleteConfirmId = id;
}
async function confirmDelete() {
if (!deleteConfirmId) return;
await clientTable.delete(deleteConfirmId);
editingClientId = null;
deleteConfirmId = null;
}
// Edit state
let editName = $state('');
let editShortCode = $state('');
let editEmail = $state('');
let editColor = $state('');
let editRate = $state(0);
function startEditing(client: Client) {
editingClientId = client.id;
editName = client.name;
editShortCode = client.shortCode ?? '';
editEmail = client.email ?? '';
editColor = client.color;
editRate = client.billingRate?.amount ?? 0;
}
let editDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSave(updates: Record<string, unknown>) {
if (!editingClientId) return;
if (editDebounce) clearTimeout(editDebounce);
const id = editingClientId;
editDebounce = setTimeout(() => {
clientTable.update(id, updates);
}, 500);
}
</script>
<svelte:head>
<title>{$_('nav.clients')} | Times</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.clients')}</h1>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
+ {$_('client.create')}
</button>
</div>
{#if showCreateForm}
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<div class="flex gap-2">
<input
type="text"
bind:value={newName}
placeholder={$_('client.name')}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<input
type="text"
bind:value={newShortCode}
placeholder={$_('client.shortCode')}
class="w-24 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-[hsl(var(--foreground))]"
/>
</div>
<div class="flex gap-2">
<input
type="email"
bind:value={newEmail}
placeholder={$_('client.email')}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))]"
/>
<div class="flex items-center gap-1">
<input
type="number"
bind:value={newRate}
min="0"
step="5"
placeholder="0"
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2.5 text-sm text-center text-[hsl(var(--foreground))]"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => (newColor = color)}
class="h-6 w-6 rounded-full border-2 transition-transform {newColor === color
? 'scale-125 border-white'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
>{$_('common.cancel')}</button
>
<button
type="submit"
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>{$_('common.create')}</button
>
</div>
</form>
{/if}
{#if activeClients.length === 0 && !showCreateForm}
<div
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
>
<p>{$_('client.noClients')}</p>
</div>
{:else}
<div class="space-y-2">
{#each activeClients as client (client.id)}
{@const projects = getClientProjects(client.id)}
{@const hours = getClientHours(client.id)}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] overflow-hidden"
>
{#if editingClientId === client.id}
<div class="p-4 space-y-3">
<div class="flex gap-2">
<input
type="text"
value={editName}
oninput={(e) => {
editName = (e.target as HTMLInputElement).value;
autoSave({ name: editName });
}}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
/>
<input
type="text"
value={editShortCode}
oninput={(e) => {
editShortCode = (e.target as HTMLInputElement).value;
autoSave({ shortCode: editShortCode || null });
}}
placeholder={$_('client.shortCode')}
class="w-24 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
/>
</div>
<div class="flex gap-2">
<input
type="email"
value={editEmail}
oninput={(e) => {
editEmail = (e.target as HTMLInputElement).value;
autoSave({ email: editEmail || null });
}}
placeholder={$_('client.email')}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
/>
<div class="flex items-center gap-1">
<input
type="number"
value={editRate}
min="0"
step="5"
oninput={(e) => {
editRate = parseInt((e.target as HTMLInputElement).value) || 0;
autoSave({
billingRate:
editRate > 0 ? { amount: editRate, currency: 'EUR', per: 'hour' } : null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-center"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => {
editColor = color;
autoSave({ color });
}}
class="h-5 w-5 rounded-full border-2 {editColor === color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex justify-end gap-2">
<button
onclick={() => handleArchive(client.id, true)}
class="text-xs text-[hsl(var(--muted-foreground))]">{$_('common.archive')}</button
>
<button onclick={() => handleDelete(client.id)} class="text-xs text-red-500"
>{$_('common.delete')}</button
>
<button
onclick={() => (editingClientId = null)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.close')}</button
>
</div>
</div>
{:else}
<button
class="flex w-full items-center gap-3 p-4 text-left"
onclick={() => startEditing(client)}
>
<div
class="h-10 w-10 shrink-0 rounded-lg flex items-center justify-center text-white text-sm font-bold"
style="background-color: {client.color}"
>
{client.shortCode || client.name.charAt(0).toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-[hsl(var(--foreground))]">{client.name}</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{projects.length}
{$_('nav.projects')}
{#if client.billingRate}
· {client.billingRate.amount}
{client.billingRate.currency}/{$_('common.hours').toLowerCase().charAt(0)}
{/if}
</p>
</div>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(hours)}
</span>
</button>
{/if}
</div>
{/each}
</div>
{/if}
{#if archivedClients.length > 0}
<div>
<button
onclick={() => (showArchived = !showArchived)}
class="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]"
>
<CaretRight size={20} class="transition-transform {showArchived ? 'rotate-90' : ''}" />
{$_('project.archived')} ({archivedClients.length})
</button>
{#if showArchived}
<div class="mt-3 space-y-2">
{#each archivedClients as client}
<div
class="flex items-center justify-between rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 opacity-60"
>
<div class="flex items-center gap-2">
<div
class="h-6 w-6 rounded flex items-center justify-center text-white text-xs font-bold"
style="background-color: {client.color}"
>
{client.shortCode || client.name.charAt(0)}
</div>
<span class="text-sm">{client.name}</span>
</div>
<button
onclick={() => handleArchive(client.id, false)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.unarchive')}</button
>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<ConfirmationModal
visible={deleteConfirmId !== null}
title={$_('common.delete')}
message={$_('client.deleteConfirm')}
onConfirm={confirmDelete}
onClose={() => (deleteConfirmId = null)}
/>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import { page } from '$app/stores';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
} from '$lib/modules/times/queries';
import { CaretLeft } from '@manacore/shared-icons';
import EntryList from '$lib/modules/times/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '$lib/modules/times/types';
const allClients = getContext<{ value: Client[] }>('clients');
const allProjects = getContext<{ value: Project[] }>('projects');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let clientId = $derived($page.params.id);
let client = $derived(allClients.value.find((c) => c.id === clientId));
let clientProjects = $derived(
allProjects.value.filter((p) => p.clientId === clientId).sort((a, b) => a.order - b.order)
);
let clientEntries = $derived(
allTimeEntries.value
.filter((e) => e.clientId === clientId && !e.isRunning)
.sort((a, b) => b.date.localeCompare(a.date))
);
let totalDuration = $derived(getTotalDuration(clientEntries));
let billableDuration = $derived(getBillableDuration(clientEntries));
function getProjectHours(projectId: string): number {
return getTotalDuration(clientEntries.filter((e) => e.projectId === projectId));
}
let billingValue = $derived(() => {
if (!client?.billingRate) return null;
return (billableDuration / 3600) * client.billingRate.amount;
});
</script>
<svelte:head>
<title>{client?.name || 'Kunde'} | Times</title>
</svelte:head>
{#if !client}
<div class="flex flex-col items-center justify-center py-20">
<p class="text-[hsl(var(--muted-foreground))]">Kunde nicht gefunden.</p>
<a href="/times/clients" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a>
</div>
{:else}
<div class="space-y-6">
<!-- Back + Header -->
<div>
<a
href="/times/clients"
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<CaretLeft size={16} />
{$_('nav.clients')}
</a>
<div class="flex items-center gap-4">
<div
class="flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white"
style="background-color: {client.color}"
>
{client.shortCode || client.name.charAt(0).toUpperCase()}
</div>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{client.name}</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{#if client.shortCode}{client.shortCode} ·
{/if}
{#if client.email}{client.email} ·
{/if}
{#if client.billingRate}
{client.billingRate.amount} {client.billingRate.currency}/h
{/if}
</p>
</div>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.projects')}</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{clientProjects.length}</p>
</div>
{#if billingValue() !== null}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">Wert</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{billingValue()!.toFixed(0)}
{client.billingRate!.currency}
</p>
</div>
{/if}
</div>
<!-- Projects -->
{#if clientProjects.length > 0}
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.projects')}
</h2>
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each clientProjects as proj}
{@const hours = getProjectHours(proj.id)}
<a
href="/times/projects/{proj.id}"
class="entry-item flex items-center gap-3 rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4"
>
<div class="h-3 w-3 rounded-full" style="background-color: {proj.color}"></div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-[hsl(var(--foreground))]">{proj.name}</p>
{#if proj.isBillable}
<span class="text-xs text-[hsl(var(--primary))]">{$_('project.billable')}</span>
{/if}
</div>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(hours)}
</span>
</a>
{/each}
</div>
</div>
{/if}
<!-- Entries -->
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
</h2>
<EntryList entries={clientEntries} />
</div>
</div>
{/if}

View file

@ -0,0 +1,113 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry } from '$lib/modules/times/types';
import {
getFilteredEntries,
getSortedEntries,
getTotalDuration,
getBillableDuration,
formatDurationCompact,
} from '$lib/modules/times/queries';
import { viewStore } from '$lib/modules/times/stores/view.svelte';
import EntryList from '$lib/modules/times/components/EntryList.svelte';
import EntryForm from '$lib/modules/times/components/EntryForm.svelte';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let showEntryForm = $state(false);
let dateFilter = $state<'week' | 'month' | 'all'>('week');
let dateRange = $derived(() => {
const now = new Date();
const today = now.toISOString().split('T')[0];
if (dateFilter === 'week') {
const weekAgo = new Date(now.getTime() - 7 * 86400000);
return { from: weekAgo.toISOString().split('T')[0], to: today };
}
if (dateFilter === 'month') {
const monthAgo = new Date(now.getTime() - 30 * 86400000);
return { from: monthAgo.toISOString().split('T')[0], to: today };
}
return { from: '', to: '' };
});
let filteredEntries = $derived(() => {
const range = dateRange();
let entries = allTimeEntries.value.filter((e) => !e.isRunning);
// Apply date range
if (range.from) {
entries = entries.filter((e) => e.date >= range.from);
}
if (range.to) {
entries = entries.filter((e) => e.date <= range.to);
}
// Apply view filters
entries = getFilteredEntries(entries, viewStore.activeFilters);
return getSortedEntries(entries, viewStore.sort);
});
let totalDuration = $derived(getTotalDuration(filteredEntries()));
let billableDuration = $derived(getBillableDuration(filteredEntries()));
</script>
<svelte:head>
<title>{$_('nav.entries')} | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.entries')}</h1>
<button
onclick={() => (showEntryForm = true)}
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"
>
+ {$_('entry.manual')}
</button>
</div>
<!-- Filters -->
<div class="flex items-center gap-2">
{#each ['week', 'month', 'all'] as period}
<button
onclick={() => (dateFilter = period as any)}
class="rounded-lg px-3 py-1.5 text-sm transition-colors {dateFilter === period
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
>
{period === 'week'
? $_('entry.thisWeek')
: period === 'month'
? $_('entry.thisMonth')
: 'Alle'}
</button>
{/each}
<!-- Totals -->
<div class="ml-auto flex items-center gap-4 text-sm">
<span class="text-[hsl(var(--muted-foreground))]">
{$_('common.total')}:
<span class="duration-display font-medium text-[hsl(var(--foreground))]"
>{formatDurationCompact(totalDuration)}</span
>
</span>
<span class="text-[hsl(var(--muted-foreground))]">
{$_('entry.billable')}:
<span class="duration-display font-medium text-[hsl(var(--primary))]"
>{formatDurationCompact(billableDuration)}</span
>
</span>
</div>
</div>
<!-- Entry List -->
<EntryList entries={filteredEntries()} />
</div>
<!-- Manual Entry Form -->
<EntryForm visible={showEntryForm} onClose={() => (showEntryForm = false)} />

View file

@ -0,0 +1,366 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { projectTable } from '$lib/modules/times/collections';
import { getTotalDuration, formatDurationCompact } from '$lib/modules/times/queries';
import type { Project, Client, TimeEntry } from '$lib/modules/times/types';
import { PROJECT_COLORS } from '$lib/modules/times/types';
import { ConfirmationModal } from '@manacore/shared-ui';
import { CaretRight } from '@manacore/shared-icons';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let showCreateForm = $state(false);
let editingProjectId = $state<string | null>(null);
let showArchived = $state(false);
let deleteConfirmId = $state<string | null>(null);
// New project form
let newName = $state('');
let newClientId = $state('');
let newColor = $state(PROJECT_COLORS[0]);
let newIsBillable = $state(true);
let newDescription = $state('');
let activeProjects = $derived(
allProjects.value.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order)
);
let archivedProjects = $derived(
allProjects.value.filter((p) => p.isArchived).sort((a, b) => a.order - b.order)
);
function getProjectHours(projectId: string): number {
const entries = allTimeEntries.value.filter((e) => e.projectId === projectId && !e.isRunning);
return getTotalDuration(entries);
}
function getBudgetPercent(project: Project): number | null {
if (!project.budget || project.budget.type !== 'hours') return null;
const hoursLogged = getProjectHours(project.id) / 3600;
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
}
async function handleCreate() {
if (!newName.trim()) return;
const client = newClientId ? allClients.value.find((c) => c.id === newClientId) : null;
await projectTable.add({
id: crypto.randomUUID(),
clientId: newClientId || null,
name: newName.trim(),
description: newDescription || null,
color: newColor,
isArchived: false,
isBillable: newIsBillable,
billingRate: client?.billingRate ?? null,
budget: null,
visibility: 'private',
guildId: null,
order: activeProjects.length,
});
newName = '';
newClientId = '';
newDescription = '';
newColor = PROJECT_COLORS[0];
newIsBillable = true;
showCreateForm = false;
}
async function handleArchive(id: string, archive: boolean) {
await projectTable.update(id, { isArchived: archive });
}
function handleDelete(id: string) {
deleteConfirmId = id;
}
async function confirmDelete() {
if (!deleteConfirmId) return;
await projectTable.delete(deleteConfirmId);
editingProjectId = null;
deleteConfirmId = null;
}
// Inline edit state
let editName = $state('');
let editClientId = $state('');
let editColor = $state('');
let editIsBillable = $state(false);
let editDescription = $state('');
function startEditing(project: Project) {
editingProjectId = project.id;
editName = project.name;
editClientId = project.clientId ?? '';
editColor = project.color;
editIsBillable = project.isBillable;
editDescription = project.description ?? '';
}
let editDebounce: ReturnType<typeof setTimeout> | null = null;
function autoSaveProject(updates: Record<string, unknown>) {
if (!editingProjectId) return;
if (editDebounce) clearTimeout(editDebounce);
const id = editingProjectId;
editDebounce = setTimeout(() => {
projectTable.update(id, updates);
}, 500);
}
</script>
<svelte:head>
<title>{$_('nav.projects')} | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.projects')}</h1>
<button
onclick={() => (showCreateForm = !showCreateForm)}
class="rounded-lg bg-[hsl(var(--primary))] px-4 py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>
+ {$_('project.create')}
</button>
</div>
<!-- Create Form -->
{#if showCreateForm}
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
bind:value={newName}
placeholder={$_('project.name')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-4 py-2.5 text-sm text-[hsl(var(--foreground))] focus:border-[hsl(var(--primary))] focus:outline-none"
/>
<div class="flex gap-2">
<select
bind:value={newClientId}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
>
<option value="">{$_('project.internal')}</option>
{#each allClients.value.filter((c) => !c.isArchived) as client}
<option value={client.id}>{client.name}</option>
{/each}
</select>
<label
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
>
<input
type="checkbox"
bind:checked={newIsBillable}
class="accent-[hsl(var(--primary))]"
/>
{$_('project.billable')}
</label>
</div>
<!-- Color Picker -->
<div class="flex flex-wrap gap-2">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => (newColor = color)}
class="h-6 w-6 rounded-full border-2 transition-transform {newColor === color
? 'scale-125 border-white'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => (showCreateForm = false)}
class="flex-1 rounded-lg border border-[hsl(var(--border))] py-2 text-sm text-[hsl(var(--muted-foreground))]"
>{$_('common.cancel')}</button
>
<button
type="submit"
class="flex-1 rounded-lg bg-[hsl(var(--primary))] py-2 text-sm font-medium text-[hsl(var(--primary-foreground))]"
>{$_('common.create')}</button
>
</div>
</form>
{/if}
<!-- Active Projects -->
{#if activeProjects.length === 0 && !showCreateForm}
<div
class="rounded-xl border border-dashed border-[hsl(var(--border))] p-8 text-center text-[hsl(var(--muted-foreground))]"
>
<p>{$_('project.noProjects')}</p>
</div>
{:else}
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{#each activeProjects as project (project.id)}
{@const client = project.clientId
? allClients.value.find((c) => c.id === project.clientId)
: undefined}
{@const hours = getProjectHours(project.id)}
{@const budgetPct = getBudgetPercent(project)}
<div
class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] overflow-hidden"
>
<!-- Color bar -->
<div class="h-1" style="background-color: {project.color}"></div>
{#if editingProjectId === project.id}
<!-- Editing -->
<div class="p-4 space-y-3">
<input
type="text"
value={editName}
oninput={(e) => {
editName = (e.target as HTMLInputElement).value;
autoSaveProject({ name: editName });
}}
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"
/>
<select
value={editClientId}
onchange={(e) => {
editClientId = (e.target as HTMLSelectElement).value;
autoSaveProject({ clientId: editClientId || null });
}}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
>
<option value="">{$_('project.internal')}</option>
{#each allClients.value.filter((c) => !c.isArchived) as c}
<option value={c.id}>{c.name}</option>
{/each}
</select>
<div class="flex flex-wrap gap-1.5">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => {
editColor = color;
autoSaveProject({ color });
}}
class="h-5 w-5 rounded-full border-2 {editColor === color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
<div class="flex items-center justify-between">
<label class="flex items-center gap-2 text-xs">
<input
type="checkbox"
checked={editIsBillable}
onchange={() => {
editIsBillable = !editIsBillable;
autoSaveProject({ isBillable: editIsBillable });
}}
class="accent-[hsl(var(--primary))]"
/>
{$_('project.billable')}
</label>
<div class="flex gap-2">
<button
onclick={() => handleArchive(project.id, true)}
class="text-xs text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>{$_('common.archive')}</button
>
<button onclick={() => handleDelete(project.id)} class="text-xs text-red-500"
>{$_('common.delete')}</button
>
<button
onclick={() => (editingProjectId = null)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.close')}</button
>
</div>
</div>
</div>
{:else}
<!-- Display -->
<button class="w-full p-4 text-left" onclick={() => startEditing(project)}>
<div class="flex items-start justify-between">
<div>
<p class="font-medium text-[hsl(var(--foreground))]">{project.name}</p>
<p class="text-xs text-[hsl(var(--muted-foreground))]">
{client?.name || $_('project.internal')}
{#if project.isBillable}
· {$_('project.billable')}
{/if}
</p>
</div>
<span class="duration-display text-sm font-medium text-[hsl(var(--foreground))]">
{formatDurationCompact(hours)}
</span>
</div>
{#if budgetPct !== null}
<div class="mt-3">
<div
class="flex items-center justify-between text-xs text-[hsl(var(--muted-foreground))]"
>
<span>{$_('project.budget')}</span>
<span>{budgetPct}%</span>
</div>
<div class="mt-1 h-1.5 rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {budgetPct > 90
? 'bg-red-500'
: budgetPct > 75
? 'bg-amber-500'
: 'bg-[hsl(var(--primary))]'}"
style="width: {budgetPct}%"
></div>
</div>
</div>
{/if}
</button>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Archived Projects -->
{#if archivedProjects.length > 0}
<div>
<button
onclick={() => (showArchived = !showArchived)}
class="flex items-center gap-2 text-sm text-[hsl(var(--muted-foreground))]"
>
<CaretRight size={20} class="transition-transform {showArchived ? 'rotate-90' : ''}" />
{$_('project.archived')} ({archivedProjects.length})
</button>
{#if showArchived}
<div class="mt-3 space-y-2">
{#each archivedProjects as project}
<div
class="flex items-center justify-between rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card))] px-4 py-3 opacity-60"
>
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" style="background-color: {project.color}"></div>
<span class="text-sm text-[hsl(var(--foreground))]">{project.name}</span>
</div>
<button
onclick={() => handleArchive(project.id, false)}
class="text-xs text-[hsl(var(--primary))]">{$_('common.unarchive')}</button
>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<ConfirmationModal
visible={deleteConfirmId !== null}
title={$_('common.delete')}
message={$_('project.deleteConfirm')}
onConfirm={confirmDelete}
onClose={() => (deleteConfirmId = null)}
/>

View file

@ -0,0 +1,324 @@
<script lang="ts">
import { page } from '$app/stores';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { projectTable } from '$lib/modules/times/collections';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
} from '$lib/modules/times/queries';
import { CaretLeft } from '@manacore/shared-icons';
import EntryList from '$lib/modules/times/components/EntryList.svelte';
import type { Project, Client, TimeEntry } from '$lib/modules/times/types';
import { PROJECT_COLORS } from '$lib/modules/times/types';
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
let projectId = $derived($page.params.id);
let project = $derived(allProjects.value.find((p) => p.id === projectId));
let client = $derived(
project?.clientId ? allClients.value.find((c) => c.id === project!.clientId) : undefined
);
let projectEntries = $derived(
allTimeEntries.value
.filter((e) => e.projectId === projectId && !e.isRunning)
.sort((a, b) => b.date.localeCompare(a.date))
);
let totalDuration = $derived(getTotalDuration(projectEntries));
let billableDuration = $derived(getBillableDuration(projectEntries));
let budgetPercent = $derived(() => {
if (!project?.budget || project.budget.type !== 'hours') return null;
const hoursLogged = totalDuration / 3600;
return Math.min(100, Math.round((hoursLogged / project.budget.amount) * 100));
});
let budgetHoursUsed = $derived(totalDuration / 3600);
let budgetHoursTotal = $derived(project?.budget?.type === 'hours' ? project.budget.amount : null);
// Edit state
let isEditing = $state(false);
let editName = $state('');
let editDescription = $state('');
let editClientId = $state('');
let editColor = $state('');
let editIsBillable = $state(false);
let editBudgetHours = $state(0);
let editRateAmount = $state(0);
function startEditing() {
if (!project) return;
isEditing = true;
editName = project.name;
editDescription = project.description ?? '';
editClientId = project.clientId ?? '';
editColor = project.color;
editIsBillable = project.isBillable;
editBudgetHours = project.budget?.type === 'hours' ? project.budget.amount : 0;
editRateAmount = project.billingRate?.amount ?? 0;
}
let debounce: ReturnType<typeof setTimeout> | null = null;
function save(updates: Record<string, unknown>) {
const id = projectId;
if (!id) return;
if (debounce) clearTimeout(debounce);
debounce = setTimeout(() => {
projectTable.update(id, updates);
}, 500);
}
async function handleArchive() {
const id = projectId;
if (!project || !id) return;
await projectTable.update(id, { isArchived: !project.isArchived });
}
</script>
<svelte:head>
<title>{project?.name || 'Projekt'} | Times</title>
</svelte:head>
{#if !project}
<div class="flex flex-col items-center justify-center py-20">
<p class="text-[hsl(var(--muted-foreground))]">Projekt nicht gefunden.</p>
<a href="/times/projects" class="mt-4 text-sm text-[hsl(var(--primary))]">{$_('common.back')}</a
>
</div>
{:else}
<div class="space-y-6">
<!-- Back + Header -->
<div>
<a
href="/times/projects"
class="mb-3 inline-flex items-center gap-1 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
<CaretLeft size={16} />
{$_('nav.projects')}
</a>
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<div class="h-4 w-4 rounded-full" style="background-color: {project.color}"></div>
<div>
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{project.name}</h1>
<p class="text-sm text-[hsl(var(--muted-foreground))]">
{client?.name || $_('project.internal')}
{#if project.isBillable}
<span
class="ml-2 rounded bg-[hsl(var(--primary)/0.1)] px-1.5 py-0.5 text-xs text-[hsl(var(--primary))]"
>
{$_('project.billable')}
</span>
{/if}
{#if project.isArchived}
<span
class="ml-2 rounded bg-[hsl(var(--muted))] px-1.5 py-0.5 text-xs text-[hsl(var(--muted-foreground))]"
>
{$_('project.archived')}
</span>
{/if}
</p>
</div>
</div>
<div class="flex gap-2">
<button
onclick={() => (isEditing ? (isEditing = false) : startEditing())}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
{isEditing ? $_('common.close') : $_('common.edit')}
</button>
<button
onclick={handleArchive}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))]"
>
{project.isArchived ? $_('common.unarchive') : $_('common.archive')}
</button>
</div>
</div>
</div>
<!-- Edit Form -->
{#if isEditing}
<div
class="rounded-xl border border-[hsl(var(--primary)/0.3)] bg-[hsl(var(--card))] p-4 space-y-3"
>
<input
type="text"
value={editName}
oninput={(e) => {
editName = (e.target as HTMLInputElement).value;
save({ name: editName });
}}
placeholder={$_('project.name')}
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"
/>
<input
type="text"
value={editDescription}
oninput={(e) => {
editDescription = (e.target as HTMLInputElement).value;
save({ description: editDescription || null });
}}
placeholder={$_('project.description')}
class="w-full rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))]"
/>
<div class="flex gap-2">
<select
value={editClientId}
onchange={(e) => {
editClientId = (e.target as HTMLSelectElement).value;
save({ clientId: editClientId || null });
}}
class="flex-1 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm"
>
<option value="">{$_('project.internal')}</option>
{#each allClients.value.filter((c) => !c.isArchived) as c}
<option value={c.id}>{c.name}</option>
{/each}
</select>
<label
class="flex items-center gap-2 rounded-lg border border-[hsl(var(--border))] px-3 py-2 text-sm"
>
<input
type="checkbox"
checked={editIsBillable}
onchange={() => {
editIsBillable = !editIsBillable;
save({ isBillable: editIsBillable });
}}
class="accent-[hsl(var(--primary))]"
/>
{$_('project.billable')}
</label>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-1">
<label class="text-xs text-[hsl(var(--muted-foreground))]"
>{$_('project.budget')} (h):</label
>
<input
type="number"
value={editBudgetHours}
min="0"
oninput={(e) => {
editBudgetHours = parseInt((e.target as HTMLInputElement).value) || 0;
save({
budget: editBudgetHours > 0 ? { type: 'hours', amount: editBudgetHours } : null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
/>
</div>
<div class="flex items-center gap-1">
<label class="text-xs text-[hsl(var(--muted-foreground))]">Rate:</label>
<input
type="number"
value={editRateAmount}
min="0"
step="5"
oninput={(e) => {
editRateAmount = parseInt((e.target as HTMLInputElement).value) || 0;
save({
billingRate:
editRateAmount > 0
? { amount: editRateAmount, currency: 'EUR', per: 'hour' }
: null,
});
}}
class="w-20 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-center text-sm"
/>
<span class="text-xs text-[hsl(var(--muted-foreground))]">/h</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
{#each PROJECT_COLORS as color}
<button
type="button"
onclick={() => {
editColor = color;
save({ color });
}}
class="h-5 w-5 rounded-full border-2 {editColor === color
? 'border-white scale-110'
: 'border-transparent'}"
style="background-color: {color}"
></button>
{/each}
</div>
</div>
{/if}
<!-- Stats -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
{#if budgetHoursTotal}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</p>
<p class="duration-display mt-1 text-xl font-bold text-[hsl(var(--foreground))]">
{budgetHoursUsed.toFixed(1)} / {budgetHoursTotal}h
</p>
</div>
{/if}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.entries')}</p>
<p class="mt-1 text-xl font-bold text-[hsl(var(--foreground))]">{projectEntries.length}</p>
</div>
</div>
<!-- Budget Progress -->
{#if budgetPercent() !== null}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<div class="flex items-center justify-between text-sm">
<span class="text-[hsl(var(--muted-foreground))]">{$_('project.budget')}</span>
<span class="font-medium text-[hsl(var(--foreground))]">{budgetPercent()}%</span>
</div>
<div class="mt-2 h-2.5 rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all {budgetPercent()! > 90
? 'bg-red-500'
: budgetPercent()! > 75
? 'bg-amber-500'
: 'bg-[hsl(var(--primary))]'}"
style="width: {budgetPercent()}%"
></div>
</div>
{#if project.billingRate}
<p class="mt-2 text-xs text-[hsl(var(--muted-foreground))]">
{project.billingRate.amount}
{project.billingRate.currency}/h · Wert: {(
(billableDuration / 3600) *
project.billingRate.amount
).toFixed(0)}
{project.billingRate.currency}
</p>
{/if}
</div>
{/if}
<!-- Entries -->
<div>
<h2 class="mb-3 text-sm font-medium text-[hsl(var(--muted-foreground))]">
{$_('nav.entries')} ({formatDurationCompact(totalDuration)})
</h2>
<EntryList entries={projectEntries} />
</div>
</div>
{/if}

View file

@ -0,0 +1,213 @@
<script lang="ts">
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import type { TimeEntry, Project, Client } from '$lib/modules/times/types';
import { exportEntriesToCSV } from '$lib/modules/times/utils/export';
import {
getTotalDuration,
getBillableDuration,
formatDurationCompact,
formatDurationDecimal,
groupEntriesByProject,
} from '$lib/modules/times/queries';
const allTimeEntries = getContext<{ value: TimeEntry[] }>('timeEntries');
const allProjects = getContext<{ value: Project[] }>('projects');
const allClients = getContext<{ value: Client[] }>('clients');
let period = $state<'week' | 'month'>('week');
let dateRange = $derived(() => {
const now = new Date();
const today = now.toISOString().split('T')[0];
const daysBack = period === 'week' ? 7 : 30;
const from = new Date(now.getTime() - daysBack * 86400000).toISOString().split('T')[0];
return { from, to: today, days: daysBack };
});
let entries = $derived(() => {
const range = dateRange();
return allTimeEntries.value.filter(
(e) => !e.isRunning && e.date >= range.from && e.date <= range.to
);
});
let totalDuration = $derived(getTotalDuration(entries()));
let billableDuration = $derived(getBillableDuration(entries()));
let nonBillableDuration = $derived(totalDuration - billableDuration);
let entryCount = $derived(entries().length);
let avgPerDay = $derived(dateRange().days > 0 ? totalDuration / dateRange().days : 0);
// Hours by project
let projectBreakdown = $derived(() => {
const groups = groupEntriesByProject(entries());
const result: { projectId: string; name: string; color: string; duration: number }[] = [];
for (const [projectId, projectEntries] of groups) {
const project = allProjects.value.find((p) => p.id === projectId);
result.push({
projectId,
name: project?.name || $_('project.internal'),
color: project?.color || '#9ca3af',
duration: getTotalDuration(projectEntries),
});
}
return result.sort((a, b) => b.duration - a.duration);
});
let maxProjectDuration = $derived(
Math.max(...(projectBreakdown().map((p) => p.duration) || [1]))
);
// Hours by day
let dailyBreakdown = $derived(() => {
const days: { date: string; label: string; duration: number }[] = [];
const numDays = period === 'week' ? 7 : 30;
for (let i = numDays - 1; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400000);
const dateStr = d.toISOString().split('T')[0];
const dayEntries = entries().filter((e) => e.date === dateStr);
days.push({
date: dateStr,
label: d.toLocaleDateString('de-DE', { weekday: 'short', day: 'numeric' }),
duration: getTotalDuration(dayEntries),
});
}
return days;
});
let maxDailyDuration = $derived(Math.max(...(dailyBreakdown().map((d) => d.duration) || [1])));
</script>
<svelte:head>
<title>{$_('nav.reports')} | Times</title>
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[hsl(var(--foreground))]">{$_('nav.reports')}</h1>
<div class="flex items-center gap-2">
<div class="flex gap-1">
{#each ['week', 'month'] as p}
<button
onclick={() => (period = p as any)}
class="rounded-lg px-3 py-1.5 text-sm transition-colors {period === p
? 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))]'
: 'text-[hsl(var(--muted-foreground))] hover:bg-[hsl(var(--accent)/0.1)]'}"
>
{p === 'week' ? $_('entry.thisWeek') : $_('entry.thisMonth')}
</button>
{/each}
</div>
<button
onclick={() => exportEntriesToCSV(entries(), allProjects.value, allClients.value)}
class="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))] hover:text-[hsl(var(--foreground))]"
>
CSV Export
</button>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.totalHours')}</p>
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">
{formatDurationDecimal(totalDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.billableHours')}</p>
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--primary))]">
{formatDurationDecimal(billableDuration)}h
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('report.avgPerDay')}</p>
<p class="duration-display mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">
{formatDurationCompact(Math.round(avgPerDay))}
</p>
</div>
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<p class="text-xs text-[hsl(var(--muted-foreground))]">{$_('nav.entries')}</p>
<p class="mt-1 text-2xl font-bold text-[hsl(var(--foreground))]">{entryCount}</p>
</div>
</div>
<!-- Billable Breakdown -->
{#if totalDuration > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
{$_('entry.billable')} vs. {$_('entry.notBillable')}
</h3>
<div class="flex h-4 overflow-hidden rounded-full">
<div
class="bg-[hsl(var(--primary))] transition-all"
style="width: {(billableDuration / totalDuration) * 100}%"
></div>
<div
class="bg-[hsl(var(--muted))] transition-all"
style="width: {(nonBillableDuration / totalDuration) * 100}%"
></div>
</div>
<div class="mt-2 flex justify-between text-xs text-[hsl(var(--muted-foreground))]">
<span>{$_('entry.billable')}: {formatDurationCompact(billableDuration)}</span>
<span>{$_('entry.notBillable')}: {formatDurationCompact(nonBillableDuration)}</span>
</div>
</div>
{/if}
<!-- Hours by Project -->
{#if projectBreakdown().length > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">
{$_('report.byProject')}
</h3>
<div class="space-y-3">
{#each projectBreakdown() as item}
<div>
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<div class="h-3 w-3 rounded-full" style="background-color: {item.color}"></div>
<span class="text-[hsl(var(--foreground))]">{item.name}</span>
</div>
<span class="duration-display text-[hsl(var(--muted-foreground))]">
{formatDurationCompact(item.duration)}
</span>
</div>
<div class="mt-1 h-2 rounded-full bg-[hsl(var(--muted))]">
<div
class="h-full rounded-full transition-all"
style="width: {(item.duration / maxProjectDuration) *
100}%; background-color: {item.color}"
></div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Daily Hours -->
{#if period === 'week' && dailyBreakdown().length > 0}
<div class="rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4">
<h3 class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">{$_('report.byDay')}</h3>
<div class="flex items-end gap-2" style="height: 120px;">
{#each dailyBreakdown() as day}
<div class="flex flex-1 flex-col items-center gap-1">
<div class="w-full flex flex-col justify-end" style="height: 100px;">
<div
class="w-full rounded-t bg-[hsl(var(--primary))] transition-all"
style="height: {maxDailyDuration > 0
? (day.duration / maxDailyDuration) * 100
: 0}%"
title={formatDurationCompact(day.duration)}
></div>
</div>
<span class="text-[10px] text-[hsl(var(--muted-foreground))]">{day.label}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>

Some files were not shown because too many files have changed in this diff Show more