fix(nextcloud-files): WR-03 hoechstens vier gleichzeitige Nextcloud-Aufrufe je Zugang
- die Aufrufsperre vergibt je Zugangsschluessel vier Plaetze bis zur Antwort (Kopfzeilen); weitere Aufrufe warten der Reihe nach und pruefen danach, ob der Zugang inzwischen tot oder der Ursprung gesperrt ist; ein widerrufener Zugang erzeugt so hoechstens vier 401 - Hochladen und Zusammenbau (Antwort erst nach dem ganzen Koerper) belegen keinen Platz - Weboberflaeche: Vorschaubilder laden weiter lazy; schlaegt eines fehl, prueft die Ansicht hoechstens alle 10 s die Verbindung und kehrt bei connectionExpired zur Anmeldung zurueck Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ export const MAX_PAUSE_SECONDS = 60 * 60;
|
|||||||
export const DEAD_KEY_TTL_MS = 24 * 60 * 60 * 1000;
|
export const DEAD_KEY_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
/** Hoechstzahl gemerkter Zugangsschluessel (aelteste fliegen zuerst raus). */
|
/** Hoechstzahl gemerkter Zugangsschluessel (aelteste fliegen zuerst raus). */
|
||||||
export const MAX_DEAD_KEYS = 10_000;
|
export const MAX_DEAD_KEYS = 10_000;
|
||||||
|
/** Hoechstens so viele Aufrufe je Zugangsschluessel warten gleichzeitig auf ihre Antwort (WR-03). */
|
||||||
|
export const MAX_CONCURRENT_PER_KEY = 4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aufrufsperre (quick-261008-mzu, D-O) — ein prozessweites Objekt, das jeder
|
* Aufrufsperre (quick-261008-mzu, D-O) — ein prozessweites Objekt, das jeder
|
||||||
@@ -26,6 +28,12 @@ export const MAX_DEAD_KEYS = 10_000;
|
|||||||
* tot: spaetere Aufrufe gehen gar nicht erst raus, und laufende Aufrufe
|
* tot: spaetere Aufrufe gehen gar nicht erst raus, und laufende Aufrufe
|
||||||
* desselben Schluessels werden abgebrochen.
|
* desselben Schluessels werden abgebrochen.
|
||||||
*
|
*
|
||||||
|
* (c) Hoechstens 4 gleichzeitige Aufrufe je Zugangsschluessel (WR-03): Die
|
||||||
|
* Sperre (b) greift erst, wenn das erste 401 zurueck ist. Ohne Grenze waeren
|
||||||
|
* bis dahin z. B. Dutzende Vorschaubilder unterwegs, und jedes davon zaehlte
|
||||||
|
* bei Nextcloud als Fehlanmeldung der gemeinsamen Server-Adresse. Ein Platz
|
||||||
|
* gilt bis zur Antwort (Kopfzeilen), nicht fuer den ganzen Datenstrom.
|
||||||
|
*
|
||||||
* Der Zustand liegt im Arbeitsspeicher; ein Neustart der API hebt ihn auf (die
|
* Der Zustand liegt im Arbeitsspeicher; ein Neustart der API hebt ihn auf (die
|
||||||
* Nextcloud-Sperre selbst bleibt dort bestehen und wird beim naechsten 429
|
* Nextcloud-Sperre selbst bleibt dort bestehen und wird beim naechsten 429
|
||||||
* wieder erkannt).
|
* wieder erkannt).
|
||||||
@@ -38,6 +46,7 @@ export class NextcloudCallGate {
|
|||||||
private readonly pausedUntil = new Map<string, number>();
|
private readonly pausedUntil = new Map<string, number>();
|
||||||
private readonly dead = new Map<string, number>();
|
private readonly dead = new Map<string, number>();
|
||||||
private readonly controllers = new Map<string, AbortController>();
|
private readonly controllers = new Map<string, AbortController>();
|
||||||
|
private readonly slots = new Map<string, { active: number; waiting: Array<() => void> }>();
|
||||||
|
|
||||||
// --- (a) Sperre je Ursprung --------------------------------------------------
|
// --- (a) Sperre je Ursprung --------------------------------------------------
|
||||||
|
|
||||||
@@ -119,4 +128,66 @@ export class NextcloudCallGate {
|
|||||||
}
|
}
|
||||||
return controller.signal;
|
return controller.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- (c) Gleichzeitige Aufrufe je Zugangsschluessel ---------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belegt einen der 4 Plaetze des Schluessels; sind alle belegt, wird in der
|
||||||
|
* Reihenfolge der Anfragen gewartet. Liefert die Freigabe (mehrfacher Aufruf ist
|
||||||
|
* wirkungslos). Bricht `signal` waehrend des Wartens ab, wird mit dessen Grund
|
||||||
|
* abgelehnt.
|
||||||
|
*/
|
||||||
|
async acquireSlot(key: string, signal?: AbortSignal): Promise<() => void> {
|
||||||
|
let slot = this.slots.get(key);
|
||||||
|
if (!slot) {
|
||||||
|
slot = { active: 0, waiting: [] };
|
||||||
|
this.slots.set(key, slot);
|
||||||
|
}
|
||||||
|
if (slot.active < MAX_CONCURRENT_PER_KEY) {
|
||||||
|
slot.active += 1;
|
||||||
|
return this.releaser(key, slot);
|
||||||
|
}
|
||||||
|
const queue = slot;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const onAbort = () => {
|
||||||
|
const i = queue.waiting.indexOf(grant);
|
||||||
|
if (i >= 0) queue.waiting.splice(i, 1);
|
||||||
|
reject(signal?.reason ?? new Error('aborted'));
|
||||||
|
};
|
||||||
|
const grant = () => {
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
queue.waiting.push(grant);
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) onAbort();
|
||||||
|
else signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.releaser(key, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Belegte und wartende Aufrufe eines Schluessels (fuer Tests). */
|
||||||
|
slotUsage(key: string): { active: number; waiting: number } {
|
||||||
|
const slot = this.slots.get(key);
|
||||||
|
return { active: slot?.active ?? 0, waiting: slot?.waiting.length ?? 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
private releaser(key: string, slot: { active: number; waiting: Array<() => void> }) {
|
||||||
|
let released = false;
|
||||||
|
return () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
const next = slot.waiting.shift();
|
||||||
|
// Der Platz geht direkt an den naechsten Wartenden (die Zahl der belegten bleibt gleich).
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
slot.active -= 1;
|
||||||
|
if (slot.active <= 0 && slot.waiting.length === 0 && this.slots.get(key) === slot) {
|
||||||
|
this.slots.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export async function putFile(
|
|||||||
headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS,
|
headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS,
|
||||||
bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS,
|
bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS,
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
|
// Die Antwort kommt erst nach dem ganzen Koerper; Uploads laufen je Datei nacheinander.
|
||||||
|
unthrottled: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -131,6 +133,7 @@ export async function uploadChunk(
|
|||||||
headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS,
|
headersTimeoutMs: DAV_CHUNK_HEADERS_TIMEOUT_MS,
|
||||||
bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS,
|
bodyTimeoutMs: DAV_CHUNK_BODY_TIMEOUT_MS,
|
||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
|
unthrottled: true,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -166,7 +169,8 @@ export async function uploadAssemble(
|
|||||||
'MOVE',
|
'MOVE',
|
||||||
'/remote.php/dav/uploads/',
|
'/remote.php/dav/uploads/',
|
||||||
[uploadId, '.file'],
|
[uploadId, '.file'],
|
||||||
{ headers, headersTimeoutMs: DAV_ASSEMBLE_HEADERS_TIMEOUT_MS },
|
// Der Zusammenbau kann Minuten dauern und darf keinen Platz blockieren (WR-03).
|
||||||
|
{ headers, headersTimeoutMs: DAV_ASSEMBLE_HEADERS_TIMEOUT_MS, unthrottled: true },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export interface DavExtra {
|
|||||||
headersTimeoutMs?: number;
|
headersTimeoutMs?: number;
|
||||||
bodyTimeoutMs?: number;
|
bodyTimeoutMs?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
/** Siehe `NcRequestOptions.unthrottled` (nur Hochladen und Zusammenbau). */
|
||||||
|
unthrottled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Allgemeiner WebDAV-Aufruf im Benutzerbereich; `segments` sind OHNE die Benutzerkennung. */
|
/** Allgemeiner WebDAV-Aufruf im Benutzerbereich; `segments` sind OHNE die Benutzerkennung. */
|
||||||
@@ -74,6 +76,7 @@ export function davRequest(
|
|||||||
headersTimeoutMs: extra.headersTimeoutMs ?? DAV_SMALL_TIMEOUT_MS,
|
headersTimeoutMs: extra.headersTimeoutMs ?? DAV_SMALL_TIMEOUT_MS,
|
||||||
bodyTimeoutMs: extra.bodyTimeoutMs ?? DAV_SMALL_TIMEOUT_MS,
|
bodyTimeoutMs: extra.bodyTimeoutMs ?? DAV_SMALL_TIMEOUT_MS,
|
||||||
signal: extra.signal,
|
signal: extra.signal,
|
||||||
|
unthrottled: extra.unthrottled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,102 @@ describe('ncRequest — Aufrufsperre', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ncRequest — gleichzeitige Aufrufe je Zugangsschluessel (WR-03)', () => {
|
||||||
|
it('zwanzig gleichzeitige Vorschauen mit widerrufenem Zugang: hoechstens vier erreichen Nextcloud', async () => {
|
||||||
|
const gate = new NextcloudCallGate();
|
||||||
|
let release!: () => void;
|
||||||
|
const hold = new Promise<void>((r) => {
|
||||||
|
release = r;
|
||||||
|
});
|
||||||
|
const { transport, calls } = fakeTransport(async () => {
|
||||||
|
await hold;
|
||||||
|
return reply(401);
|
||||||
|
});
|
||||||
|
const all = Promise.all(
|
||||||
|
Array.from({ length: 20 }, () =>
|
||||||
|
ncRequest(transport, gate, {
|
||||||
|
baseUrl: BASE,
|
||||||
|
prefix: '/index.php/core/preview',
|
||||||
|
query: { fileId: '1' },
|
||||||
|
method: 'GET',
|
||||||
|
authorization: 'Basic eA==',
|
||||||
|
credentialKey: 'key-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
expect(calls).toHaveLength(4);
|
||||||
|
expect(gate.slotUsage('key-1')).toEqual({ active: 4, waiting: 16 });
|
||||||
|
release();
|
||||||
|
const results = await all;
|
||||||
|
expect(calls.length).toBeLessThanOrEqual(4);
|
||||||
|
expect(results.every((r) => !r.ok && r.kind === 'credential-dead')).toBe(true);
|
||||||
|
expect(gate.slotUsage('key-1')).toEqual({ active: 0, waiting: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach den Antworten werden Plaetze frei: alle Aufrufe eines lebenden Zugangs kommen durch', async () => {
|
||||||
|
const gate = new NextcloudCallGate();
|
||||||
|
let inFlight = 0;
|
||||||
|
let peak = 0;
|
||||||
|
const { transport, calls } = fakeTransport(async () => {
|
||||||
|
inFlight += 1;
|
||||||
|
peak = Math.max(peak, inFlight);
|
||||||
|
await new Promise((r) => setTimeout(r, 2));
|
||||||
|
inFlight -= 1;
|
||||||
|
return reply(200, 'ok');
|
||||||
|
});
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 12 }, () =>
|
||||||
|
ncRequest(transport, gate, {
|
||||||
|
baseUrl: BASE,
|
||||||
|
prefix: '/status.php',
|
||||||
|
method: 'GET',
|
||||||
|
credentialKey: 'key-2',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(calls).toHaveLength(12);
|
||||||
|
expect(peak).toBeLessThanOrEqual(4);
|
||||||
|
expect(results.every((r) => r.ok)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Abbruch waehrend des Wartens gibt sofort aborted zurueck, ohne Aufruf', async () => {
|
||||||
|
const gate = new NextcloudCallGate();
|
||||||
|
const holders = await Promise.all([0, 1, 2, 3].map(() => gate.acquireSlot('key-3')));
|
||||||
|
const { transport, calls } = fakeTransport(async () => reply(200));
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = ncRequest(transport, gate, {
|
||||||
|
baseUrl: BASE,
|
||||||
|
prefix: '/status.php',
|
||||||
|
method: 'GET',
|
||||||
|
credentialKey: 'key-3',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
expect(await pending).toEqual({ ok: false, kind: 'aborted' });
|
||||||
|
expect(calls).toHaveLength(0);
|
||||||
|
expect(gate.slotUsage('key-3')).toEqual({ active: 4, waiting: 0 });
|
||||||
|
for (const free of holders) free();
|
||||||
|
expect(gate.slotUsage('key-3')).toEqual({ active: 0, waiting: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Uploads (unthrottled) belegen keinen Platz', async () => {
|
||||||
|
const gate = new NextcloudCallGate();
|
||||||
|
await Promise.all([0, 1, 2, 3].map(() => gate.acquireSlot('key-4')));
|
||||||
|
const { transport, calls } = fakeTransport(async () => reply(201));
|
||||||
|
const res = await ncRequest(transport, gate, {
|
||||||
|
baseUrl: BASE,
|
||||||
|
prefix: '/remote.php/dav/uploads/',
|
||||||
|
segments: ['anna', 'tessera-x', '00001'],
|
||||||
|
method: 'PUT',
|
||||||
|
credentialKey: 'key-4',
|
||||||
|
unthrottled: true,
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('readCappedText', () => {
|
describe('readCappedText', () => {
|
||||||
it('liest bis zur Grenze', async () => {
|
it('liest bis zur Grenze', async () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ export interface NcRequestOptions {
|
|||||||
authorization?: string;
|
authorization?: string;
|
||||||
/** Gesetzt bei Aufrufen mit gespeichertem App-Passwort (siehe Aufrufsperre). */
|
/** Gesetzt bei Aufrufen mit gespeichertem App-Passwort (siehe Aufrufsperre). */
|
||||||
credentialKey?: string;
|
credentialKey?: string;
|
||||||
|
/**
|
||||||
|
* Nicht unter die Grenze gleichzeitiger Aufrufe je Schluessel (WR-03) fallen: nur fuer
|
||||||
|
* Uebertragungen, deren Antwort erst nach dem ganzen Koerper kommt (PUT eines Stuecks,
|
||||||
|
* Zusammenbau). Sie laufen ohnehin nacheinander je Datei.
|
||||||
|
*/
|
||||||
|
unthrottled?: boolean;
|
||||||
/** OCS-Aufruf: setzt `OCS-APIRequest` und `Accept: application/json`. */
|
/** OCS-Aufruf: setzt `OCS-APIRequest` und `Accept: application/json`. */
|
||||||
ocs?: boolean;
|
ocs?: boolean;
|
||||||
headersTimeoutMs?: number;
|
headersTimeoutMs?: number;
|
||||||
@@ -319,6 +325,42 @@ export async function ncRequest(
|
|||||||
return { ok: false, kind: 'paused', retryAfterSeconds: pause.retryAfterSeconds };
|
return { ok: false, kind: 'paused', retryAfterSeconds: pause.retryAfterSeconds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WR-03: hoechstens 4 gleichzeitige Aufrufe je Zugangsschluessel bis zur Antwort. Ein
|
||||||
|
// widerrufener Zugang erzeugt so hoechstens ein paar 401, bevor er als tot gilt.
|
||||||
|
let releaseSlot: (() => void) | undefined;
|
||||||
|
if (opts.credentialKey && !opts.unthrottled) {
|
||||||
|
try {
|
||||||
|
releaseSlot = await gate.acquireSlot(opts.credentialKey, opts.signal);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, kind: 'aborted' };
|
||||||
|
}
|
||||||
|
// Waehrend des Wartens kann der Schluessel gestorben oder der Ursprung gesperrt worden sein.
|
||||||
|
if (gate.isDead(opts.credentialKey)) {
|
||||||
|
releaseSlot();
|
||||||
|
return { ok: false, kind: 'credential-dead' };
|
||||||
|
}
|
||||||
|
const later = gate.isPaused(origin);
|
||||||
|
if (later.paused) {
|
||||||
|
releaseSlot();
|
||||||
|
return { ok: false, kind: 'paused', retryAfterSeconds: later.retryAfterSeconds };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await sendNcRequest(transport, gate, opts, url, origin);
|
||||||
|
} finally {
|
||||||
|
// Erst NACH der Auswertung (401 -> tot, 429 -> Sperre) freigeben: der naechste Wartende
|
||||||
|
// sieht den neuen Zustand, bevor er sendet.
|
||||||
|
releaseSlot?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNcRequest(
|
||||||
|
transport: NextcloudTransport,
|
||||||
|
gate: NextcloudCallGate,
|
||||||
|
opts: NcRequestOptions,
|
||||||
|
url: string,
|
||||||
|
origin: string,
|
||||||
|
): Promise<NcResult> {
|
||||||
const headers: Record<string, string> = { 'user-agent': NC_USER_AGENT };
|
const headers: Record<string, string> = { 'user-agent': NC_USER_AGENT };
|
||||||
if (opts.ocs) {
|
if (opts.ocs) {
|
||||||
headers['ocs-apirequest'] = 'true';
|
headers['ocs-apirequest'] = 'true';
|
||||||
|
|||||||
@@ -520,3 +520,27 @@ describe('FileBrowser — Ziehen und Ablegen', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('FileBrowser — Vorschaubilder (WR-03)', () => {
|
||||||
|
it('ein fehlgeschlagenes Vorschaubild prueft die Verbindung; ist sie abgelaufen, geht es zum Anmeldebildschirm', async () => {
|
||||||
|
const photos = [
|
||||||
|
folder('/Projekte'),
|
||||||
|
entry('/Foto.jpg', { mime: 'image/jpeg', hasPreview: true, fileId: '501' }),
|
||||||
|
entry('/Bild.png', { mime: 'image/png', hasPreview: true, fileId: '502' }),
|
||||||
|
];
|
||||||
|
mockList.mockImplementation(async (p: string) => listing(p, p === '/' ? photos : []));
|
||||||
|
const { container } = await mount();
|
||||||
|
const imgs = container.querySelectorAll('img');
|
||||||
|
expect(imgs.length).toBe(2);
|
||||||
|
expect(Array.from(imgs).every((i) => i.getAttribute('loading') === 'lazy')).toBe(true);
|
||||||
|
const callsBefore = mockList.mock.calls.length;
|
||||||
|
mockList.mockRejectedValue(
|
||||||
|
new NextcloudFilesRequestError(409, 'connectionExpired', 'abgelaufen'),
|
||||||
|
);
|
||||||
|
fireEvent.error(imgs[0]);
|
||||||
|
fireEvent.error(imgs[1]);
|
||||||
|
await waitFor(() => expect(onExpired).toHaveBeenCalledTimes(1));
|
||||||
|
// Zwei Fehler kurz hintereinander loesen nur EINE Pruefung aus.
|
||||||
|
expect(mockList.mock.calls.length - callsBefore).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -736,6 +736,16 @@ export function FileBrowser({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ein Vorschaubild schlaegt fehl: hoechstens alle 10 s den Ordner still neu laden. Ist die
|
||||||
|
// Verbindung abgelaufen, fuehrt das zum Anmeldebildschirm, und keine weiteren Bilder laden (WR-03).
|
||||||
|
const previewCheckAt = useRef(0);
|
||||||
|
const onPreviewError = useCallback(() => {
|
||||||
|
const at = Date.now();
|
||||||
|
if (at - previewCheckAt.current < 10_000) return;
|
||||||
|
previewCheckAt.current = at;
|
||||||
|
void load(pathRef.current, { quiet: true });
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
// --- Darstellung ------------------------------------------------------------------------------
|
// --- Darstellung ------------------------------------------------------------------------------
|
||||||
|
|
||||||
const viewProps = {
|
const viewProps = {
|
||||||
@@ -750,6 +760,7 @@ export function FileBrowser({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onMenu: openMenu,
|
onMenu: openMenu,
|
||||||
onFocusItem: setFocusIndex,
|
onFocusItem: setFocusIndex,
|
||||||
|
onPreviewError,
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetName =
|
const targetName =
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CheckIcon, MoreIcon } from './icons';
|
|||||||
import { TypeTile } from './TypeTile';
|
import { TypeTile } from './TypeTile';
|
||||||
|
|
||||||
/** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */
|
/** Vorschauflaeche: echtes Bild, wenn die Nextcloud eines liefert, sonst die grosse Typkachel. */
|
||||||
function PreviewSurface({ entry }: { entry: NcEntry }) {
|
function PreviewSurface({ entry, onError }: { entry: NcEntry; onError?: () => void }) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
// Echte Vorschauen fuer Bilder und Videos; Textvorschauen waeren nur ein fast leeres Blatt.
|
// Echte Vorschauen fuer Bilder und Videos; Textvorschauen waeren nur ein fast leeres Blatt.
|
||||||
const family = fileTypeOf(entry).family;
|
const family = fileTypeOf(entry).family;
|
||||||
@@ -27,7 +27,10 @@ function PreviewSurface({ entry }: { entry: NcEntry }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
onError={() => setFailed(true)}
|
onError={() => {
|
||||||
|
setFailed(true);
|
||||||
|
onError?.();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TypeTile entry={entry} size="lg" />
|
<TypeTile entry={entry} size="lg" />
|
||||||
@@ -51,6 +54,7 @@ export function FileGrid({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onMenu,
|
onMenu,
|
||||||
onFocusItem,
|
onFocusItem,
|
||||||
|
onPreviewError,
|
||||||
}: EntryViewProps) {
|
}: EntryViewProps) {
|
||||||
const t = useTranslations('nextcloudFiles.browser');
|
const t = useTranslations('nextcloudFiles.browser');
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
@@ -101,7 +105,7 @@ export function FileGrid({
|
|||||||
: 'hover:bg-accent/80'
|
: 'hover:bg-accent/80'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<PreviewSurface entry={entry} />
|
<PreviewSurface entry={entry} onError={onPreviewError} />
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export interface EntryViewProps {
|
|||||||
onToggle: (entry: NcEntry, index: number) => void;
|
onToggle: (entry: NcEntry, index: number) => void;
|
||||||
onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void;
|
onMenu: (entry: NcEntry, x: number, y: number, trigger: HTMLElement | null) => void;
|
||||||
onFocusItem: (index: number) => void;
|
onFocusItem: (index: number) => void;
|
||||||
|
/**
|
||||||
|
* Ein Vorschaubild liess sich nicht laden (WR-03). Die Ansicht prueft dann einmal, ob die
|
||||||
|
* Verbindung abgelaufen ist, und kehrt gegebenenfalls zum Anmeldebildschirm zurueck.
|
||||||
|
*/
|
||||||
|
onPreviewError?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,6 +55,7 @@ export function FileList({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onMenu,
|
onMenu,
|
||||||
onFocusItem,
|
onFocusItem,
|
||||||
|
onPreviewError,
|
||||||
sort,
|
sort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
@@ -184,7 +190,7 @@ export function FileList({
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-0 pl-1 pr-2">
|
<td className="py-0 pl-1 pr-2">
|
||||||
<span className="flex min-w-0 items-center gap-3">
|
<span className="flex min-w-0 items-center gap-3">
|
||||||
<TypeTile entry={entry} preview />
|
<TypeTile entry={entry} preview onPreviewError={onPreviewError} />
|
||||||
{isFolder ? (
|
{isFolder ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ export function TypeTile({
|
|||||||
entry,
|
entry,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
preview = false,
|
preview = false,
|
||||||
|
onPreviewError,
|
||||||
}: {
|
}: {
|
||||||
entry: Pick<NcEntry, 'name' | 'type' | 'mime'> &
|
entry: Pick<NcEntry, 'name' | 'type' | 'mime'> &
|
||||||
Partial<Pick<NcEntry, 'hasPreview' | 'fileId' | 'etag'>>;
|
Partial<Pick<NcEntry, 'hasPreview' | 'fileId' | 'etag'>>;
|
||||||
size?: 'sm' | 'lg';
|
size?: 'sm' | 'lg';
|
||||||
/** Vorschaubild statt Kurzzeichen, wenn die Nextcloud eines liefert. */
|
/** Vorschaubild statt Kurzzeichen, wenn die Nextcloud eines liefert. */
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
|
/** Das Vorschaubild liess sich nicht laden. */
|
||||||
|
onPreviewError?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const { family, code } = fileTypeOf(entry);
|
const { family, code } = fileTypeOf(entry);
|
||||||
@@ -69,7 +72,10 @@ export function TypeTile({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
onError={() => setFailed(true)}
|
onError={() => {
|
||||||
|
setFailed(true);
|
||||||
|
onPreviewError?.();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user