feat(nextcloud-files): Dateiansicht mit Ablage-Kacheln, Ziehen und Ablegen und Übertragungsleiste
- Liste als echte Dateitabelle und Raster mit Vorschaubildern; Typkacheln je Dateifamilie (OKLCH-Tokens, Kontrast hell und dunkel per Test geprüft), Ordner im gedämpften Mosaik-Gelb - Ordnerpfad mit Ablagezielen, Auswahlleiste (klebt beim Scrollen), Tastatur (Enter, Rücktaste/Alt+Pfeil hoch, Entf, F2, Strg+A, Escape), Zeilen- und Rechtsklickmenü - Dialoge für neuen Ordner, Umbenennen, Verschieben (eigene Ordnerwahl) und Löschen in den Papierkorb - Ziehen und Ablegen mit Streifen-Markierung und Ordnerzeilen als Ziel, auch ganze Ordner; Übertragungsleiste mit Fortschritt, Abbrechen, Klartextfehlern und Rückfrage bei vergebenem Namen - Speicheranzeige zählt im Unterordner den Speicher des ganzen Kontos; Ansicht je Benutzer gemerkt Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<nav aria-label={t('breadcrumb.label')} className="min-w-0">
|
||||
<ol className="flex min-w-0 items-center gap-0.5 text-[15px]">
|
||||
{visible.map((crumb, i) => {
|
||||
const last = i === visible.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={crumb === 'more' ? 'more' : crumb.path}
|
||||
className={`flex items-center gap-0.5 ${last ? 'min-w-0' : 'min-w-0 shrink'}`}
|
||||
>
|
||||
{i > 0 && (
|
||||
<ChevronRightIcon size={14} className="shrink-0 text-muted-foreground/60" />
|
||||
)}
|
||||
{crumb === 'more' ? (
|
||||
<MoreCrumbs crumbs={hidden} onNavigate={onNavigate} label={t('breadcrumb.more')} />
|
||||
) : last ? (
|
||||
<span
|
||||
aria-current="page"
|
||||
className="truncate px-1.5 font-semibold text-foreground"
|
||||
title={crumb.label}
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-drop-folder={crumb.path}
|
||||
onClick={() => onNavigate(crumb.path)}
|
||||
title={crumb.label}
|
||||
className={`max-w-[12rem] truncate rounded px-1.5 py-0.5 text-muted-foreground hover:bg-muted hover:text-foreground ${
|
||||
dropTarget === crumb.path
|
||||
? 'bg-primary/25 text-foreground ring-2 ring-primary-strong'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{crumb.label}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreCrumbs({
|
||||
crumbs,
|
||||
onNavigate,
|
||||
label,
|
||||
}: {
|
||||
crumbs: Crumb[];
|
||||
onNavigate: (path: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const button = useRef<HTMLButtonElement>(null);
|
||||
const menu = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
menu.current?.querySelector<HTMLElement>('[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 (
|
||||
<span className="relative">
|
||||
<button
|
||||
ref={button}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="rounded px-1.5 py-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
…
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
ref={menu}
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
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) => (
|
||||
<button
|
||||
key={c.path}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-drop-folder={c.path}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onNavigate(c.path);
|
||||
}}
|
||||
className="block w-full truncate px-3 py-1.5 text-left text-sm hover:bg-accent focus:bg-accent focus:outline-none"
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('nextcloudFiles.dialogs');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const one = entries.length === 1;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
one
|
||||
? t('delete.titleOne', { name: entries[0].name })
|
||||
: t('delete.titleMany', { count: entries.length })
|
||||
}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={busy}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await onConfirm();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
className="btn bg-destructive text-destructive-foreground hover:bg-destructive/90 disabled:opacity-60"
|
||||
>
|
||||
{busy ? t('busy') : t('delete.confirm')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t('delete.body')}</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLElement | null>;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const panel = useRef<HTMLDivElement>(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<HTMLElement>(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<HTMLElement>(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)
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/45 p-0 sm:items-center sm:p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onCloseRef.current();
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: Fokusfalle und Escape im Dialog */}
|
||||
<div
|
||||
ref={panel}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`flex max-h-[90vh] w-full flex-col rounded-t-xl bg-card shadow-xl outline-none sm:rounded-lg dark:outline dark:outline-1 dark:outline-border ${
|
||||
wide ? 'sm:max-w-lg' : 'sm:max-w-md'
|
||||
}`}
|
||||
>
|
||||
<h2 id={titleId} className="px-5 pt-5 text-base font-semibold text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5 pt-3">{children}</div>
|
||||
{footer && (
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border px-5 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
data-testid="nc-files-drop-overlay"
|
||||
aria-hidden="true"
|
||||
className="nc-drop-stripes pointer-events-none absolute inset-0 z-20 rounded-lg motion-safe:animate-[nc-fade_120ms_ease-out]"
|
||||
>
|
||||
<div className="flex h-full min-h-40 flex-col items-center justify-center px-4">
|
||||
<div className="sticky bottom-[30vh] top-[38vh] flex max-w-full flex-col items-center gap-1 rounded-xl bg-card px-6 py-4 text-center shadow-xl ring-1 ring-border">
|
||||
<span className="mb-1 inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<UploadIcon size={20} />
|
||||
</span>
|
||||
<p className="max-w-[28rem] truncate text-xl font-semibold text-foreground sm:text-2xl">
|
||||
{t('target', { name: targetName })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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<HTMLElement>('[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<HTMLElement>('[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(
|
||||
<div
|
||||
ref={menu}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onKeyDown}
|
||||
onContextMenu={(e) => 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) => (
|
||||
<div key={a.id}>
|
||||
{a.separated && <hr className="my-1 border-border" />}
|
||||
{a.href ? (
|
||||
<a
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
href={a.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={itemClass(a)}
|
||||
onClick={() => onCloseRef.current()}
|
||||
>
|
||||
<span className="text-muted-foreground">{a.icon}</span>
|
||||
{a.label}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
tabIndex={-1}
|
||||
className={itemClass(a)}
|
||||
onClick={() => {
|
||||
onCloseRef.current();
|
||||
a.onSelect?.();
|
||||
}}
|
||||
>
|
||||
<span className={a.destructive ? '' : 'text-muted-foreground'}>{a.icon}</span>
|
||||
{a.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
|
||||
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> = {}): 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<NcEntry> = {}) =>
|
||||
entry(path, { type: 'folder', size: 4096, ...over });
|
||||
|
||||
function listing(path: string, entries: NcEntry[], over: Partial<NcListing> = {}): 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<string, NcEntry[]> = {
|
||||
'/': ROOT_ENTRIES,
|
||||
'/Projekte': [folder('/Projekte/Halle 3'), entry('/Projekte/Plan.xlsx')],
|
||||
'/Projekte/Halle 3': [],
|
||||
'/Zebra': [],
|
||||
'/Äpfel': [],
|
||||
};
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
return rtlRender(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let clicked: string[] = [];
|
||||
let onExpired: ReturnType<typeof vi.fn>;
|
||||
|
||||
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(<FileBrowser serverUrl="https://cloud.example" onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('nicht erreichbar: Klartext und "Erneut versuchen"', async () => {
|
||||
mockList.mockRejectedValueOnce(
|
||||
new NextcloudFilesRequestError(502, 'nextcloudUnavailable', 'x'),
|
||||
);
|
||||
render(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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(<FileBrowser serverUrl={null} onExpired={onExpired} />);
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string>(ROOT);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [listing, setListing] = useState<NcListing | null>(null);
|
||||
/** Speicher des ganzen Kontos (aus der Wurzel); Unterordner melden nur ihre eigene Groesse. */
|
||||
const [accountQuota, setAccountQuota] = useState<NcQuota | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<ErrorLike | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'name', dir: 'asc' });
|
||||
const [view, setView] = useState<ViewMode>('list');
|
||||
const [selection, dispatch] = useReducer(selectionReducer, EMPTY_SELECTION);
|
||||
const [focusIndex, setFocusIndex] = useState(0);
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(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<HTMLElement>(null);
|
||||
const barRef = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
/** Nach dem Laden diesen Eintrag fokussieren (z. B. den Ordner, aus dem man kam). */
|
||||
const focusAfterLoad = useRef<string | null | false>(false);
|
||||
const statusTimer = useRef<number | undefined>(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<number | undefined>(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<unknown>,
|
||||
): 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: <OpenIcon />,
|
||||
onSelect: () => navigate(entry.path),
|
||||
});
|
||||
actions.push({
|
||||
id: 'downloadZip',
|
||||
label: t('menu.downloadZip'),
|
||||
icon: <DownloadIcon />,
|
||||
onSelect: () => triggerDownload(downloadUrl(entry.path, { zip: true })),
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
id: 'download',
|
||||
label: t('menu.download'),
|
||||
icon: <DownloadIcon />,
|
||||
onSelect: () => triggerDownload(downloadUrl(entry.path)),
|
||||
});
|
||||
}
|
||||
actions.push(
|
||||
{
|
||||
id: 'rename',
|
||||
label: t('menu.rename'),
|
||||
icon: <PencilIcon />,
|
||||
onSelect: () => setDialog({ kind: 'rename', entry }),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
label: t('menu.move'),
|
||||
icon: <MoveIcon />,
|
||||
onSelect: () => setDialog({ kind: 'move', entries: [entry] }),
|
||||
},
|
||||
);
|
||||
if (serverUrl && entry.fileId) {
|
||||
actions.push({
|
||||
id: 'openInNextcloud',
|
||||
label: t('menu.openInNextcloud'),
|
||||
icon: <ExternalIcon />,
|
||||
href: `${serverUrl.replace(/\/+$/, '')}/index.php/f/${encodeURIComponent(entry.fileId)}`,
|
||||
separated: true,
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
id: 'delete',
|
||||
label: t('menu.delete'),
|
||||
icon: <TrashIcon />,
|
||||
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 = (
|
||||
<div role="status" aria-busy="true" aria-label={t('loading')} className="py-1">
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex h-11 items-center gap-3 px-4">
|
||||
<span className="h-8 w-8 rounded-md bg-muted motion-safe:animate-pulse" />
|
||||
<span
|
||||
className="h-3 rounded bg-muted motion-safe:animate-pulse"
|
||||
style={{ width: `${[38, 52, 30, 44, 26][i]}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (loadError && !listing) {
|
||||
const locked = loadError.code === 'nextcloudLocked';
|
||||
const unreachable =
|
||||
loadError.code === null ||
|
||||
loadError.code === 'nextcloudUnavailable' ||
|
||||
loadError.code === 'network' ||
|
||||
loadError.code === 'nextcloudError';
|
||||
body = (
|
||||
<div role="alert" className="flex flex-col items-start gap-3 px-5 py-10 sm:px-8">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
{locked
|
||||
? errorText(tCodes, loadError, locale)
|
||||
: unreachable
|
||||
? t('unreachable')
|
||||
: errorText(tCodes, loadError, locale)}
|
||||
</p>
|
||||
{unreachable && <p className="text-sm text-muted-foreground">{t('unreachableHint')}</p>}
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void load(path)}>
|
||||
<RetryIcon size={15} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (entries.length === 0) {
|
||||
// Die leere Flaeche ist selbst das Ablageziel: gestrichelt wie die Ablage-Markierung.
|
||||
body = (
|
||||
<div className="p-3 sm:p-4">
|
||||
<div className="flex flex-col items-center rounded-lg border-2 border-dashed border-border px-6 py-12 text-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-muted text-muted-foreground"
|
||||
>
|
||||
<UploadIcon size={20} />
|
||||
</span>
|
||||
<p className="mt-4 text-base font-semibold text-foreground">{t('empty.title')}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('empty.hint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary mt-5"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
{t('toolbar.upload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (effectiveView === 'grid') {
|
||||
body = <FileGrid {...viewProps} />;
|
||||
} else {
|
||||
body = (
|
||||
<FileList
|
||||
{...viewProps}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
onSelectAll={(all) => dispatch(all ? { type: 'selectAll', order } : { type: 'clear' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: Tastenbelegung der Dateiansicht (Entf, F2, Strg+A …) gilt fuer alle Zeilen darin */}
|
||||
<section
|
||||
ref={rootRef}
|
||||
aria-label={t('regionLabel')}
|
||||
onKeyDown={onKeyDown}
|
||||
className="surface relative max-sm:-mx-3 max-sm:rounded-none"
|
||||
>
|
||||
<div
|
||||
ref={barRef}
|
||||
className="z-20 rounded-t-lg border-b border-border bg-card max-sm:rounded-none sm:sticky sm:top-[var(--header-height)]"
|
||||
>
|
||||
{selectedEntries.length > 0 ? (
|
||||
<SelectionBar
|
||||
count={selectedEntries.length}
|
||||
onDownload={() => downloadEntries(selectedEntries)}
|
||||
onMove={() => setDialog({ kind: 'move', entries: selectedEntries })}
|
||||
onDelete={() => setDialog({ kind: 'delete', entries: selectedEntries })}
|
||||
onClear={() => dispatch({ type: 'clear' })}
|
||||
/>
|
||||
) : (
|
||||
<Toolbar
|
||||
path={path}
|
||||
onNavigate={(p) => navigate(p)}
|
||||
dropTarget={dropTarget}
|
||||
onNewFolder={() => setDialog({ kind: 'newFolder' })}
|
||||
onUploadClick={() => fileInput.current?.click()}
|
||||
view={view}
|
||||
onViewChange={changeView}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
disabled={!listing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="nc-files-upload-input"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files ? Array.from(e.target.files) : [];
|
||||
e.target.value = '';
|
||||
if (files.length > 0) uploadPicked(files);
|
||||
}}
|
||||
/>
|
||||
{(notice || listing?.truncated) && (
|
||||
<div className="space-y-1 px-4 pb-2.5">
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md bg-status-warn/12 px-3 py-2 text-sm text-status-warn-fg"
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{listing?.truncated && (
|
||||
<p className="text-xs text-muted-foreground">{t('truncated')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{body}
|
||||
{listing && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 rounded-b-lg border-t border-border bg-well px-4 py-2.5 max-sm:rounded-none">
|
||||
<p role="status" aria-live="polite" className="min-w-0 text-xs text-muted-foreground">
|
||||
{status ? (
|
||||
<span className="text-foreground">{status}</span>
|
||||
) : (
|
||||
t('count', { count: entries.length })
|
||||
)}
|
||||
</p>
|
||||
{shownQuota && <QuotaMeter quota={shownQuota} />}
|
||||
</div>
|
||||
)}
|
||||
{dropTarget !== null && <DropOverlay targetName={targetName} />}
|
||||
</section>
|
||||
|
||||
<TransferBar
|
||||
transfers={transfers.transfers}
|
||||
rootName={t('rootName')}
|
||||
onCancel={transfers.cancel}
|
||||
onRetry={transfers.retry}
|
||||
onResolve={(id, choice) => void transfers.resolve(id, choice)}
|
||||
onClearFinished={transfers.clearFinished}
|
||||
/>
|
||||
|
||||
{menu && (
|
||||
<EntryMenu
|
||||
label={t('menu.label', { name: menu.entry.name })}
|
||||
actions={menuActions(menu.entry)}
|
||||
x={menu.x}
|
||||
y={menu.y}
|
||||
returnFocus={menu.trigger}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{dialog?.kind === 'newFolder' && (
|
||||
<NameDialog
|
||||
mode="newFolder"
|
||||
initialName={freeName(
|
||||
tDialogs('newFolder.defaultName'),
|
||||
entries.map((e) => e.name),
|
||||
)}
|
||||
onSubmit={submitNewFolder}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog?.kind === 'rename' && (
|
||||
<NameDialog
|
||||
mode="rename"
|
||||
initialName={dialog.entry.name}
|
||||
isFolder={dialog.entry.type === 'folder'}
|
||||
onSubmit={(name) => submitRename(dialog.entry, name)}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog?.kind === 'move' && (
|
||||
<MoveDialog
|
||||
entries={dialog.entries}
|
||||
startPath={path}
|
||||
onMove={(target) => confirmMove(dialog.entries, target)}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
{dialog?.kind === 'delete' && (
|
||||
<DeleteDialog
|
||||
entries={dialog.entries}
|
||||
onConfirm={() => confirmDelete(dialog.entries)}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-md bg-well">
|
||||
{image && entry.fileId ? (
|
||||
// biome-ignore lint/performance/noImgElement: Vorschau kommt ueber den Tessera-Proxy
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: onError ist ein Ladefehler-Rueckfall
|
||||
<img
|
||||
src={previewUrl(entry.fileId, entry.etag)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<TypeTile entry={entry} size="lg" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
<div
|
||||
role="grid"
|
||||
aria-multiselectable="true"
|
||||
data-testid="nc-files-grid"
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))] gap-x-3 gap-y-4 p-3 sm:p-4"
|
||||
>
|
||||
{/* 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) */}
|
||||
<div role="row" className="contents">
|
||||
{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)
|
||||
<div
|
||||
key={entry.path}
|
||||
ref={(el) => 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'
|
||||
}`}
|
||||
>
|
||||
<PreviewSurface entry={entry} />
|
||||
<input
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
aria-label={t('columns.select', { name: entry.name })}
|
||||
checked={isSelected}
|
||||
onClick={(e) => 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 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-2.5 top-2.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary-strong text-primary-foreground shadow-sm"
|
||||
>
|
||||
<CheckIcon size={13} strokeWidth={3} />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('menu.label', { name: entry.name })}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
onMenu(entry, r.right - 208, r.bottom + 4, e.currentTarget);
|
||||
}}
|
||||
className={`btn btn-icon absolute right-2.5 top-2.5 h-7 w-7 bg-card/90 text-foreground shadow-sm hover:bg-card pointer-coarse:opacity-100 ${
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 focus-visible:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
<div className="px-0.5 pb-0.5 pt-2">
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onItemOpen(entry);
|
||||
}}
|
||||
title={entry.name}
|
||||
className="line-clamp-2 break-words text-left text-sm font-medium leading-snug text-foreground hover:underline hover:underline-offset-2"
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
) : (
|
||||
<p
|
||||
title={entry.name}
|
||||
className="line-clamp-2 break-words text-sm leading-snug text-foreground"
|
||||
>
|
||||
{entry.name}
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
className="mt-0.5 text-xs tabular-nums text-muted-foreground"
|
||||
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
|
||||
>
|
||||
{formatSize(entry.size, locale)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>;
|
||||
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<HTMLInputElement>(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 (
|
||||
<th
|
||||
scope="col"
|
||||
aria-sort={active ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
className={`${TH} ${className} ${align === 'right' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSortChange({ key, dir: active && sort.dir === 'asc' ? 'desc' : 'asc' })}
|
||||
className={`inline-flex items-center gap-1 rounded px-1 py-0.5 hover:text-foreground ${
|
||||
active ? 'text-foreground' : ''
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{active &&
|
||||
(sort.dir === 'asc' ? <ChevronUpIcon size={13} /> : <ChevronDownIcon size={13} />)}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<table
|
||||
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: Dateitabelle nach dem ARIA-Grid-Muster (Auswahl, Pfeiltasten)
|
||||
role="grid"
|
||||
aria-multiselectable="true"
|
||||
aria-label={t('columns.name')}
|
||||
className="w-full table-fixed border-collapse text-sm"
|
||||
>
|
||||
<colgroup>
|
||||
<col className="w-9 sm:w-10" />
|
||||
<col />
|
||||
<col className="w-[4.75rem] sm:w-[6.5rem]" />
|
||||
<col className="hidden w-[9.5rem] sm:table-column" />
|
||||
<col className="w-10 sm:w-12" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="text-xs font-medium text-muted-foreground">
|
||||
<th scope="col" className={TH}>
|
||||
<span className="flex justify-center">
|
||||
<input
|
||||
ref={allBox}
|
||||
type="checkbox"
|
||||
aria-label={t('columns.selectAll')}
|
||||
checked={all}
|
||||
onChange={() => onSelectAll(!all)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
{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')}
|
||||
<th scope="col" className={TH}>
|
||||
<span className="sr-only">{t('columns.actions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry, index) => {
|
||||
const isSelected = selected.has(entry.path);
|
||||
const isFolder = entry.type === 'folder';
|
||||
const isDrop = isFolder && dropTarget === entry.path;
|
||||
return (
|
||||
<tr
|
||||
key={entry.path}
|
||||
ref={(el) => 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'
|
||||
}`}
|
||||
>
|
||||
<td className="p-0">
|
||||
<span className="flex justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
aria-label={t('columns.select', { name: entry.name })}
|
||||
checked={isSelected}
|
||||
onClick={(e) => 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'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-0 pl-1 pr-2">
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<TypeTile entry={entry} preview />
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onItemOpen(entry);
|
||||
}}
|
||||
className="min-w-0 truncate text-left font-medium text-foreground hover:underline hover:underline-offset-2"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={downloadUrl(entry.path)}
|
||||
tabIndex={-1}
|
||||
download
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="min-w-0 truncate text-foreground hover:underline hover:underline-offset-2"
|
||||
title={entry.name}
|
||||
>
|
||||
{entry.name}
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap py-0 pr-1 text-right text-[13px] tabular-nums text-muted-foreground">
|
||||
{formatSize(entry.size, locale)}
|
||||
</td>
|
||||
<td
|
||||
className="hidden truncate py-0 pl-3 text-[13px] text-muted-foreground sm:table-cell"
|
||||
title={entry.mtime ? formatAbsolute(entry.mtime, locale) : undefined}
|
||||
>
|
||||
{entry.mtime ? formatRelative(entry.mtime, now, locale) : ''}
|
||||
</td>
|
||||
<td className="p-0 text-center">
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-haspopup="menu"
|
||||
aria-label={t('menu.label', { name: entry.name })}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
onMenu(entry, r.right - 208, r.bottom + 4, e.currentTarget);
|
||||
}}
|
||||
className={`btn btn-subtle btn-icon h-8 w-8 text-muted-foreground hover:text-foreground pointer-coarse:opacity-100 ${
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 focus-visible:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<MoreIcon />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<NcEntry[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [moveError, setMoveError] = useState<string | null>(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 (
|
||||
<Dialog
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
wide
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={blockedReason !== null || busy || loadError !== null}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setMoveError(null);
|
||||
try {
|
||||
await onMove(path);
|
||||
} catch (err) {
|
||||
setMoveError(errorText(tCodes, toErrorLike(err), locale));
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? t('busy') : t('move.submit')}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground">{t('move.hint')}</p>
|
||||
<nav aria-label={tBrowser('breadcrumb.label')} className="mt-3">
|
||||
<ol className="flex flex-wrap items-center gap-0.5 text-sm">
|
||||
{crumbs.map((c, i) => (
|
||||
<li key={c.path} className="flex min-w-0 items-center gap-0.5">
|
||||
{i > 0 && <ChevronRightIcon size={13} className="text-muted-foreground/60" />}
|
||||
{i === crumbs.length - 1 ? (
|
||||
<span aria-current="page" className="truncate px-1 font-semibold text-foreground">
|
||||
{c.label}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPath(c.path)}
|
||||
className="truncate rounded px-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<div className="mt-2 h-64 overflow-y-auto rounded-md border border-border bg-well">
|
||||
{loadError ? (
|
||||
<p role="alert" className="p-4 text-sm text-status-down-fg">
|
||||
{loadError}
|
||||
</p>
|
||||
) : folders === null ? (
|
||||
<div className="space-y-1 p-2" aria-busy="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="h-10 rounded bg-muted/70 motion-safe:animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : folders.length === 0 ? (
|
||||
<p className="p-4 text-sm text-muted-foreground">{t('move.noFolders')}</p>
|
||||
) : (
|
||||
<ul className="py-1">
|
||||
{folders.map((f) => {
|
||||
const moving = movedFolders.includes(f.path);
|
||||
return (
|
||||
<li key={f.path}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={moving}
|
||||
aria-label={t('move.openFolder', { name: f.name })}
|
||||
onClick={() => setPath(f.path)}
|
||||
className="flex h-10 w-full items-center gap-3 px-3 text-left text-sm hover:bg-accent disabled:cursor-not-allowed disabled:opacity-45 disabled:hover:bg-transparent"
|
||||
>
|
||||
<TypeTile entry={f} />
|
||||
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||
{!moving && <ChevronRightIcon size={15} className="text-muted-foreground" />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
role={moveError ? 'alert' : undefined}
|
||||
className={`mt-2 min-h-4 text-xs ${moveError ? 'text-status-down-fg' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{moveError ?? blockedReason ?? ''}
|
||||
</p>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
{basename(path) || tBrowser('rootName')}
|
||||
</p>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
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<string | null>(null);
|
||||
const input = useRef<HTMLInputElement>(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 (
|
||||
<Dialog
|
||||
title={t(`${titleKey}.title`)}
|
||||
onClose={onClose}
|
||||
initialFocus={input}
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
form="nc-files-name-form"
|
||||
className="btn btn-primary"
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? t('busy') : t(`${titleKey}.submit`)}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="nc-files-name-form" onSubmit={(e) => void submit(e)} className="space-y-1.5">
|
||||
<label htmlFor="nc-files-name" className="block text-sm font-medium text-foreground">
|
||||
{t(`${titleKey}.label`)}
|
||||
</label>
|
||||
<input
|
||||
ref={input}
|
||||
id="nc-files-name"
|
||||
type="text"
|
||||
value={name}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
aria-invalid={message ? true : undefined}
|
||||
aria-describedby={message ? 'nc-files-name-error' : undefined}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<p id="nc-files-name-error" role="alert" className="text-sm text-status-down-fg">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2.5" data-testid="nc-files-quota">
|
||||
{percent !== null && (
|
||||
// biome-ignore lint/a11y/useSemanticElements: <meter> laesst sich nicht einheitlich gestalten
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={t('label')}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
aria-valuetext={text}
|
||||
className="h-1.5 w-[120px] overflow-hidden rounded-full bg-foreground/10"
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full ${percent > 90 ? 'bg-status-warn' : 'bg-muted-foreground/70'}`}
|
||||
style={{ width: `${Math.max(percent, 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label={t('count', { count })}
|
||||
className="flex min-h-14 flex-wrap items-center gap-x-2 gap-y-1 rounded-t-lg bg-primary/14 px-2 py-2.5 motion-safe:animate-[nc-fade_150ms_ease-out] sm:px-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle btn-icon"
|
||||
aria-label={t('clear')}
|
||||
title={t('clear')}
|
||||
onClick={onClear}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
<span className="mr-auto text-[15px] font-semibold text-foreground" aria-live="polite">
|
||||
{t('count', { count })}
|
||||
</span>
|
||||
<button type="button" className="btn btn-subtle" onClick={onDownload}>
|
||||
<DownloadIcon />
|
||||
<span className="max-sm:sr-only">{t('download')}</span>
|
||||
</button>
|
||||
<button type="button" className="btn btn-subtle" onClick={onMove}>
|
||||
<MoveIcon />
|
||||
<span className="max-sm:sr-only">{t('move')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle text-status-down-fg hover:text-status-down-fg"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span className="max-sm:sr-only">{t('delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-14 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-2.5 sm:px-4">
|
||||
<div className="min-w-0 flex-1 basis-full sm:basis-auto">
|
||||
<Breadcrumb path={path} onNavigate={onNavigate} dropTarget={dropTarget} />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end gap-1.5 sm:flex-none">
|
||||
{/* Sortierung: in der Liste ueber die Spaltenkoepfe, hier nur im Raster und auf schmalen
|
||||
Bildschirmen (dort fehlt die Spalte Geaendert). */}
|
||||
<span className={`items-center ${view === 'list' ? 'flex sm:hidden' : 'flex'}`}>
|
||||
<label className="sr-only" htmlFor="nc-files-sort">
|
||||
{t('toolbar.sortBy')}
|
||||
</label>
|
||||
<select
|
||||
id="nc-files-sort"
|
||||
value={sort.key}
|
||||
onChange={(e) => onSortChange({ key: e.target.value as SortKey, dir: sort.dir })}
|
||||
className="field-plain h-8 rounded-md border border-transparent bg-transparent pl-2 pr-1 text-sm text-muted-foreground hover:bg-muted hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<option value="name">{t('sort.name')}</option>
|
||||
<option value="size">{t('sort.size')}</option>
|
||||
<option value="modified">{t('sort.modified')}</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle btn-icon text-muted-foreground"
|
||||
aria-label={
|
||||
sort.dir === 'asc' ? t('toolbar.sortAscending') : t('toolbar.sortDescending')
|
||||
}
|
||||
title={sort.dir === 'asc' ? t('toolbar.sortAscending') : t('toolbar.sortDescending')}
|
||||
onClick={() =>
|
||||
onSortChange({ key: sort.key, dir: sort.dir === 'asc' ? 'desc' : 'asc' })
|
||||
}
|
||||
>
|
||||
{sort.dir === 'asc' ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
</button>
|
||||
</span>
|
||||
<fieldset
|
||||
aria-label={t('toolbar.view')}
|
||||
className="mr-1 hidden h-8 items-center rounded-md border border-border p-0.5 sm:inline-flex"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={view === 'list'}
|
||||
aria-label={t('toolbar.viewList')}
|
||||
title={t('toolbar.viewList')}
|
||||
onClick={() => onViewChange('list')}
|
||||
className={`inline-flex h-6 w-7 items-center justify-center rounded ${
|
||||
view === 'list'
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<ListIcon size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={view === 'grid'}
|
||||
aria-label={t('toolbar.viewGrid')}
|
||||
title={t('toolbar.viewGrid')}
|
||||
onClick={() => onViewChange('grid')}
|
||||
className={`inline-flex h-6 w-7 items-center justify-center rounded ${
|
||||
view === 'grid'
|
||||
? 'bg-muted text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<GridIcon size={15} />
|
||||
</button>
|
||||
</fieldset>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onNewFolder}
|
||||
disabled={disabled}
|
||||
aria-label={t('toolbar.newFolder')}
|
||||
>
|
||||
<FolderPlusIcon />
|
||||
<span className="hidden md:inline">{t('toolbar.newFolder')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={onUploadClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<UploadIcon />
|
||||
{t('toolbar.upload')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
|
||||
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(
|
||||
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||
{ui}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let api: ReturnType<typeof useTransfers>;
|
||||
const onFolderChanged = vi.fn();
|
||||
|
||||
function Harness({ quota = null }: { quota?: number | null }) {
|
||||
api = useTransfers({ quotaAvailable: quota, onFolderChanged });
|
||||
return (
|
||||
<TransferBar
|
||||
transfers={api.transfers}
|
||||
rootName="Alle Dateien"
|
||||
onCancel={api.cancel}
|
||||
onRetry={api.retry}
|
||||
onResolve={(id, c) => 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<DroppedFile> = {}) =>
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness />);
|
||||
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(<Harness quota={5 * MB} />);
|
||||
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(<Harness />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<section
|
||||
aria-label={t('title')}
|
||||
data-testid="nc-files-transfers"
|
||||
className="sticky bottom-0 z-30 -mx-3 mt-4 sm:mx-0 sm:ml-auto sm:max-w-xl motion-safe:animate-[nc-rise_180ms_ease-out]"
|
||||
>
|
||||
<div className="overflow-hidden rounded-t-xl border border-b-0 border-border bg-card shadow-[0_-6px_24px_oklch(0_0_0/0.14)] sm:rounded-xl sm:border-b sm:mb-3">
|
||||
<div className="relative flex h-12 items-center gap-2 pl-4 pr-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('title')}</h2>
|
||||
<span className="text-sm text-muted-foreground" aria-live="polite">
|
||||
{active.length > 0
|
||||
? t('active', { count: active.length })
|
||||
: conflicts > 0
|
||||
? t('waiting', { count: conflicts })
|
||||
: failed > 0
|
||||
? t('failed', { count: failed })
|
||||
: t('allDone')}
|
||||
</span>
|
||||
{active.length > 0 && (
|
||||
<span className="text-sm tabular-nums text-muted-foreground">{overall} %</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{finished && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle h-8 text-[13px]"
|
||||
onClick={onClearFinished}
|
||||
>
|
||||
{t('clearFinished')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle btn-icon"
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? t('expand') : t('collapse')}
|
||||
title={collapsed ? t('expand') : t('collapse')}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
{collapsed ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
</button>
|
||||
{active.length > 0 && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-0.5 bg-foreground/10">
|
||||
<span
|
||||
className="block h-full bg-primary transition-[width] duration-300"
|
||||
style={{ width: `${overall}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<ul className="max-h-[min(20rem,45vh)] overflow-y-auto border-t border-border">
|
||||
{transfers.map((x) => (
|
||||
<TransferRow
|
||||
key={x.id}
|
||||
transfer={x}
|
||||
folderName={x.targetDir === '/' ? rootName : basename(x.targetDir)}
|
||||
locale={locale}
|
||||
t={t}
|
||||
errorMessage={x.error ? errorText(tCodes, x.error, locale) : ''}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
onResolve={onResolve}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferRow({
|
||||
transfer: x,
|
||||
folderName,
|
||||
locale,
|
||||
t,
|
||||
errorMessage,
|
||||
onCancel,
|
||||
onRetry,
|
||||
onResolve,
|
||||
}: {
|
||||
transfer: Transfer;
|
||||
folderName: string;
|
||||
locale: string;
|
||||
t: ReturnType<typeof useTranslations>;
|
||||
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 = <span className="text-muted-foreground">{t('queued')}</span>;
|
||||
break;
|
||||
case 'running':
|
||||
line = (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{t('progress', {
|
||||
loaded: formatSize(x.loaded, locale),
|
||||
total: formatSize(x.size, locale),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
break;
|
||||
case 'assembling':
|
||||
line = <span className="text-muted-foreground">{t('assembling')}</span>;
|
||||
break;
|
||||
case 'done':
|
||||
line = (
|
||||
<span className="inline-flex items-center gap-1 text-status-ok-fg">
|
||||
<CheckIcon size={13} strokeWidth={2.5} />
|
||||
{t('done')}
|
||||
</span>
|
||||
);
|
||||
break;
|
||||
case 'skipped':
|
||||
line = <span className="text-muted-foreground">{t('skipped')}</span>;
|
||||
break;
|
||||
case 'conflict':
|
||||
line = <span className="text-foreground">{t('conflict.text', { name: x.name })}</span>;
|
||||
break;
|
||||
default:
|
||||
line = <span className="text-status-down-fg">{errorMessage}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
data-testid="nc-files-transfer"
|
||||
data-status={x.status}
|
||||
className="flex items-start gap-3 border-b border-border/70 px-4 py-2.5 last:border-b-0"
|
||||
>
|
||||
<span className={x.status === 'done' || x.status === 'skipped' ? 'opacity-70' : ''}>
|
||||
<TypeTile entry={{ name: x.name, type: 'file', mime: null }} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm text-foreground" title={t('target', { folder: folderName })}>
|
||||
{x.name}
|
||||
</p>
|
||||
{showBar && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={x.name}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
className="mt-1.5 h-1 overflow-hidden rounded-full bg-foreground/10"
|
||||
>
|
||||
<div
|
||||
className={`h-full rounded-full bg-primary transition-[width] duration-200 ${
|
||||
x.status === 'assembling' ? 'motion-safe:animate-pulse' : ''
|
||||
}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 text-xs">{line}</p>
|
||||
{x.status === 'conflict' && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary h-7 px-2.5 text-xs"
|
||||
onClick={() => onResolve(x.id, 'replace')}
|
||||
>
|
||||
{t('conflict.replace')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary h-7 px-2.5 text-xs"
|
||||
onClick={() => onResolve(x.id, 'keepBoth')}
|
||||
>
|
||||
{t('conflict.keepBoth')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle h-7 px-2.5 text-xs"
|
||||
onClick={() => onResolve(x.id, 'skip')}
|
||||
>
|
||||
{t('conflict.skip')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{x.status === 'error' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle -ml-2 mt-1 h-7 px-2 text-xs"
|
||||
onClick={() => onRetry(x.id)}
|
||||
>
|
||||
<RetryIcon size={13} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(showBar || x.status === 'error' || x.status === 'conflict') && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-subtle btn-icon -mr-1 h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('cancelLabel', { name: x.name })}
|
||||
title={t('cancel')}
|
||||
onClick={() => onCancel(x.id)}
|
||||
>
|
||||
<CloseIcon size={15} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.22"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d={FOLDER_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<NcEntry, 'name' | 'type' | 'mime'> &
|
||||
Partial<Pick<NcEntry, 'hasPreview' | 'fileId' | 'etag'>>;
|
||||
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 (
|
||||
<span
|
||||
data-testid="type-tile"
|
||||
data-family={family}
|
||||
className={`relative inline-flex shrink-0 overflow-hidden bg-well ${s.box}`}
|
||||
>
|
||||
{/* 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 */}
|
||||
<img
|
||||
src={previewUrl(entry.fileId, entry.etag ?? null)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="type-tile"
|
||||
data-family={family}
|
||||
aria-hidden="true"
|
||||
className={`inline-flex shrink-0 select-none items-center justify-center font-semibold leading-none ${s.box} ${FAMILY_CLASS[family]}`}
|
||||
>
|
||||
{family === 'folder' ? (
|
||||
<FolderGlyph size={s.glyph} />
|
||||
) : code ? (
|
||||
<span className={s.code}>{code}</span>
|
||||
) : (
|
||||
<FileGlyph size={s.glyph} strokeWidth={1.75} />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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<SVGSVGElement> & { size?: number; children: ReactNode }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type P = SVGProps<SVGSVGElement> & { 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) => (
|
||||
<Icon {...p}>
|
||||
<path d={FOLDER_PATH} />
|
||||
</Icon>
|
||||
);
|
||||
export const FolderPlusIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M12 10v6" />
|
||||
<path d="M9 13h6" />
|
||||
<path d={FOLDER_PATH} />
|
||||
</Icon>
|
||||
);
|
||||
export const UploadIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M12 3v12" />
|
||||
<path d="m17 8-5-5-5 5" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
</Icon>
|
||||
);
|
||||
export const DownloadIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M12 15V3" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
</Icon>
|
||||
);
|
||||
export const ListIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M3 5h.01" />
|
||||
<path d="M3 12h.01" />
|
||||
<path d="M3 19h.01" />
|
||||
<path d="M8 5h13" />
|
||||
<path d="M8 12h13" />
|
||||
<path d="M8 19h13" />
|
||||
</Icon>
|
||||
);
|
||||
export const GridIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</Icon>
|
||||
);
|
||||
export const ChevronRightIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</Icon>
|
||||
);
|
||||
export const ChevronDownIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</Icon>
|
||||
);
|
||||
export const ChevronUpIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</Icon>
|
||||
);
|
||||
export const CloseIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</Icon>
|
||||
);
|
||||
export const MoreIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<circle cx="12" cy="12" r="1" />
|
||||
<circle cx="19" cy="12" r="1" />
|
||||
<circle cx="5" cy="12" r="1" />
|
||||
</Icon>
|
||||
);
|
||||
export const PencilIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M21.17 6.81a1 1 0 0 0-3.99-3.99L3.84 16.17a2 2 0 0 0-.5.83l-1.32 4.35a.5.5 0 0 0 .62.62l4.35-1.32a2 2 0 0 0 .83-.5z" />
|
||||
</Icon>
|
||||
);
|
||||
export const MoveIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M2 9V5a2 2 0 0 1 2-2h3.93a2 2 0 0 1 1.66.9l.82 1.2a2 2 0 0 0 1.66.9H20a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H2" />
|
||||
<path d="M2 13h10" />
|
||||
<path d="m9 16 3-3-3-3" />
|
||||
</Icon>
|
||||
);
|
||||
export const TrashIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</Icon>
|
||||
);
|
||||
export const ExternalIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</Icon>
|
||||
);
|
||||
export const OpenIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
||||
</Icon>
|
||||
);
|
||||
export const CheckIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</Icon>
|
||||
);
|
||||
export const RetryIcon = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M8 16H3v5" />
|
||||
</Icon>
|
||||
);
|
||||
export const FileGlyph = (p: P) => (
|
||||
<Icon {...p}>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</Icon>
|
||||
);
|
||||
@@ -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<typeof import('@/lib/nextcloud-files-api')>();
|
||||
@@ -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(<NextcloudFilesPage />);
|
||||
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(<NextcloudFilesPage />);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<section data-testid="nextcloud-files-main" className="space-y-4">
|
||||
{connected ? (
|
||||
<div data-testid="nextcloud-files-browser">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('main.server', { host: status.host ?? '' })}
|
||||
</p>
|
||||
<FileBrowser serverUrl={status.serverUrl} onExpired={() => void reloadStatus()} />
|
||||
</div>
|
||||
) : (
|
||||
<ConnectPanel
|
||||
|
||||
Reference in New Issue
Block a user