2 Commits

Author SHA1 Message Date
b0fe8de708 Passage v 0.3.0
All checks were successful
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-21 16:56:57 +02:00
71449bee1b 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
2026-04-21 16:56:27 +02:00
49 changed files with 1049 additions and 94 deletions

View File

@@ -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.

View File

@@ -37,7 +37,7 @@ from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.2.0", version="0.3.0",
) )

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.2.0</version> <version>0.3.0</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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);
} }
} }

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;

View File

@@ -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")

View File

@@ -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();

View File

@@ -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();

View File

@@ -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<>())

View File

@@ -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));
} }

View File

@@ -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<>();
} }

View File

@@ -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<>();
} }

View File

@@ -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<>();
} }

View File

@@ -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);
}
} }

View File

@@ -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();
} }

View File

@@ -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();
} }

View File

@@ -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();
} }

View File

@@ -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);
} }
} }

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.2.0", "version": "0.3.0",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -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 &amp; 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">

View File

@@ -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')

View File

@@ -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">

View File

@@ -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 &amp; 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">

View File

@@ -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')

View File

@@ -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">

View File

@@ -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 &amp; 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">

View File

@@ -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]),

View File

@@ -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 -->

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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[];
} }

View File

@@ -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 {

View File

@@ -1,29 +1,183 @@
<!-- 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'">
(click)="openLightbox(id)" <div class="hero" *ngIf="imageIds.length > 0 || editable; else empty">
role="button" <div class="hero-main"
tabindex="0"> *ngIf="heroId"
<img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" /> (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>
<button type="button" <div class="hero-rest" *ngIf="restIds.length > 0 || editable">
class="gallery-remove" <div class="gallery-tile hero-thumb"
*ngIf="editable" *ngFor="let id of restIds"
(click)="remove(id, $event)" (click)="openLightbox(id)"
aria-label="Retirer cette image"> role="button"
<lucide-icon [img]="X" [size]="14"></lucide-icon> tabindex="0">
</button> <img [src]="urlFor(id)" [alt]="'Illustration ' + id" loading="lazy" />
</div> <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>
<!-- Si pas de hero mais editable, on montre au moins l'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"
(click)="remove(id, $event)"
aria-label="Retirer cette image">
<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>
<!-- Bouton + (uploader compact), uniquement en mode edition -->
<app-image-uploader
*ngIf="editable"
[compact]="true"
(uploaded)="onUploaded($event)">
</app-image-uploader>
</div> </div>
<!-- Etat vide (lecture uniquement). --> <!-- Etat vide (lecture uniquement). -->

View File

@@ -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;

View File

@@ -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);

View File

@@ -60,7 +60,7 @@
</div> </div>
<div class="sidebar-footer"> <div class="sidebar-footer">
<span class="version">Version 0.2.0</span> <span class="version">Version 0.3.0</span>
</div> </div>
</aside> </aside>