Responsive mobile / tablette / desktop

En-tête (onglets sur une seconde ligne en tablette, menu déroulant en
mobile), panneau d'affectation et fiche bénévole en tiroir par-dessus sous
lg: (plein écran en mobile), colonne des postes de la grille rétrécie,
liste des bénévoles réduite en mobile, écran « Postes & créneaux » empilé,
aperçu de l'éditeur de badges mis à l'échelle (glisser-déposer tactile),
ligne des types de paiement repliée, marges réduites — desktop inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 18:05:16 +01:00
co-authored by Claude Opus 5.5
parent 0f904f5e3d
commit 85e841ae4a
13 changed files with 269 additions and 129 deletions
+140 -60
View File
@@ -1,4 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { API_MODE } from '@/api';
import { useVolunteers } from '@/api/hooks';
@@ -17,73 +18,122 @@ export function AppShell() {
const initials = account
? account.email.slice(0, 2).toUpperCase()
: '··';
const hasNav = tabs.length > 1;
// Sous `lg:`, les onglets ne tiennent plus à côté du titre : ils passent
// sur une seconde ligne (tablette) ou dans un menu déroulant (mobile),
// refermé à chaque changement d'écran.
const [menuOpen, setMenuOpen] = useState(false);
const { pathname } = useLocation();
useEffect(() => setMenuOpen(false), [pathname]);
const currentTab = tabs.find((tab) => tab.to === pathname);
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow">
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2">
<div className="font-display text-[14px] font-black tracking-tight">
{t('header.title')}
<header className="sticky top-0 z-30 border-b-[3px] border-ink bg-yellow">
<div className="flex items-stretch">
<div className="flex min-w-0 flex-col justify-center border-r-[3px] border-ink px-3 py-2 sm:px-4">
<div className="truncate font-display text-[13px] font-black tracking-tight sm:text-[14px]">
{t('header.title')}
</div>
{isCoordinator ? (
<div className="truncate font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div>
) : (
<div className="truncate font-mono text-[9.5px] font-medium opacity-70">
{account ? t(roleLabelKey(account.role)) : ''}
</div>
)}
</div>
{isCoordinator ? (
<div className="font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div>
) : (
<div className="font-mono text-[9.5px] font-medium opacity-70">
{account ? t(roleLabelKey(account.role)) : ''}
</div>
)}
</div>
{/* Rien à choisir avec un seul écran (bénévole, référent) : la
barre d'onglets n'a de sens que pour la coordination. */}
<nav className={cx('flex overflow-x-auto', tabs.length <= 1 && 'hidden')}>
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
cx(
'relative flex min-w-[112px] flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4',
'font-display text-[11.5px] font-bold uppercase tracking-wide',
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
)
}
>
{({ isActive }) => (
<>
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
<span>{t(tab.labelKey)}</span>
{isActive ? (
<span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" />
) : null}
</>
)}
</NavLink>
))}
</nav>
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
{API_MODE === 'mock' ? (
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block">
{t('header.mockMode')}
</div>
{/* Rien à choisir avec un seul écran (bénévole, référent) : la
barre d'onglets n'a de sens que pour la coordination. */}
{hasNav ? (
<nav className="hidden min-w-0 overflow-x-auto lg:flex">
{tabs.map((tab) => (
<TabLink key={tab.to} tab={tab} />
))}
</nav>
) : null}
<div
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
title={account?.email}
>
{initials}
<div className="ml-auto flex flex-none items-center gap-2 border-l-[3px] border-ink px-2 sm:gap-[10px] sm:px-[14px]">
{API_MODE === 'mock' ? (
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider xl:block">
{t('header.mockMode')}
</div>
) : null}
<div
className="hidden h-[30px] w-[30px] flex-none items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold sm:flex"
title={account?.email}
>
{initials}
</div>
<button
type="button"
onClick={logout}
className="cursor-pointer border-2 border-ink bg-cream px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange sm:px-[10px]"
>
{t('auth.logout')}
</button>
{hasNav ? (
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-label={t(menuOpen ? 'header.closeMenu' : 'header.openMenu')}
className="flex h-[30px] w-[34px] flex-none cursor-pointer items-center justify-center border-2 border-ink bg-ink font-display text-[15px] font-black text-yellow md:hidden"
>
{menuOpen ? '✕' : '☰'}
</button>
) : null}
</div>
<button
type="button"
onClick={logout}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange"
>
{t('auth.logout')}
</button>
</div>
{hasNav ? (
<>
{/* Tablette : seconde ligne, onglets à parts égales. */}
<nav className="hidden overflow-x-auto border-t-[3px] border-ink md:flex lg:hidden">
{tabs.map((tab) => (
<TabLink key={tab.to} tab={tab} fill />
))}
</nav>
{/* Mobile : écran courant rappelé + menu déroulant. */}
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
className="flex w-full cursor-pointer items-center gap-2 border-t-[3px] border-ink bg-ink px-3 py-[7px] text-left font-display text-[11px] font-extrabold uppercase tracking-wide text-cream md:hidden"
>
{currentTab ? (
<span className="font-mono text-[8.5px] font-medium text-yellow">{currentTab.num}</span>
) : null}
<span className="flex-1 truncate">
{currentTab ? t(currentTab.labelKey) : t('header.openMenu')}
</span>
<span aria-hidden className="text-yellow">{menuOpen ? '▲' : '▼'}</span>
</button>
{menuOpen ? (
<nav className="flex flex-col border-t-[3px] border-ink bg-yellow md:hidden">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
cx(
'flex items-center gap-3 border-b-2 border-ink px-4 py-3 last:border-b-0',
'font-display text-[12px] font-bold uppercase tracking-wide',
isActive ? 'bg-ink text-yellow' : 'hover:bg-yellow-hover',
)
}
>
<span className="font-mono text-[9px] font-medium opacity-60">{tab.num}</span>
<span>{t(tab.labelKey)}</span>
</NavLink>
))}
</nav>
) : null}
</>
) : null}
</header>
<main className="flex flex-1 flex-col">
@@ -92,3 +142,33 @@ export function AppShell() {
</div>
);
}
function TabLink({
tab,
fill = false,
}: {
tab: { to: string; num: string; labelKey: string };
fill?: boolean;
}) {
return (
<NavLink
to={tab.to}
className={({ isActive }) =>
cx(
'relative flex flex-none flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4 last:border-r-0',
'font-display text-[11.5px] font-bold uppercase tracking-wide',
fill ? 'min-w-[104px] flex-1 py-2' : 'min-w-[112px]',
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
)
}
>
{({ isActive }) => (
<>
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
<span className="whitespace-nowrap">{t(tab.labelKey)}</span>
{isActive ? <span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" /> : null}
</>
)}
</NavLink>
);
}
+2
View File
@@ -54,6 +54,8 @@
"header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
"header.mockMode": "DONNÉES DE DÉMO",
"header.openMenu": "Menu",
"header.closeMenu": "Fermer le menu",
"tab.grille": "Grille des besoins",
"tab.volunteers": "Vue par bénévole",
"tab.mobileVolunteer": "Bénévole · mobile",
+43 -21
View File
@@ -23,7 +23,25 @@ import { cx } from '@/lib/cx';
// l'écran ; les coordonnées enregistrées restent en mm.
const DEFAULT_BADGE_W_MM = 85.6;
const DEFAULT_BADGE_H_MM = 54;
const SCALE = 4.6; // px par mm à l'écran
const SCALE = 4.6; // px par mm à l'écran (au plus)
// Marges horizontales autour de l'aperçu (page + panneau + bordures), pour
// le faire tenir dans un écran étroit.
const PREVIEW_GUTTER_PX = 90;
/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un
* badge à pleine taille (téléphone, tablette en portrait). Les coordonnées
* enregistrées restent en mm, seule la conversion px ↔ mm suit l'échelle. */
function useEditorScale(badgeWidthMm: number): number {
const [viewport, setViewport] = useState(() =>
typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth,
);
useEffect(() => {
const onResize = () => setViewport(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return Math.min(SCALE, Math.max(1, (viewport - PREVIEW_GUTTER_PX) / badgeWidthMm));
}
type FieldState = BadgeFieldIn & { enabled: boolean };
@@ -126,11 +144,12 @@ function BadgeCustomizeTab() {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
const scale = useEditorScale(badgeW);
if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
@@ -157,7 +176,7 @@ function BadgeCustomizeTab() {
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4 lg:flex-row lg:items-start">
<div className="flex flex-none flex-col gap-4">
<Panel>
<PanelHeader>
@@ -182,8 +201,8 @@ function BadgeCustomizeTab() {
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
width: badgeW * SCALE,
height: badgeH * SCALE,
width: badgeW * scale,
height: badgeH * scale,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
@@ -194,6 +213,7 @@ function BadgeCustomizeTab() {
field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
scale={scale}
selected={selected === type}
onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
@@ -223,13 +243,13 @@ function BadgeCustomizeTab() {
/>
<div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
</div>
<Panel className="min-w-[320px] flex-1">
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
<PanelHeader>{t('badges.fields')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
@@ -353,6 +373,7 @@ function FieldBox({
field,
badgeW,
badgeH,
scale,
selected,
onSelect,
onChange,
@@ -360,6 +381,7 @@ function FieldBox({
field: FieldState;
badgeW: number;
badgeH: number;
scale: number;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void;
@@ -381,8 +403,8 @@ function FieldBox({
const onDragMove = (e: React.PointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
const dxMm = (e.clientX - d.pointerXPx) / scale;
const dyMm = (e.clientY - d.pointerYPx) / scale;
onChange({
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
@@ -399,8 +421,8 @@ function FieldBox({
const onResizeMove = (e: React.PointerEvent) => {
if (!resizeRef.current) return;
const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
const dwMm = (e.clientX - d.pointerXPx) / scale;
const dhMm = (e.clientY - d.pointerYPx) / scale;
onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
@@ -413,20 +435,20 @@ function FieldBox({
onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)}
className={cx(
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
'absolute flex cursor-move touch-none items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
selected ? 'border-blue-strong' : 'border-ink/40',
)}
style={{
left: field.x_mm * SCALE,
top: field.y_mm * SCALE,
width: field.width_mm * SCALE,
height: field.height_mm * SCALE,
left: field.x_mm * scale,
top: field.y_mm * scale,
width: field.width_mm * scale,
height: field.height_mm * scale,
}}
>
<span
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
style={{
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400,
color: field.color,
width: '100%',
@@ -439,7 +461,7 @@ function FieldBox({
onPointerDown={startResize}
onPointerMove={onResizeMove}
onPointerUp={() => (resizeRef.current = null)}
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
className="absolute bottom-0 right-0 h-[14px] w-[14px] cursor-nwse-resize touch-none lg:h-[10px] lg:w-[10px] border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
/>
</div>
);
@@ -570,7 +592,7 @@ function BadgeGenerateTab() {
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4">
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4">
<Panel className="max-w-[560px]">
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3">
+9 -9
View File
@@ -117,9 +117,9 @@ export function FormBuilderPage() {
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4">
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-3 sm:p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<Panel className="max-w-[420px] flex-1">
<Panel className="flex-1 lg:max-w-[420px]">
<PanelHeader>{t('form.link.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
@@ -172,7 +172,7 @@ export function FormBuilderPage() {
</div>
</Panel>
<Panel className="min-w-[320px] flex-1">
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
<PanelHeader>{t('form.fields.title')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
@@ -398,14 +398,14 @@ function PaymentOptionsForm() {
return (
<div className="mt-2 flex flex-col gap-[6px]">
{rows.map((o) => (
<div key={o.id} className="flex items-center gap-1">
<div key={o.id} className="flex flex-wrap items-center gap-1 border-b border-sand-line pb-2 sm:flex-nowrap sm:border-b-0 sm:pb-0">
<input
defaultValue={o.label}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } });
}}
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold"
className="min-w-0 flex-1 basis-full border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
/>
<input
type="number"
@@ -449,19 +449,19 @@ function PaymentOptionsForm() {
type="button"
onClick={() => remove.mutate(o.id)}
aria-label={t('action.remove')}
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
className="ml-auto h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange sm:ml-0"
>
✕
</button>
</div>
))}
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
<div className="mt-1 flex flex-wrap items-center gap-1 border-t-2 border-ink pt-2 sm:flex-nowrap">
<input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={t('form.customize.paymentOptions.label')}
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold"
className="min-w-0 flex-1 basis-full border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
/>
<input
type="number"
@@ -519,7 +519,7 @@ function PaymentOptionsForm() {
},
)
}
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
className="ml-auto h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 sm:ml-0 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
>
{t('form.customize.paymentOptions.add')}
</button>
+27 -7
View File
@@ -88,7 +88,9 @@ export function GrillePage() {
: matrix.data.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = [
'230px',
// Colonne des postes rétrécie sur petit écran, pour laisser voir au
// moins quelques tranches horaires à côté.
'clamp(132px, 36vw, 230px)',
...shownDays.flatMap((day) => {
const width = columnWidthPx(day.slotLabels.length);
return day.slotLabels.map(() => `${width}px`);
@@ -134,8 +136,8 @@ export function GrillePage() {
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
<div className="flex items-center gap-[6px]">
<div className="flex flex-wrap items-center gap-[10px] border-b-2 border-ink bg-sand px-3 py-[9px] sm:gap-[14px] sm:px-4">
<div className="flex flex-wrap items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
{t('filter.all')}
@@ -163,7 +165,7 @@ export function GrillePage() {
))}
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => runSolver.mutate()}
@@ -199,7 +201,7 @@ export function GrillePage() {
) : null}
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-2 border-ink bg-cream px-[9px] py-[5px] lg:ml-auto">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-hatch-orange" glyph="▲">
{t('grille.legend.critical')}
@@ -220,7 +222,7 @@ export function GrillePage() {
</div>
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
<div className="max-h-[calc(100dvh-150px)] min-w-0 flex-1 overflow-auto lg:max-h-[calc(100vh-118px)]">
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
@@ -278,7 +280,25 @@ export function GrillePage() {
</div>
</div>
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
{/* Sous `lg:`, pas la place d'un panneau à côté de la grille : il
s'ouvre par-dessus (plein écran sur mobile, tiroir à droite sur
tablette) seulement quand un créneau est choisi. */}
{selected ? (
<div
aria-hidden
onClick={() => setSelectedShiftId(null)}
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
/>
) : null}
<aside
className={cx(
'overflow-auto border-ink bg-paper',
'lg:static lg:z-auto lg:block lg:max-h-[calc(100vh-118px)] lg:w-[392px] lg:flex-none lg:border-l-[3px]',
selected
? 'fixed inset-y-0 right-0 z-50 block w-full sm:w-[420px] sm:border-l-[3px]'
: 'hidden',
)}
>
{selected ? (
<AssignmentPanel
key={selected.shift.shift_id}
+6 -6
View File
@@ -191,7 +191,7 @@ function PaymentConfirmationView({ token }: { token: string }) {
};
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<Panel>
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-4">
@@ -273,7 +273,7 @@ function MecenatInfoStep({
};
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
@@ -333,7 +333,7 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
};
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
@@ -406,7 +406,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
if (volunteerId) {
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
@@ -437,7 +437,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
if (!config.data.is_open) {
return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
@@ -524,7 +524,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
};
return (
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-3 sm:p-4">
<FormLogo url={logoUrl} />
<Panel>
<PanelHeader>{t('publicForm.title')}</PanelHeader>
+1 -1
View File
@@ -75,7 +75,7 @@ export function ConfigArtists() {
</div>
</div>
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(320px,1fr))]">
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr))]">
{days.map((date) => (
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
))}
+9 -8
View File
@@ -109,8 +109,9 @@ export function ConfigGrid() {
].sort();
return (
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
<div className="flex min-h-0 flex-1 flex-col items-stretch md:flex-row">
{/* Mobile : liste des postes au-dessus du détail, hauteur bornée. */}
<div className="max-h-[260px] w-full flex-none overflow-auto border-b-[3px] border-ink md:max-h-[calc(100vh-118px)] md:w-[250px] md:border-b-0 md:border-r-[3px]">
<div className="sticky top-0 z-[2] flex items-center gap-[6px] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('config.grid.positionsHeader', { count: totals.count })}
<HelpTip text={t('config.grid.note')} side="bottom" />
@@ -148,7 +149,7 @@ export function ConfigGrid() {
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
</div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="min-w-0 flex-1 p-3 sm:p-4 md:max-h-[calc(100vh-118px)] md:overflow-auto">
<PositionHeader
key={position.id}
position={position}
@@ -158,7 +159,7 @@ export function ConfigGrid() {
{positionShifts.isLoading ? <Loading /> : null}
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr))]">
{days.map((day) => (
<DayCard
key={day}
@@ -197,15 +198,15 @@ function PositionHeader({
return (
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div className="flex flex-col gap-1">
<div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto">
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={commitName}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
className="border-2 border-ink bg-yellow px-2 py-1 font-display text-[22px] font-black tracking-tight"
className="w-full min-w-0 border-2 border-ink bg-yellow px-2 py-1 font-display text-[20px] font-black tracking-tight sm:text-[22px]"
/>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<select
value={position.category}
onChange={(e) =>
@@ -250,7 +251,7 @@ function PositionHeader({
{updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null}
{generate.isError ? <ErrorNote error={generate.error} /> : null}
</div>
<div className="ml-auto flex gap-2">
<div className="flex gap-2 sm:ml-auto">
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
</div>
+1 -1
View File
@@ -32,7 +32,7 @@ export function ConfigMail() {
return (
<div className="p-4">
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr))]">
<Panel shadow="lg">
<PanelHeader>
{t('config.mail.templateTitle')} <HelpTip text={t('config.mail.smtpBanner')} side="bottom" />
+1 -1
View File
@@ -28,7 +28,7 @@ export function ConfigRules() {
const shifts = useShifts();
return (
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
<div className="grid items-start gap-4 p-3 sm:p-4 [grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
<div className="flex flex-col gap-2 p-[13px]">
+28 -14
View File
@@ -571,8 +571,10 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
return out;
}
// Mobile : nom + cotisation seulement (téléphone, email et vœux restent sur
// la fiche) ; toutes les colonnes à partir de `md:`.
const LIST_COLUMNS =
'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.2fr) minmax(0,1.2fr)';
'grid-cols-[minmax(0,1fr)_auto] md:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,1.2fr)]';
/** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut
* d'un paiement en ligne non abouti. */
@@ -677,7 +679,7 @@ export function ConfigVolunteers() {
return (
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="min-w-0 flex-1 p-3 sm:p-4 lg:max-h-[calc(100vh-118px)] lg:overflow-auto">
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
<div className="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
<div>
@@ -757,13 +759,15 @@ export function ConfigVolunteers() {
) : (
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: LIST_COLUMNS }}
className={cx(
'grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream',
LIST_COLUMNS,
)}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colWishes')}</span>
<span className="hidden md:block">{t('config.vol.colPhone')}</span>
<span className="hidden md:block">{t('config.vol.colEmail')}</span>
<span className="hidden md:block">{t('config.vol.colWishes')}</span>
<span>{t('config.vol.colFee')}</span>
</div>
{rows.map((volunteer) => {
@@ -774,10 +778,10 @@ export function ConfigVolunteers() {
type="button"
onClick={() => setSelectedId(volunteer.id)}
className={cx(
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[9px] text-left md:py-[7px]',
LIST_COLUMNS,
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: LIST_COLUMNS }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
@@ -788,13 +792,13 @@ export function ConfigVolunteers() {
{volunteer.first_name} {volunteer.last_name}
</span>
</span>
<span className="truncate font-mono text-[11px] opacity-80">
<span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
{volunteer.phone ?? '—'}
</span>
<span className="truncate font-mono text-[10.5px] opacity-70">
<span className="hidden truncate font-mono text-[10.5px] opacity-70 md:block">
{volunteer.email ?? '—'}
</span>
<span className="truncate font-mono text-[10px] opacity-70">
<span className="hidden truncate font-mono text-[10px] opacity-70 md:block">
{t('config.vol.wishesCount', {
count:
volunteer.artist_ids.length +
@@ -815,8 +819,17 @@ export function ConfigVolunteers() {
)}
</div>
{/* Fiche : à côté de la liste sur grand écran, par-dessus (plein écran
sur mobile, tiroir à droite sur tablette) en dessous de `lg:`. */}
{selected ? (
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div
aria-hidden
onClick={() => setSelectedId(null)}
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
/>
) : null}
{selected ? (
<aside className="fixed inset-y-0 right-0 z-50 w-full overflow-auto bg-paper sm:w-[400px] sm:border-l-[3px] sm:border-ink lg:static lg:z-auto lg:max-h-[calc(100vh-118px)] lg:w-[360px] lg:flex-none lg:border-l-[3px]">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="flex items-center justify-between">
<div className="flex items-center gap-[6px] font-mono text-[10px] text-yellow">
@@ -826,7 +839,8 @@ export function ConfigVolunteers() {
<button
type="button"
onClick={() => setSelectedId(null)}
className="cursor-pointer font-mono text-[11px] text-cream/70 hover:text-cream"
aria-label={t('action.close')}
className="h-8 w-8 cursor-pointer border-2 border-cream/40 font-mono text-[12px] text-cream/80 hover:text-cream lg:h-auto lg:w-auto lg:border-0"
>
✕
</button>
@@ -208,7 +208,7 @@ export function AssignmentPanel({
<button
type="button"
onClick={onClose}
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
className="h-8 w-8 flex-none cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream lg:h-6 lg:w-6"
aria-label={t('action.close')}
>
✕