Merge pull request 'release/0.1.3' (#24) from release/0.1.3 into main
Reviewed-on: #24
This commit is contained in:
commit
3680f799df
13 changed files with 256 additions and 4 deletions
|
|
@ -4,6 +4,14 @@ Toutes les évolutions notables de NerdWare. Format basé sur
|
||||||
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
|
[Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), versionné en
|
||||||
[SemVer](https://semver.org/lang/fr/).
|
[SemVer](https://semver.org/lang/fr/).
|
||||||
|
|
||||||
|
## [0.1.3] — 2026-06-12
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Notes de version « Nouveautés »** : modale sur l'accueil listant les nouveautés
|
||||||
|
par version (curées, FR/EN), avec une pastille tant que la dernière version n'a
|
||||||
|
pas été vue. Composant `Dialog` réutilisable ajouté à `@workspace/ui`.
|
||||||
|
|
||||||
## [0.1.2] — 2026-06-12
|
## [0.1.2] — 2026-06-12
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|
@ -63,6 +71,7 @@ fin), trois modes, back-office et internationalisation.
|
||||||
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
|
- **Déploiement** : Dockerfiles (serveur Bun, client nginx) + `docker-compose.prod.yml`
|
||||||
prêt pour Dokploy.
|
prêt pour Dokploy.
|
||||||
|
|
||||||
|
[0.1.3]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.3
|
||||||
[0.1.2]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.2
|
[0.1.2]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.2
|
||||||
[0.1.1]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.1
|
[0.1.1]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.1
|
||||||
[0.1.0]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.0
|
[0.1.0]: https://git.ayoubbenziza.dev/ayoub/nerdware/src/tag/v0.1.0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nerdware/server",
|
"name": "@nerdware/server",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
79
apps/web/src/components/whats-new.tsx
Normal file
79
apps/web/src/components/whats-new.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
50
apps/web/src/lib/patch-notes.ts
Normal file
50
apps/web/src/lib/patch-notes.ts
Normal 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.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -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
2
apps/web/src/types/globals.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Version de l'app injectée par Vite (define), depuis apps/web/package.json.
|
||||||
|
declare const __APP_VERSION__: string
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "nerdware",
|
"name": "nerdware",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@nerdware/shared",
|
"name": "@nerdware/shared",
|
||||||
"version": "0.1.2",
|
"version": "0.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
94
packages/ui/src/components/dialog.tsx
Normal file
94
packages/ui/src/components/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue