Add push-style host details panels (#649)

Refs: https://github.com/binaricat/Netcatty/issues/640
This commit is contained in:
Eric Chan
2026-04-08 16:42:32 +08:00
committed by GitHub
parent 1d2489b02c
commit 7e566efe9c
11 changed files with 154 additions and 18 deletions

View File

@@ -39,6 +39,7 @@ import {
import { import {
AsidePanel, AsidePanel,
AsidePanelContent, AsidePanelContent,
type AsidePanelLayout,
} from "./ui/aside-panel"; } from "./ui/aside-panel";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -63,6 +64,7 @@ interface GroupDetailsPanelProps {
terminalFontSize: number; terminalFontSize: number;
onSave: (config: GroupConfig, newName?: string, newParent?: string | null) => void; onSave: (config: GroupConfig, newName?: string, newParent?: string | null) => void;
onCancel: () => void; onCancel: () => void;
layout?: AsidePanelLayout;
} }
const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
@@ -76,6 +78,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
terminalFontSize, terminalFontSize,
onSave, onSave,
onCancel, onCancel,
layout = "overlay",
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const availableFonts = useAvailableFonts(); const availableFonts = useAvailableFonts();
@@ -351,6 +354,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClearProxy={clearProxyConfig} onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
onCancel={onCancel} onCancel={onCancel}
layout={layout}
/> />
); );
} }
@@ -368,6 +372,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClearChain={clearHostChain} onClearChain={clearHostChain}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
onCancel={onCancel} onCancel={onCancel}
layout={layout}
/> />
); );
} }
@@ -395,6 +400,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
}} }}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
onCancel={onCancel} onCancel={onCancel}
layout={layout}
/> />
); );
} }
@@ -411,6 +417,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClose={onCancel} onClose={onCancel}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
showBackButton={true} showBackButton={true}
layout={layout}
/> />
); );
} }
@@ -427,6 +434,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
onClose={onCancel} onClose={onCancel}
width="w-[380px]" width="w-[380px]"
title={t("vault.groups.details")} title={t("vault.groups.details")}
layout={layout}
actions={ actions={
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -51,6 +51,7 @@ import {
AsidePanel, AsidePanel,
AsidePanelContent, AsidePanelContent,
AsidePanelFooter, AsidePanelFooter,
type AsidePanelLayout,
} from "./ui/aside-panel"; } from "./ui/aside-panel";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
@@ -100,6 +101,7 @@ interface HostDetailsPanelProps {
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
onCreateTag?: (tag: string) => void; // Callback to create a new tag onCreateTag?: (tag: string) => void; // Callback to create a new tag
groupDefaults?: Partial<import('../domain/models').GroupConfig>; groupDefaults?: Partial<import('../domain/models').GroupConfig>;
layout?: AsidePanelLayout;
} }
const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
@@ -118,6 +120,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onCreateGroup, onCreateGroup,
onCreateTag, onCreateTag,
groupDefaults, groupDefaults,
layout = "overlay",
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend(); const { checkSshAgent } = useApplicationBackend();
@@ -502,6 +505,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onSave={handleCreateGroup} onSave={handleCreateGroup}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
onCancel={onCancel} onCancel={onCancel}
layout={layout}
/> />
); );
} }
@@ -514,6 +518,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearProxy={clearProxyConfig} onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
onCancel={onCancel} onCancel={onCancel}
layout={layout}
/> />
); );
} }
@@ -531,6 +536,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClearChain={clearHostChain} onClearChain={clearHostChain}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
onCancel={onCancel} onCancel={onCancel}
layout={layout}
/> />
); );
} }
@@ -559,6 +565,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}} }}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
onCancel={onCancel} onCancel={onCancel}
layout={layout}
/> />
); );
} }
@@ -576,6 +583,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClose={onCancel} onClose={onCancel}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
showBackButton={true} showBackButton={true}
layout={layout}
/> />
); );
} }
@@ -614,6 +622,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onClose={onCancel} onClose={onCancel}
onBack={() => setActiveSubPanel("none")} onBack={() => setActiveSubPanel("none")}
showBackButton={true} showBackButton={true}
layout={layout}
/> />
); );
} }
@@ -624,6 +633,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
open={true} open={true}
onClose={onCancel} onClose={onCancel}
width="w-[420px]" width="w-[420px]"
layout={layout}
title={ title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new") initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
} }

View File

