Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion frontend/app/core/contexts/SearchContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createContext, useState, useCallback, type ReactNode } from "react"
import { useInvalidateServers } from "@/core/hooks/useInvalidateServers"

interface SearchContextType {
searchQuery: string
Expand All @@ -14,10 +15,14 @@ export { SearchContext }
export function SearchProvider({ children }: { children: ReactNode }) {
const [searchQuery, setSearchQuery] = useState("")
const [refreshCounter, setRefreshCounter] = useState(0)
const invalidateServers = useInvalidateServers()

const triggerRefresh = useCallback(() => {
setRefreshCounter(prev => prev + 1)
}, [])
// Le nouveau serveur doit apparaître dans le Ctrl+K (SearchBar) et dans
// la home : on invalide les deux caches pour forcer un refetch client.
invalidateServers()
}, [invalidateServers])

return (
<SearchContext.Provider value={{ searchQuery, setSearchQuery, refreshCounter, triggerRefresh }}>
Expand Down
19 changes: 19 additions & 0 deletions frontend/app/core/hooks/useInvalidateServers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useCallback } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { SERVERS_QUERY_KEY, SERVERS_SEARCH_QUERY_KEY } from "@/core/lib/query-keys"

/**
* Retourne un callback qui invalide les caches globaux de la liste des
* serveurs (home + recherche Ctrl+K) pour forcer un refetch client.
*
* À appeler après toute mutation qui change la liste ou son contenu :
* ajout, suppression, renommage, changement d'IP/favicon, visibilité.
*/
export function useInvalidateServers() {
const queryClient = useQueryClient()

return useCallback(() => {
queryClient.invalidateQueries({ queryKey: SERVERS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: SERVERS_SEARCH_QUERY_KEY })
}, [queryClient])
}
17 changes: 17 additions & 0 deletions frontend/app/core/lib/query-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Clés TanStack Query partagées pour la liste des serveurs.
*
* Centralisées ici pour éviter les chaînes magiques dupliquées (typos,
* renommages partiels) : toute lecture/écriture/invalidation de ces caches
* doit passer par ces constantes.
*/

/** Liste globale des serveurs (home, `include_stats=true`), amorcée par le SSR. */
export const SERVERS_QUERY_KEY = ["servers"] as const

/**
* Préfixe du cache de la recherche Ctrl+K.
* La clé complète est `[..., refreshCounter]` ; l'invalidation par préfixe
* (sans `exact: true`) couvre toutes les générations.
*/
export const SERVERS_SEARCH_QUERY_KEY = ["servers-search"] as const
48 changes: 48 additions & 0 deletions frontend/app/core/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest"
import { registerTranslations } from "./i18n"
import { formatDate, formatNumber } from "./utils"

// Dictionnaires minimaux : formatDate ne dépend que de common.months et
// common.dateFormat (même contrat que les locales réelles).
registerTranslations("fr", {
common: {
months: {
0: "janvier",
8: "septembre",
9: "octobre",
},
dateFormat: "{{day}} {{month}} {{year}}",
},
})

describe("formatDate", () => {
// 13 octobre 2026 23:30 UTC : en Europe/Paris (UTC+2) il est déjà le 14,
// à Kiritimati (UTC+14) le 14 à 13:30. Avec des getters locaux, le HTML
// SSR (UTC) et le rendu client divergeaient -> erreur d'hydratation #418.
const nearMidnightUtc = Date.UTC(2026, 9, 13, 23, 30)

it("renders the same UTC calendar date regardless of runtime timezone", () => {
const outputs = new Set<string>()
for (const tz of ["UTC", "Europe/Paris", "America/New_York", "Pacific/Kiritimati", "Australia/Sydney"]) {
process.env.TZ = tz
outputs.add(formatDate(nearMidnightUtc, "fr"))
}
process.env.TZ = "UTC"
expect(outputs.size).toBe(1)
expect([...outputs][0]).toBe("13 octobre 2026")
})

it("formats a regular date", () => {
process.env.TZ = "Europe/Paris"
expect(formatDate(Date.UTC(2026, 8, 5, 12, 0), "fr")).toBe("5 septembre 2026")
process.env.TZ = "UTC"
})
})

