Pages de lore en blocs (grille 2D libre) + bloc image recadrable, et import dd2vtt sur les scènes
All checks were successful
All checks were successful
Refonte du système de templates de lore en assemblage de blocs (façon Lore
d'Amsel), plus le support des cartes DungeonDraft (.dd2vtt) sur les scènes.
Templates de lore — éditeur de blocs en grille :
- Builder partagé `block-grid-builder` : palette de types de blocs à glisser sur
une grille 12 colonnes, placement LIBRE (x/y) et redimensionnement en largeur
ET hauteur par poignées, renommage en place. Remplace la liste + flèches de
template-create/template-edit (dédupliqués).
- Rendu : page-view (aperçu) honore largeur ET hauteur (grille à lignes fixes) ;
page-edit garde les colonnes (côte-à-côte) avec des hauteurs naturelles pour
l'édition. Repli empilé pour les templates sans mise en page.
- Modèle : `TemplateField` gagne `id` (clé stable) et `pos {x,y,w,h}`. Les valeurs
de page sont ancrées sur l'`id` (repli sur le nom) → renommer un bloc ne perd
plus son contenu. Sérialisation JSON additive, aucune migration ; correction au
passage de la non-relecture de `foundryPath` dans le converter.
Bloc image :
- Nouveau composant `image-block` (remplace la galerie pour les pages de lore) :
plusieurs images plein cadre (carrousel), déplacement (recadrage) et zoom
persistés PAR image et PAR page. Rendu `object-fit: contain` (image entière,
bordures noires) avec zoom 0,4×–4×.
- Stockage : `Page.imageFraming` (fieldKey → imageId → {x,y,scale}), colonne
`image_framing` (migration V8), DTO/mapper + passthrough export/import.
Scènes — source de carte au choix :
- Sélecteur Dungeon Alchemist (image/vidéo + .json, inchangé) / DungeonDraft
(.dd2vtt). Le .dd2vtt embarque l'image en base64 : extraction à l'upload
(→ média) + sidecar allégé (→ données) pour que l'export Foundry et le module
fonctionnent sans changement. Mode déduit à la relecture de l'extension.
Front-only, sans migration.
i18n fr/en, tests (converters back + helper front) et specs e2e mis à jour.
This commit is contained in:
@@ -86,6 +86,7 @@ public class PageService {
|
||||
existing.setNodeId(changes.getNodeId());
|
||||
existing.setValues(CollectionUtils.copyMap(changes.getValues()));
|
||||
existing.setImageValues(CollectionUtils.copyMap(changes.getImageValues()));
|
||||
existing.setImageFraming(CollectionUtils.copyMap(changes.getImageFraming()));
|
||||
existing.setKeyValueValues(CollectionUtils.copyMap(changes.getKeyValueValues()));
|
||||
existing.setTableValues(CollectionUtils.copyMap(changes.getTableValues()));
|
||||
existing.setNotes(changes.getNotes());
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.loremind.domain.lorecontext;
|
||||
|
||||
/**
|
||||
* Cadrage d'une image dans son bloc IMAGE d'une page de lore.
|
||||
* <p>
|
||||
* Donnée purement présentationnelle, définie PAR IMAGE et PAR PAGE :
|
||||
* <ul>
|
||||
* <li>{@link #x}, {@link #y} : object-position en pourcentage (0..100).
|
||||
* 50/50 = centré (défaut).</li>
|
||||
* <li>{@link #scale} : facteur de zoom (>= 1). 1 = ajusté plein cadre (cover).</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Stockée sur la Page dans {@code imageFraming} (fieldKey → imageId → cadrage),
|
||||
* sérialisée en JSON. Entité pure du domaine : aucune dépendance technique.
|
||||
*/
|
||||
public record ImageFraming(double x, double y, double scale) {
|
||||
}
|
||||
@@ -42,6 +42,13 @@ public class Page {
|
||||
*/
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/**
|
||||
* Cadrage (pan/zoom) des images dans leur bloc IMAGE : fieldKey → imageId →
|
||||
* {@link ImageFraming}. Purement presentationnel ; l'absence d'entree = cadrage
|
||||
* par defaut (centre, plein cadre). Voir {@link ImageFraming}.
|
||||
*/
|
||||
private Map<String, Map<String, ImageFraming>> imageFraming;
|
||||
|
||||
/**
|
||||
* Valeurs des champs KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
|
||||
* fiches de personnage) : fieldName → (label → valeur). Les labels sont
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.loremind.domain.shared.template;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Value Object decrivant le placement d'un bloc dans la grille du template
|
||||
* (kernel partage).
|
||||
* <p>
|
||||
* Modele : grille responsive a 12 colonnes (facon page-builder), inspire de
|
||||
* l'app "Lore" d'Amsel. Chaque bloc occupe un rectangle de la grille :
|
||||
* <ul>
|
||||
* <li>{@link #x} : colonne de depart, 0..11</li>
|
||||
* <li>{@link #y} : ligne de depart, 0..n</li>
|
||||
* <li>{@link #w} : largeur en colonnes, 1..12</li>
|
||||
* <li>{@link #h} : hauteur en lignes, 1..n</li>
|
||||
* </ul>
|
||||
* <p>
|
||||
* Tous les champs sont nullables : un {@code pos} absent (null) ou des
|
||||
* coordonnees nulles signifient "auto-flow" — le bloc est empile a la suite
|
||||
* des precedents, exactement comme le rendu historique en une seule colonne.
|
||||
* C'est ce qui garantit la retrocompatibilite des templates existants (qui
|
||||
* n'ont aucune donnee de placement) : ils continuent de s'afficher empiles.
|
||||
* <p>
|
||||
* Donnee purement presentationnelle : ignoree par l'IA, l'export campagne et
|
||||
* l'export Foundry, qui ne lisent que l'ordre et les valeurs par nom de champ.
|
||||
* <p>
|
||||
* Entite pure du domaine : aucune dependance technique.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BlockPosition {
|
||||
/** Colonne de depart dans la grille 12 colonnes (0..11). Null = auto-flow. */
|
||||
private Integer x;
|
||||
/** Ligne de depart (0..n). Null = auto-flow. */
|
||||
private Integer y;
|
||||
/** Largeur en colonnes (1..12). Null = pleine largeur par defaut. */
|
||||
private Integer w;
|
||||
/** Hauteur en lignes (1..n). Null = hauteur du contenu. */
|
||||
private Integer h;
|
||||
}
|
||||
@@ -17,12 +17,29 @@ import java.util.List;
|
||||
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
||||
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
||||
* Ignore pour les autres types.
|
||||
* <p>
|
||||
* {@link #id} est la cle STABLE du bloc : les valeurs des Pages s'ancrent
|
||||
* dessus (et non sur {@link #name}), ce qui permet de renommer un bloc sans
|
||||
* orpheliner son contenu. Pour les templates anterieurs (qui n'ont pas d'id),
|
||||
* l'id est retro-rempli avec le nom courant a la lecture : comme les valeurs
|
||||
* existantes sont deja rangees par nom, {@code id == name} au depart et rien
|
||||
* n'a besoin d'etre migre.
|
||||
* <p>
|
||||
* {@link #pos} porte le placement du bloc dans la grille 12 colonnes du
|
||||
* template (voir {@link BlockPosition}). Null = auto-flow empile (rendu
|
||||
* historique).
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TemplateField {
|
||||
/**
|
||||
* Identifiant STABLE du bloc, cle d'ancrage des valeurs de Page.
|
||||
* Retro-rempli avec {@link #name} pour les templates sans id. Immuable :
|
||||
* survit aux renommages du bloc.
|
||||
*/
|
||||
private String id;
|
||||
/** Nom du champ tel qu'affiche dans l'UI (ex: "Histoire", "Portrait"). */
|
||||
private String name;
|
||||
/** Type du champ, pilote le rendu et la generation IA. */
|
||||
@@ -44,6 +61,12 @@ public class TemplateField {
|
||||
*/
|
||||
private String foundryPath;
|
||||
|
||||
/**
|
||||
* Placement du bloc dans la grille 12 colonnes du template. Null = auto-flow
|
||||
* (le bloc s'empile a la suite des precedents, comme le rendu historique).
|
||||
*/
|
||||
private BlockPosition pos;
|
||||
|
||||
/** Constructeur de retrocompat : type seul, layout/labels=null. */
|
||||
public TemplateField(String name, FieldType type) {
|
||||
this(name, type, null, null, null);
|
||||
@@ -59,6 +82,14 @@ public class TemplateField {
|
||||
this(name, type, layout, labels, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructeur de retrocompat (5 args) : sans id ni pos. Conserve la
|
||||
* signature publique historique (id retro-rempli plus tard, pos=null).
|
||||
*/
|
||||
public TemplateField(String name, FieldType type, ImageLayout layout, List<String> labels, String foundryPath) {
|
||||
this(null, name, type, layout, labels, foundryPath, null);
|
||||
}
|
||||
|
||||
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
||||
public static TemplateField text(String name) {
|
||||
return new TemplateField(name, FieldType.TEXT, null, null);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Convertit une {@code Map<String, Map<String, ImageFraming>>} en JSON et inversement.
|
||||
* <p>
|
||||
* Utilisé pour {@code Page.imageFraming} : pour chaque champ IMAGE (clé = id du bloc),
|
||||
* un cadrage (pan/zoom) par image. Exemple :
|
||||
* {"blk-illu": {"img-42": {"x":50.0,"y":30.0,"scale":1.4}}}
|
||||
* <p>
|
||||
* Tolérant aux propriétés inconnues (évolution de {@link ImageFraming}).
|
||||
* Adaptateur technique pur : le domaine ignore ce converter.
|
||||
*/
|
||||
@Converter
|
||||
public class ImageFramingMapJsonConverter
|
||||
implements AttributeConverter<Map<String, Map<String, ImageFraming>>, String> {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
private static final TypeReference<Map<String, Map<String, ImageFraming>>> TYPE_REF =
|
||||
new TypeReference<>() {};
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(Map<String, Map<String, ImageFraming>> attribute) {
|
||||
if (attribute == null || attribute.isEmpty()) return "{}";
|
||||
try {
|
||||
return MAPPER.writeValueAsString(attribute);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur serialisation Map<String, Map<String,ImageFraming>> -> JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Map<String, ImageFraming>> convertToEntityAttribute(String dbData) {
|
||||
if (dbData == null || dbData.isBlank()) return Collections.emptyMap();
|
||||
try {
|
||||
return MAPPER.readValue(dbData, TYPE_REF);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"Erreur deserialisation JSON -> Map<String, Map<String,ImageFraming>>", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.loremind.infrastructure.persistence.converter;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.loremind.domain.shared.template.BlockPosition;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
@@ -61,9 +62,14 @@ public class TemplateFieldListJsonConverter
|
||||
for (JsonNode item : root) {
|
||||
if (item.isTextual()) {
|
||||
// Format legacy : chaine simple, on suppose TEXT par defaut.
|
||||
result.add(TemplateField.text(item.asText()));
|
||||
// L'id stable est retro-rempli avec le nom (les valeurs de Page
|
||||
// sont deja rangees par nom -> id == name, aucune migration).
|
||||
String name = item.asText();
|
||||
TemplateField legacy = TemplateField.text(name);
|
||||
legacy.setId(name);
|
||||
result.add(legacy);
|
||||
} else if (item.isObject()) {
|
||||
// Nouveau format : {name, type}
|
||||
// Nouveau format : {id?, name, type, layout?, labels?, foundryPath?, pos?}
|
||||
String name = item.path("name").asText(null);
|
||||
String typeStr = item.path("type").asText("TEXT");
|
||||
FieldType type;
|
||||
@@ -95,8 +101,27 @@ public class TemplateFieldListJsonConverter
|
||||
}
|
||||
}
|
||||
}
|
||||
// foundryPath : lu via hasNonNull pour eviter le piege NullNode
|
||||
// (asText() renverrait la chaine "null"). Historiquement omis a la
|
||||
// relecture -> il etait perdu au save+reload : corrige ici.
|
||||
String foundryPath = item.hasNonNull("foundryPath")
|
||||
? item.get("foundryPath").asText() : null;
|
||||
// id : explicite si present, sinon retro-rempli avec le nom.
|
||||
String id = item.hasNonNull("id") ? item.get("id").asText() : null;
|
||||
if (id == null || id.isBlank()) {
|
||||
id = name;
|
||||
}
|
||||
BlockPosition pos = readPos(item.path("pos"));
|
||||
if (name != null && !name.isBlank()) {
|
||||
result.add(new TemplateField(name, type, layout, labels));
|
||||
result.add(TemplateField.builder()
|
||||
.id(id)
|
||||
.name(name)
|
||||
.type(type)
|
||||
.layout(layout)
|
||||
.labels(labels)
|
||||
.foundryPath(foundryPath)
|
||||
.pos(pos)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||
@@ -108,6 +133,31 @@ public class TemplateFieldListJsonConverter
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit le placement {@code pos: {x, y, w, h}} d'un bloc. Renvoie null si le
|
||||
* noeud n'est pas un objet ou si toutes les coordonnees sont absentes
|
||||
* (-> auto-flow empile, comportement historique).
|
||||
*/
|
||||
private static BlockPosition readPos(JsonNode posNode) {
|
||||
if (posNode == null || !posNode.isObject()) {
|
||||
return null;
|
||||
}
|
||||
Integer x = intOrNull(posNode, "x");
|
||||
Integer y = intOrNull(posNode, "y");
|
||||
Integer w = intOrNull(posNode, "w");
|
||||
Integer h = intOrNull(posNode, "h");
|
||||
if (x == null && y == null && w == null && h == null) {
|
||||
return null;
|
||||
}
|
||||
return new BlockPosition(x, y, w, h);
|
||||
}
|
||||
|
||||
/** Lit un entier optionnel d'un noeud JSON ; null si absent ou non numerique. */
|
||||
private static Integer intOrNull(JsonNode node, String field) {
|
||||
JsonNode n = node.path(field);
|
||||
return n.isNumber() ? n.asInt() : null;
|
||||
}
|
||||
|
||||
// typeRef garde pour reference future si on veut deserialiser directement.
|
||||
@SuppressWarnings("unused")
|
||||
private static final TypeReference<List<TemplateField>> TYPE_REF =
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.loremind.infrastructure.persistence.entity;
|
||||
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import com.loremind.infrastructure.persistence.converter.ImageFramingMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringListMapJsonConverter;
|
||||
import com.loremind.infrastructure.persistence.converter.StringMapJsonConverter;
|
||||
@@ -58,6 +60,11 @@ public class PageJpaEntity {
|
||||
@Convert(converter = StringListMapJsonConverter.class)
|
||||
private Map<String, List<String>> imageValues;
|
||||
|
||||
/** Cadrage (pan/zoom) des images : fieldKey → imageId → ImageFraming. JSON TEXT. */
|
||||
@Column(name = "image_framing", columnDefinition = "TEXT")
|
||||
@Convert(converter = ImageFramingMapJsonConverter.class)
|
||||
private Map<String, Map<String, ImageFraming>> imageFraming;
|
||||
|
||||
/** Valeurs des champs KEY_VALUE_LIST : fieldName → (label → valeur). JSON TEXT. */
|
||||
@Column(name = "key_value_values", columnDefinition = "TEXT")
|
||||
@Convert(converter = StringMapMapJsonConverter.class)
|
||||
|
||||
@@ -94,6 +94,7 @@ public class PostgresPageRepository implements PageRepository {
|
||||
.order(e.getOrder())
|
||||
.values(e.getValues() != null ? new HashMap<>(e.getValues()) : new HashMap<>())
|
||||
.imageValues(e.getImageValues() != null ? new HashMap<>(e.getImageValues()) : new HashMap<>())
|
||||
.imageFraming(e.getImageFraming() != null ? new HashMap<>(e.getImageFraming()) : new HashMap<>())
|
||||
.keyValueValues(e.getKeyValueValues() != null ? new HashMap<>(e.getKeyValueValues()) : new HashMap<>())
|
||||
.tableValues(e.getTableValues() != null ? new HashMap<>(e.getTableValues()) : new HashMap<>())
|
||||
.notes(e.getNotes())
|
||||
@@ -115,6 +116,7 @@ public class PostgresPageRepository implements PageRepository {
|
||||
.order(p.getOrder())
|
||||
.values(p.getValues() != null ? new HashMap<>(p.getValues()) : new HashMap<>())
|
||||
.imageValues(p.getImageValues() != null ? new HashMap<>(p.getImageValues()) : new HashMap<>())
|
||||
.imageFraming(p.getImageFraming() != null ? new HashMap<>(p.getImageFraming()) : new HashMap<>())
|
||||
.keyValueValues(p.getKeyValueValues() != null ? new HashMap<>(p.getKeyValueValues()) : new HashMap<>())
|
||||
.tableValues(p.getTableValues() != null ? new HashMap<>(p.getTableValues()) : new HashMap<>())
|
||||
.notes(p.getNotes())
|
||||
|
||||
@@ -468,8 +468,8 @@ public class ExportService {
|
||||
private ContentExport.PageDto toPageDto(PageJpaEntity e) {
|
||||
return new ContentExport.PageDto(e.getId(), e.getLoreId(), e.getNodeId(),
|
||||
e.getTemplateId(), e.getTitle(), e.getValues(), e.getImageValues(),
|
||||
e.getKeyValueValues(), e.getTableValues(), e.getNotes(), e.getTags(),
|
||||
e.getRelatedPageIds());
|
||||
e.getImageFraming(), e.getKeyValueValues(), e.getTableValues(), e.getNotes(),
|
||||
e.getTags(), e.getRelatedPageIds());
|
||||
}
|
||||
|
||||
private ContentExport.CampaignDto toCampaignDto(CampaignJpaEntity e) {
|
||||
|
||||
@@ -210,6 +210,7 @@ public class ImportService {
|
||||
e.setTitle(d.title());
|
||||
e.setValues(d.values());
|
||||
e.setImageValues(d.imageValues());
|
||||
e.setImageFraming(d.imageFraming());
|
||||
e.setKeyValueValues(d.keyValueValues());
|
||||
e.setTableValues(d.tableValues());
|
||||
e.setNotes(d.notes());
|
||||
|
||||
@@ -105,6 +105,7 @@ public record ContentExport(
|
||||
String title,
|
||||
Map<String, String> values,
|
||||
Map<String, List<String>> imageValues,
|
||||
Map<String, Map<String, com.loremind.domain.lorecontext.ImageFraming>> imageFraming,
|
||||
Map<String, Map<String, String>> keyValueValues,
|
||||
Map<String, List<Map<String, String>>> tableValues,
|
||||
String notes,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.loremind.infrastructure.web.dto.lorecontext;
|
||||
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
@@ -21,6 +22,8 @@ public class PageDTO {
|
||||
private Map<String, String> values;
|
||||
/** Pour chaque champ IMAGE du template, la liste ordonnee des IDs d'images. */
|
||||
private Map<String, List<String>> imageValues;
|
||||
/** Cadrage (pan/zoom) des images : fieldKey → imageId → {x, y, scale}. */
|
||||
private Map<String, Map<String, ImageFraming>> imageFraming;
|
||||
/** Pour chaque champ KEY_VALUE_LIST du template : label → valeur. */
|
||||
private Map<String, Map<String, String>> keyValueValues;
|
||||
/** Pour chaque champ TABLE du template : lignes (colonne → cellule). */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.loremind.infrastructure.web.dto.shared;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* DTO wire-friendly du placement d'un bloc dans la grille du template.
|
||||
* <p>
|
||||
* Miroir de {@link com.loremind.domain.shared.template.BlockPosition} :
|
||||
* grille 12 colonnes, {@code {x, y, w, h}} en unites de grille. Tous les
|
||||
* champs sont nullables (Integer) — absence = auto-flow empile cote front.
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BlockPositionDTO {
|
||||
/** Colonne de depart (0..11). */
|
||||
private Integer x;
|
||||
/** Ligne de depart (0..n). */
|
||||
private Integer y;
|
||||
/** Largeur en colonnes (1..12). */
|
||||
private Integer w;
|
||||
/** Hauteur en lignes (1..n). */
|
||||
private Integer h;
|
||||
}
|
||||
@@ -29,6 +29,21 @@ public class TemplateFieldDTO {
|
||||
/** Chemin Foundry du champ (mapping pour l'export d'acteur typé). Nullable. */
|
||||
private String foundryPath;
|
||||
|
||||
/**
|
||||
* Identifiant STABLE du bloc (cle d'ancrage des valeurs de Page). Retro-rempli
|
||||
* avec le nom cote backend pour les templates anterieurs. Appended en fin de
|
||||
* classe pour preserver la compat des constructeurs historiques.
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/** Placement du bloc dans la grille 12 colonnes. Null = auto-flow empile. */
|
||||
private BlockPositionDTO pos;
|
||||
|
||||
/** Retrocompat : constructeur sans id ni pos. */
|
||||
public TemplateFieldDTO(String name, String type, String layout, List<String> labels, String foundryPath) {
|
||||
this(name, type, layout, labels, foundryPath, null, null);
|
||||
}
|
||||
|
||||
/** Retrocompat : constructeur sans foundryPath. */
|
||||
public TemplateFieldDTO(String name, String type, String layout, List<String> labels) {
|
||||
this(name, type, layout, labels, null);
|
||||
|
||||
@@ -24,6 +24,7 @@ public class PageMapper {
|
||||
dto.setOrder(page.getOrder());
|
||||
dto.setValues(CollectionUtils.copyMap(page.getValues()));
|
||||
dto.setImageValues(CollectionUtils.copyMap(page.getImageValues()));
|
||||
dto.setImageFraming(CollectionUtils.copyMap(page.getImageFraming()));
|
||||
dto.setKeyValueValues(CollectionUtils.copyMap(page.getKeyValueValues()));
|
||||
dto.setTableValues(CollectionUtils.copyMap(page.getTableValues()));
|
||||
dto.setNotes(page.getNotes());
|
||||
@@ -45,6 +46,7 @@ public class PageMapper {
|
||||
.order(dto.getOrder())
|
||||
.values(CollectionUtils.copyMap(dto.getValues()))
|
||||
.imageValues(CollectionUtils.copyMap(dto.getImageValues()))
|
||||
.imageFraming(CollectionUtils.copyMap(dto.getImageFraming()))
|
||||
.keyValueValues(CollectionUtils.copyMap(dto.getKeyValueValues()))
|
||||
.tableValues(CollectionUtils.copyMap(dto.getTableValues()))
|
||||
.notes(dto.getNotes())
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.loremind.infrastructure.web.mapper;
|
||||
|
||||
import com.loremind.domain.shared.template.BlockPosition;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
import com.loremind.infrastructure.web.dto.shared.BlockPositionDTO;
|
||||
import com.loremind.infrastructure.web.dto.shared.TemplateFieldDTO;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -33,7 +35,11 @@ public class TemplateFieldMapper {
|
||||
&& field.getLabels() != null) {
|
||||
labels = new ArrayList<>(field.getLabels());
|
||||
}
|
||||
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr, labels, field.getFoundryPath());
|
||||
TemplateFieldDTO dto = new TemplateFieldDTO(
|
||||
field.getName(), typeStr, layoutStr, labels, field.getFoundryPath());
|
||||
dto.setId(resolveId(field.getId(), field.getName()));
|
||||
dto.setPos(toPosDTO(field.getPos()));
|
||||
return dto;
|
||||
}
|
||||
|
||||
public TemplateField toDomain(TemplateFieldDTO dto) {
|
||||
@@ -58,7 +64,34 @@ public class TemplateFieldMapper {
|
||||
if ((type == FieldType.KEY_VALUE_LIST || type == FieldType.TABLE) && dto.getLabels() != null) {
|
||||
labels = new ArrayList<>(dto.getLabels());
|
||||
}
|
||||
return new TemplateField(dto.getName(), type, layout, labels, dto.getFoundryPath());
|
||||
return TemplateField.builder()
|
||||
.id(resolveId(dto.getId(), dto.getName()))
|
||||
.name(dto.getName())
|
||||
.type(type)
|
||||
.layout(layout)
|
||||
.labels(labels)
|
||||
.foundryPath(dto.getFoundryPath())
|
||||
.pos(toPosDomain(dto.getPos()))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renvoie l'id du bloc, retro-rempli avec le nom quand il est absent.
|
||||
* Garantit une cle d'ancrage stable meme pour les clients qui n'envoient
|
||||
* pas encore d'id (front anterieur a la grille).
|
||||
*/
|
||||
private String resolveId(String id, String name) {
|
||||
return id != null && !id.isBlank() ? id : name;
|
||||
}
|
||||
|
||||
private BlockPositionDTO toPosDTO(BlockPosition pos) {
|
||||
if (pos == null) return null;
|
||||
return new BlockPositionDTO(pos.getX(), pos.getY(), pos.getW(), pos.getH());
|
||||
}
|
||||
|
||||
private BlockPosition toPosDomain(BlockPositionDTO pos) {
|
||||
if (pos == null) return null;
|
||||
return new BlockPosition(pos.getX(), pos.getY(), pos.getW(), pos.getH());
|
||||
}
|
||||
|
||||
/** Mappe une liste de champs domaine → DTO ({@code null} → liste vide). */
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Cadrage (pan/zoom) des images dans les blocs IMAGE des pages de lore.
|
||||
-- Stocké en JSON dans une colonne TEXT (fieldKey -> imageId -> {x, y, scale}).
|
||||
-- Compatible Postgres (Docker) et H2 en MODE=PostgreSQL (desktop).
|
||||
alter table pages add column image_framing TEXT;
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.loremind.domain.lorecontext.ImageFraming;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Tests du converter de cadrage d'images (Map<String, Map<String, ImageFraming>>).
|
||||
* Vérifie notamment que le record {@link ImageFraming} fait bien l'aller-retour
|
||||
* JSON (Jackson supporte les records nativement) — sinon les valeurs seraient
|
||||
* perdues silencieusement.
|
||||
*/
|
||||
class ImageFramingMapJsonConverterTest {
|
||||
|
||||
private final ImageFramingMapJsonConverter converter = new ImageFramingMapJsonConverter();
|
||||
|
||||
@Test
|
||||
void toDb_nullOrEmpty_yieldsEmptyObject() {
|
||||
assertEquals("{}", converter.convertToDatabaseColumn(null));
|
||||
assertEquals("{}", converter.convertToDatabaseColumn(Map.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_nullOrBlank_yieldsEmptyMap() {
|
||||
assertTrue(converter.convertToEntityAttribute(null).isEmpty());
|
||||
assertTrue(converter.convertToEntityAttribute(" ").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesFramingPerImage() {
|
||||
Map<String, Map<String, ImageFraming>> source = Map.of(
|
||||
"blk-illu", Map.of(
|
||||
"img-42", new ImageFraming(30.0, 70.0, 1.5),
|
||||
"img-7", new ImageFraming(50.0, 50.0, 1.0)));
|
||||
|
||||
Map<String, Map<String, ImageFraming>> back =
|
||||
converter.convertToEntityAttribute(converter.convertToDatabaseColumn(source));
|
||||
|
||||
ImageFraming f = back.get("blk-illu").get("img-42");
|
||||
assertEquals(30.0, f.x());
|
||||
assertEquals(70.0, f.y());
|
||||
assertEquals(1.5, f.scale());
|
||||
assertEquals(1.0, back.get("blk-illu").get("img-7").scale());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_readsExplicitJson() {
|
||||
Map<String, Map<String, ImageFraming>> back = converter.convertToEntityAttribute(
|
||||
"{\"blk\":{\"img\":{\"x\":10.0,\"y\":20.0,\"scale\":2.0}}}");
|
||||
ImageFraming f = back.get("blk").get("img");
|
||||
assertEquals(10.0, f.x());
|
||||
assertEquals(20.0, f.y());
|
||||
assertEquals(2.0, f.scale());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.loremind.infrastructure.persistence.converter;
|
||||
|
||||
import com.loremind.domain.shared.template.BlockPosition;
|
||||
import com.loremind.domain.shared.template.FieldType;
|
||||
import com.loremind.domain.shared.template.ImageLayout;
|
||||
import com.loremind.domain.shared.template.TemplateField;
|
||||
@@ -8,6 +9,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -208,4 +210,94 @@ class TemplateFieldListJsonConverterTest {
|
||||
assertEquals(pass1.get(i).getType(), pass2.get(i).getType());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- id stable (ancrage des valeurs de Page) --------------------
|
||||
|
||||
@Test
|
||||
void fromDb_legacyFormat_backfillsIdWithName() {
|
||||
// Format legacy (chaines) : l'id stable est retro-rempli avec le nom,
|
||||
// donc id == name -> les valeurs deja rangees par nom resolvent toujours.
|
||||
List<TemplateField> result = converter.convertToEntityAttribute("[\"Histoire\"]");
|
||||
assertEquals("Histoire", result.get(0).getName());
|
||||
assertEquals("Histoire", result.get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_newFormat_withoutId_backfillsIdWithName() {
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Ambiance\",\"type\":\"TEXT\"}]");
|
||||
assertEquals("Ambiance", result.get(0).getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_newFormat_withExplicitId_keepsItEvenWhenNameDiffers() {
|
||||
// Simule un bloc renomme : l'id stable est decorrele du nom courant.
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"id\":\"blk-1\",\"name\":\"Nouveau nom\",\"type\":\"TEXT\"}]");
|
||||
assertEquals("blk-1", result.get(0).getId());
|
||||
assertEquals("Nouveau nom", result.get(0).getName());
|
||||
}
|
||||
|
||||
// ---------- foundryPath : non-regression (perdu au save+reload avant) --
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesFoundryPath() {
|
||||
// Garde-fou : foundryPath n'etait pas relu et disparaissait au
|
||||
// save+reload. Il doit desormais survivre a l'aller-retour.
|
||||
List<TemplateField> source = List.of(
|
||||
new TemplateField("PV", FieldType.NUMBER, null, null, "attributes.hp.value"));
|
||||
|
||||
List<TemplateField> back = converter.convertToEntityAttribute(
|
||||
converter.convertToDatabaseColumn(source));
|
||||
|
||||
assertEquals("attributes.hp.value", back.get(0).getFoundryPath());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_nullFoundryPath_readsAsNull_notLiteralNullString() {
|
||||
// Piege Jackson : sur un NullNode, asText() renverrait la chaine "null".
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Histoire\",\"type\":\"TEXT\",\"foundryPath\":null}]");
|
||||
assertNull(result.get(0).getFoundryPath());
|
||||
}
|
||||
|
||||
// ---------- pos : placement dans la grille -----------------------------
|
||||
|
||||
@Test
|
||||
void roundTrip_preservesBlockPosition() {
|
||||
TemplateField illustration = TemplateField.builder()
|
||||
.id("blk-illu").name("Illustration").type(FieldType.IMAGE)
|
||||
.layout(ImageLayout.GALLERY)
|
||||
.pos(new BlockPosition(0, 0, 6, 4))
|
||||
.build();
|
||||
TemplateField ambiance = TemplateField.builder()
|
||||
.id("blk-amb").name("Ambiance générale").type(FieldType.TEXT)
|
||||
.pos(new BlockPosition(6, 0, 6, 4))
|
||||
.build();
|
||||
|
||||
List<TemplateField> back = converter.convertToEntityAttribute(
|
||||
converter.convertToDatabaseColumn(List.of(illustration, ambiance)));
|
||||
|
||||
BlockPosition p0 = back.get(0).getPos();
|
||||
assertNotNull(p0);
|
||||
assertEquals(0, p0.getX());
|
||||
assertEquals(6, p0.getW());
|
||||
// Le second bloc est place a droite du premier (cote-a-cote).
|
||||
assertEquals(6, back.get(1).getPos().getX());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_withoutPos_yieldsNullPosition() {
|
||||
// Pas de pos -> auto-flow empile (rendu historique).
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Histoire\",\"type\":\"TEXT\"}]");
|
||||
assertNull(result.get(0).getPos());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fromDb_nullPos_yieldsNullPosition() {
|
||||
List<TemplateField> result = converter.convertToEntityAttribute(
|
||||
"[{\"name\":\"Histoire\",\"type\":\"TEXT\",\"pos\":null}]");
|
||||
assertNull(result.get(0).getPos());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +53,15 @@ test.describe('Template creation', () => {
|
||||
const addFieldInput = page.getByPlaceholder('+ Ajouter un champ');
|
||||
await addFieldInput.fill('Pouvoir');
|
||||
await addFieldInput.press('Enter');
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Pouvoir' })).toBeVisible();
|
||||
await expect(page.locator('.grid-block[data-block-name="Pouvoir"]')).toBeVisible();
|
||||
|
||||
await addFieldInput.fill('Origine');
|
||||
await addFieldInput.press('Enter');
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toBeVisible();
|
||||
await expect(page.locator('.grid-block[data-block-name="Origine"]')).toBeVisible();
|
||||
|
||||
const origineRow = page.locator('.fields-list .field-row', { hasText: 'Origine' });
|
||||
await origineRow.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Origine' })).toHaveCount(0);
|
||||
const origineBlock = page.locator('.grid-block[data-block-name="Origine"]');
|
||||
await origineBlock.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('.grid-block[data-block-name="Origine"]')).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: /^Créer le template$/i }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
@@ -30,8 +30,8 @@ test.describe('Template edit', () => {
|
||||
|
||||
await expect(page.getByLabel(/^Nom$/)).toHaveValue(template.name);
|
||||
await expect(page.getByLabel(/Dossier par défaut/i)).toHaveValue(seeded.rootFolderId);
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Nom' })).toBeVisible();
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toBeVisible();
|
||||
await expect(page.locator('.grid-block[data-block-name="Nom"]')).toBeVisible();
|
||||
await expect(page.locator('.grid-block[data-block-name="Description"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('renames the template and persists to API', async ({ page, request }) => {
|
||||
@@ -58,11 +58,11 @@ test.describe('Template edit', () => {
|
||||
const addInput = page.getByPlaceholder('+ Ajouter un champ');
|
||||
await addInput.fill('Stats');
|
||||
await addInput.press('Enter');
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Stats' })).toBeVisible();
|
||||
await expect(page.locator('.grid-block[data-block-name="Stats"]')).toBeVisible();
|
||||
|
||||
const descriptionRow = page.locator('.fields-list .field-row', { hasText: 'Description' });
|
||||
await descriptionRow.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('.fields-list .field-chip', { hasText: 'Description' })).toHaveCount(0);
|
||||
const descriptionBlock = page.locator('.grid-block[data-block-name="Description"]');
|
||||
await descriptionBlock.getByRole('button', { name: 'Supprimer' }).click();
|
||||
await expect(page.locator('.grid-block[data-block-name="Description"]')).toHaveCount(0);
|
||||
|
||||
await page.getByRole('button', { name: /^Sauvegarder$/i }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/lore/${seeded.id}$`));
|
||||
|
||||
@@ -38,11 +38,24 @@
|
||||
<small class="field-hint">{{ 'sceneEdit.illustrationsHint' | translate }}</small>
|
||||
</div>
|
||||
|
||||
<!-- Battlemap Foundry : media + sidecar JSON Universal VTT (non affichee, export only) -->
|
||||
<!-- Battlemap Foundry : selon la source (Dungeon Alchemist = media + .json,
|
||||
DungeonDraft = .dd2vtt unique). Non affichee dans l'appli (export only). -->
|
||||
<div class="field">
|
||||
<label>{{ 'sceneEdit.battlemapLabel' | translate }}</label>
|
||||
<small class="field-hint">{{ 'sceneEdit.battlemapHint' | translate }}</small>
|
||||
|
||||
<div class="battlemap-source" role="group" [attr.aria-label]="'sceneEdit.battlemapSourceLabel' | translate">
|
||||
<button type="button" [class.active]="battlemapSource === 'DUNGEON_ALCHEMIST'"
|
||||
(click)="setBattlemapSource('DUNGEON_ALCHEMIST')">
|
||||
{{ 'sceneEdit.battlemapSourceDA' | translate }}
|
||||
</button>
|
||||
<button type="button" [class.active]="battlemapSource === 'DUNGEONDRAFT'"
|
||||
(click)="setBattlemapSource('DUNGEONDRAFT')">
|
||||
{{ 'sceneEdit.battlemapSourceDD' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (battlemapSource === 'DUNGEON_ALCHEMIST') {
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="battlemapUploadingMedia"
|
||||
(filesDropped)="onBattlemapMediaDropped($event)">
|
||||
@@ -76,6 +89,28 @@
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="battlemap-slot"
|
||||
appFileDrop [dropDisabled]="battlemapUploadingData"
|
||||
(filesDropped)="onDd2vttDropped($event)">
|
||||
<span class="battlemap-slot-label">{{ 'sceneEdit.battlemapDd2vttSlot' | translate }}</span>
|
||||
@if (battlemapDataFileId) {
|
||||
<span class="battlemap-file">{{ battlemapDataName || ('sceneEdit.battlemapAttached' | translate) }}</span>
|
||||
<button type="button" class="battlemap-remove" (click)="removeDd2vtt()">
|
||||
{{ 'sceneEdit.battlemapRemove' | translate }}
|
||||
</button>
|
||||
} @else {
|
||||
<label class="battlemap-pick">
|
||||
{{ (battlemapUploadingData ? 'sceneEdit.battlemapUploading' : 'sceneEdit.battlemapChoose') | translate }}
|
||||
<input type="file" accept=".dd2vtt,.uvtt,application/json" hidden (change)="onDd2vttSelected($event)" />
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
@if (battlemapDd2vttNoImage) {
|
||||
<small class="field-hint battlemap-warn">{{ 'sceneEdit.battlemapDd2vttNoImage' | translate }}</small>
|
||||
}
|
||||
<small class="field-hint">{{ 'sceneEdit.battlemapDd2vttHint' | translate }}</small>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
||||
@@ -3,6 +3,34 @@
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
// Sélecteur de source de carte (Dungeon Alchemist / DungeonDraft).
|
||||
.battlemap-source {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
margin: 0.5rem 0;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
background: #1a1a2e;
|
||||
border: none;
|
||||
color: #9ca3af;
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
& + button { border-left: 1px solid #2a2a3d; }
|
||||
&:hover { color: #d1d5db; }
|
||||
&.active { background: #6c63ff; color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
.battlemap-warn {
|
||||
color: #fca5a5 !important;
|
||||
}
|
||||
|
||||
// Widget battlemap (export Foundry) : deux emplacements media + sidecar JSON.
|
||||
.battlemap-slot {
|
||||
display: flex;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
import { forkJoin, of, firstValueFrom } from 'rxjs';
|
||||
import { switchMap } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Trash2, Sparkles } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
@@ -80,6 +80,17 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
battlemapUploadingMedia = false;
|
||||
battlemapUploadingData = false;
|
||||
|
||||
/**
|
||||
* Source de carte choisie pour CETTE scene :
|
||||
* - 'DUNGEON_ALCHEMIST' : export Foundry = image/video + .json (2 fichiers).
|
||||
* - 'DUNGEONDRAFT' : .dd2vtt unique (Universal VTT, image embarquee) ;
|
||||
* on extrait l'image a l'upload -> media, et on range le .dd2vtt en donnees.
|
||||
* Non persistee : deduite au chargement de l'extension du fichier de donnees.
|
||||
*/
|
||||
battlemapSource: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT' = 'DUNGEON_ALCHEMIST';
|
||||
/** Le .dd2vtt deposse ne contenait pas d'image embarquee (carte sans fond). */
|
||||
battlemapDd2vttNoImage = false;
|
||||
|
||||
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
|
||||
siblingScenes: Scene[] = [];
|
||||
/** Branches narratives (état local mutable, persisté au submit). */
|
||||
@@ -173,13 +184,21 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.battlemapDataFileId = scene.battlemapDataFileId ?? null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDataName = null;
|
||||
this.battlemapSource = 'DUNGEON_ALCHEMIST';
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
if (this.battlemapMediaFileId) {
|
||||
this.storedFileService.getById(this.battlemapMediaFileId)
|
||||
.subscribe({ next: f => this.battlemapMediaName = f.filename, error: () => {} });
|
||||
}
|
||||
if (this.battlemapDataFileId) {
|
||||
this.storedFileService.getById(this.battlemapDataFileId)
|
||||
.subscribe({ next: f => this.battlemapDataName = f.filename, error: () => {} });
|
||||
this.storedFileService.getById(this.battlemapDataFileId).subscribe({
|
||||
next: f => {
|
||||
this.battlemapDataName = f.filename;
|
||||
// Deduit la source : un .dd2vtt/.uvtt => DungeonDraft.
|
||||
if (/\.(dd2vtt|uvtt)$/i.test(f.filename)) this.battlemapSource = 'DUNGEONDRAFT';
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
}
|
||||
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
||||
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
|
||||
@@ -334,6 +353,106 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.battlemapDataName = null;
|
||||
}
|
||||
|
||||
// ─────────────── Source de carte (Dungeon Alchemist / DungeonDraft) ──────────
|
||||
|
||||
/** Change la source ; repart d'une carte vierge (les deux formats diffèrent). */
|
||||
setBattlemapSource(src: 'DUNGEON_ALCHEMIST' | 'DUNGEONDRAFT'): void {
|
||||
if (src === this.battlemapSource) return;
|
||||
this.battlemapSource = src;
|
||||
this.battlemapMediaFileId = null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDataFileId = null;
|
||||
this.battlemapDataName = null;
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
}
|
||||
|
||||
/** Retire la carte DungeonDraft (data + media dérivé). */
|
||||
removeDd2vtt(): void {
|
||||
this.battlemapMediaFileId = null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDataFileId = null;
|
||||
this.battlemapDataName = null;
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
}
|
||||
|
||||
onDd2vttSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) void this.uploadDd2vtt(file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
onDd2vttDropped(files: File[]): void {
|
||||
if (files[0]) void this.uploadDd2vtt(files[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload d'un .dd2vtt (Universal VTT) : on en EXTRAIT l'image embarquee (base64)
|
||||
* pour en faire le media (fond de carte pour l'export Foundry), et on range le
|
||||
* sidecar (murs/lumieres/grille, SANS l'image pour ne pas dupliquer le binaire)
|
||||
* comme fichier de donnees.
|
||||
*/
|
||||
private async uploadDd2vtt(file: File): Promise<void> {
|
||||
this.battlemapUploadingData = true;
|
||||
this.battlemapDd2vttNoImage = false;
|
||||
try {
|
||||
const json = JSON.parse(await file.text());
|
||||
const { image, ...sidecar } = json ?? {};
|
||||
// Sidecar allégé (sans l'image embarquée) ; on garde le nom .dd2vtt.
|
||||
const dataFile = new File([JSON.stringify(sidecar)], file.name, { type: 'application/json' });
|
||||
const storedData = await firstValueFrom(this.storedFileService.upload(dataFile));
|
||||
this.battlemapDataFileId = storedData.id;
|
||||
this.battlemapDataName = storedData.filename;
|
||||
|
||||
if (typeof image === 'string' && image.length > 0) {
|
||||
const blob = this.base64ToImageBlob(image);
|
||||
const imgName = this.baseName(file.name) + this.extForType(blob.type);
|
||||
const imgFile = new File([blob], imgName, { type: blob.type });
|
||||
const storedMedia = await firstValueFrom(this.storedFileService.upload(imgFile));
|
||||
this.battlemapMediaFileId = storedMedia.id;
|
||||
this.battlemapMediaName = storedMedia.filename;
|
||||
} else {
|
||||
this.battlemapMediaFileId = null;
|
||||
this.battlemapMediaName = null;
|
||||
this.battlemapDd2vttNoImage = true;
|
||||
}
|
||||
} catch {
|
||||
// JSON illisible : on stocke le fichier brut comme données, sans image.
|
||||
try {
|
||||
const stored = await firstValueFrom(this.storedFileService.upload(file));
|
||||
this.battlemapDataFileId = stored.id;
|
||||
this.battlemapDataName = stored.filename;
|
||||
this.battlemapDd2vttNoImage = true;
|
||||
} catch { /* upload échoué : on ignore */ }
|
||||
} finally {
|
||||
this.battlemapUploadingData = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Décode une image base64 (sans préfixe data:) en Blob, type sniffé. */
|
||||
private base64ToImageBlob(b64: string): Blob {
|
||||
const clean = b64.includes(',') ? b64.slice(b64.indexOf(',') + 1) : b64.trim();
|
||||
const bin = atob(clean);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new Blob([bytes], { type: this.sniffImageType(bytes) });
|
||||
}
|
||||
|
||||
private sniffImageType(b: Uint8Array): string {
|
||||
if (b[0] === 0x89 && b[1] === 0x50) return 'image/png';
|
||||
if (b[0] === 0xff && b[1] === 0xd8) return 'image/jpeg';
|
||||
if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46) return 'image/webp'; // RIFF
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
private baseName(name: string): string {
|
||||
return name.replace(/\.[^./\\]+$/, '');
|
||||
}
|
||||
|
||||
private extForType(type: string): string {
|
||||
return type === 'image/jpeg' ? '.jpg' : type === 'image/webp' ? '.webp' : '.png';
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Volontairement vide : la sidebar reste prise en charge par le composant
|
||||
// suivant (autre sous-route ou le composant detail parent) qui appellera
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<div class="grid-builder">
|
||||
<div class="builder-body">
|
||||
<!-- Palette : types de blocs à glisser sur la grille (ou cliquer) -->
|
||||
<div class="palette">
|
||||
<div class="palette-title">{{ 'templateEdit.paletteTitle' | translate }}</div>
|
||||
@for (pt of paletteTypes; track pt.type) {
|
||||
<div class="palette-item"
|
||||
(pointerdown)="startCreate($event, pt.type)"
|
||||
(click)="addType(pt.type)"
|
||||
[title]="'templateEdit.addBlockHint' | translate">
|
||||
<lucide-icon [img]="pt.icon" [size]="15"></lucide-icon>
|
||||
<span>{{ pt.labelKey | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Toile : grille 2D libre (placement + dimension en x/y/w/h) -->
|
||||
<div class="canvas"
|
||||
#canvasEl
|
||||
[class.dragging]="!!drag"
|
||||
[style.grid-template-rows]="'repeat(' + gridRows + ', ' + GRID_ROW_HEIGHT + 'px)'">
|
||||
@for (block of blocks; track block.id) {
|
||||
<div class="grid-block"
|
||||
[attr.data-block-name]="block.name"
|
||||
[style.grid-column]="gridColumn(block)"
|
||||
[style.grid-row]="gridRow(block)"
|
||||
[class.is-image]="block.type === 'IMAGE'"
|
||||
[class.is-kv]="block.type === 'KEY_VALUE_LIST'"
|
||||
[class.is-table]="block.type === 'TABLE'"
|
||||
[class.is-new]="!isExisting(block)"
|
||||
[class.dragged]="drag?.block === block">
|
||||
<div class="block-head">
|
||||
<button type="button" class="block-grip" (pointerdown)="startMove($event, block)"
|
||||
[attr.aria-label]="'templateEdit.move' | translate" [title]="'templateEdit.move' | translate">
|
||||
<lucide-icon [img]="GripVertical" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
<lucide-icon class="block-type-icon" [img]="iconFor(block.type)" [size]="13"></lucide-icon>
|
||||
<input class="block-name-input"
|
||||
[ngModel]="block.name"
|
||||
(ngModelChange)="rename(block, $event)"
|
||||
(focus)="onNameFocus(block)"
|
||||
(blur)="commitRename(block)"
|
||||
[attr.aria-label]="'templateEdit.blockNameLabel' | translate"
|
||||
[title]="block.name" />
|
||||
<button type="button" class="block-remove" (click)="remove(block)" [attr.aria-label]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (block.type === 'KEY_VALUE_LIST' || block.type === 'TABLE') {
|
||||
<div class="block-labels">
|
||||
@for (lbl of block.labels ?? []; track $index; let li = $index) {
|
||||
<span class="kv-label-chip">
|
||||
<input type="text" [ngModel]="lbl" (ngModelChange)="updateLabel(block, li, $event)"
|
||||
[placeholder]="(block.type === 'TABLE' ? 'templateEdit.column' : 'templateEdit.label') | translate"
|
||||
[attr.aria-label]="(block.type === 'TABLE' ? 'templateEdit.columnN' : 'templateEdit.labelN') | translate:{ n: (li + 1) }" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(block, li)" [attr.aria-label]="'common.remove' | translate">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn-kv-add-label" (click)="addLabel(block)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ (block.type === 'TABLE' ? 'templateEdit.column' : 'templateEdit.label') | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Poignées de redimensionnement -->
|
||||
<div class="rh rh-e" (pointerdown)="startResize($event, block, 'e')"
|
||||
[attr.aria-label]="'templateEdit.resizeWidth' | translate" [title]="'templateEdit.resizeWidth' | translate"></div>
|
||||
<div class="rh rh-s" (pointerdown)="startResize($event, block, 's')"
|
||||
[attr.aria-label]="'templateEdit.resizeHeight' | translate" [title]="'templateEdit.resizeHeight' | translate"></div>
|
||||
<div class="rh rh-se" (pointerdown)="startResize($event, block, 'se')"
|
||||
[attr.aria-label]="'templateEdit.resize' | translate" [title]="'templateEdit.resize' | translate"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Aperçu du bloc en cours de création -->
|
||||
@if (drag?.kind === 'create' && drag?.over) {
|
||||
<div class="create-preview"
|
||||
[style.grid-column]="(drag!.px + 1) + ' / span ' + drag!.pw"
|
||||
[style.grid-row]="(drag!.py + 1) + ' / span ' + drag!.ph"></div>
|
||||
}
|
||||
|
||||
@if (!blocks.length && !drag) {
|
||||
<p class="canvas-empty">{{ 'templateEdit.canvasEmpty' | translate }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fantôme suivant le pointeur pendant la création -->
|
||||
@if (drag?.kind === 'create') {
|
||||
<div class="drag-ghost" [style.left.px]="drag!.pointerX" [style.top.px]="drag!.pointerY">
|
||||
<lucide-icon [img]="iconFor(drag!.type!)" [size]="13"></lucide-icon>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="hint">{{ 'templateEdit.gridHelp' | translate }}</p>
|
||||
</div>
|
||||
@@ -0,0 +1,289 @@
|
||||
.grid-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.builder-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
// --- Palette --------------------------------------------------------------
|
||||
.palette {
|
||||
flex: 0 0 150px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.6rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border: 1px solid #23233a;
|
||||
border-radius: 8px;
|
||||
|
||||
.palette-title {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #6b7280;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.palette-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
transition: border-color 0.12s, background 0.12s, color 0.12s;
|
||||
|
||||
lucide-icon { color: #a5b4fc; flex-shrink: 0; }
|
||||
|
||||
&:hover { border-color: #6c63ff; background: #20203a; color: white; }
|
||||
&:active { cursor: grabbing; }
|
||||
}
|
||||
|
||||
// --- Toile (grille 2D libre) ----------------------------------------------
|
||||
.canvas {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px dashed #2a2a3d;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(255, 255, 255, 0.012);
|
||||
background-image:
|
||||
repeating-linear-gradient(to right, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px calc((100% - 8px) / 12)),
|
||||
repeating-linear-gradient(to bottom, rgba(255, 255, 255, 0.045) 0 1px, transparent 1px 36px);
|
||||
background-position: 4px 4px;
|
||||
|
||||
&.dragging { border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
.canvas-empty {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3 / span 3;
|
||||
align-self: center;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.grid-block {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-left: 3px solid #6c63ff; // accent par type (TEXT par défaut)
|
||||
border-radius: 5px;
|
||||
min-width: 0;
|
||||
|
||||
&.is-image { border-left-color: #818cf8; }
|
||||
&.is-kv { border-left-color: #f59e0b; }
|
||||
&.is-table { border-left-color: #14b8a6; }
|
||||
&.is-new { border-left-color: #34d399; }
|
||||
|
||||
&.dragged {
|
||||
border-color: #6c63ff;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
||||
z-index: 5;
|
||||
}
|
||||
}
|
||||
|
||||
.block-head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 3px 16px 3px 3px; // marge droite pour la poignée de largeur
|
||||
}
|
||||
|
||||
.block-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: grab;
|
||||
padding: 2px;
|
||||
touch-action: none;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active { cursor: grabbing; }
|
||||
&:hover { color: #9ca3af; }
|
||||
}
|
||||
|
||||
.block-type-icon { color: #a5b4fc; flex-shrink: 0; }
|
||||
|
||||
.block-name-input {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover { border-color: rgba(255, 255, 255, 0.12); }
|
||||
&:focus { outline: none; background: rgba(0, 0, 0, 0.3); border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
.block-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover { color: #f87171; }
|
||||
}
|
||||
|
||||
// Libellés KV/TABLE — zone défilante (sous l'en-tête)
|
||||
.block-labels {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 0.3rem;
|
||||
padding: 0 14px 12px 8px;
|
||||
}
|
||||
|
||||
.kv-label-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 1px 2px 1px 4px;
|
||||
height: fit-content;
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 0.74rem;
|
||||
width: 78px;
|
||||
padding: 2px;
|
||||
|
||||
&:focus { outline: none; }
|
||||
}
|
||||
|
||||
.kv-label-remove {
|
||||
display: inline-flex;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
padding: 1px;
|
||||
|
||||
&:hover { color: #f87171; }
|
||||
}
|
||||
}
|
||||
|
||||
.btn-kv-add-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
color: #9ca3af;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
height: fit-content;
|
||||
|
||||
&:hover { color: white; border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
// --- Poignées de redimensionnement ----------------------------------------
|
||||
.rh {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
touch-action: none;
|
||||
}
|
||||
.rh-e { top: 0; right: 0; width: 10px; height: 100%; cursor: ew-resize; }
|
||||
.rh-s { left: 0; bottom: 0; height: 10px; width: 100%; cursor: ns-resize; }
|
||||
.rh-se {
|
||||
right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; z-index: 4;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 3px;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-right: 2px solid #6c63ff;
|
||||
border-bottom: 2px solid #6c63ff;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
.grid-block:hover .rh-se::after { opacity: 1; }
|
||||
|
||||
// Aperçu du bloc en création (rectangle en pointillés)
|
||||
.create-preview {
|
||||
border: 2px dashed #6c63ff;
|
||||
border-radius: 5px;
|
||||
background: rgba(108, 99, 255, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Fantôme qui suit le pointeur pendant la création
|
||||
.drag-ghost {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
transform: translate(8px, 8px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #6c63ff;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.76rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// Repli mobile : palette au-dessus, toile pleine largeur.
|
||||
@media (max-width: 820px) {
|
||||
.builder-body { flex-direction: column; }
|
||||
.palette {
|
||||
flex: 1 1 auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
.palette-title { width: 100%; }
|
||||
}
|
||||
.canvas { width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, OnDestroy, Output, ViewChild,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
LucideAngularModule, Trash2, Type, Image as ImageIcon,
|
||||
ListOrdered, Table as TableIcon, X, GripVertical, Plus,
|
||||
} from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { FieldType, TemplateField, buildLoreTemplateField } from '../../services/template.model';
|
||||
import { GRID_COLS, GRID_ROW_HEIGHT, DEFAULT_BLOCK_H } from '../block-layout.helper';
|
||||
|
||||
const MAX_H = 40;
|
||||
|
||||
function genId(): string {
|
||||
try { return 'blk-' + crypto.randomUUID(); }
|
||||
catch { return 'blk-' + Math.random().toString(36).slice(2) + Date.now().toString(36); }
|
||||
}
|
||||
function clampW(w: number | null | undefined): number {
|
||||
return Math.min(GRID_COLS, Math.max(1, Math.round(w ?? GRID_COLS)));
|
||||
}
|
||||
function clampH(h: number | null | undefined): number {
|
||||
return Math.min(MAX_H, Math.max(1, Math.round(h ?? DEFAULT_BLOCK_H)));
|
||||
}
|
||||
|
||||
interface PaletteType {
|
||||
type: FieldType;
|
||||
icon: typeof Type;
|
||||
labelKey: string;
|
||||
nameKey: string;
|
||||
}
|
||||
|
||||
interface CanvasGeom { left: number; top: number; colUnit: number; rowUnit: number; }
|
||||
|
||||
interface DragOp {
|
||||
kind: 'create' | 'move' | 'resize';
|
||||
type?: FieldType;
|
||||
block?: TemplateField;
|
||||
edge?: 'e' | 's' | 'se';
|
||||
g: CanvasGeom;
|
||||
grabCol: number;
|
||||
grabRow: number;
|
||||
// Aperçu (création) : rectangle cible dans la grille.
|
||||
px: number; py: number; pw: number; ph: number;
|
||||
over: boolean; // pointeur au-dessus de la toile (création)
|
||||
pointerX: number; pointerY: number; // pour le fantôme suiveur
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder de blocs en grille 2D LIBRE (inspiré de l'app "Lore" d'Amsel).
|
||||
*
|
||||
* - PALETTE latérale de types de blocs : on les glisse sur la toile (ou on
|
||||
* clique pour les ajouter en bas).
|
||||
* - chaque bloc se PLACE où on veut (x ET y) en glissant sa poignée, et se
|
||||
* REDIMENSIONNE en largeur ET hauteur via les poignées de bord/coin (snap à
|
||||
* la grille 12 colonnes × lignes fixes de {@link GRID_ROW_HEIGHT}px).
|
||||
* - le nom est éditable en place (id stable → les valeurs des pages suivent).
|
||||
*
|
||||
* Le placement {x, y, w, h} est explicite (pas de "flow-packing") : le rendu
|
||||
* (page-view / page-edit) le reproduit exactement. Les chevauchements sont
|
||||
* permis (placement libre) — c'est à l'utilisateur d'arranger.
|
||||
*
|
||||
* Composant partagé par template-create et template-edit. E/S : `[(fields)]`.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-block-grid-builder',
|
||||
imports: [FormsModule, LucideAngularModule, TranslatePipe],
|
||||
templateUrl: './block-grid-builder.component.html',
|
||||
styleUrls: ['./block-grid-builder.component.scss'],
|
||||
})
|
||||
export class BlockGridBuilderComponent implements OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly X = X;
|
||||
readonly GripVertical = GripVertical;
|
||||
readonly Plus = Plus;
|
||||
readonly GRID_ROW_HEIGHT = GRID_ROW_HEIGHT;
|
||||
|
||||
readonly paletteTypes: PaletteType[] = [
|
||||
{ type: 'TEXT', icon: Type, labelKey: 'templateEdit.typeText', nameKey: 'templateEdit.defaultNameText' },
|
||||
{ type: 'IMAGE', icon: ImageIcon, labelKey: 'templateEdit.typeImage', nameKey: 'templateEdit.defaultNameImage' },
|
||||
{ type: 'KEY_VALUE_LIST', icon: ListOrdered, labelKey: 'templateEdit.typeKeyValue', nameKey: 'templateEdit.defaultNameKeyValue' },
|
||||
{ type: 'TABLE', icon: TableIcon, labelKey: 'templateEdit.typeTable', nameKey: 'templateEdit.defaultNameTable' },
|
||||
];
|
||||
|
||||
@Input() existingFieldNames: Set<string> | null = null;
|
||||
@Output() fieldsChange = new EventEmitter<TemplateField[]>();
|
||||
|
||||
@ViewChild('canvasEl') private canvasRef?: ElementRef<HTMLElement>;
|
||||
|
||||
private lastEmitted: TemplateField[] | null = null;
|
||||
blocks: TemplateField[] = [];
|
||||
|
||||
private renameOrig = '';
|
||||
drag: DragOp | null = null;
|
||||
|
||||
constructor(private translate: TranslateService, private cdr: ChangeDetectorRef) {}
|
||||
|
||||
@Input() set fields(value: TemplateField[] | null | undefined) {
|
||||
if (value && value === this.lastEmitted) return;
|
||||
this.blocks = this.normalize(value ?? []);
|
||||
queueMicrotask(() => this.emit());
|
||||
}
|
||||
|
||||
iconFor(type: FieldType) {
|
||||
switch (type) {
|
||||
case 'IMAGE': return ImageIcon;
|
||||
case 'KEY_VALUE_LIST': return ListOrdered;
|
||||
case 'TABLE': return TableIcon;
|
||||
default: return Type;
|
||||
}
|
||||
}
|
||||
|
||||
isExisting(block: TemplateField): boolean {
|
||||
return !!this.existingFieldNames?.has(block.name);
|
||||
}
|
||||
|
||||
/** Nombre de lignes visibles de la toile (au moins 12, + marge sous le contenu). */
|
||||
get gridRows(): number {
|
||||
return Math.max(12, this.maxBottom() + 3);
|
||||
}
|
||||
|
||||
gridColumn(block: TemplateField): string {
|
||||
return `${(block.pos?.x ?? 0) + 1} / span ${clampW(block.pos?.w)}`;
|
||||
}
|
||||
gridRow(block: TemplateField): string {
|
||||
return `${(block.pos?.y ?? 0) + 1} / span ${clampH(block.pos?.h)}`;
|
||||
}
|
||||
|
||||
// --- Ajout / suppression ------------------------------------------------
|
||||
|
||||
/** Clic palette : ajoute un bloc pleine largeur sous le contenu existant. */
|
||||
addType(type: FieldType): void {
|
||||
const block = this.makeBlock(type, 0, this.maxBottom(), GRID_COLS, DEFAULT_BLOCK_H);
|
||||
this.blocks = [...this.blocks, block];
|
||||
this.emit();
|
||||
}
|
||||
|
||||
remove(block: TemplateField): void {
|
||||
this.blocks = this.blocks.filter(b => b !== block);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// --- Renommage ----------------------------------------------------------
|
||||
|
||||
onNameFocus(block: TemplateField): void { this.renameOrig = block.name; }
|
||||
rename(block: TemplateField, value: string): void { block.name = value; this.emit(); }
|
||||
commitRename(block: TemplateField): void {
|
||||
block.name = block.name.trim() || this.renameOrig;
|
||||
this.emit();
|
||||
}
|
||||
|
||||
// --- Libellés (KEY_VALUE_LIST / TABLE) ----------------------------------
|
||||
|
||||
addLabel(block: TemplateField): void { block.labels = [...(block.labels ?? []), '']; this.emit(); }
|
||||
updateLabel(block: TemplateField, i: number, value: string): void {
|
||||
if (!block.labels) return; block.labels[i] = value; this.emit();
|
||||
}
|
||||
removeLabel(block: TemplateField, i: number): void {
|
||||
if (!block.labels) return; block.labels = block.labels.filter((_, k) => k !== i); this.emit();
|
||||
}
|
||||
|
||||
// --- Glisser : création / déplacement / redimensionnement ---------------
|
||||
|
||||
/** Démarre la création d'un bloc en glissant un type depuis la palette. */
|
||||
startCreate(event: PointerEvent, type: FieldType): void {
|
||||
event.preventDefault();
|
||||
const g = this.geom();
|
||||
if (!g) return;
|
||||
this.drag = {
|
||||
kind: 'create', type, g, grabCol: 0, grabRow: 0,
|
||||
px: 0, py: 0, pw: 6, ph: DEFAULT_BLOCK_H, over: false,
|
||||
pointerX: event.clientX, pointerY: event.clientY, moved: false,
|
||||
};
|
||||
this.listen();
|
||||
}
|
||||
|
||||
/** Démarre le déplacement d'un bloc (poignée). */
|
||||
startMove(event: PointerEvent, block: TemplateField): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const g = this.geom();
|
||||
if (!g) return;
|
||||
const cell = this.cellAt(event.clientX, event.clientY, g);
|
||||
this.drag = {
|
||||
kind: 'move', block, g,
|
||||
grabCol: cell.col - (block.pos?.x ?? 0),
|
||||
grabRow: cell.row - (block.pos?.y ?? 0),
|
||||
px: 0, py: 0, pw: 0, ph: 0, over: true,
|
||||
pointerX: event.clientX, pointerY: event.clientY, moved: false,
|
||||
};
|
||||
this.listen();
|
||||
}
|
||||
|
||||
/** Démarre le redimensionnement d'un bloc (bord/coin). */
|
||||
startResize(event: PointerEvent, block: TemplateField, edge: 'e' | 's' | 'se'): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const g = this.geom();
|
||||
if (!g) return;
|
||||
this.drag = {
|
||||
kind: 'resize', block, edge, g, grabCol: 0, grabRow: 0,
|
||||
px: 0, py: 0, pw: 0, ph: 0, over: true,
|
||||
pointerX: event.clientX, pointerY: event.clientY, moved: false,
|
||||
};
|
||||
this.listen();
|
||||
}
|
||||
|
||||
private onPointerMove = (event: PointerEvent): void => {
|
||||
const d = this.drag;
|
||||
if (!d) return;
|
||||
d.moved = true;
|
||||
d.pointerX = event.clientX;
|
||||
d.pointerY = event.clientY;
|
||||
const cell = this.cellAt(event.clientX, event.clientY, d.g);
|
||||
|
||||
if (d.kind === 'move' && d.block) {
|
||||
const w = clampW(d.block.pos?.w);
|
||||
const x = Math.min(GRID_COLS - w, Math.max(0, cell.col - d.grabCol));
|
||||
const y = Math.max(0, cell.row - d.grabRow);
|
||||
d.block.pos = { ...(d.block.pos ?? {}), x, y, w, h: clampH(d.block.pos?.h) };
|
||||
this.cdr.detectChanges();
|
||||
} else if (d.kind === 'resize' && d.block) {
|
||||
const x = d.block.pos?.x ?? 0;
|
||||
const y = d.block.pos?.y ?? 0;
|
||||
let w = clampW(d.block.pos?.w);
|
||||
let h = clampH(d.block.pos?.h);
|
||||
if (d.edge === 'e' || d.edge === 'se') w = Math.min(GRID_COLS - x, Math.max(1, cell.col - x + 1));
|
||||
if (d.edge === 's' || d.edge === 'se') h = Math.min(MAX_H, Math.max(1, cell.row - y + 1));
|
||||
d.block.pos = { x, y, w, h };
|
||||
this.cdr.detectChanges();
|
||||
} else if (d.kind === 'create') {
|
||||
d.over = this.isOverCanvas(event.clientX, event.clientY);
|
||||
if (d.over) {
|
||||
d.px = Math.min(GRID_COLS - d.pw, Math.max(0, cell.col));
|
||||
d.py = Math.max(0, cell.row);
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
}
|
||||
};
|
||||
|
||||
private onPointerUp = (): void => {
|
||||
const d = this.drag;
|
||||
this.unlisten();
|
||||
this.drag = null;
|
||||
if (!d) return;
|
||||
if (d.kind === 'create' && d.over) {
|
||||
const block = this.makeBlock(d.type!, d.px, d.py, d.pw, d.ph);
|
||||
this.blocks = [...this.blocks, block];
|
||||
this.emit();
|
||||
} else if ((d.kind === 'move' || d.kind === 'resize') && d.moved) {
|
||||
this.emit();
|
||||
}
|
||||
this.cdr.detectChanges();
|
||||
};
|
||||
|
||||
private listen(): void {
|
||||
window.addEventListener('pointermove', this.onPointerMove);
|
||||
window.addEventListener('pointerup', this.onPointerUp, { once: true });
|
||||
}
|
||||
private unlisten(): void {
|
||||
window.removeEventListener('pointermove', this.onPointerMove);
|
||||
window.removeEventListener('pointerup', this.onPointerUp);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void { this.unlisten(); }
|
||||
|
||||
// --- Helpers ------------------------------------------------------------
|
||||
|
||||
private geom(): CanvasGeom | null {
|
||||
const el = this.canvasRef?.nativeElement;
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const style = getComputedStyle(el);
|
||||
const padL = parseFloat(style.paddingLeft) || 0;
|
||||
const padT = parseFloat(style.paddingTop) || 0;
|
||||
const padR = parseFloat(style.paddingRight) || 0;
|
||||
const colGap = parseFloat(style.columnGap || style.gap) || 0;
|
||||
const rowGap = parseFloat(style.rowGap || style.gap) || 0;
|
||||
const innerW = rect.width - padL - padR;
|
||||
return {
|
||||
left: rect.left + padL,
|
||||
top: rect.top + padT,
|
||||
colUnit: (innerW + colGap) / GRID_COLS,
|
||||
rowUnit: GRID_ROW_HEIGHT + rowGap,
|
||||
};
|
||||
}
|
||||
|
||||
private cellAt(px: number, py: number, g: CanvasGeom): { col: number; row: number } {
|
||||
return {
|
||||
col: Math.min(GRID_COLS - 1, Math.max(0, Math.floor((px - g.left) / g.colUnit))),
|
||||
row: Math.max(0, Math.floor((py - g.top) / g.rowUnit)),
|
||||
};
|
||||
}
|
||||
|
||||
private isOverCanvas(px: number, py: number): boolean {
|
||||
const el = this.canvasRef?.nativeElement;
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
return px >= r.left && px <= r.right && py >= r.top && py <= r.bottom;
|
||||
}
|
||||
|
||||
private maxBottom(blocks: TemplateField[] = this.blocks): number {
|
||||
return blocks.reduce((m, b) => Math.max(m, (b.pos?.y ?? 0) + clampH(b.pos?.h)), 0);
|
||||
}
|
||||
|
||||
private makeBlock(type: FieldType, x: number, y: number, w: number, h: number): TemplateField {
|
||||
return {
|
||||
...buildLoreTemplateField(this.uniqueDefaultName(type), type),
|
||||
id: genId(),
|
||||
pos: { x, y, w: clampW(w), h: clampH(h) },
|
||||
};
|
||||
}
|
||||
|
||||
private uniqueDefaultName(type: FieldType): string {
|
||||
const pt = this.paletteTypes.find(p => p.type === type);
|
||||
const base = this.translate.instant(pt?.nameKey ?? 'templateEdit.defaultNameText');
|
||||
let name = base;
|
||||
let i = 2;
|
||||
while (this.blocks.some(b => b.name === name)) name = `${base} ${i++}`;
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise les champs entrants : id stable + position {x,y,w,h} complète.
|
||||
* Les blocs sans aucune position (legacy) sont empilés pleine largeur ; les
|
||||
* blocs partiels (largeur seule, ancien format) reçoivent une hauteur par défaut.
|
||||
*/
|
||||
private normalize(fields: TemplateField[]): TemplateField[] {
|
||||
let nextY = 0;
|
||||
return fields.map(f => {
|
||||
const id = f.id && f.id.trim() ? f.id : (f.name || genId());
|
||||
const labels = f.labels ? [...f.labels] : f.labels;
|
||||
const p = f.pos;
|
||||
let pos;
|
||||
if (p && (p.x != null || p.y != null || p.w != null || p.h != null)) {
|
||||
pos = { x: p.x ?? 0, y: p.y ?? nextY, w: clampW(p.w), h: clampH(p.h) };
|
||||
} else {
|
||||
pos = { x: 0, y: nextY, w: GRID_COLS, h: DEFAULT_BLOCK_H };
|
||||
}
|
||||
nextY = Math.max(nextY, pos.y + pos.h);
|
||||
return { ...f, id, labels, pos };
|
||||
});
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
this.lastEmitted = this.blocks;
|
||||
this.fieldsChange.emit(this.blocks);
|
||||
}
|
||||
}
|
||||
107
web/src/app/lore/block-layout.helper.spec.ts
Normal file
107
web/src/app/lore/block-layout.helper.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
hasBlockLayout,
|
||||
orderedBlocks,
|
||||
blockGridColumn,
|
||||
blockGridRow,
|
||||
blockKey,
|
||||
} from './block-layout.helper';
|
||||
import { TemplateField } from '../services/template.model';
|
||||
|
||||
function field(name: string, pos?: TemplateField['pos']): TemplateField {
|
||||
return { id: name, name, type: 'TEXT', pos };
|
||||
}
|
||||
|
||||
describe('blockKey', () => {
|
||||
it('utilise l id du bloc quand il est present', () => {
|
||||
expect(blockKey({ id: 'blk-1', name: 'Ambiance', type: 'TEXT' })).toBe('blk-1');
|
||||
});
|
||||
|
||||
it('retombe sur le nom quand l id est absent ou vide (templates legacy)', () => {
|
||||
expect(blockKey({ name: 'Ambiance', type: 'TEXT' })).toBe('Ambiance');
|
||||
expect(blockKey({ id: ' ', name: 'Ambiance', type: 'TEXT' })).toBe('Ambiance');
|
||||
});
|
||||
|
||||
it('reste stable quand le nom change mais pas l id (renommage)', () => {
|
||||
expect(blockKey({ id: 'blk-1', name: 'Nouveau nom', type: 'TEXT' })).toBe('blk-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasBlockLayout', () => {
|
||||
it('false quand aucun bloc n a de position (templates legacy)', () => {
|
||||
expect(hasBlockLayout([field('A'), field('B')])).toBe(false);
|
||||
});
|
||||
|
||||
it('false pour une liste vide / nullish', () => {
|
||||
expect(hasBlockLayout([])).toBe(false);
|
||||
expect(hasBlockLayout(null)).toBe(false);
|
||||
expect(hasBlockLayout(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('true des qu un bloc porte x, y ou w', () => {
|
||||
expect(hasBlockLayout([field('A'), field('B', { x: 0 })])).toBe(true);
|
||||
expect(hasBlockLayout([field('A', { w: 6 })])).toBe(true);
|
||||
expect(hasBlockLayout([field('A', { y: 2 })])).toBe(true);
|
||||
});
|
||||
|
||||
it('false quand pos est present mais entierement nul', () => {
|
||||
expect(hasBlockLayout([field('A', {})])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('orderedBlocks', () => {
|
||||
it('conserve l ordre du tableau (et les references) sans mise en page', () => {
|
||||
const a = field('A');
|
||||
const b = field('B');
|
||||
const out = orderedBlocks([a, b]);
|
||||
expect(out).toEqual([a, b]);
|
||||
expect(out[0]).toBe(a); // meme reference -> track field stable
|
||||
});
|
||||
|
||||
it('trie par (ligne, colonne) quand une mise en page est presente', () => {
|
||||
const topRight = field('TR', { x: 6, y: 0, w: 6 });
|
||||
const topLeft = field('TL', { x: 0, y: 0, w: 6 });
|
||||
const bottom = field('B', { x: 0, y: 1, w: 12 });
|
||||
const sorted = orderedBlocks([bottom, topRight, topLeft]);
|
||||
expect(sorted.map(f => f.name)).toEqual(['TL', 'TR', 'B']);
|
||||
});
|
||||
|
||||
it('ne mute pas le tableau source', () => {
|
||||
const src = [field('B', { x: 6, y: 0 }), field('A', { x: 0, y: 0 })];
|
||||
const copy = [...src];
|
||||
orderedBlocks(src);
|
||||
expect(src).toEqual(copy);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockGridColumn', () => {
|
||||
it('null sans colonne definie', () => {
|
||||
expect(blockGridColumn(field('A'))).toBeNull();
|
||||
expect(blockGridColumn(field('A', { y: 3 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('convertit x (0-based) en colonne 1-based avec span w', () => {
|
||||
expect(blockGridColumn(field('A', { x: 0, w: 6 }))).toBe('1 / span 6');
|
||||
expect(blockGridColumn(field('A', { x: 6, w: 6 }))).toBe('7 / span 6');
|
||||
});
|
||||
|
||||
it('pleine largeur (span 12) quand w absent', () => {
|
||||
expect(blockGridColumn(field('A', { x: 0 }))).toBe('1 / span 12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('blockGridRow', () => {
|
||||
it('null sans ligne definie', () => {
|
||||
expect(blockGridRow(field('A'))).toBeNull();
|
||||
expect(blockGridRow(field('A', { x: 0, w: 6 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('place sur la ligne y+1 avec la hauteur par defaut quand h absent', () => {
|
||||
expect(blockGridRow(field('A', { y: 0 }))).toBe('1 / span 4');
|
||||
expect(blockGridRow(field('A', { y: 2 }))).toBe('3 / span 4');
|
||||
});
|
||||
|
||||
it('place avec span h quand h present', () => {
|
||||
expect(blockGridRow(field('A', { y: 1, h: 6 }))).toBe('2 / span 6');
|
||||
});
|
||||
});
|
||||
84
web/src/app/lore/block-layout.helper.ts
Normal file
84
web/src/app/lore/block-layout.helper.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { TemplateField } from '../services/template.model';
|
||||
|
||||
/**
|
||||
* Helpers de mise en page "blocs" des pages de lore (grille 12 colonnes libre,
|
||||
* inspiree de l'app "Lore" d'Amsel). Partages par page-view (apercu) et
|
||||
* page-edit (saisie) pour rendre une page selon le placement defini sur son
|
||||
* template.
|
||||
*
|
||||
* Modele : grille 2D LIBRE. Chaque bloc occupe un rectangle explicite de la
|
||||
* grille via {@link TemplateField.pos} {x, y, w, h} (en unites de grille) :
|
||||
* on le place ou on veut (x ET y) et on le dimensionne en largeur ET hauteur.
|
||||
* Les lignes ont une hauteur fixe ({@link GRID_ROW_HEIGHT}px), donc `h` a un
|
||||
* sens visuel.
|
||||
*
|
||||
* Retrocompatibilite : un template dont AUCUN bloc ne porte de position est
|
||||
* rendu comme avant — une seule colonne, blocs empiles dans l'ordre du tableau.
|
||||
* La grille ne s'active que lorsqu'au moins un bloc a ete place via le builder.
|
||||
*/
|
||||
|
||||
/** Nombre de colonnes de la grille. */
|
||||
export const GRID_COLS = 12;
|
||||
/** Hauteur d'une unite de ligne (px). Partagee builder + rendu pour coherence. */
|
||||
export const GRID_ROW_HEIGHT = 32;
|
||||
/** Hauteur par defaut d'un nouveau bloc, en unites de ligne. */
|
||||
export const DEFAULT_BLOCK_H = 4;
|
||||
|
||||
/**
|
||||
* Clé STABLE d'ancrage des valeurs de Page : l'id du bloc, avec repli sur le
|
||||
* nom (les templates anterieurs ont id == name, donc transparent). Permet de
|
||||
* renommer un bloc sans orpheliner ses valeurs (qui restent rangees sous l'id).
|
||||
*/
|
||||
export function blockKey(field: TemplateField): string {
|
||||
return field.id && field.id.trim() ? field.id : field.name;
|
||||
}
|
||||
|
||||
/** True si au moins un bloc porte une position de grille exploitable. */
|
||||
export function hasBlockLayout(fields: TemplateField[] | null | undefined): boolean {
|
||||
return (fields ?? []).some(f => {
|
||||
const p = f.pos;
|
||||
return !!p && (p.x != null || p.y != null || p.w != null || p.h != null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les blocs ordonnes pour le rendu : tries par (ligne, colonne) quand
|
||||
* une mise en page est presente, sinon l'ordre d'origine du tableau (rendu
|
||||
* historique empile). Ne mute jamais l'entree (copie avant tri), et conserve
|
||||
* les memes references d'objets (pour un `track field` stable cote template).
|
||||
*/
|
||||
export function orderedBlocks(fields: TemplateField[] | null | undefined): TemplateField[] {
|
||||
const list = fields ?? [];
|
||||
if (!hasBlockLayout(list)) return list;
|
||||
return [...list].sort((a, b) => {
|
||||
const ay = a.pos?.y ?? 0;
|
||||
const by = b.pos?.y ?? 0;
|
||||
if (ay !== by) return ay - by;
|
||||
return (a.pos?.x ?? 0) - (b.pos?.x ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur CSS `grid-column` d'un bloc (colonne de depart x+1, sur w colonnes).
|
||||
* Null si aucune coordonnee horizontale -> repli empile. A binder via
|
||||
* `[style.grid-column]`, inerte hors d'un conteneur grid.
|
||||
*/
|
||||
export function blockGridColumn(field: TemplateField): string | null {
|
||||
const p = field.pos;
|
||||
if (!p || (p.x == null && p.w == null)) return null;
|
||||
const x = (p.x ?? 0) + 1;
|
||||
const w = p.w ?? GRID_COLS;
|
||||
return `${x} / span ${w}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valeur CSS `grid-row` d'un bloc (ligne de depart y+1, sur h lignes).
|
||||
* Null si aucune coordonnee verticale -> repli empile (hauteur auto).
|
||||
*/
|
||||
export function blockGridRow(field: TemplateField): string | null {
|
||||
const p = field.pos;
|
||||
if (!p || (p.y == null && p.h == null)) return null;
|
||||
const y = (p.y ?? 0) + 1;
|
||||
const h = p.h ?? DEFAULT_BLOCK_H;
|
||||
return `${y} / span ${h}`;
|
||||
}
|
||||
@@ -43,17 +43,21 @@
|
||||
</select>
|
||||
<p class="hint">{{ 'pageEdit.folderHint' | translate }}</p>
|
||||
</div>
|
||||
<!-- Champs dynamiques du template -------------------------------- -->
|
||||
<!-- Champs dynamiques du template. Rendus en grille 12 colonnes quand le
|
||||
template porte une mise en page (hasLayout) ; sinon empilés (rendu
|
||||
historique). Les blocs suivent l'ordre (ligne, colonne) du template. -->
|
||||
@if (template?.fields?.length) {
|
||||
<h2 class="section-title">{{ 'pageEdit.fields' | translate }}</h2>
|
||||
@for (field of template!.fields; track field) {
|
||||
<div class="edit-fields" [class.edit-fields--grid]="hasLayout">
|
||||
@for (field of orderedFields; track field) {
|
||||
<!-- Champ TEXT : textarea editable -->
|
||||
@if (field.type === 'TEXT') {
|
||||
<div class="field">
|
||||
<div class="field"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||||
<label>{{ field.name }}</label>
|
||||
<textarea
|
||||
[(ngModel)]="values[field.name]"
|
||||
[name]="'value_' + field.name"
|
||||
[(ngModel)]="values[keyOf(field)]"
|
||||
[name]="'value_' + keyOf(field)"
|
||||
rows="4"
|
||||
[placeholder]="'pageEdit.valuePlaceholder' | translate:{ field: field.name }">
|
||||
</textarea>
|
||||
@@ -61,19 +65,22 @@
|
||||
}
|
||||
<!-- Champ IMAGE : galerie editable. -->
|
||||
@if (field.type === 'IMAGE') {
|
||||
<div class="field">
|
||||
<div class="field"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||||
<label>{{ field.name }}</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="imageValues[field.name] || []"
|
||||
<app-image-block
|
||||
[editable]="true"
|
||||
[layout]="field.layout ?? 'GALLERY'"
|
||||
(imageIdsChange)="imageValues[field.name] = $event">
|
||||
</app-image-gallery>
|
||||
[imageIds]="imageValues[keyOf(field)] || []"
|
||||
(imageIdsChange)="imageValues[keyOf(field)] = $event"
|
||||
[framing]="imageFraming[keyOf(field)] || {}"
|
||||
(framingChange)="imageFraming[keyOf(field)] = $event">
|
||||
</app-image-block>
|
||||
</div>
|
||||
}
|
||||
<!-- Champ KEY_VALUE_LIST : liste libellé/valeur (labels figés par le template). -->
|
||||
@if (field.type === 'KEY_VALUE_LIST') {
|
||||
<div class="field">
|
||||
<div class="field"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||||
<label>{{ field.name }}</label>
|
||||
<div class="kv-grid">
|
||||
@for (lbl of field.labels ?? []; track $index) {
|
||||
@@ -81,8 +88,8 @@
|
||||
<span class="kv-label">{{ lbl }}</span>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="keyValueValues[field.name][lbl]"
|
||||
[name]="'kv_' + field.name + '_' + lbl"
|
||||
[(ngModel)]="keyValueValues[keyOf(field)][lbl]"
|
||||
[name]="'kv_' + keyOf(field) + '_' + lbl"
|
||||
placeholder="—" />
|
||||
</div>
|
||||
}
|
||||
@@ -94,7 +101,8 @@
|
||||
}
|
||||
<!-- Champ TABLE : colonnes figées par le template, lignes libres. -->
|
||||
@if (field.type === 'TABLE') {
|
||||
<div class="field">
|
||||
<div class="field"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null">
|
||||
<label>{{ field.name }}</label>
|
||||
@if ((field.labels ?? []).length) {
|
||||
<div class="table-edit-wrap">
|
||||
@@ -108,20 +116,20 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of tableRows(field.name); track $index; let ri = $index) {
|
||||
@for (row of tableRows(keyOf(field)); track $index; let ri = $index) {
|
||||
<tr>
|
||||
@for (col of field.labels; track $index) {
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="row[col]"
|
||||
[name]="'tbl_' + field.name + '_' + ri + '_' + col"
|
||||
[name]="'tbl_' + keyOf(field) + '_' + ri + '_' + col"
|
||||
[placeholder]="col" />
|
||||
</td>
|
||||
}
|
||||
<td class="table-edit-actions-col">
|
||||
<button type="button" class="btn-row-delete"
|
||||
(click)="removeTableRow(field.name, ri)"
|
||||
(click)="removeTableRow(keyOf(field), ri)"
|
||||
[attr.aria-label]="'pageEdit.deleteRowN' | translate:{ n: (ri + 1) }" [title]="'pageEdit.deleteRow' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="13"></lucide-icon>
|
||||
</button>
|
||||
@@ -130,7 +138,7 @@
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn-row-add" (click)="addTableRow(field.name, field.labels)">
|
||||
<button type="button" class="btn-row-add" (click)="addTableRow(keyOf(field), field.labels)">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon>
|
||||
{{ 'pageEdit.addRow' | translate }}
|
||||
</button>
|
||||
@@ -141,6 +149,7 @@
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Tags --------------------------------------------------------- -->
|
||||
<h2 class="section-title">{{ 'pageEdit.tags' | translate }}</h2>
|
||||
|
||||
@@ -42,6 +42,35 @@
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
// Conteneur des champs dynamiques.
|
||||
// - Sans mise en page (hasLayout=false) : `display: contents` -> les .field
|
||||
// restent des enfants flex directs de .edit-form (même gap, rendu inchangé).
|
||||
// - Avec mise en page : grille 12 colonnes ; chaque .field est placé via
|
||||
// [style.grid-column] / [style.grid-row].
|
||||
.edit-fields {
|
||||
display: contents;
|
||||
|
||||
// En saisie, on honore la LARGEUR (colonnes, pour le côte-à-côte) mais la
|
||||
// hauteur reste NATURELLE (les widgets — textarea, tableau, image — ont besoin
|
||||
// de respirer). Les blocs s'écoulent par ligne dans l'ordre (ligne, colonne).
|
||||
&.edit-fields--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
grid-auto-flow: row;
|
||||
gap: 1.25rem 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
// Repli mobile : la grille se réempile en une colonne, en conservant l'espacement.
|
||||
@media (max-width: 820px) {
|
||||
.edit-fields.edit-fields--grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -12,13 +12,15 @@ import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { Page, ImageFraming } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { hasBlockLayout, orderedBlocks, blockGridColumn, blockKey } from '../block-layout.helper';
|
||||
import { TemplateField } from '../../services/template.model';
|
||||
import { ChipsInputComponent } from '../../shared/chips-input/chips-input.component';
|
||||
import { LoreLinkPickerComponent } from '../../shared/lore-link-picker/lore-link-picker.component';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||
import { AiChatDrawerComponent, ChatPrimaryAction } from '../../shared/ai-chat-drawer/ai-chat-drawer.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { ImageBlockComponent } from '../../shared/image-block/image-block.component';
|
||||
import { Lore } from '../../services/lore.model';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
@@ -37,7 +39,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-edit',
|
||||
imports: [FormsModule, RouterLink, LucideAngularModule, TranslatePipe, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageGalleryComponent],
|
||||
imports: [FormsModule, RouterLink, LucideAngularModule, TranslatePipe, ChipsInputComponent, LoreLinkPickerComponent, BreadcrumbComponent, AiChatDrawerComponent, ImageBlockComponent],
|
||||
templateUrl: './page-edit.component.html',
|
||||
styleUrls: ['./page-edit.component.scss']
|
||||
})
|
||||
@@ -55,6 +57,16 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
/** Toutes les pages du lore — nécessaire au lore-link-picker pour l'autocomplete. */
|
||||
allPages: Page[] = [];
|
||||
|
||||
/** Blocs du template ordonnes pour le rendu (tries par grille si placee). */
|
||||
orderedFields: TemplateField[] = [];
|
||||
/** True si le template porte une mise en page grille -> saisie en grille 12 col. */
|
||||
hasLayout = false;
|
||||
|
||||
/** Placement horizontal d'un bloc (colonnes) ; hauteur naturelle en édition. */
|
||||
readonly gridColumn = blockGridColumn;
|
||||
/** Clé stable d'ancrage des valeurs (id, repli sur le nom). */
|
||||
readonly keyOf = blockKey;
|
||||
|
||||
/** Modèle du formulaire (bindé via ngModel). */
|
||||
title = '';
|
||||
nodeId = '';
|
||||
@@ -66,6 +78,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
* la liste ordonnee des IDs d'images uploadees.
|
||||
*/
|
||||
imageValues: Record<string, string[]> = {};
|
||||
/** Cadrage (pan/zoom) des images : fieldKey → imageId → {x, y, scale}. */
|
||||
imageFraming: Record<string, Record<string, ImageFraming>> = {};
|
||||
/** Valeurs des champs KEY_VALUE_LIST (liste clé/valeur) : fieldName → (label → valeur). */
|
||||
keyValueValues: Record<string, Record<string, string>> = {};
|
||||
/** Valeurs des champs TABLE : fieldName → lignes (colonne → cellule). */
|
||||
@@ -171,6 +185,8 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
private hydrate(page: Page, templates: Template[]): void {
|
||||
this.page = page;
|
||||
this.template = templates.find(t => t.id === page.templateId) ?? null;
|
||||
this.orderedFields = orderedBlocks(this.template?.fields);
|
||||
this.hasLayout = hasBlockLayout(this.template?.fields);
|
||||
this.title = page.title;
|
||||
this.nodeId = page.nodeId;
|
||||
this.notes = page.notes ?? '';
|
||||
@@ -180,25 +196,32 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
// structure `imageValues: Map<String, List<String>>` a l'etape 5).
|
||||
const base: Record<string, string> = {};
|
||||
const imageBase: Record<string, string[]> = {};
|
||||
const framingBase: Record<string, Record<string, ImageFraming>> = {};
|
||||
const kvBase: Record<string, Record<string, string>> = {};
|
||||
const tableBase: Record<string, Array<Record<string, string>>> = {};
|
||||
// Les valeurs sont rangées par clé STABLE (id) ; on relit d'abord par clé,
|
||||
// puis par nom (pages dont les valeurs étaient encore rangées par nom — elles
|
||||
// sont ainsi migrées vers la clé id à la prochaine sauvegarde).
|
||||
for (const f of this.template?.fields ?? []) {
|
||||
const key = blockKey(f);
|
||||
if (f.type === 'TEXT') {
|
||||
base[f.name] = page.values?.[f.name] ?? '';
|
||||
base[key] = page.values?.[key] ?? page.values?.[f.name] ?? '';
|
||||
} else if (f.type === 'IMAGE') {
|
||||
// Initialise la galerie d'images pour ce champ (vide si jamais rempli).
|
||||
imageBase[f.name] = [...(page.imageValues?.[f.name] ?? [])];
|
||||
imageBase[key] = [...(page.imageValues?.[key] ?? page.imageValues?.[f.name] ?? [])];
|
||||
framingBase[key] = { ...(page.imageFraming?.[key] ?? page.imageFraming?.[f.name] ?? {}) };
|
||||
} else if (f.type === 'KEY_VALUE_LIST') {
|
||||
// Toujours initialiser l'objet interne : le ngModel du formulaire
|
||||
// bind directement keyValueValues[field.name][label].
|
||||
kvBase[f.name] = { ...(page.keyValueValues?.[f.name] ?? {}) };
|
||||
// bind directement keyValueValues[keyOf(field)][label].
|
||||
kvBase[key] = { ...(page.keyValueValues?.[key] ?? page.keyValueValues?.[f.name] ?? {}) };
|
||||
} else if (f.type === 'TABLE') {
|
||||
// Copie profonde des lignes : chaque ligne est éditée par ngModel.
|
||||
tableBase[f.name] = (page.tableValues?.[f.name] ?? []).map(row => ({ ...row }));
|
||||
tableBase[key] = (page.tableValues?.[key] ?? page.tableValues?.[f.name] ?? []).map(row => ({ ...row }));
|
||||
}
|
||||
}
|
||||
this.values = base;
|
||||
this.imageValues = imageBase;
|
||||
this.imageFraming = framingBase;
|
||||
this.keyValueValues = kvBase;
|
||||
this.tableValues = tableBase;
|
||||
this.tags = [...(page.tags ?? [])];
|
||||
@@ -215,6 +238,7 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
notes: this.notes,
|
||||
values: this.values,
|
||||
imageValues: this.imageValues,
|
||||
imageFraming: this.imageFraming,
|
||||
keyValueValues: this.keyValueValues,
|
||||
tableValues: this.tableValues,
|
||||
tags: this.tags,
|
||||
@@ -291,11 +315,13 @@ export class PageEditComponent implements OnInit, OnDestroy {
|
||||
*/
|
||||
private mergeSuggestions(suggestions: Record<string, string>): void {
|
||||
// L'IA ne genere que des valeurs texte : on ignore les champs IMAGE.
|
||||
// L'IA renvoie ses suggestions par NOM de champ (le prompt liste les noms) ;
|
||||
// on les range sous la clé STABLE (id) attendue par le formulaire.
|
||||
for (const field of this.template?.fields ?? []) {
|
||||
if (field.type !== 'TEXT') continue;
|
||||
const suggestion = suggestions[field.name];
|
||||
if (suggestion && suggestion.trim()) {
|
||||
this.values[field.name] = suggestion;
|
||||
this.values[blockKey(field)] = suggestion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,38 +17,47 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Champs dynamiques du template (seuls les champs TEXT sont rendus ici ;
|
||||
le support complet des champs IMAGE arrive a l'etape 5). -->
|
||||
<!-- Champs dynamiques du template. Rendus en grille 12 colonnes quand le
|
||||
template porte une mise en page (hasLayout) ; sinon empiles (rendu
|
||||
historique). Les blocs sont ordonnes par (ligne, colonne). -->
|
||||
@if (template?.fields?.length) {
|
||||
@for (field of template!.fields; track field) {
|
||||
<div class="view-fields" [class.view-fields--grid]="hasLayout">
|
||||
@for (field of orderedFields; track field) {
|
||||
@if (field.type === 'TEXT') {
|
||||
<section class="view-section">
|
||||
<section class="view-section"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null"
|
||||
[style.grid-row]="hasLayout ? gridRow(field) : null">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
@if (valueOf(field.name)) {
|
||||
<p class="view-section-body">{{ valueOf(field.name) }}</p>
|
||||
@if (valueOf(field)) {
|
||||
<p class="view-section-body">{{ valueOf(field) }}</p>
|
||||
} @else {
|
||||
<p class="view-section-empty">{{ 'pageView.notFilled' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@if (field.type === 'IMAGE') {
|
||||
<section class="view-section">
|
||||
<section class="view-section"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null"
|
||||
[style.grid-row]="hasLayout ? gridRow(field) : null">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
<app-image-gallery
|
||||
[imageIds]="imageIdsOf(field.name)"
|
||||
[layout]="field.layout ?? 'GALLERY'">
|
||||
</app-image-gallery>
|
||||
<app-image-block
|
||||
[imageIds]="imageIdsOf(field)"
|
||||
[framing]="imageFramingOf(field)"
|
||||
[fill]="hasLayout">
|
||||
</app-image-block>
|
||||
</section>
|
||||
}
|
||||
@if (field.type === 'KEY_VALUE_LIST') {
|
||||
<section class="view-section">
|
||||
<section class="view-section"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null"
|
||||
[style.grid-row]="hasLayout ? gridRow(field) : null">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
@if (kvHasContent(field.name, field.labels)) {
|
||||
@if (kvHasContent(field)) {
|
||||
<div class="view-kv-grid">
|
||||
@for (lbl of field.labels ?? []; track $index) {
|
||||
<div class="view-kv-cell">
|
||||
<span class="view-kv-label">{{ lbl }}</span>
|
||||
<span class="view-kv-value">{{ kvValueOf(field.name, lbl) || '—' }}</span>
|
||||
<span class="view-kv-value">{{ kvValueOf(field, lbl) || '—' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -58,9 +67,11 @@
|
||||
</section>
|
||||
}
|
||||
@if (field.type === 'TABLE') {
|
||||
<section class="view-section">
|
||||
<section class="view-section"
|
||||
[style.grid-column]="hasLayout ? gridColumn(field) : null"
|
||||
[style.grid-row]="hasLayout ? gridRow(field) : null">
|
||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||
@if (tableRowsOf(field.name).length) {
|
||||
@if (tableRowsOf(field).length) {
|
||||
<table class="view-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -70,7 +81,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of tableRowsOf(field.name); track $index) {
|
||||
@for (row of tableRowsOf(field); track $index) {
|
||||
<tr>
|
||||
@for (col of field.labels ?? []; track $index) {
|
||||
<td>{{ row[col] || '—' }}</td>
|
||||
@@ -85,6 +96,7 @@
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Tags -->
|
||||
@if ((page.tags?.length ?? 0) > 0) {
|
||||
|
||||
@@ -1,4 +1,52 @@
|
||||
// Styles spécifiques à page-view.
|
||||
// Le gros du style "fiche de jeu" vient du partial global `styles/_view.scss`.
|
||||
// Aucun override nécessaire pour l'instant — ce fichier existe pour rester
|
||||
// cohérent avec la structure des autres composants (ts/html/scss).
|
||||
|
||||
// Conteneur des champs dynamiques.
|
||||
// - Sans mise en page (hasLayout=false) : `display: contents` -> les sections
|
||||
// se comportent EXACTEMENT comme avant (enfants visuels directs de .view-page,
|
||||
// empilées, bordures de séparation gérées par _view.scss). Aucun changement.
|
||||
// - Avec mise en page : grille 12 colonnes ; chaque section est placée via
|
||||
// [style.grid-column] / [style.grid-row]. On neutralise alors les bordures
|
||||
// top (un séparateur horizontal n'a pas de sens entre blocs côte-à-côte).
|
||||
.view-fields {
|
||||
display: contents;
|
||||
|
||||
// Grille 2D fidèle : largeur (colonnes) ET hauteur (lignes de 32px = GRID_ROW_HEIGHT)
|
||||
// honorées. Chaque bloc remplit sa cellule ; le contenu trop grand défile.
|
||||
&.view-fields--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
grid-auto-rows: 32px; // = GRID_ROW_HEIGHT (block-layout.helper.ts)
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
|
||||
.view-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
|
||||
// L'image (et autres contenus extensibles) remplit la hauteur restante.
|
||||
app-image-block { flex: 1 1 auto; min-height: 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Depuis que les champs vivent dans `.view-fields`, la première section "meta"
|
||||
// (tags / pages liées / notes privées) devient `:first-of-type` parmi les
|
||||
// enfants directs de `.view-page` et perdrait sa bordure de séparation héritée
|
||||
// de _view.scss. On la lui restitue (scopé à page-view, sans impact sur
|
||||
// arc/chapter/scene-view qui n'ont pas ce wrapper).
|
||||
.view-page > .view-section:first-of-type {
|
||||
border-top: 1px solid #1e1e3a;
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
// Repli mobile : la grille se réempile en une colonne.
|
||||
@media (max-width: 820px) {
|
||||
.view-fields.view-fields--grid {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { Lore, LoreNode } from '../../services/lore.model';
|
||||
import { Template } from '../../services/template.model';
|
||||
import { Page } from '../../services/page.model';
|
||||
import { Page, ImageFraming } from '../../services/page.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { hasBlockLayout, orderedBlocks, blockGridColumn, blockGridRow, blockKey } from '../block-layout.helper';
|
||||
import { TemplateField } from '../../services/template.model';
|
||||
import { BreadcrumbComponent, BreadcrumbItem } from '../../shared/breadcrumb/breadcrumb.component';
|
||||
import { ImageGalleryComponent } from '../../shared/image-gallery/image-gallery.component';
|
||||
import { ImageBlockComponent } from '../../shared/image-block/image-block.component';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
@@ -29,7 +31,7 @@ import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-page-view',
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe, BreadcrumbComponent, ImageGalleryComponent],
|
||||
imports: [RouterModule, LucideAngularModule, TranslatePipe, BreadcrumbComponent, ImageBlockComponent],
|
||||
templateUrl: './page-view.component.html',
|
||||
styleUrls: ['./page-view.component.scss']
|
||||
})
|
||||
@@ -45,6 +47,15 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
nodes: LoreNode[] = [];
|
||||
allPages: Page[] = [];
|
||||
|
||||
/** Blocs du template ordonnes pour le rendu (tries par grille si placee). */
|
||||
orderedFields: TemplateField[] = [];
|
||||
/** True si le template porte une mise en page grille -> rendu en grille 12 col. */
|
||||
hasLayout = false;
|
||||
|
||||
/** Placement CSS d'un bloc dans la grille (binde via [style.grid-column/row]). */
|
||||
readonly gridColumn = blockGridColumn;
|
||||
readonly gridRow = blockGridRow;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
@@ -79,6 +90,8 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
this.nodes = sidebar.nodes;
|
||||
this.allPages = sidebar.pages;
|
||||
this.template = sidebar.templates.find(t => t.id === page.templateId) ?? null;
|
||||
this.orderedFields = orderedBlocks(this.template?.fields);
|
||||
this.hasLayout = hasBlockLayout(this.template?.fields);
|
||||
this.page = page;
|
||||
this.layoutService.show(buildLoreSidebarConfig(sidebar));
|
||||
this.pageTitleService.set(page.title);
|
||||
@@ -106,29 +119,43 @@ export class PageViewComponent implements OnInit, OnDestroy {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Les valeurs sont ancrées sur la clé STABLE du bloc ({@link blockKey} = id,
|
||||
// repli sur le nom). Le repli supplémentaire sur `field.name` couvre les pages
|
||||
// dont les valeurs étaient encore rangées par nom (migrées au prochain save).
|
||||
|
||||
/** Récupère la valeur d'un champ dynamique TEXT du template. */
|
||||
valueOf(fieldName: string): string {
|
||||
return this.page?.values?.[fieldName] ?? '';
|
||||
valueOf(field: TemplateField): string {
|
||||
const v = this.page?.values;
|
||||
return v?.[blockKey(field)] ?? v?.[field.name] ?? '';
|
||||
}
|
||||
|
||||
/** IDs d'images pour un champ IMAGE (liste vide si aucune). */
|
||||
imageIdsOf(fieldName: string): string[] {
|
||||
return this.page?.imageValues?.[fieldName] ?? [];
|
||||
imageIdsOf(field: TemplateField): string[] {
|
||||
const v = this.page?.imageValues;
|
||||
return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
|
||||
}
|
||||
|
||||
/** Cadrage (pan/zoom) des images d'un champ IMAGE : imageId → {x,y,scale}. */
|
||||
imageFramingOf(field: TemplateField): Record<string, ImageFraming> {
|
||||
const v = this.page?.imageFraming;
|
||||
return v?.[blockKey(field)] ?? v?.[field.name] ?? {};
|
||||
}
|
||||
|
||||
/** Valeur d'un libellé d'un champ KEY_VALUE_LIST (tableau). */
|
||||
kvValueOf(fieldName: string, label: string): string {
|
||||
return this.page?.keyValueValues?.[fieldName]?.[label] ?? '';
|
||||
kvValueOf(field: TemplateField, label: string): string {
|
||||
const v = this.page?.keyValueValues;
|
||||
return v?.[blockKey(field)]?.[label] ?? v?.[field.name]?.[label] ?? '';
|
||||
}
|
||||
|
||||
/** True si au moins une valeur de la liste clé/valeur est renseignée. */
|
||||
kvHasContent(fieldName: string, labels: string[] | null | undefined): boolean {
|
||||
return (labels ?? []).some(lbl => this.kvValueOf(fieldName, lbl).trim() !== '');
|
||||
kvHasContent(field: TemplateField): boolean {
|
||||
return (field.labels ?? []).some(lbl => this.kvValueOf(field, lbl).trim() !== '');
|
||||
}
|
||||
|
||||
/** Lignes d'un champ TABLE (liste vide si jamais rempli). */
|
||||
tableRowsOf(fieldName: string): Array<Record<string, string>> {
|
||||
return this.page?.tableValues?.[fieldName] ?? [];
|
||||
tableRowsOf(field: TemplateField): Array<Record<string, string>> {
|
||||
const v = this.page?.tableValues;
|
||||
return v?.[blockKey(field)] ?? v?.[field.name] ?? [];
|
||||
}
|
||||
|
||||
/** Helper — résout l'ID d'une page liée en son titre (pour affichage dans les chips). */
|
||||
|
||||
@@ -49,112 +49,10 @@
|
||||
|
||||
<label class="section-label">{{ 'templateCreate.fieldsLabel' | translate }}</label>
|
||||
|
||||
<ul class="fields-list">
|
||||
@for (f of fields; track f; let i = $index; let first = $first; let last = $last) {
|
||||
<li class="field-row">
|
||||
<div class="reorder-stack">
|
||||
<button type="button" class="btn-icon btn-reorder"
|
||||
(click)="moveField(i, -1)"
|
||||
[disabled]="first"
|
||||
[attr.aria-label]="'templateCreate.moveUp' | translate" [title]="'templateCreate.moveUp' | translate">
|
||||
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-icon btn-reorder"
|
||||
(click)="moveField(i, 1)"
|
||||
[disabled]="last"
|
||||
[attr.aria-label]="'templateCreate.moveDown' | translate" [title]="'templateCreate.moveDown' | translate">
|
||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<span class="field-chip"
|
||||
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||
[class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
|
||||
[class.field-chip-table]="f.type === 'TABLE'">
|
||||
<lucide-icon [img]="iconFor(f.type)" [size]="12"></lucide-icon>
|
||||
{{ f.name }}
|
||||
</span>
|
||||
<select
|
||||
class="type-select"
|
||||
[ngModel]="f.type"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldType(i, $event)"
|
||||
[title]="'templateCreate.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateCreate.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateCreate.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateCreate.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateCreate.typeTable' | translate }}</option>
|
||||
</select>
|
||||
@if (f.type === 'IMAGE') {
|
||||
<select
|
||||
class="layout-select"
|
||||
[ngModel]="f.layout ?? 'GALLERY'"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldLayout(i, $event)"
|
||||
[title]="'templateCreate.layoutTitle' | translate">
|
||||
<option value="GALLERY">{{ 'templateCreate.layoutGallery' | translate }}</option>
|
||||
<option value="HERO">{{ 'templateCreate.layoutHero' | translate }}</option>
|
||||
<option value="MASONRY">{{ 'templateCreate.layoutMasonry' | translate }}</option>
|
||||
<option value="CAROUSEL">{{ 'templateCreate.layoutCarousel' | translate }}</option>
|
||||
</select>
|
||||
}
|
||||
<button type="button" class="btn-icon" (click)="removeField(i)" [attr.aria-label]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
|
||||
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
|
||||
<li class="kv-labels-row">
|
||||
@for (lbl of f.labels ?? []; track $index; let li = $index) {
|
||||
<span class="kv-label-chip">
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="lbl"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="updateLabel(f, li, $event)"
|
||||
[placeholder]="(f.type === 'TABLE' ? 'templateCreate.column' : 'templateCreate.label') | translate"
|
||||
[attr.aria-label]="(f.type === 'TABLE' ? 'templateCreate.columnN' : 'templateCreate.labelN') | translate:{ n: (li + 1) }" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" [attr.aria-label]="'common.remove' | translate">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ (f.type === 'TABLE' ? 'templateCreate.column' : 'templateCreate.label') | translate }}
|
||||
</button>
|
||||
@if (!(f.labels ?? []).length) {
|
||||
<span class="kv-labels-hint">
|
||||
{{ (f.type === 'TABLE' ? 'templateCreate.tableLabelsHint' : 'templateCreate.kvLabelsHint') | translate }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div class="field-row add-row">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[placeholder]="'templateCreate.addFieldPlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<select
|
||||
class="type-select"
|
||||
[(ngModel)]="newFieldType"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[attr.aria-label]="'templateCreate.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateCreate.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateCreate.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateCreate.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateCreate.typeTable' | translate }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add" (click)="addField()" [title]="'templateCreate.addFieldTitle' | translate">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="hint">{{ 'templateCreate.fieldsHelp' | translate }}</p>
|
||||
<app-block-grid-builder
|
||||
[fields]="fields"
|
||||
(fieldsChange)="fields = $event">
|
||||
</app-block-grid-builder>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,18 +20,22 @@
|
||||
}
|
||||
|
||||
.template-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
|
||||
.col-left, .col-right {
|
||||
.col-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -1,67 +1,42 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { FieldType, ImageLayout, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||
import { TemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { BlockGridBuilderComponent } from '../block-grid-builder/block-grid-builder.component';
|
||||
import { popReturnTo } from '../return-stack.helper';
|
||||
|
||||
/**
|
||||
* Écran de création d'un Template (gabarit de Page).
|
||||
* - Champs principaux : nom, description, noeud par défaut.
|
||||
* - Liste dynamique de "champs du template" (ex: "Nom", "Description", "Personnalité").
|
||||
* Le user peut ajouter/retirer n'importe lequel — tous sont égaux.
|
||||
* - Agencement des blocs délégué à {@link BlockGridBuilderComponent} (grille 12 col).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-create',
|
||||
imports: [FormsModule, ReactiveFormsModule, RouterModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [ReactiveFormsModule, RouterModule, TranslatePipe, BlockGridBuilderComponent],
|
||||
templateUrl: './template-create.component.html',
|
||||
styleUrls: ['./template-create.component.scss']
|
||||
})
|
||||
export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Type = Type;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ListOrdered = ListOrdered;
|
||||
readonly TableIcon = TableIcon;
|
||||
readonly X = X;
|
||||
|
||||
/** Icone du chip selon le type du champ. */
|
||||
iconFor(type: FieldType) {
|
||||
switch (type) {
|
||||
case 'IMAGE': return this.ImageIcon;
|
||||
case 'KEY_VALUE_LIST': return this.ListOrdered;
|
||||
case 'TABLE': return this.TableIcon;
|
||||
default: return this.Type;
|
||||
}
|
||||
}
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
nodes: LoreNode[] = [];
|
||||
/**
|
||||
* Champs dynamiques actuellement definis. Chaque champ a un type discriminant
|
||||
* (TEXT ou IMAGE) qui pilote son rendu sur les pages.
|
||||
* Champs dynamiques par défaut d'un nouveau template. Le builder leur affecte
|
||||
* id stable + placement (pos) à l'affichage.
|
||||
*/
|
||||
fields: TemplateField[] = [
|
||||
{ name: 'Nom', type: 'TEXT' },
|
||||
{ name: 'Description', type: 'TEXT' },
|
||||
{ name: 'Illustration', type: 'IMAGE', layout: 'GALLERY' }
|
||||
];
|
||||
/** Valeur courante de l'input d'ajout de champ (non binding direct pour reset facile). */
|
||||
newFieldName = '';
|
||||
/** Type choisi pour le prochain champ a ajouter. */
|
||||
newFieldType: FieldType = 'TEXT';
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
@@ -132,62 +107,6 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
return current ? `template-create,${current}` : 'template-create';
|
||||
}
|
||||
|
||||
addField(): void {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!name) return;
|
||||
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
|
||||
if (this.fields.some(f => f.name === name)) return;
|
||||
this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
|
||||
this.newFieldName = '';
|
||||
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
||||
// plusieurs champs du meme type.
|
||||
}
|
||||
|
||||
removeField(index: number): void {
|
||||
this.fields = this.fields.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
|
||||
moveField(index: number, direction: -1 | 1): void {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= this.fields.length) return;
|
||||
const next = [...this.fields];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
this.fields = next;
|
||||
}
|
||||
|
||||
/** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
|
||||
setFieldType(index: number, type: FieldType): void {
|
||||
this.fields = this.fields.map((f, i) =>
|
||||
i === index ? buildLoreTemplateField(f.name, type, f) : f
|
||||
);
|
||||
}
|
||||
|
||||
/** Met a jour le layout d'un champ IMAGE. */
|
||||
setFieldLayout(index: number, layout: ImageLayout): void {
|
||||
this.fields = this.fields.map((f, i) =>
|
||||
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
|
||||
// Mutation en place des labels : recreer le tableau de fields a chaque
|
||||
// frappe ferait perdre le focus de l'input en cours d'edition.
|
||||
|
||||
addLabel(field: TemplateField): void {
|
||||
field.labels = [...(field.labels ?? []), ''];
|
||||
}
|
||||
|
||||
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
|
||||
if (!field.labels) return;
|
||||
field.labels[labelIndex] = value;
|
||||
}
|
||||
|
||||
removeLabel(field: TemplateField, labelIndex: number): void {
|
||||
if (!field.labels) return;
|
||||
field.labels = field.labels.filter((_, i) => i !== labelIndex);
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid) return;
|
||||
const raw = this.form.value;
|
||||
|
||||
@@ -35,112 +35,11 @@
|
||||
<!-- Colonne droite --------------------------------------------- -->
|
||||
<div class="col-right">
|
||||
<label class="section-label">{{ 'templateEdit.fieldsLabel' | translate }}</label>
|
||||
<ul class="fields-list">
|
||||
@for (f of fields; track f; let i = $index; let first = $first; let last = $last) {
|
||||
<li class="field-row">
|
||||
<div class="reorder-stack">
|
||||
<button type="button" class="btn-icon-ghost btn-reorder"
|
||||
(click)="moveField(i, -1)"
|
||||
[disabled]="first"
|
||||
[attr.aria-label]="'templateEdit.moveUp' | translate" [title]="'templateEdit.moveUp' | translate">
|
||||
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="btn-icon-ghost btn-reorder"
|
||||
(click)="moveField(i, 1)"
|
||||
[disabled]="last"
|
||||
[attr.aria-label]="'templateEdit.moveDown' | translate" [title]="'templateEdit.moveDown' | translate">
|
||||
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<span class="field-chip"
|
||||
[class.field-chip-image]="f.type === 'IMAGE'"
|
||||
[class.field-chip-kv]="f.type === 'KEY_VALUE_LIST'"
|
||||
[class.field-chip-table]="f.type === 'TABLE'"
|
||||
[class.field-chip-existing]="f.type === 'TEXT' && isExistingField(f)"
|
||||
[class.field-chip-new]="f.type === 'TEXT' && !isExistingField(f)">
|
||||
<lucide-icon [img]="iconFor(f.type)" [size]="12"></lucide-icon>
|
||||
{{ f.name }}
|
||||
</span>
|
||||
<select
|
||||
class="type-select"
|
||||
[ngModel]="f.type"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldType(i, $event)"
|
||||
[title]="'templateEdit.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateEdit.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateEdit.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateEdit.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateEdit.typeTable' | translate }}</option>
|
||||
</select>
|
||||
@if (f.type === 'IMAGE') {
|
||||
<select
|
||||
class="layout-select"
|
||||
[ngModel]="f.layout ?? 'GALLERY'"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="setFieldLayout(i, $event)"
|
||||
[title]="'templateEdit.layoutTitle' | translate">
|
||||
<option value="GALLERY">{{ 'templateEdit.layoutGallery' | translate }}</option>
|
||||
<option value="HERO">{{ 'templateEdit.layoutHero' | translate }}</option>
|
||||
<option value="MASONRY">{{ 'templateEdit.layoutMasonry' | translate }}</option>
|
||||
<option value="CAROUSEL">{{ 'templateEdit.layoutCarousel' | translate }}</option>
|
||||
</select>
|
||||
}
|
||||
<button type="button" class="btn-icon-danger" (click)="removeField(i)" [attr.aria-label]="'common.delete' | translate">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</li>
|
||||
<!-- Libelles (lignes d'une liste clé/valeur, ou colonnes d'un tableau) -->
|
||||
@if (f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE') {
|
||||
<li class="kv-labels-row">
|
||||
@for (lbl of f.labels ?? []; track $index; let li = $index) {
|
||||
<span class="kv-label-chip">
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="lbl"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
(ngModelChange)="updateLabel(f, li, $event)"
|
||||
[placeholder]="(f.type === 'TABLE' ? 'templateEdit.column' : 'templateEdit.label') | translate"
|
||||
[attr.aria-label]="(f.type === 'TABLE' ? 'templateEdit.columnN' : 'templateEdit.labelN') | translate:{ n: (li + 1) }" />
|
||||
<button type="button" class="kv-label-remove" (click)="removeLabel(f, li)" [attr.aria-label]="'common.remove' | translate">
|
||||
<lucide-icon [img]="X" [size]="11"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
<button type="button" class="btn-kv-add-label" (click)="addLabel(f)">
|
||||
<lucide-icon [img]="Plus" [size]="12"></lucide-icon>
|
||||
{{ (f.type === 'TABLE' ? 'templateEdit.column' : 'templateEdit.label') | translate }}
|
||||
</button>
|
||||
@if (!(f.labels ?? []).length) {
|
||||
<span class="kv-labels-hint">
|
||||
{{ (f.type === 'TABLE' ? 'templateEdit.tableLabelsHint' : 'templateEdit.kvLabelsHint') | translate }}
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
<div class="field-row add-row">
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="newFieldName"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[placeholder]="'templateEdit.addFieldPlaceholder' | translate"
|
||||
(keydown.enter)="$event.preventDefault(); addField()" />
|
||||
<select
|
||||
class="type-select"
|
||||
[(ngModel)]="newFieldType"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[attr.aria-label]="'templateEdit.fieldTypeTitle' | translate">
|
||||
<option value="TEXT">{{ 'templateEdit.typeText' | translate }}</option>
|
||||
<option value="IMAGE">{{ 'templateEdit.typeImage' | translate }}</option>
|
||||
<option value="KEY_VALUE_LIST">{{ 'templateEdit.typeKeyValue' | translate }}</option>
|
||||
<option value="TABLE">{{ 'templateEdit.typeTable' | translate }}</option>
|
||||
</select>
|
||||
<button type="button" class="btn-add" (click)="addField()" [title]="'templateEdit.addFieldTitle' | translate">
|
||||
<lucide-icon [img]="Plus" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<p class="hint">{{ 'templateEdit.fieldsHelp' | translate }}</p>
|
||||
<app-block-grid-builder
|
||||
[fields]="fields"
|
||||
(fieldsChange)="fields = $event"
|
||||
[existingFieldNames]="originalFieldNames">
|
||||
</app-block-grid-builder>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -27,15 +27,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.template-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem 2.5rem;
|
||||
.page { max-width: 1100px; }
|
||||
|
||||
.col-left, .col-right {
|
||||
// Empilé sur une colonne : la méta (nom/dossier/description) en haut, puis le
|
||||
// builder de blocs en pleine largeur (la grille 12 colonnes a besoin de place).
|
||||
.template-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
|
||||
.col-left {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
|
||||
.field:last-child { grid-column: 1 / -1; } // description en pleine largeur
|
||||
}
|
||||
.col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { forkJoin, Subject } from 'rxjs';
|
||||
import { switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown, ListOrdered, Table as TableIcon, X } from 'lucide-angular';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { LoreService } from '../../services/lore.service';
|
||||
import { TemplateService } from '../../services/template.service';
|
||||
@@ -12,63 +11,38 @@ import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { FieldType, ImageLayout, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||
import { FieldType, Template, TemplateField, buildLoreTemplateField, cleanFieldLabels } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
import { BlockGridBuilderComponent } from '../block-grid-builder/block-grid-builder.component';
|
||||
import { ConfirmDialogService } from '../../shared/confirm-dialog/confirm-dialog.service';
|
||||
|
||||
/**
|
||||
* Écran d'édition d'un Template existant.
|
||||
* Mêmes champs que la création + bouton Supprimer.
|
||||
* L'agencement des blocs est délégué à {@link BlockGridBuilderComponent}.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-template-edit',
|
||||
imports: [FormsModule, ReactiveFormsModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [ReactiveFormsModule, TranslatePipe, BlockGridBuilderComponent],
|
||||
templateUrl: './template-edit.component.html',
|
||||
styleUrls: ['./template-edit.component.scss']
|
||||
})
|
||||
export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Type = Type;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly ListOrdered = ListOrdered;
|
||||
readonly TableIcon = TableIcon;
|
||||
readonly X = X;
|
||||
|
||||
/** Icone du chip selon le type du champ. */
|
||||
iconFor(type: FieldType) {
|
||||
switch (type) {
|
||||
case 'IMAGE': return this.ImageIcon;
|
||||
case 'KEY_VALUE_LIST': return this.ListOrdered;
|
||||
case 'TABLE': return this.TableIcon;
|
||||
default: return this.Type;
|
||||
}
|
||||
}
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
templateId = '';
|
||||
template: Template | null = null;
|
||||
nodes: LoreNode[] = [];
|
||||
fields: TemplateField[] = [];
|
||||
newFieldName = '';
|
||||
newFieldType: FieldType = 'TEXT';
|
||||
/**
|
||||
* Noms des champs chargés depuis le backend — utilisés pour discriminer
|
||||
* visuellement les champs existants (orange) des champs ajoutés dans cette
|
||||
* session d'édition (vert). Non muté ensuite.
|
||||
* Noms des champs chargés depuis le backend — passés au builder pour
|
||||
* discriminer visuellement les champs existants des champs ajoutés dans
|
||||
* cette session d'édition. Non muté ensuite.
|
||||
*/
|
||||
private originalFieldNames = new Set<string>();
|
||||
originalFieldNames = new Set<string>();
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
/** True si le champ est présent depuis le chargement du template. */
|
||||
isExistingField(field: TemplateField): boolean {
|
||||
return this.originalFieldNames.has(field.name);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
private route: ActivatedRoute,
|
||||
@@ -114,10 +88,11 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
this.template = template;
|
||||
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
|
||||
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
|
||||
// On PRESERVE id et pos : le builder en a besoin pour recharger l'agencement.
|
||||
this.fields = (template.fields ?? []).map(f => {
|
||||
const type: FieldType =
|
||||
f.type === 'IMAGE' || f.type === 'KEY_VALUE_LIST' || f.type === 'TABLE' ? f.type : 'TEXT';
|
||||
return buildLoreTemplateField(f.name, type, f);
|
||||
return { ...buildLoreTemplateField(f.name, type, f), id: f.id, pos: f.pos };
|
||||
});
|
||||
this.originalFieldNames = new Set(this.fields.map(f => f.name));
|
||||
this.form.patchValue({
|
||||
@@ -128,59 +103,6 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
this.pageTitleService.set(template.name);
|
||||
}
|
||||
|
||||
addField(): void {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!name) return;
|
||||
if (this.fields.some(f => f.name === name)) return;
|
||||
this.fields = [...this.fields, buildLoreTemplateField(name, this.newFieldType)];
|
||||
this.newFieldName = '';
|
||||
}
|
||||
|
||||
removeField(index: number): void {
|
||||
this.fields = this.fields.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
/** Deplace un champ d'un cran vers le haut ou le bas. No-op aux bords. */
|
||||
moveField(index: number, direction: -1 | 1): void {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= this.fields.length) return;
|
||||
const next = [...this.fields];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
this.fields = next;
|
||||
}
|
||||
|
||||
/** Change le type d'un champ existant (TEXT / IMAGE / KEY_VALUE_LIST). */
|
||||
setFieldType(index: number, type: FieldType): void {
|
||||
this.fields = this.fields.map((f, i) =>
|
||||
i === index ? buildLoreTemplateField(f.name, type, f) : f
|
||||
);
|
||||
}
|
||||
|
||||
/** Met a jour le layout d'un champ IMAGE. */
|
||||
setFieldLayout(index: number, layout: ImageLayout): void {
|
||||
this.fields = this.fields.map((f, i) =>
|
||||
i === index && f.type === 'IMAGE' ? { ...f, layout } : f
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sous-editeur des libelles (KEY_VALUE_LIST) -------------------------
|
||||
// Mutation en place des labels : recreer le tableau de fields a chaque
|
||||
// frappe ferait perdre le focus de l'input en cours d'edition.
|
||||
|
||||
addLabel(field: TemplateField): void {
|
||||
field.labels = [...(field.labels ?? []), ''];
|
||||
}
|
||||
|
||||
updateLabel(field: TemplateField, labelIndex: number, value: string): void {
|
||||
if (!field.labels) return;
|
||||
field.labels[labelIndex] = value;
|
||||
}
|
||||
|
||||
removeLabel(field: TemplateField, labelIndex: number): void {
|
||||
if (!field.labels) return;
|
||||
field.labels = field.labels.filter((_, i) => i !== labelIndex);
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (this.form.invalid || !this.template) return;
|
||||
const raw = this.form.value;
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
// Interfaces TypeScript pour PageDTO (Backend Java).
|
||||
|
||||
/**
|
||||
* Cadrage (pan/zoom) d'une image dans son bloc IMAGE.
|
||||
* Miroir de com.loremind.domain.lorecontext.ImageFraming.
|
||||
* - x, y : object-position en pourcentage (0..100), 50/50 = centré.
|
||||
* - scale : facteur de zoom (>= 1), 1 = plein cadre (cover).
|
||||
*/
|
||||
export interface ImageFraming {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export interface Page {
|
||||
id?: string;
|
||||
loreId: string;
|
||||
@@ -14,6 +26,11 @@ export interface Page {
|
||||
* uploadees (Shared Kernel images). Structure separee de `values`.
|
||||
*/
|
||||
imageValues?: Record<string, string[]>;
|
||||
/**
|
||||
* Cadrage (pan/zoom) des images : fieldKey → imageId → {x, y, scale}.
|
||||
* Purement présentationnel ; absence = cadrage par défaut (centré, plein cadre).
|
||||
*/
|
||||
imageFraming?: Record<string, Record<string, ImageFraming>>;
|
||||
/**
|
||||
* Pour chaque champ KEY_VALUE_LIST (tableau libelle → valeur, comme sur les
|
||||
* fiches de personnage) : fieldName → (label → valeur).
|
||||
|
||||
@@ -21,11 +21,34 @@ export type FieldType = 'TEXT' | 'IMAGE' | 'NUMBER' | 'KEY_VALUE_LIST' | 'TABLE'
|
||||
*/
|
||||
export type ImageLayout = 'GALLERY' | 'HERO' | 'MASONRY' | 'CAROUSEL' | 'EDITORIAL' | 'MAPS';
|
||||
|
||||
/**
|
||||
* Placement d'un bloc dans la grille 12 colonnes du template.
|
||||
* Miroir de com.loremind.domain.shared.template.BlockPosition.
|
||||
* Tout nullable : absence (ou coordonnees nulles) = auto-flow empile,
|
||||
* exactement le rendu historique en une seule colonne.
|
||||
*/
|
||||
export interface BlockPosition {
|
||||
/** Colonne de depart (0..11). */
|
||||
x?: number | null;
|
||||
/** Ligne de depart (0..n). */
|
||||
y?: number | null;
|
||||
/** Largeur en colonnes (1..12). */
|
||||
w?: number | null;
|
||||
/** Hauteur en lignes (1..n). */
|
||||
h?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Champ d'un Template : nom + type discriminant.
|
||||
* Miroir de TemplateFieldDTO (backend).
|
||||
*/
|
||||
export interface TemplateField {
|
||||
/**
|
||||
* Identifiant STABLE du bloc : cle d'ancrage des valeurs de Page, survit aux
|
||||
* renommages. Retro-rempli avec le nom cote backend pour les templates
|
||||
* anterieurs (donc id == name au depart).
|
||||
*/
|
||||
id?: string;
|
||||
name: string;
|
||||
type: FieldType;
|
||||
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
||||
@@ -37,6 +60,8 @@ export interface TemplateField {
|
||||
labels?: string[] | null;
|
||||
/** Chemin Foundry du champ (system.<path>) quand le template est calqué Foundry. */
|
||||
foundryPath?: string | null;
|
||||
/** Placement dans la grille 12 colonnes. Null = auto-flow empile (rendu historique). */
|
||||
pos?: BlockPosition | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
66
web/src/app/shared/image-block/image-block.component.html
Normal file
66
web/src/app/shared/image-block/image-block.component.html
Normal file
@@ -0,0 +1,66 @@
|
||||
@if (currentId) {
|
||||
<div class="image-block" [class.fill]="fill">
|
||||
<div class="frame"
|
||||
[class.grabbable]="editable"
|
||||
(pointerdown)="startPan($event)"
|
||||
(wheel)="onWheel($event)">
|
||||
<img class="img"
|
||||
[src]="urlFor(currentId)"
|
||||
alt=""
|
||||
draggable="false"
|
||||
[style.object-position]="objectPosition(currentId)"
|
||||
[style.transform-origin]="objectPosition(currentId)"
|
||||
[style.transform]="zoomTransform(currentId)" />
|
||||
</div>
|
||||
|
||||
@if (hasMany) {
|
||||
<button type="button" class="nav prev" (click)="prev()" [attr.aria-label]="'imageBlock.prev' | translate">
|
||||
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
|
||||
</button>
|
||||
<button type="button" class="nav next" (click)="next()" [attr.aria-label]="'imageBlock.next' | translate">
|
||||
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
|
||||
</button>
|
||||
<div class="dots">
|
||||
@for (id of imageIds; track id; let i = $index) {
|
||||
<button type="button" class="dot" [class.active]="i === current"
|
||||
(click)="goTo(i)" [attr.aria-label]="'imageBlock.goTo' | translate:{ n: i + 1 }"></button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (editable) {
|
||||
<div class="edit-bar">
|
||||
<div class="zoom">
|
||||
<button type="button" (click)="setZoom(scaleOf(currentId) - 0.2)"
|
||||
[attr.aria-label]="'imageBlock.zoomOut' | translate" [title]="'imageBlock.zoomOut' | translate">
|
||||
<lucide-icon [img]="ZoomOut" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
<input type="range" min="0.4" max="4" step="0.05"
|
||||
[ngModel]="scaleOf(currentId)" (ngModelChange)="setZoom($event)"
|
||||
[attr.aria-label]="'imageBlock.zoom' | translate" />
|
||||
<button type="button" (click)="setZoom(scaleOf(currentId) + 0.2)"
|
||||
[attr.aria-label]="'imageBlock.zoomIn' | translate" [title]="'imageBlock.zoomIn' | translate">
|
||||
<lucide-icon [img]="ZoomIn" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
<span class="move-hint"><lucide-icon [img]="Move" [size]="13"></lucide-icon> {{ 'imageBlock.dragHint' | translate }}</span>
|
||||
<div class="edit-actions">
|
||||
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||
<button type="button" class="btn-remove" (click)="removeCurrent()"
|
||||
[attr.aria-label]="'imageBlock.remove' | translate" [title]="'imageBlock.remove' | translate">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (editable) {
|
||||
<div class="image-empty" [class.fill]="fill">
|
||||
<lucide-icon [img]="ImageIcon" [size]="28"></lucide-icon>
|
||||
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="image-empty view" [class.fill]="fill">
|
||||
<lucide-icon [img]="ImageIcon" [size]="28"></lucide-icon>
|
||||
</div>
|
||||
}
|
||||
168
web/src/app/shared/image-block/image-block.component.scss
Normal file
168
web/src/app/shared/image-block/image-block.component.scss
Normal file
@@ -0,0 +1,168 @@
|
||||
.image-block {
|
||||
position: relative;
|
||||
|
||||
// Mode "remplir le bloc" (page-view sur grille à hauteur fixe).
|
||||
&.fill {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.frame { flex: 1 1 auto; aspect-ratio: auto; min-height: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: #000; // bordures noires quand l'image entière ne remplit pas le bloc
|
||||
touch-action: none; // évite le scroll pendant le drag tactile
|
||||
|
||||
&.grabbable { cursor: grab; }
|
||||
&.grabbable:active { cursor: grabbing; }
|
||||
}
|
||||
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// contain : l'image entière est visible (letterbox/pillarbox) ; le zoom (scale)
|
||||
// permet ensuite de l'agrandir jusqu'à remplir le bloc, voire rogner.
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
// Flèches de navigation (plusieurs images)
|
||||
.nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: rgba(0, 0, 0, 0.7); }
|
||||
&.prev { left: 8px; }
|
||||
&.next { right: 8px; }
|
||||
}
|
||||
|
||||
.dots {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 999px;
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
|
||||
&.active { background: white; }
|
||||
&:hover { background: rgba(255, 255, 255, 0.8); }
|
||||
}
|
||||
}
|
||||
|
||||
// Barre d'édition (zoom + déplacer + ajouter/supprimer)
|
||||
.edit-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 5px;
|
||||
color: #d1d5db;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { border-color: #6c63ff; color: white; }
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 120px;
|
||||
accent-color: #6c63ff;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.move-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.74rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #3f1f1f;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fca5a5;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: #5a2a2a; }
|
||||
}
|
||||
}
|
||||
|
||||
// État vide
|
||||
.image-empty {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
border: 1px dashed #2a2a3d;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.015);
|
||||
color: #4b5563;
|
||||
|
||||
&.fill { height: 100%; aspect-ratio: auto; }
|
||||
}
|
||||
209
web/src/app/shared/image-block/image-block.component.ts
Normal file
209
web/src/app/shared/image-block/image-block.component.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, Output,
|
||||
} from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import {
|
||||
LucideAngularModule, ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut, Move, Image as ImageIcon,
|
||||
} from 'lucide-angular';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { ImageService } from '../../services/image.service';
|
||||
import { Image } from '../../services/image.model';
|
||||
import { ImageFraming } from '../../services/page.model';
|
||||
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
|
||||
|
||||
const DEFAULT_FRAMING: ImageFraming = { x: 50, y: 50, scale: 1 };
|
||||
// scale 1 = image ENTIÈRE visible (object-fit contain, bordures noires si besoin).
|
||||
// < 1 : encore plus petite (plus de marge) ; > 1 : on zoome -> remplit puis rogne.
|
||||
const MIN_SCALE = 0.4;
|
||||
const MAX_SCALE = 4;
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloc image d'une page de lore : une ou plusieurs images, chacune remplissant
|
||||
* tout le bloc (object-fit cover). En mode édition, on peut DÉPLACER l'image
|
||||
* (glisser pour recadrer, via object-position) et la ZOOMER (molette / curseur),
|
||||
* le cadrage étant persisté PAR IMAGE (pan/zoom) côté page.
|
||||
*
|
||||
* Remplace `app-image-gallery` pour les blocs IMAGE des pages (les variantes
|
||||
* grille/mosaïque/héros n'ont plus de sens : l'image occupe le bloc entier).
|
||||
*
|
||||
* E/S :
|
||||
* [imageIds] (string[]) + (imageIdsChange)
|
||||
* [framing] (imageId → {x,y,scale}) + (framingChange)
|
||||
* [editable]
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-image-block',
|
||||
imports: [FormsModule, LucideAngularModule, ImageUploaderComponent, TranslatePipe],
|
||||
templateUrl: './image-block.component.html',
|
||||
styleUrls: ['./image-block.component.scss'],
|
||||
})
|
||||
export class ImageBlockComponent implements OnDestroy {
|
||||
readonly ChevronLeft = ChevronLeft;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly X = X;
|
||||
readonly ZoomIn = ZoomIn;
|
||||
readonly ZoomOut = ZoomOut;
|
||||
readonly Move = Move;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
|
||||
@Input() imageIds: string[] = [];
|
||||
@Input() framing: Record<string, ImageFraming> = {};
|
||||
@Input() editable = false;
|
||||
/** Remplit la hauteur du conteneur (bloc à hauteur fixe) au lieu d'un ratio 16/9. */
|
||||
@Input() fill = false;
|
||||
|
||||
@Output() imageIdsChange = new EventEmitter<string[]>();
|
||||
@Output() framingChange = new EventEmitter<Record<string, ImageFraming>>();
|
||||
|
||||
/** Index de l'image affichée. */
|
||||
current = 0;
|
||||
|
||||
private panState:
|
||||
| { id: string; startX: number; startY: number; fw: number; fh: number; ox: number; oy: number; scale: number }
|
||||
| null = null;
|
||||
|
||||
constructor(private imageService: ImageService, private cdr: ChangeDetectorRef) {}
|
||||
|
||||
// --- Accès aux images ---------------------------------------------------
|
||||
|
||||
get currentId(): string | null {
|
||||
const i = clamp(this.current, 0, Math.max(0, this.imageIds.length - 1));
|
||||
return this.imageIds[i] ?? null;
|
||||
}
|
||||
|
||||
get hasMany(): boolean {
|
||||
return this.imageIds.length > 1;
|
||||
}
|
||||
|
||||
urlFor(id: string): string {
|
||||
return this.imageService.contentUrl(id);
|
||||
}
|
||||
|
||||
framingOf(id: string | null): ImageFraming {
|
||||
return (id && this.framing?.[id]) || DEFAULT_FRAMING;
|
||||
}
|
||||
|
||||
/** object-position CSS du cadrage courant (sert aussi de transform-origin). */
|
||||
objectPosition(id: string | null): string {
|
||||
const f = this.framingOf(id);
|
||||
return `${f.x}% ${f.y}%`;
|
||||
}
|
||||
|
||||
/** transform CSS (zoom) du cadrage courant. */
|
||||
zoomTransform(id: string | null): string {
|
||||
return `scale(${this.framingOf(id).scale})`;
|
||||
}
|
||||
|
||||
scaleOf(id: string | null): number {
|
||||
return this.framingOf(id).scale;
|
||||
}
|
||||
|
||||
// --- Navigation ---------------------------------------------------------
|
||||
|
||||
prev(): void {
|
||||
if (!this.imageIds.length) return;
|
||||
this.current = (this.current - 1 + this.imageIds.length) % this.imageIds.length;
|
||||
}
|
||||
|
||||
next(): void {
|
||||
if (!this.imageIds.length) return;
|
||||
this.current = (this.current + 1) % this.imageIds.length;
|
||||
}
|
||||
|
||||
goTo(i: number): void {
|
||||
this.current = clamp(i, 0, this.imageIds.length - 1);
|
||||
}
|
||||
|
||||
// --- Ajout / suppression (édition) --------------------------------------
|
||||
|
||||
onUploaded(image: Image): void {
|
||||
const ids = [...this.imageIds, image.id];
|
||||
this.imageIdsChange.emit(ids);
|
||||
this.current = ids.length - 1; // affiche la nouvelle image
|
||||
}
|
||||
|
||||
removeCurrent(): void {
|
||||
const id = this.currentId;
|
||||
if (!id) return;
|
||||
// Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse.
|
||||
this.imageService.delete(id).subscribe({ error: () => {} });
|
||||
const ids = this.imageIds.filter(i => i !== id);
|
||||
if (this.framing?.[id]) {
|
||||
const next = { ...this.framing };
|
||||
delete next[id];
|
||||
this.framing = next;
|
||||
this.framingChange.emit(next);
|
||||
}
|
||||
this.imageIdsChange.emit(ids);
|
||||
this.current = clamp(this.current, 0, Math.max(0, ids.length - 1));
|
||||
}
|
||||
|
||||
// --- Recadrage : déplacement (drag) -------------------------------------
|
||||
|
||||
startPan(event: PointerEvent): void {
|
||||
if (!this.editable) return;
|
||||
const id = this.currentId;
|
||||
if (!id) return;
|
||||
event.preventDefault();
|
||||
const frame = event.currentTarget as HTMLElement;
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const f = this.framingOf(id);
|
||||
this.panState = {
|
||||
id, startX: event.clientX, startY: event.clientY,
|
||||
fw: rect.width, fh: rect.height, ox: f.x, oy: f.y, scale: f.scale,
|
||||
};
|
||||
frame.setPointerCapture?.(event.pointerId);
|
||||
window.addEventListener('pointermove', this.onPanMove);
|
||||
window.addEventListener('pointerup', this.onPanUp, { once: true });
|
||||
}
|
||||
|
||||
private onPanMove = (event: PointerEvent): void => {
|
||||
const s = this.panState;
|
||||
if (!s) return;
|
||||
// Glisser l'image la fait suivre le pointeur (object-position diminue quand
|
||||
// on tire vers la droite -> on révèle la partie gauche). Plus on est zoomé,
|
||||
// plus le déplacement est fin (/ scale).
|
||||
const dxPct = ((event.clientX - s.startX) / s.fw) * 100 / s.scale;
|
||||
const dyPct = ((event.clientY - s.startY) / s.fh) * 100 / s.scale;
|
||||
const x = clamp(s.ox - dxPct, 0, 100);
|
||||
const y = clamp(s.oy - dyPct, 0, 100);
|
||||
this.framing[s.id] = { x, y, scale: s.scale }; // mise à jour live (sans émettre)
|
||||
this.cdr.detectChanges();
|
||||
};
|
||||
|
||||
private onPanUp = (): void => {
|
||||
window.removeEventListener('pointermove', this.onPanMove);
|
||||
if (!this.panState) return;
|
||||
this.panState = null;
|
||||
this.framing = { ...this.framing };
|
||||
this.framingChange.emit(this.framing);
|
||||
};
|
||||
|
||||
// --- Recadrage : zoom ---------------------------------------------------
|
||||
|
||||
setZoom(scale: number): void {
|
||||
const id = this.currentId;
|
||||
if (!id) return;
|
||||
const f = this.framingOf(id);
|
||||
this.framing = { ...this.framing, [id]: { ...f, scale: clamp(scale, MIN_SCALE, MAX_SCALE) } };
|
||||
this.framingChange.emit(this.framing);
|
||||
}
|
||||
|
||||
onWheel(event: WheelEvent): void {
|
||||
if (!this.editable) return;
|
||||
const id = this.currentId;
|
||||
if (!id) return;
|
||||
event.preventDefault();
|
||||
const delta = event.deltaY < 0 ? 0.12 : -0.12;
|
||||
this.setZoom(this.framingOf(id).scale + delta);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
window.removeEventListener('pointermove', this.onPanMove);
|
||||
window.removeEventListener('pointerup', this.onPanUp);
|
||||
}
|
||||
}
|
||||
@@ -905,7 +905,23 @@
|
||||
"addFieldTitle": "Add the field",
|
||||
"fieldsHelp": "Text = free + AI-generatable. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).",
|
||||
"deleteTitle": "Delete the template",
|
||||
"deleteMessage": "Delete the template \"{{name}}\"?"
|
||||
"deleteMessage": "Delete the template \"{{name}}\"?",
|
||||
"move": "Move (drag)",
|
||||
"narrower": "Narrower",
|
||||
"wider": "Wider",
|
||||
"widthLabel": "Width in columns",
|
||||
"gridHelp": "Drag a block type from the palette onto the grid. Move a block with its handle (top-left), resize its width AND height with the edge and corner handles. Click a name to rename. The layout is preserved in the preview.",
|
||||
"blockNameLabel": "Block name",
|
||||
"paletteTitle": "Blocks",
|
||||
"addBlockHint": "Drag onto the grid (or click) to add",
|
||||
"canvasEmpty": "Drag a block from the palette to start",
|
||||
"resize": "Resize (drag)",
|
||||
"defaultNameText": "Text",
|
||||
"defaultNameImage": "Illustration",
|
||||
"defaultNameKeyValue": "Characteristics",
|
||||
"defaultNameTable": "Table",
|
||||
"resizeWidth": "Width (drag)",
|
||||
"resizeHeight": "Height (drag)"
|
||||
},
|
||||
"sceneCreate": {
|
||||
"title": "Create a new scene",
|
||||
@@ -985,7 +1001,13 @@
|
||||
"deleteMessage": "Delete the scene \"{{name}}\"?",
|
||||
"deleteIrreversible": "This action is irreversible.",
|
||||
"saveError": "Error while saving",
|
||||
"deleteError": "Error while deleting"
|
||||
"deleteError": "Error while deleting",
|
||||
"battlemapSourceLabel": "Map source",
|
||||
"battlemapSourceDA": "Dungeon Alchemist (image/video + JSON)",
|
||||
"battlemapSourceDD": "DungeonDraft (.dd2vtt)",
|
||||
"battlemapDd2vttSlot": ".dd2vtt file",
|
||||
"battlemapDd2vttHint": "The .dd2vtt contains the map AND the walls/lights; the embedded image is extracted automatically for the Foundry export.",
|
||||
"battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background."
|
||||
},
|
||||
"sceneView": {
|
||||
"subtitle": "Scene",
|
||||
@@ -1531,5 +1553,15 @@
|
||||
"importInterrupted": "The import was interrupted before completion (connection dropped or timed out). Please try again.",
|
||||
"unknownServerError": "Unknown server error.",
|
||||
"networkError": "Network error"
|
||||
},
|
||||
"imageBlock": {
|
||||
"prev": "Previous image",
|
||||
"next": "Next image",
|
||||
"goTo": "Go to image {{n}}",
|
||||
"zoom": "Zoom",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"dragHint": "Drag to reframe",
|
||||
"remove": "Remove this image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,7 +905,23 @@
|
||||
"addFieldTitle": "Ajouter le champ",
|
||||
"fieldsHelp": "Texte = libre + générable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).",
|
||||
"deleteTitle": "Supprimer le template",
|
||||
"deleteMessage": "Supprimer le template « {{name}} » ?"
|
||||
"deleteMessage": "Supprimer le template « {{name}} » ?",
|
||||
"move": "Déplacer (glisser)",
|
||||
"narrower": "Rétrécir",
|
||||
"wider": "Élargir",
|
||||
"widthLabel": "Largeur en colonnes",
|
||||
"gridHelp": "Glissez un type de bloc depuis la palette sur la grille. Déplacez un bloc avec sa poignée (en haut à gauche), redimensionnez-le en largeur ET hauteur avec les poignées de bord et de coin. Cliquez sur un nom pour le renommer. La disposition est conservée dans l'aperçu.",
|
||||
"blockNameLabel": "Nom du bloc",
|
||||
"paletteTitle": "Blocs",
|
||||
"addBlockHint": "Glissez sur la grille (ou cliquez) pour ajouter",
|
||||
"canvasEmpty": "Glissez un bloc depuis la palette pour commencer",
|
||||
"resize": "Redimensionner (glisser)",
|
||||
"defaultNameText": "Texte",
|
||||
"defaultNameImage": "Illustration",
|
||||
"defaultNameKeyValue": "Caractéristiques",
|
||||
"defaultNameTable": "Tableau",
|
||||
"resizeWidth": "Largeur (glisser)",
|
||||
"resizeHeight": "Hauteur (glisser)"
|
||||
},
|
||||
"sceneCreate": {
|
||||
"title": "Créer une nouvelle scène",
|
||||
@@ -985,7 +1001,13 @@
|
||||
"deleteMessage": "Supprimer la scène \"{{name}}\" ?",
|
||||
"deleteIrreversible": "Cette action est irréversible.",
|
||||
"saveError": "Erreur lors de la sauvegarde",
|
||||
"deleteError": "Erreur lors de la suppression"
|
||||
"deleteError": "Erreur lors de la suppression",
|
||||
"battlemapSourceLabel": "Source de la carte",
|
||||
"battlemapSourceDA": "Dungeon Alchemist (image/vidéo + JSON)",
|
||||
"battlemapSourceDD": "DungeonDraft (.dd2vtt)",
|
||||
"battlemapDd2vttSlot": "Fichier .dd2vtt",
|
||||
"battlemapDd2vttHint": "Le .dd2vtt contient la carte ET les murs/lumières ; l'image embarquée est extraite automatiquement pour l'export Foundry.",
|
||||
"battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond."
|
||||
},
|
||||
"sceneView": {
|
||||
"subtitle": "Scène",
|
||||
@@ -1531,5 +1553,15 @@
|
||||
"importInterrupted": "L'import s'est interrompu avant la fin (connexion coupée ou délai dépassé). Réessayez.",
|
||||
"unknownServerError": "Erreur inconnue côté serveur.",
|
||||
"networkError": "Erreur réseau"
|
||||
},
|
||||
"imageBlock": {
|
||||
"prev": "Image précédente",
|
||||
"next": "Image suivante",
|
||||
"goTo": "Aller à l'image {{n}}",
|
||||
"zoom": "Zoom",
|
||||
"zoomIn": "Zoomer",
|
||||
"zoomOut": "Dézoomer",
|
||||
"dragHint": "Glisser pour recadrer",
|
||||
"remove": "Retirer cette image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,19 @@
|
||||
"addFieldPlaceholder": "+ Add a field",
|
||||
"addFieldTitle": "Add the field",
|
||||
"fieldsHelp": "Text = free + AI-generatable. Image = gallery. Key/value list = label/value pairs (stats). Table = fixed columns + rows added freely (shop, inventory…).",
|
||||
"blockNameLabel": "Block name",
|
||||
"paletteTitle": "Blocks",
|
||||
"addBlockHint": "Drag onto the grid (or click) to add",
|
||||
"canvasEmpty": "Drag a block from the palette to start",
|
||||
"move": "Move (drag)",
|
||||
"resize": "Resize (drag)",
|
||||
"resizeWidth": "Width (drag)",
|
||||
"resizeHeight": "Height (drag)",
|
||||
"defaultNameText": "Text",
|
||||
"defaultNameImage": "Illustration",
|
||||
"defaultNameKeyValue": "Characteristics",
|
||||
"defaultNameTable": "Table",
|
||||
"gridHelp": "Drag a block type from the palette onto the grid. Move a block with its handle (top-left), resize its width AND height with the edge and corner handles. Click a name to rename. The layout is preserved in the preview.",
|
||||
"deleteTitle": "Delete the template",
|
||||
"deleteMessage": "Delete the template \"{{name}}\"?"
|
||||
}
|
||||
|
||||
@@ -136,6 +136,19 @@
|
||||
"addFieldPlaceholder": "+ Ajouter un champ",
|
||||
"addFieldTitle": "Ajouter le champ",
|
||||
"fieldsHelp": "Texte = libre + générable par l'IA. Image = galerie. Liste clé/valeur = paires libellé/valeur (stats). Tableau = colonnes fixes + lignes ajoutées librement (boutique, inventaire…).",
|
||||
"blockNameLabel": "Nom du bloc",
|
||||
"paletteTitle": "Blocs",
|
||||
"addBlockHint": "Glissez sur la grille (ou cliquez) pour ajouter",
|
||||
"canvasEmpty": "Glissez un bloc depuis la palette pour commencer",
|
||||
"move": "Déplacer (glisser)",
|
||||
"resize": "Redimensionner (glisser)",
|
||||
"resizeWidth": "Largeur (glisser)",
|
||||
"resizeHeight": "Hauteur (glisser)",
|
||||
"defaultNameText": "Texte",
|
||||
"defaultNameImage": "Illustration",
|
||||
"defaultNameKeyValue": "Caractéristiques",
|
||||
"defaultNameTable": "Tableau",
|
||||
"gridHelp": "Glissez un type de bloc depuis la palette sur la grille. Déplacez un bloc avec sa poignée (en haut à gauche), redimensionnez-le en largeur ET hauteur avec les poignées de bord et de coin. Cliquez sur un nom pour le renommer. La disposition est conservée dans l'aperçu.",
|
||||
"deleteTitle": "Supprimer le template",
|
||||
"deleteMessage": "Supprimer le template « {{name}} » ?"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"battlemapUploading": "Uploading...",
|
||||
"battlemapAttached": "File attached",
|
||||
"battlemapRemove": "Remove",
|
||||
"battlemapSourceLabel": "Map source",
|
||||
"battlemapSourceDA": "Dungeon Alchemist (image/video + JSON)",
|
||||
"battlemapSourceDD": "DungeonDraft (.dd2vtt)",
|
||||
"battlemapDd2vttSlot": ".dd2vtt file",
|
||||
"battlemapDd2vttHint": "The .dd2vtt contains the map AND the walls/lights; the embedded image is extracted automatically for the Foundry export.",
|
||||
"battlemapDd2vttNoImage": "No image found in this .dd2vtt — the exported map will have no background.",
|
||||
"nameLabel": "Scene title *",
|
||||
"namePlaceholder": "E.g.: Arrival at the village",
|
||||
"descriptionLabel": "Short description *",
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
"battlemapUploading": "Envoi...",
|
||||
"battlemapAttached": "Fichier attache",
|
||||
"battlemapRemove": "Retirer",
|
||||
"battlemapSourceLabel": "Source de la carte",
|
||||
"battlemapSourceDA": "Dungeon Alchemist (image/vidéo + JSON)",
|
||||
"battlemapSourceDD": "DungeonDraft (.dd2vtt)",
|
||||
"battlemapDd2vttSlot": "Fichier .dd2vtt",
|
||||
"battlemapDd2vttHint": "Le .dd2vtt contient la carte ET les murs/lumières ; l'image embarquée est extraite automatiquement pour l'export Foundry.",
|
||||
"battlemapDd2vttNoImage": "Aucune image trouvée dans ce .dd2vtt — la carte exportée n'aura pas de fond.",
|
||||
"nameLabel": "Titre de la scène *",
|
||||
"namePlaceholder": "Ex: Arrivée au village",
|
||||
"descriptionLabel": "Description courte *",
|
||||
|
||||
@@ -40,6 +40,16 @@
|
||||
"lightboxAria": "Full-screen image",
|
||||
"lightboxAlt": "Enlarged image"
|
||||
},
|
||||
"imageBlock": {
|
||||
"prev": "Previous image",
|
||||
"next": "Next image",
|
||||
"goTo": "Go to image {{n}}",
|
||||
"zoom": "Zoom",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"dragHint": "Drag to reframe",
|
||||
"remove": "Remove this image"
|
||||
},
|
||||
"imageUploader": {
|
||||
"addImage": "Add an image",
|
||||
"uploading": "Uploading",
|
||||
|
||||
@@ -40,6 +40,16 @@
|
||||
"lightboxAria": "Image en plein écran",
|
||||
"lightboxAlt": "Image agrandie"
|
||||
},
|
||||
"imageBlock": {
|
||||
"prev": "Image précédente",
|
||||
"next": "Image suivante",
|
||||
"goTo": "Aller à l'image {{n}}",
|
||||
"zoom": "Zoom",
|
||||
"zoomIn": "Zoomer",
|
||||
"zoomOut": "Dézoomer",
|
||||
"dragHint": "Glisser pour recadrer",
|
||||
"remove": "Retirer cette image"
|
||||
},
|
||||
"imageUploader": {
|
||||
"addImage": "Ajouter une image",
|
||||
"uploading": "Upload en cours",
|
||||
|
||||
Reference in New Issue
Block a user