1
0

4 Коммитууд 5aeebd1d6b ... 713e7ff397

Эзэн SHA1 Мессеж Огноо
  Code_Uwe 713e7ff397 feat(ui): implement consistent loading UI delays across components and add uxTimings module 1 долоо хоног өмнө
  Code_Uwe 956d088bc4 feat(ui): add DropdownMenuItem for navigating to the last valid branch and adjust DropdownMenuContent width 1 долоо хоног өмнө
  Code_Uwe d8fa38db65 fix(ui): improve layout of actions group in TopNav for better separation and spacing 1 долоо хоног өмнө
  Code_Uwe df591ffeff feat(ui): add DebouncedSkeleton component to improve loading experience and reduce flicker 1 долоо хоног өмнө

+ 30 - 3
components/app-shell/QuickNav.jsx

@@ -3,7 +3,12 @@
 import React from "react";
 import Link from "next/link";
 import { usePathname, useRouter } from "next/navigation";
-import { FolderOpen, Search as SearchIcon, TriangleAlert } from "lucide-react";
+import {
+	FolderOpen,
+	Search as SearchIcon,
+	TriangleAlert,
+	CornerDownLeft,
+} from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
 import { getBranches } from "@/lib/frontend/apiClient";
@@ -24,6 +29,7 @@ import { Button } from "@/components/ui/button";
 import {
 	DropdownMenu,
 	DropdownMenuContent,
+	DropdownMenuItem,
 	DropdownMenuLabel,
 	DropdownMenuRadioGroup,
 	DropdownMenuRadioItem,
@@ -42,7 +48,7 @@ const BRANCH_LIST_STATE = Object.freeze({
 
 // Header polish:
 // - outline buttons normally have a subtle shadow; remove it for crisp header UI
-// - normalize padding when an icon is present (avoid "uneven" look)
+// - normalize padding when an icon is present
 const TOPNAV_BUTTON_CLASS = "shadow-none has-[>svg]:px-3";
 
 // Active nav style (blue like multi-branch selection)
@@ -225,7 +231,7 @@ export default function QuickNav() {
 						</Button>
 					</DropdownMenuTrigger>
 
-					<DropdownMenuContent align="end" className="min-w-56">
+					<DropdownMenuContent align="end" className="min-w-64">
 						<DropdownMenuLabel>Niederlassung</DropdownMenuLabel>
 						<DropdownMenuSeparator />
 
@@ -235,6 +241,27 @@ export default function QuickNav() {
 									Die URL-Niederlassung <strong>{routeBranch}</strong> existiert
 									nicht. Bitte wählen Sie eine gültige Niederlassung aus.
 								</div>
+
+								<DropdownMenuItem
+									disabled={!canNavigate}
+									onSelect={(e) => {
+										e.preventDefault();
+										if (!canNavigate) return;
+										navigateToBranchKeepingContext(effectiveBranch);
+									}}
+									title={
+										canNavigate
+											? `Zur letzten gültigen Niederlassung wechseln (${effectiveBranch})`
+											: "Keine gültige Niederlassung verfügbar"
+									}
+								>
+									<CornerDownLeft className="h-4 w-4" aria-hidden="true" />
+									Zur letzten gültigen Niederlassung
+									<span className="ml-auto text-xs text-muted-foreground">
+										{canNavigate ? effectiveBranch : ""}
+									</span>
+								</DropdownMenuItem>
+
 								<DropdownMenuSeparator />
 							</>
 						) : null}

+ 6 - 5
components/app-shell/SessionIndicator.jsx

@@ -5,18 +5,19 @@ import { Loader2 } from "lucide-react";
 
 import { useAuth } from "@/components/auth/authContext";
 import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import {
+	SESSION_INDICATOR_DELAY_MS,
+	SESSION_INDICATOR_MIN_VISIBLE_MS,
+} from "@/lib/frontend/ui/uxTimings";
 
 export default function SessionIndicator() {
 	const { status, isValidating } = useAuth();
 
 	const isActive = status === "loading" || Boolean(isValidating);
 
-	// Debounce policy:
-	// - Show only if it lasts longer than 200ms.
-	// - Once shown, keep it visible for at least 250ms to avoid blinking.
 	const visible = useDebouncedVisibility(isActive, {
-		delayMs: 200,
-		minVisibleMs: 250,
+		delayMs: SESSION_INDICATOR_DELAY_MS,
+		minVisibleMs: SESSION_INDICATOR_MIN_VISIBLE_MS,
 	});
 
 	if (!visible) return null;

+ 2 - 6
components/app-shell/ThemeToggleButton.jsx

@@ -11,12 +11,12 @@ import {
 	TooltipProvider,
 	TooltipTrigger,
 } from "@/components/ui/tooltip";
+import { TOOLTIP_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 export default function ThemeToggleButton() {
 	const { setTheme } = useTheme();
 
 	function toggleTheme() {
-		// Read current theme from the DOM class to avoid hydration-unsafe theme reads.
 		const isDark =
 			typeof document !== "undefined" &&
 			document.documentElement.classList.contains("dark");
@@ -25,7 +25,7 @@ export default function ThemeToggleButton() {
 	}
 
 	return (
-		<TooltipProvider delayDuration={200}>
+		<TooltipProvider delayDuration={TOOLTIP_DELAY_MS}>
 			<Tooltip>
 				<TooltipTrigger asChild>
 					<Button
@@ -35,12 +35,8 @@ export default function ThemeToggleButton() {
 						onClick={toggleTheme}
 						aria-label="Design umschalten"
 					>
-						{/* Light mode: show Moon (switch to dark) */}
 						<Moon className="h-4 w-4 dark:hidden" aria-hidden="true" />
-
-						{/* Dark mode: show Sun (switch to light) */}
 						<Sun className="hidden h-4 w-4 dark:block" aria-hidden="true" />
-
 						<span className="sr-only">Design umschalten</span>
 					</Button>
 				</TooltipTrigger>

+ 9 - 2
components/app-shell/TopNav.jsx

@@ -44,9 +44,16 @@ export default function TopNav() {
 							</nav>
 						</div>
 
+						{/* Actions group (clean separation + consistent spacing) */}
 						<div className="flex items-center gap-3">
-							<ThemeToggleButton />
-							<SessionIndicator />
+							<div className="flex items-center gap-3" aria-label="Aktionen">
+								<ThemeToggleButton />
+								<SessionIndicator />
+							</div>
+
+							{/* Subtle separator between actions and user menu */}
+							<div className="h-6 w-px bg-border" aria-hidden="true" />
+
 							<UserStatus />
 						</div>
 					</div>

+ 2 - 3
components/explorer/levels/DaysExplorer.jsx

@@ -11,6 +11,7 @@ import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
 import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -23,8 +24,6 @@ import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
 import ForbiddenView from "@/components/system/ForbiddenView";
 import { Button } from "@/components/ui/button";
 
-const LOADING_DELAY_MS = 300;
-
 export default function DaysExplorer({ branch, year, month }) {
 	const daysLoadFn = React.useCallback(
 		() => getDays(branch, year, month),
@@ -47,7 +46,7 @@ export default function DaysExplorer({ branch, year, month }) {
 	);
 
 	const showLoadingUi = useDebouncedVisibility(daysQuery.status === "loading", {
-		delayMs: LOADING_DELAY_MS,
+		delayMs: LOADING_UI_DELAY_MS,
 		minVisibleMs: 0,
 	});
 

+ 2 - 3
components/explorer/levels/FilesExplorer.jsx

@@ -15,6 +15,7 @@ import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
 import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 import { buildPdfUrl } from "@/lib/frontend/explorer/pdfUrl";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
@@ -37,8 +38,6 @@ import {
 	TableCaption,
 } from "@/components/ui/table";
 
-const LOADING_DELAY_MS = 300;
-
 export default function FilesExplorer({ branch, year, month, day }) {
 	const filesLoadFn = React.useCallback(
 		() => getFiles(branch, year, month, day),
@@ -69,7 +68,7 @@ export default function FilesExplorer({ branch, year, month, day }) {
 	const showLoadingUi = useDebouncedVisibility(
 		filesQuery.status === "loading",
 		{
-			delayMs: LOADING_DELAY_MS,
+			delayMs: LOADING_UI_DELAY_MS,
 			minVisibleMs: 0,
 		},
 	);

+ 2 - 3
components/explorer/levels/MonthsExplorer.jsx

@@ -12,6 +12,7 @@ import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
 import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -24,8 +25,6 @@ import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
 import ForbiddenView from "@/components/system/ForbiddenView";
 import { Button } from "@/components/ui/button";
 
-const LOADING_DELAY_MS = 300;
-
 export default function MonthsExplorer({ branch, year }) {
 	const monthsLoadFn = React.useCallback(
 		() => getMonths(branch, year),
@@ -44,7 +43,7 @@ export default function MonthsExplorer({ branch, year }) {
 	const showLoadingUi = useDebouncedVisibility(
 		monthsQuery.status === "loading",
 		{
-			delayMs: LOADING_DELAY_MS,
+			delayMs: LOADING_UI_DELAY_MS,
 			minVisibleMs: 0,
 		},
 	);

+ 2 - 3
components/explorer/levels/YearsExplorer.jsx

@@ -11,6 +11,7 @@ import { mapExplorerError } from "@/lib/frontend/explorer/errorMapping";
 import { buildLoginUrl, LOGIN_REASONS } from "@/lib/frontend/authRedirect";
 import { useExplorerQuery } from "@/lib/frontend/hooks/useExplorerQuery";
 import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import ExplorerBreadcrumbs from "@/components/explorer/breadcrumbs/ExplorerBreadcrumbs";
 import ExplorerPageShell from "@/components/explorer/ExplorerPageShell";
@@ -23,8 +24,6 @@ import ExplorerNotFound from "@/components/explorer/states/ExplorerNotFound";
 import ForbiddenView from "@/components/system/ForbiddenView";
 import { Button } from "@/components/ui/button";
 
-const LOADING_DELAY_MS = 300;
-
 export default function YearsExplorer({ branch }) {
 	const loadFn = React.useCallback(() => getYears(branch), [branch]);
 	const { status, data, error, retry } = useExplorerQuery(loadFn, [loadFn]);
@@ -32,7 +31,7 @@ export default function YearsExplorer({ branch }) {
 	const mapped = React.useMemo(() => mapExplorerError(error), [error]);
 
 	const showLoadingUi = useDebouncedVisibility(status === "loading", {
-		delayMs: LOADING_DELAY_MS,
+		delayMs: LOADING_UI_DELAY_MS,
 		minVisibleMs: 0,
 	});
 

+ 2 - 5
components/search/SearchResults.jsx

@@ -15,12 +15,11 @@ import {
 	SEARCH_RESULTS_SORT,
 } from "@/lib/frontend/search/resultsSorting";
 import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+import { LOADING_UI_DELAY_MS } from "@/lib/frontend/ui/uxTimings";
 
 import SearchResultsToolbar from "@/components/search/SearchResultsToolbar";
 import SearchResultsTable from "@/components/search/SearchResultsTable";
 
-const LOADING_DELAY_MS = 300;
-
 export default function SearchResults({
 	branch,
 	status,
@@ -37,7 +36,7 @@ export default function SearchResults({
 	const [sortMode, setSortMode] = React.useState(SEARCH_RESULTS_SORT.RELEVANCE);
 
 	const showLoadingUi = useDebouncedVisibility(status === "loading", {
-		delayMs: LOADING_DELAY_MS,
+		delayMs: LOADING_UI_DELAY_MS,
 		minVisibleMs: 0,
 	});
 
@@ -86,8 +85,6 @@ export default function SearchResults({
 		);
 	}
 
-	// Debounced loading UI:
-	// - If loading is very fast, do not show skeletons (prevents flicker).
 	if (showLoadingUi) {
 		return <ExplorerLoading variant="table" count={8} />;
 	}

+ 27 - 0
components/ui/debounced-skeleton.jsx

@@ -0,0 +1,27 @@
+"use client";
+
+import React from "react";
+
+import { cn } from "@/lib/utils";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useDebouncedVisibility } from "@/lib/frontend/hooks/useDebouncedVisibility";
+
+/**
+ * DebouncedSkeleton
+ *
+ * Behavior:
+ * - Reserves layout space immediately (renders a plain div with sizing classes).
+ * - Shows the animated Skeleton only after delayMs.
+ *
+ * This avoids "skeleton flicker" on fast loads.
+ */
+export function DebouncedSkeleton({ className, delayMs = 200, ...props }) {
+	const visible = useDebouncedVisibility(true, { delayMs, minVisibleMs: 0 });
+
+	if (!visible) {
+		// Reserve space, but do not show animation/background yet.
+		return <div aria-hidden="true" className={cn(className)} />;
+	}
+
+	return <Skeleton className={className} {...props} />;
+}

+ 6 - 0
lib/frontend/ui/uxTimings.js

@@ -0,0 +1,6 @@
+export const SESSION_INDICATOR_DELAY_MS = 200;
+export const SESSION_INDICATOR_MIN_VISIBLE_MS = 250;
+
+export const LOADING_UI_DELAY_MS = 300;
+
+export const TOOLTIP_DELAY_MS = 200;