feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog

- Kachel mit Zählern und roten/gelben Clouds, nur mit Modulzugriff sichtbar (geteilte Widget-Typen, Modulzuordnung, Katalog)
- Anleitung für Anwender und Administratoren, Eintrag im Changelog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-02 15:00:31 +02:00
parent 6698ef1a92
commit 87a7b7cbcd
18 changed files with 564 additions and 27 deletions
@@ -29,6 +29,8 @@ vi.mock('next-intl', () => ({
'xframe.description': 'Webseite einbetten',
'proxmox.name': 'Proxmox',
'proxmox.description': 'Zustand Ihrer Proxmox-Server auf einen Blick',
'nextcloudStatus.name': 'Nextcloud-Status',
'nextcloudStatus.description': 'Ampelübersicht Ihrer Nextcloud-Clouds',
},
common: {
close: 'Schließen',
@@ -44,7 +46,8 @@ import { WidgetCatalogModal } from './widget-catalog-modal';
// Neun Kacheln sind Plattform-Kacheln ohne moduleSlug und erscheinen auch bei
// leerer Modulliste; die Proxmox-Kachel (quick-260924-i8v) nur mit Zugriff
// auf das Modul 'proxmox'.
// auf das Modul 'proxmox', die Nextcloud-Status-Kachel (quick-261002-k67) nur
// mit Zugriff auf das Modul 'nextcloud-status'.
const baseProps = {
isOpen: true,
onClose: vi.fn(),
@@ -151,18 +154,33 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)',
).map((c) => c.getAttribute('data-widget-type'));
}
it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => {
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
it("zeigt mit Zugriff auf beide Module alle zwoelf Kacheln in der Reihenfolge der Registry, Nextcloud-Status zuletzt", () => {
render(
<WidgetCatalogModal
{...baseProps}
accessibleModuleSlugs={['proxmox', 'nextcloud-status']}
/>,
);
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
expect(catalogTypes().at(-1)).toBe('reminder');
expect(catalogTypes().at(-1)).toBe('nextcloud-status');
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
});
it('zeigt ohne Modulzugriff die neun Plattform-Kacheln ohne Proxmox', () => {
it('zeigt ohne Modulzugriff die zehn Plattform-Kacheln ohne die beiden Modul-Kacheln', () => {
render(<WidgetCatalogModal {...baseProps} />);
expect(catalogTypes()).toEqual(WIDGET_TYPES.filter((type) => type !== 'proxmox'));
expect(catalogTypes()).toEqual(
WIDGET_TYPES.filter((type) => type !== 'proxmox' && type !== 'nextcloud-status'),
);
});
it("mit Zugriff nur auf 'nextcloud-status' kommt nur diese Modul-Kachel dazu, Proxmox bleibt aus", () => {
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['nextcloud-status']} />);
expect(catalogTypes()).toContain('nextcloud-status');
expect(catalogTypes()).not.toContain('proxmox');
expect(screen.getByRole('button', { name: /Nextcloud-Status/ })).toBeInTheDocument();
});
it('die Kachel „Proxmox“ fehlt, wenn das Modul nicht zugaenglich ist, und erscheint, wenn doch', () => {
@@ -35,6 +35,8 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
'proxmox',
// Erinnerungen (quick-260929-if2)
'reminder',
// Nextcloud-Status — zweite Modul-Kachel (quick-261002-k67)
'nextcloud-status',
];
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
@@ -72,7 +74,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
});
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu; quick-261002-k67: Nextcloud-Status dazu, zwoelf Typen)', () => {
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
@@ -90,6 +92,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 },
});
let counted = 0;
@@ -99,7 +102,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
counted++;
}
}
expect(counted).toBe(44);
expect(counted).toBe(48);
});
});
@@ -147,7 +150,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
});
it('die elf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
it('die zwoelf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
});
@@ -157,10 +160,12 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
}
});
it("nur proxmox traegt moduleSlug 'proxmox', alle anderen Kacheln keinen (quick-260924-i8v)", () => {
it("nur proxmox und nextcloud-status tragen einen moduleSlug (gleichnamig), alle anderen Kacheln keinen (quick-260924-i8v, quick-261002-k67)", () => {
for (const type of WIDGET_TYPES) {
if (type === 'proxmox') {
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('proxmox');
} else if (type === 'nextcloud-status') {
expect(WIDGET_REGISTRY[type].moduleSlug).toBe('nextcloud-status');
} else {
expect(WIDGET_REGISTRY[type].moduleSlug).toBeUndefined();
}
@@ -229,14 +234,22 @@ describe('visibleWidgetTypes (quick-260922-m1h)', () => {
note: {},
};
it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlt nur Proxmox', () => {
it('behaelt die Reihenfolge der Registry bei; ohne Modulzugriff fehlen nur die beiden Modul-Kacheln', () => {
expect(visibleWidgetTypes(WIDGET_REGISTRY, [])).toEqual(
WIDGET_TYPES.filter((type) => type !== 'proxmox'),
WIDGET_TYPES.filter((type) => type !== 'proxmox' && type !== 'nextcloud-status'),
);
});
it("mit Zugriff auf 'proxmox' erscheinen alle zehn Kacheln in WIDGET_TYPES-Reihenfolge (quick-260924-i8v)", () => {
expect(visibleWidgetTypes(WIDGET_REGISTRY, ['proxmox'])).toEqual([...WIDGET_TYPES]);
it("mit Zugriff auf beide Module erscheinen alle zwoelf Kacheln in WIDGET_TYPES-Reihenfolge (quick-260924-i8v, quick-261002-k67)", () => {
expect(visibleWidgetTypes(WIDGET_REGISTRY, ['proxmox', 'nextcloud-status'])).toEqual([
...WIDGET_TYPES,
]);
});
it("mit Zugriff nur auf 'nextcloud-status' kommt nur diese Modul-Kachel dazu (quick-261002-k67)", () => {
expect(visibleWidgetTypes(WIDGET_REGISTRY, ['nextcloud-status'])).toEqual(
WIDGET_TYPES.filter((type) => type !== 'proxmox'),
);
});
it('Kacheln ohne moduleSlug sind immer sichtbar', () => {
@@ -20,6 +20,7 @@ import type { ComponentType } from 'react';
* xframe: Webseite als Rahmen (quick-260921-qd3).
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
* reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2).
* nextcloud-status: Ampeluebersicht der Nextcloud-Clouds, zweite Modul-Kachel (quick-261002-k67).
*/
export type { WidgetType };
@@ -102,6 +103,12 @@ export const WIDGET_CONSTRAINTS: Record<
// 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 =
// rund 340x270 px: drei bis vier Erinnerungen auf einmal.
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
// quick-261002-k67: Vorbild Proxmox. 4 Zeilen = 104 px reichen fuer die
// drei Zaehler (Rahmenkopf plus Zaehlerzeile); darunter blendet sich die
// Liste der roten/gelben Clouds per Container-Query aus. 12x8 (im 48er-
// Raster) = rund 340x216 px: Zaehler und etwa drei Clouds mit Grund.
// 6 Spalten = rund 166 px zeigen nur die Zaehler.
'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 },
};
/**
@@ -132,7 +139,7 @@ export interface WidgetDefinition {
/**
* Modul, zu dem diese Kachel gehoert (quick-260922-m1h), aus
* `WIDGET_MODULE_SLUGS`. Fehlt der Eintrag, ist es eine Plattform-Kachel
* und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox.
* und immer sichtbar — der Zustand fuer alle Kacheln ausser Proxmox und Nextcloud-Status.
*/
moduleSlug?: string;
/** Inline SVG icon as React component */
@@ -410,6 +417,26 @@ function ReminderIcon({ className }: { className?: string }) {
);
}
function NextcloudStatusIcon({ className }: { className?: string }) {
return (
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
</svg>
);
}
/**
* Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints.
@@ -516,6 +543,17 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
moduleSlug: WIDGET_MODULE_SLUGS.reminder,
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
'nextcloud-status': {
type: 'nextcloud-status',
nameKey: 'nextcloudStatus.name',
descriptionKey: 'nextcloudStatus.description',
icon: NextcloudStatusIcon,
...WIDGET_CONSTRAINTS['nextcloud-status'],
// Zweite Modul-Kachel: ohne Zugriff auf das Modul 'nextcloud-status' fehlt
// sie im Katalog (Komfort) und verbindlich serverseitig auf dem Dashboard.
moduleSlug: WIDGET_MODULE_SLUGS['nextcloud-status'],
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
},
};
/**
@@ -0,0 +1,160 @@
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { NextcloudInstance, NextcloudList, RatingLevel } from '@/lib/nextcloud-status-api';
import de from '@/messages/de.json';
import { NextcloudStatusWidget } from './nextcloud-status-widget';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockListInstances = vi.fn();
const mockCheckAll = vi.fn();
const mockCheckOne = vi.fn();
vi.mock('@/lib/nextcloud-status-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/nextcloud-status-api')>();
return {
...actual,
listInstances: (...a: unknown[]) => mockListInstances(...a),
checkAll: (...a: unknown[]) => mockCheckAll(...a),
checkOne: (...a: unknown[]) => mockCheckOne(...a),
};
});
vi.mock('next/link', () => ({
default: ({ href, children, ...rest }: { href: string; children: React.ReactNode }) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
function make(
id: string,
customerName: string,
level: RatingLevel,
reason: NextcloudInstance['rating']['reason'] = 'current',
updateTo: string | null = null,
): NextcloudInstance {
return {
id,
customerName,
baseUrl: `https://${id}.example.de`,
logoUrl: null,
hasUploadedLogo: false,
logoVersion: 0,
status: {
checkedAt: null,
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
errorKind: null,
errorDetail: null,
},
rating: { level, reason, updateTo, eolDate: null, cycle: 35 },
};
}
function list(instances: NextcloudInstance[]): NextcloudList {
return { instances, reference: { newestVersion: '35.0.1', fetchedAt: null } };
}
const props = { instanceId: 'w1', config: {}, isEditMode: false };
describe('NextcloudStatusWidget', () => {
beforeEach(() => {
mockListInstances.mockReset();
mockCheckAll.mockReset();
mockCheckOne.mockReset();
});
afterEach(() => cleanup());
it('zeigt Zähler 2/1/1 und Rot vor Gelb mit Name und Grund', async () => {
mockListInstances.mockResolvedValue(
list([
make('g1', 'Grün A', 'green'),
make('g2', 'Grün B', 'green'),
make('y1', 'Gelb A', 'yellow', 'update-available', '34.0.4'),
make('r1', 'Rot A', 'red', 'maintenance'),
]),
);
render(<NextcloudStatusWidget {...props} />);
await screen.findByTestId('nextcloud-counters');
expect(screen.getByTestId('nextcloud-count-green')).toHaveTextContent('2');
expect(screen.getByTestId('nextcloud-count-yellow')).toHaveTextContent('1');
expect(screen.getByTestId('nextcloud-count-red')).toHaveTextContent('1');
expect(screen.queryByTestId('nextcloud-count-unknown')).toBeNull();
const rows = screen.getAllByTestId('nextcloud-row');
expect(rows).toHaveLength(2);
expect(within(rows[0]).getByText('Rot A')).toBeInTheDocument();
expect(within(rows[0]).getByText('Wartungsmodus')).toBeInTheDocument();
expect(within(rows[1]).getByText('Gelb A')).toBeInTheDocument();
expect(within(rows[1]).getByText('Update auf 34.0.4 verfügbar')).toBeInTheDocument();
});
it('alles grün: ruhige Zeile, keine Liste', async () => {
mockListInstances.mockResolvedValue(list([make('g1', 'Grün A', 'green')]));
render(<NextcloudStatusWidget {...props} />);
expect(await screen.findByText('Alle Clouds sind aktuell')).toBeInTheDocument();
expect(screen.queryByTestId('nextcloud-list')).toBeNull();
});
it('ohne Clouds ein Hinweis', async () => {
mockListInstances.mockResolvedValue(list([]));
render(<NextcloudStatusWidget {...props} />);
expect(await screen.findByText('Noch keine Cloud eingetragen')).toBeInTheDocument();
});
it('Ladefehler zeigt den Fehlersatz', async () => {
mockListInstances.mockRejectedValue(new Error('boom'));
render(<NextcloudStatusWidget {...props} />);
expect(await screen.findByText('Status konnte nicht geladen werden')).toBeInTheDocument();
});
it('zählt Clouds ohne Bewertung getrennt, aber nur wenn es welche gibt', async () => {
mockListInstances.mockResolvedValue(
list([make('g1', 'Grün A', 'green'), make('u1', 'Grau A', 'unknown', 'no-reference')]),
);
render(<NextcloudStatusWidget {...props} />);
await screen.findByTestId('nextcloud-counters');
expect(screen.getByTestId('nextcloud-count-unknown')).toHaveTextContent('1');
// Mit einer unbewerteten Cloud gilt „Alle aktuell“ nicht.
expect(screen.queryByText('Alle Clouds sind aktuell')).toBeNull();
});
it('Ansichtsmodus: Zeilen und Zählerbereich führen zur Modulseite', async () => {
mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')]));
render(<NextcloudStatusWidget {...props} />);
await screen.findByTestId('nextcloud-list');
const links = screen.getAllByRole('link');
expect(links.length).toBeGreaterThanOrEqual(2);
for (const link of links) expect(link).toHaveAttribute('href', '/modules/nextcloud-status');
});
it('Bearbeitungsmodus: keine Links', async () => {
mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')]));
render(<NextcloudStatusWidget {...props} isEditMode />);
await screen.findByTestId('nextcloud-list');
expect(screen.queryByRole('link')).toBeNull();
expect(screen.getByText('Rot A')).toBeInTheDocument();
});
it('ruft nie eine Prüfung auf, nur die Leseabfrage', async () => {
mockListInstances.mockResolvedValue(list([make('r1', 'Rot A', 'red', 'unreachable')]));
render(<NextcloudStatusWidget {...props} />);
await waitFor(() => expect(mockListInstances).toHaveBeenCalledTimes(1));
await screen.findByTestId('nextcloud-list');
expect(mockCheckAll).not.toHaveBeenCalled();
expect(mockCheckOne).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,213 @@
'use client';
import Link from 'next/link';
import { useLocale, useTranslations } from 'next-intl';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import type { WidgetProps } from '@/components/dashboard/widget-registry';
import { RATING_STYLE, ratingReasonText } from '@/components/nextcloud-status/rating-display';
import { sortClouds } from '@/components/nextcloud-status/sort-clouds';
// Aus dem Klienten NUR die Leseabfrage (Muster T-I8V-02): die Pruefungen
// (`checkAll`/`checkOne`) gehoeren der Modulseite und duerfen hier nie
// auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen bei den
// Clouds aus.
import { listInstances, type NextcloudList, type RatingLevel } from '@/lib/nextcloud-status-api';
/** Fuenf Minuten: so oft liest die Kachel den gespeicherten Stand neu (die Pruefung selbst laeuft stuendlich). */
const REFRESH_MS = 5 * 60_000;
const ROW_CLASS = 'flex items-center gap-2 rounded-md px-1.5 py-1 text-sm';
const MODULE_HREF = '/modules/nextcloud-status';
/**
* NextcloudStatusWidget (quick-261002-k67, L-10) — Ampeluebersicht: drei
* Zaehler (gruen/gelb/rot, „ohne Bewertung“ nur wenn es solche gibt) und
* darunter die roten, dann gelben Clouds mit Name und Grund.
*
* - Liest ausschliesslich `GET /modules/nextcloud-status/instances`
* (serverseitig hinter `@UseModule`), alle 5 Minuten neu; bei verborgenem
* Browser-Tab pausiert der Takt, beim Zurueckkehren laedt die Kachel sofort.
* - Groessenstufen per Container-Query am Kachelrumpf des Wrappers (der ist
* bereits `@container-size`): niedrig oder schmal zeigt nur die Zaehler.
* - Ansichtsmodus: Zaehlerbereich und Zeilen fuehren zur Modulseite.
* Bearbeitungsmodus: Zeilen ohne Ziel und ohne Tabstopp — Links stehen im
* Abbruch-Selektor von `dashboard-grid.tsx`, Anker-Zeilen wuerden das
* Ziehen ueber fast die ganze Kachel blockieren (wie bei Proxmox).
* - Die Kopfzeile (Symbol + Name) setzt der Rahmen (`FRAME_HEADER_TYPES`).
*/
export function NextcloudStatusWidget({ isEditMode }: WidgetProps) {
const t = useTranslations('nextcloudStatus');
const locale = useLocale();
const [data, setData] = useState<NextcloudList | null>(null);
// Fehler als Flag, nicht als Text: `t` gehoert nicht in die Effekt-Abhaengigkeiten.
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => {
let cancelled = false;
function load() {
listInstances()
.then((list) => {
if (cancelled) return;
setData(list);
setLoadFailed(false);
})
.catch(() => {
// Ein schon geladener Stand bleibt stehen; der Fehlersatz erscheint nur, solange es noch nie einen gab.
if (!cancelled) setLoadFailed(true);
});
}
function handleVisibilityChange() {
if (document.visibilityState === 'visible') load();
}
load();
const intervalId = setInterval(() => {
if (document.visibilityState !== 'hidden') load();
}, REFRESH_MS);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
cancelled = true;
clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
const counts = useMemo(() => {
const result: Record<RatingLevel, number> = { green: 0, yellow: 0, red: 0, unknown: 0 };
for (const instance of data?.instances ?? []) result[instance.rating.level] += 1;
return result;
}, [data]);
const attention = useMemo(
() =>
sortClouds(
(data?.instances ?? []).filter(
(i) => i.rating.level === 'red' || i.rating.level === 'yellow',
),
'status',
),
[data],
);
function frame(body: ReactNode, bodyClassName = 'gap-2') {
return (
<div data-testid="nextcloud-status-widget" className="flex h-full flex-col overflow-hidden">
<div className={`flex min-h-0 flex-1 flex-col p-2.5 ${bodyClassName}`}>{body}</div>
</div>
);
}
function centered(text: string) {
return frame(
<p className="text-sm text-muted-foreground">{text}</p>,
'items-center justify-center gap-1.5 text-center',
);
}
if (data === null && loadFailed) return centered(t('widget.loadError'));
if (data === null) {
return frame(
<div aria-busy="true">
<div className="h-6 w-full animate-pulse rounded-full bg-muted motion-reduce:animate-none" />
<span className="sr-only">{t('widget.loading')}</span>
</div>,
);
}
if (data.instances.length === 0) return centered(t('widget.empty'));
const chips: RatingLevel[] = ['green', 'yellow', 'red'];
if (counts.unknown > 0) chips.push('unknown');
const counters = (
<ul
data-testid="nextcloud-counters"
aria-label={t('widget.countsLabel')}
className="flex shrink-0 flex-wrap gap-1.5"
>
{chips.map((level) => (
<li
key={level}
data-level={level}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${RATING_STYLE[level].pill}`}
>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${RATING_STYLE[level].fill}`}
/>
<span className="tabular-nums" data-testid={`nextcloud-count-${level}`}>
{counts[level]}
</span>
{/* Die Ampelfarbe haengt nie nur an der Farbe: das Wort steht immer dabei. */}
<span>{t(`level.${level}`)}</span>
</li>
))}
</ul>
);
function row(instance: (typeof attention)[number]): ReactNode {
const style = RATING_STYLE[instance.rating.level];
const content = (
<>
<span aria-hidden="true" className={`h-2 w-2 shrink-0 rounded-full ${style.fill}`} />
<span className="min-w-0 flex-1 truncate">{instance.customerName}</span>
<span
className={`shrink-0 truncate text-right text-xs @max-[15rem]:hidden ${style.text}`}
data-testid="nextcloud-row-reason"
>
{ratingReasonText(t, instance.rating, locale)}
</span>
</>
);
return (
<li key={instance.id} data-testid="nextcloud-row" data-level={instance.rating.level}>
{isEditMode ? (
<div className={ROW_CLASS}>{content}</div>
) : (
<Link
href={MODULE_HREF}
className={`${ROW_CLASS} transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`}
>
{content}
</Link>
)}
</li>
);
}
const allGood = counts.yellow === 0 && counts.red === 0 && counts.unknown === 0;
return frame(
<>
{isEditMode ? (
counters
) : (
<Link
href={MODULE_HREF}
aria-label={t('widget.open')}
className="rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{counters}
</Link>
)}
{allGood ? (
<p className="shrink-0 truncate text-sm font-medium text-status-ok-fg [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden">
{t('widget.allGood')}
</p>
) : (
attention.length > 0 && (
<ul
data-testid="nextcloud-list"
className="-mx-1 min-h-0 flex-1 overflow-y-auto [@container(max-height:7.5rem)]:hidden @max-[8rem]:hidden"
>
{attention.map((instance) => row(instance))}
</ul>
)
)}
</>,
);
}
@@ -67,6 +67,7 @@ const PATHS: Record<string, ReactNode> = {
<path d="M6 6h.01M6 18h.01" />
</>
),
'nextcloud-status': <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />,
reminder: (
<>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
@@ -9,12 +9,20 @@ import { WidgetIcon } from './widget-icon';
/**
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2; Erinnerungen seit
* quick-260929-if2). Notiz,
* quick-260929-if2; Nextcloud-Status seit quick-261002-k67 — die Kachel hat
* keinen eigenen Titel, nur Zaehler und Liste). Notiz,
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
* ein Foto braucht keine Ueberschrift.
*/
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch', 'reminder']);
const FRAME_HEADER_TYPES = new Set([
'calendar',
'clock',
'calculator',
'stopwatch',
'reminder',
'nextcloud-status',
]);
/**
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den