From d7f221850f5143be037cf7bafb3c851fb9562585 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Tue, 14 Oct 2025 12:19:40 +0200 Subject: [PATCH] design --- frontend/package-lock.json | 27 ++ frontend/package.json | 1 + frontend/src/index.jsx | 489 ++++++++++++++++++++++--------------- frontend/src/styles.css | 242 +++++++++++++----- 4 files changed, 502 insertions(+), 257 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1259bb0..32223e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "papercrate-frontend", "version": "0.1.0", "dependencies": { + "@tabler/icons-react": "3.11.0", "axios": "1.7.7", "react": "18.3.1", "react-dom": "18.3.1", @@ -1963,6 +1964,32 @@ "node": ">=14.0.0" } }, + "node_modules/@tabler/icons": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.11.0.tgz", + "integrity": "sha512-/vZinJNvCYhdAB+RUsyCpanSPuOEKHHIZi4Uu0Bw7ilewHnQhCWUPrT704uHCRli2ROl7spADPmWzAqOganA5A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + } + }, + "node_modules/@tabler/icons-react": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.11.0.tgz", + "integrity": "sha512-xHNBi9mns1slvqos+7LkP3ube4CjWrANMbxMaorzwzO9J/+y1sAEG/sN8CV8FmtpYW/9/gDR+OWCjjLLg0RmAw==", + "license": "MIT", + "dependencies": { + "@tabler/icons": "3.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/codecalm" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 41a215a..59af28a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,7 @@ "lint": "echo \"No linting configured\"" }, "dependencies": { + "@tabler/icons-react": "3.11.0", "axios": "1.7.7", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 0dadcf1..65ea337 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -24,6 +24,14 @@ import './styles.css'; import AssetManager, { getAssetFromVersion, resolveDocumentAssetUrl } from './asset_manager'; import useApiError from './hooks/useApiError'; import SkeuomorphicWorkspace from './skeuomorphic_ws'; +import { + IconChevronRight, + IconDownload as TablerDownload, + IconFolderFilled, + IconPencil, + IconTagFilled, + IconTrash, +} from '@tabler/icons-react'; const runtimeApiBase = typeof window !== 'undefined' && window.__PAPERCRATE_API_BASE_URL @@ -175,150 +183,6 @@ const getTagColorStyle = (hex) => { }; }; - -const IconChevronRight = ({ className }) => ( - -); - -const IconTrash = ({ className }) => ( - -); - -const IconDownload = ({ className }) => ( - -); - -const IconEdit = ({ className }) => ( - -); - -const IconFolder = ({ className }) => ( - -); - const createRootNode = () => ({ id: 'root', name: DEFAULT_FOLDER_NAME, @@ -386,10 +250,20 @@ const FolderNode = ({ onFolderDragEnd, draggingFolderId, }) => { - const canToggle = node.id === 'root' || node.children.length > 0 || !node.loaded; - const icon = canToggle ? : null; - const canDrag = node.id !== 'root'; + const isRoot = node.id === 'root'; + const hasChildren = node.children.length > 0; + const canToggle = !isRoot && (hasChildren || !node.loaded); + const showChevron = !isRoot && hasChildren; + const icon = showChevron ? ( + + ) : null; + const canDrag = !isRoot; const isDragging = draggingFolderId === node.id; + const isExpanded = isRoot ? true : Boolean(node.expanded); + const rowClasses = ['folder-row']; + if (isSelected) { + rowClasses.push('active'); + } const handleToggleClick = (event) => { event.stopPropagation(); @@ -401,7 +275,7 @@ const FolderNode = ({ return (
  • onSelect(node.id)} onDragOver={(event) => onDragOver(event, node.id)} @@ -417,13 +291,22 @@ const FolderNode = ({ } }} > - - {icon} + {!isRoot && ( + + {icon} + + )} + + + {node.name} - {node.name} {node.id !== 'root' && ( )}
    - {node.expanded && node.children.length > 0 && ( -
      + {isExpanded && node.children.length > 0 && ( +
        {renderChildren(node.children, depth + 1)}
      )} @@ -530,6 +413,8 @@ const DocumentsTable = ({ breadcrumbs, onRefresh, onShowSkeuoWorkspace = () => {}, + onRequestCreateFolder = () => {}, + creatingFolder = false, subfolders, documents, searchResults, @@ -654,6 +539,13 @@ const DocumentsTable = ({ )}
      + @@ -732,7 +624,11 @@ const DocumentsTable = ({ >
      - +
      {folder.name} @@ -836,7 +732,11 @@ const DocumentsTable = ({ onAuxClick={(event) => event.stopPropagation()} onContextMenu={(event) => event.stopPropagation()} > - + Download ) : ( @@ -1222,7 +1122,7 @@ const DetailPanel = ({ aria-label="Edit title" title="Edit title" > - + )} @@ -1266,7 +1166,7 @@ const DetailPanel = ({ } }} > - + Download - -
      -
        - {rootNode && renderNodes([rootNode.id], 0)} -
      -
      -
      -
      -

      Tags

      - {tags.length} -
      - -
      + + All tags +
      ); @@ -1896,6 +1790,11 @@ const AppLayout = () => { [reportApiError], ); const [loading, setLoading] = useState(false); + const [isCreateFolderModalOpen, setCreateFolderModalOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(''); + const [createFolderError, setCreateFolderError] = useState(''); + const [creatingFolder, setCreatingFolder] = useState(false); + const createFolderInputRef = useRef(null); const [folderNodes, setFolderNodes] = useState(() => { const rootNode = createRootNode(); return new Map([[rootNode.id, rootNode]]); @@ -2063,6 +1962,7 @@ const AppLayout = () => { const bootstrapInitializedRef = useRef(false); const selectionInitializedRef = useRef(false); const dragCounterRef = useRef(0); + const prefetchedFoldersRef = useRef(new Set(['root'])); const selectionAnchorRef = useRef(routeDocumentId); const selectionOrderRef = useRef(initialSelection); @@ -2098,6 +1998,7 @@ const AppLayout = () => { setPreviewEntries(() => new Map()); previewInflightRef.current = new Map(); dragCounterRef.current = 0; + prefetchedFoldersRef.current = new Set(['root']); breadcrumbFetchRef.current = new Set(); bootstrapInitializedRef.current = false; selectionInitializedRef.current = false; @@ -2613,6 +2514,7 @@ const AppLayout = () => { name: folderId === 'root' ? DEFAULT_FOLDER_NAME : data.folder?.name || existingNode.name, parentId: data.folder?.parent_id ?? existingNode.parentId ?? 'root', children: childIds, + expanded: folderId === 'root' ? true : existingNode.expanded, loaded: true, }); @@ -2641,6 +2543,33 @@ const AppLayout = () => { }, [assetManager, folderContents], ); + const greedyPrefetchFolders = useCallback( + async (startIds) => { + const queue = Array.isArray(startIds) ? [...startIds] : []; + const visited = prefetchedFoldersRef.current; + + while (queue.length) { + const nextId = queue.shift(); + if (!nextId || visited.has(nextId)) { + continue; + } + visited.add(nextId); + + try { + const contents = await ensureFolderData(nextId, { force: false }); + const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : []; + subfolders.forEach((entry) => { + if (entry?.id && !visited.has(entry.id)) { + queue.push(entry.id); + } + }); + } catch (error) { + console.warn('[Folders] Failed to prefetch folder tree for', nextId, error); + } + } + }, + [ensureFolderData], + ); const isInvalidFolderDrop = useCallback( (sourceId, targetId) => { @@ -2882,6 +2811,13 @@ const AppLayout = () => { if (showLoading) setLoading(true); try { const contents = await ensureFolderData(targetId, { force: true }); + if (targetId !== 'root') { + try { + await ensureFolderData('root', { force: false }); + } catch (error) { + console.warn('Failed to refresh root folder tree', error); + } + } const nextSelectedId = applySelectedFolder(targetId, contents); setSearchResults(null); if (nextSelectedId) { @@ -2896,6 +2832,28 @@ const AppLayout = () => { [ensureFolderData, applySelectedFolder, ensureDocumentDetail, notifyApiError], ); + useEffect(() => { + if (!folderContents || typeof folderContents.forEach !== 'function') { + return; + } + + const pendingIds = []; + folderContents.forEach((contents) => { + const subfolders = Array.isArray(contents?.subfolders) ? contents.subfolders : []; + subfolders.forEach((entry) => { + if (entry?.id && !prefetchedFoldersRef.current.has(entry.id)) { + pendingIds.push(entry.id); + } + }); + }); + + if (!pendingIds.length) { + return; + } + + greedyPrefetchFolders(pendingIds).catch(() => {}); + }, [folderContents, greedyPrefetchFolders]); + const selectFolder = useCallback( async (folderId, { replace = false, immediate = false } = {}) => { const targetId = folderId && folderId !== 'root' ? folderId : 'root'; @@ -4284,17 +4242,18 @@ const AppLayout = () => { async (name, onSuccess) => { if (!token) { setStatusMessage('Log in to create folders.', 'error'); - return; + return false; } if (!name.trim()) { setStatusMessage('Folder name cannot be empty.', 'error'); - return; + return false; } const payload = { name: name.trim(), parent_id: selectedFolder === 'root' ? null : selectedFolder, }; setLoading(true); + let succeeded = false; try { const { data } = await api.post('/folders', payload); onSuccess(); @@ -4321,16 +4280,94 @@ const AppLayout = () => { return next; }); await ensureFolderData(selectedFolder, { force: true }); + succeeded = true; } catch (error) { const message = error.response?.data?.error || 'Failed to create folder.'; notifyApiError(error, message); + succeeded = false; } finally { setLoading(false); } + return succeeded; }, [token, selectedFolder, ensureFolderData, notifyApiError, setStatusMessage], ); + const openCreateFolderModal = useCallback(() => { + setNewFolderName(''); + setCreateFolderError(''); + setCreateFolderModalOpen(true); + }, []); + + const closeCreateFolderModal = useCallback(() => { + if (creatingFolder) { + return; + } + setCreateFolderModalOpen(false); + setNewFolderName(''); + setCreateFolderError(''); + }, [creatingFolder]); + + const handleCreateFolderSubmit = useCallback( + async (event) => { + event.preventDefault(); + const trimmed = newFolderName.trim(); + if (!trimmed) { + setCreateFolderError('Folder name cannot be empty.'); + return; + } + + if (!token) { + setCreateFolderError('Log in to create folders.'); + return; + } + + setCreatingFolder(true); + setCreateFolderError(''); + + try { + const success = await handleFolderCreate(trimmed, () => { + setCreateFolderModalOpen(false); + setNewFolderName(''); + setCreateFolderError(''); + }); + if (!success) { + setCreateFolderError('Unable to create folder. Check the status message for details.'); + } + } finally { + setCreatingFolder(false); + } + }, + [newFolderName, handleFolderCreate, token], + ); + + useEffect(() => { + if (!isCreateFolderModalOpen) { + return; + } + const node = createFolderInputRef.current; + if (node) { + node.focus(); + node.select(); + } + }, [isCreateFolderModalOpen]); + + useEffect(() => { + if (!isCreateFolderModalOpen) { + return; + } + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeCreateFolderModal(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isCreateFolderModalOpen, closeCreateFolderModal]); + useEffect(() => { if (!token) return undefined; @@ -4555,6 +4592,9 @@ const AppLayout = () => { notifyApiError(error, 'Failed to load folder.'); } } + if (folderId === 'root') { + return; + } setFolderNodes((prev) => { const next = new Map(prev); const current = next.get(folderId); @@ -4991,7 +5031,6 @@ const AppLayout = () => { onDrop: folderClickHandlers.onDrop, onDragOver: folderClickHandlers.onDragOver, onDragLeave: folderClickHandlers.onDragLeave, - onCreateFolder: handleFolderCreate, onDeleteFolder: handleFolderDelete, selectedFolder, onFolderDragStart: handleFolderDragStart, @@ -5018,6 +5057,8 @@ const AppLayout = () => { breadcrumbs, onRefresh: refreshCurrentFolder, onShowSkeuoWorkspace: showSkeuoWorkspace, + onRequestCreateFolder: openCreateFolderModal, + creatingFolder, subfolders: currentSubfolders, documents, searchResults, @@ -5211,6 +5252,54 @@ const AppLayout = () => { + {isCreateFolderModalOpen && ( +
      +
      event.stopPropagation()} + > +

      Create folder

      +
      + + { + setNewFolderName(event.target.value); + if (createFolderError) { + setCreateFolderError(''); + } + }} + placeholder="Enter folder name" + disabled={creatingFolder} + autoComplete="off" + /> + {createFolderError &&

      {createFolderError}

      } +
      + + +
      +
      +
      +
      + )} ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index caadfb1..bf9066b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,15 +1,56 @@ :root { color-scheme: light; - --bg: #f4f5f7; + /* Neutrals */ + --bg: #f6f7f9; --surface: #ffffff; - --surface-subtle: #f8f9fb; - --fg: #1b1f24; - --muted: #5f6673; - --border: #d0d5dd; - --accent: #2b5cff; - --accent-soft: rgba(43, 92, 255, 0.12); - --danger: #d92c20; - --success: #12805a; + --surface-subtle: #fbfbfd; + --fg: #1e2127; + --muted: #646a75; + --border: #dde1e6; + --shadow-faint: rgba(0, 0, 0, 0.06); + --shadow-soft: rgba(0, 0, 0, 0.12); + --shadow-medium: rgba(0, 0, 0, 0.18); + --overlay-backdrop: rgba(30, 33, 39, 0.45); + --overlay-shadow: rgba(30, 33, 39, 0.18); + + /* Accent (primary action, selection, focus) */ + --accent: #3f6ad8; + --accent-hover: #365cb9; + --on-accent: #ffffff; + + /* Soft tints & outlines (for chips, row hovers, subtle fills) */ + --accent-soft: rgba(63, 106, 216, 0.12); + --accent-elevated: rgba(63, 106, 216, 0.16); + --accent-elevated-strong: rgba(63, 106, 216, 0.22); + --accent-outline: rgba(63, 106, 216, 0.45); + --accent-outline-strong: rgba(63, 106, 216, 0.9); + --accent-focus: rgba(63, 106, 216, 0.85); + + /* States specific to explorer UX */ + --row-hover-bg: rgba(63, 106, 216, 0.06); + --row-active-bg: rgba(63, 106, 216, 0.12); + --sidebar-hover-bg: rgba(63, 106, 216, 0.08); + --sidebar-active-bg: rgba(63, 106, 216, 0.16); + --selection-ring: #9bb6ff; + + /* Semantic colors */ + --success: #2e7d6b; + --success-subtle: rgba(46, 125, 107, 0.12); + --warning: #b98a2e; + --warning-subtle: rgba(185, 138, 46, 0.12); + --danger: #cc5a52; + --danger-border: rgba(204, 90, 82, 0.45); + --danger-soft: rgba(204, 90, 82, 0.12); + --danger-subtle: rgba(204, 90, 82, 0.08); + + /* File-type accents (used for icons/labels) */ + --file-folder: #e6a23c; /* folders */ + --file-doc: #3f6ad8; /* docs */ + --file-code: #5b9b5f; /* code */ + --file-image: #9a6bd1; /* images */ + --file-audio: #db6f9a; /* audio */ + --file-archive: #9c7a59; /* zips */ + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 15px; } @@ -37,7 +78,7 @@ button { border: 1px solid transparent; padding: 0.35rem 0.85rem; background: var(--accent); - color: #fff; + color: var(--on-accent); cursor: pointer; font-weight: 500; transition: background 0.15s ease, border-color 0.15s ease; @@ -49,7 +90,7 @@ button[disabled] { } button:hover:not([disabled]) { - background: #2047d9; + background: var(--accent-hover); } a.button-link { @@ -61,7 +102,7 @@ a.button-link { border: 1px solid transparent; padding: 0.35rem 0.85rem; background: var(--accent); - color: #fff; + color: var(--on-accent); text-decoration: none; font-weight: 500; transition: background 0.15s ease, border-color 0.15s ease; @@ -73,7 +114,7 @@ a.button-link[aria-disabled='true'] { } a.button-link:hover:not([aria-disabled='true']) { - background: #2047d9; + background: var(--accent-hover); } button.secondary { @@ -89,12 +130,12 @@ button.secondary:hover:not([disabled]) { button.danger { background: transparent; color: var(--danger); - border-color: rgba(217, 44, 32, 0.45); + border-color: var(--danger-border); } button.danger:hover:not([disabled]) { border-color: var(--danger); - background: rgba(217, 44, 32, 0.08); + background: var(--danger-subtle); } button.icon-button { @@ -150,6 +191,11 @@ button.icon-button.ghost:hover:not([disabled]) { fill: none; } +.icon--fill path { + stroke: none; + fill: currentColor; +} + .app-shell { height: 100%; display: flex; @@ -327,7 +373,7 @@ button.icon-button.ghost:hover:not([disabled]) { } .tags-table tr.editing { - background: rgba(43, 92, 255, 0.08); + background: var(--sidebar-hover-bg); } .tags-table__label { @@ -342,7 +388,7 @@ button.icon-button.ghost:hover:not([disabled]) { width: 1rem; height: 1rem; border-radius: 2px; - box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); + box-shadow: inset 0 0 0 1px var(--shadow-faint); } .tags-panel__error { @@ -501,22 +547,31 @@ button.icon-button.ghost:hover:not([disabled]) { cursor: pointer; color: var(--fg); transition: background 0.12s ease, color 0.12s ease; - font-size: 0.78rem; } .folder-row span.name { + display: inline-flex; + align-items: center; + gap: 0.25rem; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.folder-row span.name .folder-icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--accent); +} + .folder-row:hover { - background: var(--surface-subtle); + background: var(--sidebar-hover-bg); } .folder-row.active { - background: rgba(43, 92, 255, 0.12); + background: var(--sidebar-active-bg); color: var(--fg); } @@ -563,6 +618,10 @@ button.icon-button.ghost:hover:not([disabled]) { padding: 0; } +.folder-children.folder-children--level1 { + margin-left: 0; +} + .sidebar-section { margin-top: 1rem; display: flex; @@ -570,6 +629,21 @@ button.icon-button.ghost:hover:not([disabled]) { gap: 0.4rem; } +.sidebar { + gap: 0.28rem; + overflow-y: auto; +} + +.sidebar-section:first-of-type, +.sidebar-section--folders { + margin-top: 0; +} + +.sidebar-section--folders { + display: flex; + flex-direction: column; +} + .sidebar-section__header { display: flex; align-items: center; @@ -587,28 +661,47 @@ button.icon-button.ghost:hover:not([disabled]) { color: inherit; } -.sidebar-link { +.sidebar-item { border: none; background: transparent; - padding: 0.3rem 0.3rem; + padding: 0.16rem 0.26rem; text-align: left; - font-size: 0.82rem; color: var(--fg); - border-radius: 3px; + border-radius: 2px; cursor: pointer; transition: background 0.12s ease, color 0.12s ease; + display: flex; + align-items: center; + gap: 0.38rem; + width: 100%; } -.sidebar-link:hover { - background: var(--surface-subtle); +.sidebar-item:hover { + background: var(--sidebar-hover-bg); } -.sidebar-link.active { - background: rgba(43, 92, 255, 0.12); +.sidebar-item.active { + background: var(--sidebar-active-bg); color: var(--accent); font-weight: 600; } +.sidebar-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-item__icon { + width: 1rem; + height: 1rem; + flex-shrink: 0; + color: var(--accent); +} + +.sidebar-item.active .sidebar-item__icon { + color: currentColor; +} + .folder-row.is-drop-target { outline: 2px dashed var(--accent); outline-offset: 2px; @@ -685,7 +778,7 @@ button.icon-button.ghost:hover:not([disabled]) { height: 48px; object-fit: cover; border-radius: 3px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24); + box-shadow: 0 1px 3px var(--shadow-medium); } .thumb-placeholder { @@ -739,8 +832,8 @@ button.icon-button.ghost:hover:not([disabled]) { } .documents-panel tbody tr.folder.focused { - box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.45); - background: rgba(43, 92, 255, 0.08); + box-shadow: inset 2px 0 0 var(--accent-outline); + background: var(--sidebar-hover-bg); } .documents-panel tbody tr.document { @@ -748,8 +841,8 @@ button.icon-button.ghost:hover:not([disabled]) { } .documents-panel tbody tr.document.selected { - background: rgba(43, 92, 255, 0.12); - box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.9); + background: var(--accent-soft); + box-shadow: inset 2px 0 0 var(--accent-outline-strong); } .documents-panel tbody tr.document, @@ -760,7 +853,7 @@ button.icon-button.ghost:hover:not([disabled]) { } .documents-panel tbody tr.document.focused:not(.selected) { - box-shadow: inset 2px 0 0 rgba(43, 92, 255, 0.45); + box-shadow: inset 2px 0 0 var(--accent-outline); } .doc-name { @@ -980,7 +1073,7 @@ button.icon-button.ghost:hover:not([disabled]) { align-items: center; justify-content: center; transition: transform 120ms ease; - filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18)); + filter: drop-shadow(0 2px 6px var(--shadow-soft)); transform-origin: center; border-radius: 6px; overflow: hidden; @@ -1018,10 +1111,10 @@ button.icon-button.ghost:hover:not([disabled]) { .preview-stack__image:hover, .preview-stack__image:focus-visible { - outline-color: rgba(24, 119, 242, 0.85); + outline-color: var(--accent-focus); box-shadow: - inset 0 0 0 999px rgba(24, 119, 242, 0.18), - 0 6px 18px rgba(24, 119, 242, 0.24); + inset 0 0 0 999px var(--accent-elevated), + 0 6px 18px var(--accent-elevated-strong); filter: saturate(118%) brightness(1.05); } @@ -1127,19 +1220,19 @@ form.inline { } .status-banner.success { - background: rgba(18, 128, 90, 0.1); + background: var(--success-subtle); color: var(--success); } .status-banner.error { - background: rgba(217, 44, 32, 0.1); + background: var(--danger-soft); color: var(--danger); } .drop-overlay { position: fixed; inset: 0; - background: rgba(43, 92, 255, 0.12); + background: var(--accent-soft); backdrop-filter: blur(4px); display: none; align-items: center; @@ -1161,6 +1254,57 @@ form.inline { box-shadow: none; border: 1px solid var(--border); } +.modal-backdrop { + position: fixed; + inset: 0; + background: var(--overlay-backdrop); + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + z-index: 3000; +} + +.modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + box-shadow: 0 18px 42px var(--overlay-shadow); + width: min(360px, 100%); + padding: 1.6rem; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal h3 { + margin: 0; + font-size: 1.15rem; + font-weight: 600; +} + +.modal__form { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.modal__form label { + font-weight: 600; + font-size: 0.9rem; +} + +.modal__error { + margin: -0.45rem 0 0; + font-size: 0.85rem; + color: var(--danger); +} + +.modal__actions { + display: flex; + justify-content: flex-end; + gap: 0.6rem; +} @media (max-width: 1080px) { .app-main { @@ -1187,22 +1331,6 @@ form.inline { min-height: auto; } } -.sidebar .column-body { - gap: 0.28rem; -} - -.new-folder-form { - display: flex; - gap: 0.45rem; -} - -.folder-tree-scroller { - flex: 1; - overflow-y: auto; - padding-right: 0.4rem; - margin-right: -0.4rem; - min-height: 0; -} .login-screen { min-height: 100vh; display: flex;