v3.0.1: qualité & stabilité — tests, sécurité, UX consolidée
🧪 Tests: 73 → 130 (+57 tests) - Upload API, labels sync, commit history (401/400/502/cached) - File create/update (sha=null, sha=abc) - Admin delete cascade (12 tests: oauth, tags, workspaces, pages…) - Gitea status (linked/unlinked/disconnect) - OAuth link mode (session, redirect, callback) 🔒 Sécurité - ContentSecurityPolicyMiddleware (CSP headers) - RateLimitMiddleware (100 req/min/IP) - Pydantic models: ErrorResponse, SuccessResponse - Input validation upload (10MB, allowed extensions) - Pydantic request models 🎨 UX - static/css/design-tokens.css (500 lines, thèmes + skeletons) - Toast system: 16 alert() remplacés par toast() - 59 lignes CSS dupliquées retirées (6 templates) - Skeletons cohérents sur toutes les vues ⚡ Performance - Cache TTL sur get_file_commits (consistant avec les autres méthodes)
This commit is contained in:
+47
-21
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v=1.0">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=2.4.6">
|
||||
<script src="https://unpkg.com/[email protected]"></script>
|
||||
<script src="https://unpkg.com/[email protected]" defer></script>
|
||||
@@ -333,11 +334,6 @@
|
||||
<!-- Uncollapse button → inside topbar, no overlap -->
|
||||
<!-- (Moved into topbar block below) -->
|
||||
|
||||
|
||||
<style>
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
|
||||
<!-- ═══════════ MAIN ═══════════ -->
|
||||
<div class="main-wrapper">
|
||||
|
||||
@@ -497,6 +493,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ TOAST NOTIFICATIONS ============ -->
|
||||
<div class="toast-container">
|
||||
<template x-for="t in toasts" :key="t.id">
|
||||
<div class="toast" :class="'toast-' + t.type"
|
||||
x-show="true"
|
||||
x-transition:enter=""
|
||||
x-transition:leave=""
|
||||
x-text="t.msg"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -517,6 +524,17 @@
|
||||
sidebarCollapsed: false,
|
||||
mobileSidebarOpen: false,
|
||||
showInvite: true,
|
||||
// -- Toast notifications --
|
||||
toasts: [],
|
||||
toast(msg, type) {
|
||||
type = type || 'info';
|
||||
var id = Date.now() + Math.random();
|
||||
this.toasts.push({ id: id, msg: msg, type: type });
|
||||
var self = this;
|
||||
setTimeout(function() {
|
||||
self.toasts = self.toasts.filter(function(t) { return t.id !== id; });
|
||||
}, 3000);
|
||||
},
|
||||
wsHeaderHover: false,
|
||||
workspaceName: '{{ workspace_name }}',
|
||||
workspaceInitial: '{{ workspace_initial }}',
|
||||
@@ -620,7 +638,7 @@
|
||||
this.sidebarCollapsed = true;
|
||||
window.location.href = url;
|
||||
},
|
||||
openQuickFind() { alert('Quick Find — Ctrl+K'); },
|
||||
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
|
||||
|
||||
// ── Tree state ──
|
||||
expandedNodes: {},
|
||||
@@ -725,11 +743,11 @@
|
||||
// Reload to update sidebar
|
||||
location.reload();
|
||||
})
|
||||
.catch(e => alert('Favorite action failed'));
|
||||
.catch(e => this.toast('Favorite action failed', 'error'));
|
||||
break;
|
||||
case 'copyLink':
|
||||
const url = window.location.origin + '/' + pageId;
|
||||
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
|
||||
navigator.clipboard.writeText(url).then(() => this.toast('Link copied!', 'success'));
|
||||
break;
|
||||
case 'duplicate':
|
||||
fetch(`/board/api/pages?title=${encodeURIComponent(pageName + ' copy')}§ion=Private&project=${encodeURIComponent(this.workspaceKey)}`, {
|
||||
@@ -737,7 +755,7 @@
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(e => alert('Duplicate failed'));
|
||||
.catch(e => this.toast('Duplicate failed', 'error'));
|
||||
break;
|
||||
case 'rename':
|
||||
const newName = prompt('New name:', pageName);
|
||||
@@ -750,7 +768,7 @@
|
||||
case 'moveTo':
|
||||
const section = prompt('Move to section (Private, Recents, Favorites):', 'Private');
|
||||
if (!section) break;
|
||||
alert(`Moved to ${section}`);
|
||||
this.toast('Moved to ' + section, 'success');
|
||||
break;
|
||||
case 'delete':
|
||||
if (!confirm(`Move "${pageName}" to trash?`)) break;
|
||||
@@ -775,13 +793,13 @@
|
||||
window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
|
||||
}
|
||||
})
|
||||
.catch(e => alert('Delete failed: ' + e.message));
|
||||
.catch(e => this.toast('Delete failed: ' + e.message, 'error'));
|
||||
break;
|
||||
case 'openTab':
|
||||
window.open('/' + pageId, '_blank');
|
||||
break;
|
||||
case 'openPeek':
|
||||
alert('Side peek not available in browser');
|
||||
this.toast('Side peek not available in browser', 'info');
|
||||
break;
|
||||
}
|
||||
this.contextMenu.visible = false;
|
||||
@@ -806,7 +824,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed to create page: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed to create page: ' + err.message, 'error'); });
|
||||
},
|
||||
newSubPage(parentId) {
|
||||
const project = this.workspaceKey || '';
|
||||
@@ -817,7 +835,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed to create sub-page: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed to create sub-page: ' + err.message, 'error'); });
|
||||
},
|
||||
newPageInWorkspace() {
|
||||
const name = prompt('File name:');
|
||||
@@ -829,7 +847,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
newFolderInWorkspace() {
|
||||
const name = prompt('Folder name:');
|
||||
@@ -841,7 +859,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(() => { window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
deleteWorkspacePage(pageId) {
|
||||
if (!confirm('Delete this page?')) return;
|
||||
@@ -851,7 +869,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(() => { window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
|
||||
// ── Drag & Drop ──
|
||||
@@ -880,7 +898,7 @@
|
||||
body: JSON.stringify({ parent_id: parentId })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||
.catch(err => { alert('Move failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Move failed: ' + err.message, 'error'); });
|
||||
},
|
||||
|
||||
// ── Sub-item creation in folders ──
|
||||
@@ -894,7 +912,7 @@
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(data => { window.location.href = `/pages/${data.id}`; })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
newFolderInFolder(parentId) {
|
||||
const name = prompt('Sub-folder name:');
|
||||
@@ -905,7 +923,7 @@
|
||||
body: JSON.stringify({ name: name.trim(), type: 'folder', parent_id: parentId })
|
||||
})
|
||||
.then(r => { if (!r.ok) throw new Error('Failed'); window.location.reload(); })
|
||||
.catch(err => { alert('Failed: ' + err.message); });
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
openWorkspacePage(pageId) {
|
||||
window.location = `/pages/${pageId}`;
|
||||
@@ -937,7 +955,7 @@
|
||||
},
|
||||
createDatabase() {
|
||||
this.showNewPageMenu = false;
|
||||
alert('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.');
|
||||
this.toast('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.', 'info');
|
||||
},
|
||||
createBoard() {
|
||||
this.showNewPageMenu = false;
|
||||
@@ -1030,6 +1048,14 @@
|
||||
if (e.persisted) applySavedTheme();
|
||||
});
|
||||
|
||||
// -- Global toast helper (accessible from any component) --
|
||||
window.showToast = function(msg, type) {
|
||||
var el = document.querySelector('[x-data]');
|
||||
if (el && el.__x && el.__x.$data && el.__x.$data.toast) {
|
||||
el.__x.$data.toast(msg, type || 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// ═══════════ GLOBAL KEYBOARD SHORTCUTS ═══════════
|
||||
document.addEventListener('keydown', function(e) {
|
||||
var tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||
|
||||
Reference in New Issue
Block a user