From 86fbe8f396e7d5962cecc0e4310509ff5d876e0e Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 8 Oct 2026 21:43:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(nextcloud-files):=20Dateiansicht=20mit=20A?= =?UTF-8?q?blage-Kacheln,=20Ziehen=20und=20Ablegen=20und=20=C3=9Cbertragun?= =?UTF-8?q?gsleiste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../nextcloud-files/components/Breadcrumb.tsx | 160 +++ .../components/DeleteDialog.tsx | 59 ++ .../nextcloud-files/components/Dialog.tsx | 100 ++ .../components/DropOverlay.tsx | 33 + .../nextcloud-files/components/EntryMenu.tsx | 157 +++ .../components/FileBrowser.test.tsx | 522 ++++++++++ .../components/FileBrowser.tsx | 968 ++++++++++++++++++ .../nextcloud-files/components/FileGrid.tsx | 181 ++++ .../nextcloud-files/components/FileList.tsx | 250 +++++ .../nextcloud-files/components/MoveDialog.tsx | 186 ++++ .../nextcloud-files/components/NameDialog.tsx | 121 +++ .../nextcloud-files/components/QuotaMeter.tsx | 43 + .../components/SelectionBar.tsx | 60 ++ .../nextcloud-files/components/Toolbar.tsx | 138 +++ .../components/TransferBar.test.tsx | 278 +++++ .../components/TransferBar.tsx | 264 +++++ .../nextcloud-files/components/TypeTile.tsx | 94 ++ .../nextcloud-files/components/icons.tsx | 157 +++ .../nextcloud-files-page.test.tsx | 52 + .../(portal)/modules/nextcloud-files/page.tsx | 10 +- apps/web/src/app/globals.css | 95 ++ .../nextcloud-files/drop-entries.ts | 125 +++ .../components/nextcloud-files/error-text.ts | 57 ++ .../nextcloud-files/file-format.test.ts | 79 ++ .../components/nextcloud-files/file-format.ts | 116 +++ .../file-type-contrast.test.ts | 68 ++ .../nextcloud-files/file-types.test.ts | 53 + .../components/nextcloud-files/file-types.ts | 200 ++++ .../components/nextcloud-files/paths.test.ts | 60 ++ .../src/components/nextcloud-files/paths.ts | 84 ++ .../nextcloud-files/selection.test.ts | 79 ++ .../components/nextcloud-files/selection.ts | 82 ++ .../nextcloud-files/use-transfers.ts | 323 ++++++ apps/web/src/messages/de.json | 166 ++- apps/web/src/messages/en.json | 166 ++- 35 files changed, 5575 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/Breadcrumb.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/DeleteDialog.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/Dialog.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/DropOverlay.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/EntryMenu.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/MoveDialog.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/NameDialog.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/QuotaMeter.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/SelectionBar.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/Toolbar.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx create mode 100644 apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx create mode 100644 apps/web/src/components/nextcloud-files/drop-entries.ts create mode 100644 apps/web/src/components/nextcloud-files/error-text.ts create mode 100644 apps/web/src/components/nextcloud-files/file-format.test.ts create mode 100644 apps/web/src/components/nextcloud-files/file-format.ts create mode 100644 apps/web/src/components/nextcloud-files/file-type-contrast.test.ts create mode 100644 apps/web/src/components/nextcloud-files/file-types.test.ts create mode 100644 apps/web/src/components/nextcloud-files/file-types.ts create mode 100644 apps/web/src/components/nextcloud-files/paths.test.ts create mode 100644 apps/web/src/components/nextcloud-files/paths.ts create mode 100644 apps/web/src/components/nextcloud-files/selection.test.ts create mode 100644 apps/web/src/components/nextcloud-files/selection.ts create mode 100644 apps/web/src/components/nextcloud-files/use-transfers.ts diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/Breadcrumb.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/Breadcrumb.tsx new file mode 100644 index 0000000..993fa69 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/Breadcrumb.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useEffect, useRef, useState } from 'react'; +import { pathOf, ROOT, segments } from '@/components/nextcloud-files/paths'; +import { ChevronRightIcon } from './icons'; + +/** Ab so vielen Ordnerebenen wird die Mitte zu "…" zusammengefasst. */ +const COLLAPSE_FROM = 4; + +interface Crumb { + label: string; + path: string; +} + +/** + * Ordnerpfad (quick-261008-mzu): "Alle Dateien", dann die Ordner. Jede Stufe + * ausser der letzten ist ein Knopf und zugleich ein Ablageziel beim Ziehen + * (`data-drop-folder`). Lange Pfade fassen die Mitte in einem Menue zusammen. + */ +export function Breadcrumb({ + path, + onNavigate, + dropTarget, +}: { + path: string; + onNavigate: (path: string) => void; + /** Ordner, ueber dem gerade Dateien schweben (hebt die Stufe hervor). */ + dropTarget?: string | null; +}) { + const t = useTranslations('nextcloudFiles.browser'); + const parts = segments(path); + const crumbs: Crumb[] = [ + { label: t('rootName'), path: ROOT }, + ...parts.map((label, i) => ({ label, path: pathOf(parts, i + 1) })), + ]; + + let visible: (Crumb | 'more')[] = crumbs; + let hidden: Crumb[] = []; + if (crumbs.length > COLLAPSE_FROM) { + hidden = crumbs.slice(1, -2); + visible = [crumbs[0], 'more', ...crumbs.slice(-2)]; + } + + return ( + + ); +} + +function MoreCrumbs({ + crumbs, + onNavigate, + label, +}: { + crumbs: Crumb[]; + onNavigate: (path: string) => void; + label: string; +}) { + const [open, setOpen] = useState(false); + const button = useRef(null); + const menu = useRef(null); + + useEffect(() => { + if (!open) return; + menu.current?.querySelector('[role="menuitem"]')?.focus(); + const close = (e: MouseEvent) => { + if (!menu.current?.contains(e.target as Node) && e.target !== button.current) setOpen(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [open]); + + return ( + + + {open && ( +
{ + if (e.key === 'Escape') { + e.stopPropagation(); + setOpen(false); + button.current?.focus(); + } + }} + className="absolute left-0 top-full z-30 mt-1 min-w-48 rounded-md border border-border bg-card py-1 shadow-lg" + > + {crumbs.map((c) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/DeleteDialog.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/DeleteDialog.tsx new file mode 100644 index 0000000..fd5b684 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/DeleteDialog.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useState } from 'react'; +import type { NcEntry } from '@/lib/nextcloud-files-api'; +import { Dialog } from './Dialog'; + +/** + * Rueckfrage vor dem Loeschen (quick-261008-mzu): nennt den Namen bzw. die + * Anzahl und dass die Eintraege im Papierkorb der Nextcloud wiederherstellbar sind. + */ +export function DeleteDialog({ + entries, + onConfirm, + onClose, +}: { + entries: NcEntry[]; + onConfirm: () => Promise; + onClose: () => void; +}) { + const t = useTranslations('nextcloudFiles.dialogs'); + const [busy, setBusy] = useState(false); + const one = entries.length === 1; + + return ( + + + + + } + > +

{t('delete.body')}

+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/Dialog.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/Dialog.tsx new file mode 100644 index 0000000..5334248 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/Dialog.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { type ReactNode, useEffect, useId, useRef } from 'react'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Gemeinsamer Rahmen der Dialoge der Dateiansicht (quick-261008-mzu): modal, + * Fokus bleibt im Dialog (Tab kreist), Escape schliesst, beim Schliessen kehrt + * der Fokus dorthin zurueck, wo er vorher war. + */ +export function Dialog({ + title, + onClose, + children, + footer, + wide = false, + initialFocus, +}: { + title: ReactNode; + onClose: () => void; + children: ReactNode; + footer?: ReactNode; + wide?: boolean; + /** Element, das beim Oeffnen den Fokus bekommt (sonst das erste fokussierbare). */ + initialFocus?: React.RefObject; +}) { + const titleId = useId(); + const panel = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // biome-ignore lint/correctness/useExhaustiveDependencies: nur beim Oeffnen fokussieren + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + const target = + initialFocus?.current ?? + panel.current?.querySelector(FOCUSABLE) ?? + panel.current; + target?.focus(); + return () => { + if (previous && document.contains(previous)) previous.focus(); + }; + }, []); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onCloseRef.current(); + return; + } + if (e.key !== 'Tab' || !panel.current) return; + const items = Array.from(panel.current.querySelectorAll(FOCUSABLE)); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: Hintergrund schliesst per Klick, Tastatur ueber Escape + // biome-ignore lint/a11y/noNoninteractiveElementInteractions: dito (Hintergrund des Dialogs) +
{ + if (e.target === e.currentTarget) onCloseRef.current(); + }} + > + {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: Fokusfalle und Escape im Dialog */} +
+

+ {title} +

+
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/DropOverlay.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/DropOverlay.tsx new file mode 100644 index 0000000..9551509 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/DropOverlay.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { UploadIcon } from './icons'; + +/** + * Ablage-Markierung (quick-261008-mzu, L-09): das eine laute Element der + * Dateiansicht. Liegt ueber der Dateikarte, faengt keine Zeigerereignisse ab + * (die Ordnerzeilen darunter bleiben Ablageziele) und nennt das Ziel gross. + * Das Schild bleibt beim Scrollen im sichtbaren Bereich. + */ +export function DropOverlay({ targetName }: { targetName: string }) { + const t = useTranslations('nextcloudFiles.browser.drop'); + return ( + + ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/EntryMenu.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/EntryMenu.tsx new file mode 100644 index 0000000..16f1826 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/EntryMenu.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; + +export interface EntryAction { + id: string; + label: string; + icon: ReactNode; + /** Verweis statt Knopf (z. B. "In Nextcloud öffnen" im neuen Tab). */ + href?: string; + onSelect?: () => void; + destructive?: boolean; + /** Trennlinie vor diesem Eintrag. */ + separated?: boolean; +} + +/** + * Menue eines Eintrags (quick-261008-mzu): dasselbe fuer den ⋯-Knopf und den + * Rechtsklick. Aus einer Aktionsliste gebaut, damit Etappe 2 "Teilen" ergaenzen + * kann. Pfeiltasten wandern, Escape schliesst, der Fokus kehrt zum Ausloeser zurueck. + */ +export function EntryMenu({ + label, + actions, + x, + y, + onClose, + returnFocus, +}: { + label: string; + actions: EntryAction[]; + /** Bildschirmposition (Mauszeiger oder Unterkante des Knopfes). */ + x: number; + y: number; + onClose: () => void; + returnFocus?: HTMLElement | null; +}) { + const menu = useRef(null); + const [pos, setPos] = useState({ left: x, top: y }); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + // Im Fenster halten: rechts und unten nicht ueber den Rand hinaus. + useLayoutEffect(() => { + const el = menu.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + setPos({ + left: Math.max(8, Math.min(x, vw - rect.width - 8)), + top: y + rect.height > vh - 8 ? Math.max(8, y - rect.height) : y, + }); + }, [x, y]); + + useEffect(() => { + menu.current?.querySelector('[role="menuitem"]')?.focus(); + const close = (e: Event) => { + if (!menu.current?.contains(e.target as Node)) onCloseRef.current(); + }; + // Beim Oeffnen wechselt oft die Leiste (Auswahl) und der Browser korrigiert die Scrollposition; + // dieses Scrollen schliesst das Menue nicht, erst ein spaeteres des Benutzers. + const openedAt = Date.now(); + const onScroll = () => { + if (Date.now() - openedAt > 400) onCloseRef.current(); + }; + document.addEventListener('mousedown', close); + window.addEventListener('scroll', onScroll, { passive: true }); + window.addEventListener('resize', onScroll); + return () => { + document.removeEventListener('mousedown', close); + window.removeEventListener('scroll', onScroll); + window.removeEventListener('resize', onScroll); + if (returnFocus && document.contains(returnFocus)) returnFocus.focus(); + }; + }, [returnFocus]); + + const onKeyDown = (e: React.KeyboardEvent) => { + const items = Array.from( + menu.current?.querySelectorAll('[role="menuitem"]') ?? [], + ); + const index = items.indexOf(document.activeElement as HTMLElement); + if (e.key === 'Escape' || e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + onCloseRef.current(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + items[(index + 1) % items.length]?.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + items[(index - 1 + items.length) % items.length]?.focus(); + } else if (e.key === 'Home') { + e.preventDefault(); + items[0]?.focus(); + } else if (e.key === 'End') { + e.preventDefault(); + items[items.length - 1]?.focus(); + } + // Die Tastenbelegung der Dateiansicht (Entf, F2 …) gilt nicht im Menue. + e.stopPropagation(); + }; + + const itemClass = (a: EntryAction) => + `flex h-9 w-full items-center gap-2.5 px-3 text-left text-sm outline-none hover:bg-accent focus-visible:bg-accent focus:bg-accent ${ + a.destructive ? 'text-status-down-fg' : 'text-foreground' + }`; + + return createPortal( +
e.preventDefault()} + style={{ left: pos.left, top: pos.top }} + className="fixed z-50 min-w-52 rounded-lg border border-border bg-card py-1 shadow-lg motion-safe:animate-[nc-fade_100ms_ease-out]" + > + {actions.map((a) => ( +
+ {a.separated &&
} + {a.href ? ( + onCloseRef.current()} + > + {a.icon} + {a.label} + + ) : ( + + )} +
+ ))} +
, + document.body, + ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx new file mode 100644 index 0000000..a5f898d --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx @@ -0,0 +1,522 @@ +import { + act, + cleanup, + fireEvent, + render as rtlRender, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + type NcEntry, + type NcListing, + NextcloudFilesRequestError, +} from '@/lib/nextcloud-files-api'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import de from '@/messages/de.json'; +import { FileBrowser } from './FileBrowser'; + +const mockList = vi.fn(); +const mockCreate = vi.fn(); +const mockMove = vi.fn(); +const mockDelete = vi.fn(); +const mockUpload = vi.fn(); + +vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listFolder: (...a: unknown[]) => mockList(...a), + createFolder: (...a: unknown[]) => mockCreate(...a), + moveEntry: (...a: unknown[]) => mockMove(...a), + deleteEntry: (...a: unknown[]) => mockDelete(...a), + }; +}); +vi.mock('@/lib/nextcloud-files-upload', () => ({ + uploadFile: (...a: unknown[]) => mockUpload(...a), +})); + +const GB = 1024 ** 3; +const NOW = Date.now(); + +function entry(path: string, over: Partial = {}): NcEntry { + const name = path.slice(path.lastIndexOf('/') + 1); + return { + name, + path, + type: 'file', + size: 1536, + mime: null, + mtime: new Date(NOW - 3 * 3600_000).toISOString(), + etag: `e-${name}`, + fileId: String(name.length + 100), + permissions: 'RGDNVW', + hasPreview: false, + favorite: false, + ...over, + }; +} +const folder = (path: string, over: Partial = {}) => + entry(path, { type: 'folder', size: 4096, ...over }); + +function listing(path: string, entries: NcEntry[], over: Partial = {}): NcListing { + return { + path, + entries, + quota: { used: 4.2 * GB, available: 5.8 * GB }, + truncated: false, + ...over, + }; +} + +const ROOT_ENTRIES = [ + entry('/Datei 10.txt'), + folder('/Zebra'), + entry('/Datei 2.txt', { size: 12 * 1024 * 1024 }), + folder('/Äpfel'), + folder('/Projekte'), + entry('/Bericht.pdf'), +]; + +const FOLDERS: Record = { + '/': ROOT_ENTRIES, + '/Projekte': [folder('/Projekte/Halle 3'), entry('/Projekte/Plan.xlsx')], + '/Projekte/Halle 3': [], + '/Zebra': [], + '/Äpfel': [], +}; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +let clicked: string[] = []; +let onExpired: ReturnType; + +beforeEach(() => { + window.history.replaceState(null, '', '/modules/nextcloud-files'); + window.localStorage.clear(); + useAuthStore.setState({ user: { id: 'u1' } as never }); + mockList.mockReset().mockImplementation(async (p: string) => listing(p, FOLDERS[p] ?? [])); + mockCreate.mockReset().mockResolvedValue({ path: '/x' }); + mockMove.mockReset().mockResolvedValue({ from: '', to: '' }); + mockDelete.mockReset().mockResolvedValue({ deleted: true }); + mockUpload.mockReset().mockResolvedValue({ path: '/x', size: 1 }); + onExpired = vi.fn(); + clicked = []; + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function ( + this: HTMLAnchorElement, + ) { + clicked.push(this.href); + }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +async function mount() { + const view = render(); + await screen.findByRole('row', { name: 'Projekte' }); + return view; +} + +const rowNames = () => + screen + .getAllByRole('row') + .map((r) => r.getAttribute('aria-label')) + .filter(Boolean); + +describe('FileBrowser — Liste', () => { + it('Ordner zuerst, dann Dateien, deutsche Sortierung mit Zahlen; Groesse rechts in Tabellenziffern, Geaendert relativ mit absolutem Titel', async () => { + await mount(); + expect(rowNames()).toEqual([ + 'Äpfel', + 'Projekte', + 'Zebra', + 'Bericht.pdf', + 'Datei 2.txt', + 'Datei 10.txt', + ]); + const row = screen.getByRole('row', { name: 'Datei 2.txt' }); + const size = within(row).getByText('12 MB'); + expect(size.className).toContain('text-right'); + expect(size.className).toContain('tabular-nums'); + const modified = within(row).getByText('vor 3 Std.'); + expect(modified.getAttribute('title')).toMatch(/\d{2}\.\d{2}\.\d{4}, \d{2}:\d{2}/); + }); + + it('Klick auf einen Ordnernamen laedt ihn; der Pfad beginnt mit "Alle Dateien", die letzte Stufe ist kein Knopf', async () => { + await mount(); + fireEvent.click(within(screen.getByRole('row', { name: 'Projekte' })).getByText('Projekte')); + await screen.findByRole('row', { name: 'Plan.xlsx' }); + expect(mockList).toHaveBeenLastCalledWith('/Projekte'); + const nav = screen.getByRole('navigation', { name: 'Ordnerpfad' }); + expect(within(nav).getByRole('button', { name: 'Alle Dateien' })).toBeTruthy(); + const current = within(nav).getByText('Projekte'); + expect(current.tagName).not.toBe('BUTTON'); + expect(current.getAttribute('aria-current')).toBe('page'); + expect(window.location.search).toBe('?path=%2FProjekte'); + }); + + it('startet im Ordner aus ?path= und geht mit Backspace und Alt+Pfeil-hoch eine Ebene hoeher', async () => { + window.history.replaceState(null, '', '/modules/nextcloud-files?path=%2FProjekte%2FHalle%203'); + render(); + await screen.findByText('Dieser Ordner ist leer.'); + expect(mockList).toHaveBeenCalledWith('/Projekte/Halle 3'); + const region = screen.getByRole('region', { name: 'Dateien' }); + fireEvent.keyDown(region, { key: 'Backspace' }); + await screen.findByRole('row', { name: 'Plan.xlsx' }); + fireEvent.keyDown(screen.getByRole('row', { name: 'Plan.xlsx' }), { + key: 'ArrowUp', + altKey: true, + }); + await screen.findByRole('row', { name: 'Bericht.pdf' }); + expect(window.location.search).toBe(''); + }); + + it('Enter oeffnet einen Ordner und laedt eine Datei herunter', async () => { + await mount(); + const file = screen.getByRole('row', { name: 'Bericht.pdf' }); + fireEvent.focus(file); + fireEvent.keyDown(file, { key: 'Enter' }); + expect(clicked).toHaveLength(1); + expect(clicked[0]).toContain('/modules/nextcloud-files/download?path=%2FBericht.pdf'); + const dir = screen.getByRole('row', { name: 'Projekte' }); + fireEvent.focus(dir); + fireEvent.keyDown(dir, { key: 'Enter' }); + await screen.findByRole('row', { name: 'Plan.xlsx' }); + }); + + it('Auswahl mit Strg-Klick ersetzt die Werkzeugleiste; Strg+A waehlt alles, Escape hebt auf', async () => { + await mount(); + fireEvent.click(screen.getByRole('row', { name: 'Bericht.pdf' })); + fireEvent.click(screen.getByRole('row', { name: 'Datei 2.txt' }), { ctrlKey: true }); + const bar = screen.getByRole('toolbar', { name: '2 ausgewählt' }); + for (const name of ['Herunterladen', 'Verschieben', 'Löschen', 'Auswahl aufheben']) { + expect(within(bar).getByRole('button', { name })).toBeTruthy(); + } + expect(screen.queryByRole('button', { name: 'Hochladen' })).toBeNull(); + fireEvent.keyDown(screen.getByRole('row', { name: 'Bericht.pdf' }), { + key: 'a', + ctrlKey: true, + }); + expect(screen.getByText('6 ausgewählt')).toBeTruthy(); + fireEvent.keyDown(screen.getByRole('row', { name: 'Bericht.pdf' }), { key: 'Escape' }); + expect(screen.queryByText(/ausgewählt/)).toBeNull(); + expect(screen.getByRole('button', { name: 'Hochladen' })).toBeTruthy(); + }); + + it('Herunterladen mehrerer Eintraege nutzt das Auswahl-ZIP', async () => { + await mount(); + fireEvent.click(screen.getByRole('row', { name: 'Bericht.pdf' })); + fireEvent.click(screen.getByRole('row', { name: 'Projekte' }), { ctrlKey: true }); + fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' })); + expect(clicked[0]).toContain('/download/zip?dir=%2F&name=Projekte&name=Bericht.pdf'); + }); + + it('Entf oeffnet die Rueckfrage mit Anzahl und Papierkorb; Bestaetigen loescht je Pfad und laedt neu', async () => { + await mount(); + fireEvent.click(screen.getByRole('row', { name: 'Bericht.pdf' })); + fireEvent.click(screen.getByRole('row', { name: 'Datei 10.txt' }), { ctrlKey: true }); + fireEvent.keyDown(screen.getByRole('row', { name: 'Bericht.pdf' }), { key: 'Delete' }); + const dialog = screen.getByRole('dialog', { name: '2 Elemente löschen?' }); + expect(within(dialog).getByText(/Papierkorb Ihrer Nextcloud/)).toBeTruthy(); + const before = mockList.mock.calls.length; + fireEvent.click(within(dialog).getByRole('button', { name: 'In den Papierkorb verschieben' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(mockDelete.mock.calls.map((c) => c[0])).toEqual(['/Bericht.pdf', '/Datei 10.txt']); + expect(mockList.mock.calls.length).toBeGreaterThan(before); + expect(await screen.findByText('2 Elemente in den Papierkorb verschoben.')).toBeTruthy(); + }); + + it('F2 oeffnet Umbenennen mit markiertem Namen ohne Endung; nameTaken erscheint im Dialog', async () => { + mockMove.mockRejectedValue(new NextcloudFilesRequestError(409, 'nameTaken', 'Name vergeben')); + await mount(); + const row = screen.getByRole('row', { name: 'Bericht.pdf' }); + fireEvent.focus(row); + fireEvent.keyDown(row, { key: 'F2' }); + const dialog = screen.getByRole('dialog', { name: 'Umbenennen' }); + const input = within(dialog).getByLabelText('Neuer Name') as HTMLInputElement; + expect(input.value).toBe('Bericht.pdf'); + expect([input.selectionStart, input.selectionEnd]).toEqual([0, 7]); + fireEvent.change(input, { target: { value: 'Datei 2.txt' } }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Umbenennen' })); + expect( + await within(dialog).findByText('Ein Eintrag mit diesem Namen existiert bereits.'), + ).toBeTruthy(); + expect(mockMove).toHaveBeenCalledWith('/Bericht.pdf', '/Datei 2.txt'); + }); + + it('Verschieben: nur Ordner, gesperrt fuer den eigenen Ordner und fuer den verschobenen Ordner selbst; ruft moveEntry je Eintrag', async () => { + await mount(); + fireEvent.click(screen.getByRole('row', { name: 'Projekte' })); + fireEvent.click(screen.getByRole('row', { name: 'Bericht.pdf' }), { ctrlKey: true }); + fireEvent.click(screen.getByRole('button', { name: 'Verschieben' })); + const dialog = await screen.findByRole('dialog', { name: '2 Elemente verschieben' }); + await within(dialog).findByRole('button', { name: 'Ordner „Zebra“ öffnen' }); + // nur Ordner + expect(within(dialog).queryByText('Bericht.pdf')).toBeNull(); + const submit = within(dialog).getByRole('button', { name: 'Hierher verschieben' }); + expect((submit as HTMLButtonElement).disabled).toBe(true); + expect(within(dialog).getByText('Die Auswahl liegt bereits in diesem Ordner.')).toBeTruthy(); + // Der verschobene Ordner selbst laesst sich nicht oeffnen + expect( + ( + within(dialog).getByRole('button', { + name: 'Ordner „Projekte“ öffnen', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + fireEvent.click(within(dialog).getByRole('button', { name: 'Ordner „Zebra“ öffnen' })); + await waitFor(() => + expect( + (within(dialog).getByRole('button', { name: 'Hierher verschieben' }) as HTMLButtonElement) + .disabled, + ).toBe(false), + ); + fireEvent.click(within(dialog).getByRole('button', { name: 'Hierher verschieben' })); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + expect(mockMove.mock.calls).toEqual([ + ['/Projekte', '/Zebra/Projekte'], + ['/Bericht.pdf', '/Zebra/Bericht.pdf'], + ]); + }); + + it('Verschieben eines Ordners in sich selbst ist gesperrt', async () => { + mockList.mockImplementation(async (p: string) => + listing(p, p === '/Projekte' ? [folder('/Projekte/Halle 3')] : (FOLDERS[p] ?? [])), + ); + await mount(); + fireEvent.contextMenu(screen.getByRole('row', { name: 'Zebra' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Verschieben' })); + const dialog = await screen.findByRole('dialog'); + // In "Zebra" selbst hineinnavigieren ist gesperrt; in Projekte darf verschoben werden + expect( + (await within(dialog).findByRole('button', { + name: 'Ordner „Zebra“ öffnen', + })) as HTMLButtonElement, + ).toHaveProperty('disabled', true); + }); + + it('Rechtsklick oeffnet dasselbe Menue wie der Knopf; Escape schliesst, der Fokus kehrt zurueck', async () => { + await mount(); + const row = screen.getByRole('row', { name: 'Bericht.pdf' }); + fireEvent.contextMenu(row, { clientX: 100, clientY: 100 }); + const menu = screen.getByRole('menu'); + const items = within(menu) + .getAllByRole('menuitem') + .map((i) => i.textContent); + expect(items).toEqual([ + 'Herunterladen', + 'Umbenennen', + 'Verschieben', + 'In Nextcloud öffnen', + 'Löschen', + ]); + expect( + within(menu).getByRole('menuitem', { name: 'In Nextcloud öffnen' }).getAttribute('href'), + ).toBe('https://cloud.example/index.php/f/111'); + fireEvent.keyDown(menu, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(document.activeElement).toBe(row); + + const button = within(row).getByRole('button', { name: 'Aktionen für „Bericht.pdf“' }); + fireEvent.click(button); + expect( + within(screen.getByRole('menu')) + .getAllByRole('menuitem') + .map((i) => i.textContent), + ).toEqual(items); + }); + + it('Ordner-Menue bietet Oeffnen und ZIP', async () => { + await mount(); + fireEvent.contextMenu(screen.getByRole('row', { name: 'Projekte' })); + fireEvent.click(screen.getByRole('menuitem', { name: 'Als ZIP herunterladen' })); + expect(clicked[0]).toContain('download?path=%2FProjekte&zip=1'); + }); + + it('Liste/Raster wird je Benutzer gespeichert und beim Start wiederhergestellt', async () => { + const first = await mount(); + fireEvent.click(screen.getByRole('button', { name: 'Raster' })); + expect(window.localStorage.getItem('tessera:nextcloud-files:view:u1')).toBe('grid'); + expect(screen.getByTestId('nc-files-grid')).toBeTruthy(); + first.unmount(); + render(); + expect(await screen.findByTestId('nc-files-grid')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Raster' }).getAttribute('aria-pressed')).toBe( + 'true', + ); + }); + + it('Raster zeigt Vorschaubilder nur bei hasPreview, sonst (auch nach Ladefehler) die Typkachel', async () => { + window.localStorage.setItem('tessera:nextcloud-files:view:u1', 'grid'); + mockList.mockImplementation(async (p: string) => + listing(p, [ + entry('/Foto.jpg', { hasPreview: true, fileId: '42', etag: 'abc' }), + entry('/Kaputt.png', { hasPreview: true, fileId: '43' }), + entry('/Text.pdf', { hasPreview: false }), + ]), + ); + render(); + const grid = await screen.findByTestId('nc-files-grid'); + const imgs = grid.querySelectorAll('img'); + expect(imgs).toHaveLength(2); + expect(imgs[0].getAttribute('src')).toContain('/preview?fileId=42&v=abc'); + fireEvent.error(imgs[1]); + expect(grid.querySelectorAll('img')).toHaveLength(1); + const kaputt = screen.getByRole('gridcell', { name: 'Kaputt.png' }); + expect(within(kaputt).getByText('PNG')).toBeTruthy(); + expect( + within(screen.getByRole('gridcell', { name: 'Text.pdf' })).getByText('PDF'), + ).toBeTruthy(); + }); + + it('leerer Ordner gibt eine Anleitung', async () => { + mockList.mockResolvedValue(listing('/', [])); + render(); + expect(await screen.findByText('Dieser Ordner ist leer.')).toBeTruthy(); + expect(screen.getByText('Dateien hierher ziehen oder hochladen.')).toBeTruthy(); + }); + + it('connectionExpired ruft onExpired', async () => { + mockList.mockRejectedValue( + new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'), + ); + render(); + await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1)); + }); + + it('nicht erreichbar: Klartext und "Erneut versuchen"', async () => { + mockList.mockRejectedValueOnce( + new NextcloudFilesRequestError(502, 'nextcloudUnavailable', 'x'), + ); + render(); + expect(await screen.findByText('Nextcloud ist gerade nicht erreichbar.')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' })); + expect(await screen.findByRole('row', { name: 'Projekte' })).toBeTruthy(); + }); + + it('ein verschwundener Ordner fuehrt zurueck zu allen Dateien mit Hinweis', async () => { + window.history.replaceState(null, '', '/modules/nextcloud-files?path=%2FWeg'); + mockList.mockImplementation(async (p: string) => { + if (p === '/Weg') throw new NextcloudFilesRequestError(404, 'notFound', 'weg'); + return listing(p, FOLDERS[p] ?? []); + }); + render(); + expect(await screen.findByText(/Der Ordner existiert nicht mehr/)).toBeTruthy(); + expect(await screen.findByRole('row', { name: 'Projekte' })).toBeTruthy(); + }); + + it('Speicheranzeige: belegt von gesamt, unbegrenzt nur belegt', async () => { + await mount(); + expect(screen.getByText('4,2 GB von 10 GB belegt')).toBeTruthy(); + expect(screen.getByRole('meter').getAttribute('aria-valuenow')).toBe('42'); + cleanup(); + mockList.mockImplementation(async (p: string) => + listing(p, FOLDERS[p] ?? [], { quota: { used: 4.2 * GB, available: null } }), + ); + render(); + expect(await screen.findByText('4,2 GB belegt')).toBeTruthy(); + expect(screen.queryByRole('meter')).toBeNull(); + }); + + it('im Unterordner zaehlt der Speicher des ganzen Kontos, nicht die Ordnergroesse', async () => { + mockList.mockImplementation(async (p: string) => + listing(p, FOLDERS[p] ?? [], { + quota: p === '/' ? { used: 4 * GB, available: 6 * GB } : { used: 1024, available: 6 * GB }, + }), + ); + window.history.replaceState(null, '', '/modules/nextcloud-files?path=%2FProjekte'); + render(); + expect(await screen.findByText('4 GB von 10 GB belegt')).toBeTruthy(); + }); + + it('abgeschnittene Ordner nennen die Grenze', async () => { + mockList.mockResolvedValue(listing('/', ROOT_ENTRIES, { truncated: true })); + render(); + expect(await screen.findByText('Es werden die ersten 5000 Einträge angezeigt.')).toBeTruthy(); + }); +}); + +describe('FileBrowser — Ziehen und Ablegen', () => { + const files = (list: File[]) => ({ + types: ['Files'], + files: list, + items: list.map((f) => ({ kind: 'file', getAsFile: () => f })), + dropEffect: 'none', + }); + + it('zeigt die Ablage-Markierung mit dem aktuellen Ordner, wechselt ueber einer Ordnerzeile, blendet beim Verlassen aus', async () => { + await mount(); + const dt = files([new File(['x'], 'neu.txt')]); + fireEvent.dragEnter(window, { dataTransfer: dt }); + expect(screen.getByTestId('nc-files-drop-overlay').textContent).toContain( + 'In „Alle Dateien“ ablegen', + ); + const row = screen.getByRole('row', { name: 'Projekte' }); + fireEvent.dragEnter(row, { dataTransfer: dt }); + fireEvent.dragOver(row, { dataTransfer: dt }); + expect(screen.getByTestId('nc-files-drop-overlay').textContent).toContain( + 'In „Projekte“ ablegen', + ); + expect(row.className).toContain('ring-primary-strong'); + fireEvent.dragLeave(row, { dataTransfer: dt }); + expect(screen.getByTestId('nc-files-drop-overlay')).toBeTruthy(); + fireEvent.dragLeave(window, { dataTransfer: dt }); + expect(screen.queryByTestId('nc-files-drop-overlay')).toBeNull(); + }); + + it('Ablegen auf einer Ordnerzeile laedt in diesen Ordner hoch', async () => { + await mount(); + const file = new File(['hallo'], 'neu.txt'); + const dt = files([file]); + const row = screen.getByRole('row', { name: 'Projekte' }); + fireEvent.dragEnter(row, { dataTransfer: dt }); + fireEvent.drop(row, { dataTransfer: dt }); + await waitFor(() => expect(mockUpload).toHaveBeenCalledTimes(1)); + expect(mockUpload.mock.calls[0][0]).toBe(file); + expect(mockUpload.mock.calls[0][1]).toBe('/Projekte/neu.txt'); + expect(screen.queryByTestId('nc-files-drop-overlay')).toBeNull(); + }); + + it('ein Ziehen ohne Dateien (markierter Text) wird ignoriert', async () => { + await mount(); + const dt = { types: ['text/plain'], files: [], items: [] }; + fireEvent.dragEnter(window, { dataTransfer: dt }); + expect(screen.queryByTestId('nc-files-drop-overlay')).toBeNull(); + fireEvent.drop(window, { dataTransfer: dt }); + await act(async () => {}); + expect(mockUpload).not.toHaveBeenCalled(); + }); + + it('Hochladen ueber die Dateiauswahl landet im aktuellen Ordner; danach wird die Liste neu geladen', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + await mount(); + const input = screen.getByTestId('nc-files-upload-input') as HTMLInputElement; + const file = new File(['abc'], 'Notiz.txt'); + const before = mockList.mock.calls.length; + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => + expect(mockUpload).toHaveBeenCalledWith(file, '/Notiz.txt', expect.anything()), + ); + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + await waitFor(() => expect(mockList.mock.calls.length).toBeGreaterThan(before)); + expect(screen.getByText('Übertragungen')).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx new file mode 100644 index 0000000..de1ccea --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx @@ -0,0 +1,968 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { collectDrop, hasFiles, snapshotDrop } from '@/components/nextcloud-files/drop-entries'; +import { type ErrorLike, errorText, toErrorLike } from '@/components/nextcloud-files/error-text'; +import { basename, freeName, join, parent, ROOT } from '@/components/nextcloud-files/paths'; +import { EMPTY_SELECTION, selectionReducer } from '@/components/nextcloud-files/selection'; +import { isActive, useTransfers } from '@/components/nextcloud-files/use-transfers'; +import { + createFolder, + deleteEntry, + downloadUrl, + listFolder, + moveEntry, + type NcEntry, + type NcListing, + type NcQuota, + zipUrl, +} from '@/lib/nextcloud-files-api'; +import { useAuthStore } from '@/lib/stores/auth-store'; +import { DeleteDialog } from './DeleteDialog'; +import { DropOverlay } from './DropOverlay'; +import { type EntryAction, EntryMenu } from './EntryMenu'; +import { FileGrid } from './FileGrid'; +import { FileList } from './FileList'; +import { + DownloadIcon, + ExternalIcon, + MoveIcon, + OpenIcon, + PencilIcon, + RetryIcon, + TrashIcon, + UploadIcon, +} from './icons'; +import { MoveDialog } from './MoveDialog'; +import { NameDialog } from './NameDialog'; +import { QuotaMeter } from './QuotaMeter'; +import { SelectionBar } from './SelectionBar'; +import { type SortDir, type SortKey, Toolbar, type ViewMode } from './Toolbar'; +import { TransferBar } from './TransferBar'; + +const VIEW_KEY_PREFIX = 'tessera:nextcloud-files:view:'; +const STATUS_MS = 6000; + +type DialogState = + | { kind: 'newFolder' } + | { kind: 'rename'; entry: NcEntry } + | { kind: 'move'; entries: NcEntry[] } + | { kind: 'delete'; entries: NcEntry[] }; + +interface MenuState { + entry: NcEntry; + x: number; + y: number; + trigger: HTMLElement | null; +} + +function useMediaQuery(query: string, fallback: boolean): boolean { + const [matches, setMatches] = useState(fallback); + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const mq = window.matchMedia(query); + setMatches(mq.matches); + const onChange = () => setMatches(mq.matches); + mq.addEventListener?.('change', onChange); + return () => mq.removeEventListener?.('change', onChange); + }, [query]); + return matches; +} + +/** Laedt eine Adresse herunter, ohne die Seite zu verlassen (Content-Disposition: attachment). */ +export function triggerDownload(url: string): void { + const a = document.createElement('a'); + a.href = url; + a.download = ''; + a.rel = 'noopener'; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); +} + +function isTypingTarget(el: EventTarget | null): boolean { + if (!(el instanceof HTMLElement)) return false; + return !!el.closest( + 'input:not([type="checkbox"]), textarea, select, [contenteditable="true"], [role="dialog"], [role="menu"]', + ); +} + +/** + * Dateiansicht "Mosaik-Ablage" (quick-261008-mzu, L-06/L-09/D-L): Ordner + * durchblaettern, Liste oder Raster (je Benutzer gemerkt), Auswahl, + * Tastatur, Zeilen- und Rechtsklickmenue, Dialoge, Speicheranzeige, Ziehen + * und Ablegen mit Ordnerzeilen als Ziel und die Uebertragungsleiste. Der + * Ordner steht zusaetzlich in `?path=` (replaceState, kein useSearchParams), + * damit ein Neuladen im selben Ordner bleibt. + */ +export function FileBrowser({ + serverUrl, + onExpired, +}: { + /** Adresse der Nextcloud fuer "In Nextcloud öffnen". */ + serverUrl: string | null; + /** Die Verbindung ist abgelaufen: die Seite zeigt wieder den Anmeldebildschirm. */ + onExpired: () => void; +}) { + const t = useTranslations('nextcloudFiles.browser'); + const tCodes = useTranslations('nextcloudFiles.codes'); + const tDialogs = useTranslations('nextcloudFiles.dialogs'); + const locale = useLocale(); + const userId = useAuthStore((s) => s.user?.id ?? null); + + const [path, setPath] = useState(ROOT); + const [ready, setReady] = useState(false); + const [listing, setListing] = useState(null); + /** Speicher des ganzen Kontos (aus der Wurzel); Unterordner melden nur ihre eigene Groesse. */ + const [accountQuota, setAccountQuota] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [notice, setNotice] = useState(null); + const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'name', dir: 'asc' }); + const [view, setView] = useState('list'); + const [selection, dispatch] = useReducer(selectionReducer, EMPTY_SELECTION); + const [focusIndex, setFocusIndex] = useState(0); + const [menu, setMenu] = useState(null); + const [dialog, setDialog] = useState(null); + const [status, setStatus] = useState(null); + const [dropTarget, setDropTarget] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const wide = useMediaQuery('(min-width: 640px)', true); + const coarse = useMediaQuery('(pointer: coarse)', false); + + const pathRef = useRef(path); + pathRef.current = path; + const onExpiredRef = useRef(onExpired); + onExpiredRef.current = onExpired; + const requestSeq = useRef(0); + const itemRefs = useRef<(HTMLElement | null)[]>([]); + const rootRef = useRef(null); + const barRef = useRef(null); + const fileInput = useRef(null); + /** Nach dem Laden diesen Eintrag fokussieren (z. B. den Ordner, aus dem man kam). */ + const focusAfterLoad = useRef(false); + const statusTimer = useRef(undefined); + + const effectiveView: ViewMode = wide ? view : 'list'; + + // --- Ordner laden ---------------------------------------------------------------------------- + + const load = useCallback( + async (target: string, opts: { quiet?: boolean } = {}) => { + const seq = ++requestSeq.current; + if (!opts.quiet) setLoading(true); + try { + const result = await listFolder(target); + if (seq !== requestSeq.current) return; + setListing(result); + if (target === ROOT) setAccountQuota(result.quota); + setLoadError(null); + setNow(Date.now()); + } catch (err) { + if (seq !== requestSeq.current) return; + const e = toErrorLike(err); + if (e.code === 'connectionExpired') { + onExpiredRef.current(); + return; + } + if (e.code === 'notFound' && target !== ROOT) { + setNotice(t('folderGone')); + setPath(ROOT); + return; + } + setLoadError(e); + if (!opts.quiet) setListing(null); + } finally { + if (seq === requestSeq.current) setLoading(false); + } + }, + [t], + ); + + // Startordner aus der Adresse lesen (nur im Browser, daher erst nach dem Einhaengen). + useEffect(() => { + const raw = new URLSearchParams(window.location.search).get('path'); + if (raw?.startsWith('/') && !raw.includes('\0')) setPath(raw); + setReady(true); + }, []); + + useEffect(() => { + if (ready) void load(path); + }, [ready, path, load]); + + // Ordner in der Adresse spiegeln, ohne neuen Verlaufseintrag und ohne Neurendern der Seite. + useEffect(() => { + if (!ready) return; + const url = new URL(window.location.href); + if (path === ROOT) url.searchParams.delete('path'); + else url.searchParams.set('path', path); + if (url.href !== window.location.href) + window.history.replaceState(window.history.state, '', url); + }, [ready, path]); + + // In einem Unterordner nennt Nextcloud als "belegt" nur die Groesse dieses Ordners. Fuer die + // Speicheranzeige einmal die Wurzel fragen (nur deren Kopfdaten werden verwendet). + const accountKnown = accountQuota !== null; + useEffect(() => { + if (!ready || path === ROOT || accountKnown) return; + let cancelled = false; + listFolder(ROOT) + .then((root) => { + if (!cancelled) setAccountQuota(root.quota); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [ready, path, accountKnown]); + + const shownQuota = useMemo((): NcQuota | null => { + if (!listing) return null; + if (path === ROOT) return listing.quota; + if (!accountQuota) return null; + const free = listing.quota.available; + if (free !== null && accountQuota.available !== null) { + // Gesamtgroesse bleibt gleich; der freie Platz ist ueberall der des Kontos und aktuell. + const total = accountQuota.used + accountQuota.available; + return { used: Math.max(0, total - free), available: free }; + } + return accountQuota; + }, [listing, path, accountQuota]); + + // Die Werkzeug- bzw. Auswahlleiste klebt (ab sm) unter der App-Leiste; die Spaltenkoepfe der + // Liste kleben darunter. Ihre Hoehe wechselt (Auswahl, Umbruch), deshalb gemessen. + useEffect(() => { + const bar = barRef.current; + const root = rootRef.current; + if (!bar || !root || typeof ResizeObserver === 'undefined') return; + const update = () => { + const sticky = window.getComputedStyle(bar).position === 'sticky'; + root.style.setProperty('--nc-bar-h', sticky ? `${bar.offsetHeight}px` : '0px'); + }; + const observer = new ResizeObserver(update); + observer.observe(bar); + window.addEventListener('resize', update); + return () => { + observer.disconnect(); + window.removeEventListener('resize', update); + }; + }, []); + + // Relative Zeitangaben einmal je Minute auffrischen. + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 60_000); + return () => window.clearInterval(timer); + }, []); + + // --- Ansicht je Benutzer merken -------------------------------------------------------------- + + useEffect(() => { + if (!userId) return; + try { + const saved = window.localStorage.getItem(`${VIEW_KEY_PREFIX}${userId}`); + if (saved === 'list' || saved === 'grid') setView(saved); + } catch { + // Ohne Speicher bleibt die Liste. + } + }, [userId]); + + const changeView = (next: ViewMode) => { + setView(next); + if (!userId) return; + try { + window.localStorage.setItem(`${VIEW_KEY_PREFIX}${userId}`, next); + } catch { + // Ohne Speicher gilt die Wahl nur bis zum Neuladen. + } + }; + + // --- Sortierung ------------------------------------------------------------------------------- + + const entries = useMemo(() => { + const list = listing?.entries ?? []; + const collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' }); + const dir = sort.dir === 'asc' ? 1 : -1; + const byName = (a: NcEntry, b: NcEntry) => collator.compare(a.name, b.name); + const time = (e: NcEntry) => (e.mtime ? new Date(e.mtime).getTime() : 0); + return [...list].sort((a, b) => { + if (a.type !== b.type) return a.type === 'folder' ? -1 : 1; + let c = 0; + if (sort.key === 'size') c = a.size - b.size; + else if (sort.key === 'modified') c = time(a) - time(b); + if (c === 0) c = byName(a, b); + return c * dir; + }); + }, [listing, sort, locale]); + + const order = useMemo(() => entries.map((e) => e.path), [entries]); + const selectedSet = useMemo(() => new Set(selection.selected), [selection.selected]); + const selectedEntries = useMemo( + () => entries.filter((e) => selectedSet.has(e.path)), + [entries, selectedSet], + ); + + // Verschwundene Eintraege aus der Auswahl nehmen; Fokus nach dem Laden setzen. + useEffect(() => { + dispatch({ type: 'prune', existing: order }); + setFocusIndex((i) => Math.min(i, Math.max(0, order.length - 1))); + const want = focusAfterLoad.current; + if (want !== false && listing) { + focusAfterLoad.current = false; + const index = want === null ? 0 : Math.max(0, order.indexOf(want)); + setFocusIndex(index); + requestAnimationFrame(() => (itemRefs.current[index] ?? rootRef.current)?.focus()); + } + }, [order, listing]); + + // --- Navigation ------------------------------------------------------------------------------- + + const navigate = useCallback((target: string, focusPath: string | null = null) => { + const inside = !!rootRef.current?.contains(document.activeElement); + focusAfterLoad.current = inside || document.activeElement === document.body ? focusPath : false; + setNotice(null); + setMenu(null); + dispatch({ type: 'clear' }); + setFocusIndex(0); + setPath(target); + }, []); + + const goUp = useCallback(() => { + const current = pathRef.current; + if (current === ROOT) return; + navigate(parent(current), current); + }, [navigate]); + + // --- Statuszeile ------------------------------------------------------------------------------ + + const say = useCallback((message: string) => { + setStatus(message); + window.clearTimeout(statusTimer.current); + statusTimer.current = window.setTimeout(() => setStatus(null), STATUS_MS); + }, []); + useEffect(() => () => window.clearTimeout(statusTimer.current), []); + + // --- Uebertragungen --------------------------------------------------------------------------- + + const reloadTimer = useRef(undefined); + const transfers = useTransfers({ + quotaAvailable: listing?.quota.available ?? null, + onFolderChanged: (dir) => { + const current = pathRef.current; + if (dir !== current && parent(dir) !== current) return; + window.clearTimeout(reloadTimer.current); + reloadTimer.current = window.setTimeout( + () => void load(pathRef.current, { quiet: true }), + 300, + ); + }, + }); + useEffect(() => () => window.clearTimeout(reloadTimer.current), []); + const enqueueRef = useRef(transfers.enqueue); + enqueueRef.current = transfers.enqueue; + + const anyActive = transfers.transfers.some(isActive); + useEffect(() => { + if (!anyActive) return; + // Seite verlassen bricht laufende Uebertragungen ab — der Browser fragt nach. + const warn = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; + }; + window.addEventListener('beforeunload', warn); + return () => window.removeEventListener('beforeunload', warn); + }, [anyActive]); + + // Ziehen und Ablegen: am Fenster, mit Tiefenzaehler; nur Ziehen mit Dateien zaehlt. + useEffect(() => { + let depth = 0; + const targetOf = (e: DragEvent): string => { + const el = e.target instanceof Element ? e.target.closest('[data-drop-folder]') : null; + return el?.getAttribute('data-drop-folder') ?? pathRef.current; + }; + const onEnter = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer)) return; + depth += 1; + setDropTarget(targetOf(e)); + }; + const onOver = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer)) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + const target = targetOf(e); + setDropTarget((prev) => (prev === target ? prev : target)); + }; + const onLeave = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer)) return; + depth = Math.max(0, depth - 1); + if (depth === 0) setDropTarget(null); + }; + const onDrop = (e: DragEvent) => { + if (!hasFiles(e.dataTransfer) || !e.dataTransfer) return; + e.preventDefault(); + depth = 0; + const target = targetOf(e); + setDropTarget(null); + const snapshot = snapshotDrop(e.dataTransfer); + void collectDrop(snapshot).then((result) => { + if (result.problem) { + say(t(`drop.${result.problem}`)); + return; + } + enqueueRef.current(result.files, target, result.folders); + }); + }; + window.addEventListener('dragenter', onEnter); + window.addEventListener('dragover', onOver); + window.addEventListener('dragleave', onLeave); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onEnter); + window.removeEventListener('dragover', onOver); + window.removeEventListener('dragleave', onLeave); + window.removeEventListener('drop', onDrop); + }; + }, [say, t]); + + // --- Aktionen --------------------------------------------------------------------------------- + + const openEntry = useCallback( + (entry: NcEntry) => { + if (entry.type === 'folder') navigate(entry.path); + else triggerDownload(downloadUrl(entry.path)); + }, + [navigate], + ); + + const downloadEntries = (list: NcEntry[]) => { + if (list.length === 0) return; + if (list.length === 1) { + const [only] = list; + triggerDownload(downloadUrl(only.path, { zip: only.type === 'folder' })); + return; + } + triggerDownload( + zipUrl( + path, + list.map((e) => e.name), + ), + ); + }; + + /** Fuehrt eine Aktion je Eintrag aus und meldet Teilerfolge; laedt danach neu. */ + const runEach = async ( + list: NcEntry[], + action: (entry: NcEntry) => Promise, + ): Promise<{ failed: number; reason: string }> => { + let failed = 0; + let reason = ''; + for (const entry of list) { + try { + await action(entry); + } catch (err) { + const e = toErrorLike(err); + if (e.code === 'connectionExpired') { + onExpiredRef.current(); + return { failed: list.length, reason: '' }; + } + failed += 1; + reason = errorText(tCodes, e, locale); + } + } + return { failed, reason }; + }; + + const confirmDelete = async (list: NcEntry[]) => { + const { failed, reason } = await runEach(list, (e) => deleteEntry(e.path)); + setDialog(null); + dispatch({ type: 'clear' }); + await load(pathRef.current, { quiet: true }); + say( + failed > 0 + ? t('status.failed', { failed, reason }) + : t('status.deleted', { count: list.length, name: list[0]?.name ?? '' }), + ); + }; + + const confirmMove = async (list: NcEntry[], targetDir: string) => { + if (list.length === 1) { + // Ein einzelner Eintrag: Fehler bleiben im Dialog sichtbar. + await moveEntry(list[0].path, join(targetDir, list[0].name)); + } else { + const { failed, reason } = await runEach(list, (e) => + moveEntry(e.path, join(targetDir, e.name)), + ); + if (failed > 0) { + setDialog(null); + dispatch({ type: 'clear' }); + await load(pathRef.current, { quiet: true }); + say(t('status.failed', { failed, reason })); + return; + } + } + setDialog(null); + dispatch({ type: 'clear' }); + await load(pathRef.current, { quiet: true }); + say( + t('status.moved', { + count: list.length, + name: list[0]?.name ?? '', + target: targetDir === ROOT ? t('rootName') : basename(targetDir), + }), + ); + }; + + const submitRename = async (entry: NcEntry, name: string) => { + const to = join(parent(entry.path), name); + await moveEntry(entry.path, to); + setDialog(null); + focusAfterLoad.current = to; + await load(pathRef.current, { quiet: true }); + dispatch({ type: 'click', key: to, order: [to] }); + say(t('status.renamed', { name })); + }; + + const submitNewFolder = async (name: string) => { + const to = join(pathRef.current, name); + await createFolder(to); + setDialog(null); + focusAfterLoad.current = to; + await load(pathRef.current, { quiet: true }); + say(t('status.folderCreated', { name })); + }; + + const uploadPicked = (files: File[]) => { + transfers.enqueue( + files.map((file) => ({ file, relativeDir: '' })), + pathRef.current, + ); + }; + + const menuActions = (entry: NcEntry): EntryAction[] => { + const actions: EntryAction[] = []; + if (entry.type === 'folder') { + actions.push({ + id: 'open', + label: t('menu.open'), + icon: , + onSelect: () => navigate(entry.path), + }); + actions.push({ + id: 'downloadZip', + label: t('menu.downloadZip'), + icon: , + onSelect: () => triggerDownload(downloadUrl(entry.path, { zip: true })), + }); + } else { + actions.push({ + id: 'download', + label: t('menu.download'), + icon: , + onSelect: () => triggerDownload(downloadUrl(entry.path)), + }); + } + actions.push( + { + id: 'rename', + label: t('menu.rename'), + icon: , + onSelect: () => setDialog({ kind: 'rename', entry }), + }, + { + id: 'move', + label: t('menu.move'), + icon: , + onSelect: () => setDialog({ kind: 'move', entries: [entry] }), + }, + ); + if (serverUrl && entry.fileId) { + actions.push({ + id: 'openInNextcloud', + label: t('menu.openInNextcloud'), + icon: , + href: `${serverUrl.replace(/\/+$/, '')}/index.php/f/${encodeURIComponent(entry.fileId)}`, + separated: true, + }); + } + actions.push({ + id: 'delete', + label: t('menu.delete'), + icon: , + destructive: true, + separated: !(serverUrl && entry.fileId), + onSelect: () => setDialog({ kind: 'delete', entries: [entry] }), + }); + return actions; + }; + + // --- Zeigen und Tastatur ---------------------------------------------------------------------- + + const registerItem = useCallback((index: number, el: HTMLElement | null) => { + itemRefs.current[index] = el; + }, []); + + const onItemClick = (e: React.MouseEvent, entry: NcEntry, index: number) => { + setFocusIndex(index); + const ctrl = e.ctrlKey || e.metaKey; + // Auf Touchgeraeten oeffnet ein Tippen, solange nichts ausgewaehlt ist (Auswahl ueber das Kaestchen). + if (coarse && !ctrl && !e.shiftKey && selection.selected.length === 0) { + openEntry(entry); + return; + } + dispatch({ type: 'click', key: entry.path, order, ctrl, shift: e.shiftKey }); + }; + + const onToggle = (entry: NcEntry, index: number) => { + setFocusIndex(index); + dispatch({ type: 'toggle', key: entry.path }); + }; + + const openMenu = (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => { + if (!selectedSet.has(entry.path)) { + dispatch({ type: 'click', key: entry.path, order }); + } + const index = order.indexOf(entry.path); + if (index >= 0) setFocusIndex(index); + setMenu({ entry, x, y, trigger: trigger ?? itemRefs.current[index] ?? null }); + }; + + const gridColumns = (): number => { + const first = itemRefs.current[0]; + const grid = first?.closest('[data-testid="nc-files-grid"]'); + if (!grid) return 1; + const cols = window.getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean); + return Math.max(1, cols.length); + }; + + const moveFocus = (index: number, extend: boolean) => { + if (entries.length === 0) return; + const next = Math.max(0, Math.min(entries.length - 1, index)); + setFocusIndex(next); + itemRefs.current[next]?.focus(); + if (extend) dispatch({ type: 'click', key: entries[next].path, order, shift: true }); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (dialog || isTypingTarget(e.target)) return; + const entry = entries[focusIndex]; + const mod = e.ctrlKey || e.metaKey; + const step = effectiveView === 'grid' ? gridColumns() : 1; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + moveFocus(focusIndex + step, e.shiftKey); + return; + case 'ArrowUp': + e.preventDefault(); + if (e.altKey) goUp(); + else moveFocus(focusIndex - step, e.shiftKey); + return; + case 'ArrowRight': + if (effectiveView === 'grid') { + e.preventDefault(); + moveFocus(focusIndex + 1, e.shiftKey); + } + return; + case 'ArrowLeft': + if (effectiveView === 'grid') { + e.preventDefault(); + moveFocus(focusIndex - 1, e.shiftKey); + } + return; + case 'Home': + e.preventDefault(); + moveFocus(0, e.shiftKey); + return; + case 'End': + e.preventDefault(); + moveFocus(entries.length - 1, e.shiftKey); + return; + case 'Backspace': + e.preventDefault(); + goUp(); + return; + case 'Enter': + if ( + entry && + !(e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) + ) { + e.preventDefault(); + openEntry(entry); + } + return; + case ' ': + if (entry && !(e.target instanceof HTMLButtonElement)) { + e.preventDefault(); + dispatch({ type: 'toggle', key: entry.path }); + } + return; + case 'Escape': + if (selection.selected.length > 0) { + e.preventDefault(); + dispatch({ type: 'clear' }); + } + return; + case 'Delete': { + const list = selectedEntries.length > 0 ? selectedEntries : entry ? [entry] : []; + if (list.length > 0) { + e.preventDefault(); + setDialog({ kind: 'delete', entries: list }); + } + return; + } + case 'F2': { + const target = selectedEntries.length === 1 ? selectedEntries[0] : entry; + if (target) { + e.preventDefault(); + setDialog({ kind: 'rename', entry: target }); + } + return; + } + case 'ContextMenu': + case 'F10': + if (entry && (e.key === 'ContextMenu' || e.shiftKey)) { + e.preventDefault(); + const el = itemRefs.current[focusIndex]; + const r = el?.getBoundingClientRect(); + openMenu(entry, (r?.left ?? 0) + 48, (r?.bottom ?? 0) + 2, el ?? null); + } + return; + default: + if (mod && (e.key === 'a' || e.key === 'A')) { + e.preventDefault(); + dispatch({ type: 'selectAll', order }); + } + } + }; + + // --- Darstellung ------------------------------------------------------------------------------ + + const viewProps = { + entries, + selected: selectedSet, + focusIndex, + dropTarget, + now, + registerItem, + onItemClick, + onItemOpen: openEntry, + onToggle, + onMenu: openMenu, + onFocusItem: setFocusIndex, + }; + + const targetName = + dropTarget === null ? '' : dropTarget === ROOT ? t('rootName') : basename(dropTarget); + + let body: React.ReactNode; + if (loading && !listing) { + body = ( +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ + +
+ ))} +
+ ); + } else if (loadError && !listing) { + const locked = loadError.code === 'nextcloudLocked'; + const unreachable = + loadError.code === null || + loadError.code === 'nextcloudUnavailable' || + loadError.code === 'network' || + loadError.code === 'nextcloudError'; + body = ( +
+

+ {locked + ? errorText(tCodes, loadError, locale) + : unreachable + ? t('unreachable') + : errorText(tCodes, loadError, locale)} +

+ {unreachable &&

{t('unreachableHint')}

} + +
+ ); + } else if (entries.length === 0) { + // Die leere Flaeche ist selbst das Ablageziel: gestrichelt wie die Ablage-Markierung. + body = ( +
+
+ +

{t('empty.title')}

+

{t('empty.hint')}

+ +
+
+ ); + } else if (effectiveView === 'grid') { + body = ; + } else { + body = ( + dispatch(all ? { type: 'selectAll', order } : { type: 'clear' })} + /> + ); + } + + return ( +
+ {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: Tastenbelegung der Dateiansicht (Entf, F2, Strg+A …) gilt fuer alle Zeilen darin */} +
+
+ {selectedEntries.length > 0 ? ( + downloadEntries(selectedEntries)} + onMove={() => setDialog({ kind: 'move', entries: selectedEntries })} + onDelete={() => setDialog({ kind: 'delete', entries: selectedEntries })} + onClear={() => dispatch({ type: 'clear' })} + /> + ) : ( + navigate(p)} + dropTarget={dropTarget} + onNewFolder={() => setDialog({ kind: 'newFolder' })} + onUploadClick={() => fileInput.current?.click()} + view={view} + onViewChange={changeView} + sort={sort} + onSortChange={setSort} + disabled={!listing} + /> + )} +
+ { + const files = e.target.files ? Array.from(e.target.files) : []; + e.target.value = ''; + if (files.length > 0) uploadPicked(files); + }} + /> + {(notice || listing?.truncated) && ( +
+ {notice && ( +

+ {notice} +

+ )} + {listing?.truncated && ( +

{t('truncated')}

+ )} +
+ )} + {body} + {listing && ( +
+

+ {status ? ( + {status} + ) : ( + t('count', { count: entries.length }) + )} +

+ {shownQuota && } +
+ )} + {dropTarget !== null && } +
+ + void transfers.resolve(id, choice)} + onClearFinished={transfers.clearFinished} + /> + + {menu && ( + setMenu(null)} + /> + )} + + {dialog?.kind === 'newFolder' && ( + e.name), + )} + onSubmit={submitNewFolder} + onClose={() => setDialog(null)} + /> + )} + {dialog?.kind === 'rename' && ( + submitRename(dialog.entry, name)} + onClose={() => setDialog(null)} + /> + )} + {dialog?.kind === 'move' && ( + confirmMove(dialog.entries, target)} + onClose={() => setDialog(null)} + /> + )} + {dialog?.kind === 'delete' && ( + confirmDelete(dialog.entries)} + onClose={() => setDialog(null)} + /> + )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx new file mode 100644 index 0000000..e8f3381 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { formatAbsolute, formatSize } from '@/components/nextcloud-files/file-format'; +import { fileTypeOf } from '@/components/nextcloud-files/file-types'; +import { type NcEntry, previewUrl } from '@/lib/nextcloud-files-api'; +import type { EntryViewProps } from './FileList'; +import { CheckIcon, MoreIcon } from './icons'; +import { TypeTile } from './TypeTile'; + +/** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */ +function PreviewSurface({ entry }: { entry: NcEntry }) { + const [failed, setFailed] = useState(false); + // Echte Vorschauen fuer Bilder und Videos; Textvorschauen waeren nur ein fast leeres Blatt. + const family = fileTypeOf(entry).family; + const image = + (family === 'image' || family === 'media') && entry.hasPreview && entry.fileId && !failed; + return ( +
+ {image && entry.fileId ? ( + // biome-ignore lint/performance/noImgElement: Vorschau kommt ueber den Tessera-Proxy + // biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError ist ein Ladefehler-Rueckfall + setFailed(true)} + /> + ) : ( + + )} +
+ ); +} + +/** + * Rasteransicht (quick-261008-mzu): Vorschauflaechen mit dem Namen darunter. + * Gleiches Auswahl-, Menue- und Tastaturmodell wie die Liste. + */ +export function FileGrid({ + entries, + selected, + focusIndex, + dropTarget, + registerItem, + onItemClick, + onItemOpen, + onToggle, + onMenu, + onFocusItem, +}: EntryViewProps) { + const t = useTranslations('nextcloudFiles.browser'); + const locale = useLocale(); + const count = selected.size; + + return ( + // biome-ignore lint/a11y/useSemanticElements: ARIA-Raster (Grid-Muster), keine Tabelle +
+ {/* Eine einzige Zeile ohne eigene Box: das Raster ordnet die Zellen frei an. */} + {/* biome-ignore lint/a11y/useSemanticElements: Zeile des ARIA-Rasters */} + {/* biome-ignore lint/a11y/useFocusableInteractive: fokussierbar sind die Zellen (wandernder Tabindex) */} +
+ {entries.map((entry, index) => { + const isSelected = selected.has(entry.path); + const isFolder = entry.type === 'folder'; + const isDrop = isFolder && dropTarget === entry.path; + return ( + // biome-ignore lint/a11y/useSemanticElements: Zelle des ARIA-Rasters + // biome-ignore lint/a11y/useKeyWithClickEvents: Tastatur ist am Behaelter gebunden (Enter, Leertaste, Pfeile) +
registerItem(index, el)} + role="gridcell" + aria-selected={isSelected} + aria-label={entry.name} + tabIndex={index === focusIndex ? 0 : -1} + data-drop-folder={isFolder ? entry.path : undefined} + data-entry-path={entry.path} + onFocus={(e) => { + if (e.target === e.currentTarget) onFocusItem(index); + }} + onClick={(e) => onItemClick(e, entry, index)} + onDoubleClick={() => onItemOpen(entry)} + onContextMenu={(e) => { + e.preventDefault(); + onMenu(entry, e.clientX, e.clientY, e.currentTarget); + }} + className={`group relative min-w-0 cursor-default rounded-lg p-1.5 outline-none focus-visible:ring-2 focus-visible:ring-ring ${ + isDrop + ? 'bg-primary/30 ring-2 ring-primary-strong' + : isSelected + ? 'bg-primary/12 ring-2 ring-primary-strong' + : 'hover:bg-accent/80' + }`} + > + + e.stopPropagation()} + onChange={() => onToggle(entry, index)} + className={`absolute left-3 top-3 h-4 w-4 pointer-coarse:opacity-100 ${ + isSelected + ? 'sr-only' + : count > 0 + ? 'opacity-100' + : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100' + }`} + /> + {isSelected && ( + + )} + +
+ {isFolder ? ( + + ) : ( +

+ {entry.name} +

+ )} +

+ {formatSize(entry.size, locale)} +

+
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx new file mode 100644 index 0000000..78bd205 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx @@ -0,0 +1,250 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useEffect, useRef } from 'react'; +import { + formatAbsolute, + formatRelative, + formatSize, +} from '@/components/nextcloud-files/file-format'; +import { downloadUrl, type NcEntry } from '@/lib/nextcloud-files-api'; +import { ChevronDownIcon, ChevronUpIcon, MoreIcon } from './icons'; +import type { SortDir, SortKey } from './Toolbar'; +import { TypeTile } from './TypeTile'; + +/** Kopfzellen: klebt unter der App-Leiste, damit die Spalten beim Scrollen lesbar bleiben. */ +const TH = + 'sticky top-[calc(var(--header-height)+var(--nc-bar-h,0px))] z-10 h-9 border-b border-border bg-card p-0 font-medium'; + +export interface EntryViewProps { + entries: NcEntry[]; + selected: ReadonlySet; + focusIndex: number; + /** Ordner, ueber dem gerade Dateien schweben. */ + dropTarget: string | null; + now: number; + registerItem: (index: number, el: HTMLElement | null) => void; + onItemClick: (e: React.MouseEvent, entry: NcEntry, index: number) => void; + onItemOpen: (entry: NcEntry) => void; + onToggle: (entry: NcEntry, index: number) => void; + onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void; + onFocusItem: (index: number) => void; +} + +/** + * Dateiliste (quick-261008-mzu, L-09): eine echte Dateitabelle, keine Karten. + * Kaestchen, Typkachel, Name, Groesse rechtsbuendig in Tabellenziffern, + * Geaendert relativ (absolut im Tooltip), Menueknopf. Zeilen sind ueber eine + * wandernde Tabulatorposition per Pfeiltasten erreichbar; Ordnerzeilen sind + * Ablageziele beim Ziehen. + */ +export function FileList({ + entries, + selected, + focusIndex, + dropTarget, + now, + registerItem, + onItemClick, + onItemOpen, + onToggle, + onMenu, + onFocusItem, + sort, + onSortChange, + onSelectAll, +}: EntryViewProps & { + sort: { key: SortKey; dir: SortDir }; + onSortChange: (sort: { key: SortKey; dir: SortDir }) => void; + onSelectAll: (all: boolean) => void; +}) { + const t = useTranslations('nextcloudFiles.browser'); + const locale = useLocale(); + const allBox = useRef(null); + const count = selected.size; + const all = entries.length > 0 && count === entries.length; + const some = count > 0 && !all; + + useEffect(() => { + if (allBox.current) allBox.current.indeterminate = some; + }, [some]); + + const header = ( + key: SortKey, + label: string, + className: string, + align: 'left' | 'right' = 'left', + ) => { + const active = sort.key === key; + return ( + + + + ); + }; + + return ( + + + + + + + + + + + + {header('name', t('columns.name'), 'pl-1')} + {header('size', t('columns.size'), 'pr-1', 'right')} + {header('modified', t('columns.modified'), 'hidden pl-1 sm:table-cell')} + + + + + {entries.map((entry, index) => { + const isSelected = selected.has(entry.path); + const isFolder = entry.type === 'folder'; + const isDrop = isFolder && dropTarget === entry.path; + return ( + registerItem(index, el)} + aria-selected={isSelected} + aria-label={entry.name} + tabIndex={index === focusIndex ? 0 : -1} + data-drop-folder={isFolder ? entry.path : undefined} + data-entry-path={entry.path} + onFocus={(e) => { + if (e.target === e.currentTarget) onFocusItem(index); + }} + onClick={(e) => onItemClick(e, entry, index)} + onDoubleClick={() => onItemOpen(entry)} + onContextMenu={(e) => { + e.preventDefault(); + onMenu(entry, e.clientX, e.clientY, e.currentTarget); + }} + className={`group h-11 cursor-default border-b border-border/70 outline-none last:border-b-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${ + isDrop + ? 'bg-primary/30 ring-2 ring-inset ring-primary-strong' + : isSelected + ? 'bg-primary/12 hover:bg-primary/18' + : 'hover:bg-accent/70' + }`} + > + + + + + + + ); + })} + +
+ + onSelectAll(!all)} + className="h-4 w-4" + /> + + + {t('columns.actions')} +
+ + e.stopPropagation()} + onChange={() => onToggle(entry, index)} + className={`h-4 w-4 transition-opacity pointer-coarse:opacity-100 ${ + isSelected || count > 0 + ? 'opacity-100' + : 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100' + }`} + /> + + + + + {isFolder ? ( + + ) : ( + e.stopPropagation()} + className="min-w-0 truncate text-foreground hover:underline hover:underline-offset-2" + title={entry.name} + > + {entry.name} + + )} + + + {formatSize(entry.size, locale)} + + {entry.mtime ? formatRelative(entry.mtime, now, locale) : ''} + + +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/MoveDialog.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/MoveDialog.tsx new file mode 100644 index 0000000..13f147b --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/MoveDialog.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useCallback, useEffect, useState } from 'react'; +import { errorText, toErrorLike } from '@/components/nextcloud-files/error-text'; +import { + basename, + isInside, + parent, + pathOf, + ROOT, + segments, +} from '@/components/nextcloud-files/paths'; +import { listFolder, type NcEntry } from '@/lib/nextcloud-files-api'; +import { Dialog } from './Dialog'; +import { ChevronRightIcon } from './icons'; +import { TypeTile } from './TypeTile'; + +/** + * Zielordner waehlen (quick-261008-mzu): eigene Ordnernavigation mit Pfad. "Hierher + * verschieben" ist gesperrt fuer den Ordner, in dem die Auswahl schon liegt, und + * fuer einen verschobenen Ordner selbst oder etwas darin. + */ +export function MoveDialog({ + entries, + startPath, + onMove, + onClose, +}: { + entries: NcEntry[]; + startPath: string; + /** Wirft bei einem Fehler; der Dialog bleibt dann offen. */ + onMove: (targetDir: string) => Promise; + onClose: () => void; +}) { + const t = useTranslations('nextcloudFiles.dialogs'); + const tBrowser = useTranslations('nextcloudFiles.browser'); + const tCodes = useTranslations('nextcloudFiles.codes'); + const locale = useLocale(); + const [path, setPath] = useState(startPath); + const [folders, setFolders] = useState(null); + const [loadError, setLoadError] = useState(null); + const [busy, setBusy] = useState(false); + const [moveError, setMoveError] = useState(null); + + const load = useCallback( + async (target: string) => { + setFolders(null); + setLoadError(null); + try { + const listing = await listFolder(target); + const collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' }); + setFolders( + listing.entries + .filter((e) => e.type === 'folder') + .sort((a, b) => collator.compare(a.name, b.name)), + ); + } catch (err) { + setLoadError(errorText(tCodes, toErrorLike(err), locale)); + } + }, + [locale, tCodes], + ); + + useEffect(() => { + void load(path); + }, [load, path]); + + const movedFolders = entries.filter((e) => e.type === 'folder').map((e) => e.path); + const sources = new Set(entries.map((e) => parent(e.path))); + const same = sources.size === 1 && sources.has(path); + const intoItself = movedFolders.some((f) => f === path || isInside(f, path)); + const blockedReason = intoItself ? t('move.intoItself') : same ? t('move.sameFolder') : null; + + const parts = segments(path); + const crumbs = [ + { label: tBrowser('rootName'), path: ROOT }, + ...parts.map((label, i) => ({ label, path: pathOf(parts, i + 1) })), + ]; + + const title = + entries.length === 1 + ? t('move.title', { count: 1, name: entries[0].name }) + : t('move.title', { count: entries.length, name: '' }); + + return ( + + + + + } + > +

{t('move.hint')}

+ +
+ {loadError ? ( +

+ {loadError} +

+ ) : folders === null ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : folders.length === 0 ? ( +

{t('move.noFolders')}

+ ) : ( +
    + {folders.map((f) => { + const moving = movedFolders.includes(f.path); + return ( +
  • + +
  • + ); + })} +
+ )} +
+

+ {moveError ?? blockedReason ?? ''} +

+

+ {basename(path) || tBrowser('rootName')} +

+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/NameDialog.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/NameDialog.tsx new file mode 100644 index 0000000..32b0be7 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/NameDialog.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { type FormEvent, useEffect, useRef, useState } from 'react'; +import { errorText, toErrorLike } from '@/components/nextcloud-files/error-text'; +import { nameProblem, splitExtension } from '@/components/nextcloud-files/paths'; +import { Dialog } from './Dialog'; + +/** + * Name eingeben (quick-261008-mzu): neuer Ordner oder Umbenennen. Beim + * Umbenennen einer Datei ist nur der Name ohne Endung markiert. Pruefungen aus + * paths.ts; Fehler der API (z. B. Name vergeben) erscheinen im Dialog. + */ +export function NameDialog({ + mode, + initialName, + isFolder = true, + onSubmit, + onClose, +}: { + mode: 'newFolder' | 'rename'; + initialName: string; + isFolder?: boolean; + /** Wirft bei einem Fehler; der Dialog bleibt dann offen und zeigt ihn. */ + onSubmit: (name: string) => Promise; + onClose: () => void; +}) { + const t = useTranslations('nextcloudFiles.dialogs'); + const tCodes = useTranslations('nextcloudFiles.codes'); + const locale = useLocale(); + const [name, setName] = useState(initialName); + const [touched, setTouched] = useState(false); + const [busy, setBusy] = useState(false); + const [apiError, setApiError] = useState(null); + const input = useRef(null); + + useEffect(() => { + const el = input.current; + if (!el) return; + const end = isFolder ? initialName.length : splitExtension(initialName)[0].length; + el.setSelectionRange(0, end); + }, [initialName, isFolder]); + + const trimmed = name.trim(); + const problem = nameProblem(trimmed); + const unchanged = mode === 'rename' && trimmed === initialName; + const shownProblem = touched && problem ? t(`nameProblems.${problem}`) : null; + + const submit = async (e: FormEvent) => { + e.preventDefault(); + setTouched(true); + if (problem || busy) return; + if (unchanged) { + onClose(); + return; + } + setBusy(true); + setApiError(null); + try { + await onSubmit(trimmed); + } catch (err) { + setApiError(errorText(tCodes, toErrorLike(err), locale)); + setBusy(false); + } + }; + + const titleKey = mode === 'newFolder' ? 'newFolder' : 'rename'; + const message = apiError ?? shownProblem; + + return ( + + + + + } + > +
void submit(e)} className="space-y-1.5"> + + { + setName(e.target.value); + setApiError(null); + setTouched(true); + }} + className={`w-full rounded border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring ${ + message ? 'border-destructive' : 'border-border' + }`} + /> + {message && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/QuotaMeter.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/QuotaMeter.tsx new file mode 100644 index 0000000..20d44fe --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/QuotaMeter.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { quotaParts } from '@/components/nextcloud-files/file-format'; +import type { NcQuota } from '@/lib/nextcloud-files-api'; + +/** + * Speicheranzeige (quick-261008-mzu): schmaler Balken plus Satz. Unbegrenzter + * Speicher zeigt nur den belegten Platz; ueber 90 % wird der Balken zur Warnung. + */ +export function QuotaMeter({ quota }: { quota: NcQuota }) { + const t = useTranslations('nextcloudFiles.browser.quota'); + const locale = useLocale(); + const p = quotaParts(quota, locale); + const text = + p.total === null + ? t('usedOnly', { used: p.used }) + : t('ofTotal', { used: p.used, total: p.total }); + const percent = p.ratio === null ? null : Math.round(p.ratio * 100); + + return ( +
+ {percent !== null && ( + // biome-ignore lint/a11y/useSemanticElements: laesst sich nicht einheitlich gestalten +
+
90 ? 'bg-status-warn' : 'bg-muted-foreground/70'}`} + style={{ width: `${Math.max(percent, 2)}%` }} + /> +
+ )} + {text} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/SelectionBar.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SelectionBar.tsx new file mode 100644 index 0000000..d0f73aa --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/SelectionBar.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { CloseIcon, DownloadIcon, MoveIcon, TrashIcon } from './icons'; + +/** + * Auswahlleiste (quick-261008-mzu): ersetzt die Werkzeugleiste, solange etwas + * ausgewaehlt ist — Anzahl links, Aktionen rechts, im Gelbton der Auswahl. + */ +export function SelectionBar({ + count, + onDownload, + onMove, + onDelete, + onClear, +}: { + count: number; + onDownload: () => void; + onMove: () => void; + onDelete: () => void; + onClear: () => void; +}) { + const t = useTranslations('nextcloudFiles.browser.selection'); + return ( +
+ + + {t('count', { count })} + + + + +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/Toolbar.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/Toolbar.tsx new file mode 100644 index 0000000..0fe32a5 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/Toolbar.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { Breadcrumb } from './Breadcrumb'; +import { + ChevronDownIcon, + ChevronUpIcon, + FolderPlusIcon, + GridIcon, + ListIcon, + UploadIcon, +} from './icons'; + +export type SortKey = 'name' | 'size' | 'modified'; +export type SortDir = 'asc' | 'desc'; +export type ViewMode = 'list' | 'grid'; + +/** + * Werkzeugleiste der Dateiansicht (quick-261008-mzu): links der Ordnerpfad, + * rechts Neuer Ordner, Hochladen (die eine Hauptaktion), Ansicht und Sortierung. + */ +export function Toolbar({ + path, + onNavigate, + dropTarget, + onNewFolder, + onUploadClick, + view, + onViewChange, + sort, + onSortChange, + disabled = false, +}: { + path: string; + onNavigate: (path: string) => void; + dropTarget: string | null; + onNewFolder: () => void; + onUploadClick: () => void; + view: ViewMode; + onViewChange: (view: ViewMode) => void; + sort: { key: SortKey; dir: SortDir }; + onSortChange: (sort: { key: SortKey; dir: SortDir }) => void; + disabled?: boolean; +}) { + const t = useTranslations('nextcloudFiles.browser'); + + return ( +
+
+ +
+
+ {/* Sortierung: in der Liste ueber die Spaltenkoepfe, hier nur im Raster und auf schmalen + Bildschirmen (dort fehlt die Spalte Geaendert). */} + + + + + +
+ + +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.test.tsx new file mode 100644 index 0000000..ee70703 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.test.tsx @@ -0,0 +1,278 @@ +import { + act, + cleanup, + fireEvent, + render as rtlRender, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DroppedFile } from '@/components/nextcloud-files/drop-entries'; +import { useTransfers } from '@/components/nextcloud-files/use-transfers'; +import { NextcloudFilesRequestError } from '@/lib/nextcloud-files-api'; +import type { UploadOptions } from '@/lib/nextcloud-files-upload'; +import de from '@/messages/de.json'; +import { TransferBar } from './TransferBar'; + +const mockUpload = vi.fn(); +const mockList = vi.fn(); +const mockCreate = vi.fn(); + +vi.mock('@/lib/nextcloud-files-upload', () => ({ + uploadFile: (...a: unknown[]) => mockUpload(...a), +})); +vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listFolder: (...a: unknown[]) => mockList(...a), + createFolder: (...a: unknown[]) => mockCreate(...a), + }; +}); + +const MB = 1024 * 1024; + +interface Call { + file: File; + path: string; + opts: UploadOptions; + resolve: () => void; + reject: (err: unknown) => void; +} +let calls: Call[] = []; + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +let api: ReturnType; +const onFolderChanged = vi.fn(); + +function Harness({ quota = null }: { quota?: number | null }) { + api = useTransfers({ quotaAvailable: quota, onFolderChanged }); + return ( + void api.resolve(id, c)} + onClearFinished={api.clearFinished} + /> + ); +} + +const file = (name: string, size = 3 * MB) => new File([new Uint8Array(size)], name); +const queue = (list: File[], dir = '/', extra: Partial = {}) => + act(() => + api.enqueue( + list.map((f) => ({ file: f, relativeDir: '', ...extra })), + dir, + ), + ); + +beforeEach(() => { + calls = []; + mockUpload.mockReset().mockImplementation( + (f: File, path: string, opts: UploadOptions) => + new Promise<{ path: string; size: number }>((resolve, reject) => { + calls.push({ + file: f, + path, + opts, + resolve: () => resolve({ path, size: f.size }), + reject, + }); + opts.signal?.addEventListener('abort', () => + reject(new NextcloudFilesRequestError(0, 'aborted', 'abgebrochen')), + ); + }), + ); + mockList.mockReset().mockResolvedValue({ + path: '/', + entries: [{ name: 'Bericht.pdf' }, { name: 'Bericht (2).pdf' }], + quota: { used: 0, available: null }, + truncated: false, + }); + mockCreate.mockReset().mockResolvedValue({ path: '/x' }); + onFolderChanged.mockReset(); +}); + +afterEach(() => cleanup()); + +describe('Uebertragungsleiste und Warteschlange', () => { + it('startet hoechstens zwei Uebertragungen gleichzeitig', async () => { + render(); + await queue([file('a.txt'), file('b.txt'), file('c.txt')]); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(screen.getAllByTestId('nc-files-transfer').map((r) => r.dataset.status)).toEqual([ + 'running', + 'running', + 'queued', + ]); + await act(async () => calls[0].resolve()); + await waitFor(() => expect(calls).toHaveLength(3)); + expect(calls[2].path).toBe('/c.txt'); + }); + + it('Zeile mit Typkachel, Name, Fortschrittsbalken und "x MB von y MB"; Kopf mit Anzahl', async () => { + render(); + await queue([file('Bericht.pdf', 30 * MB)], '/Projekte'); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => calls[0].opts.onProgress?.(0.4, 12 * MB)); + const row = screen.getByTestId('nc-files-transfer'); + expect(within(row).getByText('PDF')).toBeTruthy(); + expect(within(row).getByText('Bericht.pdf')).toBeTruthy(); + expect(within(row).getByRole('progressbar').getAttribute('aria-valuenow')).toBe('40'); + expect(within(row).getByText('12 MB von 30 MB')).toBeTruthy(); + expect(screen.getByText('Übertragungen')).toBeTruthy(); + expect(screen.getByText('1 läuft')).toBeTruthy(); + expect(calls[0].path).toBe('/Projekte/Bericht.pdf'); + }); + + it('Abbrechen bricht nur diese Uebertragung ab', async () => { + render(); + await queue([file('a.txt'), file('b.txt')]); + await waitFor(() => expect(calls).toHaveLength(2)); + fireEvent.click(screen.getByRole('button', { name: 'Hochladen von „a.txt“ abbrechen' })); + expect(calls[0].opts.signal?.aborted).toBe(true); + expect(calls[1].opts.signal?.aborted).toBe(false); + await waitFor(() => expect(screen.getAllByTestId('nc-files-transfer')).toHaveLength(1)); + expect(screen.getByText('b.txt')).toBeTruthy(); + }); + + it('Fehler zeigt Klartext und "Erneut versuchen" startet neu', async () => { + render(); + await queue([file('a.txt')]); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => + calls[0].reject(new NextcloudFilesRequestError(0, 'network', 'Verbindung weg')), + ); + expect( + await screen.findByText( + 'Die Verbindung wurde unterbrochen. Bitte prüfen Sie Ihr Netzwerk und versuchen Sie es erneut.', + ), + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' })); + await waitFor(() => expect(calls).toHaveLength(2)); + }); + + it('nextcloudLocked nennt die Wartezeit, unbekannte Kennungen die Servermeldung', async () => { + render(); + await queue([file('a.txt'), file('b.txt')]); + await waitFor(() => expect(calls).toHaveLength(2)); + await act(async () => { + calls[0].reject( + new NextcloudFilesRequestError(503, 'nextcloudLocked', 'x', { retryAfterSeconds: 120 }), + ); + calls[1].reject(new NextcloudFilesRequestError(500, 'irgendwas', 'Eigene Meldung.')); + }); + expect(await screen.findByText(/in 2 Minuten erneut/)).toBeTruthy(); + expect(screen.getByText('Eigene Meldung.')).toBeTruthy(); + }); + + it('vergebener Name: Ersetzen sendet das Entity-Tag', async () => { + render(); + await queue([file('Bericht.pdf')]); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => + calls[0].reject( + new NextcloudFilesRequestError(409, 'nameTaken', 'vergeben', { + existing: { etag: 'abc', size: 10, mtime: 1 }, + }), + ), + ); + expect(await screen.findByText('Im Ordner gibt es „Bericht.pdf“ schon.')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Ersetzen' })); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1].opts.replaceEtag).toBe('abc'); + expect(calls[1].path).toBe('/Bericht.pdf'); + }); + + it('vergebener Name: Beide behalten waehlt einen freien Namen', async () => { + render(); + await queue([file('Bericht.pdf')]); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => + calls[0].reject(new NextcloudFilesRequestError(409, 'nameTaken', 'vergeben', {})), + ); + fireEvent.click(await screen.findByRole('button', { name: 'Beide behalten' })); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(calls[1].path).toBe('/Bericht (3).pdf'); + expect(calls[1].opts.replaceEtag).toBeUndefined(); + }); + + it('vergebener Name: Ueberspringen beendet ohne Upload', async () => { + render(); + await queue([file('Bericht.pdf')]); + await waitFor(() => expect(calls).toHaveLength(1)); + await act(async () => + calls[0].reject(new NextcloudFilesRequestError(409, 'nameTaken', 'vergeben', {})), + ); + expect(await screen.findByText('1 Rückfrage offen')).toBeTruthy(); + fireEvent.click(await screen.findByRole('button', { name: 'Überspringen' })); + expect(await screen.findByText('Übersprungen')).toBeTruthy(); + expect(calls).toHaveLength(1); + }); + + it('einklappen, ausklappen und Erledigte entfernen; ein fertiger Upload meldet den Ordner', async () => { + render(); + await queue([file('a.txt'), file('b.txt')], '/Projekte'); + await waitFor(() => expect(calls).toHaveLength(2)); + await act(async () => calls[0].resolve()); + expect(await screen.findByText('Hochgeladen')).toBeTruthy(); + expect(onFolderChanged).toHaveBeenCalledWith('/Projekte'); + fireEvent.click(screen.getByRole('button', { name: 'Übertragungen einklappen' })); + expect(screen.queryAllByTestId('nc-files-transfer')).toHaveLength(0); + fireEvent.click(screen.getByRole('button', { name: 'Übertragungen ausklappen' })); + expect(screen.getAllByTestId('nc-files-transfer')).toHaveLength(2); + fireEvent.click(screen.getByRole('button', { name: 'Erledigte entfernen' })); + expect(screen.getAllByTestId('nc-files-transfer')).toHaveLength(1); + expect(screen.getByText('b.txt')).toBeTruthy(); + }); + + it('eine Datei groesser als der freie Speicher wird gar nicht erst gesendet', async () => { + render(); + await queue([file('klein.txt', 2 * MB), file('gross.bin', 10 * MB)]); + await waitFor(() => expect(calls).toHaveLength(1)); + expect(calls[0].path).toBe('/klein.txt'); + const rows = screen.getAllByTestId('nc-files-transfer'); + expect(rows[1].dataset.status).toBe('error'); + expect( + within(rows[1]).getByText( + 'Nicht genug Speicherplatz in Ihrer Nextcloud. Frei sind noch 3 MB.', + ), + ).toBeTruthy(); + }); + + it('abgelegte Ordner: Unterordner werden angelegt, "existiert schon" ist kein Fehler', async () => { + mockCreate.mockRejectedValueOnce(new NextcloudFilesRequestError(409, 'nameTaken', 'da')); + render(); + await act(() => + api.enqueue( + [ + { file: file('a.txt'), relativeDir: 'Neu/Unter' }, + { file: file('b.txt'), relativeDir: 'Neu' }, + ], + '/Projekte', + ), + ); + await waitFor(() => expect(calls).toHaveLength(2)); + expect(mockCreate.mock.calls.map((c) => c[0])).toEqual([ + '/Projekte/Neu', + '/Projekte/Neu/Unter', + ]); + expect(calls.map((c) => c.path).sort()).toEqual([ + '/Projekte/Neu/Unter/a.txt', + '/Projekte/Neu/b.txt', + ]); + expect(onFolderChanged).toHaveBeenCalledWith('/Projekte'); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.tsx new file mode 100644 index 0000000..b80f18d --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.tsx @@ -0,0 +1,264 @@ +'use client'; + +import { useLocale, useTranslations } from 'next-intl'; +import { useState } from 'react'; +import { errorText } from '@/components/nextcloud-files/error-text'; +import { formatSize } from '@/components/nextcloud-files/file-format'; +import { basename } from '@/components/nextcloud-files/paths'; +import { + type ConflictChoice, + isActive, + type Transfer, +} from '@/components/nextcloud-files/use-transfers'; +import { CheckIcon, ChevronDownIcon, ChevronUpIcon, CloseIcon, RetryIcon } from './icons'; +import { TypeTile } from './TypeTile'; + +/** + * Uebertragungsleiste (quick-261008-mzu, L-09): unten angedockt, einklappbar. + * Je Datei ein Akzent-Fortschrittsbalken, Abbrechen, Klartextfehler mit + * "Erneut versuchen" und bei vergebenem Namen die drei Wahlmoeglichkeiten. + */ +export function TransferBar({ + transfers, + rootName, + onCancel, + onRetry, + onResolve, + onClearFinished, +}: { + transfers: Transfer[]; + /** Anzeigename der Wurzel ("Alle Dateien") fuer das Ziel im Tooltip. */ + rootName: string; + onCancel: (id: string) => void; + onRetry: (id: string) => void; + onResolve: (id: string, choice: ConflictChoice) => void; + onClearFinished: () => void; +}) { + const t = useTranslations('nextcloudFiles.transfers'); + const tCodes = useTranslations('nextcloudFiles.codes'); + const locale = useLocale(); + const [collapsed, setCollapsed] = useState(false); + + if (transfers.length === 0) return null; + + const active = transfers.filter(isActive); + const conflicts = transfers.filter((x) => x.status === 'conflict').length; + const failed = transfers.filter((x) => x.status === 'error').length; + const finished = transfers.some((x) => x.status === 'done' || x.status === 'skipped'); + const counted = transfers.filter((x) => isActive(x) || x.status === 'done'); + const total = counted.reduce((s, x) => s + x.size, 0); + const loaded = counted.reduce((s, x) => s + (x.status === 'done' ? x.size : x.loaded), 0); + const overall = total > 0 ? Math.round((loaded / total) * 100) : 0; + + return ( +
+
+
+

{t('title')}

+ + {active.length > 0 + ? t('active', { count: active.length }) + : conflicts > 0 + ? t('waiting', { count: conflicts }) + : failed > 0 + ? t('failed', { count: failed }) + : t('allDone')} + + {active.length > 0 && ( + {overall} % + )} + + {finished && ( + + )} + + {active.length > 0 && ( +
+ {!collapsed && ( +
    + {transfers.map((x) => ( + + ))} +
+ )} +
+
+ ); +} + +function TransferRow({ + transfer: x, + folderName, + locale, + t, + errorMessage, + onCancel, + onRetry, + onResolve, +}: { + transfer: Transfer; + folderName: string; + locale: string; + t: ReturnType; + errorMessage: string; + onCancel: (id: string) => void; + onRetry: (id: string) => void; + onResolve: (id: string, choice: ConflictChoice) => void; +}) { + const percent = + x.size > 0 ? Math.round((x.loaded / x.size) * 100) : x.status === 'done' ? 100 : 0; + const showBar = x.status === 'running' || x.status === 'assembling' || x.status === 'queued'; + + let line: React.ReactNode; + switch (x.status) { + case 'queued': + line = {t('queued')}; + break; + case 'running': + line = ( + + {t('progress', { + loaded: formatSize(x.loaded, locale), + total: formatSize(x.size, locale), + })} + + ); + break; + case 'assembling': + line = {t('assembling')}; + break; + case 'done': + line = ( + + + {t('done')} + + ); + break; + case 'skipped': + line = {t('skipped')}; + break; + case 'conflict': + line = {t('conflict.text', { name: x.name })}; + break; + default: + line = {errorMessage}; + } + + return ( +
  • + + + +
    +

    + {x.name} +

    + {showBar && ( +
    +
    +
    + )} +

    {line}

    + {x.status === 'conflict' && ( +
    + + + +
    + )} + {x.status === 'error' && ( + + )} +
    + {(showBar || x.status === 'error' || x.status === 'conflict') && ( + + )} +
  • + ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx new file mode 100644 index 0000000..b371217 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useState } from 'react'; +import { FAMILY_CLASS, fileTypeOf } from '@/components/nextcloud-files/file-types'; +import { type NcEntry, previewUrl } from '@/lib/nextcloud-files-api'; +import { FileGlyph, FOLDER_PATH } from './icons'; + +const SIZE = { + sm: { box: 'h-8 w-8 rounded-md', code: 'text-[10px] tracking-[0.03em]', glyph: 16 }, + lg: { box: 'h-[72px] w-[72px] rounded-lg', code: 'text-[15px] tracking-[0.05em]', glyph: 32 }, +} as const; + +/** Ordnersymbol: Flaeche leicht gefuellt, Kontur in der Ordnerfarbe. */ +function FolderGlyph({ size }: { size: number }) { + return ( + + ); +} + +/** + * Typkachel (quick-261008-mzu, L-09): Mosaikstein in der Farbe der Dateifamilie + * mit dem Kurzzeichen ("PDF", "XLSX"); Ordner zeigen das Ordnersymbol im + * gedaempften Mosaik-Gelb. In der Liste zeigen Bilder mit Vorschau ihr echtes + * Vorschaubild; laedt es nicht, faellt die Kachel auf das Kurzzeichen zurueck. + */ +export function TypeTile({ + entry, + size = 'sm', + preview = false, +}: { + entry: Pick & + Partial>; + size?: 'sm' | 'lg'; + /** Vorschaubild statt Kurzzeichen, wenn die Nextcloud eines liefert. */ + preview?: boolean; +}) { + const [failed, setFailed] = useState(false); + const { family, code } = fileTypeOf(entry); + const s = SIZE[size]; + // Nur Bilder: Textvorschauen waeren bei 32 px nur ein weisses Blatt. + const showPreview = + preview && !failed && family === 'image' && entry.hasPreview === true && !!entry.fileId; + + if (showPreview && entry.fileId) { + return ( + + {/* biome-ignore lint/performance/noImgElement: Vorschau kommt ueber den Tessera-Proxy, next/image passt nicht */} + {/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError ist ein Ladefehler-Rueckfall */} + setFailed(true)} + /> + + ); + } + + return ( + + ); +} diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx new file mode 100644 index 0000000..1e80caf --- /dev/null +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx @@ -0,0 +1,157 @@ +import type { ReactNode, SVGProps } from 'react'; + +/** + * Symbole der Dateiansicht (quick-261008-mzu). Pfade nach Lucide (ISC), wie im + * restlichen Portal inline — keine Symbolbibliothek als Abhaengigkeit. + */ +function Icon({ + size = 16, + children, + ...rest +}: SVGProps & { size?: number; children: ReactNode }) { + return ( + + ); +} + +type P = SVGProps & { size?: number }; + +export const FOLDER_PATH = + 'M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'; + +export const FolderIcon = (p: P) => ( + + + +); +export const FolderPlusIcon = (p: P) => ( + + + + + +); +export const UploadIcon = (p: P) => ( + + + + + +); +export const DownloadIcon = (p: P) => ( + + + + + +); +export const ListIcon = (p: P) => ( + + + + + + + + +); +export const GridIcon = (p: P) => ( + + + + + + +); +export const ChevronRightIcon = (p: P) => ( + + + +); +export const ChevronDownIcon = (p: P) => ( + + + +); +export const ChevronUpIcon = (p: P) => ( + + + +); +export const CloseIcon = (p: P) => ( + + + + +); +export const MoreIcon = (p: P) => ( + + + + + +); +export const PencilIcon = (p: P) => ( + + + +); +export const MoveIcon = (p: P) => ( + + + + + +); +export const TrashIcon = (p: P) => ( + + + + + +); +export const ExternalIcon = (p: P) => ( + + + + + +); +export const OpenIcon = (p: P) => ( + + + +); +export const CheckIcon = (p: P) => ( + + + +); +export const RetryIcon = (p: P) => ( + + + + + + +); +export const FileGlyph = (p: P) => ( + + + + +); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx index 7e58328..dc6337f 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx @@ -5,6 +5,7 @@ import { render as rtlRender, screen, waitFor, + within, } from '@testing-library/react'; import { NextIntlClientProvider } from 'next-intl'; import type { ReactElement } from 'react'; @@ -35,6 +36,7 @@ const mockStartFlow = vi.fn(); const mockPollFlow = vi.fn(); const mockCancelFlow = vi.fn(); const mockDisconnect = vi.fn(); +const mockListFolder = vi.fn(); vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { const actual = await importOriginal(); @@ -49,6 +51,7 @@ vi.mock('@/lib/nextcloud-files-api', async (importOriginal) => { pollLoginFlow: (...args: unknown[]) => mockPollFlow(...args), cancelLoginFlow: (...args: unknown[]) => mockCancelFlow(...args), disconnectNextcloud: (...args: unknown[]) => mockDisconnect(...args), + listFolder: (...args: unknown[]) => mockListFolder(...args), }; }); @@ -82,6 +85,26 @@ beforeEach(() => { mockPollFlow.mockReset(); mockCancelFlow.mockReset().mockResolvedValue({ cancelled: true }); mockDisconnect.mockReset(); + mockListFolder.mockReset().mockResolvedValue({ + path: '/', + entries: [ + { + name: 'Projekte', + path: '/Projekte', + type: 'folder', + size: 0, + mime: null, + mtime: null, + etag: null, + fileId: '1', + permissions: 'RGDNVCK', + hasPreview: false, + favorite: false, + }, + ], + quota: { used: 0, available: null }, + truncated: false, + }); }); afterEach(() => cleanup()); @@ -466,3 +489,32 @@ describe('AccountBar', () => { expect(screen.getByText('Angemeldet als Anna Müller')).toBeTruthy(); }); }); + +// --- Aufgabe 5: Dateiansicht ------------------------------------------------------------- + +describe('Dateiansicht auf der Seite', () => { + it('verbunden: die Dateiansicht laedt die Wurzel und zeigt die Eintraege', async () => { + mockGetStatus.mockResolvedValue(status({ account: account() })); + render(); + const browser = await screen.findByTestId('nextcloud-files-browser'); + expect(await within(browser).findByRole('row', { name: 'Projekte' })).toBeTruthy(); + expect(mockListFolder).toHaveBeenCalledWith('/'); + expect(within(browser).getByRole('button', { name: 'Hochladen' })).toBeTruthy(); + }); + + it('abgelaufene Verbindung beim Laden: Stand neu laden, Anmeldebildschirm mit Hinweis', async () => { + mockGetStatus + .mockResolvedValueOnce(status({ account: account() })) + .mockResolvedValue( + status({ account: account({ connected: false, expired: true, status: 'EXPIRED' }) }), + ); + mockListFolder.mockRejectedValue( + new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'), + ); + render(); + expect(await screen.findByTestId('nextcloud-files-connect')).toBeTruthy(); + expect(screen.getByText(/Ihre Verbindung zu Nextcloud ist abgelaufen/)).toBeTruthy(); + expect(screen.queryByTestId('nextcloud-files-browser')).toBeNull(); + expect(mockGetStatus).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx index 317f5fd..5b117f6 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx @@ -9,6 +9,7 @@ import { getNextcloudFilesStatus, type NextcloudFilesStatus } from '@/lib/nextcl import { useCanManageModule } from '@/lib/use-module-capability'; import { AccountBar } from './components/AccountBar'; import { ConnectPanel } from './components/ConnectPanel'; +import { FileBrowser } from './components/FileBrowser'; import { SettingsTab } from './components/SettingsTab'; type TabId = 'files' | 'settings'; @@ -19,8 +20,9 @@ type TabId = 'files' | 'settings'; * Freigabestufe Verwalten im Reiter "Einstellungen" ein; alle anderen sehen nur * den Reiter "Dateien" ohne Reiterleiste — bindend ist allein die API. Im * Abschnitt `nextcloud-files-main` verbindet jeder Benutzer sein eigenes Konto - * (ConnectPanel); ist es verbunden, steht dort der Dateibereich - * (`nextcloud-files-browser`, den die Dateiansicht fuellt). + * (ConnectPanel); ist es verbunden, steht dort die Dateiansicht + * (`nextcloud-files-browser`, FileBrowser). Laeuft die Verbindung ab, laedt die + * Seite den Stand neu und zeigt wieder den Anmeldebildschirm mit Hinweis. */ export default function NextcloudFilesPage() { const t = useTranslations('nextcloudFiles'); @@ -88,9 +90,7 @@ export default function NextcloudFilesPage() {
    {connected ? (
    -

    - {t('main.server', { host: status.host ?? '' })} -

    + void reloadStatus()} />
    ) : ( = 4,5:1 je Modus prueft + file-type-contrast.test.ts. */ + --ft-folder-bg: oklch(0.94 0.09 100); + --ft-folder-fg: oklch(0.45 0.1 95); + --ft-doc-bg: oklch(0.93 0.035 255); + --ft-doc-fg: oklch(0.46 0.11 255); + --ft-sheet-bg: oklch(0.93 0.035 150); + --ft-sheet-fg: oklch(0.46 0.11 150); + --ft-slides-bg: oklch(0.93 0.035 45); + --ft-slides-fg: oklch(0.46 0.11 45); + --ft-pdf-bg: oklch(0.93 0.035 27); + --ft-pdf-fg: oklch(0.46 0.11 27); + --ft-image-bg: oklch(0.93 0.035 305); + --ft-image-fg: oklch(0.46 0.11 305); + --ft-media-bg: oklch(0.93 0.035 340); + --ft-media-fg: oklch(0.46 0.11 340); + --ft-archive-bg: oklch(0.93 0.0175 75); + --ft-archive-fg: oklch(0.46 0.055 75); + --ft-code-bg: oklch(0.93 0.035 200); + --ft-code-fg: oklch(0.46 0.11 200); + --ft-other-bg: oklch(0.93 0.01 260); + --ft-other-fg: oklch(0.46 0.01 260); } .dark { @@ -212,6 +258,28 @@ --status-idle-fg: oklch(0.72 0.02 260); --status-orphan-fg: oklch(0.70 0.01 260); --well: oklch(0.215 0.01 260); + + /* Nextcloud-Dateien: Typfarben je Dateifamilie, dunkler Modus. */ + --ft-folder-bg: oklch(0.36 0.07 100); + --ft-folder-fg: oklch(0.88 0.14 100); + --ft-doc-bg: oklch(0.33 0.045 255); + --ft-doc-fg: oklch(0.84 0.09 255); + --ft-sheet-bg: oklch(0.33 0.045 150); + --ft-sheet-fg: oklch(0.84 0.09 150); + --ft-slides-bg: oklch(0.33 0.045 45); + --ft-slides-fg: oklch(0.84 0.09 45); + --ft-pdf-bg: oklch(0.33 0.045 27); + --ft-pdf-fg: oklch(0.84 0.09 27); + --ft-image-bg: oklch(0.33 0.045 305); + --ft-image-fg: oklch(0.84 0.09 305); + --ft-media-bg: oklch(0.33 0.045 340); + --ft-media-fg: oklch(0.84 0.09 340); + --ft-archive-bg: oklch(0.33 0.0225 75); + --ft-archive-fg: oklch(0.84 0.045 75); + --ft-code-bg: oklch(0.33 0.045 200); + --ft-code-fg: oklch(0.84 0.09 200); + --ft-other-bg: oklch(0.33 0.01 260); + --ft-other-fg: oklch(0.84 0.01 260); } body { @@ -832,3 +900,30 @@ html[data-desktop-os="linux"] .animate-pulse { padding-block: 0.125rem; line-height: 1.5rem; } + +/* --------------------------------------------------------------------------- + Nextcloud-Dateien: Ablage-Markierung beim Ziehen (quick-261008-mzu). Das + einzige laute Element der Dateiansicht: diagonale Streifen im Akzent mit + gestrichelter Kante, solange Dateien ueber dem Fenster schweben. + ------------------------------------------------------------------------- */ +.nc-drop-stripes { + background-image: repeating-linear-gradient( + 135deg, + color-mix(in oklab, var(--primary) 22%, transparent) 0 10px, + transparent 10px 20px + ); + outline: 2px dashed var(--primary-strong); + outline-offset: -2px; +} + +/* Bewegung der Dateiansicht nur als Antwort auf eine Handlung (Auswahl, + Ablegen, erste Uebertragung); `motion-safe:` schaltet sie bei reduzierter + Bewegung ganz ab. */ +@keyframes nc-fade { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes nc-rise { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} diff --git a/apps/web/src/components/nextcloud-files/drop-entries.ts b/apps/web/src/components/nextcloud-files/drop-entries.ts new file mode 100644 index 0000000..4269612 --- /dev/null +++ b/apps/web/src/components/nextcloud-files/drop-entries.ts @@ -0,0 +1,125 @@ +/** + * Abgelegte Dateien einsammeln (quick-261008-mzu): einzelne Dateien und ganze + * Ordner (ueber `webkitGetAsEntry`, rekursiv). Ordner liefern ihre Dateien mit + * dem relativen Unterordner, damit die Warteschlange fehlende Ordner anlegen + * kann. Grenzen: hoechstens 2000 Dateien und 20 Ebenen — darueber bricht das + * Einsammeln mit einem klaren Grund ab, statt halb hochzuladen. + * + * WICHTIG: `webkitGetAsEntry` funktioniert nur synchron im `drop`-Ereignis. + * Deshalb liest `entriesFromDrop` die Eintraege sofort und erst danach wird + * asynchron weitergearbeitet. + */ + +export const MAX_DROP_FILES = 2000; +export const MAX_DROP_DEPTH = 20; + +export interface DroppedFile { + file: File; + /** Unterordner relativ zum Ziel ("" = direkt ins Ziel), ohne fuehrenden Schraegstrich. */ + relativeDir: string; +} + +export type DropProblem = 'tooMany' | 'tooDeep' | 'unreadable'; + +export interface DropResult { + files: DroppedFile[]; + /** Leere Ordner, die trotzdem angelegt werden sollen (relativ zum Ziel). */ + folders: string[]; + problem: DropProblem | null; +} + +interface FsEntry { + isFile: boolean; + isDirectory: boolean; + name: string; +} +interface FsFileEntry extends FsEntry { + file(ok: (f: File) => void, fail: (e: unknown) => void): void; +} +interface FsDirEntry extends FsEntry { + createReader(): { + readEntries(ok: (entries: FsEntry[]) => void, fail: (e: unknown) => void): void; + }; +} + +/** Was im `drop`-Ereignis SOFORT gelesen werden muss. */ +export interface DropSnapshot { + entries: FsEntry[]; + files: File[]; +} + +/** Nur ein Ziehen mit Dateien zaehlt (kein markierter Text, keine Verweise). */ +export function hasFiles(dt: Pick | null | undefined): boolean { + if (!dt?.types) return false; + return Array.from(dt.types as ArrayLike).includes('Files'); +} + +export function snapshotDrop(dt: DataTransfer): DropSnapshot { + const entries: FsEntry[] = []; + const items = dt.items ? Array.from(dt.items) : []; + for (const item of items) { + if (item.kind !== 'file') continue; + const getter = (item as DataTransferItem & { webkitGetAsEntry?: () => FsEntry | null }) + .webkitGetAsEntry; + const entry = typeof getter === 'function' ? getter.call(item) : null; + if (entry) entries.push(entry); + } + return { entries, files: dt.files ? Array.from(dt.files) : [] }; +} + +class Stop extends Error { + constructor(readonly problem: DropProblem) { + super(problem); + } +} + +function readFile(entry: FsFileEntry): Promise { + return new Promise((resolve, reject) => entry.file(resolve, reject)); +} + +async function readAll(entry: FsDirEntry): Promise { + const reader = entry.createReader(); + const all: FsEntry[] = []; + // readEntries liefert in Portionen (Chrome: 100); leer = fertig. + for (;;) { + const batch = await new Promise((resolve, reject) => + reader.readEntries(resolve, reject), + ); + if (batch.length === 0) return all; + all.push(...batch); + } +} + +export async function collectDrop(snapshot: DropSnapshot): Promise { + // Ohne Eintragsschnittstelle (alte Browser, Tests): nur die flachen Dateien. + if (snapshot.entries.length === 0) { + const files = snapshot.files.map((file) => ({ file, relativeDir: '' })); + if (files.length > MAX_DROP_FILES) return { files: [], folders: [], problem: 'tooMany' }; + return { files, folders: [], problem: null }; + } + const files: DroppedFile[] = []; + const folders: string[] = []; + + const walk = async (entry: FsEntry, dir: string, depth: number): Promise => { + if (entry.isFile) { + if (files.length >= MAX_DROP_FILES) throw new Stop('tooMany'); + files.push({ file: await readFile(entry as FsFileEntry), relativeDir: dir }); + return; + } + if (!entry.isDirectory) return; + if (depth >= MAX_DROP_DEPTH) throw new Stop('tooDeep'); + const sub = dir ? `${dir}/${entry.name}` : entry.name; + folders.push(sub); + for (const child of await readAll(entry as FsDirEntry)) { + await walk(child, sub, depth + 1); + } + }; + + try { + for (const entry of snapshot.entries) await walk(entry, '', 0); + } catch (err) { + const problem = err instanceof Stop ? err.problem : 'unreadable'; + return { files: [], folders: [], problem }; + } + return { files, folders, problem: null }; +} diff --git a/apps/web/src/components/nextcloud-files/error-text.ts b/apps/web/src/components/nextcloud-files/error-text.ts new file mode 100644 index 0000000..6bb1037 --- /dev/null +++ b/apps/web/src/components/nextcloud-files/error-text.ts @@ -0,0 +1,57 @@ +import { NextcloudFilesRequestError } from '@/lib/nextcloud-files-api'; +import { formatSize } from './file-format'; + +/** Uebersetzer fuer den Namensraum `nextcloudFiles.codes`. */ +export type CodesTranslator = (key: string, values?: Record) => string; + +const KNOWN = new Set([ + 'lengthRequired', + 'chunkTooLarge', + 'fileTooLarge', + 'changedMeanwhile', + 'nameTaken', + 'quotaExceeded', + 'nextcloudLocked', + 'connectionExpired', + 'aborted', + 'network', + 'notFound', + 'moveIntoItself', + 'invalidName', + 'invalidPath', + 'nextcloudUnavailable', +]); + +export interface ErrorLike { + code: string | null; + message: string; + extra?: Record; +} + +export function toErrorLike(err: unknown): ErrorLike { + if (err instanceof NextcloudFilesRequestError) { + return { code: err.code, message: err.message, extra: err.extra }; + } + return { code: null, message: '' }; +} + +/** + * Klartext zu einer Fehlerkennung der API (quick-261008-mzu). Unbekannte + * Kennungen zeigen die deutsche Meldung des Servers, sonst einen allgemeinen Satz. + */ +export function errorText(t: CodesTranslator, error: ErrorLike, locale: string): string { + const code = error.code ?? ''; + if (code === 'nextcloudLocked') { + const seconds = Number(error.extra?.retryAfterSeconds); + const minutes = Math.max( + 1, + Math.ceil((Number.isFinite(seconds) && seconds > 0 ? seconds : 60) / 60), + ); + return t('nextcloudLocked', { minutes }); + } + if (code === 'quotaExceeded' && typeof error.extra?.free === 'number') { + return t('quotaExceededFree', { free: formatSize(error.extra.free, locale) }); + } + if (KNOWN.has(code)) return t(code); + return error.message?.trim() ? error.message : t('generic'); +} diff --git a/apps/web/src/components/nextcloud-files/file-format.test.ts b/apps/web/src/components/nextcloud-files/file-format.test.ts new file mode 100644 index 0000000..7dd84a3 --- /dev/null +++ b/apps/web/src/components/nextcloud-files/file-format.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { formatAbsolute, formatRelative, formatSize, quotaParts } from './file-format'; + +const GB = 1024 ** 3; + +describe('formatSize', () => { + it('Basis 1024, hoechstens eine Nachkommastelle, je Sprache', () => { + expect(formatSize(0, 'de')).toBe('0 B'); + expect(formatSize(512, 'de')).toBe('512 B'); + expect(formatSize(1536, 'de')).toBe('1,5 KB'); + expect(formatSize(1536, 'en')).toBe('1.5 KB'); + expect(formatSize(12 * 1024 * 1024, 'de')).toBe('12 MB'); + expect(formatSize(4.2 * GB, 'de')).toBe('4,2 GB'); + expect(formatSize(3 * 1024 ** 4, 'de')).toBe('3 TB'); + }); + + it('rundet an der Einheitsgrenze in die naechste Einheit', () => { + expect(formatSize(1024 * 1024 - 10, 'de')).toBe('1 MB'); + }); + + it('negative oder ungueltige Werte ergeben 0 B', () => { + expect(formatSize(-5, 'de')).toBe('0 B'); + expect(formatSize(Number.NaN, 'de')).toBe('0 B'); + }); +}); + +describe('formatRelative', () => { + const now = new Date(2026, 9, 8, 15, 0, 0); + + it('unter einer Minute: gerade eben / just now', () => { + expect(formatRelative(new Date(now.getTime() - 30_000), now, 'de')).toBe('gerade eben'); + expect(formatRelative(new Date(now.getTime() - 30_000), now, 'en')).toBe('just now'); + }); + + it('Minuten und Stunden in Kurzform', () => { + expect(formatRelative(new Date(now.getTime() - 3 * 3600_000), now, 'de')).toBe('vor 3 Std.'); + expect(formatRelative(new Date(now.getTime() - 12 * 60_000), now, 'de')).toBe('vor 12 Min.'); + }); + + it('Tage nach Kalendertagen: gestern', () => { + expect(formatRelative(new Date(2026, 9, 7, 9, 0), now, 'de')).toBe('gestern'); + expect(formatRelative(new Date(2026, 9, 3, 9, 0), now, 'de')).toBe('vor 5 Tagen'); + }); + + it('aelter als 30 Tage: Datum', () => { + expect(formatRelative(new Date(2026, 8, 8, 10, 0), now, 'de')).toBe('08.09.2026'); + }); + + it('ISO-Zeichenkette wird angenommen', () => { + expect(formatRelative(new Date(now.getTime() - 2 * 3600_000).toISOString(), now, 'de')).toBe( + 'vor 2 Std.', + ); + }); +}); + +describe('formatAbsolute', () => { + it('Datum und Uhrzeit', () => { + const text = formatAbsolute(new Date(2026, 9, 8, 14, 5), 'de'); + expect(text).toContain('08.10.2026'); + expect(text).toContain('14:05'); + }); +}); + +describe('quotaParts', () => { + it('belegt von gesamt', () => { + const p = quotaParts({ used: 4.2 * GB, available: 5.8 * GB }, 'de'); + expect(p).toMatchObject({ used: '4,2 GB', total: '10 GB', free: '5,8 GB' }); + expect(p.ratio).toBeCloseTo(0.42, 5); + }); + + it('unbegrenzt: nur belegt', () => { + expect(quotaParts({ used: 4.2 * GB, available: null }, 'de')).toEqual({ + used: '4,2 GB', + total: null, + free: null, + ratio: null, + }); + }); +}); diff --git a/apps/web/src/components/nextcloud-files/file-format.ts b/apps/web/src/components/nextcloud-files/file-format.ts new file mode 100644 index 0000000..f503d6d --- /dev/null +++ b/apps/web/src/components/nextcloud-files/file-format.ts @@ -0,0 +1,116 @@ +/** + * Anzeigeformate der Dateiansicht (quick-261008-mzu): Groessen, relative und + * absolute Zeitangaben, Speicherplatz. Reine Funktionen, keine React-Abhaengigkeit. + */ + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +/** Groesse zur Basis 1024 mit hoechstens einer Nachkommastelle: 1536 → "1,5 KB" (de). */ +export function formatSize(bytes: number, locale: string): string { + const value = Number.isFinite(bytes) && bytes > 0 ? bytes : 0; + let unit = 0; + let scaled = value; + while (scaled >= 1024 && unit < UNITS.length - 1) { + scaled /= 1024; + unit++; + } + // Rundung auf eine Stelle kann 1023,96 KB zu "1.024 KB" machen — dann eine Einheit hoeher. + if (unit < UNITS.length - 1 && Math.round(scaled * 10) / 10 >= 1024) { + scaled /= 1024; + unit++; + } + const number = new Intl.NumberFormat(locale, { + maximumFractionDigits: unit === 0 ? 0 : 1, + }).format(scaled); + return `${number} ${UNITS[unit]}`; +} + +const JUST_NOW: Record = { de: 'gerade eben', en: 'just now' }; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function toTime(date: Date | string | number): number { + return date instanceof Date ? date.getTime() : new Date(date).getTime(); +} + +/** Nur das Datum, z. B. "08.09.2026" (de). */ +export function formatDate(date: Date | string | number, locale: string): string { + return new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(new Date(toTime(date))); +} + +/** + * Relative Zeitangabe: "gerade eben", "vor 12 Min.", "vor 3 Std.", "gestern", + * "vor 5 Tagen"; was aelter als 30 Tage ist, erscheint als Datum. + */ +export function formatRelative( + date: Date | string | number, + now: Date | number, + locale: string, +): string { + const then = toTime(date); + const ref = now instanceof Date ? now.getTime() : now; + if (!Number.isFinite(then)) return ''; + const diff = ref - then; + if (diff < 60_000 && diff > -60_000) { + return JUST_NOW[locale.slice(0, 2)] ?? JUST_NOW.en; + } + if (diff < 0 || diff > 30 * DAY) return formatDate(then, locale); + const rtf = new Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' }); + if (diff < HOUR) return rtf.format(-Math.floor(diff / MINUTE), 'minute'); + if (diff < DAY) return rtf.format(-Math.floor(diff / HOUR), 'hour'); + // Ganze Kalendertage, damit "gestern" auch um 00:30 fuer 23:50 des Vortags stimmt. + const a = new Date(then); + const b = new Date(ref); + const days = Math.round( + (Date.UTC(b.getFullYear(), b.getMonth(), b.getDate()) - + Date.UTC(a.getFullYear(), a.getMonth(), a.getDate())) / + DAY, + ); + return rtf.format(-Math.max(1, days), 'day'); +} + +/** Datum und Uhrzeit fuer den Tooltip, z. B. "08.10.2026, 14:05". */ +export function formatAbsolute(date: Date | string | number, locale: string): string { + return new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(toTime(date))); +} + +export interface QuotaParts { + /** Belegter Platz, formatiert. */ + used: string; + /** Gesamter Platz (belegt + frei), formatiert; null = unbegrenzt oder unbekannt. */ + total: string | null; + /** Freier Platz, formatiert; null = unbegrenzt oder unbekannt. */ + free: string | null; + /** Anteil belegt 0..1; null = unbegrenzt. */ + ratio: number | null; +} + +/** Bestandteile der Speicheranzeige; der Satz selbst kommt aus den Sprachdateien. */ +export function quotaParts( + quota: { used: number; available: number | null }, + locale: string, +): QuotaParts { + const used = Math.max(0, quota.used); + if (quota.available === null || quota.available < 0) { + return { used: formatSize(used, locale), total: null, free: null, ratio: null }; + } + const total = used + quota.available; + return { + used: formatSize(used, locale), + total: formatSize(total, locale), + free: formatSize(quota.available, locale), + ratio: total > 0 ? Math.min(1, used / total) : 0, + }; +} diff --git a/apps/web/src/components/nextcloud-files/file-type-contrast.test.ts b/apps/web/src/components/nextcloud-files/file-type-contrast.test.ts new file mode 100644 index 0000000..2f6f5f6 --- /dev/null +++ b/apps/web/src/components/nextcloud-files/file-type-contrast.test.ts @@ -0,0 +1,68 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { FILE_FAMILIES } from './file-types'; + +/** + * Kontrastpruefung der Typfarben (quick-261008-mzu, D-L): Kurzzeichen auf der + * Kachel >= 4,5:1 (WCAG AA) in beiden Modi, und die Kachel hebt sich von der + * Karte ab (>= 1,15:1). OKLCH → OKLab → lineares sRGB nach Bjoern Ottosson. + */ + +const css = readFileSync(resolve(__dirname, '../../app/globals.css'), 'utf8'); + +function block(selector: ':root' | '.dark'): string { + const re = selector === ':root' ? /^:root\s*\{([\s\S]*?)^\}/m : /^\.dark\s*\{([\s\S]*?)^\}/m; + const m = re.exec(css); + if (!m) throw new Error(`Block ${selector} fehlt`); + return m[1]; +} + +function oklch(source: string, name: string): [number, number, number] { + const m = new RegExp(`--${name}:\\s*oklch\\(([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)\\)`).exec(source); + if (!m) throw new Error(`--${name} fehlt`); + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function luminance([l, c, h]: [number, number, number]): number { + const a = c * Math.cos((h * Math.PI) / 180); + const b = c * Math.sin((h * Math.PI) / 180); + const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3; + const clamp = (v: number) => Math.min(1, Math.max(0, v)); + const r = clamp(4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_); + const g = clamp(-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_); + const bl = clamp(-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; +} + +function contrast(x: number, y: number): number { + const [hi, lo] = x > y ? [x, y] : [y, x]; + return (hi + 0.05) / (lo + 0.05); +} + +describe('Typfarben der Dateiansicht', () => { + for (const [mode, selector, card] of [ + ['hell', ':root', [1, 0, 0]], + ['dunkel', '.dark', [0.245, 0.01, 260]], + ] as const) { + describe(mode, () => { + const source = block(selector); + for (const family of FILE_FAMILIES) { + it(`${family}: Schrift >= 4,5:1, Kachel hebt sich von der Karte ab`, () => { + const bg = luminance(oklch(source, `ft-${family}-bg`)); + const fg = luminance(oklch(source, `ft-${family}-fg`)); + expect(contrast(fg, bg)).toBeGreaterThanOrEqual(4.5); + expect( + contrast(bg, luminance(card as unknown as [number, number, number])), + ).toBeGreaterThanOrEqual(1.15); + }); + } + }); + } + + it('Karte im dunklen Modus entspricht dem Vergleichswert', () => { + expect(oklch(block('.dark'), 'card')).toEqual([0.245, 0.01, 260]); + }); +}); diff --git a/apps/web/src/components/nextcloud-files/file-types.test.ts b/apps/web/src/components/nextcloud-files/file-types.test.ts new file mode 100644 index 0000000..2e7c14a --- /dev/null +++ b/apps/web/src/components/nextcloud-files/file-types.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { FAMILY_CLASS, FILE_FAMILIES, fileTypeOf } from './file-types'; + +const file = (name: string, mime: string | null = null) => ({ name, type: 'file' as const, mime }); + +describe('fileTypeOf', () => { + it.each([ + ['Bericht.pdf', 'pdf', 'PDF'], + ['Tabelle.xlsx', 'sheet', 'XLSX'], + ['Folien.odp', 'slides', 'ODP'], + ['Foto.JPG', 'image', 'JPG'], + ['Film.mp4', 'media', 'MP4'], + ['Archiv.tar.gz', 'archive', 'GZ'], + ['skript.ts', 'code', 'TS'], + ['Notiz.md', 'doc', 'MD'], + ])('%s → %s %s', (name, family, code) => { + expect(fileTypeOf(file(name))).toEqual({ family, code }); + }); + + it('ohne Endung entscheidet der MIME-Typ: README mit text/plain → doc TXT', () => { + expect(fileTypeOf(file('README', 'text/plain'))).toEqual({ family: 'doc', code: 'TXT' }); + }); + + it('unbekannte Endung: other, Kurzzeichen auf vier Zeichen gekuerzt', () => { + expect(fileTypeOf(file('daten.xyz1234'))).toEqual({ family: 'other', code: 'XYZ1' }); + }); + + it('bei unbekannter Endung hat der MIME-Typ Vorrang', () => { + expect(fileTypeOf(file('scan.dat', 'image/png'))).toEqual({ family: 'image', code: 'DAT' }); + expect(fileTypeOf(file('auszug', 'application/pdf'))).toEqual({ family: 'pdf', code: 'PDF' }); + }); + + it('bekannte Endung schlaegt einen abweichenden MIME-Typ', () => { + expect(fileTypeOf(file('liste.csv', 'text/plain')).family).toBe('sheet'); + }); + + it('ein Ordner ist immer folder', () => { + expect(fileTypeOf({ name: 'Projekte.pdf', type: 'folder' })).toEqual({ + family: 'folder', + code: '', + }); + }); + + it('Name mit fuehrendem Punkt hat keine Endung', () => { + expect(fileTypeOf(file('.bashrc'))).toEqual({ family: 'other', code: '' }); + }); + + it('jede Familie hat feste Tailwind-Klassen', () => { + for (const f of FILE_FAMILIES) { + expect(FAMILY_CLASS[f]).toBe(`bg-ft-${f}-bg text-ft-${f}-fg`); + } + }); +}); diff --git a/apps/web/src/components/nextcloud-files/file-types.ts b/apps/web/src/components/nextcloud-files/file-types.ts new file mode 100644 index 0000000..f7ba48d --- /dev/null +++ b/apps/web/src/components/nextcloud-files/file-types.ts @@ -0,0 +1,200 @@ +/** + * Dateifamilien fuer die Typkacheln der Dateiansicht (quick-261008-mzu, L-09). + * Jede Datei bekommt eine Familie (bestimmt die Farbe der Kachel) und ein + * Kurzzeichen aus hoechstens vier Zeichen ("PDF", "XLSX"). Die Kurzzeichen sind + * die einzige gewollte Ausnahme von der Regel "keine Grossbuchstaben-Beschriftung". + */ + +export type FileFamily = + | 'folder' + | 'doc' + | 'sheet' + | 'slides' + | 'pdf' + | 'image' + | 'media' + | 'archive' + | 'code' + | 'other'; + +export const FILE_FAMILIES: readonly FileFamily[] = [ + 'folder', + 'doc', + 'sheet', + 'slides', + 'pdf', + 'image', + 'media', + 'archive', + 'code', + 'other', +]; + +export interface FileType { + family: FileFamily; + /** Kurzzeichen, hoechstens vier Zeichen, leer wenn nichts Sinnvolles vorliegt. */ + code: string; +} + +const EXTENSIONS: Record, readonly string[]> = { + doc: ['doc', 'docx', 'odt', 'rtf', 'txt', 'md', 'pages', 'dot', 'dotx', 'ott', 'tex', 'log'], + sheet: ['xls', 'xlsx', 'xlsm', 'ods', 'csv', 'tsv', 'numbers', 'xlt', 'xltx', 'ots'], + slides: ['ppt', 'pptx', 'odp', 'key', 'pps', 'ppsx', 'otp'], + pdf: ['pdf'], + image: [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'heic', + 'heif', + 'bmp', + 'tif', + 'tiff', + 'svg', + 'avif', + 'ico', + 'raw', + 'cr2', + 'nef', + 'psd', + ], + media: [ + 'mp3', + 'wav', + 'flac', + 'ogg', + 'oga', + 'm4a', + 'aac', + 'opus', + 'mp4', + 'm4v', + 'mov', + 'avi', + 'mkv', + 'webm', + 'wmv', + 'mpg', + 'mpeg', + ], + archive: ['zip', 'rar', '7z', 'tar', 'gz', 'tgz', 'bz2', 'xz', 'zst', 'iso', 'dmg', 'cab'], + code: [ + 'js', + 'mjs', + 'cjs', + 'ts', + 'tsx', + 'jsx', + 'json', + 'xml', + 'html', + 'htm', + 'css', + 'scss', + 'py', + 'php', + 'java', + 'c', + 'h', + 'cpp', + 'cs', + 'go', + 'rs', + 'rb', + 'sh', + 'ps1', + 'bat', + 'sql', + 'yml', + 'yaml', + 'toml', + 'ini', + 'conf', + 'vue', + 'kt', + 'swift', + ], +}; + +const FAMILY_BY_EXT = new Map(); +for (const [family, exts] of Object.entries(EXTENSIONS)) { + for (const ext of exts) FAMILY_BY_EXT.set(ext, family as FileFamily); +} + +/** Kurzzeichen fuer bekannte MIME-Typen, wenn die Datei keine (bekannte) Endung hat. */ +const CODE_BY_MIME: Record = { + 'text/plain': 'TXT', + 'text/markdown': 'MD', + 'text/csv': 'CSV', + 'application/pdf': 'PDF', + 'image/jpeg': 'JPG', + 'image/png': 'PNG', + 'image/gif': 'GIF', + 'image/webp': 'WEBP', + 'application/zip': 'ZIP', + 'application/json': 'JSON', +}; + +function familyFromMime(mime: string): FileFamily | null { + const m = mime.toLowerCase(); + if (m === 'application/pdf') return 'pdf'; + if (m.startsWith('image/')) return 'image'; + if (m.startsWith('audio/') || m.startsWith('video/')) return 'media'; + if (m.includes('spreadsheet') || m.includes('excel') || m === 'text/csv') return 'sheet'; + if (m.includes('presentation') || m.includes('powerpoint')) return 'slides'; + if (m.includes('wordprocessing') || m.includes('msword') || m.includes('opendocument.text')) { + return 'doc'; + } + if (m.startsWith('text/')) return 'doc'; + if (m.includes('zip') || m.includes('compressed') || m.includes('x-tar') || m.includes('gzip')) { + return 'archive'; + } + if (m === 'application/json' || m === 'application/xml' || m.includes('javascript')) { + return 'code'; + } + return null; +} + +/** Endung ohne Punkt in Kleinbuchstaben; leer bei Namen ohne Endung oder mit fuehrendem Punkt. */ +export function extensionOf(name: string): string { + const dot = name.lastIndexOf('.'); + if (dot <= 0 || dot === name.length - 1) return ''; + return name.slice(dot + 1).toLowerCase(); +} + +export function fileTypeOf(entry: { + name: string; + type: 'folder' | 'file'; + mime?: string | null; +}): FileType { + if (entry.type === 'folder') return { family: 'folder', code: '' }; + const ext = extensionOf(entry.name); + const known = ext ? FAMILY_BY_EXT.get(ext) : undefined; + const code = ext ? ext.toUpperCase().slice(0, 4) : ''; + if (known) return { family: known, code }; + const mime = entry.mime ?? ''; + const byMime = mime ? familyFromMime(mime) : null; + if (byMime) { + return { family: byMime, code: code || CODE_BY_MIME[mime.toLowerCase()] || '' }; + } + return { family: 'other', code }; +} + +/** + * Tailwind-Klassen je Familie als feste Zeichenketten, damit der Tailwind-Scanner + * sie findet (zusammengesetzte Klassennamen wuerden nicht erzeugt). + */ +export const FAMILY_CLASS: Record = { + folder: 'bg-ft-folder-bg text-ft-folder-fg', + doc: 'bg-ft-doc-bg text-ft-doc-fg', + sheet: 'bg-ft-sheet-bg text-ft-sheet-fg', + slides: 'bg-ft-slides-bg text-ft-slides-fg', + pdf: 'bg-ft-pdf-bg text-ft-pdf-fg', + image: 'bg-ft-image-bg text-ft-image-fg', + media: 'bg-ft-media-bg text-ft-media-fg', + archive: 'bg-ft-archive-bg text-ft-archive-fg', + code: 'bg-ft-code-bg text-ft-code-fg', + other: 'bg-ft-other-bg text-ft-other-fg', +}; diff --git a/apps/web/src/components/nextcloud-files/paths.test.ts b/apps/web/src/components/nextcloud-files/paths.test.ts new file mode 100644 index 0000000..ddefa3d --- /dev/null +++ b/apps/web/src/components/nextcloud-files/paths.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { + freeName, + isInside, + join, + nameProblem, + parent, + segments, + splitExtension, + validName, +} from './paths'; + +describe('paths', () => { + it('join und parent', () => { + expect(join('/', 'a')).toBe('/a'); + expect(join('/a', 'b')).toBe('/a/b'); + expect(parent('/a/b')).toBe('/a'); + expect(parent('/a')).toBe('/'); + expect(parent('/')).toBe('/'); + }); + + it('segments', () => { + expect(segments('/')).toEqual([]); + expect(segments('/Projekte/Übergabe')).toEqual(['Projekte', 'Übergabe']); + }); + + it('validName', () => { + expect(validName('Bericht.pdf')).toBe(true); + expect(validName('')).toBe(false); + expect(validName(' ')).toBe(false); + expect(validName('.')).toBe(false); + expect(validName('..')).toBe(false); + expect(validName('a/b')).toBe(false); + expect(validName('a\\b')).toBe(false); + expect(validName('datei.part')).toBe(false); + expect(validName('x'.repeat(251))).toBe(false); + expect(validName('x'.repeat(250))).toBe(true); + expect(nameProblem('a/b')).toBe('slash'); + }); + + it('splitExtension kennt Doppelendungen', () => { + expect(splitExtension('Bericht.pdf')).toEqual(['Bericht', '.pdf']); + expect(splitExtension('Archiv.tar.gz')).toEqual(['Archiv', '.tar.gz']); + expect(splitExtension('README')).toEqual(['README', '']); + expect(splitExtension('.bashrc')).toEqual(['.bashrc', '']); + }); + + it('freeName', () => { + expect(freeName('Bericht.pdf', ['Bericht.pdf', 'Bericht (2).pdf'])).toBe('Bericht (3).pdf'); + expect(freeName('Archiv.tar.gz', ['Archiv.tar.gz'])).toBe('Archiv (2).tar.gz'); + expect(freeName('Neu.txt', ['Alt.txt'])).toBe('Neu.txt'); + }); + + it('isInside', () => { + expect(isInside('/a', '/a/b')).toBe(true); + expect(isInside('/a', '/ab')).toBe(false); + expect(isInside('/a', '/a')).toBe(false); + expect(isInside('/', '/a')).toBe(true); + }); +}); diff --git a/apps/web/src/components/nextcloud-files/paths.ts b/apps/web/src/components/nextcloud-files/paths.ts new file mode 100644 index 0000000..741f29c --- /dev/null +++ b/apps/web/src/components/nextcloud-files/paths.ts @@ -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}/`); +} diff --git a/apps/web/src/components/nextcloud-files/selection.test.ts b/apps/web/src/components/nextcloud-files/selection.test.ts new file mode 100644 index 0000000..495803b --- /dev/null +++ b/apps/web/src/components/nextcloud-files/selection.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { EMPTY_SELECTION, type SelectionState, selectionReducer } from './selection'; + +const order = ['/a', '/b', '/c', '/d', '/e']; + +describe('selectionReducer', () => { + it('Klick waehlt nur diesen Eintrag und setzt den Anker', () => { + const s = selectionReducer( + { selected: ['/a', '/b'], anchor: '/a' }, + { + type: 'click', + key: '/c', + order, + }, + ); + expect(s).toEqual({ selected: ['/c'], anchor: '/c' }); + }); + + it('Strg-Klick schaltet um', () => { + let s = selectionReducer(EMPTY_SELECTION, { type: 'click', key: '/a', order }); + s = selectionReducer(s, { type: 'click', key: '/c', order, ctrl: true }); + expect(s.selected).toEqual(['/a', '/c']); + s = selectionReducer(s, { type: 'click', key: '/a', order, ctrl: true }); + expect(s.selected).toEqual(['/c']); + }); + + it('Umschalt-Klick waehlt den Bereich ab dem Anker in der aktuellen Sortierung', () => { + const s0 = selectionReducer(EMPTY_SELECTION, { type: 'click', key: '/b', order }); + expect(selectionReducer(s0, { type: 'click', key: '/d', order, shift: true }).selected).toEqual( + ['/b', '/c', '/d'], + ); + // Umgekehrte Sortierung: der Bereich folgt der neuen Reihenfolge. + const reversed = [...order].reverse(); + expect( + selectionReducer(s0, { type: 'click', key: '/d', order: reversed, shift: true }).selected, + ).toEqual(['/d', '/c', '/b']); + // Rueckwaerts vom Anker + expect(selectionReducer(s0, { type: 'click', key: '/a', order, shift: true }).selected).toEqual( + ['/a', '/b'], + ); + }); + + it('Kontrollkaestchen schaltet einzeln um', () => { + let s = selectionReducer(EMPTY_SELECTION, { type: 'toggle', key: '/b' }); + s = selectionReducer(s, { type: 'toggle', key: '/c' }); + expect(s.selected).toEqual(['/b', '/c']); + s = selectionReducer(s, { type: 'toggle', key: '/b' }); + expect(s.selected).toEqual(['/c']); + }); + + it('alles waehlen und leeren', () => { + const all = selectionReducer(EMPTY_SELECTION, { type: 'selectAll', order }); + expect(all.selected).toEqual(order); + expect(selectionReducer(all, { type: 'clear' })).toEqual(EMPTY_SELECTION); + }); + + it('verschwundene Eintraege fallen aus der Auswahl', () => { + const s = selectionReducer( + { selected: ['/a', '/c'], anchor: '/c' }, + { + type: 'prune', + existing: ['/a', '/b'], + }, + ); + expect(s).toEqual({ selected: ['/a'], anchor: null }); + }); + + it('veraendert die Eingaben nicht', () => { + const selected = Object.freeze(['/a']) as readonly string[]; + const frozenOrder = Object.freeze([...order]) as readonly string[]; + const state: SelectionState = Object.freeze({ selected, anchor: '/a' }); + selectionReducer(state, { type: 'click', key: '/c', order: frozenOrder, ctrl: true }); + selectionReducer(state, { type: 'click', key: '/c', order: frozenOrder, shift: true }); + selectionReducer(state, { type: 'toggle', key: '/a' }); + selectionReducer(state, { type: 'selectAll', order: frozenOrder }); + expect(selected).toEqual(['/a']); + expect(frozenOrder).toEqual(order); + }); +}); diff --git a/apps/web/src/components/nextcloud-files/selection.ts b/apps/web/src/components/nextcloud-files/selection.ts new file mode 100644 index 0000000..754dbed --- /dev/null +++ b/apps/web/src/components/nextcloud-files/selection.ts @@ -0,0 +1,82 @@ +/** + * Auswahl in der Dateiansicht (quick-261008-mzu) als reiner Zustandsuebergang. + * Schluessel sind die Pfade der Eintraege. Bereichsauswahl (Umschalt-Klick) + * richtet sich nach der AKTUELLEN Sortierung, die der Aufrufer als `order` + * uebergibt. Eingaben werden nie veraendert. + */ + +export interface SelectionState { + selected: readonly string[]; + /** Ausgangspunkt fuer Umschalt-Klicks. */ + anchor: string | null; +} + +export const EMPTY_SELECTION: SelectionState = { selected: [], anchor: null }; + +export type SelectionAction = + | { + type: 'click'; + key: string; + order: readonly string[]; + ctrl?: boolean; + shift?: boolean; + } + /** Kontrollkaestchen: schaltet genau diesen Eintrag um. */ + | { type: 'toggle'; key: string } + | { type: 'selectAll'; order: readonly string[] } + | { type: 'clear' } + /** Eintraege, die nicht mehr in der Liste stehen, fallen heraus. */ + | { type: 'prune'; existing: readonly string[] }; + +function inOrder(keys: Iterable, order: readonly string[]): string[] { + const set = new Set(keys); + return order.filter((k) => set.has(k)); +} + +export function selectionReducer(state: SelectionState, action: SelectionAction): SelectionState { + switch (action.type) { + case 'click': { + const { key, order } = action; + if (action.shift && state.anchor !== null) { + const from = order.indexOf(state.anchor); + const to = order.indexOf(key); + if (from !== -1 && to !== -1) { + const [a, b] = from <= to ? [from, to] : [to, from]; + const range = order.slice(a, b + 1); + const merged = action.ctrl ? inOrder([...state.selected, ...range], order) : range; + return { selected: merged, anchor: state.anchor }; + } + } + if (action.ctrl) { + const has = state.selected.includes(key); + return { + selected: has ? state.selected.filter((k) => k !== key) : [...state.selected, key], + anchor: key, + }; + } + return { selected: [key], anchor: key }; + } + case 'toggle': { + const has = state.selected.includes(action.key); + return { + selected: has + ? state.selected.filter((k) => k !== action.key) + : [...state.selected, action.key], + anchor: action.key, + }; + } + case 'selectAll': + return { selected: [...action.order], anchor: state.anchor }; + case 'clear': + return EMPTY_SELECTION; + case 'prune': { + const existing = new Set(action.existing); + const selected = state.selected.filter((k) => existing.has(k)); + const anchor = state.anchor !== null && existing.has(state.anchor) ? state.anchor : null; + if (selected.length === state.selected.length && anchor === state.anchor) return state; + return { selected, anchor }; + } + default: + return state; + } +} diff --git a/apps/web/src/components/nextcloud-files/use-transfers.ts b/apps/web/src/components/nextcloud-files/use-transfers.ts new file mode 100644 index 0000000..8fdf5be --- /dev/null +++ b/apps/web/src/components/nextcloud-files/use-transfers.ts @@ -0,0 +1,323 @@ +'use client'; + +import { NEXTCLOUD_FILES_CHUNK_SIZE } from '@tessera/shared'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { createFolder, listFolder, NextcloudFilesRequestError } from '@/lib/nextcloud-files-api'; +import { uploadFile } from '@/lib/nextcloud-files-upload'; +import type { DroppedFile } from './drop-entries'; +import { freeName, join } from './paths'; + +/** + * Warteschlange der Uebertragungen (quick-261008-mzu, L-06/L-09): hoechstens zwei + * Dateien gleichzeitig, jede mit eigenem Abbruch. Ein vergebener Name endet nicht + * als Fehler, sondern als Rueckfrage (Ersetzen, Beide behalten, Ueberspringen). + * Was nicht in den freien Speicher passt, wird gar nicht erst gesendet. + */ + +export const MAX_PARALLEL = 2; +/** Fortschritt hoechstens alle 100 ms in den Zustand schreiben (sonst rendert die Leiste dauernd). */ +const PROGRESS_INTERVAL_MS = 100; + +export type TransferStatus = + | 'queued' + | 'running' + | 'assembling' + | 'done' + | 'error' + | 'conflict' + | 'skipped'; + +export interface TransferError { + code: string | null; + message: string; + extra: Record; +} + +export interface ExistingFile { + etag: string | null; + size: number | null; + mtime: string | number | null; +} + +export interface Transfer { + id: string; + /** Name, unter dem die Datei abgelegt wird (nach "Beide behalten" der freie Name). */ + name: string; + size: number; + /** Zielordner der Datei. */ + targetDir: string; + status: TransferStatus; + loaded: number; + error: TransferError | null; + existing: ExistingFile | null; +} + +export type ConflictChoice = 'replace' | 'keepBoth' | 'skip'; + +interface Internal { + file: File; + replaceEtag?: string; + controller?: AbortController; + /** Ordner, in den abgelegt wurde (fuer das Anlegen von Unterordnern). */ + baseDir: string; + relativeDir: string; +} + +export interface UseTransfersOptions { + /** Freier Speicher in Byte; null = unbegrenzt oder unbekannt. */ + quotaAvailable: number | null; + /** Ein Ordner hat sich geaendert (Datei fertig hochgeladen oder Unterordner angelegt). */ + onFolderChanged?: (dir: string) => void; +} + +let counter = 0; +function nextId(): string { + counter += 1; + return `t${Date.now().toString(36)}-${counter}`; +} + +function toError(err: unknown): TransferError { + if (err instanceof NextcloudFilesRequestError) { + return { code: err.code, message: err.message, extra: err.extra }; + } + return { code: null, message: err instanceof Error ? err.message : String(err), extra: {} }; +} + +function existingOf(err: NextcloudFilesRequestError): ExistingFile { + const raw = err.extra.existing as Partial | undefined; + return { + etag: typeof raw?.etag === 'string' ? raw.etag : null, + size: typeof raw?.size === 'number' ? raw.size : null, + mtime: raw?.mtime ?? null, + }; +} + +const ACTIVE: readonly TransferStatus[] = ['queued', 'running', 'assembling']; + +export function isActive(t: Pick): boolean { + return ACTIVE.includes(t.status); +} + +export function useTransfers(options: UseTransfersOptions) { + const [transfers, setTransfers] = useState([]); + const internals = useRef(new Map()); + const started = useRef(new Set()); + const folders = useRef(new Map>()); + const optsRef = useRef(options); + optsRef.current = options; + const listRef = useRef(transfers); + listRef.current = transfers; + + const patch = useCallback((id: string, change: Partial) => { + setTransfers((list) => list.map((t) => (t.id === id ? { ...t, ...change } : t))); + }, []); + + /** Legt `relativeDir` unter `baseDir` Stufe fuer Stufe an; "existiert schon" ist kein Fehler. */ + const ensureFolders = useCallback((baseDir: string, relativeDir: string): Promise => { + const parts = relativeDir.split('/').filter(Boolean); + let chain: Promise = Promise.resolve(); + let path = baseDir; + parts.forEach((part, index) => { + path = join(path, part); + const target = path; + const known = folders.current.get(target); + if (known) { + chain = chain.then(() => known); + return; + } + const created = chain.then(async () => { + try { + await createFolder(target); + } catch (err) { + if (!(err instanceof NextcloudFilesRequestError && err.code === 'nameTaken')) { + folders.current.delete(target); + throw err; + } + } + if (index === 0) optsRef.current.onFolderChanged?.(baseDir); + }); + folders.current.set(target, created); + chain = created; + }); + return chain; + }, []); + + const run = useCallback( + async (id: string) => { + const it = internals.current.get(id); + const item = listRef.current.find((t) => t.id === id); + if (!it || !item) { + started.current.delete(id); + return; + } + const controller = new AbortController(); + it.controller = controller; + patch(id, { status: 'running', loaded: 0, error: null }); + const chunked = item.size > NEXTCLOUD_FILES_CHUNK_SIZE; + let lastReport = 0; + try { + if (it.relativeDir) await ensureFolders(it.baseDir, it.relativeDir); + await uploadFile(it.file, join(item.targetDir, item.name), { + signal: controller.signal, + ...(it.replaceEtag ? { replaceEtag: it.replaceEtag } : {}), + onProgress: (fraction, bytes) => { + const now = Date.now(); + if (fraction >= 1) { + patch(id, { loaded: item.size, ...(chunked ? { status: 'assembling' } : {}) }); + return; + } + if (now - lastReport < PROGRESS_INTERVAL_MS) return; + lastReport = now; + patch(id, { loaded: bytes }); + }, + }); + patch(id, { status: 'done', loaded: item.size }); + optsRef.current.onFolderChanged?.(item.targetDir); + } catch (err) { + if (controller.signal.aborted) { + // Abgebrochen: die Zeile ist bereits entfernt. + } else if (err instanceof NextcloudFilesRequestError && err.code === 'nameTaken') { + patch(id, { status: 'conflict', existing: existingOf(err), error: null }); + } else { + patch(id, { status: 'error', error: toError(err) }); + } + } finally { + it.controller = undefined; + started.current.delete(id); + } + }, + [ensureFolders, patch], + ); + + // Die Warteschlange abarbeiten: wartende Eintraege starten, solange weniger als zwei laufen. + useEffect(() => { + const busy = transfers.filter( + (t) => + t.status === 'running' || + t.status === 'assembling' || + (t.status === 'queued' && started.current.has(t.id)), + ).length; + let free = MAX_PARALLEL - busy; + for (const t of transfers) { + if (free <= 0) break; + if (t.status !== 'queued' || started.current.has(t.id)) continue; + started.current.add(t.id); + free -= 1; + void run(t.id); + } + }, [transfers, run]); + + // Beim Verlassen der Ansicht laufende Uebertragungen abbrechen; der Hochlader raeumt bei Nextcloud auf. + useEffect(() => { + const map = internals.current; + return () => { + for (const it of map.values()) it.controller?.abort(); + }; + }, []); + + const enqueue = useCallback( + (files: readonly DroppedFile[], baseDir: string, emptyFolders: readonly string[] = []) => { + for (const folder of emptyFolders) { + void ensureFolders(baseDir, folder).catch(() => undefined); + } + if (files.length === 0) return; + const available = optsRef.current.quotaAvailable; + let pending = listRef.current + .filter(isActive) + .reduce((sum, t) => sum + Math.max(0, t.size - t.loaded), 0); + const added: Transfer[] = []; + for (const { file, relativeDir } of files) { + const id = nextId(); + const targetDir = relativeDir ? join(baseDir, relativeDir) : baseDir; + internals.current.set(id, { file, baseDir, relativeDir }); + const tooBig = available !== null && pending + file.size > available; + if (!tooBig) pending += file.size; + added.push({ + id, + name: file.name, + size: file.size, + targetDir, + status: tooBig ? 'error' : 'queued', + loaded: 0, + existing: null, + error: tooBig + ? { + code: 'quotaExceeded', + message: '', + extra: { free: Math.max(0, (available ?? 0) - pending) }, + } + : null, + }); + } + setTransfers((list) => [...list, ...added]); + }, + [ensureFolders], + ); + + const cancel = useCallback((id: string) => { + const it = internals.current.get(id); + it?.controller?.abort(); + internals.current.delete(id); + started.current.delete(id); + setTransfers((list) => list.filter((t) => t.id !== id)); + }, []); + + const retry = useCallback( + (id: string) => { + const it = internals.current.get(id); + const item = listRef.current.find((t) => t.id === id); + if (!it || !item) return; + // Nach "inzwischen geaendert" zuerst neu fragen, statt mit dem alten Stand zu ersetzen. + if (item.error?.code === 'changedMeanwhile') it.replaceEtag = undefined; + patch(id, { status: 'queued', error: null, loaded: 0 }); + }, + [patch], + ); + + const resolve = useCallback( + async (id: string, choice: ConflictChoice) => { + const it = internals.current.get(id); + const item = listRef.current.find((t) => t.id === id); + if (!it || !item) return; + if (choice === 'skip') { + patch(id, { status: 'skipped', existing: null }); + return; + } + if (choice === 'replace') { + it.replaceEtag = item.existing?.etag ?? undefined; + patch(id, { status: 'queued', existing: null, loaded: 0 }); + return; + } + try { + const listing = await listFolder(item.targetDir); + const taken = [ + ...listing.entries.map((e) => e.name), + // Namen, die gerade in denselben Ordner unterwegs sind, gelten ebenfalls als vergeben. + ...listRef.current + .filter((t) => t.id !== id && t.targetDir === item.targetDir && isActive(t)) + .map((t) => t.name), + ]; + it.replaceEtag = undefined; + patch(id, { + name: freeName(item.name, taken), + status: 'queued', + existing: null, + loaded: 0, + }); + } catch (err) { + patch(id, { status: 'error', error: toError(err), existing: null }); + } + }, + [patch], + ); + + const clearFinished = useCallback(() => { + setTransfers((list) => { + const keep = list.filter((t) => t.status !== 'done' && t.status !== 'skipped'); + for (const t of list) if (!keep.includes(t)) internals.current.delete(t.id); + return keep; + }); + }, []); + + return { transfers, enqueue, cancel, retry, resolve, clearFinished }; +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index ee3b675..81af874 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -2334,9 +2334,6 @@ "userHint": "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten.", "goToSettings": "Zu den Einstellungen" }, - "main": { - "server": "Nextcloud: {host}" - }, "connect": { "title": "Mit Nextcloud verbinden", "expiredNotice": "Ihre Verbindung zu Nextcloud ist abgelaufen oder wurde in Nextcloud widerrufen. Bitte melden Sie sich neu an.", @@ -2407,6 +2404,169 @@ "trustedDomains": "Der Rechnername der Adresse muss in den vertrauenswürdigen Domains (trusted_domains) der Nextcloud stehen.", "twoFactor": "Benutzer mit Zwei-Faktor-Anmeldung verbinden sich über den Browser, die Anmeldung mit Passwort genügt für sie nicht." } + }, + "browser": { + "rootName": "Alle Dateien", + "regionLabel": "Dateien", + "breadcrumb": { + "label": "Ordnerpfad", + "more": "Weitere Ordner" + }, + "toolbar": { + "newFolder": "Neuer Ordner", + "upload": "Hochladen", + "view": "Ansicht", + "viewList": "Liste", + "viewGrid": "Raster", + "sortBy": "Sortieren nach", + "sortAscending": "Aufsteigend", + "sortDescending": "Absteigend", + "reload": "Neu laden" + }, + "sort": { + "name": "Name", + "size": "Größe", + "modified": "Geändert" + }, + "columns": { + "name": "Name", + "size": "Größe", + "modified": "Geändert", + "actions": "Aktionen", + "selectAll": "Alle auswählen", + "select": "„{name}“ auswählen" + }, + "selection": { + "count": "{count} ausgewählt", + "download": "Herunterladen", + "move": "Verschieben", + "delete": "Löschen", + "clear": "Auswahl aufheben" + }, + "menu": { + "label": "Aktionen für „{name}“", + "open": "Öffnen", + "download": "Herunterladen", + "downloadZip": "Als ZIP herunterladen", + "rename": "Umbenennen", + "move": "Verschieben", + "openInNextcloud": "In Nextcloud öffnen", + "delete": "Löschen" + }, + "empty": { + "title": "Dieser Ordner ist leer.", + "hint": "Dateien hierher ziehen oder hochladen." + }, + "loading": "Ordner wird geladen …", + "folderSize": "Ordner", + "unreachable": "Nextcloud ist gerade nicht erreichbar.", + "unreachableHint": "Prüfen Sie, ob die Nextcloud läuft, und versuchen Sie es dann erneut.", + "retry": "Erneut versuchen", + "folderGone": "Der Ordner existiert nicht mehr. Sie sehen wieder alle Dateien.", + "locked": "Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in {minutes, plural, one {# Minute} other {# Minuten}} erneut.", + "truncated": "Es werden die ersten 5000 Einträge angezeigt.", + "count": "{count, plural, =0 {Keine Elemente} one {# Element} other {# Elemente}}", + "quota": { + "label": "Speicherplatz", + "ofTotal": "{used} von {total} belegt", + "usedOnly": "{used} belegt" + }, + "drop": { + "target": "In „{name}“ ablegen", + "hint": "Ordner werden mit ihrem Inhalt übernommen.", + "tooMany": "Zu viele Dateien auf einmal. Legen Sie höchstens 2000 Dateien in einem Schritt ab.", + "tooDeep": "Die Ordner sind zu tief verschachtelt. Mehr als 20 Ebenen lassen sich nicht ablegen.", + "unreadable": "Die abgelegten Dateien ließen sich nicht lesen. Bitte versuchen Sie es erneut." + }, + "status": { + "deleted": "{count, plural, one {„{name}“ ist im Papierkorb.} other {# Elemente in den Papierkorb verschoben.}}", + "moved": "{count, plural, one {„{name}“ nach „{target}“ verschoben.} other {# Elemente nach „{target}“ verschoben.}}", + "renamed": "Umbenannt in „{name}“.", + "folderCreated": "Ordner „{name}“ angelegt.", + "failed": "{failed, plural, one {Ein Element ließ sich nicht verarbeiten} other {# Elemente ließen sich nicht verarbeiten}}: {reason}" + } + }, + "dialogs": { + "cancel": "Abbrechen", + "close": "Schließen", + "newFolder": { + "title": "Neuer Ordner", + "label": "Name des Ordners", + "submit": "Anlegen", + "defaultName": "Neuer Ordner" + }, + "rename": { + "title": "Umbenennen", + "label": "Neuer Name", + "submit": "Umbenennen" + }, + "busy": "Einen Moment …", + "nameProblems": { + "empty": "Bitte geben Sie einen Namen ein.", + "dots": "„.“ und „..“ sind als Name nicht erlaubt.", + "slash": "Der Name darf keinen Schrägstrich enthalten.", + "part": "Namen mit der Endung „.part“ verwendet Nextcloud selbst. Bitte wählen Sie einen anderen.", + "tooLong": "Der Name ist zu lang. Erlaubt sind höchstens 250 Zeichen." + }, + "move": { + "title": "{count, plural, one {„{name}“ verschieben} other {# Elemente verschieben}}", + "hint": "Wählen Sie den Ordner, in den verschoben werden soll.", + "submit": "Hierher verschieben", + "noFolders": "Hier gibt es keine Unterordner.", + "sameFolder": "Die Auswahl liegt bereits in diesem Ordner.", + "intoItself": "Ein Ordner lässt sich nicht in sich selbst verschieben.", + "openFolder": "Ordner „{name}“ öffnen" + }, + "delete": { + "titleOne": "„{name}“ löschen?", + "titleMany": "{count} Elemente löschen?", + "body": "Die Einträge kommen in den Papierkorb Ihrer Nextcloud und lassen sich dort wiederherstellen.", + "confirm": "In den Papierkorb verschieben" + } + }, + "transfers": { + "title": "Übertragungen", + "active": "{count, plural, one {# läuft} other {# laufen}}", + "allDone": "Alles erledigt", + "waiting": "{count, plural, one {# Rückfrage offen} other {# Rückfragen offen}}", + "failed": "{count, plural, one {# fehlgeschlagen} other {# fehlgeschlagen}}", + "collapse": "Übertragungen einklappen", + "expand": "Übertragungen ausklappen", + "clearFinished": "Erledigte entfernen", + "progress": "{loaded} von {total}", + "queued": "Wartet", + "assembling": "Nextcloud setzt die Datei zusammen …", + "done": "Hochgeladen", + "skipped": "Übersprungen", + "cancel": "Abbrechen", + "cancelLabel": "Hochladen von „{name}“ abbrechen", + "retry": "Erneut versuchen", + "target": "Ziel: {folder}", + "conflict": { + "text": "Im Ordner gibt es „{name}“ schon.", + "replace": "Ersetzen", + "keepBoth": "Beide behalten", + "skip": "Überspringen" + } + }, + "codes": { + "lengthRequired": "Die Größe der Datei ließ sich nicht bestimmen. Bitte versuchen Sie es erneut.", + "chunkTooLarge": "Ein Teilstück der Datei war zu groß. Bitte laden Sie die Seite neu und versuchen Sie es erneut.", + "fileTooLarge": "Die Datei ist größer, als die Nextcloud annimmt.", + "changedMeanwhile": "Die Datei wurde inzwischen in der Nextcloud geändert. Versuchen Sie es erneut, um neu zu entscheiden.", + "nameTaken": "Ein Eintrag mit diesem Namen existiert bereits.", + "quotaExceeded": "Nicht genug Speicherplatz in Ihrer Nextcloud.", + "quotaExceededFree": "Nicht genug Speicherplatz in Ihrer Nextcloud. Frei sind noch {free}.", + "nextcloudLocked": "Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in {minutes, plural, one {# Minute} other {# Minuten}} erneut.", + "connectionExpired": "Die Verbindung zu Nextcloud ist abgelaufen. Bitte melden Sie sich neu an.", + "aborted": "Abgebrochen.", + "network": "Die Verbindung wurde unterbrochen. Bitte prüfen Sie Ihr Netzwerk und versuchen Sie es erneut.", + "notFound": "Der Eintrag existiert nicht mehr.", + "moveIntoItself": "Ein Ordner lässt sich nicht in sich selbst verschieben.", + "invalidName": "Dieser Name ist nicht erlaubt.", + "invalidPath": "Dieser Name ist nicht erlaubt.", + "nextcloudUnavailable": "Nextcloud ist nicht erreichbar oder antwortet nicht rechtzeitig.", + "generic": "Das hat nicht geklappt. Bitte versuchen Sie es erneut." } } } diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index c22b389..da9c901 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -2334,9 +2334,6 @@ "userHint": "Please contact an administrator or someone with the Manage permission level.", "goToSettings": "Go to settings" }, - "main": { - "server": "Nextcloud: {host}" - }, "connect": { "title": "Connect to Nextcloud", "expiredNotice": "Your connection to Nextcloud has expired or was revoked in Nextcloud. Please sign in again.", @@ -2407,6 +2404,169 @@ "trustedDomains": "The host name of the address has to be listed in the trusted domains (trusted_domains) of Nextcloud.", "twoFactor": "Users with two-factor authentication connect through the browser; signing in with a password is not enough for them." } + }, + "browser": { + "rootName": "All files", + "regionLabel": "Files", + "breadcrumb": { + "label": "Folder path", + "more": "More folders" + }, + "toolbar": { + "newFolder": "New folder", + "upload": "Upload", + "view": "View", + "viewList": "List", + "viewGrid": "Grid", + "sortBy": "Sort by", + "sortAscending": "Ascending", + "sortDescending": "Descending", + "reload": "Reload" + }, + "sort": { + "name": "Name", + "size": "Size", + "modified": "Modified" + }, + "columns": { + "name": "Name", + "size": "Size", + "modified": "Modified", + "actions": "Actions", + "selectAll": "Select all", + "select": "Select “{name}”" + }, + "selection": { + "count": "{count} selected", + "download": "Download", + "move": "Move", + "delete": "Delete", + "clear": "Clear selection" + }, + "menu": { + "label": "Actions for “{name}”", + "open": "Open", + "download": "Download", + "downloadZip": "Download as ZIP", + "rename": "Rename", + "move": "Move", + "openInNextcloud": "Open in Nextcloud", + "delete": "Delete" + }, + "empty": { + "title": "This folder is empty.", + "hint": "Drag files here or upload them." + }, + "loading": "Loading folder …", + "folderSize": "Folder", + "unreachable": "Nextcloud cannot be reached right now.", + "unreachableHint": "Check that Nextcloud is running, then try again.", + "retry": "Try again", + "folderGone": "The folder no longer exists. You are back at all files.", + "locked": "Nextcloud is temporarily blocking requests from the Tessera server. Please try again in {minutes, plural, one {# minute} other {# minutes}}.", + "truncated": "Showing the first 5000 entries.", + "count": "{count, plural, =0 {No items} one {# item} other {# items}}", + "quota": { + "label": "Storage", + "ofTotal": "{used} of {total} used", + "usedOnly": "{used} used" + }, + "drop": { + "target": "Drop into “{name}”", + "hint": "Folders are uploaded with their contents.", + "tooMany": "Too many files at once. Drop at most 2000 files in one go.", + "tooDeep": "The folders are nested too deeply. More than 20 levels cannot be dropped.", + "unreadable": "The dropped files could not be read. Please try again." + }, + "status": { + "deleted": "{count, plural, one {“{name}” is in the trash.} other {# items moved to the trash.}}", + "moved": "{count, plural, one {“{name}” moved to “{target}”.} other {# items moved to “{target}”.}}", + "renamed": "Renamed to “{name}”.", + "folderCreated": "Folder “{name}” created.", + "failed": "{failed, plural, one {One item could not be processed} other {# items could not be processed}}: {reason}" + } + }, + "dialogs": { + "cancel": "Cancel", + "close": "Close", + "newFolder": { + "title": "New folder", + "label": "Folder name", + "submit": "Create", + "defaultName": "New folder" + }, + "rename": { + "title": "Rename", + "label": "New name", + "submit": "Rename" + }, + "busy": "One moment …", + "nameProblems": { + "empty": "Please enter a name.", + "dots": "“.” and “..” are not allowed as a name.", + "slash": "The name must not contain a slash.", + "part": "Nextcloud uses names ending in “.part” itself. Please choose another one.", + "tooLong": "The name is too long. At most 250 characters are allowed." + }, + "move": { + "title": "{count, plural, one {Move “{name}”} other {Move # items}}", + "hint": "Choose the folder to move to.", + "submit": "Move here", + "noFolders": "There are no subfolders here.", + "sameFolder": "The selection is already in this folder.", + "intoItself": "A folder cannot be moved into itself.", + "openFolder": "Open folder “{name}”" + }, + "delete": { + "titleOne": "Delete “{name}”?", + "titleMany": "Delete {count} items?", + "body": "The items go to the trash of your Nextcloud and can be restored there.", + "confirm": "Move to trash" + } + }, + "transfers": { + "title": "Transfers", + "active": "{count, plural, one {# running} other {# running}}", + "allDone": "All done", + "waiting": "{count, plural, one {# question open} other {# questions open}}", + "failed": "{count, plural, one {# failed} other {# failed}}", + "collapse": "Collapse transfers", + "expand": "Expand transfers", + "clearFinished": "Remove finished", + "progress": "{loaded} of {total}", + "queued": "Waiting", + "assembling": "Nextcloud is assembling the file …", + "done": "Uploaded", + "skipped": "Skipped", + "cancel": "Cancel", + "cancelLabel": "Cancel uploading “{name}”", + "retry": "Try again", + "target": "Destination: {folder}", + "conflict": { + "text": "“{name}” already exists in the folder.", + "replace": "Replace", + "keepBoth": "Keep both", + "skip": "Skip" + } + }, + "codes": { + "lengthRequired": "The size of the file could not be determined. Please try again.", + "chunkTooLarge": "A part of the file was too large. Please reload the page and try again.", + "fileTooLarge": "The file is larger than Nextcloud accepts.", + "changedMeanwhile": "The file was changed in Nextcloud in the meantime. Try again to decide anew.", + "nameTaken": "An item with this name already exists.", + "quotaExceeded": "Not enough storage in your Nextcloud.", + "quotaExceededFree": "Not enough storage in your Nextcloud. {free} are still free.", + "nextcloudLocked": "Nextcloud is temporarily blocking requests from the Tessera server. Please try again in {minutes, plural, one {# minute} other {# minutes}}.", + "connectionExpired": "The connection to Nextcloud has expired. Please sign in again.", + "aborted": "Cancelled.", + "network": "The connection was interrupted. Please check your network and try again.", + "notFound": "The item no longer exists.", + "moveIntoItself": "A folder cannot be moved into itself.", + "invalidName": "This name is not allowed.", + "invalidPath": "This name is not allowed.", + "nextcloudUnavailable": "Nextcloud cannot be reached or does not respond in time.", + "generic": "That did not work. Please try again." } } }