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:
co-authored by
Claude Opus 5.5
parent
0f904f5e3d
commit
85e841ae4a
@@ -917,6 +917,7 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
|
||||
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
|
||||
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
|
||||
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
|
||||
|
||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
|
||||
+140
-60
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) ?? []} />
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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')}
|
||||
>
|
||||
✕
|
||||
|
||||
Reference in New Issue
Block a user