feat: dashboard 3-column layout — project folders treeview, project cards with open button, info panel

This commit is contained in:
A0 Orchestrator
2026-07-03 17:21:42 +02:00
parent d80e5990e4
commit aae93fe36b
11 changed files with 1454 additions and 193 deletions
+456 -86
View File
@@ -1,20 +1,21 @@
/**
* Dashboard Page Project overview after login
* Dashboard Page - 3-column layout with project folder tree, project cards, and info panel
*/
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { NotificationPanel } from '../components/NotificationPanel';
import { ShareDialog } from '../components/ShareDialog';
import TreeView from '../components/TreeView';
import type { TreeNode } from '../types/ui.types';
import {
getProjects, deleteProject,
getProjectFolders, createProjectFolder, renameProjectFolder, deleteProjectFolder,
moveProjectToFolder, getProjectShares,
type Project, type ProjectFolder, type ProjectShare,
} from '../services/api';
const API_BASE = import.meta.env.VITE_API_BASE || '';
interface Project {
id: string;
name: string;
description: string | null;
created_at: string;
updated_at: string;
}
const ROOT_FOLDER_ID = '__root__';
interface DashboardProps {
onOpenProject: (projectId: string) => void;
@@ -23,21 +24,35 @@ interface DashboardProps {
export function Dashboard({ onOpenProject }: DashboardProps) {
const { user, logout, token } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [folders, setFolders] = useState<ProjectFolder[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [shareProjectId, setShareProjectId] = useState<string | null>(null);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(ROOT_FOLDER_ID);
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
const [shares, setShares] = useState<ProjectShare[]>([]);
const [sharesLoading, setSharesLoading] = useState(false);
const [draggedProjectId, setDraggedProjectId] = useState<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; folderId: string | null } | null>(null);
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
const fetchProjects = useCallback(async () => {
const fetchAll = useCallback(async () => {
if (!token) return;
setLoading(true);
try {
const res = await fetch(`${API_BASE}/api/projects`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
setProjects(await res.json());
}
const [projs, fldrs] = await Promise.all([
getProjects(token),
getProjectFolders(token),
]);
setAllProjects(projs);
setFolders(fldrs);
} catch {
// ignore
} finally {
@@ -46,22 +61,86 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
}, [token]);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
fetchAll();
}, [fetchAll]);
useEffect(() => {
if (selectedFolderId === ROOT_FOLDER_ID) {
setProjects(allProjects);
} else if (selectedFolderId === null) {
setProjects(allProjects.filter(p => p.folder_id === null));
} else {
setProjects(allProjects.filter(p => p.folder_id === selectedFolderId));
}
}, [allProjects, selectedFolderId]);
useEffect(() => {
if (!token || !selectedProjectId) {
setShares([]);
return;
}
setSharesLoading(true);
getProjectShares(token, selectedProjectId)
.then(s => setShares(s))
.catch(() => setShares([]))
.finally(() => setSharesLoading(false));
}, [token, selectedProjectId]);
const selectedProject = useMemo(
() => allProjects.find(p => p.id === selectedProjectId) ?? null,
[allProjects, selectedProjectId],
);
const treeNodes = useMemo((): TreeNode[] => {
const rootProjectsCount = allProjects.filter(p => p.folder_id === null).length;
const root: TreeNode = {
id: ROOT_FOLDER_ID,
name: 'Alle Projekte',
count: allProjects.length,
expanded: true,
children: [
{ id: '__unfiled__', name: 'Ohne Ordner', count: rootProjectsCount },
],
};
const buildFolderNode = (folder: ProjectFolder): TreeNode => {
const childFolders = folders.filter(f => f.parent_id === folder.id);
const projCount = allProjects.filter(p => p.folder_id === folder.id).length;
return {
id: folder.id,
name: folder.name,
count: projCount,
expanded: true,
children: childFolders.map(buildFolderNode),
};
};
const topFolders = folders.filter(f => f.parent_id === null);
root.children!.push(...topFolders.map(buildFolderNode));
return [root];
}, [folders, allProjects]);
const folderNameById = useMemo(() => {
const map = new Map<string, string>();
map.set(ROOT_FOLDER_ID, 'Alle Projekte');
map.set('__unfiled__', 'Ohne Ordner');
for (const f of folders) map.set(f.id, f.name);
return map;
}, [folders]);
const handleCreate = async () => {
if (!newName.trim()) return;
if (!newName.trim() || !token) return;
try {
const folderId = selectedFolderId === ROOT_FOLDER_ID || selectedFolderId === '__unfiled__'
? null : selectedFolderId;
const res = await fetch(`${API_BASE}/api/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ name: newName, description: newDesc || null }),
body: JSON.stringify({ name: newName, description: newDesc || null, folder_id: folderId }),
});
if (res.ok) {
setNewName('');
setNewDesc('');
setShowCreate(false);
fetchProjects();
fetchAll();
}
} catch {
// ignore
@@ -70,18 +149,158 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Projekt wirklich löschen?')) return;
if (!confirm('Projekt wirklich loeschen?')) return;
if (!token) return;
try {
await fetch(`${API_BASE}/api/projects/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
fetchProjects();
await deleteProject(token, id);
if (selectedProjectId === id) setSelectedProjectId(null);
fetchAll();
} catch {
// ignore
}
};
const handleFolderSelect = (id: string) => {
if (id === ROOT_FOLDER_ID) {
setSelectedFolderId(ROOT_FOLDER_ID);
} else if (id === '__unfiled__') {
setSelectedFolderId(null);
} else {
setSelectedFolderId(id);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !token) return;
const parentId = newFolderParent === ROOT_FOLDER_ID || newFolderParent === '__unfiled__'
? null : newFolderParent;
try {
await createProjectFolder(token, newFolderName, parentId);
setNewFolderName('');
setShowCreateFolder(false);
setNewFolderParent(null);
fetchAll();
} catch {
// ignore
}
};
const handleRenameFolder = async (id: string) => {
if (!renameValue.trim() || !token) return;
try {
await renameProjectFolder(token, id, renameValue);
setRenamingFolderId(null);
setRenameValue('');
fetchAll();
} catch {
// ignore
}
};
const handleDeleteFolder = async (id: string) => {
if (!token) return;
if (!confirm('Ordner loeschen? Projekte darin werden nach "Ohne Ordner" verschoben.')) return;
try {
await deleteProjectFolder(token, id);
if (selectedFolderId === id) setSelectedFolderId(ROOT_FOLDER_ID);
fetchAll();
} catch {
// ignore
}
};
const handleProjectCardClick = (projectId: string) => {
setSelectedProjectId(projectId);
};
const handleProjectDragStart = (e: React.DragEvent, projectId: string) => {
setDraggedProjectId(projectId);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', `project:${projectId}`);
};
const handleFolderDragOver = (e: React.DragEvent) => {
if (!draggedProjectId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
(e.currentTarget as HTMLElement).classList.add('tree-drop-target');
};
const handleFolderDragLeave = (e: React.DragEvent) => {
(e.currentTarget as HTMLElement).classList.remove('tree-drop-target');
};
const handleFolderDrop = async (e: React.DragEvent, folderId: string) => {
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).classList.remove('tree-drop-target');
if (!draggedProjectId || !token) return;
const targetFolderId = folderId === ROOT_FOLDER_ID || folderId === '__unfiled__' ? null : folderId;
try {
await moveProjectToFolder(token, draggedProjectId, targetFolderId);
fetchAll();
} catch {
// ignore
} finally {
setDraggedProjectId(null);
}
};
const handleContextMenu = (e: React.MouseEvent, folderId: string | null) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, folderId });
};
const handleContextAction = (action: string, folderId: string | null) => {
setContextMenu(null);
if (action === 'new') {
setNewFolderParent(folderId);
setShowCreateFolder(true);
} else if (action === 'rename' && folderId) {
const folder = folders.find(f => f.id === folderId);
if (folder) {
setRenamingFolderId(folderId);
setRenameValue(folder.name);
}
} else if (action === 'delete' && folderId) {
handleDeleteFolder(folderId);
}
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleDateString('de-DE', {
year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
};
const renderFolderActions = (node: TreeNode) => {
if (node.id === ROOT_FOLDER_ID || node.id === '__unfiled__') return null;
return (
<span className="folder-action-buttons">
<button
className="folder-action-btn"
title="Umbenennen"
onClick={(e) => {
e.stopPropagation();
setRenamingFolderId(node.id);
const folder = folders.find(f => f.id === node.id);
if (folder) setRenameValue(folder.name);
}}
></button>
<button
className="folder-action-btn delete"
title="Loeschen"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(node.id);
}}
></button>
</span>
);
};
return (
<div className="dashboard-page">
<header className="dashboard-header">
@@ -95,71 +314,222 @@ export function Dashboard({ onOpenProject }: DashboardProps) {
</div>
</header>
<div className="dashboard-content">
<div className="dashboard-section-header">
<h2>Projekte</h2>
<button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}>
+ Neues Projekt
</button>
</div>
{showCreate && (
<div className="dashboard-create-form">
<input
type="text"
placeholder="Projektname"
value={newName}
onChange={e => setNewName(e.target.value)}
autoFocus
/>
<input
type="text"
placeholder="Beschreibung (optional)"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
/>
<button onClick={handleCreate}>Erstellen</button>
<button onClick={() => setShowCreate(false)}>Abbrechen</button>
<div className="dashboard-3col">
<div className="dashboard-treeview" onContextMenu={(e) => handleContextMenu(e, null)}>
<div className="treeview-header">
<h3>Ordner</h3>
<button
className="treeview-add-folder-btn"
title="Neuer Ordner"
onClick={() => { setNewFolderParent(null); setShowCreateFolder(true); }}
>+</button>
</div>
)}
{loading ? (
<p className="dashboard-loading">Lade Projekte</p>
) : projects.length === 0 ? (
<p className="dashboard-empty">Noch keine Projekte. Erstellen Sie ein neues Projekt.</p>
) : (
<div className="dashboard-project-grid">
{projects.map(project => (
<div
key={project.id}
className="dashboard-project-card"
onClick={() => onOpenProject(project.id)}
>
<h3>{project.name}</h3>
{project.description && <p>{project.description}</p>}
<div className="dashboard-project-meta">
<span>Erstellt: {new Date(project.created_at).toLocaleDateString('de-DE')}</span>
<div className="dashboard-project-actions">
<button
className="dashboard-share-btn"
onClick={(e) => { e.stopPropagation(); setShareProjectId(project.id); }}
>
Teilen
</button>
<button
className="dashboard-delete-btn"
onClick={(e) => handleDelete(project.id, e)}
>
Löschen
</button>
</div>
</div>
{showCreateFolder && (
<div className="folder-create-form">
<input
type="text"
placeholder="Ordnername"
value={newFolderName}
onChange={e => setNewFolderName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreateFolder(); if (e.key === 'Escape') setShowCreateFolder(false); }}
autoFocus
/>
<div className="folder-create-actions">
<button onClick={handleCreateFolder}>OK</button>
<button onClick={() => { setShowCreateFolder(false); setNewFolderName(''); }}>×</button>
</div>
</div>
)}
<TreeView
nodes={treeNodes}
selectedId={selectedFolderId ?? undefined}
onSelect={handleFolderSelect}
renderActions={renderFolderActions}
/>
{renamingFolderId && (
<div className="folder-rename-form">
<input
type="text"
value={renameValue}
onChange={e => setRenameValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRenameFolder(renamingFolderId); if (e.key === 'Escape') { setRenamingFolderId(null); setRenameValue(''); } }}
onBlur={() => { if (renameValue.trim()) handleRenameFolder(renamingFolderId); else { setRenamingFolderId(null); setRenameValue(''); } }}
autoFocus
/>
</div>
)}
<div className="treeview-drop-overlay">
{folders.map(folder => (
<div
key={folder.id}
className="treeview-drop-zone"
onDragOver={handleFolderDragOver}
onDragLeave={handleFolderDragLeave}
onDrop={(e) => handleFolderDrop(e, folder.id)}
/>
))}
</div>
)}
</div>
<div className="dashboard-content">
<div className="dashboard-section-header">
<h2>{folderNameById.get(selectedFolderId ?? ROOT_FOLDER_ID) ?? 'Projekte'}</h2>
<button className="dashboard-create-btn" onClick={() => setShowCreate(!showCreate)}>
+ Neues Projekt
</button>
</div>
{showCreate && (
<div className="dashboard-create-form">
<input
type="text"
placeholder="Projektname"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
autoFocus
/>
<input
type="text"
placeholder="Beschreibung (optional)"
value={newDesc}
onChange={e => setNewDesc(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleCreate(); }}
/>
<button onClick={handleCreate}>Erstellen</button>
<button onClick={() => setShowCreate(false)}>Abbrechen</button>
</div>
)}
{loading ? (
<p className="dashboard-loading">Lade Projekte...</p>
) : projects.length === 0 ? (
<p className="dashboard-empty">Keine Projekte in dieser Ansicht. Erstellen Sie ein neues Projekt.</p>
) : (
<div className="dashboard-project-grid">
{projects.map(project => (
<div
key={project.id}
className={`dashboard-project-card${selectedProjectId === project.id ? ' selected' : ''}`}
onClick={() => handleProjectCardClick(project.id)}
draggable
onDragStart={(e) => handleProjectDragStart(e, project.id)}
onDragEnd={() => setDraggedProjectId(null)}
>
<h3>{project.name}</h3>
{project.description && <p>{project.description}</p>}
<div className="dashboard-project-meta">
<span>{new Date(project.created_at).toLocaleDateString('de-DE')}</span>
<div className="dashboard-project-actions">
<button
className="dashboard-open-btn"
onClick={(e) => { e.stopPropagation(); onOpenProject(project.id); }}
>
Oeffnen
</button>
<button
className="dashboard-share-btn"
onClick={(e) => { e.stopPropagation(); setShareProjectId(project.id); }}
>
Teilen
</button>
<button
className="dashboard-delete-btn"
onClick={(e) => handleDelete(project.id, e)}
>
Loeschen
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="dashboard-info-panel">
{selectedProject ? (
<>
<h3 className="info-panel-title">{selectedProject.name}</h3>
{selectedProject.description && (
<p className="info-panel-description">{selectedProject.description}</p>
)}
<div className="info-panel-section">
<h4>Metadaten</h4>
<dl className="info-panel-meta">
<dt>Erstellt</dt>
<dd>{formatDate(selectedProject.created_at)}</dd>
<dt>Aktualisiert</dt>
<dd>{formatDate(selectedProject.updated_at)}</dd>
<dt>Owner</dt>
<dd>{selectedProject.owner_id}</dd>
<dt>Ordner</dt>
<dd>{selectedProject.folder_id ? (folderNameById.get(selectedProject.folder_id) ?? 'Unbekannt') : 'Ohne Ordner'}</dd>
</dl>
</div>
<div className="info-panel-section">
<h4>Sichtbarkeit / Freigaben</h4>
{sharesLoading ? (
<p className="info-panel-loading">Lade Freigaben...</p>
) : shares.length === 0 ? (
<p className="info-panel-empty">Keine Freigaben</p>
) : (
<ul className="info-panel-shares">
{shares.map(share => (
<li key={share.id} className="info-panel-share-item">
<span className="info-panel-share-email">{share.shared_with_email}</span>
<span className="info-panel-share-perm">{share.permission}</span>
</li>
))}
</ul>
)}
</div>
<button
className="info-panel-open-btn"
onClick={() => onOpenProject(selectedProject.id)}
>
Projekt oeffnen
</button>
</>
) : (
<div className="info-panel-placeholder">
<p>Waehlen Sie ein Projekt aus, um Details zu sehen.</p>
</div>
)}
</div>
</div>
{contextMenu && (
<>
<div className="context-menu-overlay" onClick={() => setContextMenu(null)} />
<div
className="context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="context-menu-item"
onClick={() => handleContextAction('new', contextMenu.folderId)}
>Neuer Ordner</button>
{contextMenu.folderId && contextMenu.folderId !== ROOT_FOLDER_ID && contextMenu.folderId !== '__unfiled__' && (
<>
<button
className="context-menu-item"
onClick={() => handleContextAction('rename', contextMenu.folderId)}
>Umbenennen</button>
<button
className="context-menu-item danger"
onClick={() => handleContextAction('delete', contextMenu.folderId)}
>Loeschen</button>
</>
)}
</div>
</>
)}
{token && (
<ShareDialog
token={token}
+63
View File
@@ -18,6 +18,7 @@ export interface Project {
name: string;
description: string | null;
owner_id: string;
folder_id: string | null;
created_at: string;
updated_at: string;
}
@@ -107,6 +108,68 @@ export async function deleteProject(token: string, id: string): Promise<void> {
});
}
// ─── Project Folders ─────────────────────────────────────
export interface ProjectFolder {
id: string;
name: string;
parent_id: string | null;
owner_id: string;
created_at: string;
}
export async function getProjectFolders(token: string): Promise<ProjectFolder[]> {
const res = await fetch(`${API_BASE}/api/project-folders`, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load project folders');
return res.json();
}
export async function createProjectFolder(token: string, name: string, parentId?: string | null): Promise<ProjectFolder> {
const res = await fetch(`${API_BASE}/api/project-folders`, {
method: 'POST',
headers: authHeaders(token),
body: JSON.stringify({ name, parent_id: parentId ?? null }),
});
if (!res.ok) throw new Error('Failed to create project folder');
return res.json();
}
export async function renameProjectFolder(token: string, id: string, name: string): Promise<ProjectFolder> {
const res = await fetch(`${API_BASE}/api/project-folders/${id}`, {
method: 'PUT',
headers: authHeaders(token),
body: JSON.stringify({ name }),
});
if (!res.ok) throw new Error('Failed to rename project folder');
return res.json();
}
export async function deleteProjectFolder(token: string, id: string): Promise<void> {
const res = await fetch(`${API_BASE}/api/project-folders/${id}`, {
method: 'DELETE',
headers: authHeaders(token),
});
if (!res.ok) throw new Error('Failed to delete project folder');
}
export async function moveProjectToFolder(token: string, projectId: string, folderId: string | null): Promise<Project> {
const res = await fetch(`${API_BASE}/api/projects/${projectId}/folder`, {
method: 'PUT',
headers: authHeaders(token),
body: JSON.stringify({ folder_id: folderId }),
});
if (!res.ok) throw new Error('Failed to move project to folder');
return res.json();
}
export async function getProjectsByFolder(token: string, folderId: string | null): Promise<Project[]> {
const url = folderId === null
? `${API_BASE}/api/projects?folderId=null`
: `${API_BASE}/api/projects?folderId=${folderId}`;
const res = await fetch(url, { headers: authHeaders(token) });
if (!res.ok) throw new Error('Failed to load projects by folder');
return res.json();
}
// ─── Drawings ───────────────────────────────────────────
export async function getDrawings(token: string, projectId: string): Promise<Drawing[]> {
const res = await fetch(`${API_BASE}/api/projects/${projectId}/drawings`, { headers: authHeaders(token) });
+339 -2
View File
@@ -134,6 +134,343 @@
background: var(--bg-primary, #1a1a2e);
color: var(--text-primary, #e0e0e0);
font-family: 'Inter', system-ui, sans-serif;
display: flex;
flex-direction: column;
}
.dashboard-3col {
display: grid;
grid-template-columns: 240px 1fr 300px;
flex: 1;
gap: 1px;
background: var(--border-color, #2a2a4a);
overflow: hidden;
min-height: 0;
}
.dashboard-treeview {
background: var(--bg-secondary, #16213e);
overflow-y: auto;
padding: 12px;
position: relative;
}
.dashboard-info-panel {
background: var(--bg-secondary, #16213e);
overflow-y: auto;
padding: 16px;
}
.treeview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.treeview-header h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #e0e0e0);
}
.treeview-add-folder-btn {
background: var(--accent-primary, #4a9eff);
color: white;
border: none;
border-radius: 4px;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.treeview-add-folder-btn:hover { opacity: 0.85; }
.folder-create-form {
margin-bottom: 8px;
padding: 8px;
background: var(--bg-input, #0f0f23);
border-radius: 6px;
border: 1px solid var(--border-color, #2a2a4a);
}
.folder-create-form input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 4px;
background: var(--bg-primary, #1a1a2e);
color: var(--text-primary, #e0e0e0);
font-size: 0.8125rem;
outline: none;
box-sizing: border-box;
}
.folder-create-form input:focus { border-color: var(--accent-primary, #4a9eff); }
.folder-create-actions {
display: flex;
gap: 4px;
margin-top: 4px;
}
.folder-create-actions button {
padding: 4px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
}
.folder-create-actions button:first-child {
background: var(--accent-primary, #4a9eff);
color: white;
}
.folder-create-actions button:last-child {
background: transparent;
color: var(--text-secondary, #888);
border: 1px solid var(--border-color, #2a2a4a);
}
.folder-rename-form {
margin-bottom: 8px;
padding: 8px;
background: var(--bg-input, #0f0f23);
border-radius: 6px;
border: 1px solid var(--border-color, #2a2a4a);
}
.folder-rename-form input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 4px;
background: var(--bg-primary, #1a1a2e);
color: var(--text-primary, #e0e0e0);
font-size: 0.8125rem;
outline: none;
box-sizing: border-box;
}
.folder-rename-form input:focus { border-color: var(--accent-primary, #4a9eff); }
.folder-action-buttons {
display: inline-flex;
gap: 2px;
margin-left: 4px;
}
.folder-action-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
font-size: 12px;
color: var(--text-secondary, #888);
border-radius: 3px;
transition: color 0.15s, background 0.15s;
}
.folder-action-btn:hover { color: var(--accent-primary, #4a9eff); background: rgba(74, 158, 255, 0.1); }
.folder-action-btn.delete:hover { color: #e74c3c; background: rgba(231, 76, 60, 0.1); }
.tree-drop-target {
background: rgba(74, 158, 255, 0.15) !important;
outline: 2px dashed var(--accent-primary, #4a9eff);
}
.treeview-drop-overlay {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
}
.treeview-drop-zone {
pointer-events: auto;
}
.context-menu-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 5000;
}
.context-menu {
position: fixed;
z-index: 5001;
background: var(--bg-secondary, #16213e);
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
min-width: 160px;
padding: 4px 0;
}
.context-menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 16px;
background: none;
border: none;
cursor: pointer;
color: var(--text-primary, #e0e0e0);
font-size: 0.8125rem;
transition: background 0.15s;
}
.context-menu-item:hover { background: rgba(74, 158, 255, 0.1); }
.context-menu-item.danger:hover { background: rgba(231, 76, 60, 0.1); color: #e74c3c; }
.dashboard-project-card.selected {
border-color: var(--accent-primary, #4a9eff);
box-shadow: 0 0 0 1px var(--accent-primary, #4a9eff);
}
.dashboard-open-btn {
padding: 4px 10px;
border: 1px solid var(--accent-primary, #4a9eff);
border-radius: 4px;
background: var(--accent-primary, #4a9eff);
color: white;
cursor: pointer;
font-size: 12px;
font-weight: 600;
}
.dashboard-open-btn:hover { opacity: 0.85; }
.info-panel-title {
font-size: 1.125rem;
font-weight: 700;
margin: 0 0 8px 0;
color: var(--text-primary, #e0e0e0);
}
.info-panel-description {
font-size: 0.8125rem;
color: var(--text-secondary, #888);
margin: 0 0 16px 0;
line-height: 1.5;
}
.info-panel-section {
margin-bottom: 20px;
}
.info-panel-section h4 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-secondary, #888);
margin: 0 0 8px 0;
padding-bottom: 4px;
border-bottom: 1px solid var(--border-color, #2a2a4a);
}
.info-panel-meta {
margin: 0;
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
}
.info-panel-meta dt {
font-size: 0.75rem;
color: var(--text-secondary, #888);
font-weight: 500;
}
.info-panel-meta dd {
font-size: 0.75rem;
color: var(--text-primary, #e0e0e0);
margin: 0;
}
.info-panel-loading, .info-panel-empty {
font-size: 0.75rem;
color: var(--text-secondary, #888);
padding: 8px 0;
}
.info-panel-shares {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.info-panel-share-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
border: 1px solid var(--border-color, #2a2a4a);
border-radius: 4px;
font-size: 0.75rem;
}
.info-panel-share-email {
color: var(--text-primary, #e0e0e0);
}
.info-panel-share-perm {
font-size: 0.6875rem;
text-transform: uppercase;
font-weight: 600;
padding: 1px 6px;
border-radius: 3px;
background: var(--border-color, #2a2a4a);
color: var(--text-secondary, #888);
}
.info-panel-open-btn {
width: 100%;
padding: 10px;
border: none;
border-radius: 6px;
background: var(--accent-primary, #4a9eff);
color: white;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
margin-top: 8px;
}
.info-panel-open-btn:hover { opacity: 0.85; }
.info-panel-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
}
.info-panel-placeholder p {
color: var(--text-secondary, #888);
font-size: 0.875rem;
text-align: center;
}
@media (max-width: 1024px) {
.dashboard-3col {
grid-template-columns: 1fr;
}
.dashboard-treeview, .dashboard-info-panel {
display: none;
}
}
.dashboard-header {
@@ -179,9 +516,9 @@
}
.dashboard-content {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
overflow-y: auto;
background: var(--bg-secondary, #16213e);
}
.dashboard-section-header {