mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat: port the demo redesign with Manage and Settings tabs
This commit is contained in:
1 parent
f8151cb885
commit
35c954b79a
128 files changed
+13262
-8143
No files matched your search
+321
-443
@@ -1,106 +1,122 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LogOut,
|
||||
PanelRight,
|
||||
Pin,
|
||||
Settings,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
SquareArrowOutUpRight,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import type { TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { Skeleton } from "@/components/skeleton";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import { useRailItems, type RailItemDef } from "./rail-items";
|
||||
import {
|
||||
groupRailItems,
|
||||
useRailItems,
|
||||
type RailGroup,
|
||||
type RailItemDef,
|
||||
} from "./rail-items";
|
||||
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
|
||||
import { RailBadge } from "./RailBadge";
|
||||
import { rem } from "@/lib/rem";
|
||||
|
||||
/** Core rail views; plugins add their own ids at runtime. */
|
||||
export type CoreRailView =
|
||||
| "hosts"
|
||||
| "credentials"
|
||||
| "quick-connect"
|
||||
| ToolsTab
|
||||
| "connections"
|
||||
| "user-profile"
|
||||
| "admin-settings";
|
||||
"hosts" | "credentials" | "quick-connect" | ToolsTab | "connections";
|
||||
|
||||
export type RailView = CoreRailView | (string & {});
|
||||
|
||||
type RailItem =
|
||||
| {
|
||||
kind?: undefined;
|
||||
view: RailView;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| {
|
||||
kind: "tab";
|
||||
tabType: TabType;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}
|
||||
| { kind: "separator" };
|
||||
const btnBase =
|
||||
"relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring";
|
||||
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
|
||||
const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60";
|
||||
const activeClass = "text-accent-brand bg-accent-brand/10";
|
||||
|
||||
function buildRailButtons(
|
||||
items: RailItemDef[],
|
||||
t: (key: string) => string,
|
||||
hidden: Set<string>,
|
||||
): RailItem[] {
|
||||
const all: RailItem[] = [];
|
||||
for (const item of items) {
|
||||
const Icon = item.icon;
|
||||
if (item.kind === "tab") {
|
||||
all.push({
|
||||
kind: "tab",
|
||||
tabType: item.id as TabType,
|
||||
icon: <Icon size={16} />,
|
||||
title: t(item.labelKey),
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
} else {
|
||||
all.push({
|
||||
view: item.id as RailView,
|
||||
icon: <Icon size={16} />,
|
||||
title: t(item.labelKey),
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
useBadge: item.useBadge,
|
||||
});
|
||||
}
|
||||
if (item.separatorAfter) all.push({ kind: "separator" });
|
||||
}
|
||||
|
||||
// Filter out hidden items, then collapse consecutive/leading/trailing separators
|
||||
const filtered = all.filter((item) => {
|
||||
if (item.kind === "separator") return true;
|
||||
if ("tabType" in item) return !hidden.has(item.tabType);
|
||||
return !hidden.has(item.view);
|
||||
});
|
||||
|
||||
const result: RailItem[] = [];
|
||||
for (const item of filtered) {
|
||||
if (item.kind === "separator") {
|
||||
if (result.length === 0 || result[result.length - 1].kind === "separator")
|
||||
continue;
|
||||
result.push(item);
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
if (result[result.length - 1]?.kind === "separator") result.pop();
|
||||
return result;
|
||||
function RailLabel({
|
||||
expanded,
|
||||
children,
|
||||
}: {
|
||||
expanded: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
expanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const btnBase =
|
||||
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
|
||||
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
|
||||
function RailIcon({
|
||||
children,
|
||||
useBadge,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
useBadge?: () => number | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
{children}
|
||||
{useBadge && (
|
||||
<RailBadge useBadge={useBadge} className="-top-1.5 -right-2" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Rule({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{ width: expanded ? `calc(100% - ${rem(16)})` : rem(20) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The band name rides on the divider between bands, so expanding the rail
|
||||
* never pushes items down from under the pointer.
|
||||
*/
|
||||
function GroupDivider({
|
||||
group,
|
||||
expanded,
|
||||
}: {
|
||||
group: RailGroup;
|
||||
expanded: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="my-0.5 flex h-3 shrink-0 items-center gap-1.5 px-2.5">
|
||||
<span
|
||||
aria-hidden={!expanded}
|
||||
className={`shrink-0 overflow-hidden text-[10px] font-semibold uppercase leading-none tracking-widest whitespace-nowrap text-muted-foreground/60 transition-[opacity,max-width] duration-200 ${
|
||||
expanded ? "max-w-32 opacity-100 delay-75" : "max-w-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{t(`nav.group.${group}`)}
|
||||
</span>
|
||||
<div className="h-px min-w-3 flex-1 bg-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuTarget {
|
||||
id: string;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
hideable: boolean;
|
||||
}
|
||||
|
||||
export function AppRail({
|
||||
railView,
|
||||
@@ -111,6 +127,8 @@ export function AppRail({
|
||||
onRailClick,
|
||||
onOpenTab,
|
||||
onOpenInRightDock,
|
||||
onOpenSettings,
|
||||
onOpenPalette,
|
||||
onLogout,
|
||||
}: {
|
||||
railView: RailView;
|
||||
@@ -122,6 +140,8 @@ export function AppRail({
|
||||
onRailClick: (view: RailView) => void;
|
||||
onOpenTab?: (type: TabType) => void;
|
||||
onOpenInRightDock?: (view: RailView) => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenPalette?: () => void;
|
||||
onLogout: (options?: { manual?: boolean }) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -134,22 +154,9 @@ export function AppRail({
|
||||
readRailPreference("showPinAppRailButton"),
|
||||
);
|
||||
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
|
||||
// Which promotable item was right-clicked, so the menu can offer to open it
|
||||
// as a tab. Null when the right-click landed on empty rail space.
|
||||
const [menuTarget, setMenuTarget] = useState<{
|
||||
view: RailView;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
rightDockable?: boolean;
|
||||
} | null>(null);
|
||||
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("hiddenRailTabs");
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
|
||||
const [managing, setManaging] = useState(false);
|
||||
const hiddenTabs = useHiddenRailTabs();
|
||||
|
||||
useEffect(() => {
|
||||
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
|
||||
@@ -194,33 +201,20 @@ export function AppRail({
|
||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||
const railItems = useRailItems();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem("hiddenRailTabs");
|
||||
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
|
||||
} catch {
|
||||
setHiddenTabs(new Set());
|
||||
}
|
||||
};
|
||||
window.addEventListener("hiddenRailTabsChanged", handler);
|
||||
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || (expandOnHover && hovered);
|
||||
const railButtons = buildRailButtons(
|
||||
railItems.filter((item) => item.placement !== "footer"),
|
||||
t,
|
||||
hiddenTabs,
|
||||
const railExpanded = pinned || (expandOnHover && hovered) || managing;
|
||||
const mainItems = railItems.filter((item) => item.placement !== "footer");
|
||||
const bands = useMemo(
|
||||
() => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))),
|
||||
[mainItems, hiddenTabs],
|
||||
);
|
||||
const hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id));
|
||||
const footerItems = railItems.filter(
|
||||
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
|
||||
);
|
||||
const setRailPinned = (nextPinned: boolean) => {
|
||||
setPinned(nextPinned);
|
||||
localStorage.setItem("pinAppRail", String(nextPinned));
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hiddenItems.length === 0) setManaging(false);
|
||||
}, [hiddenItems.length]);
|
||||
|
||||
const togglePinned = () => {
|
||||
setRailPreference("pinAppRail", !pinned);
|
||||
@@ -232,29 +226,92 @@ export function AppRail({
|
||||
setMenuPos(null);
|
||||
};
|
||||
|
||||
const renderItem = (item: RailItemDef) => {
|
||||
const Icon = item.icon;
|
||||
const title = t(item.labelKey);
|
||||
const isTab = item.kind === "tab";
|
||||
const active = !isTab && sidebarOpen && railView === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.id as RailView);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.id as TabType);
|
||||
}}
|
||||
onContextMenu={() =>
|
||||
setMenuTarget({
|
||||
id: item.id,
|
||||
title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
hideable: !item.alwaysVisible && item.hideable !== false,
|
||||
})
|
||||
}
|
||||
data-rail-item=""
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${active ? activeClass : idle}`}
|
||||
>
|
||||
<RailIcon useBadge={item.useBadge}>
|
||||
<Icon size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{title}</RailLabel>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
|
||||
style={{ width: rem(railExpanded ? 160 : 40) }}
|
||||
style={{ width: rem(railExpanded ? 172 : 40) }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// Keep the menu on screen when right-clicking near the viewport edges
|
||||
const MENU_W = 190;
|
||||
const MENU_H = 96;
|
||||
const MENU_W = 200;
|
||||
const MENU_H = 180;
|
||||
setMenuPos({
|
||||
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
|
||||
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
|
||||
});
|
||||
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
|
||||
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
|
||||
setMenuTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{onOpenPalette && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenPalette}
|
||||
title={t("nav.searchEverything")}
|
||||
aria-label={t("nav.searchEverything")}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<Search size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
|
||||
</button>
|
||||
<Rule expanded={railExpanded} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
|
||||
{!pluginsSettled
|
||||
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
|
||||
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
|
||||
<div
|
||||
key={`rail-skeleton-${i}`}
|
||||
style={btnStyle}
|
||||
@@ -263,307 +320,106 @@ export function AppRail({
|
||||
<Skeleton className="size-4 shrink-0" />
|
||||
</div>
|
||||
))
|
||||
: railButtons.map((item, i) =>
|
||||
item.kind === "separator" ? (
|
||||
<div
|
||||
key={`sep-${i}`}
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{
|
||||
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
|
||||
}}
|
||||
/>
|
||||
) : "tabType" in item ? (
|
||||
<button
|
||||
key={item.tabType}
|
||||
onClick={() => onOpenTab?.(item.tabType)}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
: bands.map((band, i) => (
|
||||
<div key={band.group} className="flex flex-col gap-1">
|
||||
{i > 0 && (
|
||||
<GroupDivider group={band.group} expanded={railExpanded} />
|
||||
)}
|
||||
{band.items.map(renderItem)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pluginsSettled && hiddenItems.length > 0 && (
|
||||
<>
|
||||
<Rule expanded={railExpanded} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManaging((v) => !v)}
|
||||
aria-expanded={managing}
|
||||
title={t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${managing ? activeClass : idle}`}
|
||||
>
|
||||
<RailIcon>
|
||||
<EyeOff size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{t("nav.hiddenCount", { count: hiddenItems.length })}
|
||||
</RailLabel>
|
||||
</button>
|
||||
{managing &&
|
||||
hiddenItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={`hidden-${item.id}`}
|
||||
type="button"
|
||||
onClick={() => void toggleHiddenRailTab(item.id)}
|
||||
title={t("nav.showInRail")}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={(e) => {
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.view as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.view);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.view as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable || item.rightDockable)
|
||||
setMenuTarget({
|
||||
view: item.view,
|
||||
title: item.title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={
|
||||
item.promotable || item.rightDockable ? "" : undefined
|
||||
}
|
||||
title={
|
||||
item.promotable
|
||||
? `${item.title}\n${t("nav.openAsTabHint")}`
|
||||
: item.title
|
||||
}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.view
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
<RailIcon>
|
||||
<Icon size={16} />
|
||||
</RailIcon>
|
||||
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
{showPinButton && (
|
||||
<button
|
||||
onClick={() => setRailPinned(!pinned)}
|
||||
style={btnStyle}
|
||||
title={
|
||||
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||
}
|
||||
className={`${btnBase} ${
|
||||
pinned
|
||||
? "text-accent-brand bg-accent-brand/10 hover:text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<Pin size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
|
||||
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
|
||||
}`}
|
||||
>
|
||||
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{showPinButton && (
|
||||
<div
|
||||
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
|
||||
style={{
|
||||
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{footerItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const title = t(item.labelKey);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={(e) => {
|
||||
if (item.kind === "tab") {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
return;
|
||||
}
|
||||
onRailClick(item.id as RailView);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.id as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable || item.rightDockable)
|
||||
setMenuTarget({
|
||||
view: item.id as RailView,
|
||||
title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: item.rightDockable,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={
|
||||
item.promotable || item.rightDockable ? "" : undefined
|
||||
}
|
||||
title={
|
||||
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
|
||||
}
|
||||
type="button"
|
||||
onClick={() => setRailPreference("pinAppRail", !pinned)}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.id
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{item.useBadge && (
|
||||
<RailBadge
|
||||
useBadge={item.useBadge}
|
||||
className="-top-1.5 -right-2"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{(
|
||||
[
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
icon: <User size={16} />,
|
||||
title: t("nav.userProfile"),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
view: "admin-settings" as RailView,
|
||||
icon: <Settings size={16} />,
|
||||
title: t("nav.admin"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as {
|
||||
view: RailView;
|
||||
icon: ReactElement;
|
||||
title: string;
|
||||
promotable?: boolean;
|
||||
}[]
|
||||
).map((item) => (
|
||||
<button
|
||||
key={item.view}
|
||||
onClick={(e) => {
|
||||
if (item.promotable && (e.ctrlKey || e.metaKey)) {
|
||||
onOpenTab?.(item.view as TabType);
|
||||
return;
|
||||
title={
|
||||
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
|
||||
}
|
||||
onRailClick(item.view);
|
||||
}}
|
||||
onAuxClick={(e) => {
|
||||
if (e.button !== 1 || !item.promotable) return;
|
||||
e.preventDefault();
|
||||
onOpenTab?.(item.view as TabType);
|
||||
}}
|
||||
onContextMenu={() => {
|
||||
if (item.promotable)
|
||||
setMenuTarget({
|
||||
view: item.view,
|
||||
title: item.title,
|
||||
promotable: item.promotable,
|
||||
rightDockable: true,
|
||||
});
|
||||
}}
|
||||
data-rail-promotable={item.promotable ? "" : undefined}
|
||||
title={
|
||||
item.promotable
|
||||
? `${item.title}\n${t("nav.openAsTabHint")}`
|
||||
: item.title
|
||||
}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${
|
||||
sidebarOpen && railView === item.view
|
||||
? "text-accent-brand bg-accent-brand/10"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
className={`${btnBase} ${pinned ? activeClass : idle}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-2 my-1 border-t border-border" />
|
||||
<RailIcon>
|
||||
<Pin size={16} />
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>
|
||||
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
|
||||
</RailLabel>
|
||||
</button>
|
||||
<Rule expanded={railExpanded} />
|
||||
</>
|
||||
)}
|
||||
{footerItems.map(renderItem)}
|
||||
{footerItems.length > 0 && <Rule expanded={railExpanded} />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLogout({ manual: true })}
|
||||
style={btnStyle}
|
||||
title={t("common.logout")}
|
||||
aria-label={t("common.logout")}
|
||||
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
style={{ width: rem(16), height: rem(16) }}
|
||||
>
|
||||
<RailIcon>
|
||||
<LogOut size={16} />
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
|
||||
>
|
||||
{t("common.logout")}
|
||||
</span>
|
||||
</RailIcon>
|
||||
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<button
|
||||
onClick={() => onRailClick("user-profile")}
|
||||
title={t("nav.userProfile")}
|
||||
aria-label={t("nav.userProfile")}
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
style={{ padding: "0 8px" }}
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
title={t("nav.openSettings")}
|
||||
aria-label={t("nav.openSettings")}
|
||||
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
|
||||
style={{ padding: `0 ${rem(8)}` }}
|
||||
>
|
||||
<div
|
||||
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
|
||||
@@ -577,7 +433,7 @@ export function AppRail({
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
|
||||
{username || "User"}
|
||||
{username || t("nav.roleUser")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
|
||||
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
|
||||
@@ -589,79 +445,101 @@ export function AppRail({
|
||||
{menuPos && (
|
||||
<div
|
||||
data-rail-context-menu
|
||||
role="menu"
|
||||
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
|
||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
|
||||
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
|
||||
>
|
||||
{menuTarget && (
|
||||
<>
|
||||
{menuTarget.promotable && (
|
||||
<button
|
||||
<MenuItem
|
||||
icon={<SquareArrowOutUpRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenTab?.(menuTarget.view as TabType);
|
||||
onOpenTab?.(menuTarget.id as TabType);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<SquareArrowOutUpRight className="size-3" />
|
||||
</span>
|
||||
{t("nav.openAsTab")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.rightDockable && (
|
||||
<button
|
||||
<MenuItem
|
||||
icon={<PanelRight className="size-3" />}
|
||||
onClick={() => {
|
||||
onOpenInRightDock?.(menuTarget.view);
|
||||
onOpenInRightDock?.(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<PanelRight className="size-3" />
|
||||
</span>
|
||||
{t("nav.openInRightDock")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
)}
|
||||
{menuTarget.hideable && (
|
||||
<MenuItem
|
||||
icon={<EyeOff className="size-3" />}
|
||||
onClick={() => {
|
||||
void toggleHiddenRailTab(menuTarget.id);
|
||||
setMenuPos(null);
|
||||
}}
|
||||
>
|
||||
{t("nav.hideFromRail")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<div className="h-px bg-border my-1" />
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
<MenuItem
|
||||
icon={pinned ? <Check className="size-3" /> : null}
|
||||
onClick={togglePinned}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={pinned}
|
||||
checked={pinned}
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
{pinned && <Check className="size-3" />}
|
||||
</span>
|
||||
{t("newUi.sidebar.userProfile.pinAppRail")}
|
||||
</button>
|
||||
<button
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
icon={expandOnHover ? <Check className="size-3" /> : null}
|
||||
onClick={toggleExpandOnHover}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={expandOnHover}
|
||||
checked={expandOnHover}
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
{expandOnHover && <Check className="size-3" />}
|
||||
</span>
|
||||
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
||||
</button>
|
||||
</MenuItem>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
<MenuItem
|
||||
icon={<SlidersHorizontal className="size-3" />}
|
||||
onClick={() => {
|
||||
onRailClick("user-profile");
|
||||
onOpenSettings();
|
||||
setMenuPos(null);
|
||||
}}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
<SlidersHorizontal className="size-3" />
|
||||
</span>
|
||||
{t("nav.sidebarSettings")}
|
||||
</button>
|
||||
{t("nav.openSettings")}
|
||||
</MenuItem>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
icon,
|
||||
onClick,
|
||||
checked,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
checked?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role={checked === undefined ? "menuitem" : "menuitemcheckbox"}
|
||||
aria-checked={checked}
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<span className="shrink-0 w-3 flex items-center justify-center">
|
||||
{icon}
|
||||
</span>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user