@@ -17,6 +17,7 @@ import {
AsidePanel, AsidePanel,
AsidePanelContent, AsidePanelContent,
AsidePanelFooter, AsidePanelFooter,
type AsidePanelLayout,
} from './ui/aside-panel'; } from './ui/aside-panel';
interface SerialPort { interface SerialPort {
@@ -35,6 +36,7 @@ interface SerialHostDetailsPanelProps {
groups?: string[]; groups?: string[];
onSave: (host: Host) => void; onSave: (host: Host) => void;
onCancel: () => void; onCancel: () => void;
layout?: AsidePanelLayout;
} }
const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600]; const BAUD_RATES = [300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600];
@@ -49,6 +51,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
groups = [], groups = [],
onSave, onSave,
onCancel, onCancel,
layout = 'overlay',
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const terminalBackend = useTerminalBackend(); const terminalBackend = useTerminalBackend();
@@ -164,6 +167,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
title={t('serial.edit.title')} title={t('serial.edit.title')}
subtitle={initialData.label} subtitle={initialData.label}
className="z-40" className="z-40"
layout={layout}
> >
<AsidePanelContent> <AsidePanelContent>
{/* Label */} {/* Label */}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { import {
AsidePanel, AsidePanel,
AsidePanelContent, AsidePanelContent,
type AsidePanelLayout,
} from './ui/aside-panel'; } from './ui/aside-panel';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
import { ThemeList } from './ThemeList'; import { ThemeList } from './ThemeList';
@@ -13,6 +14,7 @@ interface ThemeSelectPanelProps {
onClose: () => void; onClose: () => void;
onBack?: () => void; onBack?: () => void;
showBackButton?: boolean; showBackButton?: boolean;
layout?: AsidePanelLayout;
} }
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
@@ -22,6 +24,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
onClose, onClose,
onBack, onBack,
showBackButton = true, showBackButton = true,
layout = 'overlay',
}) => { }) => {
return ( return (
<AsidePanel <AsidePanel
@@ -30,6 +33,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
title="Select Color Theme" title="Select Color Theme"
showBackButton={showBackButton} showBackButton={showBackButton}
onBack={onBack} onBack={onBack}
layout={layout}
> >
<AsidePanelContent className="p-0"> <AsidePanelContent className="p-0">
<ScrollArea className="h-full"> <ScrollArea className="h-full">

View File

@@ -1443,6 +1443,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}, [managedSources]); }, [managedSources]);
const isHostsSectionActive = currentSection === "hosts"; const isHostsSectionActive = currentSection === "hosts";
const hasHostsSidePanel =
isHostsSectionActive &&
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
const splitViewGridStyle = hasHostsSidePanel
? {
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 220px), 280px))",
justifyContent: "start" as const,
}
: undefined;
const isSameDropTarget = useCallback((a: DropTarget | null, b: DropTarget | null) => { const isSameDropTarget = useCallback((a: DropTarget | null, b: DropTarget | null) => {
if (!a || !b) return a === b; if (!a || !b) return a === b;
@@ -1973,9 +1982,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3> </h3>
<div className={cn( <div className={cn(
viewMode === "grid" viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" ? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0", : "flex flex-col gap-0",
)}> )}
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
{pinnedHosts.map((host) => { {pinnedHosts.map((host) => {
const safeHost = sanitizeHost(host); const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost); const effectiveDistro = getEffectiveHostDistro(safeHost);
@@ -2073,9 +2086,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</h3> </h3>
<div className={cn( <div className={cn(
viewMode === "grid" viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" ? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0", : "flex flex-col gap-0",
)}> )}
style={viewMode === "grid" ? splitViewGridStyle : undefined}>
{recentHosts.map((host) => { {recentHosts.map((host) => {
const safeHost = sanitizeHost(host); const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost); const effectiveDistro = getEffectiveHostDistro(safeHost);
@@ -2174,9 +2191,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn( className={cn(
displayedGroups.length === 0 ? "hidden" : "", displayedGroups.length === 0 ? "hidden" : "",
viewMode === "grid" viewMode === "grid"
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" ? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0", : "flex flex-col gap-0",
)} )}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
onDragOver={(e) => { onDragOver={(e) => {
e.preventDefault(); e.preventDefault();
}} }}
@@ -2414,9 +2435,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<div <div
className={cn( className={cn(
viewMode === "grid" viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" ? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0", : "flex flex-col gap-0",
)} )}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
> >
{group.hosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => { {group.hosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
const safeHost = sanitizeHost(host); const safeHost = sanitizeHost(host);
@@ -2555,9 +2580,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<div <div
className={cn( className={cn(
viewMode === "grid" viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" ? cn(
"grid gap-3",
!hasHostsSidePanel && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4",
)
: "flex flex-col gap-0", : "flex flex-col gap-0",
)} )}
style={viewMode === "grid" ? splitViewGridStyle : undefined}
> >
{displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => { {displayedHosts.filter((h) => selectedGroupPath || !pinnedRecentIds.has(h.id)).map((host) => {
const safeHost = sanitizeHost(host); const safeHost = sanitizeHost(host);
@@ -2823,6 +2852,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsGroupPanelOpen(false); setIsGroupPanelOpen(false);
setEditingGroupPath(null); setEditingGroupPath(null);
}} }}
layout="inline"
/> />
)} )}
@@ -2862,6 +2892,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
Array.from(new Set([...customGroups, groupPath])), Array.from(new Set([...customGroups, groupPath])),
); );
}} }}
layout="inline"
/> />
)} )}
@@ -2882,6 +2913,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsHostPanelOpen(false); setIsHostPanelOpen(false);
setEditingHost(null); setEditingHost(null);
}} }}
layout="inline"
/> />
)} )}

