feat(PROJ-11): build team chat via direct browser-to-Synapse connection
Frontend: matrix-js-sdk client per opened team, /sync against Synapse directly (no chat traffic proxied through the backend), timeline with day-grouping and read receipts, history backfill, lazy-loaded chat tab in TeamsPage's team detail view (keeps matrix-js-sdk out of the initial bundle). Backend: POST /api/chat/token mints/caches a short-lived per-member Matrix access token via Synapse's admin puppet-login (1h TTL, in-memory cache with 5min pre-expiry refresh, concurrent-request dedup, audited on real mints only); GET /api/chat/room resolves a team's Matrix room. Cached tokens are invalidated immediately on member suspend/leave. Fixes found while wiring this up: - createRoom() now explicitly sets history_visibility=shared (PROJ-9) instead of relying on the private_chat preset's implicit default. - POST /api/organizations never provisioned a Matrix account for the org creator (only the invitation-accept path did) — they'd have seen "Chat wird eingerichtet" forever. Now enqueues the same provision_matrix_user job invitations use. Open: SYNAPSE_PUBLIC_BASE_URL needs documenting in backend/.env.example (blocked by local permission rules on that file — added manually on the talkhub test host instead), per-tile unread counts need an app-level Matrix client instead of one per opened team, and Synapse needs TLS + public reachability before this works outside a LAN. Verified end-to-end on the talkhub/matrix test LXCs: registered a throwaway account, created org → department → season → team, joined the team, minted a token, sent a message directly to Synapse, read it back from room history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0143yYvjesjHndBACoweyK67
This commit is contained in:
1 parent
8b6114c3e8
commit
7fa658b4ff
17 files changed
+1205
-7
No files matched your search
Generated
+160
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "ai-coding-starter-kit-frontend",
|
||||
"name": "talktime-frontend",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ai-coding-starter-kit-frontend",
|
||||
"name": "talktime-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -33,6 +33,7 @@
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"matrix-js-sdk": "^42.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -302,6 +303,12 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "8.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.5.tgz",
|
||||
"integrity": "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -1224,6 +1231,15 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
"version": "18.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-18.8.0.tgz",
|
||||
"integrity": "sha512-J+bYzUqtkYSrIUbDT8Razf9F1gzlpC94qeC5LQlr9QmjrzyaErwlV9v7S+gtSOeDvJCdfP8ebEDaANHiVLCYQg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||
@@ -2828,6 +2844,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/events": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
|
||||
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2927,6 +2949,12 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/another-json": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
|
||||
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
@@ -3034,6 +3062,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.21",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
|
||||
@@ -3118,6 +3152,15 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -3288,6 +3331,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
|
||||
"integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -3649,6 +3705,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -3988,6 +4053,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz",
|
||||
"integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@@ -4160,6 +4237,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -4179,6 +4269,44 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-events-sdk": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
|
||||
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/matrix-js-sdk": {
|
||||
"version": "42.3.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-42.3.0.tgz",
|
||||
"integrity": "sha512-jXXFLlaA25zQ2oxXCgbAEubGC8KpF8OabSf387W0RXfkwqhNXEbU+veeUaZMT+bCbyE5nDxkN3iBy038VbntQg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^8.0.0",
|
||||
"@matrix-org/matrix-sdk-crypto-wasm": "^18.4.0",
|
||||
"another-json": "^0.2.0",
|
||||
"bs58": "^6.0.0",
|
||||
"content-type": "^2.0.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"p-retry": "8",
|
||||
"sdp-transform": "^3.0.0",
|
||||
"unhomoglyph": "^1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/matrix-widget-api": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.19.0.tgz",
|
||||
"integrity": "sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/events": "^3.0.0",
|
||||
"events": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@@ -4361,6 +4489,21 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.1.tgz",
|
||||
"integrity": "sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-network-error": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -4939,6 +5082,15 @@
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
|
||||
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -5245,6 +5397,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unhomoglyph": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
|
||||
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"matrix-js-sdk": "^42.3.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface MessageComposerProps {
|
||||
disabled: boolean;
|
||||
sending: boolean;
|
||||
onSend: (body: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function MessageComposer({ disabled, sending, onSend }: MessageComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
async function submit() {
|
||||
const body = value.trim();
|
||||
if (!body || disabled || sending) return;
|
||||
setValue("");
|
||||
try {
|
||||
await onSend(body);
|
||||
} catch {
|
||||
// Keep the text so nothing is lost when sending failed.
|
||||
setValue(body);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
void submit();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex items-end gap-2 border-t p-3">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
placeholder="Nachricht schreiben…"
|
||||
aria-label="Nachricht schreiben"
|
||||
className="max-h-32 min-h-[40px] resize-none"
|
||||
/>
|
||||
<Button type="submit" disabled={disabled || sending || !value.trim()}>
|
||||
{sending ? "Sendet…" : "Senden"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDayLabel, formatTime } from "@/lib/matrix";
|
||||
import type { ChatMessage } from "@/hooks/use-team-chat";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[];
|
||||
ownUserId: string | null;
|
||||
loading: boolean;
|
||||
canLoadMore: boolean;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
interface DayGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
function groupByDay(messages: ChatMessage[]): DayGroup[] {
|
||||
const groups: DayGroup[] = [];
|
||||
for (const message of messages) {
|
||||
const date = new Date(message.timestamp);
|
||||
const key = date.toDateString();
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.key === key) {
|
||||
last.messages.push(message);
|
||||
} else {
|
||||
groups.push({ key, label: formatDayLabel(date), messages: [message] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
ownUserId,
|
||||
loading,
|
||||
canLoadMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: MessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
const groups = useMemo(() => groupByDay(messages), [messages]);
|
||||
const lastId = messages[messages.length - 1]?.id;
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [lastId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3 p-4" aria-busy="true" aria-label="Nachrichten werden geladen">
|
||||
<Skeleton className="h-12 w-2/3" />
|
||||
<Skeleton className="h-12 w-1/2" />
|
||||
<Skeleton className="h-12 w-3/5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Noch keine Nachrichten. Schreib die erste.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4" role="log" aria-live="polite" aria-label="Chatverlauf">
|
||||
{canLoadMore && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" onClick={onLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? "Lädt…" : "Ältere Nachrichten laden"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<h3 className="text-xs font-medium text-muted-foreground">{group.label}</h3>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{group.messages.map((message) => {
|
||||
const own = message.senderId === ownUserId;
|
||||
return (
|
||||
<article
|
||||
key={message.id}
|
||||
className={cn("flex flex-col", own ? "items-end" : "items-start")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-lg px-3 py-2 sm:max-w-[70%]",
|
||||
own ? "bg-primary text-primary-foreground" : "bg-muted",
|
||||
message.pending && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{!own && (
|
||||
<p className="text-xs font-medium opacity-80">{message.senderName}</p>
|
||||
)}
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{message.body}</p>
|
||||
<p className="mt-1 text-[10px] opacity-70">
|
||||
<time dateTime={new Date(message.timestamp).toISOString()}>
|
||||
{formatTime(new Date(message.timestamp))}
|
||||
</time>
|
||||
{message.pending && " · wird gesendet…"}
|
||||
</p>
|
||||
</div>
|
||||
{own && message.readBy.length > 0 && (
|
||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
gelesen von: {message.readBy.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { useTeamChat } from "@/hooks/use-team-chat";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
import { MessageList } from "./MessageList";
|
||||
|
||||
interface TeamChatProps {
|
||||
/** Lifted into the parent so the unread badge can live on the tab trigger. */
|
||||
chat: ReturnType<typeof useTeamChat>;
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export function TeamChat({ chat, teamName }: TeamChatProps) {
|
||||
const { status, markRead, messages } = chat;
|
||||
const lastId = messages[messages.length - 1]?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "ready" && lastId) {
|
||||
markRead();
|
||||
}
|
||||
}, [status, lastId, markRead]);
|
||||
|
||||
if (status === "pending") {
|
||||
const setupText =
|
||||
chat.pendingReason === "room_not_provisioned"
|
||||
? "Der Gruppenraum für dieses Team wird gerade angelegt. Sobald er bereit ist, erscheint der Verlauf hier automatisch."
|
||||
: "Dein Chat-Zugang wird gerade eingerichtet. Das dauert in der Regel nur kurz — diese Seite aktualisiert sich automatisch.";
|
||||
return (
|
||||
<Alert>
|
||||
<AlertTitle>
|
||||
{chat.pendingReason === "room_not_provisioned"
|
||||
? "Raum wird eingerichtet"
|
||||
: "Chat wird eingerichtet"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>{setupText}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Chat nicht verfügbar</AlertTitle>
|
||||
<AlertDescription className="space-y-3">
|
||||
<p>{chat.error ?? "Verbindung zum Chat-Server fehlgeschlagen."}</p>
|
||||
<Button variant="outline" size="sm" onClick={chat.retry}>
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[60vh] flex-col rounded-lg border sm:h-[65vh]"
|
||||
aria-label={`Chat für ${teamName}`}
|
||||
>
|
||||
<div className="min-h-0 flex-1">
|
||||
<MessageList
|
||||
messages={chat.messages}
|
||||
ownUserId={chat.ownUserId}
|
||||
loading={status === "loading"}
|
||||
canLoadMore={chat.canLoadMore}
|
||||
loadingMore={chat.loadingMore}
|
||||
onLoadMore={() => void chat.loadMore()}
|
||||
/>
|
||||
</div>
|
||||
{chat.error && status === "ready" && (
|
||||
<p className="px-3 pb-1 text-xs text-destructive">{chat.error}</p>
|
||||
)}
|
||||
<MessageComposer
|
||||
disabled={status !== "ready"}
|
||||
sending={chat.sending}
|
||||
onSend={chat.sendMessage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useTeamChat } from "@/hooks/use-team-chat";
|
||||
import { TeamChat } from "./TeamChat";
|
||||
|
||||
interface TeamDetailProps {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
departmentName: string;
|
||||
seasonName: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TeamDetail({
|
||||
teamId,
|
||||
teamName,
|
||||
departmentName,
|
||||
seasonName,
|
||||
onClose,
|
||||
}: TeamDetailProps) {
|
||||
const { token } = useAuth();
|
||||
const chat = useTeamChat(token, teamId);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{teamName}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{departmentName} · {seasonName}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Schließen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Übersicht</TabsTrigger>
|
||||
<TabsTrigger value="chat" className="gap-2">
|
||||
Chat
|
||||
{chat.unreadCount > 0 && (
|
||||
<Badge variant="secondary" aria-label={`${chat.unreadCount} ungelesene Nachrichten`}>
|
||||
{chat.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="pt-4">
|
||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
||||
<dt className="text-muted-foreground">Abteilung</dt>
|
||||
<dd>{departmentName}</dd>
|
||||
<dt className="text-muted-foreground">Saison</dt>
|
||||
<dd>{seasonName}</dd>
|
||||
</dl>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chat" className="pt-4">
|
||||
<TeamChat chat={chat} teamName={teamName} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ClientEvent,
|
||||
Direction,
|
||||
EventType,
|
||||
RoomEvent,
|
||||
SyncState,
|
||||
type MatrixClient,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
} from "matrix-js-sdk";
|
||||
import {
|
||||
ChatSetupPendingError,
|
||||
createMatrixClient,
|
||||
fetchChatCredentials,
|
||||
fetchTeamRoomId,
|
||||
type ChatSetupReason,
|
||||
} from "@/lib/matrix";
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
body: string;
|
||||
timestamp: number;
|
||||
pending: boolean;
|
||||
readBy: string[];
|
||||
}
|
||||
|
||||
export type ChatStatus = "loading" | "pending" | "error" | "ready";
|
||||
|
||||
interface UseTeamChatResult {
|
||||
status: ChatStatus;
|
||||
pendingReason: ChatSetupReason | null;
|
||||
error: string | null;
|
||||
messages: ChatMessage[];
|
||||
ownUserId: string | null;
|
||||
unreadCount: number;
|
||||
canLoadMore: boolean;
|
||||
loadingMore: boolean;
|
||||
sending: boolean;
|
||||
sendMessage: (body: string) => Promise<void>;
|
||||
loadMore: () => Promise<void>;
|
||||
markRead: () => void;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
function readReceiptsFor(room: Room, eventId: string, ownUserId: string): string[] {
|
||||
const event = room.findEventById(eventId);
|
||||
if (!event) return [];
|
||||
return room
|
||||
.getReceiptsForEvent(event)
|
||||
.filter((r) => r.type === "m.read" && r.userId !== ownUserId)
|
||||
.map((r) => room.getMember(r.userId)?.name ?? r.userId);
|
||||
}
|
||||
|
||||
function toMessages(room: Room, ownUserId: string): ChatMessage[] {
|
||||
return room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
.filter((event) => event.getType() === EventType.RoomMessage && !event.isRedacted())
|
||||
.map((event) => {
|
||||
const id = event.getId() ?? `${event.getTs()}`;
|
||||
return {
|
||||
id,
|
||||
senderId: event.getSender() ?? "",
|
||||
senderName: room.getMember(event.getSender() ?? "")?.name ?? event.getSender() ?? "",
|
||||
body: (event.getContent().body as string) ?? "",
|
||||
timestamp: event.getTs(),
|
||||
pending: event.status !== null,
|
||||
readBy: readReceiptsFor(room, id, ownUserId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function useTeamChat(authToken: string | null, teamId: string | null): UseTeamChatResult {
|
||||
const [status, setStatus] = useState<ChatStatus>("loading");
|
||||
const [pendingReason, setPendingReason] = useState<ChatSetupReason | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [ownUserId, setOwnUserId] = useState<string | null>(null);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [canLoadMore, setCanLoadMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
|
||||
const clientRef = useRef<MatrixClient | null>(null);
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
const room = roomRef.current;
|
||||
const userId = clientRef.current?.getUserId();
|
||||
if (!room || !userId) return;
|
||||
setMessages(toMessages(room, userId));
|
||||
setUnreadCount(room.getUnreadNotificationCount() ?? 0);
|
||||
setCanLoadMore(Boolean(room.getLiveTimeline().getPaginationToken(Direction.Backward)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authToken || !teamId) {
|
||||
setStatus("loading");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let client: MatrixClient | null = null;
|
||||
|
||||
const onTimeline = () => !cancelled && refresh();
|
||||
|
||||
async function connect() {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
setPendingReason(null);
|
||||
try {
|
||||
const roomId = await fetchTeamRoomId(authToken!, teamId!);
|
||||
const creds = await fetchChatCredentials(authToken!);
|
||||
if (cancelled) return;
|
||||
|
||||
client = createMatrixClient(creds);
|
||||
clientRef.current = client;
|
||||
setOwnUserId(creds.userId);
|
||||
|
||||
client.on(RoomEvent.Timeline, onTimeline);
|
||||
client.on(RoomEvent.Receipt, onTimeline);
|
||||
client.on(RoomEvent.LocalEchoUpdated, onTimeline);
|
||||
client.on(ClientEvent.Sync, (state: SyncState) => {
|
||||
if (cancelled) return;
|
||||
if (state === SyncState.Prepared || state === SyncState.Syncing) {
|
||||
const room = client!.getRoom(roomId);
|
||||
if (!room) {
|
||||
// Member not (yet) joined to the room — provisioning still running.
|
||||
setStatus("pending");
|
||||
setPendingReason("room_not_provisioned");
|
||||
return;
|
||||
}
|
||||
roomRef.current = room;
|
||||
setStatus("ready");
|
||||
refresh();
|
||||
} else if (state === SyncState.Error) {
|
||||
setStatus("error");
|
||||
setError("Verbindung zum Chat-Server fehlgeschlagen.");
|
||||
}
|
||||
});
|
||||
|
||||
await client.startClient({ initialSyncLimit: PAGE_SIZE });
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof ChatSetupPendingError) {
|
||||
setStatus("pending");
|
||||
setPendingReason(err.reason);
|
||||
// Poll instead of erroring until provisioning caught up.
|
||||
pollTimer = setTimeout(() => void connect(), POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
setStatus("error");
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
if (client) {
|
||||
client.removeAllListeners();
|
||||
client.stopClient();
|
||||
}
|
||||
clientRef.current = null;
|
||||
roomRef.current = null;
|
||||
};
|
||||
}, [authToken, teamId, attempt, refresh]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (body: string) => {
|
||||
const client = clientRef.current;
|
||||
const room = roomRef.current;
|
||||
if (!client || !room || !body.trim()) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await client.sendTextMessage(room.roomId, body.trim());
|
||||
} catch (err) {
|
||||
// Expired token: one automatic retry with fresh credentials, then surface the error.
|
||||
try {
|
||||
const creds = await fetchChatCredentials(authToken!, { refresh: true });
|
||||
client.setAccessToken(creds.accessToken);
|
||||
await client.sendTextMessage(room.roomId, body.trim());
|
||||
} catch {
|
||||
setError((err as Error).message || "Nachricht konnte nicht gesendet werden.");
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
refresh();
|
||||
}
|
||||
},
|
||||
[authToken, refresh],
|
||||
);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
const client = clientRef.current;
|
||||
const room = roomRef.current;
|
||||
if (!client || !room || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
await client.paginateEventTimeline(room.getLiveTimeline(), {
|
||||
backwards: true,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
refresh();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, refresh]);
|
||||
|
||||
const markRead = useCallback(() => {
|
||||
const client = clientRef.current;
|
||||
const room = roomRef.current;
|
||||
if (!client || !room) return;
|
||||
const events = room.getLiveTimeline().getEvents();
|
||||
const last: MatrixEvent | undefined = events[events.length - 1];
|
||||
if (last) {
|
||||
void client.sendReadReceipt(last).catch(() => undefined);
|
||||
setUnreadCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(() => setAttempt((n) => n + 1), []);
|
||||
|
||||
return {
|
||||
status,
|
||||
pendingReason,
|
||||
error,
|
||||
messages,
|
||||
ownUserId,
|
||||
unreadCount,
|
||||
canLoadMore,
|
||||
loadingMore,
|
||||
sending,
|
||||
sendMessage,
|
||||
loadMore,
|
||||
markRead,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createClient, type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
export interface ChatCredentials {
|
||||
homeserverUrl: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
expiresInMs: number;
|
||||
}
|
||||
|
||||
export type ChatSetupReason = "matrix_not_provisioned" | "room_not_provisioned";
|
||||
|
||||
export class ChatSetupPendingError extends Error {
|
||||
constructor(public readonly reason: ChatSetupReason) {
|
||||
super(reason);
|
||||
this.name = "ChatSetupPendingError";
|
||||
}
|
||||
}
|
||||
|
||||
async function readError(res: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string };
|
||||
return body.error ?? `Fehler (${res.status})`;
|
||||
} catch {
|
||||
return `Fehler (${res.status})`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks the TalkTime backend for a short-lived Matrix access token for the logged-in user. */
|
||||
export async function fetchChatCredentials(
|
||||
token: string,
|
||||
options: { refresh?: boolean } = {},
|
||||
): Promise<ChatCredentials> {
|
||||
const res = await fetch("/api/chat/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
// `refresh` forces the backend to drop its cached token instead of handing
|
||||
// back the one Synapse just rejected.
|
||||
body: JSON.stringify({ refresh: options.refresh === true }),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
throw new ChatSetupPendingError("matrix_not_provisioned");
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(await readError(res));
|
||||
}
|
||||
return (await res.json()) as ChatCredentials;
|
||||
}
|
||||
|
||||
/** Resolves the Matrix room id of a team. */
|
||||
export async function fetchTeamRoomId(token: string, teamId: string): Promise<string> {
|
||||
const res = await fetch(`/api/chat/room?teamId=${encodeURIComponent(teamId)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.status === 409) {
|
||||
throw new ChatSetupPendingError("room_not_provisioned");
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(await readError(res));
|
||||
}
|
||||
const body = (await res.json()) as { matrixRoomId: string };
|
||||
return body.matrixRoomId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Matrix client that talks to Synapse directly.
|
||||
* Chat traffic never passes through the TalkTime backend.
|
||||
*/
|
||||
export function createMatrixClient(creds: ChatCredentials): MatrixClient {
|
||||
return createClient({
|
||||
baseUrl: creds.homeserverUrl,
|
||||
accessToken: creds.accessToken,
|
||||
userId: creds.userId,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDayLabel(date: Date): string {
|
||||
const today = new Date();
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
const sameDay = (a: Date, b: Date) => a.toDateString() === b.toDateString();
|
||||
|
||||
if (sameDay(date, today)) return "Heute";
|
||||
if (sameDay(date, yesterday)) return "Gestern";
|
||||
return date.toLocaleDateString("de-DE", {
|
||||
weekday: "long",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(date: Date): string {
|
||||
return date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Suspense, lazy, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { useOrganization } from "@/lib/organization-context";
|
||||
import { useApiList } from "@/hooks/use-api-list";
|
||||
// Lazy: pulls in matrix-js-sdk only once a team is actually opened.
|
||||
const TeamDetail = lazy(() =>
|
||||
import("@/components/chat/TeamDetail").then((m) => ({ default: m.TeamDetail })),
|
||||
);
|
||||
|
||||
interface Department {
|
||||
id: string;
|
||||
@@ -36,6 +40,7 @@ export function TeamsPage() {
|
||||
const [departmentId, setDepartmentId] = useState<string>("");
|
||||
const [seasonId, setSeasonId] = useState<string>("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
|
||||
const orgQuery = activeOrganizationId ? `organizationId=${activeOrganizationId}` : null;
|
||||
const { data: departments } = useApiList<Department>(
|
||||
@@ -79,6 +84,8 @@ export function TeamsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTeam = teams.find((t) => t.id === selectedTeamId) ?? null;
|
||||
|
||||
const departmentName = (id: string) => departments.find((d) => d.id === id)?.name ?? "?";
|
||||
const seasonName = (id: string) => seasons.find((s) => s.id === id)?.name ?? "?";
|
||||
|
||||
@@ -140,7 +147,22 @@ export function TeamsPage() {
|
||||
<p className="text-sm text-muted-foreground">Keine Teams</p>
|
||||
) : (
|
||||
teams.map((team) => (
|
||||
<Card key={team.id}>
|
||||
<Card
|
||||
key={team.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedTeamId === team.id}
|
||||
onClick={() => setSelectedTeamId(team.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedTeamId(team.id);
|
||||
}
|
||||
}}
|
||||
className={`cursor-pointer transition-colors hover:bg-accent ${
|
||||
selectedTeamId === team.id ? "ring-2 ring-ring" : ""
|
||||
}`}
|
||||
>
|
||||
<CardContent className="pt-6 space-y-1">
|
||||
<p className="font-medium">{team.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -151,6 +173,18 @@ export function TeamsPage() {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedTeam && (
|
||||
<Suspense fallback={<p className="text-sm text-muted-foreground">Lädt…</p>}>
|
||||
<TeamDetail
|
||||
teamId={selectedTeam.id}
|
||||
teamName={selectedTeam.name}
|
||||
departmentName={departmentName(selectedTeam.departmentId)}
|
||||
seasonName={seasonName(selectedTeam.seasonId)}
|
||||
onClose={() => setSelectedTeamId(null)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user