release/0.1.3 #24

Merged
ayoub merged 4 commits from release/0.1.3 into main 2026-06-15 07:21:28 +00:00
8 changed files with 243 additions and 0 deletions
Showing only changes of commit 6692fad394 - Show all commits

View file

@ -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<string | null>(() => {
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 (
<Dialog
onOpenChange={(open) => {
if (open) {
markSeen()
}
}}
>
<DialogTrigger asChild>
<button
className={`text-muted-foreground hover:text-foreground relative inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors ${className}`}
>
<Sparkles className="size-4" /> {t.whatsNew.button}
{hasNew && (
<span className="bg-primary absolute -top-0.5 -right-0.5 size-2 rounded-full" />
)}
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.whatsNew.title}</DialogTitle>
</DialogHeader>
<div className="flex max-h-[60vh] flex-col gap-4 overflow-y-auto">
{PATCH_NOTES.map((note) => (
<div key={note.version} className="flex flex-col gap-1">
<p className="font-heading text-sm font-bold">
v{note.version}
<span className="text-muted-foreground text-xs font-normal">
{" "}
· {note.date}
</span>
</p>
<ul className="text-muted-foreground list-disc pl-5 text-sm">
{(lang === "fr" ? note.fr : note.en).map((line, i) => (
<li key={i}>{line}</li>
))}
</ul>
</div>
))}
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -10,6 +10,10 @@ export const en: Dict = {
loading: "Loading…", loading: "Loading…",
error: "Error", error: "Error",
}, },
whatsNew: {
button: "What's new",
title: "What's new",
},
home: { home: {
tagline: "Geek culture party game", tagline: "Geek culture party game",
create: "Create a room", create: "Create a room",

View file

@ -8,6 +8,10 @@ export const fr = {
loading: "Chargement…", loading: "Chargement…",
error: "Erreur", error: "Erreur",
}, },
whatsNew: {
button: "Nouveautés",
title: "Nouveautés",
},
home: { home: {
tagline: "Party game culture geek", tagline: "Party game culture geek",
create: "Créer une room", create: "Créer une room",

View file

@ -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.",
],
},
]

View file

@ -8,6 +8,7 @@ import { useI18n } from "@/i18n/context"
import { errorMessage } from "@/i18n/error" import { errorMessage } from "@/i18n/error"
import { LanguageToggle } from "@/i18n/language-toggle" import { LanguageToggle } from "@/i18n/language-toggle"
import { ThemeToggle } from "@/components/theme-toggle" import { ThemeToggle } from "@/components/theme-toggle"
import { WhatsNew } from "@/components/whats-new"
const inputClass = 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" "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 ( return (
<div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6"> <div className="relative flex min-h-svh items-center justify-center overflow-hidden p-6">
<div className="absolute top-4 right-4 z-10 flex items-center gap-2"> <div className="absolute top-4 right-4 z-10 flex items-center gap-2">
<WhatsNew />
<ThemeToggle /> <ThemeToggle />
<LanguageToggle /> <LanguageToggle />
</div> </div>

2
apps/web/src/types/globals.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
// Version de l'app injectée par Vite (define), depuis apps/web/package.json.
declare const __APP_VERSION__: string

View file

@ -1,11 +1,19 @@
import path from "path" import path from "path"
import { readFileSync } from "node:fs"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import { defineConfig } from "vite" import { defineConfig } from "vite"
const pkg = JSON.parse(
readFileSync(path.resolve(__dirname, "package.json"), "utf-8")
) as { version: string }
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),

View file

@ -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<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger(
props: React.ComponentProps<typeof DialogPrimitive.Trigger>
) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
/>
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-1/2 left-1/2 z-50 grid w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 rounded-2xl border p-6 shadow-lg duration-200",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring absolute top-4 right-4 rounded-md opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:outline-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-1.5 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-lg font-bold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
}