Templates (v7.71.x) : - registre unifié \ emplates\ (migrations 48-49) + TemplateService.instantiate unique (UI, API v2, agent, scheduler) - sélecteur (pilule page vide, menu •••, commande /template), gestionnaire /templates, menu New ▾, From template, base inline dans un document - 141 presets système (59 pages, 42 bases, 15 blocs, 25 lignes), titre auto depuis le template, variables title réservée - récurrences RRULE + scheduler dédupliqué, agent apply_template/list_templates, API /api/templates + /api/v2/fd-templates - correctifs : bouton Templates, centrage fenêtre, filtres CSP, flux de création, variable title - tests : tests/test_fd_templates.py (19) et e2e/templates_picker.spec.js (8) Inclut le travail déjà présent dans le working tree (vues Notion : view_query/view_aggregate/form_projection/geocoding, property_types, database_table, docs agents-skills) et ignore .playwright-mcp/.
369 lines
23 KiB
HTML
369 lines
23 KiB
HTML
{% extends "base.html" %}
|
|
{% block page_icon %}{{ fd_icon("file",18) }}{% endblock %}
|
|
{% block page_title %}Templates{% endblock %}
|
|
{% block title_prefix %}Templates{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-title-area">
|
|
<div class="page-title">
|
|
<span class="page-icon-lg">{{ fd_icon("file",24) }}</span>
|
|
<h1>Templates</h1>
|
|
</div>
|
|
<p style="color:var(--text-dim);font-size:13px;margin:4px 0 0;">
|
|
Modèles réutilisables : pages, lignes de base, bases entières, blocs.
|
|
<span x-show="loading">Chargement…</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div x-data="fdTemplatesManager()" x-init="init()" class="lib-container">
|
|
<!-- Toolbar -->
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
|
<div style="position:relative;flex:1;min-width:180px;">
|
|
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
|
<input type="text" placeholder="Rechercher un template…" x-model="search" @input.debounce.300ms="reload()"
|
|
style="width:100%;padding:8px 12px 8px 36px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box;">
|
|
</div>
|
|
<select x-model="kindFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
|
<option value="">Tous genres</option>
|
|
<option value="page">Pages</option>
|
|
<option value="row">Lignes</option>
|
|
<option value="database">Bases</option>
|
|
<option value="blocks">Blocs</option>
|
|
</select>
|
|
<select x-model="scopeFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
|
<option value="">Toutes portées</option>
|
|
<option value="personal">Personnel</option>
|
|
<option value="workspace">Workspace</option>
|
|
<option value="teamspace">Teamspace</option>
|
|
<option value="system">Système</option>
|
|
</select>
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-dim);">
|
|
<input type="checkbox" x-model="showArchived" @change="reload()" style="accent-color:var(--accent);"> Archivés
|
|
</label>
|
|
<button class="btn-sm" @click="createOpen = !createOpen" style="padding:8px 14px;">+ Nouveau ▾</button>
|
|
</div>
|
|
|
|
<!-- Panneau création -->
|
|
<div x-show="createOpen" @click.outside="createOpen = false" x-transition
|
|
style="background:var(--bg-secondary,#232323);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:12px;">
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:640px;">
|
|
<input x-model="form.name" placeholder="Nom du template"
|
|
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
|
<select x-model="form.kind" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
|
<option value="page">Template de page</option>
|
|
<option value="row">Template de ligne</option>
|
|
<option value="database">Template de base</option>
|
|
<option value="blocks">Template de blocs</option>
|
|
</select>
|
|
<input x-model="form.target_collection_id" x-show="form.kind === 'row'" placeholder="ID de la base cible"
|
|
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
|
<input x-model="form.from_page_id" placeholder="Depuis la page n°… (optionnel)"
|
|
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
|
</div>
|
|
<div x-show="formError" x-text="formError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
|
|
<div style="display:flex;gap:8px;margin-top:10px;">
|
|
<button class="btn-sm" @click="create()" style="padding:8px 16px;">Créer</button>
|
|
<button class="btn-sm" @click="createOpen = false" style="padding:8px 16px;">Annuler</button>
|
|
<label style="margin-left:auto;font-size:13px;color:var(--text-dim);cursor:pointer;">
|
|
Importer un .json <input type="file" accept=".json,application/json" @change="importFile($event)" style="display:none;">
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Erreur globale -->
|
|
<div x-show="error" style="background:rgba(229,72,77,.12);border:1px solid #e5484d;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:13px;">
|
|
<span x-text="error"></span>
|
|
<button @click="reload()" style="margin-left:8px;">Réessayer</button>
|
|
</div>
|
|
|
|
<!-- Liste -->
|
|
<div x-show="!loading && !items.length" style="color:var(--text-dim);font-size:13px;padding:24px 0;text-align:center;">
|
|
Aucun template. Créez-en un avec « + Nouveau », ou dupliquez un preset système.
|
|
</div>
|
|
<div style="display:flex;flex-direction:column;gap:8px;">
|
|
<template x-for="t in items" :key="t.id">
|
|
<div @click="select(t)" :class="{selected: selected && selected.id === t.id}"
|
|
style="display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;cursor:pointer;background:var(--bg-secondary,#232323);">
|
|
<span style="font-size:20px;" x-text="t.icon || '📄'"></span>
|
|
<div style="flex:1;min-width:0;">
|
|
<div style="font-weight:600;font-size:14px;color:var(--text,#fff);display:flex;align-items:center;gap:8px;">
|
|
<span x-text="t.name"></span>
|
|
<span x-show="t.is_system" style="font-size:11px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;color:var(--text-dim);">système</span>
|
|
<span x-show="t.is_default" style="font-size:11px;color:#f5a524;">★ défaut</span>
|
|
<span x-show="t.recurrence && t.recurrence.enabled" style="font-size:11px;color:var(--text-dim);">↻ <span x-text="t.recurrence.rrule"></span></span>
|
|
</div>
|
|
<div style="font-size:12px;color:var(--text-dim);" x-text="(kindLabel(t.kind) + ' · ' + t.scope + (t.usage_count ? ' · ' + t.usage_count + ' utilisations' : ''))"></div>
|
|
</div>
|
|
<button class="btn-sm" @click.stop="useTemplate(t)" style="padding:6px 12px;">Utiliser</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Side peek : détail + réglages -->
|
|
<div x-show="selected" x-transition
|
|
style="position:fixed;top:0;right:0;bottom:0;width:min(480px,94vw);background:var(--bg-secondary,#232323);border-left:1px solid var(--border);z-index:1200;padding:18px;overflow-y:auto;box-shadow:-16px 0 50px rgba(0,0,0,.5);">
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
|
<span style="font-size:24px;" x-text="selected.icon"></span>
|
|
<input x-model="selected.name" @change="saveMeta()" :disabled="selected.is_system"
|
|
style="flex:1;font-size:17px;font-weight:700;background:transparent;border:none;color:var(--text,#fff);outline:none;">
|
|
<button @click="selected = null" style="background:none;border:none;color:var(--text-dim);font-size:18px;cursor:pointer;">✕</button>
|
|
</div>
|
|
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;" x-text="(kindLabel(selected.kind) + ' · portée ' + selected.scope)"></div>
|
|
<textarea x-model="selected.description" @change="saveMeta()" :disabled="selected.is_system" rows="2" placeholder="Description…"
|
|
style="width:100%;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;box-sizing:border-box;"></textarea>
|
|
|
|
<div x-show="detailError" x-text="detailError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
|
|
|
|
<h4 style="margin:16px 0 6px;font-size:13px;">Aperçu</h4>
|
|
<div style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:13px;">
|
|
<div style="font-weight:600;margin-bottom:6px;" x-text="detail.title || selected.name"></div>
|
|
<template x-for="b in (detail.blocks_preview || []).slice(0,6)" :key="b.id">
|
|
<div style="color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
|
|
</template>
|
|
<div x-show="!(detail.blocks_preview || []).length" style="color:var(--text-dim);">Aucun contenu.</div>
|
|
<div x-show="(detail.warnings || []).length" style="color:#f5a524;font-size:12px;margin-top:6px;" x-text="detail.warnings.join(' · ')"></div>
|
|
</div>
|
|
|
|
<h4 style="margin:16px 0 6px;font-size:13px;">Variables déclarées (JSON)</h4>
|
|
<textarea x-model="variablesText" rows="4" :disabled="selected.is_system"
|
|
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
|
|
<div style="display:flex;gap:8px;margin-top:6px;">
|
|
<button class="btn-sm" @click="saveVariables()" x-show="!selected.is_system">Enregistrer</button>
|
|
</div>
|
|
|
|
<div x-show="selected.kind === 'row'">
|
|
<h4 style="margin:16px 0 6px;font-size:13px;">Propriétés par défaut (JSON, par nom)</h4>
|
|
<textarea x-model="defaultsText" rows="4" :disabled="selected.is_system"
|
|
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
|
|
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap;">
|
|
<button class="btn-sm" @click="saveDefaults()" x-show="!selected.is_system">Enregistrer</button>
|
|
<button class="btn-sm" @click="toggleDefault()" x-show="!selected.is_system" x-text="selected.is_default ? 'Retirer le défaut' : 'Définir comme défaut'"></button>
|
|
</div>
|
|
<h4 style="margin:16px 0 6px;font-size:13px;">Récurrence (RRULE)</h4>
|
|
<div style="display:flex;gap:8px;">
|
|
<input x-model="rrule" placeholder="FREQ=WEEKLY;BYDAY=MO" :disabled="selected.is_system"
|
|
style="flex:1;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;">
|
|
<button class="btn-sm" @click="saveRecurrence()" x-show="!selected.is_system">OK</button>
|
|
<button class="btn-sm" @click="clearRecurrence()" x-show="!selected.is_system && selected.recurrence">Arrêter</button>
|
|
</div>
|
|
<div x-show="selected.recurrence" style="font-size:12px;color:var(--text-dim);margin-top:4px;"
|
|
x-text="('Prochain passage : ' + (selected.recurrence.next_run_at || '—'))"></div>
|
|
<h4 style="margin:16px 0 6px;font-size:13px;">Journal récent</h4>
|
|
<div style="font-size:12px;color:var(--text-dim);">
|
|
<template x-for="r in runs" :key="r.id">
|
|
<div x-text="(r.created_at + ' · ' + r.run_source + ' · ' + r.status + (r.created_id ? ' → ' + r.created_id : ''))"></div>
|
|
</template>
|
|
<div x-show="!runs.length">Aucune instanciation journalisée.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 style="margin:16px 0 6px;font-size:13px;">Actions</h4>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
|
<button class="btn-sm" @click="useTemplate(selected)">Utiliser</button>
|
|
<button class="btn-sm" @click="duplicate(selected)" x-text="selected.is_system ? 'Dupliquer pour modifier' : 'Dupliquer'"></button>
|
|
<button class="btn-sm" @click="exportJson(selected)">Exporter</button>
|
|
<button class="btn-sm" @click="archive(selected)" x-show="!selected.is_system" x-text="selected.archived_at ? 'Restaurer' : 'Archiver'"></button>
|
|
<button class="btn-sm btn-sm-danger" @click="remove(selected)" x-show="!selected.is_system">Supprimer</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
|
if (window.Alpine) { Alpine.data('fdTemplatesManager', fdTemplatesManager); }
|
|
else document.addEventListener('alpine:init', function () { Alpine.data('fdTemplatesManager', fdTemplatesManager); });
|
|
|
|
function fdTemplatesManager() {
|
|
return {
|
|
items: [], selected: null, detail: { blocks_preview: [], warnings: [] },
|
|
runs: [], loading: true, error: '', detailError: '',
|
|
search: '', kindFilter: '', scopeFilter: '', showArchived: false,
|
|
createOpen: false, form: { name: '', kind: 'page', target_collection_id: '', from_page_id: '' },
|
|
formError: '', variablesText: '[]', defaultsText: '{}', rrule: '',
|
|
|
|
csrf() { return (window.getCsrf && window.getCsrf()) || ''; },
|
|
headers(json) {
|
|
const h = { 'X-CSRF-Token': this.csrf() };
|
|
if (json) h['Content-Type'] = 'application/json';
|
|
return h;
|
|
},
|
|
kindLabel(k) { return { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' }[k] || k; },
|
|
|
|
async init() { await this.reload(); },
|
|
async reload() {
|
|
this.loading = true; this.error = '';
|
|
try {
|
|
const p = new URLSearchParams();
|
|
if (this.kindFilter) p.set('kind', this.kindFilter);
|
|
if (this.scopeFilter) p.set('scope', this.scopeFilter);
|
|
if (this.search) p.set('q', this.search);
|
|
if (this.showArchived) p.set('include_archived', 'true');
|
|
const r = await fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const d = await r.json();
|
|
this.items = d.templates || [];
|
|
} catch (e) { this.error = 'Chargement impossible : ' + e.message; this.items = []; }
|
|
this.loading = false;
|
|
},
|
|
|
|
async select(t) {
|
|
this.selected = t; this.detailError = ''; this.runs = [];
|
|
this.variablesText = JSON.stringify(t.variables || [], null, 1);
|
|
this.defaultsText = JSON.stringify((t.manifest || {}).property_defaults || {}, null, 1);
|
|
this.rrule = (t.recurrence && t.recurrence.rrule) || '';
|
|
try {
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
const d = await r.json();
|
|
this.detail = { title: d.name, blocks_preview: d.blocks_preview || [], warnings: [] };
|
|
const pv = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/preview',
|
|
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
|
if (pv.ok) { const pd = await pv.json(); this.detail = { title: pd.title, blocks_preview: pd.blocks_preview || [], warnings: pd.warnings || [] }; }
|
|
if (t.kind === 'row') {
|
|
const rr = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/runs', { credentials: 'same-origin' });
|
|
if (rr.ok) this.runs = (await rr.json()).runs || [];
|
|
}
|
|
} catch (e) { this.detailError = 'Aperçu impossible : ' + e.message; }
|
|
},
|
|
|
|
async create() {
|
|
this.formError = '';
|
|
if (!this.form.name.trim()) { this.formError = 'Nom requis.'; return; }
|
|
if (this.form.kind === 'row' && !this.form.target_collection_id && !this.form.from_page_id) {
|
|
this.formError = 'Un template de ligne exige une base cible (ID).'; return;
|
|
}
|
|
const body = { name: this.form.name.trim(), kind: this.form.kind, scope: 'personal' };
|
|
if (this.form.target_collection_id) body.target_collection_id = parseInt(this.form.target_collection_id, 10);
|
|
if (this.form.from_page_id) body.from_page_id = parseInt(this.form.from_page_id, 10);
|
|
try {
|
|
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
|
|
if (!r.ok) { const t = await r.text(); throw new Error('HTTP ' + r.status + ' ' + t.slice(0, 200)); }
|
|
this.createOpen = false; this.form = { name: '', kind: 'page', target_collection_id: '', from_page_id: '' };
|
|
await this.reload();
|
|
window.showToast && window.showToast('Template créé');
|
|
} catch (e) { this.formError = 'Création impossible : ' + e.message; }
|
|
},
|
|
|
|
async saveMeta() {
|
|
this.detailError = '';
|
|
try {
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id),
|
|
{ method: 'PATCH', headers: this.headers(true), credentials: 'same-origin',
|
|
body: JSON.stringify({ name: this.selected.name, description: this.selected.description }) });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
Object.assign(this.selected, await r.json());
|
|
const i = this.items.findIndex(x => x.id === this.selected.id);
|
|
if (i >= 0) this.items[i] = this.selected;
|
|
} catch (e) { this.detailError = 'Enregistrement impossible : ' + e.message; }
|
|
},
|
|
async saveVariables() {
|
|
this.detailError = '';
|
|
try {
|
|
const v = JSON.parse(this.variablesText);
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/variables',
|
|
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({ variables: v }) });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
Object.assign(this.selected, await r.json());
|
|
} catch (e) { this.detailError = 'Variables invalides ou enregistrement impossible : ' + e.message; }
|
|
},
|
|
async saveDefaults() {
|
|
this.detailError = '';
|
|
try {
|
|
const v = JSON.parse(this.defaultsText);
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/property-defaults',
|
|
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(v) });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
Object.assign(this.selected, await r.json());
|
|
} catch (e) { this.detailError = 'JSON invalide ou enregistrement impossible : ' + e.message; }
|
|
},
|
|
async toggleDefault() {
|
|
const url = '/api/templates/' + encodeURIComponent(this.selected.id) +
|
|
(this.selected.is_default ? '/unset-default' : '/set-default');
|
|
const r = await fetch(url, { method: 'POST', headers: this.headers(), credentials: 'same-origin' });
|
|
if (r.ok) { Object.assign(this.selected, await r.json()); this.reload(); }
|
|
else this.detailError = 'Défaut : HTTP ' + r.status;
|
|
},
|
|
async saveRecurrence() {
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
|
|
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin',
|
|
body: JSON.stringify({ rrule: this.rrule }) });
|
|
if (r.ok) { const d = await r.json(); this.selected.recurrence = d.recurrence; }
|
|
else this.detailError = 'Récurrence : HTTP ' + r.status;
|
|
},
|
|
async clearRecurrence() {
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
|
|
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
|
if (r.ok) { this.selected.recurrence = null; this.rrule = ''; }
|
|
},
|
|
|
|
async useTemplate(t) {
|
|
try {
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate',
|
|
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
|
if (!r.ok) { const txt = await r.text(); throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 300)); }
|
|
const d = await r.json();
|
|
(d.warnings || []).forEach(w => window.showToast && window.showToast(w, 'error'));
|
|
window.showToast && window.showToast('Créé depuis « ' + t.name + ' »');
|
|
if (d.url) { window.fdNavigate ? window.fdNavigate(d.url) : (window.location = d.url); }
|
|
else this.reload();
|
|
} catch (e) { this.detailError = 'Application impossible : ' + e.message; window.showToast && window.showToast('Échec : ' + e.message, 'error'); }
|
|
},
|
|
async duplicate(t) {
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/duplicate',
|
|
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
|
if (r.ok) { await this.reload(); window.showToast && window.showToast('Template dupliqué'); }
|
|
else this.detailError = 'Duplication : HTTP ' + r.status;
|
|
},
|
|
async archive(t) {
|
|
const action = t.archived_at ? 'restore' : 'archive';
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/' + action,
|
|
{ method: 'POST', headers: this.headers(), credentials: 'same-origin' });
|
|
if (r.ok) { Object.assign(t, await r.json()); this.reload(); }
|
|
},
|
|
async remove(t) {
|
|
if (!confirm('Supprimer « ' + t.name + ' » ? (corbeille)')) return;
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(t.id),
|
|
{ method: 'DELETE', headers: this.headers(), credentials: 'same-origin' });
|
|
if (r.ok) { this.selected = null; this.reload(); }
|
|
else this.detailError = 'Suppression : HTTP ' + r.status;
|
|
},
|
|
async exportJson(t) {
|
|
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
|
|
if (!r.ok) return;
|
|
const d = await r.json();
|
|
const manifest = {
|
|
format: 'flowdeck-template', version: 1, kind: d.kind, name: d.name,
|
|
description: d.description, icon: d.icon, category: d.category,
|
|
variables: d.variables, manifest: d.manifest, content: { blocks: d.blocks_preview || [] },
|
|
};
|
|
const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' });
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob); a.download = 'template-' + d.name + '.json'; a.click();
|
|
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
},
|
|
async importFile(ev) {
|
|
const f = ev.target.files && ev.target.files[0];
|
|
if (!f) return;
|
|
try {
|
|
const text = await f.text();
|
|
const m = JSON.parse(text);
|
|
if (m.format !== 'flowdeck-template') throw new Error('format inconnu');
|
|
const body = {
|
|
kind: m.kind, name: m.name, description: m.description || '', icon: m.icon || '📄',
|
|
category: m.category || '', scope: 'personal', blocks: ((m.content || {}).blocks) || [],
|
|
manifest: m.manifest || {}, variables: m.variables || [],
|
|
target_collection_id: ((m.target || {}).collection_id) || undefined,
|
|
};
|
|
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
this.createOpen = false; await this.reload();
|
|
window.showToast && window.showToast('Template importé : ' + m.name);
|
|
} catch (e) { this.formError = 'Import impossible : ' + e.message; }
|
|
ev.target.value = '';
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|