Modernize explorer UI and add folder tree
This commit is contained in:
+188
-20
@@ -48,15 +48,21 @@ const icons = {
|
||||
const uiIcons = {
|
||||
arrowLeft: '<path d="M15 18 9 12l6-6"></path>',
|
||||
arrowRight: '<path d="m9 18 6-6-6-6"></path>',
|
||||
chevronDown: '<path d="m6 9 6 6 6-6"></path>',
|
||||
chevronRight: '<path d="m9 18 6-6-6-6"></path>',
|
||||
close: '<path d="M18 6 6 18"></path><path d="m6 6 12 12"></path>',
|
||||
delete: '<path d="M4 7h16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M6 7l1 14h10l1-14"></path><path d="M9 7V4h6v3"></path>',
|
||||
enter: '<path d="M7 8 3 12l4 4"></path><path d="M3 12h12a4 4 0 0 0 4-4V5"></path>',
|
||||
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"></path><path d="M14 2v6h6"></path>',
|
||||
folder: '<path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>',
|
||||
folderUpload: '<path d="M3 6h6l2 2h10v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path><path d="M12 16v-6"></path><path d="m9 13 3-3 3 3"></path>',
|
||||
grid: '<rect x="4" y="4" width="6" height="6" rx="1"></rect><rect x="14" y="4" width="6" height="6" rx="1"></rect><rect x="4" y="14" width="6" height="6" rx="1"></rect><rect x="14" y="14" width="6" height="6" rx="1"></rect>',
|
||||
list: '<path d="M9 6h11"></path><path d="M9 12h11"></path><path d="M9 18h11"></path><path d="M4 6h.01"></path><path d="M4 12h.01"></path><path d="M4 18h.01"></path>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.5.5l2-2a5 5 0 0 0-7-7l-1.1 1.1"></path><path d="M14 11a5 5 0 0 0-7.5-.5l-2 2a5 5 0 0 0 7 7l1.1-1.1"></path>',
|
||||
maximize: '<rect x="5" y="5" width="14" height="14" rx="2"></rect>',
|
||||
minimize: '<path d="M8 4h12v12"></path><path d="M4 8h12v12H4Z"></path>',
|
||||
refresh: '<path d="M20 6v5h-5"></path><path d="M4 18v-5h5"></path><path d="M19 11a7 7 0 0 0-12-4.9L4 9"></path><path d="M5 13a7 7 0 0 0 12 4.9l3-2.9"></path>',
|
||||
tree: '<path d="M4 5h6"></path><path d="M4 12h10"></path><path d="M4 19h14"></path><path d="M4 5v14"></path><circle cx="4" cy="5" r="1.5"></circle><circle cx="4" cy="12" r="1.5"></circle><circle cx="4" cy="19" r="1.5"></circle>',
|
||||
upload: '<path d="M12 16V5"></path><path d="m8 9 4-4 4 4"></path><path d="M5 16v3h14v-3"></path>'
|
||||
};
|
||||
const previewExtensions = new Set(['txt', 'log', 'md', 'rd', 'rst', 'yaml', 'yml', 'json', 'xml', 'csv', 'tsv', 'ini', 'cfg', 'conf', 'env', 'toml', 'sql', 'nfo', 'srt', 'sub', 'vtt', 'm3u', 'm3u8', 'pls', 'sh', 'bash', 'zsh', 'php', 'js', 'ts', 'tsx', 'jsx', 'css', 'html', 'py', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'bat', 'ps1', 'properties', 'service', 'timer', 'mount', 'rules', 'cron', 'plg', 'page', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf', 'mp3', 'flac', 'm4a', 'ogg', 'wav', 'mp4', 'mkv', 'webm', 'mov']);
|
||||
@@ -101,8 +107,8 @@ function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
|
||||
return null;
|
||||
}
|
||||
const win = createWindow('Explorer', 'explorer', {
|
||||
width: 760,
|
||||
height: 460,
|
||||
width: 900,
|
||||
height: 540,
|
||||
x: 24 + state.windows.length * 24,
|
||||
y: 20 + state.windows.length * 20,
|
||||
data: {
|
||||
@@ -112,6 +118,10 @@ function openExplorer(initialPath = state.config?.roots?.[0]?.path) {
|
||||
selectedPaths: [],
|
||||
selectionAnchorPath: null,
|
||||
view: 'icons',
|
||||
treeVisible: true,
|
||||
treeExpanded: [],
|
||||
treeChildren: {},
|
||||
treeLoading: [],
|
||||
loading: false,
|
||||
error: null
|
||||
}
|
||||
@@ -262,20 +272,21 @@ function renderWindow(win) {
|
||||
if (win.kind === 'explorer') renderExplorer(body, win);
|
||||
if (win.kind === 'properties') renderProperties(body);
|
||||
if (win.kind === 'preview') renderPreview(body, win);
|
||||
body.addEventListener('scroll', () => {
|
||||
const scrollHost = body.querySelector('.explorer-content') || body;
|
||||
scrollHost.addEventListener('scroll', () => {
|
||||
if (win.data.lockScroll) {
|
||||
body.scrollTop = win.data.lockScrollTop || 0;
|
||||
body.scrollLeft = win.data.lockScrollLeft || 0;
|
||||
scrollHost.scrollTop = win.data.lockScrollTop || 0;
|
||||
scrollHost.scrollLeft = win.data.lockScrollLeft || 0;
|
||||
return;
|
||||
}
|
||||
win.data.scrollTop = body.scrollTop;
|
||||
win.data.scrollLeft = body.scrollLeft;
|
||||
win.data.scrollTop = scrollHost.scrollTop;
|
||||
win.data.scrollLeft = scrollHost.scrollLeft;
|
||||
});
|
||||
|
||||
el.append(titlebar, body);
|
||||
requestAnimationFrame(() => {
|
||||
body.scrollTop = win.data.scrollTop || 0;
|
||||
body.scrollLeft = win.data.scrollLeft || 0;
|
||||
scrollHost.scrollTop = win.data.scrollTop || 0;
|
||||
scrollHost.scrollLeft = win.data.scrollLeft || 0;
|
||||
});
|
||||
if (!win.maximized) {
|
||||
const resize = document.createElement('div');
|
||||
@@ -288,9 +299,11 @@ function renderWindow(win) {
|
||||
|
||||
function renderExplorer(body, win) {
|
||||
const data = win.data;
|
||||
body.classList.add('explorer-body');
|
||||
body.innerHTML = `
|
||||
<div class="pathbar">
|
||||
<button class="icon up-button" data-action="up" title="Eine Ebene zurueck" aria-label="Eine Ebene zurueck">${iconSvg('arrowLeft')}</button>
|
||||
<button class="icon tree-toggle ${data.treeVisible ? 'active' : ''}" data-action="toggle-tree" title="Ordnerstruktur ${data.treeVisible ? 'ausblenden' : 'einblenden'}" aria-label="Ordnerstruktur ${data.treeVisible ? 'ausblenden' : 'einblenden'}" aria-pressed="${data.treeVisible ? 'true' : 'false'}">${iconSvg('tree')}</button>
|
||||
<input value="${escapeAttr(data.path || '')}" aria-label="Pfad">
|
||||
<button class="icon" data-action="go" title="Öffnen" aria-label="Öffnen">${iconSvg('enter')}</button>
|
||||
<button class="icon" data-action="refresh" title="Neu laden" aria-label="Neu laden">${iconSvg('refresh')}</button>
|
||||
@@ -304,9 +317,14 @@ function renderExplorer(body, win) {
|
||||
<button class="icon ${data.view === 'icons' ? 'active' : ''}" data-action="view-icons" title="Symbolansicht" aria-label="Symbolansicht">${iconSvg('grid')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
|
||||
${data.error ? `<p class="muted">${escapeHtml(data.error)}</p>` : ''}
|
||||
${data.loading ? '<p class="muted">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
|
||||
<div class="explorer-layout ${data.treeVisible ? 'with-tree' : ''}">
|
||||
${data.treeVisible ? renderFolderTree(win) : ''}
|
||||
<div class="explorer-content">
|
||||
<div class="drop-zone ${data.dragOver ? 'drag-over' : ''}">
|
||||
${data.error ? `<p class="muted explorer-message">${escapeHtml(data.error)}</p>` : ''}
|
||||
${data.loading ? '<p class="muted explorer-message">Lade...</p>' : renderFileEntries(data.entries || [], data.view || 'list', selectedPathSet(win))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -314,11 +332,16 @@ function renderExplorer(body, win) {
|
||||
body.querySelector('[data-action="go"]').addEventListener('click', () => loadExplorer(win, input.value));
|
||||
body.querySelector('[data-action="refresh"]').addEventListener('click', () => loadExplorer(win, data.path));
|
||||
body.querySelector('[data-action="up"]').addEventListener('click', () => loadExplorer(win, parentPath(data.path)));
|
||||
body.querySelector('[data-action="toggle-tree"]').addEventListener('click', () => {
|
||||
data.treeVisible = !data.treeVisible;
|
||||
render();
|
||||
});
|
||||
body.querySelector('[data-action="view-list"]').addEventListener('click', () => setExplorerView(win, 'list'));
|
||||
body.querySelector('[data-action="view-icons"]').addEventListener('click', () => setExplorerView(win, 'icons'));
|
||||
body.querySelector('[data-action="upload-files"]').addEventListener('click', () => body.querySelector('[data-upload="files"]').click());
|
||||
body.querySelector('[data-action="upload-folder"]').addEventListener('click', () => body.querySelector('[data-upload="folder"]').click());
|
||||
body.querySelector('[data-action="delete-selected"]').addEventListener('click', () => deleteEntries(win));
|
||||
bindFolderTree(body, win);
|
||||
for (const inputEl of body.querySelectorAll('.upload-input')) {
|
||||
inputEl.addEventListener('change', async () => {
|
||||
try {
|
||||
@@ -332,6 +355,7 @@ function renderExplorer(body, win) {
|
||||
}
|
||||
|
||||
const dropZone = body.querySelector('.drop-zone');
|
||||
const fileScrollHost = body.querySelector('.explorer-content') || body;
|
||||
dropZone.addEventListener('dragenter', (event) => {
|
||||
if (hasDropPayload(event)) {
|
||||
event.preventDefault();
|
||||
@@ -372,12 +396,12 @@ function renderExplorer(body, win) {
|
||||
row.addEventListener('dragstart', (event) => event.preventDefault());
|
||||
row.addEventListener('selectstart', (event) => event.preventDefault());
|
||||
row.addEventListener('pointerdown', (event) => {
|
||||
lockExplorerScroll(win, body);
|
||||
lockExplorerScroll(win, fileScrollHost);
|
||||
startItemPointerDrag(event, win, entry);
|
||||
});
|
||||
row.addEventListener('click', (event) => {
|
||||
if (shouldSuppressItemActivation()) return;
|
||||
selectEntry(win, entry, body, event);
|
||||
selectEntry(win, entry, fileScrollHost, event);
|
||||
});
|
||||
row.addEventListener('dblclick', () => {
|
||||
if (shouldSuppressItemActivation()) return;
|
||||
@@ -402,6 +426,71 @@ function renderExplorer(body, win) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderFolderTree(win) {
|
||||
const roots = state.config?.roots || [];
|
||||
const branches = roots.map((root) => renderTreeBranch(win, root.path, root.label || root.path, 0)).join('');
|
||||
return `
|
||||
<aside class="folder-tree" aria-label="Ordnerstruktur">
|
||||
<div class="tree-heading">
|
||||
<strong>Ordner</strong>
|
||||
<button class="icon" data-action="close-tree" title="Ordnerstruktur ausblenden" aria-label="Ordnerstruktur ausblenden">${iconSvg('close')}</button>
|
||||
</div>
|
||||
<div class="tree-scroll">${branches || '<p class="muted">Keine Roots</p>'}</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderTreeBranch(win, path, label, level) {
|
||||
const data = win.data;
|
||||
const expanded = (data.treeExpanded || []).includes(path);
|
||||
const loading = (data.treeLoading || []).includes(path);
|
||||
const children = data.treeChildren?.[path];
|
||||
const active = normalizePath(data.path) === normalizePath(path);
|
||||
const mayExpand = loading || children === undefined || children.length > 0;
|
||||
const childMarkup = expanded && children?.length
|
||||
? `<div class="tree-children">${children.map((entry) => renderTreeBranch(win, entry.path, entry.name, level + 1)).join('')}</div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="tree-branch">
|
||||
<div class="tree-row ${active ? 'active' : ''}" style="--tree-level:${level}">
|
||||
<button class="tree-caret" data-tree-toggle="${escapeAttr(path)}" title="${expanded ? 'Zuklappen' : 'Aufklappen'}" aria-label="${expanded ? 'Zuklappen' : 'Aufklappen'}" ${mayExpand ? '' : 'disabled'}>
|
||||
${loading ? '<span class="tree-spinner"></span>' : iconSvg(expanded ? 'chevronDown' : 'chevronRight')}
|
||||
</button>
|
||||
<button class="tree-label" data-tree-open="${escapeAttr(path)}" title="${escapeAttr(path)}">
|
||||
<span class="tree-folder">${iconSvg('folder')}</span>
|
||||
<span>${escapeHtml(label)}</span>
|
||||
</button>
|
||||
</div>
|
||||
${childMarkup}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function bindFolderTree(body, win) {
|
||||
body.querySelector('[data-action="close-tree"]')?.addEventListener('click', () => {
|
||||
win.data.treeVisible = false;
|
||||
render();
|
||||
});
|
||||
for (const button of body.querySelectorAll('[data-tree-open]')) {
|
||||
button.addEventListener('click', () => loadExplorer(win, button.dataset.treeOpen));
|
||||
}
|
||||
for (const button of body.querySelectorAll('[data-tree-toggle]')) {
|
||||
button.addEventListener('click', async () => {
|
||||
const path = button.dataset.treeToggle;
|
||||
const expanded = new Set(win.data.treeExpanded || []);
|
||||
if (expanded.has(path)) {
|
||||
expanded.delete(path);
|
||||
win.data.treeExpanded = [...expanded];
|
||||
render();
|
||||
return;
|
||||
}
|
||||
expanded.add(path);
|
||||
win.data.treeExpanded = [...expanded];
|
||||
await loadTreeChildren(win, path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderFileEntries(entries, view, selectedPaths = new Set()) {
|
||||
if (!entries.length) {
|
||||
return '<p class="muted">Dieser Ordner ist leer. Dateien können hier abgelegt werden.</p>';
|
||||
@@ -412,8 +501,8 @@ function renderFileEntries(entries, view, selectedPaths = new Set()) {
|
||||
function renderFileTable(entries, selectedPaths) {
|
||||
const rows = entries.map((entry) => `
|
||||
<tr class="file-row ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
||||
<td><span class="name-cell"><span class="badge">${icons[entry.type] || 'Item'}</span>${escapeHtml(entry.name)}</span></td>
|
||||
<td>${entry.type}</td>
|
||||
<td><span class="name-cell">${fileTypeIcon(entry)}<span>${escapeHtml(entry.name)}</span></span></td>
|
||||
<td>${escapeHtml(typeLabel(entry.type))}</td>
|
||||
<td>${entry.type === 'file' ? formatBytes(entry.size) : ''}</td>
|
||||
<td>${new Date(entry.modifiedAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
@@ -430,15 +519,25 @@ function renderFileTable(entries, selectedPaths) {
|
||||
function renderFileIcons(entries, selectedPaths) {
|
||||
const items = entries.map((entry) => `
|
||||
<button type="button" class="file-row icon-item ${selectedPaths.has(entry.path) ? 'selected' : ''}" data-path="${escapeAttr(entry.path)}" data-type="${escapeAttr(entry.type)}">
|
||||
<span class="file-icon ${escapeAttr(entry.type)}" aria-label="${escapeAttr(icons[entry.type] || 'Item')}"></span>
|
||||
${fileTypeIcon(entry, true)}
|
||||
<span class="icon-name">${escapeHtml(entry.name)}</span>
|
||||
<span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : entry.type}</span>
|
||||
<span class="icon-meta">${entry.type === 'file' ? formatBytes(entry.size) : typeLabel(entry.type)}</span>
|
||||
</button>
|
||||
`).join('');
|
||||
|
||||
return `<div class="icon-grid">${items}</div>`;
|
||||
}
|
||||
|
||||
function fileTypeIcon(entry, large = false) {
|
||||
const name = entry?.type === 'directory' ? 'folder' : entry?.type === 'symlink' ? 'link' : 'file';
|
||||
const label = icons[entry?.type] || 'Item';
|
||||
return `<span class="entry-icon ${escapeAttr(entry?.type || 'other')} ${large ? 'large' : ''}" aria-label="${escapeAttr(label)}">${iconSvg(name)}</span>`;
|
||||
}
|
||||
|
||||
function typeLabel(type) {
|
||||
return ({ directory: 'Ordner', file: 'Datei', symlink: 'Verknüpfung', other: 'Element' })[type] || 'Element';
|
||||
}
|
||||
|
||||
function setExplorerView(win, view) {
|
||||
win.data.view = view;
|
||||
render();
|
||||
@@ -916,6 +1015,8 @@ async function loadExplorer(win, targetPath) {
|
||||
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(targetPath)}`));
|
||||
win.data.path = result.path;
|
||||
win.data.entries = result.entries;
|
||||
cacheTreeChildren(win, result.path, result.entries);
|
||||
await hydrateTreePath(win, result.path);
|
||||
reconcileExplorerSelection(win);
|
||||
} catch (error) {
|
||||
win.data.error = error.message;
|
||||
@@ -925,6 +1026,59 @@ async function loadExplorer(win, targetPath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTreeChildren(win, path) {
|
||||
if (win.data.treeChildren?.[path] !== undefined) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const loading = new Set(win.data.treeLoading || []);
|
||||
loading.add(path);
|
||||
win.data.treeLoading = [...loading];
|
||||
render();
|
||||
try {
|
||||
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(path)}`));
|
||||
cacheTreeChildren(win, result.path, result.entries);
|
||||
} catch (error) {
|
||||
win.data.treeChildren = { ...(win.data.treeChildren || {}), [path]: [] };
|
||||
showError(error);
|
||||
} finally {
|
||||
win.data.treeLoading = (win.data.treeLoading || []).filter((item) => item !== path);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function cacheTreeChildren(win, path, entries) {
|
||||
win.data.treeChildren = {
|
||||
...(win.data.treeChildren || {}),
|
||||
[path]: (entries || []).filter((entry) => entry.type === 'directory')
|
||||
};
|
||||
}
|
||||
|
||||
async function hydrateTreePath(win, targetPath) {
|
||||
const root = rootForPath(targetPath);
|
||||
if (!root) return;
|
||||
const relative = normalizePath(targetPath).slice(normalizePath(root.path).length).split('/').filter(Boolean);
|
||||
const paths = [normalizePath(root.path)];
|
||||
for (const part of relative) paths.push(joinPath(paths.at(-1), part));
|
||||
win.data.treeExpanded = [...new Set([...(win.data.treeExpanded || []), ...paths])];
|
||||
for (const path of paths.slice(0, -1)) {
|
||||
if (win.data.treeChildren?.[path] !== undefined) continue;
|
||||
try {
|
||||
const result = await api(apiUrl(`list.php?path=${encodeURIComponent(path)}`));
|
||||
cacheTreeChildren(win, result.path, result.entries);
|
||||
} catch {
|
||||
win.data.treeChildren = { ...(win.data.treeChildren || {}), [path]: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rootForPath(path) {
|
||||
const normalized = normalizePath(path);
|
||||
return (state.config?.roots || [])
|
||||
.filter((root) => normalized === normalizePath(root.path) || isPathInside(normalized, root.path))
|
||||
.sort((a, b) => b.path.length - a.path.length)[0] || null;
|
||||
}
|
||||
|
||||
async function handleDrop(event, win, targetPath) {
|
||||
const uploadItems = await getDroppedUploadItems(event);
|
||||
recordDebug('html.drop', {
|
||||
@@ -2221,6 +2375,9 @@ function clearPointerDropTarget() {
|
||||
for (const item of workspace.querySelectorAll('.file-row.drop-target')) {
|
||||
item.classList.remove('drop-target');
|
||||
}
|
||||
for (const item of workspace.querySelectorAll('.tree-label.drop-target')) {
|
||||
item.classList.remove('drop-target');
|
||||
}
|
||||
}
|
||||
|
||||
function findPointerDropTarget(event) {
|
||||
@@ -2253,6 +2410,11 @@ function findPointerDropTarget(event) {
|
||||
let row = null;
|
||||
for (const candidate of elements) {
|
||||
try {
|
||||
const treeTarget = candidate.closest?.('.tree-label[data-tree-open]');
|
||||
if (treeTarget && treeTarget.closest?.('.window') === windowEl && isValidPointerTargetPath(treeTarget.dataset.treeOpen, drag)) {
|
||||
row = treeTarget;
|
||||
break;
|
||||
}
|
||||
const candidateRow = candidate.closest?.('.file-row');
|
||||
if (!candidateRow || candidateRow.dataset.type !== 'directory') {
|
||||
continue;
|
||||
@@ -2273,7 +2435,7 @@ function findPointerDropTarget(event) {
|
||||
}
|
||||
}
|
||||
if (row) {
|
||||
return { windowId: win.id, path: row.dataset.path, row };
|
||||
return { windowId: win.id, path: row.dataset.path || row.dataset.treeOpen, row };
|
||||
}
|
||||
|
||||
return { windowId: win.id, path: win.data.path, row: null };
|
||||
@@ -2295,6 +2457,11 @@ function markDropCandidates(sourcePaths, active) {
|
||||
const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
|
||||
row.classList.toggle('drop-candidate', active && valid);
|
||||
}
|
||||
for (const row of workspace.querySelectorAll('.tree-label[data-tree-open]')) {
|
||||
const path = row.dataset.treeOpen || '';
|
||||
const valid = !sourcePaths.includes(path) && isValidPointerTargetPath(path, source);
|
||||
row.classList.toggle('drop-candidate', active && valid);
|
||||
}
|
||||
}
|
||||
|
||||
function isValidPointerTargetPath(path, drag = state.pointerDrag) {
|
||||
@@ -2313,7 +2480,8 @@ function lockExplorerScroll(win, body) {
|
||||
|
||||
function restoreExplorerScroll(win, body) {
|
||||
if (!body) {
|
||||
body = workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
|
||||
body = workspace.querySelector(`.window[data-window-id="${win.id}"] .explorer-content`)
|
||||
|| workspace.querySelector(`.window[data-window-id="${win.id}"] .window-body`);
|
||||
}
|
||||
if (!body) return;
|
||||
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
<p id="statusText">Initialisiere...</p>
|
||||
</div>
|
||||
<nav class="toolbar" aria-label="Workspace actions">
|
||||
<button id="newExplorerButton" title="Explorer öffnen">Explorer</button>
|
||||
<button id="newPropertiesButton" title="Eigenschaften öffnen">Eigenschaften</button>
|
||||
<button id="newQueueButton" title="Transfer-Queue öffnen">Transfers</button>
|
||||
<button id="newExplorerButton" title="Explorer öffnen"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg><span>Explorer</span></button>
|
||||
<button id="newPropertiesButton" title="Eigenschaften öffnen"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v5"></path><path d="M12 8h.01"></path></svg><span>Eigenschaften</span></button>
|
||||
<button id="newQueueButton" title="Transfer-Queue öffnen"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m17 3 4 4-4 4"></path><path d="M3 7h18"></path><path d="m7 21-4-4 4-4"></path><path d="M21 17H3"></path></svg><span>Transfers</span></button>
|
||||
<button id="settingsButton" class="icon" title="Einstellungen" aria-label="Einstellungen" aria-expanded="false"><svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.08A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.88.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.08A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.08A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.88-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.08A1.7 1.7 0 0 0 19.4 15Z"></path></svg></button>
|
||||
</nav>
|
||||
</header>
|
||||
<main id="workspace" class="workspace" aria-label="U-Navigator Workspace"></main>
|
||||
|
||||
+266
-49
@@ -1,17 +1,19 @@
|
||||
.u-nav {
|
||||
--bg: #f4f6f8;
|
||||
--bg: #f1f4f7;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef2f5;
|
||||
--text: #18222d;
|
||||
--muted: #617182;
|
||||
--line: #cfd8e1;
|
||||
--surface-2: #f5f7f9;
|
||||
--surface-3: #e9eef3;
|
||||
--text: #17212b;
|
||||
--muted: #637282;
|
||||
--line: #d7dee5;
|
||||
--accent: #e86f2d;
|
||||
--accent-2: #287a65;
|
||||
--shadow: 0 18px 55px rgba(24, 34, 45, 0.22);
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
--shadow: 0 20px 60px rgba(24, 34, 45, 0.18), 0 3px 12px rgba(24, 34, 45, 0.08);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
display: grid;
|
||||
font: 12.5px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font: 13px/1.4 Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: calc(100vh - 170px);
|
||||
overflow: hidden;
|
||||
@@ -22,7 +24,8 @@
|
||||
.u-nav {
|
||||
--bg: #101418;
|
||||
--surface: #1b2229;
|
||||
--surface-2: #242e37;
|
||||
--surface-2: #222b33;
|
||||
--surface-3: #2b3741;
|
||||
--text: #eef3f7;
|
||||
--muted: #a3b0bd;
|
||||
--line: #36434f;
|
||||
@@ -39,16 +42,29 @@
|
||||
.u-nav button {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease, color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.u-nav button:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.u-nav button:focus-visible,
|
||||
.u-nav input:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 72%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.u-nav button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.u-nav button.primary {
|
||||
@@ -68,15 +84,15 @@
|
||||
.u-nav button.icon {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
flex: 0 0 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 30px;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
max-width: 26px;
|
||||
min-height: 26px;
|
||||
min-width: 26px;
|
||||
max-width: 30px;
|
||||
min-height: 30px;
|
||||
min-width: 30px;
|
||||
padding: 0;
|
||||
width: 26px;
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.u-nav button.icon::before,
|
||||
@@ -103,12 +119,12 @@
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 9px 12px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
|
||||
.u-nav .topbar h1 {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
@@ -125,6 +141,12 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.u-nav .toolbar > button:not(.icon) {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.u-nav .toolbar button.active {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--accent);
|
||||
@@ -139,18 +161,19 @@
|
||||
.u-nav .window {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 240px;
|
||||
min-width: 280px;
|
||||
min-height: 280px;
|
||||
min-width: 340px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.u-nav .window.focused {
|
||||
border-color: color-mix(in srgb, var(--accent) 62%, var(--line));
|
||||
box-shadow: 0 24px 70px rgba(24, 34, 45, 0.24), 0 4px 14px rgba(24, 34, 45, 0.1);
|
||||
}
|
||||
|
||||
.u-nav .window.maximized {
|
||||
@@ -165,12 +188,14 @@
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
grid-template-columns: 1fr auto;
|
||||
min-height: 34px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
min-height: 40px;
|
||||
padding: 5px 7px 5px 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.u-nav .titlebar strong {
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -184,7 +209,14 @@
|
||||
.u-nav .window-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.u-nav .window-body.explorer-body {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.u-nav .resize-handle {
|
||||
@@ -212,16 +244,20 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: nowrap;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.u-nav .pathbar input:not(.upload-input) {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
min-height: 28px;
|
||||
min-height: 32px;
|
||||
min-width: 180px;
|
||||
padding: 0 8px;
|
||||
flex: 1 1 220px;
|
||||
@@ -244,6 +280,159 @@
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.u-nav .pathbar > button.active {
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 60%, var(--line));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.u-nav .explorer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.u-nav .explorer-layout.with-tree {
|
||||
grid-template-columns: clamp(180px, 25%, 250px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.u-nav .explorer-content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.u-nav .folder-tree {
|
||||
background: var(--surface-2);
|
||||
border-right: 1px solid var(--line);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.u-nav .tree-heading {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
justify-content: space-between;
|
||||
min-height: 38px;
|
||||
padding: 4px 6px 4px 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.u-nav .tree-scroll {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.u-nav .tree-row {
|
||||
align-items: center;
|
||||
border-radius: 7px;
|
||||
display: flex;
|
||||
min-height: 30px;
|
||||
padding-left: calc(var(--tree-level) * 15px);
|
||||
}
|
||||
|
||||
.u-nav .tree-row:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.u-nav .tree-row.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.u-nav .tree-caret,
|
||||
.u-nav .tree-label {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.u-nav .tree-caret {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
flex: 0 0 24px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.u-nav .tree-caret .ui-icon {
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
}
|
||||
|
||||
.u-nav .tree-caret:disabled {
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.u-nav .tree-label {
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 7px;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 5px 0 2px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.u-nav .tree-label:hover,
|
||||
.u-nav .tree-caret:hover {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.u-nav .tree-label.drop-candidate {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.u-nav .tree-label.drop-target {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.u-nav .tree-label > span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.u-nav .tree-folder {
|
||||
color: #d97722;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.u-nav .tree-folder .ui-icon {
|
||||
fill: color-mix(in srgb, #f5a24c 32%, transparent);
|
||||
height: 17px;
|
||||
width: 17px;
|
||||
}
|
||||
|
||||
.u-nav .tree-spinner {
|
||||
animation: tree-spin 700ms linear infinite;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 999px;
|
||||
border-top-color: var(--accent);
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
@keyframes tree-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.u-nav .upload-input {
|
||||
display: none;
|
||||
}
|
||||
@@ -251,7 +440,11 @@
|
||||
.u-nav .drop-zone {
|
||||
border: 1px dashed transparent;
|
||||
border-radius: 8px;
|
||||
min-height: 150px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.u-nav .explorer-message {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.u-nav .drop-zone.drag-over {
|
||||
@@ -297,12 +490,18 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.u-nav .file-table thead {
|
||||
position: sticky;
|
||||
top: -8px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.u-nav .file-table th,
|
||||
.u-nav .file-table td {
|
||||
background: var(--surface) !important;
|
||||
border-bottom: 1px solid var(--line);
|
||||
color: var(--text) !important;
|
||||
padding: 6px 6px;
|
||||
padding: 8px 9px;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -326,7 +525,7 @@
|
||||
.u-nav .file-row.dragging,
|
||||
.u-nav .file-row.drop-candidate,
|
||||
.u-nav .file-row.drop-target {
|
||||
background: var(--surface-2);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.u-nav .file-row:hover td,
|
||||
@@ -334,7 +533,7 @@
|
||||
.u-nav .file-row.dragging td,
|
||||
.u-nav .file-row.drop-candidate td,
|
||||
.u-nav .file-row.drop-target td {
|
||||
background: var(--surface-2) !important;
|
||||
background: var(--accent-soft) !important;
|
||||
}
|
||||
|
||||
.u-nav .file-row.drop-candidate td:first-child {
|
||||
@@ -357,29 +556,47 @@
|
||||
.u-nav .name-cell {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 9px;
|
||||
-webkit-user-drag: none;
|
||||
min-width: 130px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.u-nav .badge {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--muted);
|
||||
.u-nav .entry-icon {
|
||||
align-items: center;
|
||||
color: #68798a;
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
flex: 0 0 auto;
|
||||
justify-content: center;
|
||||
min-width: 50px;
|
||||
padding: 1px 7px;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.u-nav .entry-icon.directory {
|
||||
color: #d97722;
|
||||
}
|
||||
|
||||
.u-nav .entry-icon.directory .ui-icon {
|
||||
fill: color-mix(in srgb, #f5a24c 28%, transparent);
|
||||
}
|
||||
|
||||
.u-nav .entry-icon.symlink {
|
||||
color: var(--accent-2);
|
||||
}
|
||||
|
||||
.u-nav .entry-icon.large .ui-icon {
|
||||
height: 47px;
|
||||
stroke-width: 1.55;
|
||||
width: 47px;
|
||||
}
|
||||
|
||||
.u-nav .entry-icon.large.directory .ui-icon {
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
.u-nav .icon-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
|
||||
gap: 7px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
@@ -387,14 +604,14 @@
|
||||
align-items: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
grid-template-rows: auto minmax(28px, auto) auto;
|
||||
justify-items: center;
|
||||
min-height: 108px;
|
||||
padding: 8px 6px;
|
||||
min-height: 120px;
|
||||
padding: 10px 7px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -404,7 +621,7 @@
|
||||
.u-nav .icon-item.dragging,
|
||||
.u-nav .icon-item.drop-candidate,
|
||||
.u-nav .icon-item.drop-target {
|
||||
background: var(--surface-2);
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
@@ -503,7 +720,7 @@
|
||||
.u-nav .icon-name {
|
||||
color: var(--text);
|
||||
display: -webkit-box;
|
||||
font-size: 12px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.25;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user