UX: collapsible sidebar, settings accordion, global backend defaults, tab rename #4
@@ -8,6 +8,9 @@ vi.mock("../../store/appState", () => ({
|
|||||||
selector({
|
selector({
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: vi.fn(),
|
setSidebarView: vi.fn(),
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
setSidebarCollapsed: vi.fn(),
|
||||||
|
toggleSidebarCollapsed: vi.fn(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,15 +1,100 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
import ProjectList from "../projects/ProjectList";
|
import ProjectList from "../projects/ProjectList";
|
||||||
import McpPanel from "../mcp/McpPanel";
|
import McpPanel from "../mcp/McpPanel";
|
||||||
import SettingsPanel from "../settings/SettingsPanel";
|
import SettingsPanel from "../settings/SettingsPanel";
|
||||||
|
|
||||||
|
type SidebarView = "projects" | "mcp" | "settings";
|
||||||
|
|
||||||
|
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
|
||||||
|
{
|
||||||
|
view: "projects",
|
||||||
|
label: "Projects",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: "mcp",
|
||||||
|
label: "MCP",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 2v6" />
|
||||||
|
<path d="M15 2v6" />
|
||||||
|
<path d="M7 8h10v4a5 5 0 0 1-10 0V8z" />
|
||||||
|
<path d="M12 17v5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
view: "settings",
|
||||||
|
label: "Settings",
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { sidebarView, setSidebarView } = useAppState(
|
const { sidebarView, setSidebarView, sidebarCollapsed, setSidebarCollapsed, toggleSidebarCollapsed } = useAppState(
|
||||||
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
|
useShallow(s => ({
|
||||||
|
sidebarView: s.sidebarView,
|
||||||
|
setSidebarView: s.setSidebarView,
|
||||||
|
sidebarCollapsed: s.sidebarCollapsed,
|
||||||
|
setSidebarCollapsed: s.setSidebarCollapsed,
|
||||||
|
toggleSidebarCollapsed: s.toggleSidebarCollapsed,
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const tabCls = (view: typeof sidebarView) =>
|
if (sidebarCollapsed) {
|
||||||
|
const railBtn = (view: SidebarView, label: string, icon: ReactNode) => {
|
||||||
|
const active = sidebarView === view;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={view}
|
||||||
|
onClick={() => {
|
||||||
|
setSidebarView(view);
|
||||||
|
setSidebarCollapsed(false);
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className={`flex items-center justify-center h-10 w-full transition-colors ${
|
||||||
|
active
|
||||||
|
? "text-[var(--accent)]"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
title="Expand sidebar"
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
className="flex items-center justify-center h-10 border-b border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="9 18 15 12 9 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col py-1">
|
||||||
|
{RAIL_ICONS.map(({ view, label, icon }) => railBtn(view, label, icon))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabCls = (view: SidebarView) =>
|
||||||
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
|
||||||
sidebarView === view
|
sidebarView === view
|
||||||
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
|
||||||
@@ -29,6 +114,16 @@ export default function Sidebar() {
|
|||||||
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
|
||||||
Settings
|
Settings
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebarCollapsed}
|
||||||
|
title="Collapse sidebar"
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
className="px-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polyline points="15 18 9 12 15 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|||||||
@@ -1,6 +1,24 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
|
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
||||||
|
|
||||||
|
function loadSidebarCollapsed(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistSidebarCollapsed(value: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
// ignore — storage may be unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
// Projects
|
// Projects
|
||||||
projects: Project[];
|
projects: Project[];
|
||||||
@@ -28,6 +46,9 @@ interface AppState {
|
|||||||
setTerminalHasSelection: (has: boolean) => void;
|
setTerminalHasSelection: (has: boolean) => void;
|
||||||
sidebarView: "projects" | "mcp" | "settings";
|
sidebarView: "projects" | "mcp" | "settings";
|
||||||
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
|
toggleSidebarCollapsed: () => void;
|
||||||
dockerAvailable: boolean | null;
|
dockerAvailable: boolean | null;
|
||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
@@ -106,6 +127,17 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
||||||
sidebarView: "projects",
|
sidebarView: "projects",
|
||||||
setSidebarView: (view) => set({ sidebarView: view }),
|
setSidebarView: (view) => set({ sidebarView: view }),
|
||||||
|
sidebarCollapsed: loadSidebarCollapsed(),
|
||||||
|
setSidebarCollapsed: (collapsed) => {
|
||||||
|
persistSidebarCollapsed(collapsed);
|
||||||
|
set({ sidebarCollapsed: collapsed });
|
||||||
|
},
|
||||||
|
toggleSidebarCollapsed: () =>
|
||||||
|
set((state) => {
|
||||||
|
const next = !state.sidebarCollapsed;
|
||||||
|
persistSidebarCollapsed(next);
|
||||||
|
return { sidebarCollapsed: next };
|
||||||
|
}),
|
||||||
dockerAvailable: null,
|
dockerAvailable: null,
|
||||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||||
imageExists: null,
|
imageExists: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user