Fix mobile display issues (fixes #4)
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user