View File

@@ -7,7 +7,7 @@ import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types'; import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar'; import { DistroAvatar } from '../DistroAvatar';
import { AsidePanel } from '../ui/aside-panel'; import { AsidePanel, type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
@@ -24,6 +24,7 @@ export interface ChainPanelProps {
onClearChain: () => void; onClearChain: () => void;
onBack: () => void; onBack: () => void;
onCancel: () => void; onCancel: () => void;
layout?: AsidePanelLayout;
} }
export const ChainPanel: React.FC<ChainPanelProps> = ({ export const ChainPanel: React.FC<ChainPanelProps> = ({
@@ -37,6 +38,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
onClearChain, onClearChain,
onBack, onBack,
onCancel, onCancel,
layout = 'overlay',
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -54,6 +56,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
title={t('hostDetails.chain.title')} title={t('hostDetails.chain.title')}
showBackButton={true} showBackButton={true}
onBack={onBack} onBack={onBack}
layout={layout}
actions={ actions={
<Button size="sm" onClick={onBack}> <Button size="sm" onClick={onBack}>
{t('common.save')} {t('common.save')}

View File

@@ -5,7 +5,7 @@
import { FolderPlus,HelpCircle,Plus } from 'lucide-react'; import { FolderPlus,HelpCircle,Plus } from 'lucide-react';
import React from 'react'; import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel'; import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
@@ -42,6 +42,7 @@ export interface CreateGroupPanelProps {
onSave: () => void; onSave: () => void;
onBack: () => void; onBack: () => void;
onCancel: () => void; onCancel: () => void;
layout?: AsidePanelLayout;
} }
export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
@@ -53,6 +54,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
onSave, onSave,
onBack, onBack,
onCancel, onCancel,
layout = 'overlay',
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
@@ -62,6 +64,7 @@ export const CreateGroupPanel: React.FC<CreateGroupPanelProps> = ({
title={t('hostDetails.group.title')} title={t('hostDetails.group.title')}
showBackButton={true} showBackButton={true}
onBack={onBack} onBack={onBack}
layout={layout}
actions={ actions={
<Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}> <Button size="sm" onClick={onSave} disabled={!newGroupName.trim()}>
{t('common.save')} {t('common.save')}

View File

@@ -6,7 +6,7 @@ import { Plus,X } from 'lucide-react';
import React from 'react'; import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { EnvVar } from '../../types'; import { EnvVar } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel'; import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
@@ -25,6 +25,7 @@ export interface EnvVarsPanelProps {
onSave: () => void; onSave: () => void;
onBack: () => void; onBack: () => void;
onCancel: () => void; onCancel: () => void;
layout?: AsidePanelLayout;
} }
export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
@@ -41,6 +42,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
onSave, onSave,
onBack, onBack,
onCancel, onCancel,
layout = 'overlay',
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
@@ -50,6 +52,7 @@ export const EnvVarsPanel: React.FC<EnvVarsPanelProps> = ({
title={t('hostDetails.envVars.title')} title={t('hostDetails.envVars.title')}
showBackButton={true} showBackButton={true}
onBack={onBack} onBack={onBack}
layout={layout}
actions={ actions={
<Button size="sm" onClick={onSave}> <Button size="sm" onClick={onSave}>
{t('common.save')} {t('common.save')}

View File

@@ -7,7 +7,7 @@ import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { ProxyConfig } from '../../types'; import { ProxyConfig } from '../../types';
import { AsidePanel,AsidePanelContent } from '../ui/aside-panel'; import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
@@ -19,6 +19,7 @@ export interface ProxyPanelProps {
onClearProxy: () => void; onClearProxy: () => void;
onBack: () => void; onBack: () => void;
onCancel: () => void; onCancel: () => void;
layout?: AsidePanelLayout;
} }
export const ProxyPanel: React.FC<ProxyPanelProps> = ({ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
@@ -27,6 +28,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
onClearProxy, onClearProxy,
onBack, onBack,
onCancel, onCancel,
layout = 'overlay',
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
@@ -36,6 +38,7 @@ export const ProxyPanel: React.FC<ProxyPanelProps> = ({
title={t('hostDetails.proxyPanel.title')} title={t('hostDetails.proxyPanel.title')}
showBackButton={true} showBackButton={true}
onBack={onBack} onBack={onBack}
layout={layout}
actions={ actions={
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}> <Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
{t('common.save')} {t('common.save')}

View File

@@ -1,5 +1,5 @@
import { ArrowLeft, MoreVertical, X } from 'lucide-react'; import { ArrowLeft, MoreVertical, X } from 'lucide-react';
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react'; import React, { createContext, ReactNode, useCallback, useContext, useMemo, useState } from 'react';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './popover'; import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area'; import { ScrollArea } from './scroll-area';
@@ -44,6 +44,7 @@ interface AsidePanelProps {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
width?: string; width?: string;
layout?: AsidePanelLayout;
} }
interface AsidePanelHeaderProps { interface AsidePanelHeaderProps {
@@ -171,14 +172,34 @@ interface AsidePanelStackProps {
initialItem: AsideContentItem; initialItem: AsideContentItem;
className?: string; className?: string;
width?: string; width?: string;
layout?: AsidePanelLayout;
} }
export type AsidePanelLayout = 'overlay' | 'inline';
const resolveInlineWidth = (width: string) => {
const arbitraryWidthMatch = width.match(/w-\[(.+)\]/);
if (arbitraryWidthMatch) {
return arbitraryWidthMatch[1];
}
switch (width) {
case 'w-full':
return '100%';
case 'w-screen':
return '100vw';
default:
return '380px';
}
};
export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
open, open,
onClose, onClose,
initialItem, initialItem,
className, className,
width = 'w-[380px]', width = 'w-[380px]',
layout = 'overlay',
}) => { }) => {
const [stack, setStack] = useState<AsideContentItem[]>([initialItem]); const [stack, setStack] = useState<AsideContentItem[]>([initialItem]);
@@ -205,6 +226,13 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
const currentItem = stack[stack.length - 1]; const currentItem = stack[stack.length - 1];
const canGoBack = stack.length > 1; const canGoBack = stack.length > 1;
const inlineWidth = useMemo(() => resolveInlineWidth(width), [width]);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
// Reset stack when panel closes/opens // Reset stack when panel closes/opens
React.useEffect(() => { React.useEffect(() => {
@@ -218,10 +246,13 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return ( return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}> <AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn( <div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden", layout === 'inline'
width, ? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className className
)}> )}
style={inlineStyle}>
<AsidePanelHeader <AsidePanelHeader
title={currentItem.title} title={currentItem.title}
subtitle={currentItem.subtitle} subtitle={currentItem.subtitle}
@@ -248,15 +279,27 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
children, children,
className, className,
width = 'w-[380px]', width = 'w-[380px]',
layout = 'overlay',
}) => { }) => {
if (!open) return null; if (!open) return null;
const inlineWidth = resolveInlineWidth(width);
const inlineStyle = layout === 'inline'
? ({
width: inlineWidth,
['--aside-inline-width' as string]: inlineWidth,
} as React.CSSProperties)
: undefined;
return ( return (
<div className={cn( <div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden", layout === 'inline'
width, ? "relative split-panel-enter shrink-0 h-full min-h-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden shadow-[-16px_0_32px_hsl(var(--foreground)/0.08)]"
: "absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
layout === 'overlay' && width,
className className
)}> )}
style={inlineStyle}>
{title && ( {title && (
<AsidePanelHeader <AsidePanelHeader
title={title} title={title}

View File

@@ -102,6 +102,29 @@
} }
} }
@keyframes split-panel-enter {
0% {
width: 0;
min-width: 0;
opacity: 0;
transform: translateX(22px);
}
55% {
opacity: 0.88;
}
100% {
width: var(--aside-inline-width);
min-width: var(--aside-inline-width);
opacity: 1;
transform: translateX(0);
}
}
.split-panel-enter {
animation: split-panel-enter 220ms cubic-bezier(0.24, 0.84, 0.32, 1) both;
will-change: width, opacity, transform;
}
:root { :root {
color-scheme: light; color-scheme: light;
} }