fix(nextcloud-status): grosse Logos beim Hochladen verkleinern, Doppelklick auf Jetzt pruefen sperren
Ein 2346-px-Logo liess die Linux-App beim Neuzeichnen (drehende Lade-Symbole) kurz einfrieren; Logos werden jetzt im Browser auf max. 256 px verkleinert. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
updateInstance,
|
||||
uploadLogo,
|
||||
} from '@/lib/nextcloud-status-api';
|
||||
import { shrinkImage } from '@/lib/shrink-image';
|
||||
|
||||
type LogoMode = 'none' | 'upload' | 'url';
|
||||
|
||||
@@ -46,7 +47,6 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? '');
|
||||
const [mode, setMode] = useState<LogoMode>(initialMode(instance));
|
||||
const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? '');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -54,15 +54,22 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
const currentLogo =
|
||||
instance && (instance.hasUploadedLogo || instance.logoUrl) ? logoSrc(instance) : null;
|
||||
|
||||
// Grosse Logos werden vor dem Hochladen verkleinert (nur ~40 px Anzeige).
|
||||
// Das Verkleinern laeuft asynchron; Speichern wartet auf dieses Versprechen,
|
||||
// damit ein schneller Klick nicht das unverkleinerte Original hochlaedt.
|
||||
const pendingFile = useRef<Promise<File | null>>(Promise.resolve(null));
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files?.[0] ?? null;
|
||||
if (picked && picked.size > LOGO_MAX_BYTES) {
|
||||
setError(t('logoFileTooLarge'));
|
||||
setFile(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setFile(picked);
|
||||
const original = e.target.files?.[0] ?? null;
|
||||
const shrunk = original ? shrinkImage(original) : Promise.resolve(null);
|
||||
pendingFile.current = shrunk.then((picked) => {
|
||||
if (picked && picked.size > LOGO_MAX_BYTES) {
|
||||
setError(t('logoFileTooLarge'));
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
return picked;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -76,6 +83,7 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const upload = mode === 'upload' ? await pendingFile.current : null;
|
||||
let saved: NextcloudInstance;
|
||||
if (instance) {
|
||||
const payload: { customerName: string; baseUrl: string; logoUrl?: string } = {
|
||||
@@ -86,14 +94,14 @@ export function CloudForm({ instance, onClose, onSaved, onDeleted }: CloudFormPr
|
||||
else if (mode === 'none' && instance.logoUrl) payload.logoUrl = '';
|
||||
saved = await updateInstance(instance.id, payload);
|
||||
if (mode === 'none' && instance.hasUploadedLogo) saved = await removeLogo(instance.id);
|
||||
if (mode === 'upload' && file) saved = await uploadLogo(instance.id, file);
|
||||
if (upload) saved = await uploadLogo(instance.id, upload);
|
||||
} else {
|
||||
saved = await createInstance({
|
||||
customerName: name,
|
||||
baseUrl: url,
|
||||
...(mode === 'url' && logoUrl.trim() ? { logoUrl: logoUrl.trim() } : {}),
|
||||
});
|
||||
if (mode === 'upload' && file) saved = await uploadLogo(saved.id, file);
|
||||
if (upload) saved = await uploadLogo(saved.id, upload);
|
||||
}
|
||||
onSaved(saved);
|
||||
onClose();
|
||||
|
||||
@@ -89,6 +89,7 @@ export function CloudTile({
|
||||
src={src}
|
||||
alt={instance.customerName}
|
||||
referrerPolicy="no-referrer"
|
||||
decoding="async"
|
||||
className="h-10 w-auto max-w-24 object-contain"
|
||||
onError={() => setLogoFailed(true)}
|
||||
/>
|
||||
|
||||
@@ -265,6 +265,28 @@ describe('NextcloudStatusPage', () => {
|
||||
expect(mockCheckAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('mehrere schnelle Klicks auf „Jetzt prüfen“ lösen nur eine Prüfung aus', async () => {
|
||||
mockCanManage = true;
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
let finish: (v: unknown) => void = () => {};
|
||||
mockCheckAll.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
render(<NextcloudStatusPage />);
|
||||
await screen.findAllByTestId('cloud-tile');
|
||||
const button = screen.getByRole('button', { name: 'Jetzt prüfen' });
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
expect(mockCheckAll).toHaveBeenCalledTimes(1);
|
||||
finish(LIST);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Jetzt prüfen' })).toBeEnabled(),
|
||||
);
|
||||
});
|
||||
|
||||
it('Kachel-Prüfung ersetzt nur diese Kachel', async () => {
|
||||
mockCanManage = true;
|
||||
mockListInstances.mockResolvedValue(LIST);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import {
|
||||
readSortPreference,
|
||||
@@ -119,7 +119,12 @@ export default function NextcloudStatusPage() {
|
||||
if (userId) writeSortPreference(userId, key);
|
||||
};
|
||||
|
||||
// Sperre per Ref: mehrere schnelle Klicks loesen nur EINE Pruefung aus —
|
||||
// der State `checkingAll` greift erst nach dem naechsten Rendern.
|
||||
const checkAllRunning = useRef(false);
|
||||
const handleCheckAll = async () => {
|
||||
if (checkAllRunning.current) return;
|
||||
checkAllRunning.current = true;
|
||||
setCheckingAll(true);
|
||||
try {
|
||||
const list = await checkAll();
|
||||
@@ -128,6 +133,7 @@ export default function NextcloudStatusPage() {
|
||||
} catch {
|
||||
setError(t('loadError'));
|
||||
} finally {
|
||||
checkAllRunning.current = false;
|
||||
setCheckingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shrinkImage } from './shrink-image';
|
||||
|
||||
function file(type: string, size = 10): File {
|
||||
return new File([new Uint8Array(size)], `logo.${type.split('/')[1]}`, { type });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('shrinkImage', () => {
|
||||
it('laesst GIFs unveraendert (koennen animiert sein)', async () => {
|
||||
const gif = file('image/gif');
|
||||
expect(await shrinkImage(gif)).toBe(gif);
|
||||
});
|
||||
|
||||
it('gibt ohne createImageBitmap das Original zurueck', async () => {
|
||||
vi.stubGlobal('createImageBitmap', undefined);
|
||||
const png = file('image/png');
|
||||
expect(await shrinkImage(png)).toBe(png);
|
||||
});
|
||||
|
||||
it('laesst kleine Bilder unveraendert', async () => {
|
||||
const close = vi.fn();
|
||||
vi.stubGlobal(
|
||||
'createImageBitmap',
|
||||
vi.fn(async () => ({ width: 200, height: 100, close })),
|
||||
);
|
||||
const png = file('image/png');
|
||||
expect(await shrinkImage(png)).toBe(png);
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('verkleinert grosse Bilder auf hoechstens 256 px an der laengsten Kante', async () => {
|
||||
vi.stubGlobal(
|
||||
'createImageBitmap',
|
||||
vi.fn(async () => ({ width: 2346, height: 1173, close: vi.fn() })),
|
||||
);
|
||||
const drawImage = vi.fn();
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({ drawImage, imageSmoothingQuality: 'low' }),
|
||||
toBlob: (cb: (b: Blob) => void) => cb(new Blob([new Uint8Array(5)], { type: 'image/png' })),
|
||||
};
|
||||
const orig = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) =>
|
||||
tag === 'canvas' ? (canvas as unknown as HTMLCanvasElement) : orig(tag),
|
||||
);
|
||||
const big = file('image/jpeg', 50_000);
|
||||
const out = await shrinkImage(big);
|
||||
expect(canvas.width).toBe(256);
|
||||
expect(canvas.height).toBe(128);
|
||||
expect(out.type).toBe('image/png');
|
||||
expect(out.name).toBe('logo.png');
|
||||
expect(out.size).toBe(5);
|
||||
});
|
||||
|
||||
it('behaelt das Original, wenn das Ergebnis nicht kleiner wird', async () => {
|
||||
vi.stubGlobal(
|
||||
'createImageBitmap',
|
||||
vi.fn(async () => ({ width: 1000, height: 1000, close: vi.fn() })),
|
||||
);
|
||||
const canvas = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({ drawImage: vi.fn() }),
|
||||
toBlob: (cb: (b: Blob) => void) => cb(new Blob([new Uint8Array(100)])),
|
||||
};
|
||||
const orig = document.createElement.bind(document);
|
||||
vi.spyOn(document, 'createElement').mockImplementation((tag: string) =>
|
||||
tag === 'canvas' ? (canvas as unknown as HTMLCanvasElement) : orig(tag),
|
||||
);
|
||||
const small = file('image/png', 50);
|
||||
expect(await shrinkImage(small)).toBe(small);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Verkleinert ein hochgeladenes Bild im Browser, bevor es zum Server geht
|
||||
* (quick-261002). Logos werden nur etwa 40 Pixel hoch angezeigt; ein
|
||||
* 2000-Pixel-Bild muss die Linux-Webview bei jedem Neuzeichnen (z. B. drehende
|
||||
* Lade-Symbole) herunterrechnen, was die App kurz einfrieren laesst.
|
||||
*
|
||||
* - Bilder, die schon klein genug sind, und GIFs (evtl. animiert) bleiben unveraendert.
|
||||
* - Ergebnis als PNG (Transparenz bleibt), Seitenverhaeltnis bleibt.
|
||||
* - Ohne Canvas/`createImageBitmap` (alte Umgebung, Tests) oder bei Fehlern: Original.
|
||||
*/
|
||||
export const LOGO_MAX_EDGE = 256;
|
||||
|
||||
export async function shrinkImage(file: File, maxEdge = LOGO_MAX_EDGE): Promise<File> {
|
||||
if (file.type === 'image/gif') return file;
|
||||
if (typeof createImageBitmap !== 'function' || typeof document === 'undefined') return file;
|
||||
try {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const { width, height } = bitmap;
|
||||
if (width <= maxEdge && height <= maxEdge) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
const scale = maxEdge / Math.max(width, height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(width * scale));
|
||||
canvas.height = Math.max(1, Math.round(height * scale));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
bitmap.close();
|
||||
return file;
|
||||
}
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close();
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob || blob.size >= file.size) return file;
|
||||
const name = file.name.replace(/\.[^.]+$/, '') + '.png';
|
||||
return new File([blob], name, { type: 'image/png' });
|
||||
} catch {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user