diff --git a/app/components/action-card/index.tsx b/app/components/action-card/index.tsx new file mode 100644 index 00000000..d9bda527 --- /dev/null +++ b/app/components/action-card/index.tsx @@ -0,0 +1,87 @@ +import { Text, Title } from '@datum-cloud/datum-ui/typography'; +import { cn } from '@datum-cloud/datum-ui/utils'; +import { LucideIcon } from 'lucide-react'; +import { ReactNode } from 'react'; + +type ActionCardVariant = 'warning' | 'success' | 'destructive' | 'info'; + +interface ActionCardProps { + variant: ActionCardVariant; + icon?: LucideIcon; + title: ReactNode; + description?: ReactNode; + /** Action element (usually a Button). Caller owns onClick / loading / type. */ + action?: ReactNode; + className?: string; +} + +type TextColor = 'warning' | 'success' | 'destructive' | 'info'; + +const variantStyles: Record = { + warning: { + container: 'border-yellow-500 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-900/20', + color: 'warning', + }, + success: { + container: 'border-green-500 bg-green-50 dark:border-green-800 dark:bg-green-900/20', + color: 'success', + }, + destructive: { + container: 'border-destructive bg-destructive/5', + color: 'destructive', + }, + info: { + container: 'border-blue-500 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20', + color: 'info', + }, +}; + +/** + * Callout banner with an icon, title, description, and an action slot + * (typically a Button). + * + * Desktop (≥768px): content on the left, action right-aligned — single row. + * + * Mobile: content stacks above the action row. Text can't be crushed by the + * button, and the button sits right-aligned on its own line. + * + * Used for reactivate / deactivate / delete style callouts. + */ +export function ActionCard({ + variant, + icon: Icon, + title, + description, + action, + className, +}: ActionCardProps) { + const styles = variantStyles[variant]; + + return ( +
+
+ + {Icon && <Icon className="h-4 w-4 shrink-0" />} + {title} + + {description && ( + + {description} + + )} +
+ {action &&
{action}
} +
+ ); +} diff --git a/app/components/app-search/app-search-mobile.tsx b/app/components/app-search/app-search-mobile.tsx new file mode 100644 index 00000000..4d3da5c1 --- /dev/null +++ b/app/components/app-search/app-search-mobile.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { SearchResults } from './search-results'; +import { useAppSearch } from './use-app-search'; +import { Button } from '@datum-cloud/datum-ui/button'; +import { Input } from '@datum-cloud/datum-ui/input'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@datum-cloud/datum-ui/sheet'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; +import { SearchIcon, X } from 'lucide-react'; +import { useEffect, useRef } from 'react'; + +function AppSearchMobile() { + const state = useAppSearch(); + const { open, setOpen, search, setSearch, t } = state; + const inputRef = useRef(null); + + // Auto-focus the input when the sheet opens + useEffect(() => { + if (open) { + const id = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(id); + } + }, [open]); + + return ( + <> + + + { + setOpen(next); + if (!next) setSearch(''); + }}> + + + {t`Search`} + {t`Search users, organizations, and projects`} + + + + setSearch(e.target.value)} + placeholder={t`Search`} + className="h-9 flex-1 shadow-none focus-visible:ring-0" + onKeyDown={(e) => { + if (e.key === 'Escape') setOpen(false); + }} + /> + + +
+ +
+
+
+ + ); +} + +export default AppSearchMobile; diff --git a/app/components/app-search/index.tsx b/app/components/app-search/index.tsx index 17a6e6c9..4d82f6a2 100644 --- a/app/components/app-search/index.tsx +++ b/app/components/app-search/index.tsx @@ -1,33 +1,12 @@ 'use client'; -import { SearchResultGroup } from './search-result-group'; -import { - searchOrganizationsQuery, - searchProjectsQuery, - searchUsersQuery, -} from '@/resources/request/client'; -import { routes } from '@/utils/config/routes.config'; -import { - Command, - CommandEmpty, - CommandGroup, - CommandItem, - CommandList, -} from '@datum-cloud/datum-ui/command'; +import { SearchResults } from './search-results'; +import { useAppSearch } from './use-app-search'; import { Input } from '@datum-cloud/datum-ui/input'; -import { Text } from '@datum-cloud/datum-ui/typography'; import { cn } from '@datum-cloud/datum-ui/utils'; -import { useLingui } from '@lingui/react/macro'; -import { ComMiloapisIamV1Alpha1User } from '@openapi/iam.miloapis.com/v1alpha1'; -import { - ComMiloapisResourcemanagerV1Alpha1Organization, - ComMiloapisResourcemanagerV1Alpha1Project, -} from '@openapi/resourcemanager.miloapis.com/v1alpha1'; -import { useQuery } from '@tanstack/react-query'; -import { Activity, Building2, FolderOpen, Home, Loader2, SearchIcon, Users } from 'lucide-react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { SearchIcon } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { useNavigate } from 'react-router'; interface Props { className?: string; @@ -35,37 +14,12 @@ interface Props { } function AppSearch({ className = '', placeholder }: Props) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); + const state = useAppSearch(); + const { open, setOpen, search, setSearch, t } = state; const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 420 }); const inputRef = useRef(null); const containerRef = useRef(null); const panelRef = useRef(null); - const navigate = useNavigate(); - const { t } = useLingui(); - const searchItems = [ - { title: t`Dashboard`, icon: Home, href: routes.dashboard(), description: t`Go to dashboard` }, - { title: t`Users`, icon: Users, href: routes.users.list(), description: t`Manage users` }, - { - title: t`Organizations`, - icon: Building2, - href: routes.organizations.list(), - description: t`Manage organizations`, - }, - { - title: t`Projects`, - icon: FolderOpen, - href: routes.projects.list(), - description: t`Manage projects`, - }, - { - title: t`Activity`, - icon: Activity, - href: routes.activity.root(), - description: t`View activity logs`, - }, - ]; // Cmd+K focuses the search input useEffect(() => { @@ -93,73 +47,19 @@ function AppSearch({ className = '', placeholder }: Props) { }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Debounce search input - useEffect(() => { - const id = setTimeout(() => setDebouncedSearch(search.trim()), 300); - return () => clearTimeout(id); - }, [search]); - - const queryOptions = { - enabled: open && debouncedSearch.length >= 3, - staleTime: 30000, - refetchOnWindowFocus: false, - retry: false, + }, [setOpen, setSearch]); + + // Blur input after a command runs so the caret disappears with the panel + const wrappedState = { + ...state, + runCommand: (command: () => unknown) => { + inputRef.current?.blur(); + state.runCommand(command); + }, }; - const { - data: userResults, - isLoading: usersLoading, - isError: usersError, - } = useQuery({ - queryKey: ['search', 'users', debouncedSearch], - queryFn: () => searchUsersQuery(debouncedSearch), - ...queryOptions, - }); - - const { - data: orgResults, - isLoading: orgsLoading, - isError: orgsError, - } = useQuery({ - queryKey: ['search', 'organizations', debouncedSearch], - queryFn: () => searchOrganizationsQuery(debouncedSearch), - ...queryOptions, - }); - - const { - data: projectResults, - isLoading: projectsLoading, - isError: projectsError, - } = useQuery({ - queryKey: ['search', 'projects', debouncedSearch], - queryFn: () => searchProjectsQuery(debouncedSearch), - ...queryOptions, - }); - - const runCommand = useCallback((command: () => unknown) => { - setOpen(false); - setSearch(''); - inputRef.current?.blur(); - command(); - }, []); - - const isLoading = usersLoading || orgsLoading || projectsLoading; - const allError = usersError && orgsError && projectsError; - const someError = (usersError || orgsError || projectsError) && !allError; - const hasResults = - (userResults?.length ?? 0) > 0 || - (orgResults?.length ?? 0) > 0 || - (projectResults?.length ?? 0) > 0; - - const getDisplayName = (item: { - metadata?: { name?: string; annotations?: Record }; - }) => item.metadata?.annotations?.['kubernetes.io/display-name'] || item.metadata?.name || ''; - return (
- {/* Inline search input */}