Fix mobile display issues (fixes #4)

This commit is contained in:
Maurus Decimus
2026-04-23 15:21:55 +02:00
parent 47fcf1fe08
commit 68f0ca3629
8 changed files with 169 additions and 122 deletions
+1
View File
@@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. This projec
### Changed ### Changed
### Fixed ### Fixed
- Mobile display issues.
- Editing a secret clears its masked value. - Editing a secret clears its masked value.
- Array label properties crashes app. - Array label properties crashes app.
+68 -63
View File
@@ -61,7 +61,12 @@ function friendlyName(viewName: string): string {
return parts[parts.length - 1]; return parts[parts.length - 1];
} }
export function GlobalSearch() { interface GlobalSearchProps {
onAfterSelect?: () => void;
autoFocus?: boolean;
}
export function GlobalSearch({ onAfterSelect, autoFocus }: GlobalSearchProps = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = { const GROUP_LABELS: Record<SearchIndexEntry['type'], string> = {
@@ -146,8 +151,9 @@ export function GlobalSearch() {
setQuery(''); setQuery('');
setDebouncedQuery(''); setDebouncedQuery('');
navigate(path); navigate(path);
onAfterSelect?.();
}, },
[schema, navigate], [schema, navigate, onAfterSelect],
); );
const handleKeyDown = useCallback( const handleKeyDown = useCallback(
@@ -172,69 +178,68 @@ export function GlobalSearch() {
const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0; const showDropdown = dropdownOpen && debouncedQuery.trim().length > 0;
return ( return (
<div className="flex flex-1 items-center justify-center px-4" ref={containerRef}> <div className="relative w-full max-w-md" ref={containerRef}>
<div className="relative w-full max-w-md"> <Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <input
<input ref={inputRef}
ref={inputRef} type="text"
type="text" value={query}
value={query} autoFocus={autoFocus}
onChange={(e) => { onChange={(e) => {
handleQueryChange(e.target.value); handleQueryChange(e.target.value);
setDropdownOpen(true); setDropdownOpen(true);
}} }}
onFocus={() => { onFocus={() => {
if (query.trim()) setDropdownOpen(true); if (query.trim()) setDropdownOpen(true);
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 pl-9 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/> />
{showDropdown && ( {showDropdown && (
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg"> <div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-popover shadow-lg">
{results.length === 0 ? ( {results.length === 0 ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground"> <div className="px-3 py-4 text-center text-sm text-muted-foreground">
{t('globalSearch.noResults', 'No results found.')} {t('globalSearch.noResults', 'No results found.')}
</div> </div>
) : ( ) : (
<div className="max-h-80 overflow-y-auto py-1"> <div className="max-h-80 overflow-y-auto py-1">
{Array.from(groups.entries()).map(([type, entries]) => ( {Array.from(groups.entries()).map(([type, entries]) => (
<div key={type}> <div key={type}>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div> <div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{GROUP_LABELS[type]}</div>
{entries.map((entry) => { {entries.map((entry) => {
const flatIdx = results.indexOf(entry); const flatIdx = results.indexOf(entry);
const objectKind = schema ? getObjectKind(schema, entry.viewName) : null; const objectKind = schema ? getObjectKind(schema, entry.viewName) : null;
const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t); const { label: actionLabel, Icon: ActionIcon } = getActionInfo(entry.type, objectKind, t);
return ( return (
<button <button
key={`${type}-${entry.viewName}-${flatIdx}`} key={`${type}-${entry.viewName}-${flatIdx}`}
type="button" type="button"
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${ className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent ${
flatIdx === activeIndex ? 'bg-accent' : '' flatIdx === activeIndex ? 'bg-accent' : ''
}`} }`}
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
handleSelect(entry); handleSelect(entry);
}} }}
onMouseEnter={() => setActiveIndex(flatIdx)} onMouseEnter={() => setActiveIndex(flatIdx)}
> >
<ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" /> <ActionIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<span className="truncate font-medium">{friendlyName(entry.text)}</span> <span className="truncate font-medium">{friendlyName(entry.text)}</span>
<span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span> <span className="truncate text-xs text-muted-foreground">{entry.breadcrumb}</span>
</div> </div>
<span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span> <span className="shrink-0 text-xs text-muted-foreground">{actionLabel}</span>
</button> </button>
); );
})} })}
</div> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
)} )}
</div>
</div> </div>
); );
} }
+66 -52
View File
@@ -10,7 +10,6 @@ import * as LucideIcons from 'lucide-react';
const { ChevronDown, Lock } = LucideIcons; const { ChevronDown, Lock } = LucideIcons;
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell'; import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -262,6 +261,7 @@ export function Sidebar() {
const activeSection = useUIStore((s) => s.activeSection); const activeSection = useUIStore((s) => s.activeSection);
const setActiveSection = useUIStore((s) => s.setActiveSection); const setActiveSection = useUIStore((s) => s.setActiveSection);
const sidebarOpen = useUIStore((s) => s.sidebarOpen); const sidebarOpen = useUIStore((s) => s.sidebarOpen);
const setSidebarOpen = useUIStore((s) => s.setSidebarOpen);
const schema = useSchemaStore((s) => s.schema); const schema = useSchemaStore((s) => s.schema);
const edition = useAccountStore((s) => s.edition); const edition = useAccountStore((s) => s.edition);
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission); const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
@@ -282,6 +282,13 @@ export function Sidebar() {
} }
}, [schema, layouts, activeSection, setActiveSection]); }, [schema, layouts, activeSection, setActiveSection]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (window.matchMedia('(max-width: 767px)').matches) {
setSidebarOpen(false);
}
}, [location.pathname, setSidebarOpen]);
if (!sidebarOpen || !schema) return null; if (!sidebarOpen || !schema) return null;
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection); const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
@@ -297,58 +304,65 @@ export function Sidebar() {
}; };
return ( return (
<aside className="fixed top-14 left-0 bottom-0 z-30 hidden w-64 flex-col border-r bg-background md:flex"> <>
<ScrollArea className="flex-1 py-2"> <div
<nav className="flex flex-col gap-0.5 px-2"> aria-hidden="true"
{layout.items.map((item) => ( className="fixed inset-0 top-14 z-20 bg-black/40 md:hidden"
<SidebarTopItem onClick={() => setSidebarOpen(false)}
key={'link' in item ? item.link.viewName : item.container.name} />
item={item} <aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
sectionName={layout.name} <div className="flex-1 overflow-y-auto py-2 [scrollbar-width:thin]">
currentPath={location.pathname} <nav className="flex flex-col gap-0.5 px-2">
navigate={navigate} {layout.items.map((item) => (
edition={edition} <SidebarTopItem
onUpsell={() => setUpsellOpen(true)} key={'link' in item ? item.link.viewName : item.container.name}
/> item={item}
))} sectionName={layout.name}
</nav> currentPath={location.pathname}
</ScrollArea> navigate={navigate}
edition={edition}
onUpsell={() => setUpsellOpen(true)}
/>
))}
</nav>
</div>
{layouts.length > 1 && ( {layouts.length > 1 && (
<TooltipProvider> <TooltipProvider>
<div className="flex items-center justify-around border-t bg-background px-2 py-2"> <div className="flex items-center justify-around border-t bg-background px-2 py-2">
{layouts.map((target) => { {layouts.map((target) => {
const Icon = (LucideIcons as Record<string, unknown>)[ const Icon = (LucideIcons as Record<string, unknown>)[
target.icon target.icon
.split('-') .split('-')
.map((s) => s[0].toUpperCase() + s.slice(1)) .map((s) => s[0].toUpperCase() + s.slice(1))
.join('') .join('')
] as LucideIcons.LucideIcon | undefined; ] as LucideIcons.LucideIcon | undefined;
const isActive = target.name === activeSection; const isActive = target.name === activeSection;
return ( return (
<Tooltip key={target.name}> <Tooltip key={target.name}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={target.name} aria-label={target.name}
aria-current={isActive ? 'page' : undefined} aria-current={isActive ? 'page' : undefined}
onClick={() => handleSectionClick(target)} onClick={() => handleSectionClick(target)}
className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')} className={cn('h-9 w-9', isActive && 'bg-accent text-accent-foreground')}
> >
{Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />} {Icon ? <Icon className="h-4 w-4" /> : <LucideIcons.Circle className="h-4 w-4" />}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">{target.name}</TooltipContent> <TooltipContent side="top">{target.name}</TooltipContent>
</Tooltip> </Tooltip>
); );
})} })}
</div> </div>
</TooltipProvider> </TooltipProvider>
)} )}
<EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} /> <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />
</aside> </aside>
</>
); );
} }
+24 -3
View File
@@ -7,9 +7,10 @@
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import * as LucideIcons from 'lucide-react'; import * as LucideIcons from 'lucide-react';
const { Sun, Moon, User, LogOut, Check, Menu, Sparkles } = LucideIcons; const { Sun, Moon, User, LogOut, Check, Menu, Sparkles, Search } = LucideIcons;
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { GlobalSearch } from '@/components/common/GlobalSearch'; import { GlobalSearch } from '@/components/common/GlobalSearch';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -53,6 +54,7 @@ export function TopBar() {
const hasPermission = useAccountStore((s) => s.hasPermission); const hasPermission = useAccountStore((s) => s.hasPermission);
const schema = useSchemaStore((s) => s.schema); const schema = useSchemaStore((s) => s.schema);
const [upsellOpen, setUpsellOpen] = useState(false); const [upsellOpen, setUpsellOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const navigableLayouts = schema const navigableLayouts = schema
? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission) ? visibleLayouts(schema, edition, (prefix) => hasObjectPermission(prefix, 'Get'), hasPermission)
@@ -68,11 +70,30 @@ export function TopBar() {
<Logo /> <Logo />
</Link> </Link>
<GlobalSearch /> <div className="hidden min-w-0 flex-1 items-center justify-center px-4 md:flex">
<GlobalSearch />
</div>
<div className="flex items-center gap-2"> <div className="ml-auto flex items-center gap-2 md:ml-0">
{edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />} {edition !== 'enterprise' && <EnterpriseUpsell open={upsellOpen} onClose={() => setUpsellOpen(false)} />}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileSearchOpen(true)}
aria-label={t('search', 'Search')}
>
<Search className="h-4 w-4" />
</Button>
<Dialog open={mobileSearchOpen} onOpenChange={setMobileSearchOpen}>
<DialogContent className="top-4 translate-y-0 max-w-[calc(100vw-2rem)] p-4">
<DialogTitle className="sr-only">{t('search', 'Search')}</DialogTitle>
<GlobalSearch autoFocus onAfterSelect={() => setMobileSearchOpen(false)} />
</DialogContent>
</Dialog>
<Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}> <Button variant="ghost" size="icon" onClick={toggleTheme} aria-label={t('toggleTheme', 'Toggle theme')}>
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />} {theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button> </Button>
+1 -1
View File
@@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<HTMLOListElement, React.HTMLAttributes<HT
<ol <ol
ref={ref} ref={ref}
className={cn( className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]', 'pointer-events-none fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className, className,
)} )}
{...props} {...props}
+7 -1
View File
@@ -25,7 +25,13 @@ export function coerceLabel(value: unknown, fallback: string): string {
if (value == null) return fallback; if (value == null) return fallback;
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) return value.length > 0 ? value.map((v) => coerceLabel(v, '')).filter(Boolean).join(', ') : fallback; if (Array.isArray(value))
return value.length > 0
? value
.map((v) => coerceLabel(v, ''))
.filter(Boolean)
.join(', ')
: fallback;
if (typeof value === 'object') { if (typeof value === 'object') {
const keys = Object.keys(value as Record<string, unknown>); const keys = Object.keys(value as Record<string, unknown>);
return keys.length > 0 ? keys.join(', ') : fallback; return keys.length > 0 ? keys.join(', ') : fallback;
+1 -1
View File
@@ -248,7 +248,7 @@ export default function AdminPanel() {
<div className="flex flex-1"> <div className="flex flex-1">
<Sidebar /> <Sidebar />
<main <main
className={`flex-1 overflow-auto bg-content-background p-6 transition-all ${sidebarOpen ? 'md:ml-64' : ''}`} className={`flex-1 overflow-auto bg-content-background p-6 transition-[margin] ${sidebarOpen ? 'md:ml-64' : ''}`}
> >
<ErrorBoundary> <ErrorBoundary>
<MainContent viewName={viewName} id={id} section={section} /> <MainContent viewName={viewName} id={id} section={section} />
+1 -1
View File
@@ -34,7 +34,7 @@ export const useUIStore = create<UIState>()(
(set, get) => ({ (set, get) => ({
theme: theme:
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light', typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
sidebarOpen: true, sidebarOpen: typeof window !== 'undefined' ? (window.matchMedia?.('(min-width: 768px)').matches ?? true) : true,
activeSection: '', activeSection: '',
toggleTheme: () => { toggleTheme: () => {