This commit is contained in:
Linrador 2025-09-23 15:27:42 +02:00
parent c692cefb22
commit bb7ac51509
229 changed files with 1011 additions and 773 deletions

47
messages/de.json Normal file
View File

@ -0,0 +1,47 @@
{
"nav": {
"dashboard": "Dashboard",
"teams": {
"label": "Teams",
"overview": "Übersicht",
"manage": "Teamverwaltung"
},
"players": {
"label": "Spieler",
"overview": "Übersicht",
"stats": "Statistiken"
},
"schedule": "Spielplan"
},
"dashboard": {
"title": "Willkommen im Dashboard!"
},
"sidebar": {
"brand": "Iron:e",
"language": {
"de": "Deutsch",
"en": "Englisch"
},
"footer": {
"login": "Mit Steam anmelden",
"profile": "Profil",
"team": "Team",
"settings": "Einstellungen",
"administration": "Administration",
"signout": "Abmelden"
}
},
"game-banner": {
"disconnected": "Verbindung getrennt",
"player-connected": "Spieler verbunden",
"open-game": "Spiel öffnen",
"quit": "Verlassen",
"reconnect": "Neu verbinden"
},
"matches": {
"title": "Geplante Matches",
"description": "Keine Matches geplant.",
"filter": "Nur mein Team anzeigen",
"create-match": "Neues Match erstellen"
}
}

47
messages/en.json Normal file
View File

@ -0,0 +1,47 @@
{
"nav": {
"dashboard": "Dashboard",
"teams": {
"label": "Teams",
"overview": "Overview",
"manage": "Team Management"
},
"players": {
"label": "Players",
"overview": "Overview",
"stats": "Statistics"
},
"schedule": "Schedule"
},
"dashboard": {
"title": "Welcome!"
},
"sidebar": {
"brand": "Iron:e",
"language": {
"de": "German",
"en": "English"
},
"footer": {
"login": "Login with Steam",
"profile": "Profile",
"team": "Team",
"settings": "Settings",
"administration": "Administration",
"signout": "Sign out"
}
},
"game-banner": {
"disconnected": "Disconnected",
"player-connected": "Players connected",
"open-game": "Open game",
"quit": "Quit",
"reconnect": "Reconnect"
},
"matches": {
"title": "Scheduled matches",
"description": "No matches scheduled.",
"filter": "Show my team only",
"create-match": "Create new match"
}
}

View File

@ -1,4 +1,5 @@
import type { NextConfig } from 'next' import type { NextConfig } from 'next'
import createNextIntlPlugin from 'next-intl/plugin';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ['ironieopen.local', '*.ironieopen.local'], allowedDevOrigins: ['ironieopen.local', '*.ironieopen.local'],
@ -28,4 +29,5 @@ const nextConfig: NextConfig = {
}, },
} }
export default nextConfig const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

16
package-lock.json generated
View File

