Compare commits

...

2 Commits

Author SHA1 Message Date
Valentin ROBIN e70f0c85bb Corrige la compression d'image sur Safari/iPhone (repli PNG au lieu de JPEG)
Build and deploy / deploy (push) Successful in 37s
canvas.toBlob demandait du WebP en dur pour compresser avant l'envoi. Safari ne sait pas l'encoder et retombe silencieusement sur du PNG (imposé par la spec) — un format quasi sans perte, bien plus lourd qu'un JPEG pour une photo, au point de dépasser la limite de taille du bucket malgré une photo source raisonnable (vécu en prod : 4 Mo sur iPhone). Le support WebP est maintenant détecté une fois, avec repli explicite sur JPEG plutôt que de laisser le navigateur choisir. S'applique au Mur (déjà corrigé pour l'extension/Content-Type) et maintenant aussi aux avatars (/profile, /signup).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:04:08 +02:00
Valentin ROBIN 358bed0c2e Passe de polish visuel sur tout le site
Corrige deux incohérences trouvées en chemin : le compteur de passages dans le char en gold-bright sur marbre (même défaut de contraste déjà vu ailleurs), et le point du Crieur toujours rouge même pour un honneur positif. Réutilise les ornements existants mais jamais appelés (meander-divider entre le podium et le classement, DiamondDivider sur /profile avec une nouvelle convention de carte secondaire). Ajoute une ambiance discrète (fond qui respire très lentement, halo doré sur les cartes marbre, désactivés sous prefers-reduced-motion), étend les confettis et un nouveau son à Icare pour la parité avec la Corne d'Abondance, des loading.tsx sur les pages les plus consultées, et un petit ornement sur les états vides plutôt qu'un texte brut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 15:03:52 +02:00
19 changed files with 290 additions and 85 deletions
+9 -4
View File
@@ -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).
+1 -1
View File
@@ -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>
);
})}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+6
View File
@@ -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
View File
@@ -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;
+3 -52
View File
@@ -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,
+7 -2
View File
@@ -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&apos;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>
+6 -1
View File
@@ -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&apos;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);
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+5
View File
@@ -0,0 +1,5 @@
import { PageLoading } from "@/components/page-loading";
export default function Loading() {
return <PageLoading />;
}
+6 -6
View File
@@ -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) => (
+9 -1
View File
@@ -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">
<>
{/* 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>
);
+7 -3
View File
@@ -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
View File
@@ -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);
}
}
+58
View File
@@ -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>
);
}
+13
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+44
View File
@@ -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
View File
@@ -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,
);
});