mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
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:
parent
6d51d3eefb
commit
e7999fb7cf
101 changed files with 14322 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
25
apps/manacore/apps/web/src/lib/modules/citycorners/index.ts
Normal file
25
apps/manacore/apps/web/src/lib/modules/citycorners/index.ts
Normal 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';
|
||||
169
apps/manacore/apps/web/src/lib/modules/citycorners/queries.ts
Normal file
169
apps/manacore/apps/web/src/lib/modules/citycorners/queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
72
apps/manacore/apps/web/src/lib/modules/citycorners/types.ts
Normal file
72
apps/manacore/apps/web/src/lib/modules/citycorners/types.ts
Normal 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',
|
||||
};
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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">✓</span>
|
||||
{:else}
|
||||
<span class="text-[hsl(var(--muted-foreground))]">✗</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}
|
||||
|
|
@ -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"
|
||||
>▲</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"
|
||||
>▼</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>
|
||||
245
apps/manacore/apps/web/src/lib/modules/inventar/constants.ts
Normal file
245
apps/manacore/apps/web/src/lib/modules/inventar/constants.ts
Normal 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: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
54
apps/manacore/apps/web/src/lib/modules/inventar/index.ts
Normal file
54
apps/manacore/apps/web/src/lib/modules/inventar/index.ts
Normal 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';
|
||||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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 });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
46
apps/manacore/apps/web/src/lib/modules/photos/collections.ts
Normal file
46
apps/manacore/apps/web/src/lib/modules/photos/collections.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
46
apps/manacore/apps/web/src/lib/modules/photos/index.ts
Normal file
46
apps/manacore/apps/web/src/lib/modules/photos/index.ts
Normal 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';
|
||||
101
apps/manacore/apps/web/src/lib/modules/photos/queries.ts
Normal file
101
apps/manacore/apps/web/src/lib/modules/photos/queries.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
99
apps/manacore/apps/web/src/lib/modules/photos/types.ts
Normal file
99
apps/manacore/apps/web/src/lib/modules/photos/types.ts
Normal 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;
|
||||
}
|
||||
77
apps/manacore/apps/web/src/lib/modules/planta/collections.ts
Normal file
77
apps/manacore/apps/web/src/lib/modules/planta/collections.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
64
apps/manacore/apps/web/src/lib/modules/planta/index.ts
Normal file
64
apps/manacore/apps/web/src/lib/modules/planta/index.ts
Normal 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';
|
||||
164
apps/manacore/apps/web/src/lib/modules/planta/mutations.ts
Normal file
164
apps/manacore/apps/web/src/lib/modules/planta/mutations.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
180
apps/manacore/apps/web/src/lib/modules/planta/queries.ts
Normal file
180
apps/manacore/apps/web/src/lib/modules/planta/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
140
apps/manacore/apps/web/src/lib/modules/planta/types.ts
Normal file
140
apps/manacore/apps/web/src/lib/modules/planta/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ');
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
195
apps/manacore/apps/web/src/lib/modules/times/collections.ts
Normal file
195
apps/manacore/apps/web/src/lib/modules/times/collections.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
70
apps/manacore/apps/web/src/lib/modules/times/index.ts
Normal file
70
apps/manacore/apps/web/src/lib/modules/times/index.ts
Normal 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';
|
||||
328
apps/manacore/apps/web/src/lib/modules/times/queries.ts
Normal file
328
apps/manacore/apps/web/src/lib/modules/times/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
246
apps/manacore/apps/web/src/lib/modules/times/types.ts
Normal file
246
apps/manacore/apps/web/src/lib/modules/times/types.ts
Normal 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',
|
||||
];
|
||||
|
|
@ -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(' · ');
|
||||
}
|
||||
61
apps/manacore/apps/web/src/lib/modules/times/utils/export.ts
Normal file
61
apps/manacore/apps/web/src/lib/modules/times/utils/export.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
169
apps/manacore/apps/web/src/routes/(app)/citycorners/+page.svelte
Normal file
169
apps/manacore/apps/web/src/routes/(app)/citycorners/+page.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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')} →</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: '© <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>
|
||||
|
|
@ -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">💙</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">📍</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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(() => {
|
||||
goto('/citycorners', { replaceState: true });
|
||||
});
|
||||
</script>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()}
|
||||
133
apps/manacore/apps/web/src/routes/(app)/inventar/+page.svelte
Normal file
133
apps/manacore/apps/web/src/routes/(app)/inventar/+page.svelte
Normal 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 · {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>
|
||||
|
|
@ -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"
|
||||
>✎</button
|
||||
>
|
||||
<button
|
||||
onclick={() => deleteCategory(category.id)}
|
||||
class="text-xs text-[hsl(var(--muted-foreground))] opacity-0 hover:text-red-500 group-hover:opacity-100"
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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))]">×{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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
>×{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"
|
||||
>×</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}
|
||||
|
|
@ -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))]"
|
||||
>✎</button
|
||||
>
|
||||
<button
|
||||
onclick={() => deleteLocation(location.id)}
|
||||
class="rounded p-1 text-xs text-[hsl(var(--muted-foreground))] hover:text-red-500"
|
||||
>×</button
|
||||
>
|
||||
</div>
|
||||
{#if location.children?.length}
|
||||
{@render renderTree(location.children, depth + 1)}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/snippet}
|
||||
{@render renderTree(tree, 0)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()}
|
||||
118
apps/manacore/apps/web/src/routes/(app)/photos/+page.svelte
Normal file
118
apps/manacore/apps/web/src/routes/(app)/photos/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
135
apps/manacore/apps/web/src/routes/(app)/planta/+page.svelte
Normal file
135
apps/manacore/apps/web/src/routes/(app)/planta/+page.svelte
Normal 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>
|
||||
221
apps/manacore/apps/web/src/routes/(app)/planta/[id]/+page.svelte
Normal file
221
apps/manacore/apps/web/src/routes/(app)/planta/[id]/+page.svelte
Normal 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}
|
||||
103
apps/manacore/apps/web/src/routes/(app)/planta/add/+page.svelte
Normal file
103
apps/manacore/apps/web/src/routes/(app)/planta/add/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
403
apps/manacore/apps/web/src/routes/(app)/skilltree/+page.svelte
Normal file
403
apps/manacore/apps/web/src/routes/(app)/skilltree/+page.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
77
apps/manacore/apps/web/src/routes/(app)/times/+page.svelte
Normal file
77
apps/manacore/apps/web/src/routes/(app)/times/+page.svelte
Normal 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)} />
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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)} />
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue