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:
@@ -18,15 +18,28 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Illustrations (galerie editable) -->
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>Illustrations</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</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 class="field">
|
||||
|
||||
@@ -60,6 +60,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
/** IDs des images illustrant cet arc (bind sur app-image-gallery editable). */
|
||||
illustrationImageIds: string[] = [];
|
||||
/** IDs des images utilisees comme cartes / plans (outil de table). */
|
||||
mapImageIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
@@ -119,6 +121,7 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(arc.relatedPageIds ?? [])];
|
||||
this.illustrationImageIds = [...(arc.illustrationImageIds ?? [])];
|
||||
this.mapImageIds = [...(arc.mapImageIds ?? [])];
|
||||
this.pageTitleService.set(arc.name);
|
||||
this.form.patchValue({
|
||||
name: arc.name,
|
||||
@@ -161,7 +164,8 @@ export class ArcEditComponent implements OnInit, OnDestroy {
|
||||
rewards: this.form.value.rewards,
|
||||
resolution: this.form.value.resolution,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
mapImageIds: this.mapImageIds
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde')
|
||||
|
||||
@@ -13,9 +13,15 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Illustrations en tete de page (si presentes) -->
|
||||
<!-- Illustrations (rendu editorial magazine) -->
|
||||
<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 class="view-section">
|
||||
|
||||
@@ -18,15 +18,28 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Illustrations (galerie editable) -->
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>Illustrations</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</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 class="field">
|
||||
|
||||
@@ -54,6 +54,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
loreId: string | null = null;
|
||||
relatedPageIds: string[] = [];
|
||||
illustrationImageIds: string[] = [];
|
||||
mapImageIds: string[] = [];
|
||||
|
||||
constructor(
|
||||
private fb: FormBuilder,
|
||||
@@ -111,6 +112,7 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(chapter.relatedPageIds ?? [])];
|
||||
this.illustrationImageIds = [...(chapter.illustrationImageIds ?? [])];
|
||||
this.mapImageIds = [...(chapter.mapImageIds ?? [])];
|
||||
this.form.patchValue({
|
||||
name: chapter.name,
|
||||
description: chapter.description ?? '',
|
||||
@@ -148,7 +150,8 @@ export class ChapterEditComponent implements OnInit, OnDestroy {
|
||||
playerObjectives: this.form.value.playerObjectives,
|
||||
narrativeStakes: this.form.value.narrativeStakes,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
mapImageIds: this.mapImageIds
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId]),
|
||||
error: () => console.error('Erreur lors de la sauvegarde')
|
||||
|
||||
@@ -18,9 +18,15 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Illustrations -->
|
||||
<!-- Illustrations (rendu editorial magazine) -->
|
||||
<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 class="view-section">
|
||||
|
||||
@@ -18,15 +18,28 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Illustrations (galerie editable) -->
|
||||
<!-- Illustrations (galerie editable, rendu editorial) -->
|
||||
<div class="field">
|
||||
<label>Illustrations</label>
|
||||
<app-image-gallery
|
||||
[imageIds]="illustrationImageIds"
|
||||
[editable]="true"
|
||||
[layout]="'EDITORIAL'"
|
||||
(imageIdsChange)="illustrationImageIds = $event">
|
||||
</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 class="field">
|
||||
|
||||
@@ -53,6 +53,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
loreId: string | null = null;
|
||||
relatedPageIds: string[] = [];
|
||||
illustrationImageIds: string[] = [];
|
||||
mapImageIds: string[] = [];
|
||||
|
||||
/** Scènes du chapitre courant (hors scène éditée) — alimente le dropdown des cibles. */
|
||||
siblingScenes: Scene[] = [];
|
||||
@@ -129,6 +130,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
this.availablePages = pages;
|
||||
this.relatedPageIds = [...(scene.relatedPageIds ?? [])];
|
||||
this.illustrationImageIds = [...(scene.illustrationImageIds ?? [])];
|
||||
this.mapImageIds = [...(scene.mapImageIds ?? [])];
|
||||
this.siblingScenes = chapterScenes.filter(s => s.id !== this.sceneId);
|
||||
this.branches = (scene.branches ?? []).map(b => ({ ...b }));
|
||||
this.form.patchValue({
|
||||
@@ -179,6 +181,7 @@ export class SceneEditComponent implements OnInit, OnDestroy {
|
||||
enemies: this.form.value.enemies,
|
||||
relatedPageIds: this.relatedPageIds,
|
||||
illustrationImageIds: this.illustrationImageIds,
|
||||
mapImageIds: this.mapImageIds,
|
||||
branches: this.branches
|
||||
}).subscribe({
|
||||
next: () => this.router.navigate(['/campaigns', this.campaignId, 'arcs', this.arcId, 'chapters', this.chapterId, 'scenes', this.sceneId]),
|
||||
|
||||
@@ -13,9 +13,15 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Illustrations -->
|
||||
<!-- Illustrations (rendu editorial magazine) -->
|
||||
<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>
|
||||
|
||||
<!-- Description courte -->
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
<app-image-gallery
|
||||
[imageIds]="imageValues[field.name] || []"
|
||||
[editable]="true"
|
||||
[layout]="field.layout ?? 'GALLERY'"
|
||||
(imageIdsChange)="imageValues[field.name] = $event">
|
||||
</app-image-gallery>
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
</section>
|
||||
<section class="view-section" *ngIf="field.type === 'IMAGE'">
|
||||
<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>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
@@ -37,7 +37,21 @@
|
||||
<label class="section-label">Champs du template *</label>
|
||||
|
||||
<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'">
|
||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||
{{ f.name }}
|
||||
@@ -49,6 +63,17 @@
|
||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
||||
</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">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
|
||||
@@ -124,7 +124,8 @@
|
||||
&:hover { background: #363650; color: white; }
|
||||
}
|
||||
|
||||
.type-select {
|
||||
.type-select,
|
||||
.layout-select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
@@ -137,6 +138,12 @@
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
.layout-select {
|
||||
height: 28px;
|
||||
font-size: 0.72rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
@@ -153,6 +160,35 @@
|
||||
}
|
||||
|
||||
&.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 {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Component, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
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 { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { FieldType, TemplateField } from '../../services/template.model';
|
||||
import { FieldType, ImageLayout, TemplateField } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
@@ -29,6 +29,8 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Type = Type;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
@@ -75,7 +77,10 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
if (!name) return;
|
||||
// Unicite par nom (on ignore le type pour eviter des collisions d'affichage).
|
||||
if (this.fields.some(f => f.name === name)) return;
|
||||
this.fields = [...this.fields, { 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 = '';
|
||||
// Le type reste sur la derniere valeur choisie : pratique pour enchainer
|
||||
// plusieurs champs du meme type.
|
||||
@@ -85,12 +90,33 @@ export class TemplateCreateComponent implements OnInit, OnDestroy {
|
||||
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). */
|
||||
toggleFieldType(index: number): void {
|
||||
const field = this.fields[index];
|
||||
if (!field) return;
|
||||
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 {
|
||||
|
||||
@@ -43,7 +43,21 @@
|
||||
<label class="section-label">Champs du template</label>
|
||||
|
||||
<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'">
|
||||
<lucide-icon [img]="f.type === 'IMAGE' ? ImageIcon : Type" [size]="12"></lucide-icon>
|
||||
{{ f.name }}
|
||||
@@ -54,6 +68,17 @@
|
||||
[title]="f.type === 'TEXT' ? 'Transformer en champ Image' : 'Transformer en champ Texte'">
|
||||
{{ f.type === 'TEXT' ? 'Texte' : 'Image' }}
|
||||
</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">
|
||||
<lucide-icon [img]="X" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
|
||||
@@ -125,7 +125,8 @@
|
||||
&:hover { color: #a5b4fc; background: #1f1b3a; }
|
||||
}
|
||||
|
||||
.type-select {
|
||||
.type-select,
|
||||
.layout-select {
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
@@ -138,6 +139,12 @@
|
||||
&:focus { outline: none; border-color: #6c63ff; }
|
||||
}
|
||||
|
||||
.layout-select {
|
||||
height: 28px;
|
||||
font-size: 0.72rem;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
@@ -167,6 +174,35 @@
|
||||
&: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 {
|
||||
|
||||
@@ -3,14 +3,14 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
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 { TemplateService } from '../../services/template.service';
|
||||
import { PageService } from '../../services/page.service';
|
||||
import { LayoutService } from '../../services/layout.service';
|
||||
import { PageTitleService } from '../../services/page-title.service';
|
||||
import { LoreNode } from '../../services/lore.model';
|
||||
import { FieldType, Template, TemplateField } from '../../services/template.model';
|
||||
import { FieldType, ImageLayout, Template, TemplateField } from '../../services/template.model';
|
||||
import { loadLoreSidebarData, buildLoreSidebarConfig } from '../lore-sidebar.helper';
|
||||
|
||||
/**
|
||||
@@ -30,6 +30,8 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Type = Type;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
readonly ChevronUp = ChevronUp;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
|
||||
form: FormGroup;
|
||||
loreId = '';
|
||||
@@ -75,10 +77,12 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
this.template = template;
|
||||
// Copie defensive + normalisation du type (defaut TEXT si inconnu/manquant,
|
||||
// utile pour les templates legacy cote frontend meme si le backend le fait aussi).
|
||||
this.fields = (template.fields ?? []).map(f => ({
|
||||
name: f.name,
|
||||
type: f.type === 'IMAGE' ? 'IMAGE' : 'TEXT'
|
||||
}));
|
||||
this.fields = (template.fields ?? []).map(f => {
|
||||
const type: FieldType = f.type === 'IMAGE' ? 'IMAGE' : 'TEXT';
|
||||
return type === 'IMAGE'
|
||||
? { name: f.name, type, layout: f.layout ?? 'GALLERY' }
|
||||
: { name: f.name, type };
|
||||
});
|
||||
this.form.patchValue({
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
@@ -91,7 +95,10 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
const name = this.newFieldName.trim();
|
||||
if (!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 = '';
|
||||
}
|
||||
|
||||
@@ -99,12 +106,33 @@ export class TemplateEditComponent implements OnInit, OnDestroy {
|
||||
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). */
|
||||
toggleFieldType(index: number): void {
|
||||
const field = this.fields[index];
|
||||
if (!field) return;
|
||||
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 {
|
||||
|
||||
@@ -36,8 +36,11 @@ export interface Arc {
|
||||
/** IDs des pages du Lore liées à cet arc (weak cross-context refs). */
|
||||
relatedPageIds?: string[];
|
||||
|
||||
/** IDs des images (Shared Kernel) illustrant cet arc. */
|
||||
/** IDs des images (Shared Kernel) illustrant cet arc (ambiance). */
|
||||
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)
|
||||
@@ -55,6 +58,7 @@ export interface ArcCreate {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
@@ -71,6 +75,7 @@ export interface Chapter {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
}
|
||||
|
||||
export interface ChapterCreate {
|
||||
@@ -85,6 +90,7 @@ export interface ChapterCreate {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,6 +122,7 @@ export interface Scene {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
|
||||
/** Sorties narratives (graphe intra-chapitre). */
|
||||
branches?: SceneBranch[];
|
||||
@@ -138,5 +145,6 @@ export interface SceneCreate {
|
||||
|
||||
relatedPageIds?: string[];
|
||||
illustrationImageIds?: string[];
|
||||
mapImageIds?: string[];
|
||||
branches?: SceneBranch[];
|
||||
}
|
||||
|
||||
@@ -7,6 +7,16 @@
|
||||
*/
|
||||
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.
|
||||
* Miroir de TemplateFieldDTO (backend).
|
||||
@@ -14,6 +24,8 @@ export type FieldType = 'TEXT' | 'IMAGE';
|
||||
export interface TemplateField {
|
||||
name: string;
|
||||
type: FieldType;
|
||||
/** Uniquement pour type='IMAGE'. Absent/null = 'GALLERY'. */
|
||||
layout?: ImageLayout | null;
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
|
||||
@@ -1,29 +1,183 @@
|
||||
<!-- Grille de vignettes + uploader si editable. -->
|
||||
<div class="gallery"
|
||||
*ngIf="imageIds.length > 0 || editable; else empty">
|
||||
<!-- Container avec classe dynamique selon le layout choisi. -->
|
||||
<div [ngSwitch]="effectiveLayout" class="gallery-root">
|
||||
|
||||
<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" />
|
||||
<!-- =================== HERO =================== -->
|
||||
<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>
|
||||
|
||||
<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="hero-rest" *ngIf="restIds.length > 0 || editable">
|
||||
<div class="gallery-tile hero-thumb"
|
||||
*ngFor="let id of restIds"
|
||||
(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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Etat vide (lecture uniquement). -->
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.8rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
// =============== Common tile / remove-button ===============
|
||||
// Partage par tous les layouts : vignette, survol, bouton X.
|
||||
|
||||
.gallery-tile {
|
||||
.gallery-tile,
|
||||
.masonry-item,
|
||||
.hero-thumb,
|
||||
.carousel-slide,
|
||||
.hero-main,
|
||||
.map-tile {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
cursor: zoom-in;
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: #6c63ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(108, 99, 255, 0.18);
|
||||
|
||||
.gallery-remove { opacity: 1; }
|
||||
|
||||
img { transform: scale(1.04); }
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +50,7 @@
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
z-index: 2;
|
||||
|
||||
&:hover { background: #7f1d1d; color: white; }
|
||||
}
|
||||
@@ -60,7 +64,352 @@
|
||||
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 {
|
||||
position: fixed;
|
||||
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 { 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 { Image } from '../../services/image.model';
|
||||
import { ImageLayout } from '../../services/template.model';
|
||||
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,8 @@ import { ImageUploaderComponent } from '../image-uploader/image-uploader.compone
|
||||
export class ImageGalleryComponent {
|
||||
readonly X = X;
|
||||
readonly ImageIcon = ImageIcon;
|
||||
readonly ChevronLeft = ChevronLeft;
|
||||
readonly ChevronRight = ChevronRight;
|
||||
|
||||
/** IDs d'images a afficher. */
|
||||
@Input() imageIds: string[] = [];
|
||||
@@ -41,14 +44,45 @@ export class ImageGalleryComponent {
|
||||
/** Mode edition : afficher le bouton d'ajout + les boutons de suppression. */
|
||||
@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. */
|
||||
@Output() imageIdsChange = new EventEmitter<string[]>();
|
||||
|
||||
/** ID de l'image actuellement ouverte en lightbox (null = ferme). */
|
||||
lightboxId: string | null = null;
|
||||
|
||||
@ViewChild('carouselTrack') carouselTrack?: ElementRef<HTMLDivElement>;
|
||||
|
||||
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. */
|
||||
urlFor(id: string): string {
|
||||
return this.imageService.contentUrl(id);
|
||||
|
||||
Reference in New Issue
Block a user