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:
@@ -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 */}
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user