feat(templates): refonte complète des templates façon Notion + vues/agents-skills
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/.
This commit is contained in:
@@ -510,6 +510,9 @@
|
||||
<div class="more-menu-item" @click="moreSaveTemplate()">
|
||||
📑 Save as template
|
||||
</div>
|
||||
<div class="more-menu-item" @click="openTplPicker()">
|
||||
{{ fd_icon("file",14) }} Apply template…
|
||||
</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
{{ fd_icon("trash",14) }} Move to Trash
|
||||
@@ -746,7 +749,7 @@
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="gsMoreOpen = !gsMoreOpen"
|
||||
@click="openTplPicker()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
|
||||
</button>
|
||||
@@ -797,8 +800,8 @@
|
||||
|
||||
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
|
||||
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
|
||||
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;"
|
||||
x-transition.opacity
|
||||
@click.self="closeDbTemplatePicker()">
|
||||
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
|
||||
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
|
||||
@@ -833,6 +836,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Unified template picker (registre unifié, §7.1) ═══════════
|
||||
Toujours une issue visible (P5) : liste, état vide avec création,
|
||||
ou erreur explicite avec Réessayer — jamais un no-op silencieux. -->
|
||||
<div class="fd-tpl-overlay" id="fd-tpl-picker" data-testid="tpl-picker" x-show="tplOpen"
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2200;"
|
||||
x-transition.opacity
|
||||
@click.self="closeTplPicker()">
|
||||
<!-- Enveloppe flex SÉPARÉE du x-show : Alpine restaure display:block
|
||||
sur l'overlay (pas le flex inline) — sans cette enveloppe, le
|
||||
dialogue reste collé en haut à gauche au lieu d'être centré. -->
|
||||
<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;padding:20px;box-sizing:border-box;">
|
||||
<div class="fd-tpl" style="width:760px;max-width:96vw;max-height:80vh;display:flex;flex-direction:column;
|
||||
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Appliquer un template</h3>
|
||||
<button class="flowdeck-modal-close" @click="closeTplPicker()">×</button>
|
||||
</div>
|
||||
<div style="padding:12px 20px 0;">
|
||||
<input type="text" x-model="tplQuery" @input.debounce.300ms="loadTplList()" placeholder="🔍 Rechercher un template…"
|
||||
style="width:100%;padding:9px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box;">
|
||||
<div class="fd-gtpl-filters" style="margin-top:10px;">
|
||||
<template x-for="k in tplKindOptionsList" :key="k">
|
||||
<button type="button" class="fd-tpl-filter" :class="tplKind === k ? 'active' : ''"
|
||||
@click="setTplKind(k)" x-text="tplKindLabel(k)"></button>
|
||||
</template>
|
||||
<a href="/templates" style="margin-left:auto;font-size:12px;color:var(--text-dim);align-self:center;text-decoration:none;padding-right:6px;">Gérer les templates…</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0;flex:1;min-height:0;padding:0 20px 12px;">
|
||||
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;padding-right:8px;">
|
||||
<div x-show="tplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Chargement…</div>
|
||||
<div x-show="tplError" style="color:#e5484d;font-size:13px;padding:12px;background:rgba(229,72,77,.1);border-radius:8px;margin-bottom:8px;">
|
||||
<span x-text="tplError"></span>
|
||||
<button @click="loadTplList()" style="margin-left:8px;">Réessayer</button>
|
||||
</div>
|
||||
<template x-for="t in tplItems" :key="t.id">
|
||||
<div data-testid="tpl-item" @click="selectTpl(t)" :style="tplSelected && tplSelected.id === t.id ? 'border-color:var(--accent);' : ''"
|
||||
style="display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:8px;cursor:pointer;">
|
||||
<span style="font-size:18px;" x-text="t.icon || '📄'"></span>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;color:var(--text,#fff);font-size:13px;" x-text="t.name"></div>
|
||||
<div style="font-size:11px;color:var(--text-dim,#999);" x-text="(t.kind + ' · ' + t.scope)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!tplLoading && !tplError && !tplItems.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
|
||||
Aucun template pour ce filtre — <a href="/templates">créez-en un</a> ou choisissez un preset système.
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;border-left:1px solid var(--border);padding-left:12px;" x-show="tplSelected">
|
||||
<div style="font-weight:600;color:var(--text,#fff);font-size:14px;margin-bottom:4px;" x-text="tplPreviewTitle"></div>
|
||||
<div x-show="tplPreviewWarnings.length" style="color:#f5a524;font-size:12px;margin-bottom:6px;" x-text="tplPreviewWarnings.join(' · ')"></div>
|
||||
<template x-for="b in tplPreviewBlocks.slice(0,8)" :key="b.id">
|
||||
<div style="font-size:12px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
|
||||
</template>
|
||||
<div x-show="tplVarDefs.length" style="margin-top:8px;">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--text,#fff);margin-bottom:4px;">Variables</div>
|
||||
<template x-for="v in tplVarDefs" :key="v.name">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||
<span style="font-size:12px;color:var(--text-dim);min-width:80px;" x-text="v.label || v.name"></span>
|
||||
<input type="text" :placeholder="v.default || ''" @input="setTplVar(v.name, $event.target.value)"
|
||||
style="flex:1;padding:5px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:12px;">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 20px;border-top:1px solid var(--border);">
|
||||
<button class="flowdeck-modal-close" @click="closeTplPicker()" style="padding:8px 16px;position:static;">Annuler</button>
|
||||
<button data-testid="tpl-apply" @click="applyTpl()" :disabled="!tplSelected || tplApplying"
|
||||
style="padding:8px 20px;border-radius:8px;border:none;background:var(--accent);color:#fff;font-weight:600;cursor:pointer;opacity:.95;"
|
||||
x-text="tplApplying ? 'Application…' : 'Utiliser'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
|
||||
<div class="versions-popover"
|
||||
x-show="versionsOpen"
|
||||
|
||||
+183
-88
@@ -608,6 +608,9 @@
|
||||
<a href="/library" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("book",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Library</span>
|
||||
</a>
|
||||
<a href="/templates" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("file",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">Templates</span>
|
||||
</a>
|
||||
<a href="/my-tasks" class="sidebar-item" style="text-decoration:none;color:inherit;display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;cursor:pointer;transition:background 150ms;">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("check-square",16) }}</span><span class="page-name" style="font-size:14px;display:flex;align-items:center;gap:4px;">My Tasks</span>
|
||||
</a>
|
||||
@@ -758,6 +761,9 @@
|
||||
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('page')">
|
||||
<span>{{ fd_icon("file",16) }}</span><span>Page</span>
|
||||
</button>
|
||||
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('template')">
|
||||
<span>{{ fd_icon("copy",16) }}</span><span>From template…</span>
|
||||
</button>
|
||||
<button type="button" class="fd-new-pop-item" role="menuitem" @click="newMenuAction('chat')">
|
||||
<span>{{ fd_icon("message-square",16) }}</span><span>Chat</span>
|
||||
</button>
|
||||
@@ -1165,105 +1171,189 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new page: opens the v5.12.0 template picker (built-in + the
|
||||
* user's saved templates), then instantiates the chosen one.
|
||||
* Create a new page: **blank by default** (Notion behaviour — the "New"
|
||||
* button creates an empty document immediately). Template-based creation
|
||||
* lives in {@link createPageFromTemplate}.
|
||||
*/
|
||||
createPage: function(parentId) {
|
||||
var self = this;
|
||||
// Explicit folder (row/context-menu button) wins; otherwise create in the
|
||||
// folder the user is currently browsing on the local-workspace page.
|
||||
if (parentId == null || parentId === '') parentId = this._currentFolderId();
|
||||
this._createParentId = parentId || 0;
|
||||
// Direct creation path (fallback / no-template choice).
|
||||
this._createPlainPage = function() {
|
||||
var ctx = self._getContext();
|
||||
if (ctx.wsId > 0) {
|
||||
var body = { name: '', type: 'page' };
|
||||
if (ctx.isGitea) body.workspace_id = ctx.wsId;
|
||||
if (self._createParentId) body.parent_id = self._createParentId;
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
||||
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
|
||||
this._createPlainPage();
|
||||
},
|
||||
|
||||
/** Create an empty page and navigate to it. */
|
||||
_createPlainPage: function() {
|
||||
var self = this;
|
||||
var ctx = this._getContext();
|
||||
if (ctx.wsId > 0) {
|
||||
var body = { name: '', type: 'page' };
|
||||
if (ctx.isGitea) body.workspace_id = ctx.wsId;
|
||||
if (this._createParentId) body.parent_id = this._createParentId;
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
||||
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
|
||||
return;
|
||||
}
|
||||
var project = ctx.workspaceKey;
|
||||
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
||||
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
|
||||
},
|
||||
|
||||
/** Open the unified template picker (registry-backed) to create from a template. */
|
||||
createPageFromTemplate: function(parentId, kind) {
|
||||
if (parentId == null || parentId === '') parentId = this._currentFolderId();
|
||||
this._createParentId = parentId || 0;
|
||||
this._openTemplatePicker(kind);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unified template picker (registre `templates`, §7.1) — self-contained DOM
|
||||
* modal usable on ANY page (sidebar, workspace local, library). Always shows
|
||||
* a visible state (list / empty / error) and a working Cancel + ✕ (P5).
|
||||
*/
|
||||
_openTemplatePicker: function(kindFilter) {
|
||||
var self = this;
|
||||
var existing = document.getElementById('fd-global-tpl-picker');
|
||||
if (existing) existing.remove();
|
||||
var state = { kind: kindFilter || 'page', query: '', items: [], selected: null };
|
||||
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'fd-global-tpl-picker';
|
||||
ov.className = 'fd-gtpl-overlay open';
|
||||
ov.innerHTML =
|
||||
'<div class="fd-gtpl" role="dialog" aria-label="Nouveau depuis un template">'
|
||||
+ '<div class="fd-gtpl-head"><span class="fd-gtpl-title">Nouveau depuis un template</span>'
|
||||
+ '<button type="button" class="fd-gtpl-x" data-gtpl="close" aria-label="Fermer">×</button></div>'
|
||||
+ '<div class="fd-gtpl-body">'
|
||||
+ '<input type="text" class="fd-gtpl-search" data-gtpl="search" placeholder="🔍 Rechercher un template…">'
|
||||
+ '<div class="fd-gtpl-filters" data-gtpl="filters"></div>'
|
||||
+ '<div class="fd-gtpl-list" data-gtpl="list"><div class="fd-gtpl-empty">Chargement…</div></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="fd-gtpl-foot">'
|
||||
+ '<button type="button" class="btn-sm" data-gtpl="cancel" style="padding:8px 16px">Annuler</button>'
|
||||
+ '<button type="button" class="btn-sm" data-gtpl="use" style="padding:8px 20px;background:var(--accent);color:#fff;border:none;font-weight:600;cursor:pointer" disabled>Utiliser</button>'
|
||||
+ '</div></div>';
|
||||
document.body.appendChild(ov);
|
||||
|
||||
var KINDS = [
|
||||
{ k: 'page', label: 'Pages' },
|
||||
{ k: 'database', label: 'Bases' },
|
||||
{ k: 'row', label: 'Lignes' },
|
||||
{ k: 'blocks', label: 'Blocs' },
|
||||
{ k: '', label: 'Tous' },
|
||||
];
|
||||
var labels = { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' };
|
||||
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
var elFilters = ov.querySelector('[data-gtpl="filters"]');
|
||||
var elList = ov.querySelector('[data-gtpl="list"]');
|
||||
var elSearch = ov.querySelector('[data-gtpl="search"]');
|
||||
var elUse = ov.querySelector('[data-gtpl="use"]');
|
||||
|
||||
function renderFilters() {
|
||||
elFilters.innerHTML = KINDS.map(function(k) {
|
||||
var active = state.kind === k.k ? ' active' : '';
|
||||
return '<button type="button" class="fd-tpl-filter' + active + '" data-kind="' + k.k + '">' + k.label + '</button>';
|
||||
}).join('') + '<a href="/templates" style="margin-left:auto;align-self:center;font-size:12px;color:var(--text-dim);text-decoration:none;">Gérer…</a>';
|
||||
elFilters.querySelectorAll('[data-kind]').forEach(function(b) {
|
||||
b.onclick = function() { state.kind = b.getAttribute('data-kind'); renderFilters(); load(); };
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
if (!state.items.length) {
|
||||
elList.innerHTML = '<div class="fd-gtpl-empty">Aucun template pour ce filtre — <a href="/templates">créez-en un</a>.</div>';
|
||||
return;
|
||||
}
|
||||
var project = ctx.workspaceKey;
|
||||
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': self.getCsrfToken() }
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
||||
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
|
||||
};
|
||||
this._openTemplatePicker();
|
||||
elList.innerHTML = state.items.map(function(t, i) {
|
||||
var sel = state.selected && state.selected.id === t.id ? ' sel' : '';
|
||||
return '<div class="fd-gtpl-card' + sel + '" data-idx="' + i + '">'
|
||||
+ '<span class="fd-gtpl-ico">' + esc(t.icon || '📄') + '</span>'
|
||||
+ '<span style="flex:1;min-width:0;"><span class="fd-gtpl-name">' + esc(t.name) + '</span>'
|
||||
+ '<div class="fd-gtpl-meta">' + esc((labels[t.kind] || t.kind) + ' · ' + t.scope) + '</div></span></div>';
|
||||
}).join('');
|
||||
elList.querySelectorAll('[data-idx]').forEach(function(card) {
|
||||
card.onclick = function() {
|
||||
state.selected = state.items[parseInt(card.getAttribute('data-idx'), 10)];
|
||||
elUse.disabled = false;
|
||||
renderList();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
elList.innerHTML = '<div class="fd-gtpl-empty">Chargement…</div>';
|
||||
var p = new URLSearchParams();
|
||||
if (state.kind) p.set('kind', state.kind);
|
||||
if (state.query) p.set('q', state.query);
|
||||
fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' })
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function(d) { state.items = d.templates || []; renderList(); })
|
||||
.catch(function(e) {
|
||||
elList.innerHTML = '<div class="fd-gtpl-empty" style="color:var(--danger)">Chargement impossible : ' + esc(e.message)
|
||||
+ ' <button type="button" class="btn-sm" data-retry style="margin-left:8px">Réessayer</button></div>';
|
||||
var rb = elList.querySelector('[data-retry]');
|
||||
if (rb) rb.onclick = load;
|
||||
});
|
||||
}
|
||||
|
||||
function close() { ov.remove(); }
|
||||
ov.addEventListener('click', function(e) { if (e.target === ov) close(); });
|
||||
ov.querySelector('[data-gtpl="close"]').onclick = close;
|
||||
ov.querySelector('[data-gtpl="cancel"]').onclick = close;
|
||||
document.addEventListener('keydown', function esc(ev) {
|
||||
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
|
||||
});
|
||||
elSearch.oninput = function() { state.query = elSearch.value; };
|
||||
elSearch.onkeydown = function(ev) { if (ev.key === 'Enter') load(); };
|
||||
elUse.onclick = function() { self._applyGlobalTemplate(state.selected, elUse, close); };
|
||||
|
||||
renderFilters();
|
||||
load();
|
||||
setTimeout(function() { elSearch.focus(); }, 50);
|
||||
},
|
||||
|
||||
_openTemplatePicker: function() {
|
||||
_applyGlobalTemplate: function(t, btn, close) {
|
||||
if (!t) return;
|
||||
var self = this;
|
||||
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'fd-tpl-picker';
|
||||
ov.className = 'tpl-picker-overlay';
|
||||
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
|
||||
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
|
||||
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
|
||||
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
|
||||
document.body.appendChild(ov);
|
||||
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
|
||||
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
|
||||
fetch('/board/api/page-templates', {credentials:'same-origin'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var list = d.templates || [];
|
||||
var h = '';
|
||||
list.forEach(function(t){
|
||||
var use = t.builtin
|
||||
? "key:"+t.key
|
||||
: "id:"+t.id;
|
||||
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
|
||||
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
|
||||
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
|
||||
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
|
||||
});
|
||||
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
|
||||
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
|
||||
card.onclick = function(){ self._useTemplate(card.dataset.use); };
|
||||
});
|
||||
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
|
||||
},
|
||||
|
||||
_useTemplate: function(use){
|
||||
var ov = document.getElementById('fd-tpl-picker');
|
||||
if (ov) ov.style.display = 'none';
|
||||
var self = this;
|
||||
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
|
||||
var parts = use.split(':');
|
||||
var body = {};
|
||||
var url;
|
||||
// Pass the current workspace context so the created page is persisted
|
||||
// in the active local/Gitea workspace (mirrors _createPlainPage()).
|
||||
var ctx = self._getContext();
|
||||
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
|
||||
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
|
||||
if (self._createParentId) { body.parent_id = self._createParentId; }
|
||||
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
|
||||
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
|
||||
fetch(url, {
|
||||
var ctx = this._getContext();
|
||||
var context = {};
|
||||
if (this._createParentId) context.parent_id = this._createParentId;
|
||||
if (ctx.wsId > 0) context.workspace_id = ctx.wsId;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Application…'; }
|
||||
fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
body: JSON.stringify({ variables: {}, context: context })
|
||||
})
|
||||
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
|
||||
.then(function(d){ window.location.href = '/pages/' + d.id; })
|
||||
.catch(function(){ self._createPlainPage(); });
|
||||
.then(function(r) {
|
||||
if (!r.ok) return r.text().then(function(txt) { throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 160)); });
|
||||
return r.json();
|
||||
})
|
||||
.then(function(d) {
|
||||
(d.warnings || []).forEach(function(w) { self._toast(w, 'error'); });
|
||||
if (close) close();
|
||||
if (d.url) {
|
||||
if (window.fdNavigate) window.fdNavigate(d.url); else window.location.href = d.url;
|
||||
} else {
|
||||
self._toast('Créé depuis « ' + t.name + ' »', 'success');
|
||||
}
|
||||
})
|
||||
.catch(function(e) {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Utiliser'; }
|
||||
self._toast('Échec : ' + e.message, 'error');
|
||||
});
|
||||
},
|
||||
|
||||
/** Show the global New Folder modal (parentId = target folder, optional). */
|
||||
@@ -1836,6 +1926,8 @@
|
||||
this.newMenuOpen = false;
|
||||
if (kind === 'page') {
|
||||
if (window.FlowDeck && window.FlowDeck.createPage) window.FlowDeck.createPage();
|
||||
} else if (kind === 'template') {
|
||||
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) window.FlowDeck.createPageFromTemplate();
|
||||
} else if (kind === 'chat') {
|
||||
if (typeof this.chatNewConversation === 'function') this.chatNewConversation();
|
||||
else if (typeof this.agentNew === 'function') this.agentNew();
|
||||
@@ -1843,8 +1935,11 @@
|
||||
if (typeof this.fdCreateMeeting === 'function') this.fdCreateMeeting();
|
||||
else if (typeof this.meetingNote === 'function') this.meetingNote();
|
||||
} else if (kind === 'database') {
|
||||
if (typeof this.createDatabase === 'function') this.createDatabase();
|
||||
else if (window.showToast) window.showToast('Database creation: use a Gitea repo as a database.', 'info');
|
||||
if (window.FlowDeck && window.FlowDeck.createPageFromTemplate) {
|
||||
window.FlowDeck.createPageFromTemplate(null, 'database');
|
||||
} else if (typeof this.createDatabase === 'function') {
|
||||
this.createDatabase();
|
||||
}
|
||||
}
|
||||
},
|
||||
// ── Sidebar customization ──
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user