fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt
- PATCH mit null fuer name/url/category ergibt 400 statt 500 - Modulansicht unterscheidet Ladefehler von "nicht gefunden" - Formular/Loeschdialog nennen 403 und 400 eigens - eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten - neue Eintraege sind mit "Eigene Module" vorbelegt - Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege" Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||
function SelectionPill() {
|
||||
@@ -109,13 +109,17 @@ export function Sidebar() {
|
||||
const linkClass = (href: string) => itemClass(isActive(href));
|
||||
|
||||
const isEntryActive = (entry: SidebarEntry) =>
|
||||
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
isActive(entry.href) ||
|
||||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
|
||||
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||
// die 20-px-Modulkachel passt weiter hinein.
|
||||
const moduleItemClass = (active: boolean) =>
|
||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||
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
|
||||
@@ -186,28 +190,29 @@ export function Sidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
|
||||
// also auch hier immer zuletzt.
|
||||
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
|
||||
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
|
||||
const orderedCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const list = categories.get(entry.category);
|
||||
if (list) list.push(entry);
|
||||
else categories.set(entry.category, [entry]);
|
||||
}
|
||||
return Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
}, [entries]);
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
|
||||
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
|
||||
const collapsedEntries = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) =>
|
||||
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
|
||||
),
|
||||
[entries],
|
||||
() => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
|
||||
[orderedCategories],
|
||||
);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
||||
categories.get(entry.category)!.push(entry);
|
||||
}
|
||||
|
||||
// „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),
|
||||
);
|
||||
const ordered = orderedCategories;
|
||||
if (!searchQuery) return ordered;
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -221,10 +226,22 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [entries, searchQuery, categoryLabel]);
|
||||
}, [orderedCategories, searchQuery, categoryLabel]);
|
||||
|
||||
const navIcon = (children: React.ReactNode) => (
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
@@ -234,7 +251,12 @@ export function Sidebar() {
|
||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
||||
<li>
|
||||
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : 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(
|
||||
<>
|
||||
@@ -248,7 +270,16 @@ export function Sidebar() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : 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(
|
||||
<>
|
||||
@@ -268,9 +299,18 @@ export function Sidebar() {
|
||||
const active = isEntryActive(entry);
|
||||
return (
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={collapsedItemClass(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={24} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={24}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -290,9 +330,7 @@ export function Sidebar() {
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||
{t('noResults')}
|
||||
</div>
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{filteredCategories.map(([category, categoryEntries]) => {
|
||||
@@ -307,7 +345,19 @@ export function Sidebar() {
|
||||
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">{categoryLabel(category)}</span>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -319,7 +369,11 @@ export function Sidebar() {
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={moduleItemClass(active)}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={20}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -353,7 +407,19 @@ export function Sidebar() {
|
||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||
title={isCollapsed ? t('expand') : undefined}
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="m16 15-3-3 3-3" />
|
||||
@@ -377,9 +443,7 @@ export function Sidebar() {
|
||||
<aside
|
||||
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
|
||||
style={{
|
||||
width: isCollapsed
|
||||
? 'var(--sidebar-width-collapsed)'
|
||||
: 'var(--sidebar-width)',
|
||||
width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
|
||||
}}
|
||||
>
|
||||
{sidebarContent}
|
||||
@@ -408,7 +472,18 @@ export function Sidebar() {
|
||||
aria-label={tCommon('close')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
|
||||
>
|
||||
<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="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<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="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
|
||||
Reference in New Issue
Block a user