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