Compare commits
6 Commits
v1.6.0
...
41d00a3623
| Author | SHA1 | Date | |
|---|---|---|---|
| 41d00a3623 | |||
| d0e649baa1 | |||
| cbc89d9810 | |||
| 3cb43d6cc0 | |||
| 4ff9a239fd | |||
| 00c2cfe2c9 |
@@ -4,6 +4,21 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
|
||||
|
||||
## 1.7.0 – 2026-09-29
|
||||
|
||||
### Neu
|
||||
|
||||
- Eigene Module: Als Kategorie steht jetzt auch „Eigene Module“ zur Auswahl. Einträge dort erscheinen gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste; die Gruppe ist nur zu sehen, solange ein Eintrag darin liegt. Bestehende Einträge verschieben Sie über „Bearbeiten“ dorthin.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander.
|
||||
- Dashboard: Im Such-Widget haben die Auswahl der Suchmaschine und das Suchfeld keine helle Linie an der Unterkante mehr.
|
||||
|
||||
## 1.6.0 – 2026-09-29
|
||||
|
||||
### Neu
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||
import { MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
@@ -60,8 +60,8 @@ export class CreateCustomModuleDto {
|
||||
@Validate(NurHttpsOhneZugangsdatenConstraint)
|
||||
url!: string;
|
||||
|
||||
@IsIn([...MODULE_CATEGORIES])
|
||||
category!: (typeof MODULE_CATEGORIES)[number];
|
||||
@IsIn([...CUSTOM_MODULE_CATEGORIES])
|
||||
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
|
||||
|
||||
@@ -461,7 +461,15 @@ fn clear_check_notice(app: &AppHandle) {
|
||||
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
|
||||
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
|
||||
/// nicht unterscheidbar (quick-260922-frg).
|
||||
fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
///
|
||||
/// `install_after`: der Klick auf "Auf Version … aktualisieren" installiert
|
||||
/// NICHT den abgelegten Stand, sondern prueft zuerst frisch und installiert
|
||||
/// genau, was diese Pruefung liefert. Grund (Nutzer, 29.09.2026: "Signatur-
|
||||
/// verifikation failed", danach Browser): die Download-Adresse
|
||||
/// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein
|
||||
/// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem
|
||||
/// Server-Update passten Datei und Signatur nicht mehr zusammen.
|
||||
fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) {
|
||||
let update_item = app.state::<TrayItems>().update.clone();
|
||||
let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
|
||||
let _ = update_item.set_enabled(false);
|
||||
@@ -489,6 +497,11 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
|
||||
// Downloads an (updater.rs Z. 698-700).
|
||||
update.timeout = Some(Duration::from_secs(600));
|
||||
clear_check_notice(&app);
|
||||
if install_after {
|
||||
spawn_update_install(app.clone(), update, menu_text);
|
||||
return;
|
||||
}
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
@@ -500,7 +513,6 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = Some((update, menu_text));
|
||||
}
|
||||
clear_check_notice(&app);
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
||||
@@ -655,7 +667,7 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
apply_server(&app, Some(&normalized));
|
||||
spawn_version_check(app.clone(), normalized.clone());
|
||||
spawn_version_check(app.clone(), normalized.clone(), false);
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.navigate(with_desktop_marker(&parsed));
|
||||
@@ -868,27 +880,24 @@ pub fn run() {
|
||||
}
|
||||
"update" => {
|
||||
// `take()` verhindert Doppelklick-Downloads, der
|
||||
// gesperrte Eintrag ebenso. Ohne abgelegten Stand
|
||||
// ("Kein Update verfügbar" / "Update-Prüfung
|
||||
// fehlgeschlagen") prueft der Klick erneut -- der
|
||||
// Browser-Weg (`open_download_page`) ist nur noch
|
||||
// Rueckfall einer fehlgeschlagenen Installation.
|
||||
// gesperrte Eintrag ebenso. Liegt ein Update vor,
|
||||
// wird es NICHT aus dem Speicher installiert, sondern
|
||||
// frisch geprueft und das Ergebnis installiert
|
||||
// (`install_after`, siehe spawn_version_check). Ohne
|
||||
// abgelegten Stand ("Kein Update verfügbar" /
|
||||
// "Update-Prüfung fehlgeschlagen") prueft der Klick
|
||||
// nur erneut. Der Browser-Weg (`open_download_page`)
|
||||
// bleibt Rueckfall einer fehlgeschlagenen Installation.
|
||||
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
|
||||
let pending = app
|
||||
let had_pending = app
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut guard| guard.take());
|
||||
match pending {
|
||||
Some((update, menu_text)) => {
|
||||
spawn_update_install(app.clone(), update, menu_text)
|
||||
}
|
||||
None => {
|
||||
if let Some(url) = stored_server_url(app) {
|
||||
spawn_version_check(app.clone(), url);
|
||||
}
|
||||
}
|
||||
.and_then(|mut guard| guard.take())
|
||||
.is_some();
|
||||
if let Some(url) = stored_server_url(app) {
|
||||
spawn_version_check(app.clone(), url, had_pending);
|
||||
}
|
||||
}
|
||||
"autostart" => {
|
||||
@@ -928,7 +937,7 @@ pub fn run() {
|
||||
apply_server(app.handle(), server_url.as_deref());
|
||||
|
||||
if let Some(url) = server_url {
|
||||
spawn_version_check(app.handle().clone(), url);
|
||||
spawn_version_check(app.handle().clone(), url, false);
|
||||
}
|
||||
|
||||
// Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein
|
||||
@@ -955,7 +964,7 @@ pub fn run() {
|
||||
continue;
|
||||
}
|
||||
if let Some(url) = stored_server_url(&handle) {
|
||||
spawn_version_check(handle.clone(), url);
|
||||
spawn_version_check(handle.clone(), url, false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -255,11 +255,13 @@ body {
|
||||
Rahmen plus 2 px Akzentlinie unten. Gilt fuer Textfelder und Auswahllisten
|
||||
mit dem ueblichen `border-input`/`border-border`-Rahmen; Fehlerzustaende
|
||||
(aria-invalid, border-destructive) behalten ihre Farbe. Bewusst ausserhalb
|
||||
eines @layer, damit die Regel die Tailwind-Utilities schlaegt. */
|
||||
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive) {
|
||||
eines @layer, damit die Regel die Tailwind-Utilities schlaegt.
|
||||
`.field-plain` nimmt ein Feld aus (Such-Widget, Nutzerwunsch 29.09.: die
|
||||
helle Unterkante wirkte im Dunkelmodus wie eine weisse Linie). */
|
||||
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive, .field-plain) {
|
||||
border-bottom-color: var(--input-strong);
|
||||
}
|
||||
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive):focus {
|
||||
:is(input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]), select, textarea):is(.border-input, .border-border):not([aria-invalid="true"], .border-destructive, .field-plain):focus {
|
||||
outline: none;
|
||||
border-color: var(--input-strong);
|
||||
border-bottom-color: var(--primary);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import {
|
||||
type CustomModule,
|
||||
checkCustomModuleUrl,
|
||||
@@ -142,7 +142,7 @@ export function CustomModuleFormModal({
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{MODULE_CATEGORIES.map((c) => (
|
||||
{CUSTOM_MODULE_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{categoryLabel(c)}
|
||||
</option>
|
||||
|
||||
@@ -89,7 +89,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
|
||||
<div className="flex h-full items-center gap-2 px-1.5">
|
||||
{/* Provider dropdown (D-14: ~120px left) */}
|
||||
<select
|
||||
className="h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
|
||||
className="field-plain h-8 w-[120px] shrink-0 rounded border border-border bg-card px-2 text-sm text-foreground"
|
||||
value={selectedProviderId}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
>
|
||||
@@ -103,7 +103,7 @@ export function SearchWidget({ instanceId, config }: WidgetProps) {
|
||||
{/* Search input (flex-1 center) */}
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="field-plain h-8 flex-1 rounded border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
|
||||
@@ -40,9 +40,13 @@ vi.mock('next-intl', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
let mockCollapsed = false;
|
||||
function mockSidebarCollapsed(value: boolean) {
|
||||
mockCollapsed = value;
|
||||
}
|
||||
vi.mock('@/lib/stores/sidebar-store', () => ({
|
||||
useSidebarStore: () => ({
|
||||
isCollapsed: false,
|
||||
isCollapsed: mockCollapsed,
|
||||
isMobileOpen: false,
|
||||
toggle: vi.fn(),
|
||||
setMobileOpen: vi.fn(),
|
||||
@@ -108,6 +112,7 @@ afterEach(() => {
|
||||
mockPathname = '/';
|
||||
mockRefreshKey = 0;
|
||||
mockCustomModules = [];
|
||||
mockCollapsed = false;
|
||||
});
|
||||
|
||||
function customModule(overrides: Partial<(typeof mockCustomModules)[number]> = {}) {
|
||||
@@ -299,6 +304,50 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Domain-Tools')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Kategorie „Eigene Module“ steht zuletzt und nur, wenn ein Eintrag darin liegt', async () => {
|
||||
mockCustomModules = [
|
||||
customModule({ id: 'cm-1', category: 'custom-modules', name: 'Mein Wiki' }),
|
||||
customModule({ id: 'cm-2', category: 'fleet', name: 'Tankkarte' }),
|
||||
];
|
||||
const Sidebar = await importSidebar();
|
||||
const { unmount } = render(<Sidebar />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
|
||||
});
|
||||
const groups = Array.from(
|
||||
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||
).map((n) => n.textContent);
|
||||
expect(groups.at(-1)).toBe('custom-modules');
|
||||
expect(groups).toContain('fleet');
|
||||
unmount();
|
||||
|
||||
mockCustomModules = [customModule({ category: 'fleet' })];
|
||||
render(<Sidebar />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('fleet')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('custom-modules')).toBeNull();
|
||||
});
|
||||
|
||||
it('eingeklappt stehen Eintraege aus „Eigene Module“ zuletzt', async () => {
|
||||
mockCustomModules = [
|
||||
customModule({ id: 'cm-1', category: 'custom-modules', name: 'Mein Wiki' }),
|
||||
customModule({ id: 'cm-2', category: 'fleet', name: 'Tankkarte' }),
|
||||
];
|
||||
mockSidebarCollapsed(true);
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
const hrefs = () =>
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'));
|
||||
await waitFor(() => {
|
||||
expect(hrefs()).toContain('/modules/custom/cm-1');
|
||||
});
|
||||
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
|
||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1'));
|
||||
});
|
||||
|
||||
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
||||
mockCustomModules = [customModule({ category: 'Domain-Tools', name: 'Aaa Eigenes' })];
|
||||
const Sidebar = await importSidebar();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
@@ -116,6 +117,11 @@ export function Sidebar() {
|
||||
const moduleItemClass = (active: boolean) =>
|
||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||
|
||||
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
|
||||
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
|
||||
// mit 1 px Abstand statt 36 + 2 px.
|
||||
const collapsedItemClass = (active: boolean) => itemClass(active).replace('h-9', 'h-[34px]');
|
||||
|
||||
const fetchActiveModules = useCallback(async () => {
|
||||
const loadBuiltIn = async () => {
|
||||
try {
|
||||
@@ -180,6 +186,17 @@ export function Sidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
|
||||
// also auch hier immer zuletzt.
|
||||
const collapsedEntries = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) =>
|
||||
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
@@ -187,11 +204,15 @@ export function Sidebar() {
|
||||
categories.get(entry.category)!.push(entry);
|
||||
}
|
||||
|
||||
if (!searchQuery) return Array.from(categories.entries());
|
||||
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
|
||||
const ordered = Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
if (!searchQuery) return ordered;
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
const result: [string, SidebarEntry[]][] = [];
|
||||
for (const [cat, mods] of categories.entries()) {
|
||||
for (const [cat, mods] of ordered) {
|
||||
if (cat.toLowerCase().includes(q) || categoryLabel(cat).toLowerCase().includes(q)) {
|
||||
result.push([cat, mods]);
|
||||
} else {
|
||||
@@ -203,7 +224,7 @@ export function Sidebar() {
|
||||
}, [entries, searchQuery, categoryLabel]);
|
||||
|
||||
const navIcon = (children: React.ReactNode) => (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
@@ -211,9 +232,9 @@ export function Sidebar() {
|
||||
const sidebarContent = (
|
||||
<div className="flex h-full flex-col bg-sidebar">
|
||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
||||
<li>
|
||||
<Link href="/" className={linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
|
||||
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
|
||||
{isActive('/') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -227,7 +248,7 @@ export function Sidebar() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/marketplace" className={linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
||||
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
||||
{isActive('/marketplace') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -242,14 +263,14 @@ export function Sidebar() {
|
||||
|
||||
{/* Eingeklappt: nur die Modul-Kacheln untereinander (Name als Tooltip). */}
|
||||
{isCollapsed && entries.length > 0 && (
|
||||
<ul className="mt-3 flex flex-col gap-0.5 border-t border-sidebar-border pt-3">
|
||||
{entries.map((entry) => {
|
||||
<ul className="mt-3 flex flex-col gap-px border-t border-sidebar-border pt-3">
|
||||
{collapsedEntries.map((entry) => {
|
||||
const active = isEntryActive(entry);
|
||||
return (
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={itemClass(active)} aria-label={entry.name} title={entry.name}>
|
||||
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -1396,6 +1396,7 @@
|
||||
"security-tools": "Sicherheit",
|
||||
"fleet": "Fuhrpark",
|
||||
"infrastructure": "Infrastruktur",
|
||||
"procurement": "Beschaffung"
|
||||
"procurement": "Beschaffung",
|
||||
"custom-modules": "Eigene Module"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1396,6 +1396,7 @@
|
||||
"security-tools": "Security",
|
||||
"fleet": "Fleet",
|
||||
"infrastructure": "Infrastructure",
|
||||
"procurement": "Procurement"
|
||||
"procurement": "Procurement",
|
||||
"custom-modules": "Custom modules"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import de from './de.json';
|
||||
import en from './en.json';
|
||||
@@ -9,13 +9,13 @@ import en from './en.json';
|
||||
* API) braucht einen Anzeigenamen in `moduleCategories` beider Sprachen —
|
||||
* sonst zeigte die Seitenleiste die rohe Kennung.
|
||||
*/
|
||||
describe('MODULE_CATEGORIES', () => {
|
||||
describe('CUSTOM_MODULE_CATEGORIES (inkl. MODULE_CATEGORIES)', () => {
|
||||
it.each([
|
||||
['de', de],
|
||||
['en', en],
|
||||
] as const)('hat je Kennung einen Schluessel in moduleCategories (%s)', (_lang, messages) => {
|
||||
const labels = messages.moduleCategories as Record<string, string>;
|
||||
for (const category of MODULE_CATEGORIES) {
|
||||
for (const category of CUSTOM_MODULE_CATEGORIES) {
|
||||
expect(labels[category], `moduleCategories.${category}`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
|
||||
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
||||
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
||||
|
||||
**Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern.
|
||||
**Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“ – oder „Eigene Module“, dann stehen Ihre Einträge gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste. Diese Gruppe erscheint nur, solange ein Eintrag darin liegt. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern.
|
||||
|
||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters.
|
||||
|
||||
|
||||
@@ -274,3 +274,16 @@ export const MODULE_CATEGORIES = [
|
||||
] as const;
|
||||
|
||||
export type ModuleCategory = (typeof MODULE_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Zusaetzliche Kategorie nur fuer eigene Module (Nutzerwunsch 29.09.2026):
|
||||
* „Eigene Module“ sammelt selbst angelegte Eintraege in einer eigenen Gruppe
|
||||
* der Seitenleiste. Wie jede Kategorie erscheint sie nur, wenn ein Eintrag
|
||||
* darin liegt, und steht immer zuletzt.
|
||||
*/
|
||||
export const CUSTOM_MODULE_CATEGORY = "custom-modules" as const;
|
||||
|
||||
/** Auswahl im Formular „Eigene Module“ und Pruefung in der API. */
|
||||
export const CUSTOM_MODULE_CATEGORIES = [...MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY] as const;
|
||||
|
||||
export type CustomModuleCategory = (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||
|
||||
Reference in New Issue
Block a user