Plusieurs gros ajouts :
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question - Mise en place de l'import directement dans les outils dans la sidebar - Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie - Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF - Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama - Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud - version 0.11.0-bêta
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
<div class="rte-page">
|
||||
<div class="rte-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-save" (click)="save()" [disabled]="saving">
|
||||
<lucide-icon [img]="Save" [size]="14"></lucide-icon>
|
||||
{{ saving ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h1>{{ tableId ? 'Éditer la table' : 'Nouvelle table aléatoire' }}</h1>
|
||||
|
||||
<div class="error" *ngIf="errorMessage">{{ errorMessage }}</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="rt-name">Nom *</label>
|
||||
<input id="rt-name" type="text" [(ngModel)]="name" placeholder="Ex: Rencontres en forêt">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="rt-desc">Description</label>
|
||||
<textarea id="rt-desc" rows="2" [(ngModel)]="description" placeholder="À quoi sert cette table ?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="rt-formula">Formule du dé *</label>
|
||||
<input id="rt-formula" type="text" [(ngModel)]="diceFormula" placeholder="1d20, 2d6, d100…"
|
||||
[class.invalid]="diceFormula && !formulaValid">
|
||||
<small class="hint" [class.bad]="diceFormula && !formulaValid">
|
||||
{{ formulaValid ? 'Formule valide' : 'Format attendu : NdM (ex. 1d20, 2d6, d100)' }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<!-- Génération IA -->
|
||||
<div class="ai-box">
|
||||
<div class="ai-title">
|
||||
<lucide-icon [img]="Sparkles" [size]="15"></lucide-icon> Générer avec l'IA
|
||||
</div>
|
||||
<p class="ai-hint">Décris la table ; l'IA propose les entrées (revois-les avant d'enregistrer). Contextualisé avec ta campagne.</p>
|
||||
<textarea rows="2" [(ngModel)]="aiPrompt"
|
||||
placeholder="Ex: rencontres aléatoires dans une forêt hantée, ton sombre"></textarea>
|
||||
<div class="ai-actions">
|
||||
<button class="btn-ai" (click)="generateWithAI()" [disabled]="generating">
|
||||
<lucide-icon [img]="Sparkles" [size]="14"></lucide-icon>
|
||||
{{ generating ? 'Génération…' : 'Générer (' + diceFormula + ')' }}
|
||||
</button>
|
||||
<span class="ai-error" *ngIf="aiError">{{ aiError }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entries-head">
|
||||
<h2>Entrées</h2>
|
||||
<div class="entries-actions">
|
||||
<button class="btn-mini" (click)="autoRanges()" title="Répartir les plages sur la formule">
|
||||
<lucide-icon [img]="Wand2" [size]="13"></lucide-icon> Auto-plages
|
||||
</button>
|
||||
<button class="btn-mini" (click)="addEntry()">
|
||||
<lucide-icon [img]="Plus" [size]="13"></lucide-icon> Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry-row head">
|
||||
<span class="c-range">Min</span>
|
||||
<span class="c-range">Max</span>
|
||||
<span class="c-label">Résultat</span>
|
||||
<span class="c-detail">Détail</span>
|
||||
<span class="c-del"></span>
|
||||
</div>
|
||||
|
||||
<div class="entry-row" *ngFor="let e of entries; let i = index">
|
||||
<input class="c-range" type="number" [(ngModel)]="e.minRoll">
|
||||
<input class="c-range" type="number" [(ngModel)]="e.maxRoll">
|
||||
<input class="c-label" type="text" [(ngModel)]="e.label" placeholder="Résultat">
|
||||
<input class="c-detail" type="text" [(ngModel)]="e.detail" placeholder="Détail (optionnel)">
|
||||
<button class="c-del btn-del" (click)="removeEntry(i)" title="Supprimer">
|
||||
<lucide-icon [img]="Trash2" [size]="14"></lucide-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="empty-hint" *ngIf="entries.length === 0">Aucune entrée — clique « Ajouter ».</p>
|
||||
</div>
|
||||
@@ -0,0 +1,172 @@
|
||||
.rte-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.rte-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
&:hover { background: rgba(255, 255, 255, 0.09); }
|
||||
&:disabled { opacity: 0.5; cursor: default; }
|
||||
}
|
||||
.btn-save { background: #667eea; border-color: #667eea; color: #fff; }
|
||||
.btn-save:hover:not(:disabled) { background: #5568d3; }
|
||||
}
|
||||
|
||||
h1 { font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
h2 { font-size: 1rem; margin: 0; }
|
||||
|
||||
.error {
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(224, 90, 90, 0.12);
|
||||
border: 1px solid rgba(224, 90, 90, 0.4);
|
||||
color: #e88;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
label { font-size: 0.85rem; color: var(--color-text-muted, #9aa0aa); }
|
||||
|
||||
input, textarea {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
&.invalid { border-color: rgba(224, 90, 90, 0.6); }
|
||||
}
|
||||
|
||||
.hint { font-size: 0.75rem; color: var(--color-text-muted, #9aa0aa); }
|
||||
.hint.bad { color: #e88; }
|
||||
}
|
||||
|
||||
.entries-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
|
||||
.entries-actions { display: flex; gap: 0.4rem; }
|
||||
.btn-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
&:hover { background: rgba(255, 255, 255, 0.09); }
|
||||
}
|
||||
}
|
||||
|
||||
.entry-row {
|
||||
display: grid;
|
||||
grid-template-columns: 70px 70px 1.2fr 1.6fr 36px;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.4rem;
|
||||
|
||||
&.head {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted, #9aa0aa);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.4rem 0.55rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-del {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(224, 90, 90, 0.3);
|
||||
background: rgba(224, 90, 90, 0.08);
|
||||
color: #e88;
|
||||
cursor: pointer;
|
||||
&:hover { background: rgba(224, 90, 90, 0.18); }
|
||||
}
|
||||
}
|
||||
|
||||
.empty-hint { color: var(--color-text-muted, #9aa0aa); font-style: italic; }
|
||||
|
||||
.ai-box {
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(168, 130, 255, 0.08);
|
||||
border: 1px solid rgba(168, 130, 255, 0.28);
|
||||
|
||||
.ai-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-weight: 600;
|
||||
color: #c4a8ff;
|
||||
}
|
||||
.ai-hint { margin: 0.3rem 0 0.5rem; font-size: 0.8rem; color: var(--color-text-muted, #9aa0aa); }
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ai-actions { display: flex; align-items: center; gap: 0.7rem; margin-top: 0.5rem; }
|
||||
.btn-ai {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.9rem;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: #8a6dff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
&:hover:not(:disabled) { background: #7a5cf0; }
|
||||
&:disabled { opacity: 0.55; cursor: default; }
|
||||
}
|
||||
.ai-error { color: #e88; font-size: 0.82rem; }
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, Save, Plus, Trash2, Wand2, Sparkles } from 'lucide-angular';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { RandomTable, RandomTableEntry, RandomTableCreate } from '../../../services/random-table.model';
|
||||
import { DiceUtils } from '../../../shared/dice.utils';
|
||||
|
||||
/**
|
||||
* Création/édition d'une table aléatoire (formule de dé + entrées par plage).
|
||||
* Routes : /campaigns/:campaignId/random-tables/create
|
||||
* /campaigns/:campaignId/random-tables/:tableId/edit
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-random-table-edit',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, LucideAngularModule],
|
||||
templateUrl: './random-table-edit.component.html',
|
||||
styleUrls: ['./random-table-edit.component.scss']
|
||||
})
|
||||
export class RandomTableEditComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Save = Save;
|
||||
readonly Plus = Plus;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Wand2 = Wand2;
|
||||
readonly Sparkles = Sparkles;
|
||||
|
||||
campaignId: string | null = null;
|
||||
tableId: string | null = null;
|
||||
|
||||
name = '';
|
||||
description = '';
|
||||
diceFormula = '1d20';
|
||||
entries: RandomTableEntry[] = [];
|
||||
|
||||
saving = false;
|
||||
errorMessage = '';
|
||||
|
||||
// --- Génération IA ---
|
||||
aiPrompt = '';
|
||||
generating = false;
|
||||
aiError = '';
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: RandomTableService,
|
||||
private campaignSidebar: CampaignSidebarService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.tableId = params.get('tableId');
|
||||
if (this.campaignId) this.campaignSidebar.show(this.campaignId);
|
||||
|
||||
if (this.tableId) {
|
||||
this.service.getById(this.tableId).subscribe({
|
||||
next: (t: RandomTable) => {
|
||||
this.name = t.name;
|
||||
this.description = t.description ?? '';
|
||||
this.diceFormula = t.diceFormula;
|
||||
this.entries = t.entries.map(e => ({ ...e }));
|
||||
},
|
||||
error: () => this.back()
|
||||
});
|
||||
} else {
|
||||
// Démarre avec une entrée vide pour guider.
|
||||
this.addEntry();
|
||||
}
|
||||
}
|
||||
|
||||
get formulaValid(): boolean {
|
||||
return DiceUtils.parse(this.diceFormula) !== null;
|
||||
}
|
||||
|
||||
addEntry(): void {
|
||||
const range = DiceUtils.totalRange(this.diceFormula);
|
||||
const next = this.entries.length ? this.entries[this.entries.length - 1].maxRoll + 1 : (range?.min ?? 1);
|
||||
this.entries.push({ minRoll: next, maxRoll: next, label: '', detail: '' });
|
||||
}
|
||||
|
||||
removeEntry(i: number): void {
|
||||
this.entries.splice(i, 1);
|
||||
}
|
||||
|
||||
/** Répartit équitablement la plage du dé sur toutes les entrées. */
|
||||
autoRanges(): void {
|
||||
const range = DiceUtils.totalRange(this.diceFormula);
|
||||
if (!range || this.entries.length === 0) return;
|
||||
const span = range.max - range.min + 1;
|
||||
const n = this.entries.length;
|
||||
const base = Math.floor(span / n);
|
||||
let cursor = range.min;
|
||||
this.entries.forEach((e, idx) => {
|
||||
const size = idx === n - 1 ? (range.max - cursor + 1) : Math.max(1, base);
|
||||
e.minRoll = cursor;
|
||||
e.maxRoll = Math.min(range.max, cursor + size - 1);
|
||||
cursor = e.maxRoll + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/** Génère la table via l'IA et préremplit le formulaire (l'utilisateur révise avant d'enregistrer). */
|
||||
generateWithAI(): void {
|
||||
if (!this.campaignId) return;
|
||||
if (!this.aiPrompt.trim()) { this.aiError = 'Décris la table à générer.'; return; }
|
||||
if (!this.formulaValid) { this.aiError = 'Choisis d\'abord une formule de dé valide.'; return; }
|
||||
this.generating = true;
|
||||
this.aiError = '';
|
||||
this.service.generate(this.campaignId, this.aiPrompt.trim(), this.diceFormula.trim()).subscribe({
|
||||
next: (t) => {
|
||||
this.generating = false;
|
||||
if (t.name && !this.name.trim()) this.name = t.name;
|
||||
if (t.description) this.description = t.description;
|
||||
this.entries = (t.entries ?? []).map(e => ({ ...e }));
|
||||
},
|
||||
error: (err) => {
|
||||
this.generating = false;
|
||||
this.aiError = err?.error?.message || 'Échec de la génération IA. Réessaie ou reformule.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
if (!this.campaignId) return;
|
||||
if (!this.name.trim()) { this.errorMessage = 'Le nom est requis.'; return; }
|
||||
if (!this.formulaValid) { this.errorMessage = 'Formule de dé invalide (ex. 1d20, 2d6, d100).'; return; }
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
const cleanEntries = this.entries
|
||||
.filter(e => e.label.trim())
|
||||
.map(e => ({
|
||||
minRoll: e.minRoll,
|
||||
maxRoll: Math.max(e.minRoll, e.maxRoll),
|
||||
label: e.label.trim(),
|
||||
detail: e.detail?.trim() || undefined
|
||||
}));
|
||||
|
||||
if (this.tableId) {
|
||||
const payload: RandomTable = {
|
||||
id: this.tableId,
|
||||
name: this.name.trim(),
|
||||
description: this.description.trim() || undefined,
|
||||
diceFormula: this.diceFormula.trim(),
|
||||
campaignId: this.campaignId,
|
||||
entries: cleanEntries
|
||||
};
|
||||
this.service.update(this.tableId, payload).subscribe({
|
||||
next: () => this.goToView(this.tableId!),
|
||||
error: (e) => this.fail(e)
|
||||
});
|
||||
} else {
|
||||
const payload: RandomTableCreate = {
|
||||
name: this.name.trim(),
|
||||
description: this.description.trim() || undefined,
|
||||
diceFormula: this.diceFormula.trim(),
|
||||
campaignId: this.campaignId,
|
||||
entries: cleanEntries
|
||||
};
|
||||
this.service.create(payload).subscribe({
|
||||
next: (t) => this.goToView(t.id!),
|
||||
error: (e) => this.fail(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private goToView(id: string): void {
|
||||
this.saving = false;
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'random-tables', id]);
|
||||
}
|
||||
|
||||
private fail(err: unknown): void {
|
||||
this.saving = false;
|
||||
this.errorMessage = 'Échec de l\'enregistrement.';
|
||||
console.error('RandomTable save failed', err);
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<div class="rt-page" *ngIf="table">
|
||||
<div class="rt-toolbar">
|
||||
<button class="btn-back" (click)="back()">
|
||||
<lucide-icon [img]="ArrowLeft" [size]="14"></lucide-icon>
|
||||
Retour
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button class="btn-edit" (click)="edit()">
|
||||
<lucide-icon [img]="Edit3" [size]="14"></lucide-icon>
|
||||
Éditer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<header class="rt-header">
|
||||
<h1><lucide-icon [img]="Dices" [size]="22"></lucide-icon> {{ table.name }}</h1>
|
||||
<p class="rt-desc" *ngIf="table.description">{{ table.description }}</p>
|
||||
<span class="rt-formula">Dé : <code>{{ table.diceFormula }}</code></span>
|
||||
</header>
|
||||
|
||||
<!-- Zone de jet -->
|
||||
<section class="rt-roll">
|
||||
<button class="btn-roll" (click)="roll()">
|
||||
<lucide-icon [img]="Dices" [size]="18"></lucide-icon>
|
||||
Lancer {{ table.diceFormula }}
|
||||
</button>
|
||||
<div class="rt-result" *ngIf="lastRoll">
|
||||
<span class="rt-total">{{ lastRoll.total }}</span>
|
||||
<span class="rt-rolls" *ngIf="lastRoll.rolls.length > 1">({{ lastRoll.rolls.join(' + ') }})</span>
|
||||
<span class="rt-arrow">→</span>
|
||||
<span class="rt-matched" *ngIf="matched">{{ matched.label }}</span>
|
||||
<span class="rt-nomatch" *ngIf="!matched">Aucune entrée pour ce résultat</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rt-detail" *ngIf="matched?.detail">{{ matched?.detail }}</div>
|
||||
|
||||
<!-- Liste des entrées -->
|
||||
<section class="rt-entries">
|
||||
<div class="rt-empty" *ngIf="table.entries.length === 0">
|
||||
Aucune entrée — édite la table pour en ajouter.
|
||||
</div>
|
||||
<table *ngIf="table.entries.length > 0">
|
||||
<thead>
|
||||
<tr><th class="col-range">Jet</th><th>Résultat</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let e of table.entries" [class.matched]="isMatched(e)">
|
||||
<td class="col-range">{{ rangeLabel(e) }}</td>
|
||||
<td>
|
||||
<div class="entry-label">{{ e.label }}</div>
|
||||
<div class="entry-detail" *ngIf="e.detail">{{ e.detail }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,133 @@
|
||||
.rt-page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.rt-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.spacer { flex: 1; }
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover { background: rgba(255, 255, 255, 0.09); }
|
||||
}
|
||||
}
|
||||
|
||||
.rt-header {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.rt-desc { margin: 0 0 0.4rem; color: var(--color-text-muted, #9aa0aa); }
|
||||
.rt-formula code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.rt-roll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
background: rgba(102, 126, 234, 0.08);
|
||||
border: 1px solid rgba(102, 126, 234, 0.25);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.btn-roll {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: #5568d3; }
|
||||
}
|
||||
|
||||
.rt-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.rt-total { font-weight: 700; font-size: 1.3rem; color: #8ea2ff; }
|
||||
.rt-rolls { color: var(--color-text-muted, #9aa0aa); font-size: 0.85rem; }
|
||||
.rt-matched { font-weight: 600; }
|
||||
.rt-nomatch { color: #e0a458; font-style: italic; }
|
||||
}
|
||||
|
||||
.rt-detail {
|
||||
white-space: pre-wrap;
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-left: 3px solid #667eea;
|
||||
}
|
||||
|
||||
.rt-entries {
|
||||
.rt-empty {
|
||||
color: var(--color-text-muted, #9aa0aa);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted, #9aa0aa);
|
||||
}
|
||||
.col-range { width: 84px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
|
||||
.entry-label { font-weight: 500; }
|
||||
.entry-detail {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #9aa0aa);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
tr.matched {
|
||||
background: rgba(102, 126, 234, 0.16);
|
||||
td { border-bottom-color: rgba(102, 126, 234, 0.3); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LucideAngularModule, ArrowLeft, Edit3, Dices } from 'lucide-angular';
|
||||
import { RandomTableService } from '../../../services/random-table.service';
|
||||
import { CampaignSidebarService } from '../../../services/campaign-sidebar.service';
|
||||
import { RandomTable, RandomTableEntry } from '../../../services/random-table.model';
|
||||
import { DiceUtils, DiceRoll } from '../../../shared/dice.utils';
|
||||
|
||||
/**
|
||||
* Vue d'une table aléatoire : affiche les entrées et permet de LANCER le dé
|
||||
* (jet côté client) → surligne l'entrée tombée + montre son détail.
|
||||
* Route : /campaigns/:campaignId/random-tables/:tableId
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-random-table-view',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
templateUrl: './random-table-view.component.html',
|
||||
styleUrls: ['./random-table-view.component.scss']
|
||||
})
|
||||
export class RandomTableViewComponent implements OnInit {
|
||||
readonly ArrowLeft = ArrowLeft;
|
||||
readonly Edit3 = Edit3;
|
||||
readonly Dices = Dices;
|
||||
|
||||
campaignId: string | null = null;
|
||||
tableId: string | null = null;
|
||||
table: RandomTable | null = null;
|
||||
|
||||
lastRoll: DiceRoll | null = null;
|
||||
matched: RandomTableEntry | null = null;
|
||||
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private service: RandomTableService,
|
||||
private campaignSidebar: CampaignSidebarService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
const params = this.route.snapshot.paramMap;
|
||||
this.campaignId = params.get('campaignId');
|
||||
this.tableId = params.get('tableId');
|
||||
if (this.tableId) {
|
||||
this.service.getById(this.tableId).subscribe({
|
||||
next: t => this.table = t,
|
||||
error: () => this.back()
|
||||
});
|
||||
}
|
||||
if (this.campaignId) {
|
||||
this.campaignSidebar.show(this.campaignId);
|
||||
}
|
||||
}
|
||||
|
||||
roll(): void {
|
||||
if (!this.table) return;
|
||||
const result = DiceUtils.roll(this.table.diceFormula);
|
||||
if (!result) { this.lastRoll = null; this.matched = null; return; }
|
||||
this.lastRoll = result;
|
||||
this.matched = this.table.entries.find(
|
||||
e => result.total >= e.minRoll && result.total <= e.maxRoll
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
isMatched(entry: RandomTableEntry): boolean {
|
||||
return this.matched === entry;
|
||||
}
|
||||
|
||||
rangeLabel(entry: RandomTableEntry): string {
|
||||
return entry.minRoll === entry.maxRoll
|
||||
? String(entry.minRoll)
|
||||
: `${entry.minRoll}–${entry.maxRoll}`;
|
||||
}
|
||||
|
||||
edit(): void {
|
||||
if (this.campaignId && this.tableId) {
|
||||
this.router.navigate(['/campaigns', this.campaignId, 'random-tables', this.tableId, 'edit']);
|
||||
}
|
||||
}
|
||||
|
||||
back(): void {
|
||||
this.router.navigate(this.campaignId ? ['/campaigns', this.campaignId] : ['/campaigns']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user