v3.0.1: qualité & stabilité — tests, sécurité, UX consolidée
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

🧪 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:
2026-07-13 23:08:53 -04:00
parent e04b3a38a4
commit de40c2d83e
12 changed files with 1679 additions and 88 deletions
+47 -21
View File
@@ -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')}&section=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) || '';
+6 -17
View File
@@ -42,17 +42,6 @@
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
.gw-commit-bar input:focus{border-color:var(--accent);}
.btn-primary{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);border:none;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:500;}
.btn-danger:hover{background:rgba(255,80,80,.25);}
.btn{border:none;padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;font-weight:500;border:1px solid var(--border);background:transparent;color:var(--text-dim);}
.btn:hover{background:var(--bg-tertiary);color:var(--text);}
/* ── Skeleton loading animation ── */
.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.file-skeleton{padding:16px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);max-height:70vh;overflow:hidden;}
/* Prism theme tweaks for dark UI */
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
@@ -70,7 +59,7 @@
</div>
<div class="gw-content">
<!-- Skeleton loading -->
<div x-show="fileLoading" class="file-skeleton">
<div x-show="fileLoading" class="skeleton-file">
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
@@ -450,9 +439,9 @@ function giteaWorkspace() {
}
} else {
var d = await r.json();
alert('Failed to save: ' + (d.error || 'Unknown error'));
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
}
} catch(e) { alert('Network error'); }
} catch(e) { window.showToast('Network error', 'error'); }
},
async deleteCurrentFile() {
@@ -551,9 +540,9 @@ function giteaWorkspace() {
await this.loadSidebarTree();
} else {
var d = await r.json();
alert('Failed: ' + (d.error || d.detail || 'Unknown error'));
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
}
} catch(e) { alert('Error: ' + e.message); }
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
},
// Upload files
@@ -720,7 +709,7 @@ function giteaWorkspace() {
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
alert('Rename failed');
window.showToast('Rename failed', 'error');
}
}).catch(function() {
var span = document.createElement('span');
+1 -1
View File
@@ -125,7 +125,7 @@
})
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed: ' + err.message); });
.catch(err => { window.showToast('Failed: ' + err.message, 'error'); });
}
</script>
{% endblock %}
+3 -7
View File
@@ -152,10 +152,6 @@
/* Inline rename */
.inline-rename-input{background:var(--bg);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;padding:2px 6px;outline:none;min-width:100px;}
/* Loading skeleton */
.skeleton{background:linear-gradient(90deg,var(--bg-tertiary) 25%,var(--bg-hover) 50%,var(--bg-tertiary) 75%);background-size:200% 100%;animation:shimmer 1.5s infinite;border-radius:4px;height:16px;margin:4px 0;}
@keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
/* Drag count badge */
.drag-badge{position:fixed;pointer-events:none;z-index:999;background:var(--accent);color:#fff;font-size:12px;font-weight:700;padding:2px 8px;border-radius:10px;transform:translate(-50%,-50%);}
@@ -1267,7 +1263,7 @@ window._wsInitData = (function() {
if (targetId) {
window.location = '/local-workspace?folder=' + targetId;
} else {
alert('Path not found: ' + parts.join('/'));
window.showToast('Path not found: ' + parts.join('/'), 'error');
}
},
_findFolderByPath(nodes, parts, idx) {
@@ -1807,10 +1803,10 @@ window._wsInitData = (function() {
if (r.ok) {
this._reloadAfterAction();
} else {
alert('Move failed');
window.showToast('Move failed', 'error');
}
} catch(e) {
alert('Move failed: ' + e.message);
window.showToast('Move failed: ' + e.message, 'error');
}
},
+2 -2
View File
@@ -660,9 +660,9 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
exportPage(){this.moreOpen=false;},
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{alert('Duplicate failed');});},
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
movePage(){this.moreOpen=false;},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{alert('Failed');});},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
onKd(e){
const ab=this.getActiveBlock();if(!ab)return;
+3 -17
View File
@@ -57,15 +57,6 @@
.color-swatch:hover{transform:scale(1.15);}
.color-swatch.selected{border-color:var(--text);box-shadow:0 0 0 2px var(--accent);}
/* Modal (for tag delete/rename) */
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:999;}
.modal-box{background:var(--bg-primary);border:1px solid var(--border);border-radius:12px;padding:24px;min-width:320px;box-shadow:0 12px 40px rgba(0,0,0,.8);}
.modal-box h3{font-size:16px;font-weight:600;color:#fff;}
.settings-input{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;padding:6px 10px;outline:none;width:100%;}
.settings-input:focus{border-color:var(--accent,#2383E2);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
.btn-danger{background:#E03E3E;color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:13px;}
.btn-danger:hover{background:#C93535;}
/* Admin tables */
.table-wrap{overflow-x:auto;border-radius:8px;border:1px solid #333;}
.admin-table{width:100%;border-collapse:collapse;font-size:13px;}
@@ -73,7 +64,7 @@
.admin-table td{padding:8px 12px;border-top:1px solid var(--border);color:var(--text);}
.admin-table tr:hover td{background:var(--bg-secondary);}
.stat-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:14px;text-align:center;}
.stat-value{font-size:22px;font-weight:700;color:var(--accent,#2383E2);}
.stat-value{font-size:22px;font-weight:700;color:var(--accent);}
.stat-label{font-size:11px;color:var(--text-dim);margin-top:2px;text-transform:uppercase;letter-spacing:.5px;}
.role-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500;}
.role-admin{background:rgba(35,131,226,.2);color:#4DA3F5;}
@@ -81,15 +72,10 @@
.status-dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:5px;vertical-align:middle;}
.status-dot.active{background:#0F7B6C;}
.status-dot.inactive{background:#E03E3E;}
.btn-sm{background:transparent;border:1px solid var(--border);color:var(--text-dim);padding:2px 6px;border-radius:4px;cursor:pointer;font-size:12px;}
.btn-sm:hover{background:var(--bg-tertiary);color:var(--text);}
.btn-sm-danger{color:#E03E3E;border-color:#542;}
/* Close button */
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
.settings-select{background:var(--bg-tertiary);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font-size:13px;outline:none;cursor:pointer;min-width:140px;}
.settings-select:focus{border-color:var(--accent);}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
@@ -662,9 +648,9 @@ document.addEventListener('alpine:init', function() {
await this.loadAdminUsers();
} else {
var d = await r.json();
alert(d.error || 'Failed to create user');
window.showToast(d.error || 'Failed to create user', 'error');
}
} catch(e) { alert('Network error'); }
} catch(e) { window.showToast('Network error', 'error'); }
},
async adminUpdateUser() {
+2 -23
View File
@@ -31,8 +31,8 @@
.ws-card:hover{background:var(--bg-tertiary);border-color:var(--border-strong);}
.ws-card.active{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.ws-card-name{font-size:15px;font-weight:500;margin-bottom:2px;}
.ws-card-sub{font-size:12px;color:var(--text-tertiary);margin-bottom:4px;}
.ws-card-count{font-size:12px;color:var(--text-tertiary);}
.ws-card-sub{font-size:12px;color:var(--text-dim);margin-bottom:4px;}
.ws-card-count{font-size:12px;color:var(--text-dim);}
.ws-card-actions{display:flex;gap:4px;margin-top:8px;opacity:0;transition:opacity 150ms;}
.ws-card:hover .ws-card-actions{opacity:1;}
@@ -46,33 +46,12 @@
.gitea-connect:hover{text-decoration:underline;}
.gitea-loading{color:var(--text-dim);font-size:13px;padding:12px 0;}
.dialog-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:500;}
.dialog-box{background:#1F1F1F;border:1px solid var(--border);border-radius:18px;padding:24px;width:400px;max-width:90vw;}
.dialog-box h3{margin-bottom:16px;}
.dialog-input{width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;margin-bottom:12px;outline:none;}
.dialog-input:focus{border-color:var(--accent);}
.dialog-actions{display:flex;gap:8px;justify-content:flex-end;}
.btn{padding:8px 16px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-hover);}
.btn-secondary{background:var(--bg-tertiary);color:var(--text);}
.btn-secondary:hover{background:var(--bg-hover);}
.btn-danger{background:rgba(255,80,80,.15);color:var(--danger);}
.btn-danger:hover{background:rgba(255,80,80,.25);}
.btn-new{border:1px dashed var(--border-strong);background:transparent;color:var(--text-secondary);display:flex;align-items:center;justify-content:center;min-height:120px;}
.btn-new:hover{background:var(--bg-tertiary);color:var(--text);}
.btn-sm{padding:4px 8px;font-size:11px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-dim);cursor:pointer;}
.btn-sm:hover{background:var(--bg-tertiary);color:var(--text);}
/* Org tabs */
.org-tab{display:inline-flex;align-items:center;gap:4px;padding:4px 10px;border-radius:6px;font-size:13px;color:var(--text-dim);background:transparent;border:1px solid var(--border);cursor:pointer;white-space:nowrap;transition:all 100ms;}
.org-tab:hover{background:var(--bg-tertiary);color:var(--text);}
.org-tab.active{background:var(--accent);color:#fff;border-color:var(--accent);}
.org-tab-badge{font-size:10px;padding:0 5px;border-radius:8px;background:rgba(255,255,255,.15);}
.search-input:focus{border-color:var(--accent);}
/* ── Skeleton loading animation ── */
.skeleton{background:var(--bg-tertiary);animation:pulse 1.5s ease-in-out infinite;border-radius:4px;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
</style>
<div class="ws-page" x-data="workspacesPage()">