From 19340fcebdafcb1bf6b2ad0d380069fcf954b151 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 2 Oct 2026 23:42:10 +0200 Subject: [PATCH] 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) --- .../nextcloud-status/components/CloudForm.tsx | 32 +++++--- .../nextcloud-status/components/CloudTile.tsx | 1 + .../nextcloud-status-page.test.tsx | 22 ++++++ .../modules/nextcloud-status/page.tsx | 8 +- apps/web/src/lib/shrink-image.test.ts | 79 +++++++++++++++++++ apps/web/src/lib/shrink-image.ts | 42 ++++++++++ 6 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/lib/shrink-image.test.ts create mode 100644 apps/web/src/lib/shrink-image.ts diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx index 4c767fc..b4f9ace 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx @@ -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(initialMode(instance)); const [logoUrl, setLogoUrl] = useState(instance?.logoUrl ?? ''); - const [file, setFile] = useState(null); const [saving, setSaving] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [error, setError] = useState(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.resolve(null)); + const handleFile = (e: React.ChangeEvent) => { - 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(); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx index 14b5f20..2332801 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx @@ -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)} /> diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx index c719383..cbdfdfe 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx @@ -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(); + 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); diff --git a/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx b/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx index 97c443a..ed0cb85 100644 --- a/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx +++ b/apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx @@ -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); } }; diff --git a/apps/web/src/lib/shrink-image.test.ts b/apps/web/src/lib/shrink-image.test.ts new file mode 100644 index 0000000..cc6a54b --- /dev/null +++ b/apps/web/src/lib/shrink-image.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/lib/shrink-image.ts b/apps/web/src/lib/shrink-image.ts new file mode 100644 index 0000000..f2a4677 --- /dev/null +++ b/apps/web/src/lib/shrink-image.ts @@ -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 { + 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((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; + } +}