mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-05 22:56:34 +02:00
feat: add shortcuts
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear } from "react-icons/go";
|
||||
import {
|
||||
LuCircleStop,
|
||||
LuCloud,
|
||||
LuInfo,
|
||||
LuKeyboard,
|
||||
LuPlay,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
formatGroupShortcut,
|
||||
formatShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutDef,
|
||||
type ShortcutId,
|
||||
} from "@/lib/shortcuts";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface GroupTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAction: (id: ShortcutId) => void;
|
||||
/** Ordered list of groups for Mod+1..9. Index 0 is the catch-all entry. */
|
||||
groupTargets: GroupTarget[];
|
||||
onSelectGroup: (id: string) => void;
|
||||
/** All profiles for launch/stop/info entries. */
|
||||
profiles: BrowserProfile[];
|
||||
runningProfileIds: Set<string>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void;
|
||||
onKillProfile: (profile: BrowserProfile) => void;
|
||||
onShowProfileInfo: (profile: BrowserProfile) => void;
|
||||
}
|
||||
|
||||
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
|
||||
openPalette: LuKeyboard,
|
||||
openShortcuts: LuKeyboard,
|
||||
importProfile: FaDownload,
|
||||
goProfiles: LuUser,
|
||||
goProxies: FiWifi,
|
||||
goExtensions: LuPuzzle,
|
||||
goGroups: LuUsers,
|
||||
goIntegrations: LuPlug,
|
||||
goAccount: LuCloud,
|
||||
goSettings: GoGear,
|
||||
};
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<CommandShortcut className="flex items-center gap-0.5">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.25rem] h-5 px-1 rounded border border-border bg-muted text-[10px] font-medium text-muted-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
))}
|
||||
</CommandShortcut>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
|
||||
return <Tokens tokens={formatShortcut(shortcut)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-AND fuzzy filter. Every whitespace-separated token in the query has
|
||||
* to appear as a substring somewhere in the item's value or its keywords; the
|
||||
* score is reduced when tokens appear later in the haystack so a closer match
|
||||
* sorts higher. "ctest info" matches "Info — ctest" — the default cmdk filter
|
||||
* requires tokens in document order so it would otherwise return zero.
|
||||
*/
|
||||
function fuzzyFilter(
|
||||
value: string,
|
||||
search: string,
|
||||
keywords?: string[],
|
||||
): number {
|
||||
if (!search.trim()) return 1;
|
||||
const haystack = [value, ...(keywords ?? [])].join(" ").toLowerCase();
|
||||
const tokens = search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
let score = 0;
|
||||
for (const tok of tokens) {
|
||||
const idx = haystack.indexOf(tok);
|
||||
if (idx === -1) return 0;
|
||||
score += 1 / (1 + idx);
|
||||
}
|
||||
return score / tokens.length;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAction,
|
||||
groupTargets,
|
||||
onSelectGroup,
|
||||
profiles,
|
||||
runningProfileIds,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onShowProfileInfo,
|
||||
}: CommandPaletteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// `cmdk` calls onSelect BEFORE the dialog closes. Close first, then dispatch
|
||||
// on the next tick so an action that opens another dialog doesn't race
|
||||
// this one's close animation.
|
||||
const dispatch = (fn: () => void) => {
|
||||
onOpenChange(false);
|
||||
setTimeout(fn, 0);
|
||||
};
|
||||
|
||||
const byGroup = (group: ShortcutDef["group"]) =>
|
||||
SHORTCUTS.filter((s) => s.group === group);
|
||||
|
||||
// Limit to 9 — only the first 9 group targets have a Mod+digit binding.
|
||||
// We still display more in the palette (without a shortcut hint) so the
|
||||
// user can search/jump to any of them.
|
||||
const renderGroup = (target: GroupTarget, index: number) => (
|
||||
<CommandItem
|
||||
key={target.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onSelectGroup(target.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuUsers />
|
||||
<span>{target.name}</span>
|
||||
{index < 9 ? <Tokens tokens={formatGroupShortcut(index + 1)} /> : null}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||
{byGroup("navigation").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
{groupTargets.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profileGroups")}>
|
||||
{groupTargets.map((target, i) => renderGroup(target, i))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{profiles.length > 0 ? (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.groups.profiles")}>
|
||||
{profiles.map((p) => {
|
||||
const running = runningProfileIds.has(p.id);
|
||||
return running ? (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onKillProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuCircleStop />
|
||||
<span>
|
||||
{t("commandPalette.actions.stopProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
) : (
|
||||
<CommandItem
|
||||
key={`run-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onLaunchProfile(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuPlay />
|
||||
<span>
|
||||
{t("commandPalette.actions.launchProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
</span>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
{profiles.map((p) => (
|
||||
<CommandItem
|
||||
key={`info-${p.id}`}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onShowProfileInfo(p);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<LuInfo />
|
||||
<span>
|
||||
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.actions")}>
|
||||
{byGroup("actions").map((s) => {
|
||||
const Icon = ICONS[s.id];
|
||||
return (
|
||||
<CommandItem
|
||||
key={s.id}
|
||||
onSelect={() => {
|
||||
dispatch(() => {
|
||||
onAction(s.id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
<span>{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -130,6 +130,8 @@ interface ExtensionManagementDialogProps {
|
||||
onClose: () => void;
|
||||
limitedMode: boolean;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "extensions". */
|
||||
initialTab?: "extensions" | "groups";
|
||||
}
|
||||
|
||||
export function ExtensionManagementDialog({
|
||||
@@ -137,6 +139,7 @@ export function ExtensionManagementDialog({
|
||||
onClose,
|
||||
limitedMode,
|
||||
subPage,
|
||||
initialTab = "extensions",
|
||||
}: ExtensionManagementDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
@@ -208,9 +211,10 @@ export function ExtensionManagementDialog({
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
// Tab
|
||||
// Tab — keyed off `initialTab` so remounting the dialog with a new initial
|
||||
// tab (e.g. via the Mod+E shortcut toggle) jumps to that tab.
|
||||
const [activeTab, setActiveTab] = useState<"extensions" | "groups">(
|
||||
"extensions",
|
||||
initialTab,
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -1120,6 +1124,7 @@ export function ExtensionManagementDialog({
|
||||
)}
|
||||
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
|
||||
@@ -62,6 +62,8 @@ interface IntegrationsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subPage?: boolean;
|
||||
/** Which tab is displayed when the dialog mounts; defaults to "api". */
|
||||
initialTab?: "api" | "mcp";
|
||||
}
|
||||
|
||||
function AgentIcon({ category }: { category: AgentCategory }) {
|
||||
@@ -98,6 +100,7 @@ export function IntegrationsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
subPage,
|
||||
initialTab = "api",
|
||||
}: IntegrationsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
@@ -310,7 +313,7 @@ export function IntegrationsDialog({
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<AnimatedTabs defaultValue="api">
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
{t("integrations.tabApi")}
|
||||
|
||||
@@ -1052,6 +1052,13 @@ interface ProfilesDataTableProps {
|
||||
onSetPassword?: (profile: BrowserProfile) => void;
|
||||
onChangePassword?: (profile: BrowserProfile) => void;
|
||||
onRemovePassword?: (profile: BrowserProfile) => void;
|
||||
/**
|
||||
* When provided, the info dialog is controlled by the parent. Allows the
|
||||
* command palette in page.tsx to open the dialog directly without lifting
|
||||
* every other piece of internal table state.
|
||||
*/
|
||||
infoDialogProfile?: BrowserProfile | null;
|
||||
onInfoDialogProfileChange?: (profile: BrowserProfile | null) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -1084,6 +1091,8 @@ export function ProfilesDataTable({
|
||||
onSetPassword,
|
||||
onChangePassword,
|
||||
onRemovePassword,
|
||||
infoDialogProfile,
|
||||
onInfoDialogProfileChange,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
@@ -1155,8 +1164,22 @@ export function ProfilesDataTable({
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
||||
const [internalInfoDialogProfile, setInternalInfoDialogProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const isInfoDialogControlled = onInfoDialogProfileChange !== undefined;
|
||||
const profileForInfoDialog = isInfoDialogControlled
|
||||
? (infoDialogProfile ?? null)
|
||||
: internalInfoDialogProfile;
|
||||
const setProfileForInfoDialog = React.useCallback(
|
||||
(p: BrowserProfile | null) => {
|
||||
if (isInfoDialogControlled) {
|
||||
onInfoDialogProfileChange?.(p);
|
||||
} else {
|
||||
setInternalInfoDialogProfile(p);
|
||||
}
|
||||
},
|
||||
[isInfoDialogControlled, onInfoDialogProfileChange],
|
||||
);
|
||||
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
|
||||
@@ -2836,7 +2859,7 @@ export function ProfilesDataTable({
|
||||
},
|
||||
},
|
||||
],
|
||||
[t],
|
||||
[t, setProfileForInfoDialog],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal } from "react-icons/go";
|
||||
import { LuCloud, LuPlug, LuPuzzle, LuUser, LuUsers } from "react-icons/lu";
|
||||
import {
|
||||
LuCloud,
|
||||
LuKeyboard,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuUser,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
@@ -19,7 +26,8 @@ export type AppPage =
|
||||
| "settings"
|
||||
| "integrations"
|
||||
| "account"
|
||||
| "import";
|
||||
| "import"
|
||||
| "shortcuts";
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
const CLICK_WINDOW_MS = 2000;
|
||||
@@ -257,6 +265,12 @@ const MORE_ITEMS: MoreMenuItem[] = [
|
||||
labelKey: "rail.more.importProfile",
|
||||
hintKey: "rail.more.importProfileHint",
|
||||
},
|
||||
{
|
||||
page: "shortcuts",
|
||||
Icon: LuKeyboard,
|
||||
labelKey: "rail.more.keyboardShortcuts",
|
||||
hintKey: "rail.more.keyboardShortcutsHint",
|
||||
},
|
||||
];
|
||||
|
||||
export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
formatGroupShortcut,
|
||||
formatShortcut,
|
||||
SHORTCUTS,
|
||||
type ShortcutDef,
|
||||
} from "@/lib/shortcuts";
|
||||
|
||||
interface GroupTarget {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ShortcutsPageProps {
|
||||
/** Ordered list — first 9 entries display their Mod+digit binding. */
|
||||
groupTargets: GroupTarget[];
|
||||
}
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
className="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-1.5 rounded border border-border bg-muted text-[11px] font-medium text-foreground"
|
||||
>
|
||||
{tok}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutTokens({ shortcut }: { shortcut: ShortcutDef }) {
|
||||
return <Tokens tokens={formatShortcut(shortcut)} />;
|
||||
}
|
||||
|
||||
export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections: Array<{ key: ShortcutDef["group"]; titleKey: string }> = [
|
||||
{ key: "navigation", titleKey: "commandPalette.groups.navigation" },
|
||||
{ key: "actions", titleKey: "commandPalette.groups.actions" },
|
||||
];
|
||||
|
||||
const digitGroups = groupTargets.slice(0, 9);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-6 pt-4 pb-8">
|
||||
<div className="max-w-3xl w-full mx-auto flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-semibold">{t("shortcutsPage.title")}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("shortcutsPage.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{sections.map(({ key, titleKey }) => {
|
||||
const items = SHORTCUTS.filter((s) => s.group === key);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section key={key} className="flex flex-col gap-2">
|
||||
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
<div className="rounded-md border bg-card divide-y divide-border">
|
||||
{items.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{t(s.labelKey)}</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{digitGroups.length > 0 ? (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h2 className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("commandPalette.groups.profileGroups")}
|
||||
</h2>
|
||||
<div className="rounded-md border bg-card divide-y divide-border">
|
||||
{digitGroups.map((target, i) => (
|
||||
<div
|
||||
key={target.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{target.name}</span>
|
||||
<Tokens tokens={formatGroupShortcut(i + 1)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,10 +34,14 @@ function CommandDialog({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
filter,
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
filter?: React.ComponentProps<typeof CommandPrimitive>["filter"];
|
||||
shouldFilter?: React.ComponentProps<typeof CommandPrimitive>["shouldFilter"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("common.commandPalette.title");
|
||||
@@ -50,7 +54,11 @@ function CommandDialog({
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
<Command
|
||||
filter={filter}
|
||||
shouldFilter={shouldFilter}
|
||||
className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"
|
||||
>
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
|
||||
Reference in New Issue
Block a user