diff --git a/frontend/src/lib/stores/trailComments.svelte.ts b/frontend/src/lib/stores/trailComments.svelte.ts new file mode 100644 index 0000000..1907154 --- /dev/null +++ b/frontend/src/lib/stores/trailComments.svelte.ts @@ -0,0 +1,104 @@ +import { getContext, setContext } from 'svelte' +import { api, auth } from './pocketbase.svelte' +import { getTeamContext } from './teams.svelte' +import { isRecent } from '$lib/time' +import type { TrailCommentsResponse, UsersResponse } from '$lib/types' + +const KEY = Symbol('trailComment') + +/** Kommentar samt Verfasser, sofern dessen Datensatz sichtbar ist. */ +export type TrailComment = Omit & { + expand?: { created_by?: UsersResponse } +} + +/** + * Kommentare lagen bisher direkt in der Trail-Seite — als es sie nur dort + * gab, war das die einfachere Lösung. Die Trail-Liste zeigt jetzt ebenfalls + * ihre Anzahl, und zwei Ladewege auf dieselben Daten wären einer zu viel. + */ +export class TrailCommentStore { + records = $state([]) + loading = $state(false) + error = $state(null) + + private unsubscribe: (() => void) | null = null + private teams = getTeamContext() + + get scoped(): TrailComment[] { + const teamId = this.teams.activeId + if (!teamId) return [] + return this.records.filter((r) => r.team === teamId) + } + + /** Kommentare eines Trails, jüngste zuerst */ + byTrail(trailId: string): TrailComment[] { + return this.scoped + .filter((r) => r.trail === trailId) + .sort((a, b) => (a.created < b.created ? 1 : -1)) + } + + /** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */ + hasRecent(trailId: string, days?: number): boolean { + return this.byTrail(trailId).some((r) => isRecent(r.created, days)) + } + + async load() { + this.loading = true + this.error = null + try { + this.records = (await api.collection('trail_comments').getFullList({ + sort: '-created', + expand: 'created_by', + requestKey: null, + })) as TrailComment[] + } catch (e: any) { + this.error = e.message ?? 'Fehler beim Laden der Kommentare' + console.error(e) + } finally { + this.loading = false + } + } + + subscribe() { + if (this.unsubscribe) return + api.collection('trail_comments').subscribe('*', (e) => { + const record = e.record as TrailComment + const idx = this.records.findIndex((r) => r.id === record.id) + if (e.action === 'create' && idx === -1) this.records = [record, ...this.records] + else if (e.action === 'update' && idx !== -1) this.records[idx] = record + else if (e.action === 'delete' && idx !== -1) this.records = this.records.filter((r) => r.id !== record.id) + }, { expand: 'created_by' }).then((unsub) => { + this.unsubscribe = unsub + }).catch((e) => console.warn('trail_comments subscribe failed:', e)) + } + + async create(trailId: string, teamId: string, text: string) { + return await api.collection('trail_comments').create({ + trail: trailId, + team: teamId, + text: text.trim(), + created_by: auth.user?.id, + }) + } + + async remove(id: string) { + await api.collection('trail_comments').delete(id) + } + + destroy() { + if (this.unsubscribe) { + this.unsubscribe() + this.unsubscribe = null + } + } +} + +export function setTrailCommentContext() { + const store = new TrailCommentStore() + setContext(KEY, store) + return store +} + +export function getTrailCommentContext(): TrailCommentStore { + return getContext(KEY) +} diff --git a/frontend/src/lib/stores/trailMarkers.svelte.ts b/frontend/src/lib/stores/trailMarkers.svelte.ts index 7b9a112..c546297 100644 --- a/frontend/src/lib/stores/trailMarkers.svelte.ts +++ b/frontend/src/lib/stores/trailMarkers.svelte.ts @@ -1,6 +1,7 @@ import { getContext, setContext } from 'svelte' import { api, auth } from './pocketbase.svelte' import { getTeamContext } from './teams.svelte' +import { isRecent } from '$lib/time' import type { TrailMarkersResponse, UsersResponse } from '$lib/types' const KEY = Symbol('trailMarker') @@ -28,6 +29,11 @@ export class TrailMarkerStore { .sort((a, b) => (a.created < b.created ? 1 : -1)) } + /** Gab es zuletzt Bewegung? Für die Hervorhebung in der Liste. */ + hasRecent(trailId: string, days?: number): boolean { + return this.byTrail(trailId).some((r) => isRecent(r.created, days)) + } + /** Nur die offenen — was auf der Karte auffallen soll */ openByTrail(trailId: string): TrailMarker[] { return this.byTrail(trailId).filter((r) => !r.resolved) diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts index eacd705..df035cc 100644 --- a/frontend/src/lib/time.ts +++ b/frontend/src/lib/time.ts @@ -70,3 +70,19 @@ export function formatDateRange(starts: string | undefined, ends: string | undef return `${from} – ${to}` } + +/** + * Standardfenster für „neu" in Übersichten. Zwei Wochen, weil ein Trail + * selten öfter befahren wird — kürzer wäre die Hervorhebung meist leer. + */ +export const RECENT_DAYS = 14 + +/** Liegt der Zeitpunkt innerhalb der letzten `days` Tage? */ +export function isRecent(value: string | undefined, days = RECENT_DAYS): boolean { + if (!value) return false + + const then = new Date(value).getTime() + if (!Number.isFinite(then)) return false + + return Date.now() - then <= days * 24 * 60 * 60 * 1000 +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 848c16e..0e3adb4 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -11,6 +11,7 @@ import { setTrailVersionContext } from '$lib/stores/trailVersions.svelte' import { setTrailFlagContext } from '$lib/stores/trailFlags.svelte' import { setTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' + import { setTrailCommentContext } from '$lib/stores/trailComments.svelte' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import Logo from '$lib/components/Logo.svelte' @@ -34,6 +35,7 @@ const trailVersions = setTrailVersionContext() const trailFlags = setTrailFlagContext() const trailMarkers = setTrailMarkerContext() + const trailComments = setTrailCommentContext() let isMobileMenuOpen = $state(false) @@ -48,6 +50,7 @@ await Promise.all([ events.load(), runs.load(), riders.load(), times.load(), trails.load(), trailVersions.load(), trailFlags.load(), trailMarkers.load(), + trailComments.load(), ]) events.subscribe() runs.subscribe() @@ -57,6 +60,7 @@ trailVersions.subscribe() trailFlags.subscribe() trailMarkers.subscribe() + trailComments.subscribe() }) onDestroy(() => { @@ -69,6 +73,7 @@ trailVersions.destroy() trailFlags.destroy() trailMarkers.destroy() + trailComments.destroy() }) function handleLogout() { diff --git a/frontend/src/routes/dashboard/trails/+page.svelte b/frontend/src/routes/dashboard/trails/+page.svelte index 665cbe6..33ed96d 100644 --- a/frontend/src/routes/dashboard/trails/+page.svelte +++ b/frontend/src/routes/dashboard/trails/+page.svelte @@ -5,18 +5,23 @@ import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' import TrailMap from '$lib/components/TrailMap.svelte' import { trailStatus } from '$lib/trailStatus' + import { getTrailCommentContext } from '$lib/stores/trailComments.svelte' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import * as Dialog from '@/components/ui/dialog' - import { Plus, Route, AlertTriangle } from 'lucide-svelte' + import { + Plus, Route, AlertTriangle, Ruler, TrendingUp, MapPin, MessageSquare, + } from 'lucide-svelte' + import { RECENT_DAYS } from '$lib/time' import type { TrailVersionsResponse } from '$lib/types' const trails = getTrailContext() const versions = getTrailVersionContext() const markers = getTrailMarkerContext() + const comments = getTrailCommentContext() let dialog = $state(false) let form = $state({ name: '', description: '' }) @@ -109,6 +114,10 @@ {@const version = currentVersion(trail.id)} {@const open = markers.openByTrail(trail.id).length} {@const status = trailStatus(trail.status)} + {@const markerCount = markers.byTrail(trail.id).length} + {@const commentCount = comments.byTrail(trail.id).length} + {@const freshMarkers = markers.hasRecent(trail.id)} + {@const freshComments = comments.hasRecent(trail.id)} @@ -139,15 +148,60 @@ {/if}
- {formatKm(version?.distance_m)} - {version?.ascent_m ? `${version.ascent_m} hm` : '–'} + + + + {formatKm(version?.distance_m)} + + + + {version?.ascent_m ? `${version.ascent_m} hm` : '–'} + {#if open > 0} - + {open} {/if}
+ + +
+ + + {markerCount} + {#if freshMarkers} + + {/if} + + + + {commentCount} + {#if freshComments} + + {/if} + +
diff --git a/frontend/src/routes/dashboard/trails/[id]/+page.svelte b/frontend/src/routes/dashboard/trails/[id]/+page.svelte index 4846abb..817840c 100644 --- a/frontend/src/routes/dashboard/trails/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/trails/[id]/+page.svelte @@ -6,6 +6,7 @@ import { getTrailVersionContext } from '$lib/stores/trailVersions.svelte' import { getTrailFlagContext } from '$lib/stores/trailFlags.svelte' import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte' + import { getTrailCommentContext } from '$lib/stores/trailComments.svelte' import { getTeamContext } from '$lib/stores/teams.svelte' import TrailMap from '$lib/components/TrailMap.svelte' import ElevationProfile from '$lib/components/ElevationProfile.svelte' @@ -30,7 +31,7 @@ } from 'lucide-svelte' import DOMPurify from 'dompurify' import { browser } from '$app/environment' - import type { TrailCommentsResponse, TrailVersionsResponse, UsersResponse } from '$lib/types' + import type { TrailVersionsResponse } from '$lib/types' // Generics der JSON-Felder, wie in trails/+page.svelte — die // Rohdaten kommen ungetypt aus PocketBase zurück. @@ -47,6 +48,7 @@ const versions = getTrailVersionContext() const flags = getTrailFlagContext() const markers = getTrailMarkerContext() + const commentStore = getTrailCommentContext() const teams = getTeamContext() const trail = $derived(page.params.id ? trails.getById(page.params.id) : undefined) @@ -256,69 +258,17 @@ } // --- Kommentare ------------------------------------------------------ - // Eigener Store wäre Overkill: Kommentare werden nur hier gebraucht. - type Comment = TrailCommentsResponse<{ created_by?: UsersResponse }> + const comments = $derived(trail ? commentStore.byTrail(trail.id) : []) - let comments = $state([]) let commentText = $state('') let commentError = $state(null) - let unsubComments: (() => void) | null = null - - $effect(() => { - const id = trail?.id - if (!id) return - - let cancelled = false - - api.collection('trail_comments') - .getFullList({ - filter: `trail="${id}"`, - sort: '-created', - expand: 'created_by', - requestKey: null, - }) - .then((r) => { - if (!cancelled) comments = r as Comment[] - }) - .catch((e) => console.error('Kommentare laden fehlgeschlagen:', e)) - - api.collection('trail_comments') - .subscribe( - '*', - (e) => { - if (e.record.trail !== id) return - const record = e.record as Comment - const idx = comments.findIndex((c) => c.id === record.id) - if (e.action === 'create' && idx === -1) comments = [record, ...comments] - else if (e.action === 'update' && idx !== -1) comments[idx] = record - else if (e.action === 'delete' && idx !== -1) comments = comments.filter((c) => c.id !== record.id) - }, - { expand: 'created_by' }, - ) - .then((u) => { - if (cancelled) u() - else unsubComments = u - }) - .catch((e) => console.warn('trail_comments subscribe failed:', e)) - - return () => { - cancelled = true - unsubComments?.() - unsubComments = null - } - }) async function addComment() { if (!trail || !commentText.trim()) return commentError = null try { - await api.collection('trail_comments').create({ - trail: trail.id, - team: trail.team, - text: commentText.trim(), - created_by: auth.user?.id, - }) + await commentStore.create(trail.id, trail.team, commentText) commentText = '' } catch (e: any) { commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'