diff --git a/apps/web/src/components/whats-new.tsx b/apps/web/src/components/whats-new.tsx new file mode 100644 index 0000000..3534b24 --- /dev/null +++ b/apps/web/src/components/whats-new.tsx @@ -0,0 +1,79 @@ +import { useState } from "react" +import { Sparkles } from "lucide-react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@workspace/ui/components/dialog" +import { PATCH_NOTES } from "@/lib/patch-notes" +import { useI18n } from "@/i18n/context" + +const SEEN_KEY = "nerdware-seen-version" + +/** Bouton « Nouveautés » + modale des notes de version (pastille si version vue ≠ actuelle). */ +export function WhatsNew({ className = "" }: { className?: string }) { + const { t, lang } = useI18n() + const [seen, setSeen] = useState(() => { + try { + return localStorage.getItem(SEEN_KEY) + } catch { + return null + } + }) + const hasNew = seen !== __APP_VERSION__ + + function markSeen() { + try { + localStorage.setItem(SEEN_KEY, __APP_VERSION__) + } catch { + // ignore + } + setSeen(__APP_VERSION__) + } + + return ( + { + if (open) { + markSeen() + } + }} + > + + + + + + {t.whatsNew.title} + +
+ {PATCH_NOTES.map((note) => ( +
+

+ v{note.version} + + {" "} + · {note.date} + +

+
    + {(lang === "fr" ? note.fr : note.en).map((line, i) => ( +
  • {line}
  • + ))} +
+
+ ))} +
+
+
+ ) +} diff --git a/apps/web/src/i18n/en.ts b/apps/web/src/i18n/en.ts index 8129c18..c9df5c1 100644 --- a/apps/web/src/i18n/en.ts +++ b/apps/web/src/i18n/en.ts @@ -10,6 +10,10 @@ export const en: Dict = { loading: "Loading…", error: "Error", }, + whatsNew: { + button: "What's new", + title: "What's new", + }, home: { tagline: "Geek culture party game", create: "Create a room", diff --git a/apps/web/src/i18n/fr.ts b/apps/web/src/i18n/fr.ts index c6edb91..48b7a56 100644 --- a/apps/web/src/i18n/fr.ts +++ b/apps/web/src/i18n/fr.ts @@ -8,6 +8,10 @@ export const fr = { loading: "Chargement…", error: "Erreur", }, + whatsNew: { + button: "Nouveautés", + title: "Nouveautés", + }, home: { tagline: "Party game culture geek", create: "Créer une room", diff --git a/apps/web/src/lib/patch-notes.ts b/apps/web/src/lib/patch-notes.ts new file mode 100644 index 0000000..f58a05d --- /dev/null +++ b/apps/web/src/lib/patch-notes.ts @@ -0,0 +1,50 @@ +// Notes de version côté joueur (curées, bilingues). Sous-ensemble grand public +// du CHANGELOG.md (qui reste la source de vérité technique). + +export interface PatchNote { + version: string + date: string + fr: string[] + en: string[] +} + +export const PATCH_NOTES: PatchNote[] = [ + { + version: "0.1.2", + date: "2026-06-12", + fr: ["Statistiques d'audience (Umami)."], + en: ["Audience analytics (Umami)."], + }, + { + version: "0.1.1", + date: "2026-06-12", + fr: [ + "Blindtest : bouton « Activer le son » et volume réglable.", + "Blindtest porté à 90 s ; le DJ peut désormais être n'importe qui.", + "« Qui l'a ajouté ? » : l'état de validation est caché (plus de triche).", + "Thème clair / sombre / système.", + "Décompte synchronisé sur tous les appareils.", + ], + en: [ + "Blindtest: “Enable sound” button and volume control.", + "Blindtest is now 90 s; the DJ can be anyone.", + "“Who added it?”: vote status is hidden (no more giveaway).", + "Light / dark / system theme.", + "Countdown synced across all devices.", + ], + }, + { + version: "0.1.0", + date: "2026-06-11", + fr: [ + "Première version : Quiz, Images, Blindtest et mode Mixte.", + "Reconnexion après refresh, bots, historique des parties.", + "Interface FR / EN.", + ], + en: [ + "First release: Quiz, Images, Blindtest and Mixed mode.", + "Reconnect after refresh, bots, game history.", + "FR / EN interface.", + ], + }, +] diff --git a/apps/web/src/pages/home.tsx b/apps/web/src/pages/home.tsx index a862dd9..8760e2f 100644 --- a/apps/web/src/pages/home.tsx +++ b/apps/web/src/pages/home.tsx @@ -8,6 +8,7 @@ import { useI18n } from "@/i18n/context" import { errorMessage } from "@/i18n/error" import { LanguageToggle } from "@/i18n/language-toggle" import { ThemeToggle } from "@/components/theme-toggle" +import { WhatsNew } from "@/components/whats-new" const inputClass = "border-input bg-background ring-offset-background focus-visible:ring-ring h-10 w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50" @@ -62,6 +63,7 @@ export function HomePage() { return (
+
diff --git a/apps/web/src/types/globals.d.ts b/apps/web/src/types/globals.d.ts new file mode 100644 index 0000000..e69467a --- /dev/null +++ b/apps/web/src/types/globals.d.ts @@ -0,0 +1,2 @@ +// Version de l'app injectée par Vite (define), depuis apps/web/package.json. +declare const __APP_VERSION__: string diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 79a65e0..fe6ffe0 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,11 +1,19 @@ import path from "path" +import { readFileSync } from "node:fs" import tailwindcss from "@tailwindcss/vite" import react from "@vitejs/plugin-react" import { defineConfig } from "vite" +const pkg = JSON.parse( + readFileSync(path.resolve(__dirname, "package.json"), "utf-8") +) as { version: string } + // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx new file mode 100644 index 0000000..247a1f5 --- /dev/null +++ b/packages/ui/src/components/dialog.tsx @@ -0,0 +1,94 @@ +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" +import { X } from "lucide-react" + +import { cn } from "@workspace/ui/lib/utils" + +function Dialog(props: React.ComponentProps) { + return +} + +function DialogTrigger( + props: React.ComponentProps +) { + return +} + +function DialogClose(props: React.ComponentProps) { + return +} + +function DialogContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + {children} + + + Close + + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +}