feat(nextcloud-files): Dateiansicht mit Ablage-Kacheln, Ziehen und Ablegen und Übertragungsleiste

- Liste als echte Dateitabelle und Raster mit Vorschaubildern; Typkacheln je Dateifamilie (OKLCH-Tokens, Kontrast hell und dunkel per Test geprüft), Ordner im gedämpften Mosaik-Gelb
- Ordnerpfad mit Ablagezielen, Auswahlleiste (klebt beim Scrollen), Tastatur (Enter, Rücktaste/Alt+Pfeil hoch, Entf, F2, Strg+A, Escape), Zeilen- und Rechtsklickmenü
- Dialoge für neuen Ordner, Umbenennen, Verschieben (eigene Ordnerwahl) und Löschen in den Papierkorb
- Ziehen und Ablegen mit Streifen-Markierung und Ordnerzeilen als Ziel, auch ganze Ordner; Übertragungsleiste mit Fortschritt, Abbrechen, Klartextfehlern und Rückfrage bei vergebenem Namen
- Speicheranzeige zählt im Unterordner den Speicher des ganzen Kontos; Ansicht je Benutzer gemerkt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:43:46 +02:00
parent 4d4a0b31cb
commit 86fbe8f396
35 changed files with 5575 additions and 11 deletions
@@ -0,0 +1,84 @@
/**
* Pfade der Dateiansicht (quick-261008-mzu). Pfade beginnen immer mit "/";
* die Wurzel ist "/". Die API prueft jeden Pfad noch einmal selbst (D-H) —
* diese Pruefungen dienen nur der verstaendlichen Rueckmeldung im Dialog.
*/
export const ROOT = '/';
const MAX_NAME_LENGTH = 250;
export function join(dir: string, name: string): string {
const base = dir.endsWith('/') ? dir.slice(0, -1) : dir;
return `${base}/${name}`;
}
export function parent(path: string): string {
if (path === ROOT || path === '') return ROOT;
const trimmed = path.endsWith('/') ? path.slice(0, -1) : path;
const i = trimmed.lastIndexOf('/');
return i <= 0 ? ROOT : trimmed.slice(0, i);
}
export function basename(path: string): string {
if (path === ROOT) return '';
const trimmed = path.endsWith('/') ? path.slice(0, -1) : path;
return trimmed.slice(trimmed.lastIndexOf('/') + 1);
}
/** Bestandteile eines Pfads: "/a/b" → ["a", "b"]; Wurzel → []. */
export function segments(path: string): string[] {
return path.split('/').filter((s) => s !== '');
}
/** Pfad aus den ersten `count` Bestandteilen. */
export function pathOf(parts: readonly string[], count = parts.length): string {
return count <= 0 ? ROOT : `/${parts.slice(0, count).join('/')}`;
}
export type NameProblem = 'empty' | 'dots' | 'slash' | 'part' | 'tooLong';
/** null = gueltiger Name, sonst der Grund. */
export function nameProblem(name: string): NameProblem | null {
if (name.trim() === '') return 'empty';
if (name === '.' || name === '..') return 'dots';
if (name.includes('/') || name.includes('\\')) return 'slash';
if (name.toLowerCase().endsWith('.part')) return 'part';
if (name.length > MAX_NAME_LENGTH) return 'tooLong';
return null;
}
export function validName(name: string): boolean {
return nameProblem(name) === null;
}
const DOUBLE_EXTENSIONS = ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst'];
/** Name ohne und mit Endung trennen; "Archiv.tar.gz" → ["Archiv", ".tar.gz"]. */
export function splitExtension(name: string): [string, string] {
const lower = name.toLowerCase();
for (const ext of DOUBLE_EXTENSIONS) {
if (lower.endsWith(ext) && name.length > ext.length) {
return [name.slice(0, -ext.length), name.slice(-ext.length)];
}
}
const dot = name.lastIndexOf('.');
if (dot <= 0) return [name, ''];
return [name.slice(0, dot), name.slice(dot)];
}
/** Freier Name nach dem Muster "Bericht (2).pdf", "Bericht (3).pdf" … */
export function freeName(name: string, taken: readonly string[]): string {
const used = new Set(taken.map((n) => n.toLowerCase()));
if (!used.has(name.toLowerCase())) return name;
const [base, ext] = splitExtension(name);
for (let i = 2; ; i++) {
const candidate = `${base} (${i})${ext}`;
if (!used.has(candidate.toLowerCase())) return candidate;
}
}
/** Liegt `path` innerhalb von `folder` (echt darunter)? "/a" und "/ab" gelten nicht. */
export function isInside(folder: string, path: string): boolean {
if (folder === ROOT) return path !== ROOT;
return path.startsWith(`${folder}/`);
}