stammtisch-hersbruck/frontend/src/routes/dashboard/trails/[id]/+page.svelte
Daniel Michelberger 3ba8ec4c86 feat: Trailstatus in relativer Zeit
Ein Datum muss man erst gegen den heutigen Tag rechnen, um zu wissen, ob
eine Sperrung von gestern stammt oder vom letzten Sommer — der Abstand
sagt es sofort. Das genaue Datum steht daneben im Tooltip und geht
dadurch nicht verloren.

date-fns lag bereits im Projekt, es brauchte keine neue Library.
parseDate nimmt PocketBases "2026-09-12 08:30:00.000Z" mit Leerzeichen
statt T; die meisten Browser schlucken das, garantiert ist es nicht.

formatDate auf der Trail-Seite war eine Kopie von formatDateTime und
kommt jetzt aus $lib/time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P32KoesVtABd6xWsqMKzhr
2026-09-07 22:09:41 +02:00

716 lines
32 KiB
Svelte

<script lang="ts">
import { page } from '$app/state'
import { goto } from '$app/navigation'
import { api, auth, getFileURL } from '$lib/stores/pocketbase.svelte'
import { getTrailContext } from '$lib/stores/trails.svelte'
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'
import FlagIcon from '$lib/components/FlagIcon.svelte'
import IconPicker from '$lib/components/IconPicker.svelte'
import Avatar from '$lib/components/Avatar.svelte'
import { tooltip } from '$lib/stores/app.svelte'
import { formatDateTime, formatRelative } from '$lib/time'
import { positionAtDistance } from '$lib/gpx'
import { app } from '$lib/stores/app.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 * as DropdownMenu from '@/components/ui/dropdown-menu'
import * as Select from '@/components/ui/select'
import { TRAIL_STATUS, trailStatus } from '$lib/trailStatus'
import {
ArrowLeft, Upload, MapPin, MessageSquare, History, ChevronDown,
Check, Trash2, Download, Plus, Ruler, TrendingUp,
} from 'lucide-svelte'
import DOMPurify from 'dompurify'
import { browser } from '$app/environment'
import type { TrailVersionsResponse } from '$lib/types'
// Generics der JSON-Felder, wie in trails/+page.svelte — die
// Rohdaten kommen ungetypt aus PocketBase zurück.
type LineGeoJSON = { type: 'LineString'; coordinates: [number, number][] }
type Elevation = { d: number; ele: number }[]
type Version = TrailVersionsResponse<
[[number, number], [number, number]] | null,
number[] | null,
Elevation | null,
LineGeoJSON | null
>
const trails = getTrailContext()
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)
const canEdit = $derived(trail ? trails.canEdit(trail) : false)
const team = $derived(trail ? teams.records.find((t) => t.id === trail.team) : undefined)
/**
* description ist ein editor-Feld und enthält HTML. Gesetzt wird es zwar
* nur von Paten und Admins, aber ungefiltert ausgegeben wäre es ein Weg,
* Skriptcode bei allen Team-Mitgliedern auszuführen. DOMPurify braucht ein
* DOM — beim serverseitigen Rendern (kein `window`/`document`) liefern
* wir stattdessen leer; der Browser holt das beim Hydrieren nach.
*/
const safeDescription = $derived(
browser && trail?.description ? DOMPurify.sanitize(trail.description) : '',
)
const version = $derived.by((): Version | null => {
if (!trail?.current) return null
return (versions.records.find((v) => v.id === trail.current) ?? null) as Version | null
})
const trailVersions = $derived(trail ? versions.byTrail(trail.id) : [])
const trailMarkers = $derived(trail ? markers.byTrail(trail.id) : [])
const NEUTRAL = '#64748b'
/**
* Beschriftung, Icon und Farbe eines Markers. Ein eigener Text oder ein
* eigenes Icon am Marker geht vor: Beim Setzen darf der Typ nur den
* Vorschlag liefern, nicht das letzte Wort haben.
*/
function markerLook(m: { flag?: string; label?: string; icon?: string }) {
const flag = m.flag ? flags.getById(m.flag) : undefined
return {
label: m.label?.trim() || flag?.label || 'Marker',
icon: m.icon?.trim() || flag?.icon,
color: flag?.color ?? NEUTRAL,
}
}
// Für die Karte aufbereitete Marker — inklusive der Angaben fürs Popup
// (was, wann, wer). Formatiert wird hier, damit die Karte keine Stores
// und keine Datumslogik braucht.
const mapMarkers = $derived(
trailMarkers.map((m) => {
const look = markerLook(m)
return {
id: m.id,
lat: m.lat,
lng: m.lng,
color: look.color,
resolved: !!m.resolved,
label: look.label,
icon: look.icon,
note: m.note,
when: formatDate(m.created),
who: markers.authorName(m),
}
}),
)
let highlight = $state<number | null>(null)
// Versionen sind Archiv, kein Alltag — deshalb zugeklappt.
let showVersions = $state(false)
const statusInfo = $derived(trailStatus(trail?.status))
// Wer den Status gemeldet hat, steht nur als ID im Datensatz.
$effect(() => {
if (trail?.status_by) teams.loadUser(trail.status_by)
})
// --- GPX-Upload ------------------------------------------------------
let uploadDialog = $state(false)
let uploadFile = $state<File | null>(null)
let uploadNote = $state('')
let uploading = $state(false)
let uploadError = $state<string | null>(null)
async function doUpload() {
if (!trail || !uploadFile) return
uploading = true
uploadError = null
try {
await versions.upload(trail.id, uploadFile, uploadNote)
uploadDialog = false
uploadFile = null
uploadNote = ''
} catch (e: any) {
// parseGpx wirft mit verständlichem Text — direkt zeigen. Der
// Upload läuft in zwei Schritten (Version anlegen, dann aktivieren);
// scheitert der zweite, existiert die Version schon.
uploadError = (e.message ?? 'Die Datei konnte nicht verarbeitet werden.')
+ ' Prüfe unter „Versionen“, ob sie trotzdem angelegt wurde.'
} finally {
uploading = false
}
}
// --- Marker setzen ---------------------------------------------------
// Die Koordinaten kommen bereits auf die Linie projiziert aus der Karte;
// abseits des Trails lässt sich gar nicht erst klicken.
let markerDialog = $state(false)
let markerCoords = $state<{ lat: number; lng: number } | null>(null)
let markerDistance = $state<number | null>(null)
// Leerer Wert = eigener Typ. Als erste Zeile im Dropdown, damit eine
// Meldung nie daran scheitert, dass es den passenden Typ noch nicht gibt.
const CUSTOM = ''
let markerFlag = $state(CUSTOM)
let markerLabel = $state('')
let markerIcon = $state('')
let markerNote = $state('')
let markerError = $state<string | null>(null)
// Ohne Flag-Typen geht es trotzdem: Dann ist jede Meldung eine eigene.
const canPlace = $derived(!!trail)
const selectedFlag = $derived(markerFlag ? flags.getById(markerFlag) : undefined)
const markerColor = $derived(selectedFlag?.color ?? NEUTRAL)
function onPlace(p: { lat: number; lng: number; distance: number | null }) {
if (!canPlace) return
markerCoords = { lat: p.lat, lng: p.lng }
markerDistance = p.distance
markerFlag = flags.scoped[0]?.id ?? CUSTOM
markerLabel = ''
markerIcon = flags.scoped[0]?.icon ?? ''
markerNote = ''
markerError = null
markerDialog = true
}
/**
* Typwechsel übernimmt dessen Icon — als Vorschlag. Wer danach ein anderes
* wählt, behält es; erst der nächste Typwechsel schlägt wieder etwas vor.
*/
function selectFlag(id: string) {
markerFlag = id
markerIcon = id ? (flags.getById(id)?.icon ?? '') : ''
}
function removeMarker(m: { id: string; note?: string }) {
app.confirm.request({
title: 'Marker löschen?',
text: m.note
? `„${m.note}" wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.`
: 'Der Marker wird endgültig entfernt. Erledigte Meldungen bleiben sonst als Historie erhalten.',
yes: async () => {
try {
await markers.remove(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht gelöscht werden.')
} finally {
app.confirm.close()
}
},
no: () => app.confirm.close(),
})
}
/**
* Marker aus dem Höhenprofil heraus. Die Stelle kommt als Streckenkilometer
* und wird auf dieselbe Linie zurückgerechnet, die auch ein Klick auf der
* Karte trifft — beide Wege landen deshalb exakt auf dem Trail.
*/
function placeFromProfile(distance: number) {
const coords = version?.geojson?.coordinates
if (!coords?.length) return
const at = positionAtDistance(coords, version?.coord_distances ?? [], distance)
if (!at) return
onPlace({ lng: at[0], lat: at[1], distance })
}
async function saveMarker() {
if (!trail || !markerCoords) return
if (!markerFlag && !markerLabel.trim()) {
markerError = 'Bitte einen Typ wählen oder eine eigene Bezeichnung eintragen.'
return
}
try {
await markers.create({
trail: trail.id,
flag: markerFlag,
// Bei einem vorgefertigten Typ steht die Bezeichnung schon dort;
// sie hier zu kopieren hieße, sie zweimal zu pflegen.
label: markerFlag ? '' : markerLabel.trim(),
icon: markerIcon.trim(),
lat: markerCoords.lat,
lng: markerCoords.lng,
note: markerNote,
})
markerDialog = false
markerCoords = null
markerDistance = null
} catch (e: any) {
markerError = e.message ?? 'Der Marker konnte nicht gespeichert werden.'
}
}
// --- Kommentare ------------------------------------------------------
const comments = $derived(trail ? commentStore.byTrail(trail.id) : [])
let commentText = $state('')
let commentError = $state<string | null>(null)
async function addComment() {
if (!trail || !commentText.trim()) return
commentError = null
try {
await commentStore.create(trail.id, trail.team, commentText)
commentText = ''
} catch (e: any) {
commentError = e.message ?? 'Der Kommentar konnte nicht gespeichert werden.'
}
}
// --- Hilfsfunktionen -------------------------------------------------
// Dieselbe Formatierung stand hier als eigene Zeile Code und in
// $lib/time; jetzt kommt sie nur noch von dort.
const formatDate = formatDateTime
async function setStatus(status: string) {
if (!trail || status === trail.status) return
try {
await trails.setStatus(trail.id, status)
} catch (e: any) {
alert(e.message ?? 'Der Zustand konnte nicht gemeldet werden.')
}
}
</script>
{#if !trail}
<div class="space-y-4">
<Button variant="ghost" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Zurück
</Button>
<p class="text-muted-foreground">Trail nicht gefunden.</p>
</div>
{:else}
<div class="space-y-6">
<!-- Kopf -->
<div class="space-y-2">
<Button variant="ghost" size="sm" onclick={() => goto('/dashboard/trails')}>
<ArrowLeft class="size-4 mr-2" />
Trails
</Button>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
<h1 class="text-4xl font-bold tracking-tight">{trail.name}</h1>
{#if canEdit}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<!-- Farbe kommt aus derselben Quelle wie die Linie auf der Karte. -->
<Button
{...props}
variant="outline"
size="sm"
class="gap-2"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
<ChevronDown class="size-3 opacity-60" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-60">
<DropdownMenu.Label>Zustand melden</DropdownMenu.Label>
{#each TRAIL_STATUS as s (s.value)}
<DropdownMenu.Item
onclick={() => setStatus(s.value)}
class={s.value === trail.status ? 'bg-accent' : ''}
>
<span class="size-2 rounded-full mr-2 shrink-0" style:background={s.color}></span>
<span class="flex-1">{s.label}</span>
<span class="text-xs text-muted-foreground">{s.hint}</span>
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<span
class="inline-flex items-center gap-2 rounded-md border px-3 py-1 text-sm"
style="color: {statusInfo.color}; border-color: {statusInfo.color}"
>
<span class="size-2 rounded-full" style:background={statusInfo.color}></span>
{statusInfo.label}
</span>
{/if}
{#if version}
<span class="flex items-center gap-4 text-sm text-muted-foreground">
<span class="flex items-center gap-1.5">
<Ruler class="size-4" />
<strong class="text-foreground">{(version.distance_m / 1000).toFixed(1)}</strong> km
</span>
<span class="flex items-center gap-1.5">
<TrendingUp class="size-4" />
<strong class="text-foreground">{version.ascent_m}</strong> hm
</span>
</span>
{/if}
</div>
{#if trail.status_changed}
<!--
Relativ, weil am Zustand die Frage „wie alt ist die
Meldung?" haengt und nicht „welcher Tag war das?". Das
genaue Datum steht im Tooltip.
-->
<p class="text-xs text-muted-foreground">
<span style="color: {statusInfo.color}">{statusInfo.label}</span>
gemeldet
<span use:tooltip={{ content: formatDateTime(trail.status_changed) }}>
{formatRelative(trail.status_changed)}
</span>
{#if trail.status_by}
von {teams.userLabel(trail.status_by)}
{/if}
</p>
{/if}
{#if safeDescription}
<p class="text-muted-foreground text-sm">{@html safeDescription}</p>
{/if}
</div>
<div class="grid gap-6 lg:grid-cols-3 items-start">
<!-- Karte und Höhenprofil -->
<Card class="lg:col-span-2">
<CardContent class="pt-6 space-y-4">
{#if version?.geojson}
<TrailMap
geojson={version.geojson}
bounds={version.bounds}
markers={mapMarkers}
status={trail.status}
coordDistances={version.coord_distances ?? []}
highlightDistance={highlight}
onplace={canPlace ? onPlace : undefined}
ontrailhover={(d) => (highlight = d)}
/>
{#if version.elevation?.length}
<ElevationProfile
elevation={version.elevation}
highlightDistance={highlight}
onhover={(d) => (highlight = d)}
onselect={canPlace ? placeFromProfile : undefined}
/>
{/if}
{:else}
<div class="py-12 text-center space-y-3">
<Upload class="size-10 mx-auto text-muted-foreground" />
<p class="text-muted-foreground">Für diesen Trail gibt es noch keine GPX-Datei.</p>
{#if canEdit}
<Button onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
<!-- Marker, Kommentare, Versionen -->
<div class="space-y-4">
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MapPin class="size-4 text-primary" />
Marker ({trailMarkers.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<p class="text-xs text-muted-foreground">
Auf die Trail-Linie oder ins Höhenprofil klicken, um dort einen
Marker zu setzen.
</p>
{#if trailMarkers.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Meldung.</p>
{:else}
<ul class="divide-y -mx-2">
{#each trailMarkers as m (m.id)}
{@const look = markerLook(m)}
<li class="flex items-start gap-3 px-2 py-3" class:opacity-60={m.resolved}>
<FlagIcon name={look.icon} color={look.color} class="size-5" />
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{look.label}</p>
{#if m.note}
<p class="text-sm text-muted-foreground">{m.note}</p>
{/if}
<p class="text-xs text-muted-foreground">
{formatDate(m.created)} · {markers.authorName(m)}
</p>
</div>
{#if markers.canEdit(m, (trail.stewards ?? []) as string[], team?.owner, (team?.admins ?? []) as string[])}
<div class="flex shrink-0">
<Button
variant="ghost"
size="icon"
class="size-8"
tooltip={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
await markers.toggleResolved(m.id)
} catch (e: any) {
alert(e.message ?? 'Der Marker konnte nicht geändert werden.')
}
}}
>
<Check class="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="size-8"
tooltip="Marker löschen"
onclick={() => removeMarker(m)}
>
<Trash2 class="size-4 text-destructive" />
</Button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
<Card>
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<MessageSquare class="size-4 text-primary" />
Kommentare ({comments.length})
</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex gap-2">
<Input
bind:value={commentText}
placeholder="Kommentar schreiben …"
onkeydown={(e) => e.key === 'Enter' && addComment()}
/>
<Button onclick={addComment} disabled={!commentText.trim()}>
<Plus class="size-4" />
</Button>
</div>
{#if commentError}
<p class="text-sm text-destructive">{commentError}</p>
{/if}
{#if comments.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Kommentare.</p>
{:else}
<ul class="divide-y -mx-2">
{#each comments as c (c.id)}
{@const author = c.expand?.created_by}
<li class="flex gap-3 px-2 py-3">
<!-- Nur das Bild; wer dahintersteckt, sagt der Tooltip. -->
<span
use:tooltip={{
content: author?.name || author?.email || 'Unbekannt',
}}
>
<Avatar user={author} size={28} />
</span>
<div class="min-w-0 flex-1">
<p class="text-sm">{c.text}</p>
<p class="text-xs text-muted-foreground mt-1">{formatDate(c.created)}</p>
</div>
</li>
{/each}
</ul>
{/if}
</CardContent>
</Card>
<Card>
<!-- Versionen sind Archiv: zugeklappt, bis jemand sie braucht. -->
<button
type="button"
class="w-full flex items-center gap-2 px-6 py-4 text-left"
aria-expanded={showVersions}
onclick={() => (showVersions = !showVersions)}
>
<History class="size-4 text-primary" />
<span class="font-semibold text-base flex-1">
Versionen ({trailVersions.length})
</span>
<ChevronDown
class="size-4 text-muted-foreground transition-transform"
style={showVersions ? 'transform: rotate(180deg)' : ''}
/>
</button>
{#if showVersions}
<CardContent class="pt-0 space-y-3">
{#if canEdit}
<Button variant="outline" size="sm" class="w-full" onclick={() => (uploadDialog = true)}>
<Upload class="size-4 mr-2" />
GPX hochladen
</Button>
{/if}
{#if trailVersions.length === 0}
<p class="text-sm text-muted-foreground">Noch keine Version hochgeladen.</p>
{:else}
<ul class="divide-y -mx-2">
{#each trailVersions as v (v.id)}
<li class="px-2 py-3 flex items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-sm font-medium">{formatDate(v.created)}</p>
{#if v.id === trail.current}
<Badge>Aktiv</Badge>
{/if}
</div>
<p class="text-xs text-muted-foreground">
{(v.distance_m / 1000).toFixed(1)} km · {v.ascent_m} hm
{#if v.note}· {v.note}{/if}
</p>
</div>
{#if v.gpx}
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} tooltip="GPX herunterladen">
<Download class="size-4" />
</Button>
{/if}
{#if canEdit && v.id !== trail.current}
<Button variant="outline" size="sm" onclick={() => versions.activate(trail.id, v.id)}>
Aktivieren
</Button>
{/if}
</li>
{/each}
</ul>
{/if}
</CardContent>
{/if}
</Card>
</div>
</div>
</div>
{/if}
<!-- GPX-Upload -->
<Dialog.Root bind:open={uploadDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>GPX-Datei hochladen</Dialog.Title>
<Dialog.Description>
Die bisherige Version bleibt erhalten; die neue wird zur aktiven.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label for="gpx-file">Datei</Label>
<input
id="gpx-file"
type="file"
accept=".gpx,application/gpx+xml,application/xml,text/xml"
class="block w-full text-sm"
onchange={(e) => (uploadFile = e.currentTarget.files?.[0] ?? null)}
/>
</div>
<div class="space-y-2">
<Label for="gpx-note">Notiz</Label>
<Input id="gpx-note" bind:value={uploadNote} placeholder="z. B. Umleitung am Steinbruch" />
</div>
{#if uploadError}
<p class="text-sm text-destructive">{uploadError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (uploadDialog = false)}>Abbrechen</Button>
<Button onclick={doUpload} disabled={!uploadFile || uploading}>
{uploading ? 'Wird verarbeitet …' : 'Hochladen'}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Marker anlegen -->
<Dialog.Root bind:open={markerDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Marker setzen</Dialog.Title>
{#if markerDistance != null}
<Dialog.Description>
bei km {(markerDistance / 1000).toFixed(2)} des Trails
</Dialog.Description>
{/if}
</Dialog.Header>
<div class="space-y-4">
<div class="space-y-2">
<Label>Typ</Label>
<Select.Root type="single" value={markerFlag} onValueChange={selectFlag}>
<Select.Trigger class="w-full">
{#if selectedFlag}
<span class="flex items-center gap-2">
<span class="size-2 rounded-full" style:background={selectedFlag.color}></span>
{selectedFlag.label}
</span>
{:else}
Eigener Typ
{/if}
</Select.Trigger>
<Select.Content>
<Select.Item value={CUSTOM}>Eigener Typ …</Select.Item>
{#each flags.scoped as f (f.id)}
<Select.Item value={f.id}>
<span class="size-2 rounded-full mr-2 shrink-0" style:background={f.color}></span>
{f.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{#if !markerFlag}
<div class="space-y-2">
<Label for="marker-label">Bezeichnung</Label>
<Input id="marker-label" bind:value={markerLabel} placeholder="z. B. Wespennest am Anlieger" />
</div>
{/if}
<IconPicker id="marker-icon" bind:value={markerIcon} color={markerColor} />
<div class="space-y-2">
<Label for="marker-note">Notiz</Label>
<Input id="marker-note" bind:value={markerNote} placeholder="optional" />
</div>
{#if markerError}
<p class="text-sm text-destructive">{markerError}</p>
{/if}
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (markerDialog = false)}>Abbrechen</Button>
<Button onclick={saveMarker}>Speichern</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>