- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient loovverture et le redimensionnement inoperants -> cblage direct sur le document. - Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw, clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks. - database-table-container margin:0 (tableau colle a gauche, marge Library). - .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px. - ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
337 lines
15 KiB
HTML
337 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block page_icon %}{{ fd_icon("trash",18) }}{% endblock %}
|
|
{% block page_title %}Trash{% endblock %}
|
|
{% block title_prefix %}Trash{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-title-area">
|
|
<div class="page-title">
|
|
<span class="page-icon-lg">{{ fd_icon("trash",24) }}</span>
|
|
<h1>Trash</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div x-data="trashData()" class="lib-container">
|
|
<!-- Toolbar : recherche + tri + emplacement + purge -->
|
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
|
<div style="position:relative; flex:1;">
|
|
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
|
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
|
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>
|
|
|
|
<!-- Tri -->
|
|
<div class="lib-toolbar-wrap">
|
|
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
|
</button>
|
|
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
|
<div class="dd-label">Sort by</div>
|
|
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
|
|
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
|
|
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Emplacement -->
|
|
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
|
|
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
|
|
{{ fd_icon("folder",16) }}
|
|
</button>
|
|
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
|
|
<div class="dd-label">Location</div>
|
|
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
|
|
<template x-for="l in locations" :key="l">
|
|
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Purge totale -->
|
|
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
|
|
</div>
|
|
|
|
<!-- Sélection + compteur + actions groupées -->
|
|
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
|
|
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
|
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
|
|
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
|
|
</label>
|
|
<span x-text="countLabel"></span>
|
|
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
|
|
<button class="btn-sm" @click="restoreSelected()">Restore</button>
|
|
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
|
|
<button class="btn-sm" @click="clearSel()">Clear</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Items -->
|
|
<div style="min-height:200px;">
|
|
<template x-for="item in filteredItems" :key="item.id">
|
|
<div class="trash-item">
|
|
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
|
|
:checked="selected.includes(item.id)" @change="toggle(item.id)">
|
|
<span class="trash-item-icon" x-text="item.icon"></span>
|
|
<div class="trash-item-info">
|
|
<span class="trash-item-name" x-text="item.name"></span>
|
|
<span class="trash-item-path">
|
|
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
|
|
</span>
|
|
</div>
|
|
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
|
|
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
|
{{ fd_icon("trash",14) }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- État vide : aucun contenu -->
|
|
<div x-show="items.length === 0" class="lib-empty">
|
|
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
|
|
<h3>Trash is empty</h3>
|
|
<p>Deleted pages will appear here for 30 days.</p>
|
|
</div>
|
|
|
|
<!-- État vide : filtres sans résultat -->
|
|
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
|
|
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
|
|
<h3>No pages match</h3>
|
|
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
|
|
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info -->
|
|
<div style="padding:12px 16px; margin-top:16px; background:var(--bg-tertiary); border-radius:8px; font-size:13px; color:var(--text-dim); display:flex; align-items:center; gap:8px;">
|
|
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
|
<span style="font-size:16px;">ⓘ</span>
|
|
</div>
|
|
|
|
<!-- Confirmation thématique (remplace confirm() du navigateur) — classes
|
|
flowdeck-modal définies dans base.html, thème variables du site -->
|
|
<div id="fd-trash-confirm" class="flowdeck-modal-overlay" x-show="confirm.show" x-transition.opacity
|
|
@click.self="cancelConfirm()" @keydown.escape.window="cancelConfirm()" style="display:none;">
|
|
<div class="flowdeck-modal" x-transition role="alertdialog" aria-modal="true" aria-labelledby="fd-trash-confirm-title">
|
|
<div class="flowdeck-modal-header">
|
|
<h3 id="fd-trash-confirm-title" x-text="confirm.title"></h3>
|
|
<button class="flowdeck-modal-close" @click="cancelConfirm()" aria-label="Close">×</button>
|
|
</div>
|
|
<div class="flowdeck-modal-body">
|
|
<p x-text="confirm.message"></p>
|
|
</div>
|
|
<div class="flowdeck-modal-footer">
|
|
<button class="flowdeck-modal-btn cancel" @click="cancelConfirm()">Cancel</button>
|
|
<button class="flowdeck-modal-btn confirm" @click="acceptConfirm()" x-text="confirm.okText"></button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
|
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
|
// globales window — probe « Undefined variable: trashData »).
|
|
if (window.Alpine) { Alpine.data('trashData', trashData); }
|
|
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
|
|
|
function trashData() {
|
|
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
|
|
var RETENTION_DAYS = 30;
|
|
return {
|
|
search: '',
|
|
sort: 'recent',
|
|
locFilter: 'all',
|
|
sortOpen: false,
|
|
locOpen: false,
|
|
selected: [],
|
|
items: [],
|
|
// Confirmation thématique (remplace confirm() du navigateur)
|
|
confirm: { show: false, title: '', message: '', okText: 'Delete', action: null },
|
|
|
|
async init() {
|
|
try {
|
|
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
this.items = await r.json();
|
|
} catch (e) {
|
|
this.items = [];
|
|
window.showToast('Could not load the Trash: ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
get locations() {
|
|
const set = {};
|
|
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
|
|
return Object.keys(set).sort();
|
|
},
|
|
|
|
get filteredItems() {
|
|
const q = this.search.toLowerCase();
|
|
const loc = this.locFilter;
|
|
const out = this.items.filter(function (i) {
|
|
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
|
|
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
|
|
});
|
|
const key = function (i) { return i.deleted_at || ''; };
|
|
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
|
|
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
|
|
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
|
return out;
|
|
},
|
|
|
|
get countLabel() {
|
|
const n = this.filteredItems.length, t = this.items.length;
|
|
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
|
|
},
|
|
|
|
get allSelected() {
|
|
const list = this.filteredItems;
|
|
const self = this;
|
|
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
|
|
},
|
|
|
|
toggle(id) {
|
|
const i = this.selected.indexOf(id);
|
|
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
|
|
},
|
|
toggleAll() {
|
|
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
|
|
},
|
|
clearSel() { this.selected = []; },
|
|
clearFilters() { this.search = ''; this.locFilter = 'all'; },
|
|
toggleSort() { this.sortOpen = !this.sortOpen; },
|
|
toggleLoc() { this.locOpen = !this.locOpen; },
|
|
setSort(v) { this.sort = v; this.sortOpen = false; },
|
|
setLoc(v) { this.locFilter = v; this.locOpen = false; },
|
|
|
|
// ── Confirmation (modal thème FlowDeck, classes flowdeck-modal de base) ──
|
|
// Mutations de propriétés individuelles (remplacer l'objet entier tue la
|
|
// réactivité Alpine — pitfall connu).
|
|
askConfirm(title, message, okText, action) {
|
|
this.confirm.title = title;
|
|
this.confirm.message = message;
|
|
this.confirm.okText = okText || 'Delete';
|
|
this.confirm.action = action;
|
|
this.confirm.show = true;
|
|
},
|
|
cancelConfirm() {
|
|
this.confirm.show = false;
|
|
this.confirm.action = null;
|
|
},
|
|
acceptConfirm() {
|
|
const action = this.confirm.action;
|
|
this.confirm.show = false;
|
|
this.confirm.action = null;
|
|
if (action) action();
|
|
},
|
|
|
|
// ── API ──────────────────────────────────────────────────────────
|
|
// getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait
|
|
// csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux.
|
|
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
|
|
_drop(ids) {
|
|
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
|
|
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
|
|
},
|
|
|
|
async _restore(id) {
|
|
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
|
|
if (r.ok) this._drop([id]);
|
|
return r.ok;
|
|
},
|
|
async _delete(id) {
|
|
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
|
|
if (r.ok) this._drop([id]);
|
|
return r.ok;
|
|
},
|
|
|
|
async restore(id) {
|
|
try {
|
|
if (await this._restore(id)) window.showToast('Page restored', 'success');
|
|
else window.showToast('Restore failed', 'error');
|
|
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
|
|
},
|
|
async restoreSelected() {
|
|
const ids = this.selected.slice();
|
|
let ok = 0;
|
|
for (const id of ids) {
|
|
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
|
|
}
|
|
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
|
|
},
|
|
async _deleteAndToast(ids) {
|
|
let ok = 0;
|
|
for (const id of ids) {
|
|
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
|
|
}
|
|
const msg = ids.length === 1
|
|
? (ok ? 'Page permanently deleted' : 'Delete failed')
|
|
: ok + ' of ' + ids.length + ' page(s) deleted';
|
|
window.showToast(msg, ok === ids.length ? 'success' : 'error');
|
|
},
|
|
deleteForever(id) {
|
|
const self = this;
|
|
const item = this.items.find(function (i) { return i.id === id; });
|
|
const name = item ? item.name : 'this page';
|
|
this.askConfirm('Delete permanently',
|
|
'“' + name + '” will be deleted for good. This cannot be undone.',
|
|
'Delete',
|
|
function () { self._deleteAndToast([id]); });
|
|
},
|
|
deleteSelected() {
|
|
const self = this;
|
|
const ids = this.selected.slice();
|
|
this.askConfirm('Delete ' + ids.length + ' page(s) for good?',
|
|
'These pages will be permanently deleted. This cannot be undone.',
|
|
'Delete all',
|
|
function () { self._deleteAndToast(ids); });
|
|
},
|
|
async emptyTrash() {
|
|
const self = this;
|
|
const n = this.items.length;
|
|
this.askConfirm('Empty Trash?',
|
|
'All ' + n + ' page(s) in the Trash will be permanently deleted. This cannot be undone.',
|
|
'Empty Trash',
|
|
function () { self._emptyAndToast(n); });
|
|
},
|
|
async _emptyAndToast(n) {
|
|
try {
|
|
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
this.items = [];
|
|
this.selected = [];
|
|
window.showToast(n + ' page(s) purged', 'success');
|
|
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
|
|
},
|
|
|
|
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
|
|
_ts(s) {
|
|
if (!s) return null;
|
|
let t = String(s).replace(' ', 'T');
|
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
|
|
const d = new Date(t);
|
|
return isNaN(d.getTime()) ? null : d;
|
|
},
|
|
ago(s) {
|
|
const d = this._ts(s);
|
|
if (!d) return 'Deleted';
|
|
const sec = (Date.now() - d.getTime()) / 1000;
|
|
if (sec < 60) return 'Deleted just now';
|
|
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
|
|
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
|
|
const days = Math.floor(sec / 86400);
|
|
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
|
|
},
|
|
leftLabel(s) {
|
|
const d = this._ts(s);
|
|
if (!d) return '';
|
|
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
|
|
if (left <= 0) return ' · auto-delete soon';
|
|
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|