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
+141
View File
@@ -0,0 +1,141 @@
"""FlowDeck — Security middleware: CSP headers + rate limiting."""
from __future__ import annotations
import time
from collections import defaultdict
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
# ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads
ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
# Images
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
# Documents
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
# Code
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
".swift", ".kt", ".scala", ".r",
# Archives
".zip", ".tar", ".gz", ".rar", ".7z",
# Misc
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
})
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
def validate_upload(filename: str, size: int) -> str | None:
"""Validate upload filename and size. Returns error message or None."""
if size > MAX_UPLOAD_SIZE:
return f"File '{filename}' exceeds maximum size of 10 MB"
ext = _ext(filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
def _ext(filename: str) -> str:
"""Extract lowercase extension from filename."""
if "." in filename:
return "." + filename.rsplit(".", 1)[-1].lower()
return ""
# ── Content-Security-Policy Middleware ────────────────────────
class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"""Sets Content-Security-Policy headers on all HTML responses.
A permissive-yet-safe policy for a Notion-style app that needs:
- inline scripts (Alpine.js, HTMX)
- inline styles
- font loading
- images from various sources
- media (audio/video)
- websocket connections for HMR/SSE
"""
CSP_HEADER = "Content-Security-Policy"
CSP_VALUE = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data:; "
"connect-src 'self' https: wss:; "
"media-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
)
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Only set CSP on HTML responses
content_type = response.headers.get("content-type", "")
if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE
return response
# ── Rate Limiting Middleware ──────────────────────────────────
class RateLimitMiddleware(BaseHTTPMiddleware):
"""Simple in-memory sliding-window rate limiter per IP.
Default: 100 requests per minute per IP for API routes.
Non-API routes (HTML pages, static files) are not rate-limited.
"""
# Paths that should be rate-limited
RATE_LIMITED_PREFIXES: tuple[str, ...] = (
"/api/", "/board/api/", "/auth/",
)
# Paths exempt from rate limiting even under an API prefix
EXEMPT_PATHS: frozenset[str] = frozenset({
"/api/health",
"/api/frontend-error",
"/api/frontend-errors",
})
def __init__(self, app, max_requests: int = 100, window_seconds: int = 60):
super().__init__(app)
self.max_requests = max_requests
self.window_seconds = window_seconds
self._store: dict[str, tuple[float, int]] = defaultdict(lambda: (0.0, 0))
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Only rate-limit API routes
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
return await call_next(request)
# Exempt health check and error capture
if path in self.EXEMPT_PATHS:
return await call_next(request)
ip = request.client.host if request.client else "unknown"
now = time.time()
window_start, count = self._store[ip]
if now - window_start > self.window_seconds:
self._store[ip] = (now, 1)
return await call_next(request)
if count >= self.max_requests:
return JSONResponse(
{"error": "Rate limit exceeded", "detail": f"Max {self.max_requests} req/min per IP"},
status_code=429,
)
self._store[ip] = (window_start, count + 1)
return await call_next(request)
+95
View File
@@ -0,0 +1,95 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from typing import Optional
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
# ── File Save ────────────────────────────────────────────────
class FileSaveRequest(BaseModel):
"""Request model for saving/updating a file via Gitea."""
path: str = Field(..., min_length=1, description="File path in the repository")
content: str = Field(..., description="File content (UTF-8 encoded)")
message: str = Field(default="Update via FlowDeck", description="Commit message")
sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
@model_validator(mode="after")
def validate_path_extension(self):
ext = _ext(self.path)
if ext and ext not in ALLOWED_EXTENSIONS:
raise ValueError(f"File extension '{ext}' is not allowed")
return self
# ── Upload ───────────────────────────────────────────────────
class UploadValidationResult(BaseModel):
"""Result of validating an upload."""
filename: str
size: int
extension: str
valid: bool
error: Optional[str] = None
def validate_upload_request(file: UploadFile) -> Optional[str]:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
if hasattr(file, 'size') and file.size is not None and file.size > MAX_UPLOAD_SIZE:
return f"File '{file.filename}' exceeds maximum size of 10 MB"
# Extension check
if file.filename:
ext = _ext(file.filename)
if ext and ext not in ALLOWED_EXTENSIONS:
return f"File extension '{ext}' is not allowed"
return None
# ── Issue Create / Update ────────────────────────────────────
class IssueCreateRequest(BaseModel):
"""Request model for creating a Gitea issue."""
title: str = Field(..., min_length=1, max_length=500)
body: str = Field(default="")
labels: str = Field(default="", description="Comma-separated label IDs")
milestone: str = Field(default="", description="Milestone ID")
assignee: str = Field(default="")
class IssueUpdateRequest(BaseModel):
"""Request model for updating a Gitea issue (partial update)."""
title: Optional[str] = Field(default=None, max_length=500)
body: Optional[str] = Field(default=None)
state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
milestone: Optional[str] = Field(default=None)
assignee: Optional[str] = Field(default=None)
# ── Card Move ────────────────────────────────────────────────
class CardMoveRequest(BaseModel):
"""Request model for moving a card between columns."""
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
issue_id: int = Field(..., gt=0)
column: str = Field(..., min_length=1)
# ── Column Mapping ───────────────────────────────────────────
class ColMappingRequest(BaseModel):
"""Request model for column-to-label mapping."""
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
column: str = Field(..., min_length=1)
gitea_label: str = Field(..., min_length=1)
close_issue: bool = Field(default=False)
+38
View File
@@ -0,0 +1,38 @@
"""FlowDeck — Standardized response models."""
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel
class ErrorResponse(BaseModel):
"""Standardized error response.
Example:
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
"""
error: str
detail: Optional[str] = None
model_config = {
"json_schema_extra": {
"example": {"error": "Not found", "detail": "Board not found"}
}
}
class SuccessResponse(BaseModel):
"""Standardized success response.
Example:
SuccessResponse(status="ok", data={"issue_id": 42})
"""
status: str = "ok"
data: Optional[dict[str, Any]] = None
model_config = {
"json_schema_extra": {
"example": {"status": "ok", "data": {"issue_id": 42, "column": "Done"}}
}
}
+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()">