@ -33,7 +33,7 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "15.3.0", "next": "15.3.0",
"next-auth-steam": "^0.4.0", "next-auth-steam": "^0.4.0",
"next-intl": "^4.3.4", "next-intl": "^4.3.9",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
@ -6117,9 +6117,9 @@
} }
}, },
"node_modules/next-intl": { "node_modules/next-intl": {
"version": "4.3.4", "version": "4.3.9",
"resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.4.tgz", "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.3.9.tgz",
"integrity": "sha512-VWLIDlGbnL/o4LnveJTJD1NOYN8lh3ZAGTWw2krhfgg53as3VsS4jzUVnArJdqvwtlpU/2BIDbWTZ7V4o1jFEw==", "integrity": "sha512-4oSROHlgy8a5Qr2vH69wxo9F6K0uc6nZM2GNzqSe6ET79DEzOmBeSijCRzD5txcI4i+XTGytu4cxFsDXLKEDpQ==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@ -6130,7 +6130,7 @@
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.5.4", "@formatjs/intl-localematcher": "^0.5.4",
"negotiator": "^1.0.0", "negotiator": "^1.0.0",
"use-intl": "^4.3.4" "use-intl": "^4.3.9"
}, },
"peerDependencies": { "peerDependencies": {
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0", "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0",
@ -8013,9 +8013,9 @@
} }
}, },
"node_modules/use-intl": { "node_modules/use-intl": {
"version": "4.3.4", "version": "4.3.9",
"resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.4.tgz", "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.3.9.tgz",
"integrity": "sha512-sHfiU0QeJ1rirNWRxvCyvlSh9+NczcOzRnPyMeo2rtHXhVnBsvMRjE+UG4eh3lRhCxrvcqei/I0lBxsc59on1w==", "integrity": "sha512-bZu+h13HIgOvsoGleQtUe4E6gM49CRm+AH36KnJVB/qb1+Beo7jr7HNrR8YWH8oaOkQfGNm6vh0HTepxng8UTg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@formatjs/fast-memoize": "^2.2.0", "@formatjs/fast-memoize": "^2.2.0",

View File

@ -37,7 +37,7 @@
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next": "15.3.0", "next": "15.3.0",
"next-auth-steam": "^0.4.0", "next-auth-steam": "^0.4.0",
"next-intl": "^4.3.4", "next-intl": "^4.3.9",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^3.0.3", "node-cron": "^3.0.3",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",

View File

@ -1,9 +1,9 @@
'use client' 'use client'
import { notFound, usePathname } from 'next/navigation' import { notFound, usePathname } from 'next/navigation'
import Card from '@/app/components/Card' import Card from '../components/Card'
import MatchesAdminManager from '@/app/components/admin/MatchesAdminManager' import MatchesAdminManager from '../components/admin/MatchesAdminManager'
import AdminTeamsView from '@/app/components/admin/teams/AdminTeamsView' import AdminTeamsView from '../components/admin/teams/AdminTeamsView'
export default function AdminPage() { export default function AdminPage() {
const pathname = usePathname() const pathname = usePathname()

View File

@ -1,5 +1,5 @@
import { Tabs } from '@/app/components/Tabs' import { Tabs } from '../components/Tabs'
import Tab from '@/app/components/Tab' import Tab from '../components/Tab'
export default function AdminLayout({ children }: { children: React.ReactNode }) { export default function AdminLayout({ children }: { children: React.ReactNode }) {
return ( return (

View File

@ -1,12 +1,12 @@
// /src/app/admin/server/page.tsx // /src/app/admin/server/page.tsx
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { authOptions } from '@/app/lib/auth' import { authOptions } from '@/lib/auth'
import { prisma } from '@/app/lib/prisma' import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import Card from '@/app/components/Card' import Card from '../components/Card'
import ServerView from '@/app/components/admin/server/ServerView' import ServerView from '../components/admin/server/ServerView'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'

View File

@ -3,11 +3,11 @@
import { useCallback, useEffect, useState, useRef } from 'react' import { useCallback, useEffect, useState, useRef } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '../components/LoadingSpinner'
import TeamMemberView from '@/app/components/TeamMemberView' import TeamMemberView from '../components/TeamMemberView'
import { useTeamStore } from '@/app/lib/stores' import { useTeamStore } from '@/lib/stores'
import { reloadTeam } from '@/app/lib/sse-actions' import { reloadTeam } from '@/lib/sse-actions'
import type { Player } from '@/app/types/team' import type { Player } from '../types/team'
type Props = { teamId: string } type Props = { teamId: string }

View File

@ -2,8 +2,8 @@
'use client' 'use client'
import Card from '@/app/components/Card' import Card from '../components/Card'
import AdminTeamsView from '@/app/components/admin/teams/AdminTeamsView' import AdminTeamsView from '../components/admin/teams/AdminTeamsView'
export default function AdminTeamsPage() { export default function AdminTeamsPage() {
return ( return (

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { useEffect } from 'react' import { useEffect } from 'react'
import { sound } from '@/app/lib/soundManager' import { sound } from '@/lib/soundManager';
export default function AudioPrimer() { export default function AudioPrimer() {
useEffect(() => { useEffect(() => {

View File

@ -1,17 +1,18 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter, usePathname } from '@/i18n/navigation'
import Link from 'next/link' import { useTranslations, useLocale } from 'next-intl'
import Image from 'next/image' import Link from 'next/link'
import { format } from 'date-fns' // 👈 neu import Image from 'next/image'
import { de } from 'date-fns/locale' import { format } from 'date-fns'
import Switch from '@/app/components/Switch' import { de } from 'date-fns/locale'
import Button from './Button' import Switch from '../components/Switch'
import Modal from './Modal' import Button from './Button'
import { Match } from '../types/match' import Modal from './Modal'
import { useSSEStore } from '@/app/lib/useSSEStore' import { Match } from '../../../types/match'
import { useSSEStore } from '@/lib/useSSEStore'
type Props = { matchType?: string } type Props = { matchType?: string }
@ -57,7 +58,12 @@ function getMapVoteState(m: Match, nowMs: number) {
export default function CommunityMatchList({ matchType }: Props) { export default function CommunityMatchList({ matchType }: Props) {
const { data: session } = useSession() const { data: session } = useSession()
const router = useRouter() const router = useRouter()
const pathname = usePathname()
const locale = useLocale()
const tMatches = useTranslations('matches')
const { lastEvent } = useSSEStore() const { lastEvent } = useSSEStore()
const [matches, setMatches] = useState<Match[]>([]) const [matches, setMatches] = useState<Match[]>([])
@ -272,18 +278,18 @@ export default function CommunityMatchList({ matchType }: Props) {
{/* Kopfzeile */} {/* Kopfzeile */}
<div className="flex items-center justify-between flex-wrap gap-y-4"> <div className="flex items-center justify-between flex-wrap gap-y-4">
<h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300"> <h1 className="text-2xl font-bold text-gray-700 dark:text-neutral-300">
Geplante Matches {tMatches("title")}
</h1> </h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Switch <Switch
id="only-own-team" id="only-own-team"
checked={onlyOwn} checked={onlyOwn}
onChange={setOnlyOwn} onChange={setOnlyOwn}
labelRight="Nur mein Team anzeigen" labelRight={tMatches("filter")}
/> />
{session?.user?.isAdmin && ( {session?.user?.isAdmin && (
<Button color="blue" onClick={() => setShowCreate(true)}> <Button color="blue" onClick={() => setShowCreate(true)}>
Match erstellen {tMatches("create-match")}
</Button> </Button>
)} )}
</div> </div>
@ -291,7 +297,7 @@ export default function CommunityMatchList({ matchType }: Props) {
{/* Inhalt */} {/* Inhalt */}
{grouped.length === 0 ? ( {grouped.length === 0 ? (
<p className="text-gray-700 dark:text-neutral-300">Keine Matches geplant.</p> <p className="text-gray-700 dark:text-neutral-300">{tMatches("description")}</p>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{grouped.map(([dateKey, dayMatches], dayIdx) => { {grouped.map(([dateKey, dayMatches], dayIdx) => {

View File

@ -1,5 +1,6 @@
// CompRankBadge.tsx // CompRankBadge.tsx
'use client'; 'use client';
import Image from 'next/image'; import Image from 'next/image';
import Tooltip from './Tooltip'; import Tooltip from './Tooltip';

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useDroppable, useDndContext } from '@dnd-kit/core' import { useDroppable, useDndContext } from '@dnd-kit/core'
import { Player } from '../types/team' import { Player } from '../../../types/team'
import clsx from 'clsx' import clsx from 'clsx'
type DroppableZoneProps = { type DroppableZoneProps = {

View File

@ -2,10 +2,10 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import Modal from '@/app/components/Modal' import Modal from '../components/Modal'
import Alert from '@/app/components/Alert' import Alert from '../components/Alert'
import Select from '@/app/components/Select' import Select from '../components/Select'
import LoadingSpinner from '@/app/components/LoadingSpinner' // ⬅️ NEU import LoadingSpinner from '../components/LoadingSpinner' // ⬅️ NEU
type TeamOption = { id: string; name: string; logo?: string | null } type TeamOption = { id: string; name: string; logo?: string | null }

View File

@ -14,12 +14,12 @@ import {
SortableContext, verticalListSortingStrategy, SortableContext, verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import Modal from '@/app/components/Modal' import Modal from '../components/Modal'
import SortableMiniCard from '@/app/components/SortableMiniCard' import SortableMiniCard from '../components/SortableMiniCard'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '../components/LoadingSpinner'
import { DroppableZone } from '@/app/components/DroppableZone' import { DroppableZone } from '../components/DroppableZone'
import type { Player, Team } from '@/app/types/team' import type { Player, Team } from '../../../types/team'
/* ───────────────────────── Typen ────────────────────────── */ /* ───────────────────────── Typen ────────────────────────── */
export type EditSide = 'A' | 'B' export type EditSide = 'A' | 'B'

View File

@ -5,7 +5,7 @@ import Modal from './Modal'
import MiniCard from './MiniCard' import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { Player, Team } from '../types/team' import { Player, Team } from '../../../types/team'
import Pagination from './Pagination' import Pagination from './Pagination'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'

View File

@ -4,8 +4,8 @@ import { useState, useEffect } from 'react'
import Modal from './Modal' import Modal from './Modal'
import MiniCard from './MiniCard' import MiniCard from './MiniCard'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { Player, Team } from '../types/team' import { Player, Team } from '../../../types/team'
import { leaveTeam } from '../lib/sse-actions' import { leaveTeam } from '@/lib/sse-actions'
type Props = { type Props = {
show: boolean show: boolean

View File

@ -4,8 +4,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import type { MapVoteState } from '../types/mapvote' import type { MapVoteState } from '../../../types/mapvote'
type Props = { type Props = {
match: any match: any

View File

@ -7,15 +7,15 @@ import { useRouter } from 'next/navigation'
import type React from 'react' import type React from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore' import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
import MapVoteProfileCard from './MapVoteProfileCard' import MapVoteProfileCard from './MapVoteProfileCard'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import Button from './Button' import Button from './Button'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import type { Match, MatchPlayer } from '../types/match' import type { Match, MatchPlayer } from '../../../types/match'
import type { MapVoteState } from '../types/mapvote' import type { MapVoteState } from '../../../types/mapvote'
import { MAP_OPTIONS } from '../lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
/* =================== Utilities & constants =================== */ /* =================== Utilities & constants =================== */

View File

@ -14,12 +14,12 @@ import EditMatchMetaModal from './EditMatchMetaModal'
import EditMatchPlayersModal from './EditMatchPlayersModal' import EditMatchPlayersModal from './EditMatchPlayersModal'
import type { EditSide } from './EditMatchPlayersModal' import type { EditSide } from './EditMatchPlayersModal'
import type { Match, MatchPlayer } from '../types/match' import type { Match, MatchPlayer } from '../../../types/match'
import Button from './Button' import Button from './Button'
import { MAP_OPTIONS } from '../lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import MapVoteBanner from './MapVoteBanner' import MapVoteBanner from './MapVoteBanner'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { Team } from '../types/team' import { Team } from '../../../types/team'
import Alert from './Alert' import Alert from './Alert'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'

View File

@ -1,6 +1,6 @@
import Table from './Table' import Table from './Table'
import Image from 'next/image' import Image from 'next/image'
import { MatchPlayer } from '@/app/types/match' import { MatchPlayer } from '../../../types/match'
type Props = { type Props = {
player: MatchPlayer player: MatchPlayer

View File

@ -2,11 +2,11 @@
'use client' 'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { sound } from '@/app/lib/soundManager' import { sound } from '@/lib/soundManager'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { MAP_OPTIONS } from '../lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
type Props = { type Props = {
open: boolean open: boolean

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { Team } from '@/app/types/team' import { Team } from '../../../types/team'
import { MatchPlayer } from '../types/match' import { MatchPlayer } from '../../../types/match'
import MatchPlayerCard from './MatchPlayerCard' import MatchPlayerCard from './MatchPlayerCard'
import Image from 'next/image' import Image from 'next/image'
import Button from './Button' import Button from './Button'

View File

@ -3,9 +3,9 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import TeamCard from './TeamCard' import TeamCard from './TeamCard'
import type { Team, Player } from '../types/team' import type { Team, Player } from '../../../types/team'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { TEAM_EVENTS, INVITE_EVENTS } from '../lib/sseEvents' import { TEAM_EVENTS, INVITE_EVENTS } from '@/lib/sseEvents'
type Props = { type Props = {
initialTeams: Team[] initialTeams: Team[]

View File

@ -4,9 +4,9 @@ import { useEffect, useState, useRef } from 'react'
import NotificationCenter from './NotificationCenter' import NotificationCenter from './NotificationCenter'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { NOTIFICATION_EVENTS, isSseEventType } from '../lib/sseEvents' import { NOTIFICATION_EVENTS, isSseEventType } from '@/lib/sseEvents'
import { useUiChromeStore } from '@/app/lib/useUiChromeStore' import { useUiChromeStore } from '@/lib/useUiChromeStore'
type Notification = { type Notification = {
id: string id: string

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import Image from 'next/image' import Image from 'next/image'
import { Player, Team } from '@/app/types/team' import { Player, Team } from '../../../types/team'
export type CardWidth = export type CardWidth =
| 'sm' // max-w-sm (24rem) | 'sm' // max-w-sm (24rem)

View File

@ -3,10 +3,10 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import MatchReadyOverlay from './MatchReadyOverlay' import MatchReadyOverlay from './MatchReadyOverlay'
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore' import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore' // ⬅️ neu import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
// ---- kleiner In-Memory Cache für connectHref pro matchId // ---- kleiner In-Memory Cache für connectHref pro matchId
const CONNECT_CACHE = new Map<string | undefined, string>() const CONNECT_CACHE = new Map<string | undefined, string>()

View File

@ -1,8 +1,9 @@
// Select.tsx // Select.tsx
import { useState, useRef, useEffect, useMemo } from "react"; import React, { useState, useRef, useEffect, useMemo } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
type Option = { value: string; label: string }; type Option = { value: string; label: React.ReactNode };
type SelectProps = { type SelectProps = {
options: Option[]; options: Option[];
placeholder?: string; placeholder?: string;
@ -10,6 +11,8 @@ type SelectProps = {
onChange: (value: string) => void; onChange: (value: string) => void;
dropDirection?: "up" | "down" | "auto"; dropDirection?: "up" | "down" | "auto";
className?: string; className?: string;
showArrow?: boolean;
fullWidth?: boolean;
}; };
export default function Select({ export default function Select({
@ -18,7 +21,9 @@ export default function Select({
value, value,
onChange, onChange,
dropDirection = "down", dropDirection = "down",
className className,
showArrow = true,
fullWidth = true,
}: SelectProps) { }: SelectProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [direction, setDirection] = useState<"up" | "down">("down"); const [direction, setDirection] = useState<"up" | "down">("down");
@ -26,7 +31,7 @@ export default function Select({
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLUListElement>(null); // 👈 NEU const menuRef = useRef<HTMLUListElement>(null);
const selectedOption = useMemo(() => options.find(o => o.value === value), [options, value]); const selectedOption = useMemo(() => options.find(o => o.value === value), [options, value]);
@ -70,7 +75,6 @@ export default function Select({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, dropDirection]); }, [open, dropDirection]);
// Click-outside: ignoriert Klicks im Portal-Menü
useEffect(() => { useEffect(() => {
const handlePointerDown = (event: MouseEvent) => { const handlePointerDown = (event: MouseEvent) => {
const t = event.target as Node; const t = event.target as Node;
@ -85,7 +89,7 @@ export default function Select({
const Menu = open const Menu = open
? createPortal( ? createPortal(
<ul <ul
ref={menuRef} // 👈 wichtig ref={menuRef}
className="z-[9999] fixed bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto text-sm dark:bg-neutral-900 dark:border-neutral-700" className="z-[9999] fixed bg-white border border-gray-200 rounded-lg shadow-lg max-h-60 overflow-y-auto text-sm dark:bg-neutral-900 dark:border-neutral-700"
style={{ style={{
left: coords.left, left: coords.left,
@ -97,11 +101,8 @@ export default function Select({
{options.map(option => ( {options.map(option => (
<li <li
key={option.value} key={option.value}
onClick={() => { onClick={() => { onChange(option.value); setOpen(false); }}
onChange(option.value); className={`py-2 px-3 cursor-pointer flex items-center gap-2 hover:bg-gray-100 dark:hover:bg-neutral-800 dark:text-neutral-200 ${
setOpen(false);
}}
className={`py-2 px-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-neutral-800 dark:text-neutral-200 ${
option.value === value ? "bg-gray-100 dark:bg-neutral-800 font-medium" : "" option.value === value ? "bg-gray-100 dark:bg-neutral-800 font-medium" : ""
}`} }`}
> >
@ -119,14 +120,25 @@ export default function Select({
ref={buttonRef} ref={buttonRef}
type="button" type="button"
onClick={() => setOpen(prev => !prev)} onClick={() => setOpen(prev => !prev)}
className={`relative py-2 px-4 pe-10 w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-400 ${className}`} aria-haspopup="listbox"
aria-expanded={open}
className={`relative py-2 px-3 ${showArrow ? 'pe-9' : ''} ${fullWidth ? 'w-full' : 'w-auto inline-flex'} w-full cursor-pointer bg-white border border-gray-200 rounded-lg text-start text-sm text-gray-800 hover:border-gray-300 focus:border-blue-500 focus:ring focus:ring-blue-500/50 dark:bg-neutral-900 dark:border-neutral-700 dark:text-neutral-300 ${className || ''}`}
> >
{selectedOption ? selectedOption.label : placeholder} <span className="inline-flex items-center gap-2">
<span className="absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none"> {selectedOption ? selectedOption.label : placeholder}
<svg className="w-4 h-4 text-gray-500 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path d="M7 10l5 5 5-5" />
</svg>
</span> </span>
{showArrow && (
// Pfeil: standardmäßig ↑; bei open gedreht ↓
<span
className={`absolute top-1/2 right-3 -translate-y-1/2 pointer-events-none transition-transform ${open ? 'rotate-180' : ''}`}
aria-hidden
>
<svg className="w-4 h-4 text-gray-500 dark:text-neutral-500" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
<path d="M7 14l5-5 5 5" />
</svg>
</span>
)}
</button> </button>
{Menu} {Menu}
</div> </div>

View File

@ -1,19 +1,28 @@
'use client' 'use client'
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
import Button from './Button' import Button from './Button'
import SidebarFooter from './SidebarFooter' import SidebarFooter from './SidebarFooter'
import Select from './Select';
import 'flag-icons/css/flag-icons.min.css';
type Submenu = 'teams' | 'players' | null type Submenu = 'teams' | 'players' | null
export default function Sidebar() { export default function Sidebar() {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
const locale = useLocale()
const [isOpen, setIsOpen] = useState(false) // mobile drawer // Übersetzungen
const tNav = useTranslations('nav')
const tSidebar = useTranslations('sidebar')
const [isOpen, setIsOpen] = useState(false) // mobile drawer
const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null) const [openSubmenu, setOpenSubmenu] = useState<Submenu>(null)
// Aktive Route prüfen (pathname kommt schon ohne Locale)
const isActive = (path: string) => pathname === path const isActive = (path: string) => pathname === path
const navBtnBase = const navBtnBase =
@ -28,12 +37,22 @@ export default function Sidebar() {
const toggleSubmenu = (key: Exclude<Submenu, null>) => const toggleSubmenu = (key: Exclude<Submenu, null>) =>
setOpenSubmenu(prev => (prev === key ? null : key)) setOpenSubmenu(prev => (prev === key ? null : key))
// ✅ Locale-Wechsel: gleiche Route behalten, nur Locale ändern
const changeLocale = (nextLocale: 'en' | 'de') => {
if (nextLocale === locale) return
// pathname ist z.B. '/dashboard' next-intl setzt das Locale
router.replace(pathname, {locale: nextLocale})
setIsOpen(false)
}
// Gemeinsamer Inhalt (wird in Desktop-Aside und im Mobile-Drawer benutzt) // Gemeinsamer Inhalt (wird in Desktop-Aside und im Mobile-Drawer benutzt)
const SidebarInner = useMemo( const SidebarInner = useMemo(
() => ( () => (
<div className="flex flex-col h-full min-h-0"> <div className="flex flex-col h-full min-h-0">
<header className="p-4 flex items-center justify-between"> <header className="p-4 flex items-center justify-between">
<span className="font-semibold text-xl text-black dark:text-white">Iron:e</span> <span className="font-semibold text-xl text-black dark:text-white">
{tSidebar('brand')}
</span>
{/* Close-Button nur im mobilen Drawer sichtbar */} {/* Close-Button nur im mobilen Drawer sichtbar */}
<button <button
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
@ -55,12 +74,13 @@ export default function Sidebar() {
size="sm" size="sm"
variant="link" variant="link"
className={`${navBtnBase} ${isActive('/dashboard') ? activeClasses : idleClasses}`} className={`${navBtnBase} ${isActive('/dashboard') ? activeClasses : idleClasses}`}
aria-label={tNav('dashboard')}
> >
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" /> <path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
<path d="M9 22V12h6v10" /> <path d="M9 22V12h6v10" />
</svg> </svg>
Dashboard {tNav('dashboard')}
</Button> </Button>
</li> </li>
@ -71,12 +91,14 @@ export default function Sidebar() {
size="sm" size="sm"
variant="link" variant="link"
className={`${navBtnBase} ${idleClasses} justify-between`} className={`${navBtnBase} ${idleClasses} justify-between`}
aria-expanded={openSubmenu === 'teams'}
aria-controls="submenu-teams"
> >
<span className="flex items-center gap-x-3.5"> <span className="flex items-center gap-x-3.5">
<svg className="size-5" viewBox="0 0 640 640" fill="currentColor"> <svg className="size-4" viewBox="0 0 640 640" fill="currentColor">
<path d="M320 64c35.3 0 64 28.7 64 64s-28.7 64-64 64-64-28.7-64-64 28.7-64 64-64zm96 312c0 25-12.7 47-32 59.9V528c0 26.5-21.5 48-48 48h-32c-26.5 0-48-21.5-48-48v-92.1c-19.3-12.9-32-34.9-32-59.9v-40c0-53 43-96 96-96s96 43 96 96v40zM160 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm16 240v32c0 32.5 12.1 62.1 32 84.7V528c0 1.2 0 2.5.1 3.7-8.6 7.6-19.8 12.3-31.1 12.3H144c-26.5 0-48-21.5-48-48v-56.6C76.9 428.4 64 407.7 64 384v-32c0-53 43-96 96-96 12.7 0 24.8 2.5 35.9 6.9-12.6 21.4-19.9 46.8-19.9 73.1zM480 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm-48 432v-75.3c19.9-22.5 32-52.2 32-84.7v-32c0-26.7-7.3-52.1-19.9-73.1 11.1-4.4 23.2-6.9 35.9-6.9 53 0 96 43 96 96v32c0 23.7-12.9 44.4-32 55.4V496c0 26.5-21.5 48-48 48h-32c-10.8 0-21-3.6-29.1-9.7.1-1.2.1-2.5.1-3.7z" /> <path d="M320 64c35.3 0 64 28.7 64 64s-28.7 64-64 64-64-28.7-64-64 28.7-64 64-64zm96 312c0 25-12.7 47-32 59.9V528c0 26.5-21.5 48-48 48h-32c-26.5 0-48-21.5-48-48v-92.1c-19.3-12.9-32-34.9-32-59.9v-40c0-53 43-96 96-96s96 43 96 96v40zM160 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm16 240v32c0 32.5 12.1 62.1 32 84.7V528c0 1.2 0 2.5.1 3.7-8.6 7.6-19.8 12.3-31.1 12.3H144c-26.5 0-48-21.5-48-48v-56.6C76.9 428.4 64 407.7 64 384v-32c0-53 43-96 96-96 12.7 0 24.8 2.5 35.9 6.9-12.6 21.4-19.9 46.8-19.9 73.1zM480 96c30.9 0 56 25.1 56 56s-25.1 56-56 56-56-25.1-56-56 25.1-56 56-56zm-48 432v-75.3c19.9-22.5 32-52.2 32-84.7v-32c0-26.7-7.3-52.1-19.9-73.1 11.1-4.4 23.2-6.9 35.9-6.9 53 0 96 43 96 96v32c0 23.7-12.9 44.4-32 55.4V496c0 26.5-21.5 48-48 48h-32c-10.8 0-21-3.6-29.1-9.7.1-1.2.1-2.5.1-3.7z" />
</svg> </svg>
Teams {tNav('teams.label')}
</span> </span>
<svg <svg
className={`size-4 transition-transform ${openSubmenu === 'teams' ? 'rotate-180' : ''}`} className={`size-4 transition-transform ${openSubmenu === 'teams' ? 'rotate-180' : ''}`}
@ -87,7 +109,7 @@ export default function Sidebar() {
</Button> </Button>
{openSubmenu === 'teams' && ( {openSubmenu === 'teams' && (
<ul className="pl-6 space-y-1 mt-1"> <ul id="submenu-teams" className="pl-6 space-y-1 mt-1">
<li> <li>
<Button <Button
onClick={() => { router.push('/teams'); setIsOpen(false) }} onClick={() => { router.push('/teams'); setIsOpen(false) }}
@ -95,7 +117,7 @@ export default function Sidebar() {
variant="link" variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700" className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700"
> >
Übersicht {tNav('teams.overview')}
</Button> </Button>
</li> </li>
<li> <li>
@ -105,7 +127,7 @@ export default function Sidebar() {
variant="link" variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700" className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700"
> >
Teamverwaltung {tNav('teams.manage')}
</Button> </Button>
</li> </li>
</ul> </ul>
@ -119,6 +141,8 @@ export default function Sidebar() {
size="sm" size="sm"
variant="link" variant="link"
className={`${navBtnBase} ${idleClasses} justify-between`} className={`${navBtnBase} ${idleClasses} justify-between`}
aria-expanded={openSubmenu === 'players'}
aria-controls="submenu-players"
> >
<span className="flex items-center gap-x-3.5"> <span className="flex items-center gap-x-3.5">
<svg className="size-4" viewBox="0 0 24 24" fill="currentColor"> <svg className="size-4" viewBox="0 0 24 24" fill="currentColor">
@ -128,7 +152,7 @@ export default function Sidebar() {
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
Spieler {tNav('players.label')}
</span> </span>
<svg <svg
className={`size-4 transition-transform ${openSubmenu === 'players' ? 'rotate-180' : ''}`} className={`size-4 transition-transform ${openSubmenu === 'players' ? 'rotate-180' : ''}`}
@ -139,7 +163,7 @@ export default function Sidebar() {
</Button> </Button>
{openSubmenu === 'players' && ( {openSubmenu === 'players' && (
<ul className="pl-6 space-y-1 mt-1"> <ul id="submenu-players" className="pl-6 space-y-1 mt-1">
<li> <li>
<Button <Button
onClick={() => { router.push('/players'); setIsOpen(false) }} onClick={() => { router.push('/players'); setIsOpen(false) }}
@ -147,7 +171,7 @@ export default function Sidebar() {
variant="link" variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700" className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700"
> >
Übersicht {tNav('players.overview')}
</Button> </Button>
</li> </li>
<li> <li>
@ -157,7 +181,7 @@ export default function Sidebar() {
variant="link" variant="link"
className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700" className="w-full text-start text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-neutral-700"
> >
Statistiken {tNav('players.stats')}
</Button> </Button>
</li> </li>
</ul> </ul>
@ -179,19 +203,52 @@ export default function Sidebar() {
<line x1="3" x2="21" y1="10" y2="10" /> <line x1="3" x2="21" y1="10" y2="10" />
<path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01" /> <path d="M8 14h.01M12 14h.01M16 14h.01M8 18h.01M12 18h.01M16 18h.01" />
</svg> </svg>
Spielplan {tNav('schedule')}
</Button> </Button>
</li> </li>
</ul> </ul>
</nav> </nav>
{/* Language Switcher ganz unten, mittig (mit Flaggen) */}
<div className="mt-2 mb-2 px-4">
<div className="flex items-center justify-center">
<Select
value={locale}
onChange={(val) => changeLocale(val as 'en' | 'de')}
dropDirection="up"
showArrow={false}
fullWidth={false} // 👈 NEU
options={[
{
value: 'en',
label: (
<span className="inline-flex items-center gap-2">
<span className="fi fi-gb" aria-hidden />
<span>{tSidebar('language.en')}</span>
</span>
)
},
{
value: 'de',
label: (
<span className="inline-flex items-center gap-2">
<span className="fi fi-de" aria-hidden />
<span>{tSidebar('language.de')}</span>
</span>
)
}
]}
placeholder="Language"
/>
</div>
</div>
<footer className="mt-auto border-t border-gray-200 dark:border-neutral-700"> <footer className="mt-auto border-t border-gray-200 dark:border-neutral-700">
<SidebarFooter /> <SidebarFooter />
</footer> </footer>
</div> </div>
), ),
// eslint-disable-next-line react-hooks/exhaustive-deps [pathname, openSubmenu, locale, tNav, tSidebar]
[pathname, openSubmenu]
) )
return ( return (
@ -232,7 +289,6 @@ export default function Sidebar() {
className="absolute inset-0 bg-black/40" className="absolute inset-0 bg-black/40"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
/> />
{/* Panel */} {/* Panel */}
<div <div
className=" className="

View File

@ -2,11 +2,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSession, signIn, signOut } from 'next-auth/react' import { useSession, signIn, signOut } from 'next-auth/react'
import { signOutWithStatus } from '@/app/lib/signOutWithStatus' import { useTranslations, useLocale } from 'next-intl'
import { signOutWithStatus } from '@/lib/signOutWithStatus'
import { useRouter, usePathname } from 'next/navigation' import { useRouter, usePathname } from 'next/navigation'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import Image from 'next/image' import Image from 'next/image'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '../components/LoadingSpinner'
import Button from './Button' import Button from './Button'
import UserAvatarWithStatus from './UserAvatarWithStatus' import UserAvatarWithStatus from './UserAvatarWithStatus'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
@ -14,6 +15,10 @@ import PremierRankBadge from './PremierRankBadge'
export default function SidebarFooter() { export default function SidebarFooter() {
const router = useRouter() const router = useRouter()
const pathname = usePathname() const pathname = usePathname()
// Übersetzungen
const tSidebar = useTranslations('sidebar')
const { data: session, status } = useSession() const { data: session, status } = useSession()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@ -47,11 +52,11 @@ export default function SidebarFooter() {
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
return ( return (
<button <button
onClick={() => signIn('steam')} onClick={() => signIn('steam', { callbackUrl: `/dashboard` })}
className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition" className="flex items-center justify-center gap-2 w-full py-4 px-6 bg-green-800 text-white text-md font-medium hover:bg-green-900 transition"
> >
<i className="fab fa-steam" /> <i className="fab fa-steam" />
<span>Mit Steam anmelden</span> <span>{tSidebar('footer.login')}</span>
</button> </button>
) )
} }
@ -132,7 +137,7 @@ export default function SidebarFooter() {
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/> <path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg> </svg>
Profil {tSidebar('footer.profile')}
</Button> </Button>
<Button <Button
@ -145,7 +150,7 @@ export default function SidebarFooter() {
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/> <path d="M15 9h3m-3 3h3m-3 3h3m-6 1c-.306-.613-.933-1-1.618-1H7.618c-.685 0-1.312.387-1.618 1M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm7 5a2 2 0 1 1-4 0 2 2 0 0 1 4 0Z"/>
</svg> </svg>
Team {tSidebar('footer.team')}
</Button> </Button>
<Button <Button
@ -158,7 +163,7 @@ export default function SidebarFooter() {
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 19H5a1 1 0 0 1-1-1v-1a3 3 0 0 1 3-3h2m10 1a3 3 0 0 1-3 3m3-3a3 3 0 0 0-3-3m3 3h1m-4 3a3 3 0 0 1-3-3m3 3v1m-3-4a3 3 0 0 1 3-3m-3 3h-1m4-3v-1m-2.121 1.879-.707-.707m5.656 5.656-.707-.707m-4.242 0-.707.707m5.656-5.656-.707.707M12 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/> <path d="M10 19H5a1 1 0 0 1-1-1v-1a3 3 0 0 1 3-3h2m10 1a3 3 0 0 1-3 3m3-3a3 3 0 0 0-3-3m3 3h1m-4 3a3 3 0 0 1-3-3m3 3v1m-3-4a3 3 0 0 1 3-3m-3 3h-1m4-3v-1m-2.121 1.879-.707-.707m5.656 5.656-.707-.707m-4.242 0-.707.707m5.656-5.656-.707.707M12 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
</svg> </svg>
Einstellungen {tSidebar('footer.settings')}
</Button> </Button>
{session?.user?.isAdmin && ( {session?.user?.isAdmin && (
@ -172,7 +177,7 @@ export default function SidebarFooter() {
fill="currentColor"> fill="currentColor">
<path transform="scale(0.046875)" d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l80 104c4.5 5.9 11.6 9.4 19 9.4l54.1 0 109 109c-14.7 29-10 65.4 14.3 89.6l112 112c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-112-112c-24.2-24.2-60.6-29-89.6-14.3l-109-109 0-54.1c0-7.5-3.5-14.5-9.4-19L78.6 5zM19.9 396.1C7.2 408.8 0 426.1 0 444.1C0 481.6 30.4 512 67.9 512c18 0 35.3-7.2 48-19.9L233.7 374.3c-7.8-20.9-9-43.6-3.6-65.1l-61.7-61.7L19.9 396.1zM512 144c0-10.5-1.1-20.7-3.2-30.5c-2.4-11.2-16.1-14.1-24.2-6l-63.9 63.9c-3 3-7.1 4.7-11.3 4.7L352 176c-8.8 0-16-7.2-16-16l0-57.4c0-4.2 1.7-8.3 4.7-11.3l63.9-63.9c8.1-8.1 5.2-21.8-6-24.2C388.7 1.1 378.5 0 368 0C288.5 0 224 64.5 224 144l0 .8 85.3 85.3c36-9.1 75.8 .5 104 28.7L429 274.5c49-23 83-72.8 83-130.5zM56 432a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"/> <path transform="scale(0.046875)" d="M78.6 5C69.1-2.4 55.6-1.5 47 7L7 47c-8.5 8.5-9.4 22-2.1 31.6l80 104c4.5 5.9 11.6 9.4 19 9.4l54.1 0 109 109c-14.7 29-10 65.4 14.3 89.6l112 112c12.5 12.5 32.8 12.5 45.3 0l64-64c12.5-12.5 12.5-32.8 0-45.3l-112-112c-24.2-24.2-60.6-29-89.6-14.3l-109-109 0-54.1c0-7.5-3.5-14.5-9.4-19L78.6 5zM19.9 396.1C7.2 408.8 0 426.1 0 444.1C0 481.6 30.4 512 67.9 512c18 0 35.3-7.2 48-19.9L233.7 374.3c-7.8-20.9-9-43.6-3.6-65.1l-61.7-61.7L19.9 396.1zM512 144c0-10.5-1.1-20.7-3.2-30.5c-2.4-11.2-16.1-14.1-24.2-6l-63.9 63.9c-3 3-7.1 4.7-11.3 4.7L352 176c-8.8 0-16-7.2-16-16l0-57.4c0-4.2 1.7-8.3 4.7-11.3l63.9-63.9c8.1-8.1 5.2-21.8-6-24.2C388.7 1.1 378.5 0 368 0C288.5 0 224 64.5 224 144l0 .8 85.3 85.3c36-9.1 75.8 .5 104 28.7L429 274.5c49-23 83-72.8 83-130.5zM56 432a24 24 0 1 1 48 0 24 24 0 1 1 -48 0z"/>
</svg> </svg>
Administration {tSidebar('footer.administration')}
</Button> </Button>
)} )}
@ -187,7 +192,7 @@ export default function SidebarFooter() {
viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/> <path d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg> </svg>
Abmelden {tSidebar('footer.signout')}
</Button> </Button>
</div> </div>
</motion.div> </motion.div>

View File

@ -4,7 +4,7 @@
import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable' import { useSortable, defaultAnimateLayoutChanges } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import MiniCard from './MiniCard' import MiniCard from './MiniCard'
import { Player } from '../types/team' import { Player } from '../../../types/team'
type Props = { type Props = {
player: Player player: Player

View File

@ -5,7 +5,7 @@ import { useState, useMemo } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Button from './Button' import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Team } from '../types/team' import type { Team } from '../../../types/team'
type Props = { type Props = {
team: Team team: Team

View File

@ -8,17 +8,17 @@ import { useSession } from 'next-auth/react'
import TeamInvitationBanner from './TeamInvitationBanner' import TeamInvitationBanner from './TeamInvitationBanner'
import TeamMemberView from './TeamMemberView' import TeamMemberView from './TeamMemberView'
import NoTeamView from './NoTeamView' import NoTeamView from './NoTeamView'
import LoadingSpinner from '@/app/components/LoadingSpinner' import LoadingSpinner from '../components/LoadingSpinner'
import CreateTeamButton from './CreateTeamButton' import CreateTeamButton from './CreateTeamButton'
import type { Player, Team } from '../types/team' import type { Player, Team } from '../../../types/team'
import type { Invitation } from '../types/invitation' import type { Invitation } from '../../../types/invitation'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { import {
INVITE_EVENTS, INVITE_EVENTS,
TEAM_EVENTS, TEAM_EVENTS,
SELF_EVENTS, SELF_EVENTS,
isSseEventType, isSseEventType,
} from '@/app/lib/sseEvents' } from '@/lib/sseEvents'
type Props = { type Props = {
refetchKey?: string refetchKey?: string

View File

@ -5,8 +5,8 @@ import { useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Button from './Button' import Button from './Button'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import type { Invitation } from '../types/invitation' import type { Invitation } from '../../../types/invitation'
import type { Team } from '../types/team' import type { Team } from '../../../types/team'
type Props = { type Props = {
invitation: Invitation invitation: Invitation

View File

@ -10,22 +10,22 @@ import SortableMiniCard from './SortableMiniCard'
import LeaveTeamModal from './LeaveTeamModal' import LeaveTeamModal from './LeaveTeamModal'
import InvitePlayersModal from './InvitePlayersModal' import InvitePlayersModal from './InvitePlayersModal'
import Modal from './Modal' import Modal from './Modal'
import { Player } from '../types/team' import { Player } from '../../../types/team'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { leaveTeam, reloadTeam, renameTeam } from '@/app/lib/sse-actions' import { leaveTeam, reloadTeam, renameTeam } from '@/lib/sse-actions'
import Button from './Button' import Button from './Button'
import Image from 'next/image' import Image from 'next/image'
import TeamPremierRankBadge from './TeamPremierRankBadge' import TeamPremierRankBadge from './TeamPremierRankBadge'
import Link from 'next/link' import Link from 'next/link'
import { Team } from '../types/team' import { Team } from '../../../types/team'
import { useTeamStore } from '../lib/stores' import { useTeamStore } from '@/lib/stores'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
import { import {
TEAM_EVENTS, TEAM_EVENTS,
SELF_EVENTS, SELF_EVENTS,
isSseEventType, isSseEventType,
type SSEEventType, type SSEEventType,
} from '@/app/lib/sseEvents' } from '@/lib/sseEvents'
type Props = { type Props = {
team?: Team team?: Team

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
import { Player } from '../types/team' import { Player } from '../../../types/team'
type Props = { type Props = {
players: Player[] players: Player[]

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import ComboBox from '@/app/components/ComboBox' import ComboBox from '../components/ComboBox'
export default function TeamSelector() { export default function TeamSelector() {
const [teams, setTeams] = useState<string[]>([]) const [teams, setTeams] = useState<string[]>([])

View File

@ -3,9 +3,11 @@
import React, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
import Link from 'next/link' import Link from 'next/link'
import Button from '@/app/components/Button' import Button from '../components/Button'
import { useUiChromeStore } from '@/app/lib/useUiChromeStore' import { useUiChromeStore } from '@/lib/useUiChromeStore'
import { MAP_OPTIONS } from '@/app/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
export type TelemetryBannerVariant = 'connected' | 'disconnected' export type TelemetryBannerVariant = 'connected' | 'disconnected'
@ -76,6 +78,12 @@ export default function TelemetryBanner({
const phaseStr = String(phase ?? 'unknown').toLowerCase() const phaseStr = String(phase ?? 'unknown').toLowerCase()
const show = visible && phaseStr !== 'unknown' const show = visible && phaseStr !== 'unknown'
const router = useRouter()
const pathname = usePathname()
// Übersetzungen
const tGameBanner = useTranslations('game-banner')
useEffect(() => { useEffect(() => {
if (!show) { setBannerPx(0); return } if (!show) { setBannerPx(0); return }
const el = ref.current const el = ref.current
@ -163,28 +171,28 @@ export default function TelemetryBanner({
<> <>
<div className="text-sm flex items-center gap-2"> <div className="text-sm flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15"> <span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
{serverLabel ?? 'CS2-Server'} {serverLabel ?? 'CS2 Server'}
</span> </span>
</div> </div>
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1"> <div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
<span>Map: <span className="font-semibold">{prettyMap}</span></span> <span>Map: <span className="font-semibold">{prettyMap}</span></span>
<span>Phase: <span className="font-semibold">{prettyPhase}</span></span> <span>Phase: <span className="font-semibold">{prettyPhase}</span></span>
<span>Score: <span className="font-semibold">{prettyScore}</span></span> <span>Score: <span className="font-semibold">{prettyScore}</span></span>
<span>Spieler verbunden: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span> <span>{tGameBanner("player-connected")}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
</div> </div>
</> </>
) : ( ) : (
<> <>
<div className="text-sm flex items-center gap-2"> <div className="text-sm flex items-center gap-2">
<span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15"> <span className="inline-flex items-center gap-1 font-semibold px-2 py-0.5 rounded-md bg-white/10 ring-1 ring-white/15">
Verbindung getrennt {tGameBanner("disconnected")}
</span> </span>
</div> </div>
<div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1"> <div className="text-xs opacity-90 mt-0.5 flex flex-wrap gap-x-3 gap-y-1">
<span>Map: <span className="font-semibold">{prettyMap}</span></span> <span>Map: <span className="font-semibold">{prettyMap}</span></span>
<span>Phase: <span className="font-semibold">{prettyPhase}</span></span> <span>Phase: <span className="font-semibold">{prettyPhase}</span></span>
<span>Score: <span className="font-semibold">{prettyScore}</span></span> <span>Score: <span className="font-semibold">{prettyScore}</span></span>
<span>Spieler verbunden: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span> <span>{tGameBanner("player-connected")}: <span className="font-semibold">{connectedCount}</span> / {totalExpected}</span>
</div> </div>
</> </>
)} )}
@ -237,7 +245,7 @@ export default function TelemetryBanner({
variant="solid" variant="solid"
size="md" size="md"
onClick={() => onReconnect()} onClick={() => onReconnect()}
title="Neu verbinden" title={tGameBanner("reconnect")}
/> />
)} )}
@ -249,7 +257,7 @@ export default function TelemetryBanner({
size="md" size="md"
className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none" className="w-12 h-12 !p-0 flex flex-col items-center justify-center leading-none"
onClick={() => onDisconnect?.()} onClick={() => onDisconnect?.()}
aria-label="Verbindung trennen" aria-label={tGameBanner("disconnected")}
title={undefined} title={undefined}
> >
<svg <svg
@ -259,7 +267,7 @@ export default function TelemetryBanner({
> >
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> <path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg> </svg>
<span className="mt-0.5 text-[11px] font-medium opacity-90">Verlassen</span> <span className="mt-0.5 text-[11px] font-medium opacity-90">{tGameBanner("quit")}</span>
</Button> </Button>
)} )}
</div> </div>

View File

@ -3,13 +3,13 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useReadyOverlayStore } from '@/app/lib/useReadyOverlayStore' import { useReadyOverlayStore } from '@/lib/useReadyOverlayStore'
import { usePresenceStore } from '@/app/lib/usePresenceStore' import { usePresenceStore } from '@/lib/usePresenceStore'
import { useTelemetryStore } from '@/app/lib/useTelemetryStore' import { useTelemetryStore } from '@/lib/useTelemetryStore'
import { useMatchRosterStore } from '@/app/lib/useMatchRosterStore' import { useMatchRosterStore } from '@/lib/useMatchRosterStore'
import TelemetryBanner from './TelemetryBanner' import TelemetryBanner from './TelemetryBanner'
import { MAP_OPTIONS } from '@/app/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) { function makeWsUrl(host?: string, port?: string, path?: string, scheme?: string) {
const h = (host ?? '').trim() || '127.0.0.1' const h = (host ?? '').trim() || '127.0.0.1'

View File

@ -4,7 +4,7 @@
import { useEffect, useState, useMemo } from 'react' import { useEffect, useState, useMemo } from 'react'
import Image from 'next/image' import Image from 'next/image'
import clsx from 'clsx' import clsx from 'clsx'
import { useSSEStore } from '@/app/lib/useSSEStore' import { useSSEStore } from '@/lib/useSSEStore'
type Presence = 'online' | 'away' | 'offline' type Presence = 'online' | 'away' | 'offline'

View File

@ -1,5 +1,5 @@
// /src/app/components/UserHeader.tsx // /src/app/components/UserHeader.tsx
import { Tabs } from '@/app/components/Tabs' import { Tabs } from '../components/Tabs'
import PremierRankBadge from './PremierRankBadge' import PremierRankBadge from './PremierRankBadge'
type UserHeaderProps = { type UserHeaderProps = {

View File

@ -1,17 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react'
import Modal from '@/app/components/Modal'
import Select from '@/app/components/Select'
import Input from '../Input'
import Button from '../Button'
import DatePickerWithTime from '../DatePickerWithTime'
import Link from 'next/link'
import Image from 'next/image'
import Switch from '../Switch'
import CommunityMatchList from '../CommunityMatchList' import CommunityMatchList from '../CommunityMatchList'
import Card from '../Card'
function getRoundedDate() { function getRoundedDate() {
const now = new Date() const now = new Date()

View File

@ -1,5 +1,5 @@
// /src/app/admin/server/ServerView.tsx // /src/app/admin/server/ServerView.tsx
import Button from '@/app/components/Button' // ⬅️ neu import Button from '../../Button'
type ServerConfigShape = { type ServerConfigShape = {
serverIp: string serverIp: string

View File

@ -4,11 +4,11 @@
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Button from '@/app/components/Button' import Button from '../../Button'
import Modal from '@/app/components/Modal' import Modal from '../../Modal'
import Input from '@/app/components/Input' import Input from '../../Input'
import TeamCard from '@/app/components/TeamCard' import TeamCard from '../../TeamCard'
import type { Team } from '@/app/types/team' import type { Team } from '@/types/team'
import LoadingSpinner from '../../LoadingSpinner' import LoadingSpinner from '../../LoadingSpinner'
export default function AdminTeamsView() { export default function AdminTeamsView() {

View File

@ -8,8 +8,8 @@ import { useRouter } from 'next/navigation'
import Table from '../../../Table' import Table from '../../../Table'
import PremierRankBadge from '../../../PremierRankBadge' import PremierRankBadge from '../../../PremierRankBadge'
import CompRankBadge from '../../../CompRankBadge' import CompRankBadge from '../../../CompRankBadge'
import { MAP_OPTIONS } from '@/app/lib/mapOptions' import { MAP_OPTIONS } from '@/lib/mapOptions'
import Button from '@/app/components/Button' import Button from '../../../Button'
interface Match { interface Match {
id: string id: string

View File

@ -1,10 +1,9 @@
'use client' 'use client'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import Chart from '@/app/components/Chart' import Chart from '../../../Chart'
import { MatchStats } from '@/app/types/match' import { MatchStats } from '@/types/match'
import Card from '../../../Card' import Card from '../../../Card'
// import UserClips from '../../../UserClips'
type MatchStatsProps = { type MatchStatsProps = {
stats: { matches: MatchStats[] } stats: { matches: MatchStats[] }
@ -12,7 +11,7 @@ type MatchStatsProps = {
export default function UserProfile({ stats }: MatchStatsProps) { export default function UserProfile({ stats }: MatchStatsProps) {
const { data: session } = useSession() const { data: session } = useSession()
const steamId = session?.user?.steamId ?? '' // ← für UserClips const steamId = session?.user?.steamId ?? ''
const { matches } = stats const { matches } = stats

View File

@ -8,8 +8,8 @@ import StaticEffects from './StaticEffects';
import RadarHeader from './RadarHeader'; import RadarHeader from './RadarHeader';
import RadarCanvas from './RadarCanvas'; import RadarCanvas from './RadarCanvas';
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore'; import { useAvatarDirectoryStore } from '../../lib/useAvatarDirectoryStore';
import { useTelemetryStore } from '@/app/lib/useTelemetryStore'; import { useTelemetryStore } from '../../lib/useTelemetryStore';
import { useBombBeep } from './hooks/useBombBeep'; import { useBombBeep } from './hooks/useBombBeep';
import { useOverview } from './hooks/useOverview'; import { useOverview } from './hooks/useOverview';

View File

@ -1,7 +1,7 @@
// /src/app/radar/TeamSidebar.tsx // /src/app/radar/TeamSidebar.tsx
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useAvatarDirectoryStore } from '@/app/lib/useAvatarDirectoryStore' import { useAvatarDirectoryStore } from '../../lib/useAvatarDirectoryStore'
export type Team = 'T' | 'CT' export type Team = 'T' | 'CT'

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { BombState } from '../lib/types'; import { BombState } from '@/lib/types';
const BOMB_FUSE_MS = 40_000; const BOMB_FUSE_MS = 40_000;

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Mapper, Overview } from '../lib/types'; import { Mapper, Overview } from '@/lib/types';
import { defaultWorldToPx, parseOverviewJson, parseValveKvOverview } from '../lib/helpers'; import { defaultWorldToPx, parseOverviewJson, parseValveKvOverview } from '@/lib/helpers';
export function useOverview(activeMapKey: string | null, playersForAutoFit: {x:number;y:number}[]) { export function useOverview(activeMapKey: string | null, playersForAutoFit: {x:number;y:number}[]) {
const [overview, setOverview] = useState<Overview | null>(null); const [overview, setOverview] = useState<Overview | null>(null);

View File

@ -1,8 +1,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '../lib/types'; import { BombState, DeathMarker, Grenade, PlayerState, Score, Trail, WsStatus } from '@/lib/types';
import { UI } from '../lib/ui'; import { UI } from '@/lib/ui';
import { asNum, mapTeam, steamIdOf } from '../lib/helpers'; import { asNum, mapTeam, steamIdOf } from '@/lib/helpers';
import { normalizeGrenades } from '../lib/grenades'; import { normalizeGrenades } from '@/lib/grenades';
export function useRadarState(mySteamId: string | null) { export function useRadarState(mySteamId: string | null) {
// WS / Map // WS / Map

View File

@ -4,12 +4,20 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useSession } from 'next-auth/react' import { useSession } from 'next-auth/react'
import { useRouter, usePathname } from '@/i18n/navigation'
import { useTranslations, useLocale } from 'next-intl'
export default function Dashboard() { export default function Dashboard() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
const [teams, setTeams] = useState<string[]>([]) const [teams, setTeams] = useState<string[]>([])
const [selectedTeam, setSelectedTeam] = useState('') const [selectedTeam, setSelectedTeam] = useState('')
const router = useRouter()
const pathname = usePathname()
const locale = useLocale()
const tDashboard = useTranslations('dashboard')
// Teams laden (robust) // Teams laden (robust)
useEffect(() => { useEffect(() => {
let abort = false let abort = false
@ -44,7 +52,7 @@ export default function Dashboard() {
return ( return (
<> <>
<h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white"> <h1 className="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
Willkommen im Dashboard! {tDashboard('title')}
</h1> </h1>
{/* Beispiel: Teams anzeigen (optional) */} {/* Beispiel: Teams anzeigen (optional) */}

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

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