describe("formatNumber", () => {
it("groups thousands deterministically per language", () => {
expect(formatNumber("fr", 1234567)).toBe("1\u202F234\u202F567")
expect(formatNumber("en", 1234567)).toBe("1,234,567")
expect(formatNumber("fr", 0)).toBe("0")
})
})
11 changes: 8 additions & 3 deletions frontend/app/core/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,18 @@ export function formatNumber(language: string | undefined | null, value: number)
* navigateur (noms de mois, ordre) et peut casser l'hydratation (#418).
* Les noms de mois et le gabarit de format viennent des fichiers de
* traduction (common.months / common.dateFormat).
*
* La date est lue en UTC : `getDate()`/`getMonth()` utilisent le fuseau local,
* qui diffère entre le serveur SSR (UTC) et le navigateur (ex. Europe/Paris),
* ce qui faisait diverger le HTML SSR et le rendu client pour les timestamps
* proches de minuit (erreur d'hydratation #418 sur la page serveur).
*/
export function formatDate(timestamp: number, language: string | undefined | null): string {
const lang = acceptableLanguage(language)
const d = new Date(timestamp)
const day = d.getDate()
const year = d.getFullYear()
const month = translate(lang, `common.months.${d.getMonth()}`)
const day = d.getUTCDate()
const year = d.getUTCFullYear()
const month = translate(lang, `common.months.${d.getUTCMonth()}`)
const template = translate(lang, "common.dateFormat")
return template
.replaceAll("{{day}}", String(day))
Expand Down
11 changes: 10 additions & 1 deletion frontend/app/pages/account/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { MetaFunction } from "react-router"

import { useWebPush } from "@/core/hooks/useWebPush"
import { useToast } from "@/core/hooks/useToast"
import { useInvalidateServers } from "@/core/hooks/useInvalidateServers"
import { AccountServersTab } from "./components/AccountServersTab"
import { AccountAlertsTab } from "./components/AccountAlertsTab"
import { AccountProfileTab } from "./components/AccountProfileTab"
Expand Down Expand Up @@ -46,6 +47,7 @@ export default function Account() {
const [servers, setServers] = useState<Server[]>([])
const [allAlerts, setAllAlerts] = useState<ExtendedAlert[]>([])
const [loading, setLoading] = useState(true)
const invalidateServers = useInvalidateServers()

// Web Push State from Custom Hook
const {
Expand Down Expand Up @@ -88,6 +90,13 @@ export default function Account() {
}
}, [getToken, isLoaded, isSignedIn])

// Après un renommage : on recharge "mes serveurs" ET on invalide le cache
// global pour que la home et le Ctrl+K affichent le nouveau nom.
const handleServersMutated = useCallback(() => {
loadData()
invalidateServers()
}, [loadData, invalidateServers])

useEffect(() => {
if (isLoaded) {
// eslint-disable-next-line react-hooks/set-state-in-effect
Expand Down Expand Up @@ -216,7 +225,7 @@ export default function Account() {
t={t}
loading={loading}
servers={servers}
loadData={loadData}
loadData={handleServersMutated}
/>
)}

Expand Down
13 changes: 12 additions & 1 deletion frontend/app/pages/admin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAuth } from "@clerk/react"
import { useLanguage } from "@/core/contexts/LanguageContext"
import { useAdmin } from "@/core/contexts/AdminContext"
import { useToast } from "@/core/hooks/useToast"
import { useInvalidateServers } from "@/core/hooks/useInvalidateServers"
import { fetchAdminUsers, fetchServers, toggleServerVisibility } from "@/core/lib/api"
import type { User, Server } from "@/core/lib/api"
import { Button } from "@/ui/components/button"
Expand Down Expand Up @@ -80,6 +81,7 @@ export default function AdminDashboard() {
const [togglingServerId, setTogglingServerId] = useState<number | null>(null)

const { showToast: triggerToast } = useToast()
const invalidateServers = useInvalidateServers()

const loadData = useCallback(async () => {
if (!isAdmin) return
Expand All @@ -104,6 +106,14 @@ export default function AdminDashboard() {
}
}, [isAdmin, getToken, t, triggerToast])

// Après une mutation (delete/rename/IP/favicon/visibilité) : on recharge le
// tableau admin ET on invalide le cache global pour que la home et le
// Ctrl+K reflètent le changement sans attendre l'expiration du staleTime.
const refreshAfterMutation = useCallback(() => {
loadData()
invalidateServers()
}, [loadData, invalidateServers])

useEffect(() => {
if (isLoaded && isAdmin) {
// eslint-disable-next-line react-hooks/set-state-in-effect
Expand All @@ -124,6 +134,7 @@ export default function AdminDashboard() {

if (result.success) {
setServers(prev => prev.map(s => s.id === serverId ? { ...s, hidden: !currentHidden } : s))
invalidateServers()
triggerToast("success", t("admin.toast.visibilitySuccess", { name: serverName }))
} else {
triggerToast("error", result.message_key ? t(result.message_key) : result.message || t("common.error"))
Expand Down Expand Up @@ -345,7 +356,7 @@ export default function AdminDashboard() {
handleToggleServer={handleToggleServer}
getUserDisplayName={getUserDisplayName}
t={t}
onRefresh={loadData}
onRefresh={refreshAfterMutation}
triggerToast={triggerToast}
/>
)}
Expand Down
32 changes: 20 additions & 12 deletions frontend/app/pages/home/components/Hero3D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ArrowDown } from "lucide-react"
import { useLanguage } from "@/core/contexts/LanguageContext"
import { useTheme } from "@/core/hooks/useTheme"
import type { Server as ServerType } from "@/core/lib/api"
import { formatNumber } from "@/core/lib/utils"
import { resolveToken, withAlpha } from "@/core/lib/theme-colors"

// Predefined heights for the graph nodes (representing server player count stats)
Expand All @@ -24,19 +25,26 @@ const points = defaultYValues.map((y, idx) => {
}
})

// Animated counter hook (module-level to comply with React rules of hooks)
// Animated counter hook (module-level to comply with React rules of hooks).
// SSR-safe : le compteur démarre toujours à 0 (comme le HTML serveur) et la
// préférence `prefers-reduced-motion` n'est lue que dans l'effet, sinon le
// premier rendu client diverge du SSR -> erreur d'hydratation #418.
function useAnimatedCount(target: number, duration = 1200) {
const [prefersReducedMotion] = useState(
() => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches,
)
const [prevTarget, setPrevTarget] = useState(target)
const [count, setCount] = useState(() => (prefersReducedMotion ? target : 0))
const [count, setCount] = useState(0)
if (prevTarget !== target) {
setPrevTarget(target)
setCount(prefersReducedMotion ? target : 0)
setCount(0)
}
useEffect(() => {
if (prefersReducedMotion || target === 0) return
if (typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
// Synchro au montage (pas de rendu serveur divergent : SSR et
// premier rendu client partent de 0).
// eslint-disable-next-line react-hooks/set-state-in-effect
setCount(target)
return
}
if (target === 0) return
let start = 0
const step = target / (duration / 16)
const timer = setInterval(() => {
Expand All @@ -49,12 +57,12 @@ function useAnimatedCount(target: number, duration = 1200) {
}
}, 16)
return () => clearInterval(timer)
}, [target, duration, prefersReducedMotion])
}, [target, duration])
return count
}

export function Hero3D({ servers = [] }: { servers?: ServerType[] }) {
const { t } = useLanguage()
const { t, language } = useLanguage()
const { theme } = useTheme()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const containerRef = useRef<HTMLDivElement | null>(null)
Expand Down Expand Up @@ -429,7 +437,7 @@ export function Hero3D({ servers = [] }: { servers?: ServerType[] }) {
<div className="flex items-center justify-center lg:justify-start gap-5 sm:gap-8">
<div className="flex flex-col items-center">
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tabular-nums flex items-baseline">
{animatedTotal.toLocaleString()}<span className="text-info text-xl sm:text-2xl font-extrabold">+</span>
{formatNumber(language, animatedTotal)}<span className="text-info text-xl sm:text-2xl font-extrabold">+</span>
</p>
<p className="text-xs sm:text-xs text-muted-foreground mt-0.5 font-medium">
{t("hero.stats.servers")}
Expand All @@ -440,7 +448,7 @@ export function Hero3D({ servers = [] }: { servers?: ServerType[] }) {

<div className="flex flex-col items-center">
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tabular-nums">
{animatedOnline.toLocaleString()}
{formatNumber(language, animatedOnline)}
</p>
<p className="text-xs sm:text-xs text-muted-foreground mt-0.5 font-medium">
{t("hero.stats.online")}
Expand All @@ -451,7 +459,7 @@ export function Hero3D({ servers = [] }: { servers?: ServerType[] }) {

<div className="flex flex-col items-center">
<p className="text-2xl sm:text-3xl font-extrabold text-foreground tabular-nums">
{animatedPlayers.toLocaleString()}
{formatNumber(language, animatedPlayers)}
</p>
<p className="text-xs sm:text-xs text-muted-foreground mt-0.5 font-medium">
{t("hero.stats.players")}
Expand Down
3 changes: 2 additions & 1 deletion frontend/app/pages/home/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useAuth } from "@clerk/react"
import { useAdmin } from "@/core/contexts/AdminContext"
import { useSearch } from "@/core/hooks/useSearch"
import { useLanguage } from "@/core/contexts/LanguageContext"
import { SERVERS_QUERY_KEY } from "@/core/lib/query-keys"

import { Hero3D } from "@/pages/home/components/Hero3D"
import { FaqSection } from "@/pages/home/components/FaqSection"
Expand Down Expand Up @@ -130,7 +131,7 @@ function ServerListContent({ initialServers }: { initialServers: Server[] }) {
// données SSR. Remplace l'ancien doublon load()/handleRefresh(), les
// caches module-level et le refetch manuel au montage.
const { data: servers = initialServers || [], isPending, isFetching, error, refetch } = useQuery({
queryKey: ["servers"],
queryKey: SERVERS_QUERY_KEY,
queryFn: async () => {
const token = isLoaded && isSignedIn ? await getToken() : undefined
return fetchServers(token ?? undefined, true)
Expand Down
11 changes: 8 additions & 3 deletions frontend/app/ui/layout/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useNavigate } from "react-router"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { useLanguage } from "@/core/contexts/LanguageContext"
import { useSearch } from "@/core/hooks/useSearch"
import { SERVERS_QUERY_KEY, SERVERS_SEARCH_QUERY_KEY } from "@/core/lib/query-keys"
import { MINUTE_MS } from "@/core/lib/time"

import { LunarLogo } from "@/ui/components/LunarLogo"
Expand Down Expand Up @@ -57,11 +58,15 @@ export function SearchBar({ value: propValue, onChange: propOnChange, onSelect,

const queryClient = useQueryClient()
const { data: allServers = [] } = useQuery({
queryKey: ["servers-search", refreshCounter],
queryKey: [...SERVERS_SEARCH_QUERY_KEY, refreshCounter],
queryFn: async () => {
// Optimisation : Si la homepage a déjà chargé les serveurs (via SSR ou client), on les réutilise
const cachedServers = queryClient.getQueryData<Server[]>(["servers"])
if (cachedServers && cachedServers.length > 0) {
// SAUF si un refresh a été demandé (ajout de serveur) : le cache ["servers"]
// est alors invalidé et on doit refetcher pour que le nouveau serveur
// apparaisse dans le Ctrl+K.
const cachedServers = queryClient.getQueryData<Server[]>(SERVERS_QUERY_KEY)
const serversState = queryClient.getQueryState(SERVERS_QUERY_KEY)
if (cachedServers && cachedServers.length > 0 && !serversState?.isInvalidated) {
return cachedServers
}

Expand Down
7 changes: 6 additions & 1 deletion frontend/app/ui/layout/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ export function ThemeToggle() {
const { t } = useLanguage()

return (
<Button variant="outline" size="icon" onClick={toggleTheme}>
// Sans cookie de thème, le SSR suppose "dark" alors que le client
// résout `prefers-color-scheme` : l'icône peut diverger au premier
// rendu (corrigée juste après par l'effet). Divergence attendue et
// bénigne, on coupe l'avertissement d'hydratation (même approche que
// `suppressHydrationWarning` sur <html> dans root.tsx).
<Button variant="outline" size="icon" onClick={toggleTheme} suppressHydrationWarning>
{theme === "light" ? (
<Moon className="h-4 w-4" />
) : (
Expand Down
Loading