mirror of
https://github.com/moghtech/komodo.git
synced 2025-12-05 19:17:36 -06:00
* start 1.19.4 * deploy 1.19.4-dev-1 * try smaller binaries with cargo strip * deploy 1.19.4-dev-2 * smaller binaries with cargo strip * Fix Submit Dialog Button Behavior with 500 Errors on Duplicate Names (#819) * Implement enhanced error handling and messaging for resource creation * Implement improved error handling for resource creation across alerter, build, and sync * Implement error handling improvements for resource copying and validation feedback * Adjust error handling for resource creation to distinguish validation errors from unexpected system errors * Refactor resource creation error handling by removing redundant match statements and simplifying the error propagation in multiple API modules. * fmt * bump indexmap * fix account selector showing empty when account no longer found * clean up theme logic, ensure monaco and others get up to date current theme * enforce disable_non_admin_create for tags. Clean up status code responses * update server cache concurrency controller * deploy 1.19.4-dev-3 * Allow signing in by pressing enter (#830) * Improve dialog overflow handling to prevent clipping of content (#828) * Add Email notification entry to community.md (#824) * Add clickable file path to show/hide file contents in StackInfo (#827) * add clickable file path to show/hide file contents in StackInfo Also added CopyButton due to the new functionality making the file path not selectable. * Move clicking interaction to CardHeader * Avoid sync edge cases of having toggle show function capturing showContents from outside Co-authored-by: Maxwell Becker <49575486+mbecker20@users.noreply.github.com> * Format previous change * Add `default_show_contents` to `handleToggleShow` --------- Co-authored-by: Maxwell Becker <49575486+mbecker20@users.noreply.github.com> * deploy 1.19.4-dev-4 * avoid stake info ShowHideButton double toggle * Allow multiple simultaneous Action runs for use with Args * deploy 1.19.4-dev-5 * feat: persist all table sorting states including unsorted (#832) - Always save sorting state to localStorage, even when empty/unsorted - Fixes issue where 'unsorted' state was not persisted across page reloads - Ensures consistent and predictable sorting behavior for all DataTable components * autofocus on login username field (#837) * Fix unnecessary auth queries flooding console on login page (#842) * Refactor authentication error handling to use serror::Result and status codes * Enable user query only when JWT is present * Enable query execution in useRead only if JWT is present * Revert backend auth changes - keep PR focused on frontend only * Fix unnecessary API queries to unreachable servers flooding console (#843) * Implement server availability checks in various components * Refactor server availability check to ensure only healthy servers are identified * cargo fmt * fmt * Auth error handling with status codes (#841) * Refactor authentication error handling to use serror::Result and status codes * Refactor error messages * Refactor authentication error handling to include status codes and improve error messages * clean up * clean * fmt * invalid user id also UNAUTHORIZED * deploy 1.19.4-dev-6 * deploy 1.19.4-dev-7 --------- Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com> Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com> Co-authored-by: Guten <ywzhaifei@gmail.com> Co-authored-by: Paulo Roberto Albuquerque <paulora2405@gmail.com> Co-authored-by: Lorenzo Farnararo <2814802+baldarn@users.noreply.github.com>
637 lines
18 KiB
TypeScript
637 lines
18 KiB
TypeScript
import {
|
|
LOGIN_TOKENS,
|
|
useManageUser,
|
|
useRead,
|
|
useResourceParamType,
|
|
useUser,
|
|
useUserInvalidate,
|
|
} from "@lib/hooks";
|
|
import { ResourceComponents } from "../resources";
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeftRight,
|
|
Bell,
|
|
Box,
|
|
Boxes,
|
|
Calendar,
|
|
CalendarDays,
|
|
Check,
|
|
Circle,
|
|
FileQuestion,
|
|
FolderTree,
|
|
Keyboard,
|
|
LayoutDashboard,
|
|
Loader2,
|
|
LogOut,
|
|
Plus,
|
|
Settings,
|
|
User,
|
|
Users,
|
|
X,
|
|
} from "lucide-react";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@ui/dropdown-menu";
|
|
import { Button } from "@ui/button";
|
|
import { Link } from "react-router-dom";
|
|
import {
|
|
cn,
|
|
RESOURCE_TARGETS,
|
|
usableResourcePath,
|
|
version_is_none,
|
|
} from "@lib/utils";
|
|
import { useAtom } from "jotai";
|
|
import { ReactNode, useState } from "react";
|
|
import { HomeView, homeViewAtom } from "@main";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@ui/dialog";
|
|
import { Badge } from "@ui/badge";
|
|
import { ConfirmButton } from "../util";
|
|
import { Types } from "komodo_client";
|
|
import { UpdateDetails, UpdateUser } from "@components/updates/details";
|
|
import { fmt_date, fmt_operation, fmt_version } from "@lib/formatting";
|
|
import { ResourceLink, ResourceNameSimple } from "@components/resources/common";
|
|
import { UsableResource } from "@types";
|
|
import { AlertLevel } from "@components/alert";
|
|
import { AlertDetailsDialogContent } from "@components/alert/details";
|
|
import { Separator } from "@ui/separator";
|
|
|
|
export const MobileDropdown = () => {
|
|
const type = useResourceParamType();
|
|
const Components = type && ResourceComponents[type];
|
|
const [view, setView] = useAtom<HomeView>(homeViewAtom);
|
|
|
|
const [icon, title] = Components
|
|
? [<Components.Icon />, (type === "ResourceSync" ? "Sync" : type) + "s"]
|
|
: location.pathname === "/" && view === "Dashboard"
|
|
? [<LayoutDashboard className="w-4 h-4" />, "Dashboard"]
|
|
: location.pathname === "/" && view === "Resources"
|
|
? [<Boxes className="w-4 h-4" />, "Resources"]
|
|
: location.pathname === "/" && view === "Tree"
|
|
? [<FolderTree className="w-4 h-4" />, "Tree"]
|
|
: location.pathname === "/containers"
|
|
? [<Box className="w-4 h-4" />, "Containers"]
|
|
: location.pathname === "/settings"
|
|
? [<Settings className="w-4 h-4" />, "Settings"]
|
|
: location.pathname === "/schedules"
|
|
? [<CalendarDays className="w-4 h-4" />, "Schedules"]
|
|
: location.pathname === "/alerts"
|
|
? [<AlertTriangle className="w-4 h-4" />, "Alerts"]
|
|
: location.pathname === "/updates"
|
|
? [<Bell className="w-4 h-4" />, "Updates"]
|
|
: location.pathname.split("/")[1] === "user-groups"
|
|
? [<Users className="w-4 h-4" />, "User Groups"]
|
|
: location.pathname.split("/")[1] === "users"
|
|
? [<User className="w-4 h-4" />, "Users"]
|
|
: [<FileQuestion className="w-4 h-4" />, "Unknown"];
|
|
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild className="lg:hidden justify-self-end">
|
|
<Button
|
|
variant="ghost"
|
|
className="flex justify-start items-center gap-2 w-36 px-3"
|
|
>
|
|
{icon}
|
|
{title}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-36" side="bottom" align="start">
|
|
<DropdownMenuGroup>
|
|
<DropdownLinkItem
|
|
label="Dashboard"
|
|
icon={<LayoutDashboard className="w-4 h-4" />}
|
|
to="/"
|
|
onClick={() => setView("Dashboard")}
|
|
/>
|
|
<DropdownLinkItem
|
|
label="Resources"
|
|
icon={<Boxes className="w-4 h-4" />}
|
|
to="/"
|
|
onClick={() => setView("Resources")}
|
|
/>
|
|
<DropdownLinkItem
|
|
label="Containers"
|
|
icon={<Box className="w-4 h-4" />}
|
|
to="/containers"
|
|
/>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
{RESOURCE_TARGETS.map((type) => {
|
|
const RTIcon = ResourceComponents[type].Icon;
|
|
const name = type === "ResourceSync" ? "Sync" : type;
|
|
return (
|
|
<DropdownLinkItem
|
|
key={type}
|
|
label={`${name}s`}
|
|
icon={<RTIcon />}
|
|
to={`/${usableResourcePath(type)}`}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownLinkItem
|
|
label="Alerts"
|
|
icon={<AlertTriangle className="w-4 h-4" />}
|
|
to="/alerts"
|
|
/>
|
|
|
|
<DropdownLinkItem
|
|
label="Updates"
|
|
icon={<Bell className="w-4 h-4" />}
|
|
to="/updates"
|
|
/>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownLinkItem
|
|
label="Schedules"
|
|
icon={<CalendarDays className="w-4 h-4" />}
|
|
to="/schedules"
|
|
/>
|
|
|
|
<DropdownLinkItem
|
|
label="Settings"
|
|
icon={<Settings className="w-4 h-4" />}
|
|
to="/settings"
|
|
/>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
};
|
|
|
|
const DropdownLinkItem = ({
|
|
label,
|
|
icon,
|
|
to,
|
|
onClick,
|
|
}: {
|
|
label: string;
|
|
icon: ReactNode;
|
|
to: string;
|
|
onClick?: () => void;
|
|
}) => {
|
|
return (
|
|
<Link to={to} onClick={onClick}>
|
|
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
|
|
{icon}
|
|
{label}
|
|
</DropdownMenuItem>
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
export const UserDropdown = () => {
|
|
const [_, setRerender] = useState(false);
|
|
const rerender = () => setRerender((r) => !r);
|
|
const [viewLogout, setViewLogout] = useState(false);
|
|
const [open, _setOpen] = useState(false);
|
|
const setOpen = (open: boolean) => {
|
|
_setOpen(open);
|
|
if (open) {
|
|
setViewLogout(false);
|
|
}
|
|
};
|
|
const user = useUser().data;
|
|
const userInvalidate = useUserInvalidate();
|
|
const accounts = LOGIN_TOKENS.accounts();
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" className="flex items-center gap-2 px-2">
|
|
<UsernameView
|
|
username={user?.username}
|
|
avatar={(user?.config.data as any).avatar}
|
|
/>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="w-[260px] flex flex-col gap-2 items-end p-2"
|
|
side="bottom"
|
|
align="end"
|
|
sideOffset={16}
|
|
>
|
|
<div className="flex items-center justify-between gap-2 w-full">
|
|
<div className="flex gap-2 items-center text-muted-foreground pl-4 text-sm">
|
|
<ArrowLeftRight className="w-4" />
|
|
Switch accounts
|
|
</div>
|
|
<Button
|
|
className="px-2 py-0"
|
|
variant={viewLogout ? "secondary" : "outline"}
|
|
onClick={() => setViewLogout((l) => !l)}
|
|
>
|
|
<Settings className="w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{accounts.map((login) => (
|
|
<Account
|
|
login={login}
|
|
current_id={user?._id?.$oid}
|
|
setOpen={setOpen}
|
|
rerender={rerender}
|
|
viewLogout={viewLogout}
|
|
/>
|
|
))}
|
|
|
|
<Separator />
|
|
|
|
<Link
|
|
to={`/login?${new URLSearchParams({ backto: `${location.pathname}${location.search}` })}`}
|
|
className="w-full"
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setOpen(false)}
|
|
className="flex gap-1 items-center justify-center w-full"
|
|
>
|
|
Add account
|
|
<Plus className="w-4" />
|
|
</Button>
|
|
</Link>
|
|
|
|
{viewLogout && (
|
|
<ConfirmButton
|
|
title="Log Out All"
|
|
icon={<LogOut className="w-4 h-4" />}
|
|
variant="destructive"
|
|
className="flex gap-2 items-center justify-center w-full max-w-full"
|
|
onClick={() => {
|
|
LOGIN_TOKENS.remove_all();
|
|
userInvalidate();
|
|
}}
|
|
/>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
};
|
|
|
|
const Account = ({
|
|
login,
|
|
current_id,
|
|
setOpen,
|
|
rerender,
|
|
viewLogout,
|
|
}: {
|
|
login: Types.JwtResponse;
|
|
current_id?: string;
|
|
setOpen: (open: boolean) => void;
|
|
rerender: () => void;
|
|
viewLogout: boolean;
|
|
}) => {
|
|
const res = useRead("GetUsername", { user_id: login.user_id });
|
|
if (!res.data) return;
|
|
const selected = login.user_id === current_id;
|
|
return (
|
|
<div className="flex gap-2 items-center w-full">
|
|
<Button
|
|
variant={selected ? "secondary" : "ghost"}
|
|
className="flex gap-2 items-center justify-between w-full"
|
|
onClick={() => {
|
|
if (selected) {
|
|
// Noop
|
|
setOpen(false);
|
|
return;
|
|
}
|
|
LOGIN_TOKENS.change(login.user_id);
|
|
location.reload();
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<UsernameView
|
|
username={res.data?.username}
|
|
avatar={res.data?.avatar}
|
|
/>
|
|
</div>
|
|
{selected && (
|
|
<Circle className="w-3 h-3 stroke-none transition-colors fill-green-500" />
|
|
)}
|
|
</Button>
|
|
|
|
{viewLogout && (
|
|
<Button
|
|
variant="destructive"
|
|
className="px-2 py-0"
|
|
onClick={() => {
|
|
LOGIN_TOKENS.remove(login.user_id);
|
|
if (selected) {
|
|
location.reload();
|
|
} else {
|
|
rerender();
|
|
}
|
|
}}
|
|
>
|
|
<LogOut className="w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const UsernameView = ({
|
|
username,
|
|
avatar,
|
|
full,
|
|
}: {
|
|
username: string | undefined;
|
|
avatar: string | undefined;
|
|
full?: boolean;
|
|
}) => {
|
|
return (
|
|
<>
|
|
{avatar ? <img src={avatar} className="w-4" /> : <User className="w-4" />}
|
|
<div
|
|
className={cn(
|
|
"overflow-hidden overflow-ellipsis",
|
|
full ? "max-w-[200px]" : "hidden xl:flex max-w-[140px]"
|
|
)}
|
|
>
|
|
{username}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export const TopbarUpdates = () => {
|
|
const updates = useRead("ListUpdates", {}).data;
|
|
|
|
const last_opened = useUser().data?.last_update_view;
|
|
const unseen_update = updates?.updates.some(
|
|
(u) => u.start_ts > (last_opened ?? Number.MAX_SAFE_INTEGER)
|
|
);
|
|
|
|
const userInvalidate = useUserInvalidate();
|
|
const { mutate } = useManageUser("SetLastSeenUpdate", {
|
|
onSuccess: userInvalidate,
|
|
});
|
|
|
|
return (
|
|
<DropdownMenu onOpenChange={(o) => o && mutate({})}>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="relative">
|
|
<Bell className="w-4 h-4" />
|
|
<Circle
|
|
className={cn(
|
|
"absolute top-2 right-2 w-2 h-2 stroke-blue-500 fill-blue-500 transition-opacity",
|
|
unseen_update ? "opacity-1" : "opacity-0"
|
|
)}
|
|
/>
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
className="w-[100vw] md:w-[500px] h-[500px] overflow-auto"
|
|
sideOffset={20}
|
|
>
|
|
<DropdownMenuGroup>
|
|
{updates?.updates.map((update) => (
|
|
<SingleUpdate update={update} key={update.id} />
|
|
))}
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
};
|
|
|
|
const SingleUpdate = ({ update }: { update: Types.UpdateListItem }) => {
|
|
const Components =
|
|
update.target.type !== "System"
|
|
? ResourceComponents[update.target.type]
|
|
: null;
|
|
|
|
const Icon = () => {
|
|
if (update.status === Types.UpdateStatus.Complete) {
|
|
if (update.success) return <Check className="w-4 h-4 stroke-green-500" />;
|
|
else return <X className="w-4 h-4 stroke-red-500" />;
|
|
} else return <Loader2 className="w-4 h-4 animate-spin" />;
|
|
};
|
|
|
|
return (
|
|
<UpdateDetails id={update.id}>
|
|
<div className="px-2 py-4 hover:bg-muted transition-colors border-b last:border-none cursor-pointer">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm w-full">
|
|
<div className="flex items-center gap-2">
|
|
<Icon />
|
|
{fmt_operation(update.operation)}
|
|
<div className="text-xs text-muted-foreground">
|
|
{!version_is_none(update.version) &&
|
|
fmt_version(update.version)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
{Components && (
|
|
<>
|
|
<Components.Icon />
|
|
<ResourceNameSimple
|
|
type={update.target.type as UsableResource}
|
|
id={update.target.id}
|
|
/>
|
|
</>
|
|
)}
|
|
{!Components && (
|
|
<>
|
|
<Settings className="w-4 h-4" />
|
|
System
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground w-48">
|
|
<div className="flex items-center gap-2 h-[20px]">
|
|
<Calendar className="w-4 h-4" />
|
|
<div>
|
|
{update.status === Types.UpdateStatus.InProgress
|
|
? "ongoing"
|
|
: fmt_date(new Date(update.start_ts))}
|
|
</div>
|
|
</div>
|
|
<UpdateUser user_id={update.operator} iconSize={4} defaultAvatar />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</UpdateDetails>
|
|
);
|
|
};
|
|
|
|
export const TopbarAlerts = () => {
|
|
const { data } = useRead(
|
|
"ListAlerts",
|
|
{ query: { resolved: false } },
|
|
{ refetchInterval: 3_000 }
|
|
);
|
|
const [open, setOpen] = useState(false);
|
|
|
|
// If this is set, details will open.
|
|
const [alert, setAlert] = useState<Types.Alert>();
|
|
|
|
if (!data || data.alerts.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger asChild disabled={!data?.alerts.length}>
|
|
<Button variant="ghost" size="icon" className="relative">
|
|
<AlertTriangle className="w-4 h-4" />
|
|
{!!data?.alerts.length && (
|
|
<div className="absolute top-0 right-0 w-4 h-4 bg-red-500 flex items-center justify-center text-[10px] text-white rounded-full">
|
|
{data.alerts.length}
|
|
</div>
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent sideOffset={20}>
|
|
{data?.alerts.map((alert) => (
|
|
<DropdownMenuItem
|
|
key={alert._id?.$oid}
|
|
className="flex items-center gap-8 border-b last:border-none cursor-pointer"
|
|
onClick={() => setAlert(alert)}
|
|
>
|
|
<div className="w-24">
|
|
<AlertLevel level={alert.level} />
|
|
</div>
|
|
<div className="w-64">
|
|
<div className="w-fit">
|
|
<ResourceLink
|
|
type={alert.target.type as UsableResource}
|
|
id={alert.target.id}
|
|
onClick={() => setOpen(false)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<p className="w-64">{alert.data.type}</p>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<AlertDetails alert={alert} onClose={() => setAlert(undefined)} />
|
|
</>
|
|
);
|
|
};
|
|
|
|
const AlertDetails = ({
|
|
alert,
|
|
onClose,
|
|
}: {
|
|
alert: Types.Alert | undefined;
|
|
onClose: () => void;
|
|
}) => (
|
|
<>
|
|
{alert && (
|
|
<Dialog open={!!alert} onOpenChange={(o) => !o && onClose()}>
|
|
<AlertDetailsDialogContent alert={alert} onClose={onClose} />
|
|
</Dialog>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
export const Docs = () => (
|
|
<a
|
|
href="https://komo.do/docs/intro"
|
|
target="_blank"
|
|
className="hidden lg:block"
|
|
>
|
|
<Button variant="link" size="sm" className="px-2">
|
|
<div>Docs</div>
|
|
</Button>
|
|
</a>
|
|
);
|
|
|
|
export const Version = () => {
|
|
const version = useRead("GetVersion", {}, { refetchInterval: 30_000 }).data
|
|
?.version;
|
|
|
|
if (!version) return null;
|
|
return (
|
|
<a
|
|
href="https://github.com/moghtech/komodo/releases"
|
|
target="_blank"
|
|
className="hidden lg:block"
|
|
>
|
|
<Button variant="link" size="sm" className="px-2">
|
|
<div>v{version}</div>
|
|
</Button>
|
|
</a>
|
|
);
|
|
};
|
|
|
|
export const KeyboardShortcuts = () => {
|
|
return (
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="hidden md:flex">
|
|
<Keyboard className="w-4 h-4" />
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Keyboard Shortcuts</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-3 grid-cols-2 pt-8">
|
|
<KeyboardShortcut label="Save" keys={["Ctrl / Cmd", "Enter"]} />
|
|
<KeyboardShortcut label="Go Home" keys={["Shift", "H"]} />
|
|
|
|
<KeyboardShortcut label="Go to Servers" keys={["Shift", "G"]} />
|
|
<KeyboardShortcut label="Go to Stacks" keys={["Shift", "Z"]} />
|
|
<KeyboardShortcut label="Go to Deployments" keys={["Shift", "D"]} />
|
|
<KeyboardShortcut label="Go to Builds" keys={["Shift", "B"]} />
|
|
<KeyboardShortcut label="Go to Repos" keys={["Shift", "R"]} />
|
|
<KeyboardShortcut label="Go to Procedures" keys={["Shift", "P"]} />
|
|
|
|
<KeyboardShortcut label="Search" keys={["Shift", "S"]} />
|
|
<KeyboardShortcut label="Add Filter Tag" keys={["Shift", "T"]} />
|
|
<KeyboardShortcut
|
|
label="Clear Filter Tags"
|
|
keys={["Shift", "C"]}
|
|
divider={false}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
const KeyboardShortcut = ({
|
|
label,
|
|
keys,
|
|
divider = true,
|
|
}: {
|
|
label: string;
|
|
keys: string[];
|
|
divider?: boolean;
|
|
}) => {
|
|
return (
|
|
<>
|
|
<div>{label}</div>
|
|
<div className="flex items-center gap-2">
|
|
{keys.map((key) => (
|
|
<Badge variant="secondary" key={key}>
|
|
{key}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
|
|
{divider && (
|
|
<div className="col-span-full bg-gray-600 h-[1px] opacity-40" />
|
|
)}
|
|
</>
|
|
);
|
|
};
|