Amélioration de l'UI : meilleur affichage des images que ce soit dans la partie lore ou la partie campagne (partie campagne : visualisation scrapbooking). Possibilité de réordonner les champs dans les templates...
Passage v0.3.0
This commit is contained in:
@@ -67,4 +67,9 @@ docker compose up -d --build
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[À définir]
|
LoreMind est distribué sous licence **[GNU AGPL v3](LICENSE)**.
|
||||||
|
|
||||||
|
En pratique :
|
||||||
|
- Tu peux l'utiliser gratuitement, l'héberger où tu veux, le modifier, le redistribuer.
|
||||||
|
- Si tu modifies le code et que tu exposes l'application modifiée sur un réseau (même en SaaS privé), tu dois rendre tes modifications publiques sous la même licence.
|
||||||
|
- Les univers (Lore) et campagnes que tu crées avec LoreMind **t'appartiennent entièrement** — la licence ne couvre que le code de l'application.
|
||||||
|
|||||||
@@ -38,12 +38,18 @@ public class Arc {
|
|||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IDs des images (Shared Kernel) servant d'illustrations a cet arc.
|
* IDs des images (Shared Kernel) servant d'illustrations a cet arc (ambiance).
|
||||||
* Galerie ordonnee : la 1ere image est l'illustration principale.
|
* Galerie ordonnee : la 1ere image est l'illustration principale.
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des images utilisees comme cartes / plans (outil de table).
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,11 +34,17 @@ public class Chapter {
|
|||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IDs des images (Shared Kernel) illustrant ce chapitre.
|
* IDs des images (Shared Kernel) illustrant ce chapitre (ambiance).
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des images utilisees comme cartes / plans pour ce chapitre (outil de table).
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
|
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,11 +48,19 @@ public class Scene {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* IDs des images (Shared Kernel) illustrant cette scene.
|
* IDs des images (Shared Kernel) illustrant cette scene.
|
||||||
* Utile pour carte du lieu, portraits des PNJ principaux, ambiance.
|
* Vocation "ambiance" : portraits, decors, moodboard. Rendu facon editorial.
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDs des images utilisees comme cartes / plans.
|
||||||
|
* Vocation "outil de table" : plan de donjon, carte du lieu, schema tactique.
|
||||||
|
* Rendu different des illustrations : vignettes plus grandes, ratio natif preserve.
|
||||||
|
*/
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
|
* Sorties narratives possibles depuis cette scène (graphe intra-chapitre).
|
||||||
* Chaque branche décrit un choix des joueurs et la scène de destination.
|
* Chaque branche décrit un choix des joueurs et la scène de destination.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.loremind.domain.lorecontext;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante de rendu pour un champ de type IMAGE.
|
||||||
|
* <p>
|
||||||
|
* - GALLERY : grille de vignettes (defaut, comportement historique)
|
||||||
|
* - HERO : premiere image en banniere pleine largeur, suivantes en petit
|
||||||
|
* - MASONRY : mosaique hauteurs variables facon Pinterest
|
||||||
|
* - CAROUSEL : defilement horizontal
|
||||||
|
* <p>
|
||||||
|
* Uniquement significatif quand {@link FieldType} = IMAGE. Ignore pour TEXT.
|
||||||
|
*/
|
||||||
|
public enum ImageLayout {
|
||||||
|
GALLERY,
|
||||||
|
HERO,
|
||||||
|
MASONRY,
|
||||||
|
CAROUSEL
|
||||||
|
}
|
||||||
@@ -12,10 +12,9 @@ import lombok.NoArgsConstructor;
|
|||||||
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
|
* Le type pilote le rendu cote front (textarea vs galerie d'images) ET
|
||||||
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
|
* la logique metier (seuls les champs TEXT sont envoyes a l'IA pour generation).
|
||||||
* <p>
|
* <p>
|
||||||
* Evolution de `List<String> fields` vers `List<TemplateField> fields` :
|
* Pour les champs IMAGE, {@link #layout} precise la variante de rendu
|
||||||
* refactor propre (DDD Value Object polymorphism) permettant d'ajouter
|
* (gallery/hero/masonry/carousel). Nullable : l'absence equivaut a GALLERY.
|
||||||
* facilement d'autres types de champs (DATE, NUMBER, RICH_TEXT...) sans
|
* Ignore pour les champs TEXT.
|
||||||
* casser le contrat.
|
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@@ -26,14 +25,26 @@ public class TemplateField {
|
|||||||
private String name;
|
private String name;
|
||||||
/** Type du champ, pilote le rendu et la generation IA. */
|
/** Type du champ, pilote le rendu et la generation IA. */
|
||||||
private FieldType type;
|
private FieldType type;
|
||||||
|
/** Variante de rendu pour les champs IMAGE. Null = GALLERY. */
|
||||||
|
private ImageLayout layout;
|
||||||
|
|
||||||
|
/** Constructeur de retrocompat : type seul, layout=null. */
|
||||||
|
public TemplateField(String name, FieldType type) {
|
||||||
|
this(name, type, null);
|
||||||
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
/** Raccourci : construit un champ de type TEXT (cas le plus courant). */
|
||||||
public static TemplateField text(String name) {
|
public static TemplateField text(String name) {
|
||||||
return new TemplateField(name, FieldType.TEXT);
|
return new TemplateField(name, FieldType.TEXT, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raccourci : construit un champ de type IMAGE. */
|
/** Raccourci : construit un champ de type IMAGE avec layout GALLERY. */
|
||||||
public static TemplateField image(String name) {
|
public static TemplateField image(String name) {
|
||||||
return new TemplateField(name, FieldType.IMAGE);
|
return new TemplateField(name, FieldType.IMAGE, ImageLayout.GALLERY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raccourci : construit un champ IMAGE avec un layout specifique. */
|
||||||
|
public static TemplateField image(String name, ImageLayout layout) {
|
||||||
|
return new TemplateField(name, FieldType.IMAGE, layout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
|||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.lorecontext.FieldType;
|
||||||
|
import com.loremind.domain.lorecontext.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
import jakarta.persistence.AttributeConverter;
|
import jakarta.persistence.AttributeConverter;
|
||||||
import jakarta.persistence.Converter;
|
import jakarta.persistence.Converter;
|
||||||
@@ -72,8 +73,20 @@ public class TemplateFieldListJsonConverter
|
|||||||
// Type inconnu (ajoute par une version future) : fallback TEXT.
|
// Type inconnu (ajoute par une version future) : fallback TEXT.
|
||||||
type = FieldType.TEXT;
|
type = FieldType.TEXT;
|
||||||
}
|
}
|
||||||
|
ImageLayout layout = null;
|
||||||
|
if (type == FieldType.IMAGE) {
|
||||||
|
String layoutStr = item.path("layout").asText(null);
|
||||||
|
if (layoutStr != null && !layoutStr.isBlank()) {
|
||||||
|
try {
|
||||||
|
layout = ImageLayout.valueOf(layoutStr);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
// Layout inconnu : on laisse null → rendu GALLERY par defaut cote UI.
|
||||||
|
layout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (name != null && !name.isBlank()) {
|
if (name != null && !name.isBlank()) {
|
||||||
result.add(new TemplateField(name, type));
|
result.add(new TemplateField(name, type, layout));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
// Autres types de noeuds (nombre, booleen...) : ignores silencieusement.
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ public class ArcJpaEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images "cartes / plans". */
|
||||||
|
@Column(name = "map_image_ids", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ public class ChapterJpaEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "map_image_ids", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ public class SceneJpaEntity {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(name = "map_image_ids", columnDefinition = "TEXT")
|
||||||
|
@Convert(converter = StringListJsonConverter.class)
|
||||||
|
@Builder.Default
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
|
|
||||||
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
|
// Graphe narratif intra-chapitre : sorties possibles vers d'autres scènes.
|
||||||
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
|
// Persisté en TEXT JSON via converter (pattern homogène avec les autres listes).
|
||||||
@Column(name = "branches", columnDefinition = "TEXT")
|
@Column(name = "branches", columnDefinition = "TEXT")
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.mapImageIds(jpaEntity.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(jpaEntity.getMapImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(jpaEntity.getCreatedAt())
|
.createdAt(jpaEntity.getCreatedAt())
|
||||||
.updatedAt(jpaEntity.getUpdatedAt())
|
.updatedAt(jpaEntity.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
@@ -107,6 +110,9 @@ public class PostgresArcRepository implements ArcRepository {
|
|||||||
.illustrationImageIds(arc.getIllustrationImageIds() != null
|
.illustrationImageIds(arc.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(arc.getIllustrationImageIds())
|
? new ArrayList<>(arc.getIllustrationImageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.mapImageIds(arc.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(arc.getMapImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(arc.getCreatedAt())
|
.createdAt(arc.getCreatedAt())
|
||||||
.updatedAt(arc.getUpdatedAt())
|
.updatedAt(arc.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.mapImageIds(jpaEntity.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(jpaEntity.getMapImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(jpaEntity.getCreatedAt())
|
.createdAt(jpaEntity.getCreatedAt())
|
||||||
.updatedAt(jpaEntity.getUpdatedAt())
|
.updatedAt(jpaEntity.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
@@ -102,6 +105,9 @@ public class PostgresChapterRepository implements ChapterRepository {
|
|||||||
.illustrationImageIds(chapter.getIllustrationImageIds() != null
|
.illustrationImageIds(chapter.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(chapter.getIllustrationImageIds())
|
? new ArrayList<>(chapter.getIllustrationImageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.mapImageIds(chapter.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(chapter.getMapImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.createdAt(chapter.getCreatedAt())
|
.createdAt(chapter.getCreatedAt())
|
||||||
.updatedAt(chapter.getUpdatedAt())
|
.updatedAt(chapter.getUpdatedAt())
|
||||||
.build();
|
.build();
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
.illustrationImageIds(jpaEntity.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
? new ArrayList<>(jpaEntity.getIllustrationImageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.mapImageIds(jpaEntity.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(jpaEntity.getMapImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.branches(jpaEntity.getBranches() != null
|
.branches(jpaEntity.getBranches() != null
|
||||||
? new ArrayList<>(jpaEntity.getBranches())
|
? new ArrayList<>(jpaEntity.getBranches())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
@@ -115,6 +118,9 @@ public class PostgresSceneRepository implements SceneRepository {
|
|||||||
.illustrationImageIds(scene.getIllustrationImageIds() != null
|
.illustrationImageIds(scene.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(scene.getIllustrationImageIds())
|
? new ArrayList<>(scene.getIllustrationImageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.mapImageIds(scene.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(scene.getMapImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.branches(scene.getBranches() != null
|
.branches(scene.getBranches() != null
|
||||||
? new ArrayList<>(scene.getBranches())
|
? new ArrayList<>(scene.getBranches())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ public class ImageController {
|
|||||||
.contentType(MediaType.parseMediaType(img.getContentType()))
|
.contentType(MediaType.parseMediaType(img.getContentType()))
|
||||||
.contentLength(img.getSizeBytes())
|
.contentLength(img.getSizeBytes())
|
||||||
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
|
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||||
|
// Autorise explicitement l'utilisation cross-origin du binaire dans une <img>.
|
||||||
|
// Sans ce header, Firefox 109+ applique ORB (Opaque Response Blocking) et
|
||||||
|
// bloque l'image quand le front (localhost:4200) la charge depuis l'API (localhost:8080).
|
||||||
|
.header("Cross-Origin-Resource-Policy", "cross-origin")
|
||||||
.body(new InputStreamResource(stream));
|
.body(new InputStreamResource(stream));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ public class ArcDTO {
|
|||||||
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */
|
/** IDs des pages du Lore liées à cet arc (weak cross-context references). */
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
/** IDs des images (Shared Kernel) illustrant cet arc. */
|
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images utilisees comme cartes / plans. */
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ public class ChapterDTO {
|
|||||||
/** IDs des pages du Lore liées (weak cross-context references). */
|
/** IDs des pages du Lore liées (weak cross-context references). */
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
/** IDs des images (Shared Kernel) illustrant ce chapitre. */
|
/** IDs des images (Shared Kernel) illustrant ce chapitre (ambiance). */
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images utilisees comme cartes / plans. */
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ public class SceneDTO {
|
|||||||
/** IDs des pages du Lore liées (weak cross-context references). */
|
/** IDs des pages du Lore liées (weak cross-context references). */
|
||||||
private List<String> relatedPageIds = new ArrayList<>();
|
private List<String> relatedPageIds = new ArrayList<>();
|
||||||
|
|
||||||
/** IDs des images (Shared Kernel) illustrant cette scene. */
|
/** IDs des images (Shared Kernel) illustrant cette scene (ambiance). */
|
||||||
private List<String> illustrationImageIds = new ArrayList<>();
|
private List<String> illustrationImageIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** IDs des images utilisees comme cartes / plans (outil de table). */
|
||||||
|
private List<String> mapImageIds = new ArrayList<>();
|
||||||
|
|
||||||
/** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
|
/** Branches narratives : sorties possibles vers d'autres scènes du même chapitre. */
|
||||||
private List<SceneBranchDTO> branches = new ArrayList<>();
|
private List<SceneBranchDTO> branches = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import lombok.NoArgsConstructor;
|
|||||||
* <p>
|
* <p>
|
||||||
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
|
* Miroir wire-friendly de {@link com.loremind.domain.lorecontext.TemplateField}.
|
||||||
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
* Le type est serialise en string (TEXT/IMAGE) pour interop facile avec Angular.
|
||||||
|
* Le layout (null pour TEXT, ou GALLERY/HERO/MASONRY/CAROUSEL pour IMAGE) pilote
|
||||||
|
* le rendu visuel des champs image cote front.
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@@ -17,4 +19,11 @@ public class TemplateFieldDTO {
|
|||||||
private String name;
|
private String name;
|
||||||
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
|
/** "TEXT" ou "IMAGE" (string pour serialisation JSON transparente). */
|
||||||
private String type;
|
private String type;
|
||||||
|
/** "GALLERY" | "HERO" | "MASONRY" | "CAROUSEL", null si type=TEXT. */
|
||||||
|
private String layout;
|
||||||
|
|
||||||
|
/** Retrocompat : constructeur sans layout. */
|
||||||
|
public TemplateFieldDTO(String name, String type) {
|
||||||
|
this(name, type, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public class ArcMapper {
|
|||||||
dto.setResolution(arc.getResolution());
|
dto.setResolution(arc.getResolution());
|
||||||
dto.setRelatedPageIds(copyList(arc.getRelatedPageIds()));
|
dto.setRelatedPageIds(copyList(arc.getRelatedPageIds()));
|
||||||
dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds()));
|
dto.setIllustrationImageIds(copyList(arc.getIllustrationImageIds()));
|
||||||
|
dto.setMapImageIds(copyList(arc.getMapImageIds()));
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ public class ArcMapper {
|
|||||||
.resolution(dto.getResolution())
|
.resolution(dto.getResolution())
|
||||||
.relatedPageIds(copyList(dto.getRelatedPageIds()))
|
.relatedPageIds(copyList(dto.getRelatedPageIds()))
|
||||||
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
|
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
|
||||||
|
.mapImageIds(copyList(dto.getMapImageIds()))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class ChapterMapper {
|
|||||||
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
dto.setNarrativeStakes(chapter.getNarrativeStakes());
|
||||||
dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds()));
|
dto.setRelatedPageIds(copyList(chapter.getRelatedPageIds()));
|
||||||
dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds()));
|
dto.setIllustrationImageIds(copyList(chapter.getIllustrationImageIds()));
|
||||||
|
dto.setMapImageIds(copyList(chapter.getMapImageIds()));
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ public class ChapterMapper {
|
|||||||
.narrativeStakes(dto.getNarrativeStakes())
|
.narrativeStakes(dto.getNarrativeStakes())
|
||||||
.relatedPageIds(copyList(dto.getRelatedPageIds()))
|
.relatedPageIds(copyList(dto.getRelatedPageIds()))
|
||||||
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
|
.illustrationImageIds(copyList(dto.getIllustrationImageIds()))
|
||||||
|
.mapImageIds(copyList(dto.getMapImageIds()))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ public class SceneMapper {
|
|||||||
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
|
dto.setIllustrationImageIds(scene.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(scene.getIllustrationImageIds())
|
? new ArrayList<>(scene.getIllustrationImageIds())
|
||||||
: new ArrayList<>());
|
: new ArrayList<>());
|
||||||
|
dto.setMapImageIds(scene.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(scene.getMapImageIds())
|
||||||
|
: new ArrayList<>());
|
||||||
dto.setBranches(toBranchDTOs(scene.getBranches()));
|
dto.setBranches(toBranchDTOs(scene.getBranches()));
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
@@ -70,6 +73,9 @@ public class SceneMapper {
|
|||||||
.illustrationImageIds(dto.getIllustrationImageIds() != null
|
.illustrationImageIds(dto.getIllustrationImageIds() != null
|
||||||
? new ArrayList<>(dto.getIllustrationImageIds())
|
? new ArrayList<>(dto.getIllustrationImageIds())
|
||||||
: new ArrayList<>())
|
: new ArrayList<>())
|
||||||
|
.mapImageIds(dto.getMapImageIds() != null
|
||||||
|
? new ArrayList<>(dto.getMapImageIds())
|
||||||
|
: new ArrayList<>())
|
||||||
.branches(toBranchDomain(dto.getBranches()))
|
.branches(toBranchDomain(dto.getBranches()))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.loremind.infrastructure.web.mapper;
|
package com.loremind.infrastructure.web.mapper;
|
||||||
|
|
||||||
import com.loremind.domain.lorecontext.FieldType;
|
import com.loremind.domain.lorecontext.FieldType;
|
||||||
|
import com.loremind.domain.lorecontext.ImageLayout;
|
||||||
import com.loremind.domain.lorecontext.TemplateField;
|
import com.loremind.domain.lorecontext.TemplateField;
|
||||||
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
import com.loremind.infrastructure.web.dto.lorecontext.TemplateFieldDTO;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -11,6 +12,8 @@ import org.springframework.stereotype.Component;
|
|||||||
* <p>
|
* <p>
|
||||||
* Tolerance : un type inconnu recu du client est interprete comme TEXT
|
* Tolerance : un type inconnu recu du client est interprete comme TEXT
|
||||||
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
|
* (plus safe que de rejeter la requete et d'interrompre la sauvegarde).
|
||||||
|
* Un layout inconnu ou absent sur un champ IMAGE est interprete comme GALLERY.
|
||||||
|
* Le layout est force a null pour les champs TEXT.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TemplateFieldMapper {
|
public class TemplateFieldMapper {
|
||||||
@@ -18,7 +21,12 @@ public class TemplateFieldMapper {
|
|||||||
public TemplateFieldDTO toDTO(TemplateField field) {
|
public TemplateFieldDTO toDTO(TemplateField field) {
|
||||||
if (field == null) return null;
|
if (field == null) return null;
|
||||||
String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name();
|
String typeStr = field.getType() != null ? field.getType().name() : FieldType.TEXT.name();
|
||||||
return new TemplateFieldDTO(field.getName(), typeStr);
|
String layoutStr = null;
|
||||||
|
if (field.getType() == FieldType.IMAGE) {
|
||||||
|
ImageLayout layout = field.getLayout() != null ? field.getLayout() : ImageLayout.GALLERY;
|
||||||
|
layoutStr = layout.name();
|
||||||
|
}
|
||||||
|
return new TemplateFieldDTO(field.getName(), typeStr, layoutStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
public TemplateField toDomain(TemplateFieldDTO dto) {
|
public TemplateField toDomain(TemplateFieldDTO dto) {
|
||||||
@@ -29,6 +37,16 @@ public class TemplateFieldMapper {
|
|||||||
} catch (IllegalArgumentException ex) {
|
} catch (IllegalArgumentException ex) {
|
||||||
type = FieldType.TEXT;
|
type = FieldType.TEXT;
|
||||||
}
|
}
|
||||||
return new TemplateField(dto.getName(), type);
|
ImageLayout layout = null;
|
||||||
|
if (type == FieldType.IMAGE) {
|
||||||
|
try {
|
||||||
|
layout = dto.getLayout() != null
|
||||||
|
? ImageLayout.valueOf(dto.getLayout())
|
||||||
|
: ImageLayout.GALLERY;
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
layout = ImageLayout.GALLERY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new TemplateField(dto.getName(), type, layout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
"
|
"
|
||||||
|
|
||||||
core:
|
core:
|
||||||
image: ${REGISTRY:-gitea.example.com}/ietm64/core:${TAG:-latest}
|
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/core:${TAG:-latest}
|
||||||
container_name: loremind-core
|
container_name: loremind-core
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -77,7 +77,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
brain:
|
brain:
|
||||||
image: ${REGISTRY:-gitea.example.com}/ietm64/brain:${TAG:-latest}
|
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/brain:${TAG:-latest}
|
||||||
container_name: loremind-brain
|
container_name: loremind-brain
|
||||||
environment:
|
environment:
|
||||||
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
|
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
|
||||||
@@ -95,7 +95,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: ${REGISTRY:-gitea.example.com}/ietm64/web:${TAG:-latest}
|
image: ${REGISTRY:-git.igmlcreation.fr}/ietm64/web:${TAG:-latest}
|
||||||
container_name: loremind-web
|
container_name: loremind-web
|
||||||
depends_on:
|
depends_on:
|
||||||
- core
|
- core
|
||||||
|
|||||||
@@ -18,15 +18,28 @@
|
|||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
<!-- Illustrations (galerie editable) -->
|
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Illustrations</label>
|
<label>Illustrations</label>
|
||||||
<app-image-gallery
|
<app-image-gallery
|
||||||
[imageIds]="illustrationImageIds"
|
[imageIds]="illustrationImageIds"
|
||||||
[editable]="true"
|
[editable]="true"
|
||||||
|
[layout]="'EDITORIAL'"
|
||||||
(imageIdsChange)="illustrationImageIds = $event">
|
(imageIdsChange)="illustrationImageIds = $event">
|
||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
<small class="field-hint">Glisse-depose ou clique sur "+ Ajouter" pour uploader. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
|
<small class="field-hint">Ambiances, portraits, visuels evocateurs de l'arc. JPEG, PNG, WebP ou GIF, 10 Mo max.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cartes & plans -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Cartes & plans</label>
|
||||||
|
<app-image-gallery
|
||||||
|
[imageIds]="mapImageIds"
|
||||||
|
[editable]="true"
|
||||||
|
[layout]="'MAPS'"
|
||||||
|
(imageIdsChange)="mapImageIds = $event">
|
||||||
|
</app-image-gallery>
|
||||||
|
<small class="field-hint">Cartes regionales et plans utiles aux joueurs pour situer l'action.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
|
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
|
||||||
illustrationImageIds: string[] = [];
|
illustrationImageIds: string[] = [];
|
||||||
|
/** IDs des images utilisees comme cartes / plans (outil de table). */
|
||||||
|
mapImageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
@@ -119,6 +121,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
||||||
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
|
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
|
||||||
|
this.mapImageIds = [...(arc.mapImageIds ?? [])];
|
||||||
this.pageTitleService.set(arc.name);
|
this.pageTitleService.set(arc.name);
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: arc.name,
|
name: arc.name,
|
||||||
@@ -161,7 +164,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
|||||||
rewards: this.form.value.rewards,
|
rewards: this.form.value.rewards,
|
||||||
resolution: this.form.value.resolution,
|
resolution: this.form.value.resolution,
|
||||||
relatedPageIds: this.relatedPageIds,
|
relatedPageIds: this.relatedPageIds,
|
||||||
illustrationImageIds: this.illustrationImageIds
|
illustrationImageIds: this.illustrationImageIds,
|
||||||
|
mapImageIds: this.mapImageIds
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
|
|||||||
@@ -13,9 +13,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Illustrations en tete de page (si presentes) -->
|
<!-- Illustrations (rendu editorial magazine) -->
|
||||||
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
|
<section class="view-section" *ngIf="(arc.illustrationImageIds?.length ?? 0) > 0">
|
||||||
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []"></app-image-gallery>
|
<app-image-gallery [imageIds]="arc.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cartes & plans -->
|
||||||
|
<section class="view-section" *ngIf="(arc.mapImageIds?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
|
||||||
|
<app-image-gallery [imageIds]="arc.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="view-section">
|
<section class="view-section">
|
||||||
|
|||||||
@@ -18,15 +18,28 @@
|
|||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
<!-- Illustrations (galerie editable) -->
|
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Illustrations</label>
|
<label>Illustrations</label>
|
||||||
<app-image-gallery
|
<app-image-gallery
|
||||||
[imageIds]="illustrationImageIds"
|
[imageIds]="illustrationImageIds"
|
||||||
[editable]="true"
|
[editable]="true"
|
||||||
|
[layout]="'EDITORIAL'"
|
||||||
(imageIdsChange)="illustrationImageIds = $event">
|
(imageIdsChange)="illustrationImageIds = $event">
|
||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
<small class="field-hint">Ajoute des cartes, portraits ou ambiances pour illustrer ce chapitre.</small>
|
<small class="field-hint">Portraits, ambiances, scenes marquantes du chapitre.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cartes & plans -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Cartes & plans</label>
|
||||||
|
<app-image-gallery
|
||||||
|
[imageIds]="mapImageIds"
|
||||||
|
[editable]="true"
|
||||||
|
[layout]="'MAPS'"
|
||||||
|
(imageIdsChange)="mapImageIds = $event">
|
||||||
|
</app-image-gallery>
|
||||||
|
<small class="field-hint">Cartes regionales, plans de donjon, schemas utiles a la table.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
loreId: string | null = null;
|
loreId: string | null = null;
|
||||||
relatedPageIds: string[] = [];
|
relatedPageIds: string[] = [];
|
||||||
illustrationImageIds: string[] = [];
|
illustrationImageIds: string[] = [];
|
||||||
|
mapImageIds: string[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private fb: FormBuilder,
|
private fb: FormBuilder,
|
||||||
@@ -111,6 +112,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
||||||
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
||||||
|
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: chapter.name,
|
name: chapter.name,
|
||||||
description: chapter.description ?? '',
|
description: chapter.description ?? '',
|
||||||
@@ -148,7 +150,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
|||||||
playerObjectives: this.form.value.playerObjectives,
|
playerObjectives: this.form.value.playerObjectives,
|
||||||
narrativeStakes: this.form.value.narrativeStakes,
|
narrativeStakes: this.form.value.narrativeStakes,
|
||||||
relatedPageIds: this.relatedPageIds,
|
relatedPageIds: this.relatedPageIds,
|
||||||
illustrationImageIds: this.illustrationImageIds
|
illustrationImageIds: this.illustrationImageIds,
|
||||||
|
mapImageIds: this.mapImageIds
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
|
||||||
error: () => console.error('Erreur lors de la sauvegarde')
|
error: () => console.error('Erreur lors de la sauvegarde')
|
||||||
|
|||||||
@@ -18,9 +18,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Illustrations -->
|
<!-- Illustrations (rendu editorial magazine) -->
|
||||||
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
|
<section class="view-section" *ngIf="(chapter.illustrationImageIds?.length ?? 0) > 0">
|
||||||
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []"></app-image-gallery>
|
<app-image-gallery [imageIds]="chapter.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cartes & plans -->
|
||||||
|
<section class="view-section" *ngIf="(chapter.mapImageIds?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
|
||||||
|
<app-image-gallery [imageIds]="chapter.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="view-section">
|
<section class="view-section">
|
||||||
|
|||||||
@@ -18,15 +18,28 @@
|
|||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
<!-- Illustrations (galerie editable) -->
|
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Illustrations</label>
|
<label>Illustrations</label>
|
||||||
<app-image-gallery
|
<app-image-gallery
|
||||||
[imageIds]="illustrationImageIds"
|
[imageIds]="illustrationImageIds"
|
||||||
[editable]="true"
|
[editable]="true"
|
||||||
|
[layout]="'EDITORIAL'"
|
||||||
(imageIdsChange)="illustrationImageIds = $event">
|
(imageIdsChange)="illustrationImageIds = $event">
|
||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
<small class="field-hint">Carte du lieu, portrait des PNJ presents, ambiance visuelle...</small>
|
<small class="field-hint">Portraits des PNJ, ambiance visuelle, scenes evocatrices...</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cartes & plans (galerie editable, rendu maps) -->
|
||||||
|
<div class="field">
|
||||||
|
<label>Cartes & plans</label>
|
||||||
|
<app-image-gallery
|
||||||
|
[imageIds]="mapImageIds"
|
||||||
|
[editable]="true"
|
||||||
|
[layout]="'MAPS'"
|
||||||
|
(imageIdsChange)="mapImageIds = $event">
|
||||||
|
</app-image-gallery>
|
||||||
|
<small class="field-hint">Plans du lieu, cartes tactiques, schemas utilisables a la table.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
loreId: string | null = null;
|
loreId: string | null = null;
|
||||||
relatedPageIds: string[] = [];
|
relatedPageIds: string[] = [];
|
||||||
illustrationImageIds: string[] = [];
|
illustrationImageIds: string[] = [];
|
||||||
|
mapImageIds: string[] = [];
|
||||||
|
|
||||||
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
|
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
|
||||||
siblingScenes: Scene[] = [];
|
siblingScenes: Scene[] = [];
|
||||||
@@ -129,6 +130,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
this.availablePages = pages;
|
this.availablePages = pages;
|
||||||
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
||||||
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
||||||
|
this.mapImageIds = [...(scene.mapImageIds ?? [])];
|
||||||
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
||||||
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
|
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
@@ -179,6 +181,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
|||||||
enemies: this.form.value.enemies,
|
enemies: this.form.value.enemies,
|
||||||
relatedPageIds: this.relatedPageIds,
|
relatedPageIds: this.relatedPageIds,
|
||||||
illustrationImageIds: this.illustrationImageIds,
|
illustrationImageIds: this.illustrationImageIds,
|
||||||
|
mapImageIds: this.mapImageIds,
|
||||||
branches: this.branches
|
branches: this.branches
|
||||||
}).subscribe({
|
}).subscribe({
|
||||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
||||||
|
|||||||
@@ -13,9 +13,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Illustrations -->
|
<!-- Illustrations (rendu editorial magazine) -->
|
||||||
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
|
<section class="view-section" *ngIf="(scene.illustrationImageIds?.length ?? 0) > 0">
|
||||||
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []"></app-image-gallery>
|
<app-image-gallery [imageIds]="scene.illustrationImageIds ?? []" [layout]="'EDITORIAL'"></app-image-gallery>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cartes & plans -->
|
||||||
|
<section class="view-section" *ngIf="(scene.mapImageIds?.length ?? 0) > 0">
|
||||||
|
<h2 class="view-section-title"><span class="view-section-icon">🗺️</span> Cartes & plans</h2>
|
||||||
|
<app-image-gallery [imageIds]="scene.mapImageIds ?? []" [layout]="'MAPS'"></app-image-gallery>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Description courte -->
|
<!-- Description courte -->
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
<app-image-gallery
|
<app-image-gallery
|
||||||
[imageIds]="imageValues[field.name] || []"
|
[imageIds]="imageValues[field.name] || []"
|
||||||
[editable]="true"
|
[editable]="true"
|
||||||
|
[layout]="field.layout ?? 'GALLERY'"
|
||||||
(imageIdsChange)="imageValues[field.name] = $event">
|
(imageIdsChange)="imageValues[field.name] = $event">
|
||||||
</app-image-gallery>
|
</app-image-gallery>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,7 +28,10 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="view-section" *ngIf="field.type === 'IMAGE'">
|
<section class="view-section" *ngIf="field.type === 'IMAGE'">
|
||||||
<h2 class="view-section-title">{{ field.name }}</h2>
|
<h2 class="view-section-title">{{ field.name }}</h2>
|
||||||
<app-image-gallery [imageIds]="imageIdsOf(field.name)"></app-image-gallery>
|
<app-image-gallery
|
||||||
|
[imageIds]="imageIdsOf(field.name)"
|
||||||
|
[layout]="field.layout ?? 'GALLERY'">
|
||||||
|
</app-image-gallery>
|
||||||
</section>
|
</section>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -37,7 +37,21 @@
|
|||||||
<label class="section-label">Champs du template *</label>
|
<label class="section-label">Champs du template *</label>
|
||||||
|
|
||||||
<ul class="fields-list">
|
<ul class="fields-list">
|
||||||
<li class="field-row" *ngFor="let f of fields; let i = index">
|
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
|
||||||
|
<div class="reorder-stack">
|
||||||
|
<button type="button" class="btn-icon btn-reorder"
|
||||||
|
(click)="moveField(i, -1)"
|
||||||
|
[disabled]="first"
|
||||||
|
aria-label="Monter d'un cran" title="Monter">
|
||||||
|
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-icon btn-reorder"
|
||||||
|
(click)="moveField(i, 1)"
|
||||||
|
[disabled]="last"
|
||||||
|
aria-label="Descendre d'un cran" title="Descendre">
|
||||||
|
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
||||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||||
{{ f.name }}
|
{{ f.name }}
|
||||||
@@ -49,6 +63,17 @@
|
|||||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
||||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
||||||
</button>
|
</button>
|
||||||
|
<select *ngIf="f.type === 'IMAGE'"
|
||||||
|
class="layout-select"
|
||||||
|
[ngModel]="f.layout ?? 'GALLERY'"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="setFieldLayout(i, $event)"
|
||||||
|
title="Mise en page des images">
|
||||||
|
<option value="GALLERY">Grille</option>
|
||||||
|
<option value="HERO">Heros</option>
|
||||||
|
<option value="MASONRY">Mosaique</option>
|
||||||
|
<option value="CAROUSEL">Carrousel</option>
|
||||||
|
</select>
|
||||||
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
|
<button type="button" class="btn-icon" (click)="removeField(i)" aria-label="Supprimer">
|
||||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -124,7 +124,8 @@
|
|||||||
&:hover { background: #363650; color: white; }
|
&:hover { background: #363650; color: white; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select {
|
.type-select,
|
||||||
|
.layout-select {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
border: 1px solid #2a2a3d;
|
border: 1px solid #2a2a3d;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -137,6 +138,12 @@
|
|||||||
&:focus { outline: none; border-color: #6c63ff; }
|
&:focus { outline: none; border-color: #6c63ff; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-select {
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
@@ -153,6 +160,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.add-row { margin-top: 0.5rem; }
|
&.add-row { margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.reorder-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.btn-reorder {
|
||||||
|
width: 22px;
|
||||||
|
height: 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2a2a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #6c63ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
|
import { LucideAngularModule, Plus, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService } from '../../services/layout.service';
|
import { LayoutService } from '../../services/layout.service';
|
||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { FieldType, TemplateField } from '../../services/template.model';
|
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,6 +29,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Type = Type;
|
readonly Type = Type;
|
||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
|
readonly ChevronUp = ChevronUp;
|
||||||
|
readonly ChevronDown = ChevronDown;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
@@ -75,7 +77,10 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
if (!name) return;
|
if (!name) return;
|
||||||
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
|
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
|
||||||
if (this.fields.some(f => f.name === name)) return;
|
if (this.fields.some(f => f.name === name)) return;
|
||||||
this.fields = [...this.fields, { name, type: this.newFieldType }];
|
const newField: TemplateField = this.newFieldType === 'IMAGE'
|
||||||
|
? { name, type: 'IMAGE', layout: 'GALLERY' }
|
||||||
|
: { name, type: 'TEXT' };
|
||||||
|
this.fields = [...this.fields, newField];
|
||||||
this.newFieldName = '';
|
this.newFieldName = '';
|
||||||
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
||||||
// plusieurs champs du meme type.
|
// plusieurs champs du meme type.
|
||||||
@@ -85,12 +90,33 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
|||||||
this.fields = this.fields.filter((_, i) => i !== index);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
|
/** Bascule le type d'un champ existant (TEXT <-> IMAGE). */
|
||||||
toggleFieldType(index: number): void {
|
toggleFieldType(index: number): void {
|
||||||
const field = this.fields[index];
|
const field = this.fields[index];
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
||||||
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
|
this.fields = this.fields.map((f, i) => {
|
||||||
|
if (i !== index) return f;
|
||||||
|
return nextType === 'IMAGE'
|
||||||
|
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
|
||||||
|
: { name: f.name, type: 'TEXT' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
|
|||||||
@@ -43,7 +43,21 @@
|
|||||||
<label class="section-label">Champs du template</label>
|
<label class="section-label">Champs du template</label>
|
||||||
|
|
||||||
<ul class="fields-list">
|
<ul class="fields-list">
|
||||||
<li class="field-row" *ngFor="let f of fields; let i = index">
|
<li class="field-row" *ngFor="let f of fields; let i = index; let first = first; let last = last">
|
||||||
|
<div class="reorder-stack">
|
||||||
|
<button type="button" class="btn-icon-ghost btn-reorder"
|
||||||
|
(click)="moveField(i, -1)"
|
||||||
|
[disabled]="first"
|
||||||
|
aria-label="Monter d'un cran" title="Monter">
|
||||||
|
<lucide-icon [img]="ChevronUp" [size]="12"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-icon-ghost btn-reorder"
|
||||||
|
(click)="moveField(i, 1)"
|
||||||
|
[disabled]="last"
|
||||||
|
aria-label="Descendre d'un cran" title="Descendre">
|
||||||
|
<lucide-icon [img]="ChevronDown" [size]="12"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
<span class="field-chip" [class.field-chip-image]="f.type === 'IMAGE'">
|
||||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||||
{{ f.name }}
|
{{ f.name }}
|
||||||
@@ -54,6 +68,17 @@
|
|||||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
||||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
||||||
</button>
|
</button>
|
||||||
|
<select *ngIf="f.type === 'IMAGE'"
|
||||||
|
class="layout-select"
|
||||||
|
[ngModel]="f.layout ?? 'GALLERY'"
|
||||||
|
[ngModelOptions]="{ standalone: true }"
|
||||||
|
(ngModelChange)="setFieldLayout(i, $event)"
|
||||||
|
title="Mise en page des images">
|
||||||
|
<option value="GALLERY">Grille</option>
|
||||||
|
<option value="HERO">Heros</option>
|
||||||
|
<option value="MASONRY">Mosaique</option>
|
||||||
|
<option value="CAROUSEL">Carrousel</option>
|
||||||
|
</select>
|
||||||
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
|
<button type="button" class="btn-icon-ghost" (click)="removeField(i)" aria-label="Supprimer">
|
||||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -125,7 +125,8 @@
|
|||||||
&:hover { color: #a5b4fc; background: #1f1b3a; }
|
&:hover { color: #a5b4fc; background: #1f1b3a; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.type-select {
|
.type-select,
|
||||||
|
.layout-select {
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
border: 1px solid #2a2a3d;
|
border: 1px solid #2a2a3d;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -138,6 +139,12 @@
|
|||||||
&:focus { outline: none; border-color: #6c63ff; }
|
&:focus { outline: none; border-color: #6c63ff; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layout-select {
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
@@ -167,6 +174,35 @@
|
|||||||
&:focus { border: none; }
|
&:focus { border: none; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reorder-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.btn-reorder {
|
||||||
|
width: 22px;
|
||||||
|
height: 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #2a2a3d;
|
||||||
|
color: white;
|
||||||
|
border-color: #6c63ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon-ghost {
|
.btn-icon-ghost {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon } from 'lucide-angular';
|
import { LucideAngularModule, Plus, X, Trash2, Type, Image as ImageIcon, ChevronUp, ChevronDown } from 'lucide-angular';
|
||||||
import { LoreService } from '../../services/lore.service';
|
import { LoreService } from '../../services/lore.service';
|
||||||
import { TemplateService } from '../../services/template.service';
|
import { TemplateService } from '../../services/template.service';
|
||||||
import { PageService } from '../../services/page.service';
|
import { PageService } from '../../services/page.service';
|
||||||
import { LayoutService } from '../../services/layout.service';
|
import { LayoutService } from '../../services/layout.service';
|
||||||
import { PageTitleService } from '../../services/page-title.service';
|
import { PageTitleService } from '../../services/page-title.service';
|
||||||
import { LoreNode } from '../../services/lore.model';
|
import { LoreNode } from '../../services/lore.model';
|
||||||
import { FieldType, Template, TemplateField } from '../../services/template.model';
|
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
|
||||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +30,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
readonly Type = Type;
|
readonly Type = Type;
|
||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
|
readonly ChevronUp = ChevronUp;
|
||||||
|
readonly ChevronDown = ChevronDown;
|
||||||
|
|
||||||
form: FormGroup;
|
form: FormGroup;
|
||||||
loreId = '';
|
loreId = '';
|
||||||
@@ -75,10 +77,12 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
this.template = template;
|
this.template = template;
|
||||||
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
|
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
|
||||||
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
|
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
|
||||||
this.fields = (template.fields ?? []).map(f => ({
|
this.fields = (template.fields ?? []).map(f => {
|
||||||
name: f.name,
|
const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT';
|
||||||
type: f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'
|
return type === 'IMAGE'
|
||||||
}));
|
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
|
||||||
|
: { name: f.name, type };
|
||||||
|
});
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
name: template.name,
|
name: template.name,
|
||||||
description: template.description,
|
description: template.description,
|
||||||
@@ -91,7 +95,10 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
const name = this.newFieldName.trim();
|
const name = this.newFieldName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
if (this.fields.some(f => f.name === name)) return;
|
if (this.fields.some(f => f.name === name)) return;
|
||||||
this.fields = [...this.fields, { name, type: this.newFieldType }];
|
const newField: TemplateField = this.newFieldType === 'IMAGE'
|
||||||
|
? { name, type: 'IMAGE', layout: 'GALLERY' }
|
||||||
|
: { name, type: 'TEXT' };
|
||||||
|
this.fields = [...this.fields, newField];
|
||||||
this.newFieldName = '';
|
this.newFieldName = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,12 +106,33 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
|||||||
this.fields = this.fields.filter((_, i) => i !== index);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/** Bascule le type d'un champ (TEXT <-> IMAGE). */
|
/** Bascule le type d'un champ (TEXT <-> IMAGE). */
|
||||||
toggleFieldType(index: number): void {
|
toggleFieldType(index: number): void {
|
||||||
const field = this.fields[index];
|
const field = this.fields[index];
|
||||||
if (!field) return;
|
if (!field) return;
|
||||||
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
const nextType: FieldType = field.type === 'TEXT' ? 'IMAGE' : 'TEXT';
|
||||||
this.fields = this.fields.map((f, i) => i === index ? { ...f, type: nextType } : f);
|
this.fields = this.fields.map((f, i) => {
|
||||||
|
if (i !== index) return f;
|
||||||
|
return nextType === 'IMAGE'
|
||||||
|
? { name: f.name, type: 'IMAGE', layout: f.layout ?? 'GALLERY' }
|
||||||
|
: { name: f.name, type: 'TEXT' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
save(): void {
|
save(): void {
|
||||||
|
|||||||
@@ -36,8 +36,11 @@ export interface Arc {
|
|||||||
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
|
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
|
|
||||||
/** IDs des images (Shared Kernel) illustrant cet arc. */
|
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
|
||||||
illustrationImageIds?: string[];
|
illustrationImageIds?: string[];
|
||||||
|
|
||||||
|
/** IDs des images utilisees comme cartes / plans (outil de table). */
|
||||||
|
mapImageIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload pour la création d'un Arc (pas d'id)
|
// Payload pour la création d'un Arc (pas d'id)
|
||||||
@@ -55,6 +58,7 @@ export interface ArcCreate {
|
|||||||
|
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
illustrationImageIds?: string[];
|
illustrationImageIds?: string[];
|
||||||
|
mapImageIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Chapter {
|
export interface Chapter {
|
||||||
@@ -71,6 +75,7 @@ export interface Chapter {
|
|||||||
|
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
illustrationImageIds?: string[];
|
illustrationImageIds?: string[];
|
||||||
|
mapImageIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChapterCreate {
|
export interface ChapterCreate {
|
||||||
@@ -85,6 +90,7 @@ export interface ChapterCreate {
|
|||||||
|
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
illustrationImageIds?: string[];
|
illustrationImageIds?: string[];
|
||||||
|
mapImageIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,6 +122,7 @@ export interface Scene {
|
|||||||
|
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
illustrationImageIds?: string[];
|
illustrationImageIds?: string[];
|
||||||
|
mapImageIds?: string[];
|
||||||
|
|
||||||
/** Sorties narratives (graphe intra-chapitre). */
|
/** Sorties narratives (graphe intra-chapitre). */
|
||||||
branches?: SceneBranch[];
|
branches?: SceneBranch[];
|
||||||
@@ -138,5 +145,6 @@ export interface SceneCreate {
|
|||||||
|
|
||||||
relatedPageIds?: string[];
|
relatedPageIds?: string[];
|
||||||
illustrationImageIds?: string[];
|
illustrationImageIds?: string[];
|
||||||
|
mapImageIds?: string[];
|
||||||
branches?: SceneBranch[];
|
branches?: SceneBranch[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,16 @@
|
|||||||
*/
|
*/
|
||||||
export type FieldType = 'TEXT' | 'IMAGE';
|
export type FieldType = 'TEXT' | 'IMAGE';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante de rendu pour un champ IMAGE. Miroir de
|
||||||
|
* com.loremind.domain.lorecontext.ImageLayout. Ignore pour TEXT.
|
||||||
|
* - 'GALLERY' : grille de vignettes (defaut)
|
||||||
|
* - 'HERO' : premiere image en banniere, suivantes en petit
|
||||||
|
* - 'MASONRY' : mosaique hauteurs variables
|
||||||
|
* - 'CAROUSEL' : defilement horizontal
|
||||||
|
*/
|
||||||
|
export type ImageLayout = 'GALLERY' | 'HERO' | 'MASONRY' | 'CAROUSEL' | 'EDITORIAL' | 'MAPS';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Champ d'un Template : nom + type discriminant.
|
* Champ d'un Template : nom + type discriminant.
|
||||||
* Miroir de TemplateFieldDTO (backend).
|
* Miroir de TemplateFieldDTO (backend).
|
||||||
@@ -14,6 +24,8 @@ export type FieldType = 'TEXT' | 'IMAGE';
|
|||||||
export interface TemplateField {
|
export interface TemplateField {
|
||||||
name: string;
|
name: string;
|
||||||
type: FieldType;
|
type: FieldType;
|
||||||
|
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
||||||
|
layout?: ImageLayout | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
<!-- Grille de vignettes + uploader si editable. -->
|
<!-- Container avec classe dynamique selon le layout choisi. -->
|
||||||
<div class="gallery"
|
<div [ngSwitch]="effectiveLayout" class="gallery-root">
|
||||||
*ngIf="imageIds.length > 0 || editable; else empty">
|
|
||||||
|
|
||||||
<div class="gallery-tile"
|
<!-- =================== HERO =================== -->
|
||||||
*ngFor="let id of imageIds"
|
<ng-container *ngSwitchCase="'HERO'">
|
||||||
|
<div class="hero" *ngIf="imageIds.length > 0 || editable; else empty">
|
||||||
|
<div class="hero-main"
|
||||||
|
*ngIf="heroId"
|
||||||
|
(click)="openLightbox(heroId)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<img [src]="urlFor(heroId)" [alt]="'Illustration principale'" loading="lazy" />
|
||||||
|
<button type="button"
|
||||||
|
class="gallery-remove"
|
||||||
|
*ngIf="editable"
|
||||||
|
(click)="remove(heroId, $event)"
|
||||||
|
aria-label="Retirer cette image">
|
||||||
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-rest" *ngIf="restIds.length > 0 || editable">
|
||||||
|
<div class="gallery-tile hero-thumb"
|
||||||
|
*ngFor="let id of restIds"
|
||||||
(click)="openLightbox(id)"
|
(click)="openLightbox(id)"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0">
|
tabindex="0">
|
||||||
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||||
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="gallery-remove"
|
class="gallery-remove"
|
||||||
*ngIf="editable"
|
*ngIf="editable"
|
||||||
@@ -17,13 +34,150 @@
|
|||||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Bouton + (uploader compact), uniquement en mode edition -->
|
<!-- Si pas de hero mais editable, on montre au moins l'uploader. -->
|
||||||
<app-image-uploader
|
<div class="hero-rest" *ngIf="!heroId && editable">
|
||||||
|
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- =================== MASONRY =================== -->
|
||||||
|
<ng-container *ngSwitchCase="'MASONRY'">
|
||||||
|
<div class="masonry" *ngIf="imageIds.length > 0 || editable; else empty">
|
||||||
|
<div class="masonry-item"
|
||||||
|
*ngFor="let id of imageIds"
|
||||||
|
(click)="openLightbox(id)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||||
|
<button type="button"
|
||||||
|
class="gallery-remove"
|
||||||
*ngIf="editable"
|
*ngIf="editable"
|
||||||
[compact]="true"
|
(click)="remove(id, $event)"
|
||||||
(uploaded)="onUploaded($event)">
|
aria-label="Retirer cette image">
|
||||||
</app-image-uploader>
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="masonry-item masonry-uploader" *ngIf="editable">
|
||||||
|
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- =================== CAROUSEL =================== -->
|
||||||
|
<ng-container *ngSwitchCase="'CAROUSEL'">
|
||||||
|
<div class="carousel" *ngIf="imageIds.length > 0 || editable; else empty">
|
||||||
|
<button type="button"
|
||||||
|
class="carousel-nav carousel-prev"
|
||||||
|
*ngIf="imageIds.length > 1"
|
||||||
|
(click)="scrollCarousel(-1)"
|
||||||
|
aria-label="Precedent">
|
||||||
|
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="carousel-track" #carouselTrack>
|
||||||
|
<div class="carousel-slide"
|
||||||
|
*ngFor="let id of imageIds"
|
||||||
|
(click)="openLightbox(id)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||||
|
<button type="button"
|
||||||
|
class="gallery-remove"
|
||||||
|
*ngIf="editable"
|
||||||
|
(click)="remove(id, $event)"
|
||||||
|
aria-label="Retirer cette image">
|
||||||
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-slide carousel-uploader" *ngIf="editable">
|
||||||
|
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
class="carousel-nav carousel-next"
|
||||||
|
*ngIf="imageIds.length > 1"
|
||||||
|
(click)="scrollCarousel(1)"
|
||||||
|
aria-label="Suivant">
|
||||||
|
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- =================== EDITORIAL =================== -->
|
||||||
|
<!-- Rendu adaptatif facon magazine : 1 image → hero, 2 → diptyque, 3 → feature + 2 satellites, 4+ → feature + 3 satellites. -->
|
||||||
|
<ng-container *ngSwitchCase="'EDITORIAL'">
|
||||||
|
<div class="editorial" *ngIf="imageIds.length > 0 || editable; else empty">
|
||||||
|
<div class="editorial-item"
|
||||||
|
*ngFor="let id of imageIds; let i = index"
|
||||||
|
[class.editorial-feature]="i === 0"
|
||||||
|
(click)="openLightbox(id)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||||
|
<button type="button"
|
||||||
|
class="gallery-remove"
|
||||||
|
*ngIf="editable"
|
||||||
|
(click)="remove(id, $event)"
|
||||||
|
aria-label="Retirer cette image">
|
||||||
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="editorial-item editorial-uploader" *ngIf="editable">
|
||||||
|
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- =================== MAPS =================== -->
|
||||||
|
<!-- Cartes / plans : grandes vignettes, ratio natif preserve (pas de crop). -->
|
||||||
|
<ng-container *ngSwitchCase="'MAPS'">
|
||||||
|
<div class="maps" *ngIf="imageIds.length > 0 || editable; else empty">
|
||||||
|
<div class="map-tile"
|
||||||
|
*ngFor="let id of imageIds"
|
||||||
|
(click)="openLightbox(id)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<img [src]="urlFor(id)" [alt]="'Carte ' + id" loading="lazy" />
|
||||||
|
<button type="button"
|
||||||
|
class="gallery-remove"
|
||||||
|
*ngIf="editable"
|
||||||
|
(click)="remove(id, $event)"
|
||||||
|
aria-label="Retirer cette carte">
|
||||||
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="map-tile map-uploader" *ngIf="editable">
|
||||||
|
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- =================== GALLERY (default) =================== -->
|
||||||
|
<ng-container *ngSwitchDefault>
|
||||||
|
<div class="gallery" *ngIf="imageIds.length > 0 || editable; else empty">
|
||||||
|
<div class="gallery-tile"
|
||||||
|
*ngFor="let id of imageIds"
|
||||||
|
(click)="openLightbox(id)"
|
||||||
|
role="button"
|
||||||
|
tabindex="0">
|
||||||
|
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
|
||||||
|
<button type="button"
|
||||||
|
class="gallery-remove"
|
||||||
|
*ngIf="editable"
|
||||||
|
(click)="remove(id, $event)"
|
||||||
|
aria-label="Retirer cette image">
|
||||||
|
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<app-image-uploader *ngIf="editable" [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Etat vide (lecture uniquement). -->
|
<!-- Etat vide (lecture uniquement). -->
|
||||||
|
|||||||
@@ -1,33 +1,36 @@
|
|||||||
.gallery {
|
// =============== Common tile / remove-button ===============
|
||||||
display: flex;
|
// Partage par tous les layouts : vignette, survol, bouton X.
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.8rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gallery-tile {
|
.gallery-tile,
|
||||||
|
.masonry-item,
|
||||||
|
.hero-thumb,
|
||||||
|
.carousel-slide,
|
||||||
|
.hero-main,
|
||||||
|
.map-tile {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 120px;
|
border-radius: 8px;
|
||||||
height: 120px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
border: 1px solid #2a2a3d;
|
border: 1px solid #2a2a3d;
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
transition: border-color 0.15s, transform 0.15s;
|
transition: border-color 0.15s, transform 0.15s, box-shadow 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border-color: #6c63ff;
|
border-color: #6c63ff;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(108, 99, 255, 0.18);
|
||||||
|
|
||||||
.gallery-remove { opacity: 1; }
|
.gallery-remove { opacity: 1; }
|
||||||
|
|
||||||
|
img { transform: scale(1.04); }
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s, background 0.15s;
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
&:hover { background: #7f1d1d; color: white; }
|
&:hover { background: #7f1d1d; color: white; }
|
||||||
}
|
}
|
||||||
@@ -60,7 +64,352 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lightbox plein ecran
|
// =============== Layout: GALLERY (planche de contact) ===============
|
||||||
|
// Grille stricte de carres identiques, effet "contact sheet" photo.
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
background: #12121f;
|
||||||
|
border-radius: 6px;
|
||||||
|
max-width: 720px; // contient la grille pour ne pas etaler sur tout l'ecran
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-tile {
|
||||||
|
width: auto;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 2px; // carres vifs, presque sans radius
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Layout: HERO ===============
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-main {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 21 / 9;
|
||||||
|
max-height: 360px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
|
||||||
|
img { object-position: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-rest {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-thumb {
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Layout: MASONRY (Pinterest) ===============
|
||||||
|
// Colonnes larges, hauteurs naturelles preservees. Effet tres visible si les
|
||||||
|
// images n'ont pas toutes le meme ratio. Le border-radius genereux et les
|
||||||
|
// ombres accentuent le cote "tableau d'inspiration".
|
||||||
|
.masonry {
|
||||||
|
column-count: 3;
|
||||||
|
column-gap: 1.2rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
@media (max-width: 900px) { column-count: 2; }
|
||||||
|
@media (max-width: 500px) { column-count: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-item {
|
||||||
|
display: inline-block;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
break-inside: avoid;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45);
|
||||||
|
|
||||||
|
// Override de la transition par defaut pour un feel plus doux.
|
||||||
|
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 12px 32px rgba(108, 99, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: auto; // ratio natif preserve → hauteur variable entre les tuiles
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-uploader {
|
||||||
|
aspect-ratio: 3 / 4; // slot vertical, bien different d'une tuile simple
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 2px;
|
||||||
|
cursor: default;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:hover { transform: none; box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Layout: CAROUSEL (cinema) ===============
|
||||||
|
// Bande horizontale facon affiche de film : grandes slides 16/9, ombres
|
||||||
|
// marquees, fade sur les bords pour suggerer le defilement infini.
|
||||||
|
.carousel {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
|
||||||
|
// Fade gauche/droite pour signaler clairement "ca defile".
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 48px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
&::before { left: 48px; background: linear-gradient(to right, #0f0f1e 0%, transparent 100%); }
|
||||||
|
&::after { right: 48px; background: linear-gradient(to left, #0f0f1e 0%, transparent 100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-track {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
scroll-snap-type: x mandatory;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar { height: 6px; }
|
||||||
|
&::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #2a2a3d;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-slide {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 360px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
height: auto;
|
||||||
|
scroll-snap-align: start;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
box-shadow: 0 16px 40px rgba(108, 99, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-uploader {
|
||||||
|
width: 220px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 2px;
|
||||||
|
cursor: default;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:hover { transform: none; box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-nav {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #2a2a3d;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(26, 26, 46, 0.9);
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: white;
|
||||||
|
border-color: #6c63ff;
|
||||||
|
background: #1f1b3a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Layout: EDITORIAL (scrapbook polaroid) ===============
|
||||||
|
// Rendu carnet de campagne : vignettes facon polaroid, legerement inclinees,
|
||||||
|
// avec bande de papier collant (::before) et ombre portee. Au survol, la photo
|
||||||
|
// se redresse et se souleve. Pas de grille rigide : flex-wrap laisse respirer.
|
||||||
|
.editorial {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2rem 1.25rem;
|
||||||
|
padding: 1.25rem 0.5rem 0.5rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
// Fond kraft/parchemin tres discret pour suggerer le carnet.
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 20% 20%, rgba(180, 150, 100, 0.05), transparent 60%),
|
||||||
|
radial-gradient(ellipse at 80% 70%, rgba(160, 120, 80, 0.04), transparent 60%);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editorial-item {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 220px;
|
||||||
|
max-width: 100%;
|
||||||
|
background: #f5efe0; // papier blanc casse
|
||||||
|
padding: 10px 10px 34px 10px; // bas = bande blanche facon polaroid
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: zoom-in;
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.35),
|
||||||
|
0 14px 28px rgba(0, 0, 0, 0.45);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.3, 1),
|
||||||
|
box-shadow 0.3s ease;
|
||||||
|
|
||||||
|
// Rotations pseudo-aleatoires pour casser l'effet grille.
|
||||||
|
transform: rotate(-2deg);
|
||||||
|
&:nth-child(2n) { transform: rotate(1.8deg); }
|
||||||
|
&:nth-child(3n) { transform: rotate(-1.2deg); }
|
||||||
|
&:nth-child(4n) { transform: rotate(2.5deg); }
|
||||||
|
&:nth-child(5n) { transform: rotate(-2.8deg); }
|
||||||
|
&:nth-child(7n) { transform: rotate(0.9deg); }
|
||||||
|
|
||||||
|
// Ruban adhesif en haut de la photo.
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -9px;
|
||||||
|
left: 50%;
|
||||||
|
width: 68px;
|
||||||
|
height: 18px;
|
||||||
|
transform: translateX(-50%) rotate(-4deg);
|
||||||
|
background: rgba(255, 238, 200, 0.55);
|
||||||
|
border-left: 1px dashed rgba(0, 0, 0, 0.08);
|
||||||
|
border-right: 1px dashed rgba(0, 0, 0, 0.08);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
&:nth-child(2n)::before { transform: translateX(-50%) rotate(3deg); }
|
||||||
|
&:nth-child(3n)::before { transform: translateX(-50%) rotate(-7deg); left: 58%; }
|
||||||
|
&:nth-child(4n)::before { transform: translateX(-50%) rotate(5deg); left: 42%; }
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: rotate(0deg) scale(1.05) translateY(-4px);
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 8px rgba(0, 0, 0, 0.5),
|
||||||
|
0 24px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
.gallery-remove { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// La premiere image (feature) est plus grande et en ratio 4/3 pour jouer le role d'affiche.
|
||||||
|
.editorial-feature {
|
||||||
|
flex: 0 0 420px;
|
||||||
|
|
||||||
|
img { aspect-ratio: 4 / 3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bouton X : sur polaroid blanc, on renforce le contraste.
|
||||||
|
.editorial-item .gallery-remove {
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
background: rgba(17, 17, 30, 0.92);
|
||||||
|
color: #fecaca;
|
||||||
|
|
||||||
|
&:hover { background: #7f1d1d; color: white; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uploader : meme cadre polaroid mais en "coller une photo ici" dashed.
|
||||||
|
.editorial-uploader {
|
||||||
|
background: rgba(245, 239, 224, 0.06);
|
||||||
|
border: 2px dashed rgba(108, 99, 255, 0.7);
|
||||||
|
padding: 0;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
&::before { display: none; } // pas de scotch sur le slot vide
|
||||||
|
&:hover {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(0, 0, 0, 0.35),
|
||||||
|
0 14px 28px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'uploader interne doit remplir le slot.
|
||||||
|
app-image-uploader { display: block; width: 100%; height: 100%; min-height: 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive : on reduit la taille et on supprime les rotations sur mobile.
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.editorial-item { flex: 0 0 calc(50% - 0.75rem); }
|
||||||
|
.editorial-feature { flex: 0 0 calc(100% - 0.5rem); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.editorial { gap: 1.25rem 0.75rem; }
|
||||||
|
.editorial-item,
|
||||||
|
.editorial-feature {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
transform: rotate(0deg) !important;
|
||||||
|
&::before { display: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Layout: MAPS ===============
|
||||||
|
// Plans et cartes : on ne CROP pas (une carte croppee ne sert a rien).
|
||||||
|
// Grandes vignettes, ratio natif preserve via object-fit: contain.
|
||||||
|
.maps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-tile {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, #15152440 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, #15152440 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, #15152440 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, #15152440 75%),
|
||||||
|
#1a1a2e;
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
object-fit: contain; // Preserve le ratio natif, ajoute un padding visuel via le fond.
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-uploader {
|
||||||
|
border-style: dashed;
|
||||||
|
cursor: default;
|
||||||
|
background: #1a1a2e;
|
||||||
|
|
||||||
|
&:hover { transform: none; box-shadow: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============== Lightbox (inchange) ===============
|
||||||
.lightbox-backdrop {
|
.lightbox-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { LucideAngularModule, X, Image as ImageIcon } from 'lucide-angular';
|
import { LucideAngularModule, X, Image as ImageIcon, ChevronLeft, ChevronRight } from 'lucide-angular';
|
||||||
import { ImageService } from '../../services/image.service';
|
import { ImageService } from '../../services/image.service';
|
||||||
import { Image } from '../../services/image.model';
|
import { Image } from '../../services/image.model';
|
||||||
|
import { ImageLayout } from '../../services/template.model';
|
||||||
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
|
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +35,8 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
|
|||||||
export class ImageGalleryComponent {
|
export class ImageGalleryComponent {
|
||||||
readonly X = X;
|
readonly X = X;
|
||||||
readonly ImageIcon = ImageIcon;
|
readonly ImageIcon = ImageIcon;
|
||||||
|
readonly ChevronLeft = ChevronLeft;
|
||||||
|
readonly ChevronRight = ChevronRight;
|
||||||
|
|
||||||
/** IDs d'images a afficher. */
|
/** IDs d'images a afficher. */
|
||||||
@Input() imageIds: string[] = [];
|
@Input() imageIds: string[] = [];
|
||||||
@@ -41,14 +44,45 @@ export class ImageGalleryComponent {
|
|||||||
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
|
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
|
||||||
@Input() editable = false;
|
@Input() editable = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variante de mise en page. Null/undefined = GALLERY (rendu historique).
|
||||||
|
* HERO : premiere image en banniere pleine largeur, suivantes en petit dessous.
|
||||||
|
* MASONRY : mosaique a hauteurs variables.
|
||||||
|
* CAROUSEL : defilement horizontal avec fleches.
|
||||||
|
*/
|
||||||
|
@Input() layout: ImageLayout | null | undefined = 'GALLERY';
|
||||||
|
|
||||||
/** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */
|
/** Emet la nouvelle liste quand l'utilisateur ajoute/retire une image. */
|
||||||
@Output() imageIdsChange = new EventEmitter<string[]>();
|
@Output() imageIdsChange = new EventEmitter<string[]>();
|
||||||
|
|
||||||
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */
|
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */
|
||||||
lightboxId: string | null = null;
|
lightboxId: string | null = null;
|
||||||
|
|
||||||
|
@ViewChild('carouselTrack') carouselTrack?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
constructor(private imageService: ImageService) {}
|
constructor(private imageService: ImageService) {}
|
||||||
|
|
||||||
|
/** Layout effectif (null → GALLERY). */
|
||||||
|
get effectiveLayout(): ImageLayout {
|
||||||
|
return this.layout ?? 'GALLERY';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Premiere image (pour le layout HERO). */
|
||||||
|
get heroId(): string | null {
|
||||||
|
return this.imageIds[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Images restantes apres la hero (pour le layout HERO). */
|
||||||
|
get restIds(): string[] {
|
||||||
|
return this.imageIds.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollCarousel(direction: -1 | 1): void {
|
||||||
|
const el = this.carouselTrack?.nativeElement;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollBy({ left: direction * Math.max(240, el.clientWidth * 0.8), behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
/** URL absolue du binaire d'une image. */
|
/** URL absolue du binaire d'une image. */
|
||||||
urlFor(id: string): string {
|
urlFor(id: string): string {
|
||||||
return this.imageService.contentUrl(id);
|
return this.imageService.contentUrl(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user