Mise en ligne de la version 0.2.0
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<div class="picker">
|
||||
|
||||
<!-- Chips des pages déjà liées -->
|
||||
<div class="linked-chips" *ngIf="linkedPages.length > 0">
|
||||
<span class="chip" *ngFor="let p of linkedPages">
|
||||
<a
|
||||
class="chip-label"
|
||||
[href]="pageUrl(p.id!)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title="Ouvrir dans un nouvel onglet">
|
||||
<lucide-icon [img]="Link2" [size]="12"></lucide-icon>
|
||||
{{ p.title }}
|
||||
</a>
|
||||
<button type="button" class="chip-remove" (click)="remove(p.id!)" title="Retirer le lien">
|
||||
<lucide-icon [img]="X" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Input + dropdown de suggestions -->
|
||||
<div class="search-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher une page à lier..."
|
||||
[(ngModel)]="query"
|
||||
(focus)="dropdownOpen = true"
|
||||
(blur)="onBlur()" />
|
||||
|
||||
<ul class="suggestions" *ngIf="dropdownOpen && suggestions.length > 0">
|
||||
<li *ngFor="let p of suggestions" (mousedown)="add(p)">
|
||||
{{ p.title }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="empty-hint" *ngIf="dropdownOpen && query && suggestions.length === 0">
|
||||
Aucune page ne correspond
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.linked-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: #1e1c3a;
|
||||
color: #a5b4fc;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
overflow: hidden;
|
||||
|
||||
.chip-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover { color: #c7d2fe; }
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid #2a2a3d;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover { opacity: 1; color: #fca5a5; background: #2a2a3d; }
|
||||
}
|
||||
}
|
||||
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
color: white;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #6c63ff;
|
||||
}
|
||||
|
||||
&::placeholder { color: #6b7280; }
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
list-style: none;
|
||||
padding: 0.25rem 0;
|
||||
margin: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
|
||||
li {
|
||||
padding: 0.55rem 0.9rem;
|
||||
color: #d1d5db;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: #20203a; color: white; }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 0.55rem 0.9rem;
|
||||
margin: 0;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid #2a2a3d;
|
||||
border-radius: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { LucideAngularModule, X, Link2 } from 'lucide-angular';
|
||||
import { Page } from '../../services/page.model';
|
||||
|
||||
/**
|
||||
* Composant pour lier une entité (Page, Arc, Chapter, Scene...) à un ensemble
|
||||
* de Pages du Lore par leurs IDs.
|
||||
*
|
||||
* Prévu pour être réutilisé en Phase cross-context Campagne↔Lore (sous-tâche 4) :
|
||||
* un Arc/Chapter/Scene pourra pointer vers des Pages du Lore via ce même picker.
|
||||
*
|
||||
* Usage :
|
||||
* <app-lore-link-picker
|
||||
* [value]="relatedPageIds"
|
||||
* [availablePages]="allPages"
|
||||
* [excludePageId]="currentPageId"
|
||||
* [loreId]="loreId"
|
||||
* (valueChange)="relatedPageIds = $event"></app-lore-link-picker>
|
||||
*
|
||||
* Design :
|
||||
* - Liste des pages liées = chips cliquables (clic → navigation vers la page)
|
||||
* - Input de recherche avec dropdown de suggestions filtrées (max 8 résultats)
|
||||
* - Exclut la page courante et les pages déjà sélectionnées
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-lore-link-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './lore-link-picker.component.html',
|
||||
styleUrls: ['./lore-link-picker.component.scss']
|
||||
})
|
||||
export class LoreLinkPickerComponent {
|
||||
readonly X = X;
|
||||
readonly Link2 = Link2;
|
||||
|
||||
/** IDs des pages actuellement liées (contrôlées par le parent). */
|
||||
@Input() value: string[] = [];
|
||||
/** Référentiel de pages dans lequel on peut piocher. */
|
||||
@Input() availablePages: Page[] = [];
|
||||
/** ID de la page courante (exclue des suggestions) — optionnel. */
|
||||
@Input() excludePageId: string | null = null;
|
||||
/** ID du lore courant, utilisé pour construire les URLs des chips cliquables. */
|
||||
@Input() loreId = '';
|
||||
|
||||
@Output() valueChange = new EventEmitter<string[]>();
|
||||
|
||||
/** Texte de recherche courant. */
|
||||
query = '';
|
||||
/** true tant que l'input a le focus (pour afficher le dropdown). */
|
||||
dropdownOpen = false;
|
||||
|
||||
/** Pages actuellement liées (résolues en objets complets pour affichage). */
|
||||
get linkedPages(): Page[] {
|
||||
return this.value
|
||||
.map(id => this.availablePages.find(p => p.id === id))
|
||||
.filter((p): p is Page => !!p);
|
||||
}
|
||||
|
||||
/** Pages proposables dans le dropdown — filtrées par query, exclut current + déjà liées. */
|
||||
get suggestions(): Page[] {
|
||||
const q = this.query.trim().toLowerCase();
|
||||
return this.availablePages
|
||||
.filter(p => p.id !== this.excludePageId)
|
||||
.filter(p => !this.value.includes(p.id!))
|
||||
.filter(p => q === '' || p.title.toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
}
|
||||
|
||||
/** Ajoute une page aux liens. */
|
||||
add(page: Page): void {
|
||||
if (!page.id || this.value.includes(page.id)) return;
|
||||
this.valueChange.emit([...this.value, page.id]);
|
||||
this.query = '';
|
||||
}
|
||||
|
||||
/** Retire une page des liens. */
|
||||
remove(pageId: string): void {
|
||||
this.valueChange.emit(this.value.filter(id => id !== pageId));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL vers la page liée — utilisée par un <a target="_blank"> dans le template.
|
||||
* Ouverture en nouvel onglet : on consulte la fiche d'un PNJ/lieu sans perdre
|
||||
* le contexte de la page/scène en cours d'édition.
|
||||
*/
|
||||
pageUrl(pageId: string): string {
|
||||
return `/lore/${this.loreId}/pages/${pageId}`;
|
||||
}
|
||||
|
||||
/** Retarde la fermeture du dropdown pour laisser le temps au clic de se propager. */
|
||||
onBlur(): void {
|
||||
setTimeout(() => { this.dropdownOpen = false; }, 150);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user