Files
Termix/src/ui/sidebar/CredentialsPanel.tsx
T

331 lines
13 KiB
TypeScript

import { useState } from "react";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
Check,
ExternalLink,
Filter,
GripVertical,
Plus,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { toast } from "sonner";
import { CredentialList } from "@/sidebar/CredentialList";
import { requestManage } from "@/manage/manage-requests";
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
import { Button } from "@/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
export function CredentialsPanel({
active: _active = true,
}: {
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
const { t } = useTranslation();
const sshAuthProviders = useSshAuthProviders();
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
const [allTags, setAllTags] = useState<string[]>([]);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
const sortKey = sidebarPrefs.sort.key;
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
"credentialSidebarArrangeLocked",
);
const filterState = sidebarPrefs.filters;
const filterActive =
filterState.type.length > 0 || filterState.tags.length > 0;
function handleSortChange(key: CredentialSortKey) {
updateSidebarPrefs((prev) => ({
...prev,
sort: { ...prev.sort, key },
}));
}
function handleArrangeLockToggle() {
const unlocking = arrangeLocked;
toggleArrangeLock();
// Same reasoning as the hosts panel: reordering only sticks visually
// under manual sort, so unlocking switches to it.
if (unlocking && sortKey !== "manual") {
handleSortChange("manual");
toast.info(t("credentials.arrangeUnlockedSwitchedToManual"));
}
}
function handleFilterToggle<K extends keyof typeof filterState>(
group: K,
value: (typeof filterState)[K][number],
) {
updateSidebarPrefs((prev) => {
const arr = prev.filters[group] as string[];
const next = arr.includes(value as string)
? arr.filter((v) => v !== value)
: [...arr, value as string];
return {
...prev,
filters: { ...prev.filters, [group]: next },
};
});
}
function handleFilterClear() {
updateSidebarPrefs((prev) => ({
...prev,
filters: { type: [], tags: [] },
}));
}
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("credentials.searchCredentials")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
<div className="flex items-center gap-1.5 overflow-x-auto overflow-y-hidden toolbar-scrollbar">
<div className="flex items-center border border-border shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${sortKey !== "default" || !arrangeLocked ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.sortCredentials")}
>
<ArrowUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[160px]"
>
<DropdownMenuItem
onClick={() => handleSortChange("default")}
className="flex items-center gap-1.5"
>
{sortKey === "default" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t("credentials.sortDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(["name-asc", "name-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["username-asc", "username-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "username-asc" ? "UsernameAsc" : "UsernameDesc"}`,
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleSortChange("manual")}
className="flex items-center gap-1.5"
>
{sortKey === "manual" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<GripVertical className="size-3 shrink-0 text-muted-foreground/40" />
)}
{t("credentials.sortManual")}
</DropdownMenuItem>
<DropdownMenuCheckboxItem
checked={!arrangeLocked}
onCheckedChange={handleArrangeLockToggle}
onSelect={(event) => event.preventDefault()}
>
{t("credentials.arrangeUnlocked")}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.filterCredentials")}
>
<Filter className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[180px]"
>
{filterActive && (
<>
<DropdownMenuItem
onClick={handleFilterClear}
className="flex items-center gap-1.5 text-accent-brand"
>
<X className="size-3 shrink-0" />
{t("credentials.filterClearAll")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>
{t("credentials.filterTypeGroup")}
</DropdownMenuLabel>
{sshAuthProviders.providers
.filter((option) => option.credentialType)
.map((option) => (
<DropdownMenuCheckboxItem
key={option.type}
checked={filterState.type.includes(option.type)}
onCheckedChange={() =>
handleFilterToggle("type", option.type)
}
onSelect={(e) => e.preventDefault()}
>
{t(
`credentials.filterType${option.type.charAt(0).toUpperCase() + option.type.slice(1)}`,
{ defaultValue: t(option.labelKey) },
)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("credentials.filterTagsGroup")}
</DropdownMenuLabel>
{allTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={filterState.tags.includes(tag)}
onCheckedChange={() =>
handleFilterToggle("tags", tag)
}
onSelect={(e) => e.preventDefault()}
>
{tag}
</DropdownMenuCheckboxItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<a
href="https://docs.termix.site/features/files-and-hosts/credentials"
target="_blank"
rel="noreferrer"
title={t("hosts.docsLink")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
>
<ExternalLink className="size-3.5" />
</a>
</div>
<div className="flex items-center border border-border shrink-0">
<button
onClick={() => setCustomizePanelOpen(true)}
title={t("credentials.customizeSidebar")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
>
<SlidersHorizontal className="size-3.5" />
</button>
</div>
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
<button
onClick={() =>
requestManage({ kind: "credential", credentialId: null })
}
title={t("credentials.addCredential")}
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
>
<Plus className="size-3 shrink-0" />
<span className="hidden min-[280px]:inline">
{t("credentials.addCredential")}
</span>
</button>
</div>
</div>
</div>
}
<div className="flex flex-col flex-1 min-h-0">
<CredentialList
search={search}
sort={sortKey}
arrangeLocked={arrangeLocked}
filter={filterState}
density={sidebarPrefs.display.density}
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
onTagsChange={setAllTags}
/>
</div>
<CustomizeCredentialsSidebarPanel
open={customizePanelOpen}
onOpenChange={setCustomizePanelOpen}
preferences={sidebarPrefs}
update={updateSidebarPrefs}
/>
</div>
);
}