Badges : ordre de superposition des champs
La photo recouvrait le nom et le texte libre (champs dessinés dans l'ordre alphabétique de leur type). BadgeField.z_order (migration 0028, champs existants renumérotés photo dessous) fixe l'ordre de dessin du PDF et de l'aperçu ; boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4f86398ccb
commit
1026d9b9e1
@@ -176,7 +176,15 @@ les tables sauf les tables d'association.
|
|||||||
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
|
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
|
||||||
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
|
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
|
||||||
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
|
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
|
||||||
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`).
|
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`),
|
||||||
|
`z_order` (ordre d'empilement sur la face : les champs sont dessinés du
|
||||||
|
plus petit au plus grand, à égalité la photo dessous — avant, l'ordre
|
||||||
|
alphabétique des types faisait passer la photo par-dessus le nom et le
|
||||||
|
texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1,
|
||||||
|
`badges/model.ts:stackOrder`/`restack`, boutons « Superposition » de
|
||||||
|
l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu
|
||||||
|
suit le même ordre, le cadre du champ sélectionné restant par-dessus pour
|
||||||
|
qu'un champ recouvert se saisisse encore).
|
||||||
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
|
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
|
||||||
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
|
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
|
||||||
ligne. Édité visuellement depuis l'écran « Badges »
|
ligne. Édité visuellement depuis l'écran « Badges »
|
||||||
@@ -982,6 +990,7 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
|
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
|
||||||
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
|
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
|
||||||
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
|
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
|
||||||
|
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
|
||||||
|
|
||||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -86,7 +86,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
|
|||||||
session.exec(
|
session.exec(
|
||||||
select(BadgeField)
|
select(BadgeField)
|
||||||
.where(BadgeField.edition_id == edition_id)
|
.where(BadgeField.edition_id == edition_id)
|
||||||
.order_by(BadgeField.face, BadgeField.field_type)
|
# Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
|
||||||
|
# photo dessous, pour ne jamais masquer un texte.
|
||||||
|
.order_by(
|
||||||
|
BadgeField.face,
|
||||||
|
BadgeField.z_order,
|
||||||
|
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
|
||||||
|
BadgeField.field_type,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,8 @@ class BadgeFieldIn(ApiModel):
|
|||||||
auto_fit: bool = False
|
auto_fit: bool = False
|
||||||
# `PHOTO` seulement, ignoré sinon.
|
# `PHOTO` seulement, ignoré sinon.
|
||||||
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
||||||
|
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
|
||||||
|
z_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class BadgeFieldsSet(ApiModel):
|
class BadgeFieldsSet(ApiModel):
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Ordre d'empilement des champs du badge
|
||||||
|
|
||||||
|
Revision ID: 0028_badge_field_z_order
|
||||||
|
Revises: 0027_volunteer_badge_count
|
||||||
|
Create Date: 2026-10-27
|
||||||
|
|
||||||
|
`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au
|
||||||
|
plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et
|
||||||
|
la photo recouvrait le nom et le texte libre. Les champs existants sont
|
||||||
|
numérotés photo d'abord (dessous), puis les autres par type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0028_badge_field_z_order"
|
||||||
|
down_revision: str | None = "0027_volunteer_badge_count"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"badge_field",
|
||||||
|
sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE badge_field AS b SET z_order = ranked.rank
|
||||||
|
FROM (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY edition_id, face
|
||||||
|
ORDER BY (field_type = 'PHOTO') DESC, field_type
|
||||||
|
) - 1 AS rank
|
||||||
|
FROM badge_field
|
||||||
|
) AS ranked
|
||||||
|
WHERE b.id = ranked.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("badge_field", "z_order")
|
||||||
@@ -509,6 +509,11 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
server_default=BadgePhotoShape.RECT.value,
|
server_default=BadgePhotoShape.RECT.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
|
||||||
|
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
|
||||||
|
z_order: int = Field(
|
||||||
|
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -398,3 +398,41 @@ def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
|
|||||||
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
|
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatch):
|
||||||
|
import app.api.badges as badges_api
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
original = badges_api.generate_badge_sheets_pdf
|
||||||
|
|
||||||
|
def spy(badges, *args, **kwargs):
|
||||||
|
captured["badges"] = badges
|
||||||
|
return original(badges, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
_field(field_type="FREE_TEXT", text="Staff", z_order=2),
|
||||||
|
_field(field_type="FULL_NAME", z_order=0),
|
||||||
|
_field(field_type="PHOTO", z_order=1),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"]
|
||||||
|
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
|
||||||
|
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
|
||||||
|
(badge,) = captured["badges"]
|
||||||
|
assert [f.text for f in badge.fields] == ["Alex\nMartin", "AM", "Staff"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]},
|
||||||
|
)
|
||||||
|
assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"]
|
||||||
|
|||||||
@@ -1606,6 +1606,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
bg_color: f.bg_color ?? null,
|
bg_color: f.bg_color ?? null,
|
||||||
auto_fit: f.auto_fit ?? true,
|
auto_fit: f.auto_fit ?? true,
|
||||||
photo_shape: f.photo_shape ?? 'RECT',
|
photo_shape: f.photo_shape ?? 'RECT',
|
||||||
|
z_order: f.z_order ?? 0,
|
||||||
id: `bf${state.seq++}`,
|
id: `bf${state.seq++}`,
|
||||||
edition_id: state.edition.id,
|
edition_id: state.edition.id,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -462,6 +462,9 @@ export interface BadgeFieldIn {
|
|||||||
auto_fit: boolean;
|
auto_fit: boolean;
|
||||||
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
|
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
|
||||||
photo_shape: BadgePhotoShape;
|
photo_shape: BadgePhotoShape;
|
||||||
|
/** Ordre d'empilement sur la face : un champ recouvre ceux d'un
|
||||||
|
* `z_order` inférieur (photo dessous à égalité). */
|
||||||
|
z_order: number;
|
||||||
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
||||||
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
|||||||
@@ -480,6 +480,13 @@
|
|||||||
"badges.inspector.h": "H",
|
"badges.inspector.h": "H",
|
||||||
"badges.inspector.center": "↔ Centrer",
|
"badges.inspector.center": "↔ Centrer",
|
||||||
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
|
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
|
||||||
|
"badges.inspector.stack": "Superposition",
|
||||||
|
"badges.inspector.stack.position": "{rank} / {total} · 1 = dessus",
|
||||||
|
"badges.inspector.stack.front": "⤒ Premier plan",
|
||||||
|
"badges.inspector.stack.forward": "↑ Avancer",
|
||||||
|
"badges.inspector.stack.backward": "↓ Reculer",
|
||||||
|
"badges.inspector.stack.back": "⤓ Arrière-plan",
|
||||||
|
"badges.inspector.stack.help": "Quand des champs se chevauchent, celui du dessus recouvre les autres, sur l'aperçu comme à l'impression.",
|
||||||
"badges.inspector.hide": "Masquer ce champ",
|
"badges.inspector.hide": "Masquer ce champ",
|
||||||
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
|
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
|
||||||
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
|
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
isVerticallyCentered,
|
isVerticallyCentered,
|
||||||
renderField,
|
renderField,
|
||||||
SAFE_MARGIN_MM,
|
SAFE_MARGIN_MM,
|
||||||
|
stackOrder,
|
||||||
type BadgeData,
|
type BadgeData,
|
||||||
type FieldState,
|
type FieldState,
|
||||||
} from './model';
|
} from './model';
|
||||||
@@ -84,7 +85,8 @@ export function BadgeCanvas({
|
|||||||
onDropImage,
|
onDropImage,
|
||||||
}: BadgeCanvasProps) {
|
}: BadgeCanvasProps) {
|
||||||
const [dropping, setDropping] = useState(false);
|
const [dropping, setDropping] = useState(false);
|
||||||
const shown = fields.filter((f) => f.enabled);
|
// Du dessous vers le dessus, comme le PDF (`z_order`).
|
||||||
|
const shown = stackOrder(fields, face);
|
||||||
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
|
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -148,10 +150,11 @@ export function BadgeCanvas({
|
|||||||
style={{ inset: SAFE_MARGIN_MM * scale }}
|
style={{ inset: SAFE_MARGIN_MM * scale }}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{shown.map((field) => (
|
{shown.map((field, i) => (
|
||||||
<FieldBox
|
<FieldBox
|
||||||
key={field.field_type}
|
key={field.field_type}
|
||||||
field={field}
|
field={field}
|
||||||
|
layer={i + 1}
|
||||||
data={data}
|
data={data}
|
||||||
volunteer={volunteer}
|
volunteer={volunteer}
|
||||||
photoUrl={photoUrl}
|
photoUrl={photoUrl}
|
||||||
@@ -171,6 +174,7 @@ export function BadgeCanvas({
|
|||||||
|
|
||||||
function FieldBox({
|
function FieldBox({
|
||||||
field,
|
field,
|
||||||
|
layer,
|
||||||
data,
|
data,
|
||||||
volunteer,
|
volunteer,
|
||||||
photoUrl,
|
photoUrl,
|
||||||
@@ -183,6 +187,8 @@ function FieldBox({
|
|||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
field: FieldState;
|
field: FieldState;
|
||||||
|
/** Rang dans la pile de la face (`z-index`). */
|
||||||
|
layer: number;
|
||||||
data: BadgeData;
|
data: BadgeData;
|
||||||
volunteer: VolunteerRead | null;
|
volunteer: VolunteerRead | null;
|
||||||
photoUrl: string | null;
|
photoUrl: string | null;
|
||||||
@@ -263,45 +269,58 @@ function FieldBox({
|
|||||||
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
|
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
|
||||||
const truncated = layout?.truncated ?? false;
|
const truncated = layout?.truncated ?? false;
|
||||||
|
|
||||||
return (
|
const geometry = {
|
||||||
<div
|
left: field.x_mm * scale,
|
||||||
onPointerDown={start('move')}
|
top: field.y_mm * scale,
|
||||||
onPointerMove={move}
|
width: field.width_mm * scale,
|
||||||
onPointerUp={end}
|
height: field.height_mm * scale,
|
||||||
onPointerCancel={end}
|
};
|
||||||
className="absolute cursor-move touch-none"
|
const handlers = {
|
||||||
style={{
|
onPointerDown: start('move'),
|
||||||
left: field.x_mm * scale,
|
onPointerMove: move,
|
||||||
top: field.y_mm * scale,
|
onPointerUp: end,
|
||||||
width: field.width_mm * scale,
|
onPointerCancel: end,
|
||||||
height: field.height_mm * scale,
|
};
|
||||||
outline: selected
|
|
||||||
? '2px solid #2A6BFF'
|
|
||||||
: truncated
|
|
||||||
? '2px dashed #FF5C39'
|
|
||||||
: showGuides
|
|
||||||
? '1px dashed rgba(20,17,14,.45)'
|
|
||||||
: 'none',
|
|
||||||
// Le champ sélectionné passe devant les autres : sinon un champ
|
|
||||||
// recouvert (ex. la photo sous un texte) ne se saisit plus.
|
|
||||||
zIndex: selected ? 10 : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
|
|
||||||
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
|
|
||||||
) : rendered?.kind === 'table' ? (
|
|
||||||
<TableContent field={field} layout={rendered.layout} scale={scale} />
|
|
||||||
) : rendered?.kind === 'text' ? (
|
|
||||||
<TextContent field={field} layout={rendered.layout} scale={scale} />
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{truncated ? (
|
return (
|
||||||
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
|
<>
|
||||||
{t('badges.preview.truncated')}
|
{/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
|
||||||
</span>
|
<div
|
||||||
) : null}
|
{...(selected ? {} : handlers)}
|
||||||
|
className={cx('absolute touch-none', selected ? 'pointer-events-none' : 'cursor-move')}
|
||||||
|
style={{
|
||||||
|
...geometry,
|
||||||
|
outline: selected
|
||||||
|
? 'none'
|
||||||
|
: truncated
|
||||||
|
? '2px dashed #FF5C39'
|
||||||
|
: showGuides
|
||||||
|
? '1px dashed rgba(20,17,14,.45)'
|
||||||
|
: 'none',
|
||||||
|
zIndex: layer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
|
||||||
|
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
|
||||||
|
) : rendered?.kind === 'table' ? (
|
||||||
|
<TableContent field={field} layout={rendered.layout} scale={scale} />
|
||||||
|
) : rendered?.kind === 'text' ? (
|
||||||
|
<TextContent field={field} layout={rendered.layout} scale={scale} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{truncated && !selected ? <TruncatedTag /> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cadre de sélection par-dessus toute la pile : un champ recouvert
|
||||||
|
(ex. la photo sous un texte) reste saisissable, sans que l'aperçu
|
||||||
|
change l'ordre d'impression. */}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<>
|
<div
|
||||||
|
{...handlers}
|
||||||
|
className="absolute z-[15] cursor-move touch-none"
|
||||||
|
style={{ ...geometry, outline: '2px solid #2A6BFF' }}
|
||||||
|
>
|
||||||
|
{truncated ? <TruncatedTag /> : null}
|
||||||
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
|
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
|
||||||
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
|
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
|
||||||
{layout?.reduced
|
{layout?.reduced
|
||||||
@@ -315,9 +334,17 @@ function FieldBox({
|
|||||||
onPointerCancel={end}
|
onPointerCancel={end}
|
||||||
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
|
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TruncatedTag() {
|
||||||
|
return (
|
||||||
|
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
|
||||||
|
{t('badges.preview.truncated')}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,11 +52,15 @@ import {
|
|||||||
fitToBadge,
|
fitToBadge,
|
||||||
fmt,
|
fmt,
|
||||||
renderField,
|
renderField,
|
||||||
|
restack,
|
||||||
sameRef,
|
sameRef,
|
||||||
|
stackOrder,
|
||||||
toPayload,
|
toPayload,
|
||||||
|
zOrderOnEnable,
|
||||||
type BadgeData,
|
type BadgeData,
|
||||||
type FieldRef,
|
type FieldRef,
|
||||||
type FieldState,
|
type FieldState,
|
||||||
|
type StackMove,
|
||||||
} from './model';
|
} from './model';
|
||||||
|
|
||||||
const HISTORY_LIMIT = 40;
|
const HISTORY_LIMIT = 40;
|
||||||
@@ -95,22 +99,33 @@ type EditorAction =
|
|||||||
badgeW: number;
|
badgeW: number;
|
||||||
badgeH: number;
|
badgeH: number;
|
||||||
}
|
}
|
||||||
|
| { type: 'restack'; ref: FieldRef; move: StackMove }
|
||||||
| { type: 'fit'; badgeW: number; badgeH: number }
|
| { type: 'fit'; badgeW: number; badgeH: number }
|
||||||
| { type: 'undo' };
|
| { type: 'undo' };
|
||||||
|
|
||||||
function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
function editorReducer(state: EditorState, action: EditorAction): EditorState {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'patch':
|
case 'patch': {
|
||||||
|
// Un champ qu'on active prend place en haut de la pile (la photo en bas).
|
||||||
|
const target = state.fields.find((f) => sameRef({ face: f.face, type: f.field_type }, action.ref));
|
||||||
|
const patch =
|
||||||
|
action.patch.enabled && target && !target.enabled
|
||||||
|
? { z_order: zOrderOnEnable(state.fields, action.ref), ...action.patch }
|
||||||
|
: action.patch;
|
||||||
return {
|
return {
|
||||||
history: action.record
|
history: action.record
|
||||||
? [...state.history, state.fields].slice(-HISTORY_LIMIT)
|
? [...state.history, state.fields].slice(-HISTORY_LIMIT)
|
||||||
: state.history,
|
: state.history,
|
||||||
fields: state.fields.map((f) =>
|
fields: state.fields.map((f) =>
|
||||||
sameRef({ face: f.face, type: f.field_type }, action.ref)
|
f === target ? fitToBadge({ ...f, ...patch }, action.badgeW, action.badgeH) : f,
|
||||||
? fitToBadge({ ...f, ...action.patch }, action.badgeW, action.badgeH)
|
|
||||||
: f,
|
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
case 'restack':
|
||||||
|
return {
|
||||||
|
history: [...state.history, state.fields].slice(-HISTORY_LIMIT),
|
||||||
|
fields: restack(state.fields, action.ref, action.move),
|
||||||
|
};
|
||||||
case 'fit':
|
case 'fit':
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -540,9 +555,17 @@ function BadgeEditor({
|
|||||||
}
|
}
|
||||||
photoCount={data.volunteers.filter((v) => v.has_photo).length}
|
photoCount={data.volunteers.filter((v) => v.has_photo).length}
|
||||||
volunteerCount={total}
|
volunteerCount={total}
|
||||||
|
stack={stackOrder(fields, selectedField.face).map((f) => f.field_type)}
|
||||||
onChange={(change) =>
|
onChange={(change) =>
|
||||||
patch({ face: selectedField.face, type: selectedField.field_type }, change)
|
patch({ face: selectedField.face, type: selectedField.field_type }, change)
|
||||||
}
|
}
|
||||||
|
onRestack={(move: StackMove) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'restack',
|
||||||
|
ref: { face: selectedField.face, type: selectedField.field_type },
|
||||||
|
move,
|
||||||
|
})
|
||||||
|
}
|
||||||
onHide={() => {
|
onHide={() => {
|
||||||
patch({ face: selectedField.face, type: selectedField.field_type }, { enabled: false });
|
patch({ face: selectedField.face, type: selectedField.field_type }, { enabled: false });
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useState, type ReactNode } from 'react';
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
import type { BadgeAlign } from '@/api/schema';
|
import type { BadgeAlign, BadgeFieldType } from '@/api/schema';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout';
|
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout';
|
||||||
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState } from './model';
|
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState, type StackMove } from './model';
|
||||||
|
|
||||||
const TEXT_COLORS: Array<{ hex: string; key: string }> = [
|
const TEXT_COLORS: Array<{ hex: string; key: string }> = [
|
||||||
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
||||||
@@ -142,7 +142,9 @@ export function BadgeInspector({
|
|||||||
effectiveFontSize,
|
effectiveFontSize,
|
||||||
photoCount,
|
photoCount,
|
||||||
volunteerCount,
|
volunteerCount,
|
||||||
|
stack,
|
||||||
onChange,
|
onChange,
|
||||||
|
onRestack,
|
||||||
onHide,
|
onHide,
|
||||||
}: {
|
}: {
|
||||||
field: FieldState;
|
field: FieldState;
|
||||||
@@ -152,7 +154,10 @@ export function BadgeInspector({
|
|||||||
effectiveFontSize: number | null;
|
effectiveFontSize: number | null;
|
||||||
photoCount: number;
|
photoCount: number;
|
||||||
volunteerCount: number;
|
volunteerCount: number;
|
||||||
|
/** Types des champs activés de la face, du dessous vers le dessus. */
|
||||||
|
stack: BadgeFieldType[];
|
||||||
onChange: (patch: Partial<FieldState>) => void;
|
onChange: (patch: Partial<FieldState>) => void;
|
||||||
|
onRestack: (move: StackMove) => void;
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isPhoto = field.field_type === 'PHOTO';
|
const isPhoto = field.field_type === 'PHOTO';
|
||||||
@@ -400,6 +405,8 @@ export function BadgeInspector({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<StackSection field={field} stack={stack} onRestack={onRestack} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onHide}
|
onClick={onHide}
|
||||||
@@ -411,3 +418,52 @@ export function BadgeInspector({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STACK_MOVES: Array<{ move: StackMove; key: string; atTop: boolean }> = [
|
||||||
|
{ move: 'front', key: 'badges.inspector.stack.front', atTop: true },
|
||||||
|
{ move: 'forward', key: 'badges.inspector.stack.forward', atTop: true },
|
||||||
|
{ move: 'backward', key: 'badges.inspector.stack.backward', atTop: false },
|
||||||
|
{ move: 'back', key: 'badges.inspector.stack.back', atTop: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ordre de superposition du champ parmi ceux de sa face — l'ordre dans
|
||||||
|
* lequel le PDF les dessine (`BadgeField.z_order`). */
|
||||||
|
function StackSection({
|
||||||
|
field,
|
||||||
|
stack,
|
||||||
|
onRestack,
|
||||||
|
}: {
|
||||||
|
field: FieldState;
|
||||||
|
stack: BadgeFieldType[];
|
||||||
|
onRestack: (move: StackMove) => void;
|
||||||
|
}) {
|
||||||
|
const index = stack.indexOf(field.field_type);
|
||||||
|
const isTop = index === stack.length - 1;
|
||||||
|
const isBottom = index === 0;
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title={t('badges.inspector.stack')}
|
||||||
|
aside={t('badges.inspector.stack.position', { rank: stack.length - index, total: stack.length })}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-[7px]">
|
||||||
|
{STACK_MOVES.map(({ move, key, atTop }) => {
|
||||||
|
const disabled = stack.length < 2 || (atTop ? isTop : isBottom);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={move}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onRestack(move)}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft disabled:cursor-default disabled:opacity-40 disabled:hover:bg-cream"
|
||||||
|
>
|
||||||
|
{t(key)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
|
||||||
|
{t('badges.inspector.stack.help')}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
|
|||||||
|
|
||||||
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
|
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
|
||||||
|
|
||||||
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT' } as const;
|
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT', z_order: 0 } as const;
|
||||||
|
|
||||||
// Position/taille par défaut à l'activation d'un champ — empilés
|
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||||
// verticalement pour ne pas se superposer (repères pour un badge CR80
|
// verticalement pour ne pas se superposer (repères pour un badge CR80
|
||||||
@@ -148,6 +148,7 @@ export function fieldsFromServer(
|
|||||||
bg_color: existing.bg_color ?? null,
|
bg_color: existing.bg_color ?? null,
|
||||||
auto_fit: existing.auto_fit ?? true,
|
auto_fit: existing.auto_fit ?? true,
|
||||||
photo_shape: existing.photo_shape ?? 'RECT',
|
photo_shape: existing.photo_shape ?? 'RECT',
|
||||||
|
z_order: existing.z_order ?? 0,
|
||||||
text: existing.text ?? null,
|
text: existing.text ?? null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
@@ -161,6 +162,7 @@ export function fieldsFromServer(
|
|||||||
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
|
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
|
||||||
* faces. */
|
* faces. */
|
||||||
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
|
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
|
||||||
|
const rank = stackRanks(fields);
|
||||||
return fields
|
return fields
|
||||||
.filter((f) => f.enabled)
|
.filter((f) => f.enabled)
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
@@ -177,10 +179,62 @@ export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
|
|||||||
bg_color: f.bg_color,
|
bg_color: f.bg_color,
|
||||||
auto_fit: f.auto_fit,
|
auto_fit: f.auto_fit,
|
||||||
photo_shape: f.photo_shape,
|
photo_shape: f.photo_shape,
|
||||||
|
z_order: rank.get(f) ?? 0,
|
||||||
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Ordre d'empilement //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
/** Champs activés d'une face, du dessous vers le dessus — l'ordre de dessin
|
||||||
|
* du PDF. À `z_order` égal, la photo passe dessous, puis l'ordre de
|
||||||
|
* `FIELD_TYPES` (le serveur ne départage que la photo, mais les `z_order`
|
||||||
|
* envoyés sont toujours distincts : voir `stackRanks`). */
|
||||||
|
export function stackOrder(fields: FieldState[], face: BadgeFace): FieldState[] {
|
||||||
|
return fields
|
||||||
|
.filter((f) => f.enabled && f.face === face)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.z_order - b.z_order ||
|
||||||
|
FIELD_TYPES.indexOf(a.field_type) - FIELD_TYPES.indexOf(b.field_type),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rang de chaque champ activé dans la pile de sa face (0 = dessous). */
|
||||||
|
export function stackRanks(fields: FieldState[]): Map<FieldState, number> {
|
||||||
|
const ranks = new Map<FieldState, number>();
|
||||||
|
for (const face of FACES) stackOrder(fields, face).forEach((f, i) => ranks.set(f, i));
|
||||||
|
return ranks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StackMove = 'front' | 'forward' | 'backward' | 'back';
|
||||||
|
|
||||||
|
/** Déplace un champ dans la pile de sa face et renumérote la face (0…n-1). */
|
||||||
|
export function restack(fields: FieldState[], ref: FieldRef, move: StackMove): FieldState[] {
|
||||||
|
const stack = stackOrder(fields, ref.face);
|
||||||
|
const from = stack.findIndex((f) => f.field_type === ref.type);
|
||||||
|
if (from < 0) return fields;
|
||||||
|
const to = {
|
||||||
|
front: stack.length - 1,
|
||||||
|
forward: Math.min(from + 1, stack.length - 1),
|
||||||
|
backward: Math.max(from - 1, 0),
|
||||||
|
back: 0,
|
||||||
|
}[move];
|
||||||
|
const [moved] = stack.splice(from, 1);
|
||||||
|
stack.splice(to, 0, moved);
|
||||||
|
const rank = new Map(stack.map((f, i) => [f, i]));
|
||||||
|
return fields.map((f) => (rank.has(f) ? { ...f, z_order: rank.get(f) as number } : f));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `z_order` d'un champ qu'on active : au-dessus des autres — sauf la
|
||||||
|
* photo, dessous, pour ne pas masquer un texte déjà placé. */
|
||||||
|
export function zOrderOnEnable(fields: FieldState[], ref: FieldRef): number {
|
||||||
|
const stack = stackOrder(fields, ref.face);
|
||||||
|
if (!stack.length) return 0;
|
||||||
|
return ref.type === 'PHOTO' ? stack[0].z_order - 1 : stack[stack.length - 1].z_order + 1;
|
||||||
|
}
|
||||||
|
|
||||||
export function round1(value: number): number {
|
export function round1(value: number): number {
|
||||||
return Math.round(value * 10) / 10;
|
return Math.round(value * 10) / 10;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user