Compare commits
2 Commits
187cdec0e2
...
e70f0c85bb
| Author | SHA1 | Date | |
|---|---|---|---|
| e70f0c85bb | |||
| 358bed0c2e |
@@ -217,11 +217,16 @@ Polices (`next/font/google`, variables CSS, exposées comme `font-heading`/`font
|
||||
|
||||
Ornements en SVG/CSS inline (jamais d'image bitmap) :
|
||||
- `components/laurel-wreath.tsx` — couronne de laurier calculée par trigonométrie (deux arcs symétriques de feuilles), utilisée en logo, sur le rang 1 du podium, etc. Le favicon (`src/app/icon.svg`) est une version statique des mêmes coordonnées.
|
||||
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret.
|
||||
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés.
|
||||
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit).
|
||||
- `.meander-divider` (classe globale) — frise à méandre grecque en `background-image` SVG data-URI, séparateur discret. Utilisée sous le header, et depuis la passe de polish visuel entre le podium et le reste du classement sur `/leaderboard` (`leaderboard-view.tsx`) — seulement quand un podium est réellement affiché.
|
||||
- `components/diamond-divider.tsx` — séparateur ◆ avec filets dégradés. Utilisé sur `/profile` entre la carte profil et la carte suggestion de question (deux actions distinctes, pas la même importance — voir la convention de carte secondaire ci-dessous).
|
||||
- `.marble-surface` (classe globale) — texture marbre via dégradés (pas d'image de bruit). Porte aussi, depuis la même passe, un léger halo doré respirant (`outline`, jamais `box-shadow`/`border` pour ne jamais entrer en conflit avec l'ombre/la bordure propre à chaque usage) — animation lente (7s), désactivée sous `prefers-reduced-motion`.
|
||||
- `components/empty-state.tsx` — petit ◆ doré au-dessus d'un message "rien à afficher" (`/journal`, `/leaderboard`, `/mur`), pour qu'un état vide se lise comme un état normal de l'app plutôt qu'un oubli.
|
||||
- `components/confetti.tsx` — partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie), extrait de l'ancien code propre à Icare. À l'appelant de vérifier `prefers-reduced-motion` avant de le monter (comme déjà fait sur les deux jeux).
|
||||
- Fond (`body`, `globals.css`) : le dégradé "mer" respire lentement (`--sea-glow`, `@property` pour une transition fluide côté navigateurs qui le supportent, dégradation sans casse sinon) plutôt que rester figé — même principe de subtilité, désactivé sous `prefers-reduced-motion`.
|
||||
|
||||
Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`.
|
||||
Convention de carte : `marble-surface rounded-2xl border border-gold/40 shadow-xl` (formulaires, podium) ou `rounded-lg border-gold/25` pour les lignes de liste. Carte secondaire (action annexe, pas le cœur de la page — ex. la suggestion de question sur `/profile`) : `border-gold/20 shadow-sm` plutôt que `border-gold/40 shadow-xl`, pour une hiérarchie visuelle claire sans sortir du langage marble-surface. Bouton primaire : `bg-ink-2 text-gold-bright font-heading uppercase hover:bg-ink`. Bouton secondaire : `border border-gold/30 text-text-marble hover:bg-gold/10`.
|
||||
|
||||
Sons : synthétisés via Web Audio (jamais de fichier audio dans le projet) — `src/lib/melon/sound.ts` (pop de fusion, La Corne d'Abondance) et `src/lib/icarus/sound.ts` (montée en dents de scie à la cueillette de l'éclair de Zeus, Le Vol d'Icare). `AudioContext` créé paresseusement au premier appel, toujours après un vrai geste utilisateur (contrainte d'autoplay des navigateurs).
|
||||
|
||||
Vocabulaire diégétique (toujours accompagné du terme fonctionnel dans le code/les commentaires, pour que ça reste maintenable) : juges/admins → **Archontes**, membres → **Citoyens**, leaderboard → **L'Agora**, journal des points → **Le Crieur** (décrets), points positifs/négatifs → **Honneurs**/**Blâmes**, points → **gloires**. Exception volontaire : la page `/admin` s'appelle simplement « Admin » (police monospace, rupture délibérée avec le vocabulaire diégétique et le thème grec, pour marquer que c'est un outil technique).
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export function EntryRanking({
|
||||
<li key={p.id} className="flex min-w-0 items-center gap-1.5 text-xs">
|
||||
<Avatar pseudo={p.pseudo} avatarUrl={p.avatar_url} size="xs" />
|
||||
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>{p.pseudo}</span>
|
||||
<span className="ml-auto shrink-0 font-heading text-gold-bright">{count > 0 ? `×${count}` : "—"}</span>
|
||||
<span className="ml-auto shrink-0 font-heading text-sea">{count > 0 ? `×${count}` : "—"}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
WALL_THICKNESS,
|
||||
} from "@/lib/melon/constants";
|
||||
import { playMergeSound } from "@/lib/melon/sound";
|
||||
import { Confetti } from "@/components/confetti";
|
||||
import type { MemberRow } from "./page";
|
||||
|
||||
type Status = "playing" | "dead";
|
||||
@@ -422,6 +423,9 @@ export function MelonGame({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- monté une seule fois ; spawnPiece/maxTier sont stables tant que citizens.length ne change pas
|
||||
}, []);
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex items-center justify-between" style={{ width: CONTAINER_WIDTH }}>
|
||||
@@ -525,6 +529,8 @@ export function MelonGame({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === "dead" && !prefersReducedMotion && <Confetti />}
|
||||
|
||||
{status === "dead" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-b-2xl bg-ink/70">
|
||||
<div className="marble-surface animate-reveal flex flex-col items-center gap-2 rounded-xl border border-gold/40 px-6 py-4 text-center shadow-lg">
|
||||
|
||||
+49
-1
@@ -41,10 +41,22 @@
|
||||
--font-sans: var(--font-manrope);
|
||||
}
|
||||
|
||||
/* Intensité du dégradé "mer" du fond, en pourcentage — enregistrée via
|
||||
@property pour qu'un navigateur qui la supporte anime une transition
|
||||
fluide entre les deux valeurs de ambient-glow (sinon dégradation sans
|
||||
casse : la valeur du keyframe s'applique quand même, juste sans lissage
|
||||
intermédiaire). */
|
||||
@property --sea-glow {
|
||||
syntax: "<percentage>";
|
||||
inherits: true;
|
||||
initial-value: 30%;
|
||||
}
|
||||
|
||||
body {
|
||||
--sea-glow: 30%;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 20% -10%, color-mix(in srgb, var(--ink-2) 75%, transparent) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) 30%, transparent) 0%, transparent 65%),
|
||||
radial-gradient(ellipse 70% 60% at 100% 110%, color-mix(in srgb, var(--sea) var(--sea-glow), transparent) 0%, transparent 65%),
|
||||
var(--ink);
|
||||
background-attachment: fixed;
|
||||
color: var(--foreground);
|
||||
@@ -112,6 +124,42 @@ body {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
/* Mer de nuit qui respire très lentement, plutôt qu'un fond figé — reste
|
||||
imperceptible comme un vrai mouvement, juste une ambiance qui vit. */
|
||||
@keyframes ambient-glow {
|
||||
from {
|
||||
--sea-glow: 22%;
|
||||
}
|
||||
to {
|
||||
--sea-glow: 42%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
animation: ambient-glow 40s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
/* Léger halo doré respirant sur les cartes marbre, façon lueur de
|
||||
torche — un outline (jamais box-shadow/border, déjà utilisés par les
|
||||
classes Tailwind de chaque carte) pour ne jamais entrer en conflit
|
||||
avec l'ombre/la bordure propres à chaque usage. N'affecte pas la mise
|
||||
en page (outline ne prend pas de place). */
|
||||
@keyframes card-flicker {
|
||||
0%,
|
||||
100% {
|
||||
outline-color: transparent;
|
||||
}
|
||||
50% {
|
||||
outline-color: color-mix(in srgb, var(--gold) 35%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.marble-surface {
|
||||
outline: 1px solid transparent;
|
||||
outline-offset: 2px;
|
||||
animation: card-flicker 7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes podium-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -43,11 +43,11 @@ import {
|
||||
stepIcarus,
|
||||
} from "@/lib/icarus/physics";
|
||||
import type { Boost, Column, IcarusState, ShatterEffect } from "@/lib/icarus/types";
|
||||
import { playBoostSound } from "@/lib/icarus/sound";
|
||||
import { Confetti } from "@/components/confetti";
|
||||
|
||||
type Status = "idle" | "flying" | "dead";
|
||||
|
||||
const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
|
||||
|
||||
// 0 avant DIFFICULTY_START_SCORE, monte jusqu'à 1 sur DIFFICULTY_RAMP_SPAN
|
||||
// points, puis reste à 1 (palier, pour ne pas devenir injouable).
|
||||
function difficultyProgress(score: number): number {
|
||||
@@ -499,6 +499,7 @@ export function IcarusGame({
|
||||
const collectedIndex = boostsRef.current.findIndex((b) => collidesWithBoost(icarusRef.current.y, b));
|
||||
if (collectedIndex !== -1) {
|
||||
boostsRef.current.splice(collectedIndex, 1);
|
||||
playBoostSound();
|
||||
// Active le bouclier pour les N prochaines colonnes (distance
|
||||
// aléatoire, voir plus bas) et déclenche l'animation de bond en
|
||||
// avant. Icare se fige (vitesse verticale annulée, plus de
|
||||
@@ -637,56 +638,6 @@ export function IcarusGame({
|
||||
);
|
||||
}
|
||||
|
||||
type ConfettiPiece = {
|
||||
id: number;
|
||||
left: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
duration: number;
|
||||
spin: number;
|
||||
drift: number;
|
||||
};
|
||||
|
||||
function generateConfettiPieces(): ConfettiPiece[] {
|
||||
return Array.from({ length: 26 }, (_, index) => ({
|
||||
id: index,
|
||||
left: Math.random() * 100,
|
||||
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
|
||||
delay: Math.random() * 250,
|
||||
duration: 900 + Math.random() * 700,
|
||||
spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360),
|
||||
drift: (Math.random() - 0.5) * 70,
|
||||
}));
|
||||
}
|
||||
|
||||
function Confetti() {
|
||||
// Initialiseur paresseux de useState (n'exécute Math.random() qu'une
|
||||
// seule fois, au montage — Confetti est remonté à chaque nouvelle partie
|
||||
// terminée) plutôt qu'un appel impur dans le corps du composant.
|
||||
const [pieces] = useState(generateConfettiPieces);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{pieces.map((piece) => (
|
||||
<span
|
||||
key={piece.id}
|
||||
className="animate-confetti-fall absolute top-0 h-2.5 w-1.5 rounded-[1px]"
|
||||
style={
|
||||
{
|
||||
left: `${piece.left}%`,
|
||||
backgroundColor: piece.color,
|
||||
animationDelay: `${piece.delay}ms`,
|
||||
animationDuration: `${piece.duration}ms`,
|
||||
"--confetti-spin": `${piece.spin}deg`,
|
||||
"--confetti-drift": `${piece.drift}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function render(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
|
||||
type Entry = {
|
||||
id: number;
|
||||
@@ -56,7 +57,7 @@ export function JournalView({
|
||||
}, []);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-marble/60">Aucun décret pour l'instant.</p>;
|
||||
return <EmptyState message="Aucun décret pour l'instant." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -72,7 +73,11 @@ export function JournalView({
|
||||
className="marble-surface flex gap-3 rounded-lg border border-gold/25 px-4 py-3 text-sm shadow-sm"
|
||||
>
|
||||
<span
|
||||
className="mt-1 h-3 w-3 shrink-0 rounded-full bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]"
|
||||
className={`mt-1 h-3 w-3 shrink-0 rounded-full ${
|
||||
entry.delta >= 0
|
||||
? "bg-olive shadow-[0_0_0_2px_rgba(94,107,59,0.25)]"
|
||||
: "bg-oxblood shadow-[0_0_0_2px_rgba(165,52,42,0.25)]"
|
||||
}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { Podium } from "@/components/podium";
|
||||
import { JudgePointControls } from "@/components/judge-point-controls";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { computeRanks, computeProgress } from "@/lib/ranking";
|
||||
|
||||
type Profile = {
|
||||
@@ -94,7 +95,7 @@ export function LeaderboardView({
|
||||
const rest = hasScores ? ranked.filter((p) => p.rank > 3) : ranked;
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return <p className="text-sm text-marble/60">Personne n'est encore inscrit.</p>;
|
||||
return <EmptyState message="Personne n'est encore inscrit." />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -107,6 +108,10 @@ export function LeaderboardView({
|
||||
onApplyDelta={applyOptimisticDelta}
|
||||
/>
|
||||
|
||||
{/* Frise à méandre : marque la transition podium -> reste du
|
||||
classement, seulement quand il y a bien un podium au-dessus. */}
|
||||
{podiumProfiles.length > 0 && <div className="meander-divider mb-4" />}
|
||||
|
||||
<ol className="flex flex-col gap-2">
|
||||
{rest.map((profile) => {
|
||||
const progress = computeProgress(profile.rank, profile.previous_rank);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from "@/components/page-loading";
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading />;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } fr
|
||||
import Image from "next/image";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { IconCamera, IconClose } from "@/components/icons";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { resizeImageToBlob } from "@/lib/image";
|
||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
|
||||
@@ -434,9 +435,7 @@ export function MurView({
|
||||
{pastLoading ? (
|
||||
<p className="text-center text-sm text-marble/60">Chargement…</p>
|
||||
) : displayedNotes.length === 0 ? (
|
||||
<p className="text-center text-sm text-marble/60">
|
||||
{viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."}
|
||||
</p>
|
||||
<EmptyState message={viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."} />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{displayedNotes.map((note) => (
|
||||
@@ -558,9 +557,10 @@ export function MurView({
|
||||
)}
|
||||
</div>
|
||||
{filteredHistory.length === 0 ? (
|
||||
<p className="text-sm text-text-mut">
|
||||
{history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
||||
</p>
|
||||
<EmptyState
|
||||
message={history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
||||
className="text-text-mut"
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-gold/10">
|
||||
{filteredHistory.map((note) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { DiamondDivider } from "@/components/diamond-divider";
|
||||
import { ProfileForm } from "./profile-form";
|
||||
import { ChariotSubmissionForm } from "./chariot-submission-form";
|
||||
|
||||
@@ -58,12 +59,19 @@ export default async function ProfilePage() {
|
||||
</div>
|
||||
|
||||
{!isJudge && (
|
||||
<div className="marble-surface mt-4 rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
||||
<h2 className="mb-3 font-heading text-sm tracking-wide text-text-mut uppercase">
|
||||
Ma question pour Le Char
|
||||
</h2>
|
||||
<ChariotSubmissionForm initialText={submissionText} frozen={frozen} />
|
||||
</div>
|
||||
<>
|
||||
{/* Carte secondaire (bordure plus fine, pas de grande ombre) :
|
||||
une action ponctuelle annexe, pas le cœur de la page comme la
|
||||
carte profil au-dessus — distincte sans casser la cohérence
|
||||
marble-surface. */}
|
||||
<DiamondDivider className="my-5" />
|
||||
<div className="marble-surface rounded-2xl border border-gold/20 p-6 shadow-sm">
|
||||
<h2 className="mb-3 font-heading text-sm tracking-wide text-text-mut uppercase">
|
||||
Ma question pour Le Char
|
||||
</h2>
|
||||
<ChariotSubmissionForm initialText={submissionText} frozen={frozen} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,14 +64,18 @@ export function ProfileForm({
|
||||
let nextAvatarUrl = avatarUrl;
|
||||
|
||||
if (avatarBlob) {
|
||||
const path = `${userId}.webp`;
|
||||
// Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
|
||||
// certains navigateurs (Safari) retombent sur du PNG si le WebP
|
||||
// n'est pas supporté à l'encodage — voir cropImageToBlob.
|
||||
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
|
||||
const path = `${userId}.${extension}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
|
||||
|
||||
if (uploadError) {
|
||||
setLoading(false);
|
||||
setError("Impossible d'envoyer la photo, réessaie.");
|
||||
setError(`Impossible d'envoyer la photo (${uploadError.message}). Réessaie.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+11
-2
@@ -120,14 +120,23 @@ export default function SignupPage() {
|
||||
|
||||
let avatarUrl: string | null = null;
|
||||
if (avatarBlob) {
|
||||
const path = `${userId}.webp`;
|
||||
// Extension alignée sur le vrai type du blob (pas ".webp" en dur) :
|
||||
// certains navigateurs (Safari) retombent sur du PNG si le WebP
|
||||
// n'est pas supporté à l'encodage — voir cropImageToBlob.
|
||||
const extension = avatarBlob.type === "image/jpeg" ? "jpg" : avatarBlob.type === "image/png" ? "png" : "webp";
|
||||
const path = `${userId}.${extension}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("avatars")
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: "image/webp" });
|
||||
.upload(path, avatarBlob, { upsert: true, contentType: avatarBlob.type || "image/webp" });
|
||||
|
||||
if (!uploadError) {
|
||||
const { data: publicUrlData } = supabase.storage.from("avatars").getPublicUrl(path);
|
||||
avatarUrl = publicUrlData.publicUrl;
|
||||
} else {
|
||||
// Ne bloque pas la finalisation du compte pour une photo optionnelle
|
||||
// — juste de quoi diagnostiquer si quelqu'un signale l'absence
|
||||
// d'avatar après inscription.
|
||||
console.error("Échec de l'envoi de l'avatar à l'inscription :", uploadError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
|
||||
const CONFETTI_COLORS = ["#E7C560", "#C9A227", "#F4ECD8", "#A5342A", "#5E6B3B", "#2E6E7E", "#B08D57"];
|
||||
|
||||
type ConfettiPiece = {
|
||||
id: number;
|
||||
left: number;
|
||||
color: string;
|
||||
delay: number;
|
||||
duration: number;
|
||||
spin: number;
|
||||
drift: number;
|
||||
};
|
||||
|
||||
function generateConfettiPieces(): ConfettiPiece[] {
|
||||
return Array.from({ length: 26 }, (_, index) => ({
|
||||
id: index,
|
||||
left: Math.random() * 100,
|
||||
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
|
||||
delay: Math.random() * 250,
|
||||
duration: 900 + Math.random() * 700,
|
||||
spin: (Math.random() > 0.5 ? 1 : -1) * (360 + Math.random() * 360),
|
||||
drift: (Math.random() - 0.5) * 70,
|
||||
}));
|
||||
}
|
||||
|
||||
// Partagé entre Le Vol d'Icare et La Corne d'Abondance (fin de partie) —
|
||||
// à l'appelant de ne le monter que si prefers-reduced-motion ne le
|
||||
// désactive pas, comme déjà fait sur les deux jeux.
|
||||
export function Confetti() {
|
||||
// Initialiseur paresseux de useState (n'exécute Math.random() qu'une
|
||||
// seule fois, au montage — Confetti est remonté à chaque nouvelle partie
|
||||
// terminée) plutôt qu'un appel impur dans le corps du composant.
|
||||
const [pieces] = useState(generateConfettiPieces);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{pieces.map((piece) => (
|
||||
<span
|
||||
key={piece.id}
|
||||
className="animate-confetti-fall absolute top-0 h-2.5 w-1.5 rounded-[1px]"
|
||||
style={
|
||||
{
|
||||
left: `${piece.left}%`,
|
||||
backgroundColor: piece.color,
|
||||
animationDelay: `${piece.delay}ms`,
|
||||
animationDuration: `${piece.duration}ms`,
|
||||
"--confetti-spin": `${piece.spin}deg`,
|
||||
"--confetti-drift": `${piece.drift}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Petit ornement (◆, comme diamond-divider.tsx) au-dessus des messages
|
||||
// "rien à afficher" — sans ça ce sont des états qui ressemblent à une
|
||||
// erreur/un oubli plutôt qu'un vrai état normal de l'app.
|
||||
export function EmptyState({ message, className = "text-marble/60" }: { message: string; className?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 py-2 text-center">
|
||||
<span className="text-gold/50" aria-hidden>
|
||||
◆
|
||||
</span>
|
||||
<p className={`text-sm ${className}`}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AgoraLogo } from "@/components/agora-logo";
|
||||
|
||||
// Utilisé par les loading.tsx des pages les plus consultées : sans ça, le
|
||||
// temps de la requête d'un Server Component se traduit par un écran vide,
|
||||
// ce qui a déjà fait croire à un vrai bug une fois (/signup, voir
|
||||
// l'historique du projet) — même un indicateur minimal évite ce doute.
|
||||
export function PageLoading() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-16">
|
||||
<AgoraLogo className="h-16 w-16 animate-pulse" />
|
||||
<p className="font-heading text-xs tracking-[0.2em] text-marble/50 uppercase">Chargement…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Petit effet synthétisé via Web Audio, même principe que melon/sound.ts
|
||||
// (pas de fichier audio dans le projet, cohérent avec le reste de l'app) :
|
||||
// joué à la cueillette de l'éclair de Zeus — une montée rapide de
|
||||
// fréquence en dents de scie, pour évoquer une charge d'énergie plutôt
|
||||
// qu'un choc (le "pop" de fusion de la Corne, en onde sinusoïdale plus
|
||||
// douce, joue un rôle différent).
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (!audioContext) {
|
||||
const Ctor =
|
||||
window.AudioContext ??
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
audioContext = new Ctor();
|
||||
}
|
||||
if (audioContext.state === "suspended") {
|
||||
audioContext.resume().catch(() => {});
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
export function playBoostSound(): void {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
oscillator.type = "sawtooth";
|
||||
oscillator.frequency.setValueAtTime(280, now);
|
||||
oscillator.frequency.exponentialRampToValueAtTime(1400, now + 0.16);
|
||||
|
||||
gain.gain.setValueAtTime(0.0001, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.18, now + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.22);
|
||||
|
||||
oscillator.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.24);
|
||||
}
|
||||
+27
-7
@@ -9,8 +9,28 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
});
|
||||
}
|
||||
|
||||
// Certains navigateurs (Safari, historiquement) ne savent pas encoder du
|
||||
// WebP via canvas.toBlob/toDataURL : la spec impose alors un repli
|
||||
// silencieux sur du PNG, un format quasi sans perte — beaucoup plus lourd
|
||||
// qu'un JPEG pour une photo, au point de dépasser la limite de taille du
|
||||
// bucket malgré la "compression" (vécu en prod avec une photo iPhone de
|
||||
// 4 Mo). Détecter le support réel et demander explicitement du JPEG dans
|
||||
// ce cas — plutôt que de laisser le navigateur retomber sur du PNG —
|
||||
// garantit une vraie compression avec perte dans tous les cas.
|
||||
let cachedMimeType: string | null = null;
|
||||
|
||||
function preferredMimeType(): string {
|
||||
if (cachedMimeType) return cachedMimeType;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
cachedMimeType = canvas.toDataURL("image/webp").startsWith("data:image/webp") ? "image/webp" : "image/jpeg";
|
||||
return cachedMimeType;
|
||||
}
|
||||
|
||||
// Découpe la zone sélectionnée, redimensionne (sans jamais agrandir) à
|
||||
// maxSize et compresse en WebP côté client avant l'envoi vers le bucket.
|
||||
// maxSize et compresse (WebP si le navigateur sait l'encoder, sinon JPEG)
|
||||
// côté client avant l'envoi vers le bucket.
|
||||
export async function cropImageToBlob(
|
||||
imageSrc: string,
|
||||
crop: PixelCrop,
|
||||
@@ -33,7 +53,7 @@ export async function cropImageToBlob(
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
||||
"image/webp",
|
||||
preferredMimeType(),
|
||||
quality,
|
||||
);
|
||||
});
|
||||
@@ -41,10 +61,10 @@ export async function cropImageToBlob(
|
||||
|
||||
// Redimensionne (sans jamais agrandir) au format d'origine — contrairement
|
||||
// à cropImageToBlob qui force un carré pour les avatars — à maxDimension
|
||||
// sur le plus grand côté, et compresse en WebP avant l'envoi vers le
|
||||
// bucket. Le bucket a aussi sa propre limite de taille côté serveur
|
||||
// (défense en profondeur) : cette compression n'est qu'un premier filtre,
|
||||
// pas la seule garantie.
|
||||
// sur le plus grand côté, et compresse (WebP si supporté, sinon JPEG —
|
||||
// voir preferredMimeType) avant l'envoi vers le bucket. Le bucket a aussi
|
||||
// sa propre limite de taille côté serveur (défense en profondeur) : cette
|
||||
// compression n'est qu'un premier filtre, pas la seule garantie.
|
||||
export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> {
|
||||
const src = URL.createObjectURL(file);
|
||||
try {
|
||||
@@ -65,7 +85,7 @@ export async function resizeImageToBlob(file: File, maxDimension = 1600, quality
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
||||
"image/webp",
|
||||
preferredMimeType(),
|
||||
quality,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user