feat(18-03): Task 2 — Einstellungsseite Desktop-App mit Knoepfen, Groesse und Erklaerung

- Neue Route /settings/general/desktop (DesktopSettingsPage)
- DesktopAppSettings: Version, zwei Primaerknoepfe mit Plattform-Symbol,
  Dateiname/Groesse je Knopf, Beta-Kanal-Hinweis, vier erklaerende Saetze,
  Hinweistext wenn keine Pakete hinterlegt sind
- Seitenleiste: Eintrag "Desktop-App" unter "Allgemein"
- de/en: settings.categoryDesktopApp, settings.desktop.*
- umlaut-dictionary.ts: "neuere" zur Allowlist ergaenzt (Rule 1 —
  Waechter-Test brach an einer bereits korrekten Umlautschreibung, die
  zufaellig die Buchstabenfolge "ue" enthaelt)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 16:30:34 +02:00
parent 026d9c39af
commit e96d460b8e
7 changed files with 290 additions and 0 deletions
@@ -0,0 +1,21 @@
'use client';
import { useTranslations } from 'next-intl';
import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
/**
* Desktop-App settings page — /settings/general/desktop (D-12).
* Shows version, download buttons, file size, and explanatory text.
*/
export default function DesktopSettingsPage() {
const t = useTranslations('settings');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('desktop.title')}
</h1>
<DesktopAppSettings />
</div>
);
}
@@ -0,0 +1,105 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { DesktopAppSettings } from './desktop-app-settings';
/**
* desktop-app-settings.test — Einstellungsseite "Desktop-App" (18-03).
* next-intl-Mock nach dem Muster in widget-settings-panel.test.tsx
* (de.json-gestuetzt), `@/lib/desktop` gemockt.
*/
vi.mock('next-intl', async () => {
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
const lookup = (path: string): string | undefined =>
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
| string
| undefined;
return {
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
useLocale: () => 'de',
};
});
const { loadDesktopLatest } = vi.hoisted(() => ({ loadDesktopLatest: vi.fn() }));
vi.mock('@/lib/desktop', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/desktop')>();
return {
...actual,
loadDesktopLatest,
};
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const windowsFile = {
name: 'Tessera-Setup-1.1.0.exe',
size: 6123456,
sha256: 'x',
url: '/desktop/download/windows',
};
const linuxFile = {
name: 'Tessera-1.1.0.AppImage',
size: 106461688,
sha256: 'y',
url: '/desktop/download/linux',
};
describe('DesktopAppSettings (18-03)', () => {
it('Test 1 (beide Plattformen): Version, zwei Links, Dateiname und Groesse', async () => {
loadDesktopLatest.mockResolvedValue({
version: '1.1.0',
channel: 'live',
commit: 'abc1234',
buildTime: 'x',
files: { windows: windowsFile, linux: linuxFile },
});
render(<DesktopAppSettings />);
expect(await screen.findByText('Aktuelle Version: 1.1.0')).toBeInTheDocument();
const windowsLink = screen.getByTestId('desktop-download-windows');
expect(windowsLink).toHaveAttribute('href', 'http://localhost:3001/desktop/download/windows');
const linuxLink = screen.getByTestId('desktop-download-linux');
expect(linuxLink).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
expect(screen.getByText('Tessera-Setup-1.1.0.exe · 5,8 MB')).toBeInTheDocument();
expect(screen.getByText('Tessera-1.1.0.AppImage · 101,5 MB')).toBeInTheDocument();
});
it('Test 2 (Beta-Kanal): Hinweis mit Commit', async () => {
loadDesktopLatest.mockResolvedValue({
version: '1.1.0',
channel: 'beta',
commit: 'abc1234',
buildTime: 'x',
files: { windows: windowsFile, linux: linuxFile },
});
render(<DesktopAppSettings />);
expect(await screen.findByText('Beta-Ausgabe, Stand abc1234')).toBeInTheDocument();
});
it('Test 3 (keine Pakete): Hinweistext statt Knoepfe, Saetze bleiben', async () => {
loadDesktopLatest.mockResolvedValue(null);
render(<DesktopAppSettings />);
expect(
await screen.findByText('Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt.'),
).toBeInTheDocument();
expect(screen.queryByTestId('desktop-download-windows')).toBeNull();
expect(screen.queryByTestId('desktop-download-linux')).toBeNull();
expect(
screen.getByText(
'Die Desktop-App öffnet Tessera in einem eigenen Fenster – ohne Browser, mit Symbol im Infobereich der Taskleiste.',
),
).toBeInTheDocument();
});
});
@@ -0,0 +1,122 @@
'use client';
import { useEffect, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { desktopDownloadUrl, type DesktopFileInfo, type DesktopLatestInfo, formatFileSize, loadDesktopLatest } from '@/lib/desktop';
const BUTTON_CLASS =
'inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90';
function WindowsIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
);
}
function LinuxIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l2.5 2L4 10" strokeLinecap="round" strokeLinejoin="round" />
<line x1="8" y1="10" x2="11" y2="10" strokeLinecap="round" />
</svg>
);
}
function DownloadButton({
file,
label,
icon,
testId,
locale,
fileInfoText,
}: {
file: DesktopFileInfo;
label: string;
icon: React.ReactNode;
testId: string;
locale: string;
fileInfoText: (values: { name: string; size: string }) => string;
}) {
return (
<div>
<a href={desktopDownloadUrl(file)} download data-testid={testId} className={BUTTON_CLASS}>
{icon}
{label}
</a>
<p className="mt-1 text-xs text-muted-foreground">
{fileInfoText({ name: file.name, size: formatFileSize(file.size, locale) })}
</p>
</div>
);
}
/**
* Inhalt der Einstellungsseite "Desktop-App" (D-12): vier erklaerende
* Saetze, dann Version/Downloads oder ein Hinweis, wenn der Server keine
* Pakete traegt (`loadDesktopLatest()` liefert `null`).
*/
export function DesktopAppSettings() {
const t = useTranslations('settings');
const locale = useLocale();
const [info, setInfo] = useState<DesktopLatestInfo | null | undefined>(undefined);
useEffect(() => {
let active = true;
loadDesktopLatest().then((data) => {
if (active) setInfo(data);
});
return () => {
active = false;
};
}, []);
const fileInfoText = (values: { name: string; size: string }) => t('desktop.fileInfo', values);
return (
<div>
<p className="text-sm text-muted-foreground">{t('desktop.intro')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.firstStart')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.tray')}</p>
<p className="text-sm text-muted-foreground">{t('desktop.update')}</p>
{info === null && <p className="mt-4 text-sm text-muted-foreground">{t('desktop.unavailable')}</p>}
{info && (
<>
<p className="mt-4 text-sm text-foreground">{t('desktop.versionLabel', { version: info.version })}</p>
{info.channel === 'beta' && (
<p className="text-sm text-muted-foreground">{t('desktop.channelBeta', { commit: info.commit })}</p>
)}
<div className="mt-4 flex flex-wrap gap-4">
{info.files.windows && (
<DownloadButton
file={info.files.windows}
label={t('desktop.downloadWindows')}
icon={<WindowsIcon />}
testId="desktop-download-windows"
locale={locale}
fileInfoText={fileInfoText}
/>
)}
{info.files.linux && (
<DownloadButton
file={info.files.linux}
label={t('desktop.downloadLinux')}
icon={<LinuxIcon />}
testId="desktop-download-linux"
locale={locale}
fileInfoText={fileInfoText}
/>
)}
</div>
</>
)}
</div>
);
}
@@ -58,6 +58,19 @@ export function SettingsSidebar() {
> >
{t('categoryAccount')} {t('categoryAccount')}
</Link> </Link>
<Link
href="/settings/general/desktop"
className={`flex items-center rounded-md px-2 py-1.5 text-sm transition-colors ${
isActive('/settings/general/desktop')
? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
: 'text-sidebar-foreground hover:bg-muted'
}`}
aria-current={
isActive('/settings/general/desktop') ? 'page' : undefined
}
>
{t('categoryDesktopApp')}
</Link>
</nav> </nav>
{/* Dashboard category — existing items unchanged */} {/* Dashboard category — existing items unchanged */}
+14
View File
@@ -132,6 +132,7 @@
"categoryCalendar": "Kalender", "categoryCalendar": "Kalender",
"categoryGeneral": "Allgemein", "categoryGeneral": "Allgemein",
"categoryAccount": "Konto", "categoryAccount": "Konto",
"categoryDesktopApp": "Desktop-App",
"categorySmtp": "SMTP", "categorySmtp": "SMTP",
"account": { "account": {
"title": "Konto", "title": "Konto",
@@ -152,6 +153,19 @@
"deleteAvatarSuccess": "Profilbild erfolgreich gelöscht.", "deleteAvatarSuccess": "Profilbild erfolgreich gelöscht.",
"deleteAvatarError": "Löschen fehlgeschlagen." "deleteAvatarError": "Löschen fehlgeschlagen."
}, },
"desktop": {
"title": "Desktop-App",
"intro": "Die Desktop-App öffnet Tessera in einem eigenen Fenster – ohne Browser, mit Symbol im Infobereich der Taskleiste.",
"firstStart": "Beim ersten Start fragt die App nach der Adresse Ihres Tessera-Servers; das ist die Adresse, unter der Sie Tessera auch im Browser öffnen.",
"tray": "Schließen Sie das Fenster, läuft Tessera im Infobereich weiter; über das Symbol dort öffnen Sie das Fenster wieder, schalten den automatischen Start ein oder beenden die App.",
"update": "Erscheint eine neuere Version, weist die App Sie darauf hin und führt Sie auf diese Seite.",
"versionLabel": "Aktuelle Version: {version}",
"channelBeta": "Beta-Ausgabe, Stand {commit}",
"downloadWindows": "Für Windows herunterladen",
"downloadLinux": "Für Linux herunterladen",
"fileInfo": "{name} · {size}",
"unavailable": "Auf diesem Server sind derzeit keine Desktop-Pakete hinterlegt."
},
"sourceDeleteConfirm": "Möchten Sie diese Kalenderquelle wirklich löschen?", "sourceDeleteConfirm": "Möchten Sie diese Kalenderquelle wirklich löschen?",
"sourceDeleteCta": "Quelle löschen", "sourceDeleteCta": "Quelle löschen",
"providerDeleteConfirm": "Möchten Sie diesen Suchanbieter wirklich löschen?", "providerDeleteConfirm": "Möchten Sie diesen Suchanbieter wirklich löschen?",
+14
View File
@@ -132,6 +132,7 @@
"categoryCalendar": "Calendar", "categoryCalendar": "Calendar",
"categoryGeneral": "General", "categoryGeneral": "General",
"categoryAccount": "Account", "categoryAccount": "Account",
"categoryDesktopApp": "Desktop App",
"categorySmtp": "SMTP", "categorySmtp": "SMTP",
"account": { "account": {
"title": "Account", "title": "Account",
@@ -152,6 +153,19 @@
"deleteAvatarSuccess": "Profile picture deleted successfully.", "deleteAvatarSuccess": "Profile picture deleted successfully.",
"deleteAvatarError": "Failed to delete picture." "deleteAvatarError": "Failed to delete picture."
}, },
"desktop": {
"title": "Desktop App",
"intro": "The desktop app opens Tessera in its own window – no browser, with an icon in the notification area of the taskbar.",
"firstStart": "On first start the app asks for the address of your Tessera server; it is the address you also use to open Tessera in the browser.",
"tray": "If you close the window, Tessera keeps running in the notification area; use the icon there to reopen the window, enable automatic start, or quit the app.",
"update": "When a newer version is available the app notifies you and brings you to this page.",
"versionLabel": "Current version: {version}",
"channelBeta": "Beta build, commit {commit}",
"downloadWindows": "Download for Windows",
"downloadLinux": "Download for Linux",
"fileInfo": "{name} · {size}",
"unavailable": "No desktop packages are available on this server yet."
},
"sourceDeleteConfirm": "Are you sure you want to delete this calendar source?", "sourceDeleteConfirm": "Are you sure you want to delete this calendar source?",
"sourceDeleteCta": "Delete source", "sourceDeleteCta": "Delete source",
"providerDeleteConfirm": "Are you sure you want to delete this search provider?", "providerDeleteConfirm": "Are you sure you want to delete this search provider?",
@@ -114,6 +114,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Energiequellen', 'Energiequellen',
'neue', 'neue',
'neuen', 'neuen',
'neuere',
'Neue', 'Neue',
'Neues', 'Neues',
'aktuelle', 'aktuelle',