fix(nextcloud-status): grosse Logos beim Hochladen verkleinern, Doppelklick auf Jetzt pruefen sperren
Tessera CI/CD / Lint & Type Check (push) Successful in 56s
Tessera CI/CD / Tests (push) Successful in 2m5s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 21s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m30s

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:
2026-10-02 23:42:10 +02:00
parent 0afcf237e8
commit 19340fcebd
6 changed files with 171 additions and 13 deletions
@@ -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);
}
};
+79
View File
@@ -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);
});
});
+42
View File
@@ -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;
}
}