Skip to content

Commit f9e26d4

Browse files
Merge branch 'main' into feat/deep-linking
2 parents 578c8f4 + ee76f7a commit f9e26d4

6 files changed

Lines changed: 575 additions & 47 deletions

File tree

packages/web/src/components/layout/Sidebar.tsx

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,20 @@ import { Link, useMatchRoute } from "@tanstack/react-router";
22
import { motion } from "framer-motion";
33
import {
44
Boxes,
5+
Check,
56
ChevronRight,
7+
ChevronsUpDown,
68
Eye,
79
EyeOff,
810
LayoutDashboard,
911
Moon,
1012
Settings,
1113
Sun,
1214
} from "lucide-react";
15+
import { useEffect, useRef, useState } from "react";
1316
import { useDemo } from "@/hooks/useDemo";
17+
import { useInstances } from "@/hooks/useInstances";
1418
import { useTheme } from "@/hooks/useTheme";
15-
import { loadConfig } from "@/lib/config";
1619
import { COLOR } from "@/lib/constants";
1720

1821
const navItems = [
@@ -23,9 +26,22 @@ const navItems = [
2326

2427
export function Sidebar() {
2528
const matchRoute = useMatchRoute();
26-
const config = loadConfig();
29+
const { instances, active, activate } = useInstances();
2730
const { theme, toggle } = useTheme();
2831
const { demo, toggle: toggleDemo, mask } = useDemo();
32+
const [switcherOpen, setSwitcherOpen] = useState(false);
33+
const switcherRef = useRef<HTMLDivElement | null>(null);
34+
35+
useEffect(() => {
36+
if (!switcherOpen) return;
37+
function onClick(e: MouseEvent) {
38+
if (!switcherRef.current?.contains(e.target as Node)) {
39+
setSwitcherOpen(false);
40+
}
41+
}
42+
window.addEventListener("mousedown", onClick);
43+
return () => window.removeEventListener("mousedown", onClick);
44+
}, [switcherOpen]);
2945

3046
return (
3147
<motion.aside
@@ -58,14 +74,81 @@ export function Sidebar() {
5874
</span>
5975
</div>
6076
</div>
61-
{config && (
62-
<p
63-
className="text-xs mt-2 truncate font-mono hidden sm:block"
64-
style={{ color: "var(--text-4)" }}
65-
title={mask(config.baseUrl)}
66-
>
67-
{mask(config.baseUrl.replace(/^https?:\/\//, ""))}
68-
</p>
77+
{active && (
78+
<div ref={switcherRef} className="relative mt-2 hidden sm:block">
79+
<button
80+
type="button"
81+
onClick={() => setSwitcherOpen((v) => !v)}
82+
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left transition-colors"
83+
style={{
84+
background: switcherOpen ? "var(--surface)" : "transparent",
85+
border: `1px solid ${switcherOpen ? "var(--border)" : "transparent"}`,
86+
}}
87+
title={mask(active.baseUrl)}
88+
>
89+
<div className="min-w-0 flex-1">
90+
<p className="text-xs font-medium truncate" style={{ color: "var(--text-2)" }}>
91+
{active.name}
92+
</p>
93+
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
94+
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}
95+
</p>
96+
</div>
97+
{instances.length > 1 && (
98+
<ChevronsUpDown
99+
className="w-3.5 h-3.5 shrink-0"
100+
style={{ color: "var(--text-4)" }}
101+
strokeWidth={1.5}
102+
/>
103+
)}
104+
</button>
105+
{switcherOpen && instances.length > 1 && (
106+
<div
107+
className="absolute left-0 right-0 top-full mt-1 rounded-lg overflow-hidden z-20"
108+
style={{
109+
background: "var(--bg-2)",
110+
border: "1px solid var(--border)",
111+
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
112+
}}
113+
>
114+
{instances.map((inst) => (
115+
<button
116+
key={inst.id}
117+
type="button"
118+
onClick={() => {
119+
activate(inst.id);
120+
setSwitcherOpen(false);
121+
}}
122+
className="w-full flex items-center gap-2 px-2.5 py-2 text-left transition-colors"
123+
style={{
124+
background: inst.id === active.id ? "var(--accent-dim)" : "transparent",
125+
}}
126+
>
127+
<div className="min-w-0 flex-1">
128+
<p
129+
className="text-xs font-medium truncate"
130+
style={{
131+
color: inst.id === active.id ? "var(--accent-text)" : "var(--text-2)",
132+
}}
133+
>
134+
{inst.name}
135+
</p>
136+
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
137+
{mask(inst.baseUrl.replace(/^https?:\/\//, ""))}
138+
</p>
139+
</div>
140+
{inst.id === active.id && (
141+
<Check
142+
className="w-3.5 h-3.5 shrink-0"
143+
style={{ color: "var(--accent-text)" }}
144+
strokeWidth={2}
145+
/>
146+
)}
147+
</button>
148+
))}
149+
</div>
150+
)}
151+
</div>
69152
)}
70153
</div>
71154

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import { motion } from "framer-motion";
2+
import { Check, Pencil, Plus, Server, Trash2 } from "lucide-react";
3+
import { useState } from "react";
4+
import { SettingsForm } from "@/components/settings/SettingsForm";
5+
import { Button } from "@/components/ui/button";
6+
import { Muted } from "@/components/ui/typography";
7+
import { useInstances } from "@/hooks/useInstances";
8+
import type { Instance } from "@/lib/config";
9+
import { COLOR } from "@/lib/constants";
10+
11+
type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string };
12+
13+
interface InstancesManagerProps {
14+
onActivated?: () => void;
15+
}
16+
17+
export function InstancesManager({ onActivated }: InstancesManagerProps) {
18+
const { instances, activeId, activate, remove } = useInstances();
19+
const [mode, setMode] = useState<Mode>({ kind: "list" });
20+
21+
if (mode.kind === "create") {
22+
return (
23+
<SettingsForm
24+
instance={null}
25+
onSaved={() => {
26+
setMode({ kind: "list" });
27+
onActivated?.();
28+
}}
29+
onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined}
30+
hideCancel={instances.length === 0}
31+
/>
32+
);
33+
}
34+
35+
if (mode.kind === "edit") {
36+
const target = instances.find((i) => i.id === mode.id);
37+
if (!target) return null;
38+
return (
39+
<SettingsForm
40+
instance={target}
41+
onSaved={() => setMode({ kind: "list" })}
42+
onCancel={() => setMode({ kind: "list" })}
43+
/>
44+
);
45+
}
46+
47+
if (instances.length === 0) {
48+
return (
49+
<SettingsForm
50+
instance={null}
51+
onSaved={() => onActivated?.()}
52+
hideCancel
53+
submitLabel="Save Connection"
54+
/>
55+
);
56+
}
57+
58+
return (
59+
<div className="space-y-3">
60+
<div className="space-y-2">
61+
{instances.map((inst) => (
62+
<InstanceRow
63+
key={inst.id}
64+
instance={inst}
65+
active={inst.id === activeId}
66+
onActivate={() => {
67+
activate(inst.id);
68+
onActivated?.();
69+
}}
70+
onEdit={() => setMode({ kind: "edit", id: inst.id })}
71+
onDelete={() => remove(inst.id)}
72+
/>
73+
))}
74+
</div>
75+
76+
<Button
77+
type="button"
78+
variant="ghost"
79+
onClick={() => setMode({ kind: "create" })}
80+
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
81+
>
82+
<Plus className="w-4 h-4" strokeWidth={1.5} />
83+
Add another instance
84+
</Button>
85+
</div>
86+
);
87+
}
88+
89+
interface InstanceRowProps {
90+
instance: Instance;
91+
active: boolean;
92+
onActivate: () => void;
93+
onEdit: () => void;
94+
onDelete: () => void;
95+
}
96+
97+
function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
98+
const [confirmingDelete, setConfirmingDelete] = useState(false);
99+
100+
return (
101+
<motion.div
102+
layout
103+
className="rounded-xl p-3 flex items-center gap-3"
104+
style={{
105+
background: active ? "var(--accent-dim)" : "var(--bg-2)",
106+
border: `1px solid ${active ? "var(--accent-border)" : "var(--border)"}`,
107+
}}
108+
>
109+
<button
110+
type="button"
111+
onClick={onActivate}
112+
className="flex-1 flex items-center gap-3 text-left"
113+
disabled={active}
114+
title={active ? "Active instance" : "Switch to this instance"}
115+
>
116+
<div
117+
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
118+
style={{
119+
background: active ? "var(--accent)" : "var(--surface)",
120+
color: active ? "white" : "var(--text-3)",
121+
}}
122+
>
123+
{active ? (
124+
<Check className="w-4 h-4" strokeWidth={2} />
125+
) : (
126+
<Server className="w-4 h-4" strokeWidth={1.5} />
127+
)}
128+
</div>
129+
<div className="min-w-0 flex-1">
130+
<p
131+
className="text-sm font-medium truncate"
132+
style={{ color: active ? "var(--accent-text)" : "var(--text-1)" }}
133+
>
134+
{instance.name}
135+
</p>
136+
<Muted className="text-xs font-mono truncate">
137+
{instance.baseUrl.replace(/^https?:\/\//, "")}
138+
</Muted>
139+
</div>
140+
</button>
141+
142+
<div className="flex items-center gap-1 shrink-0">
143+
<button
144+
type="button"
145+
onClick={onEdit}
146+
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
147+
style={{ color: "var(--text-3)" }}
148+
title="Edit"
149+
>
150+
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
151+
</button>
152+
{confirmingDelete ? (
153+
<button
154+
type="button"
155+
onClick={() => {
156+
onDelete();
157+
setConfirmingDelete(false);
158+
}}
159+
className="text-xs font-medium px-2 py-1 rounded-md"
160+
style={{ color: COLOR.destructive, border: `1px solid ${COLOR.destructive}` }}
161+
>
162+
Confirm
163+
</button>
164+
) : (
165+
<button
166+
type="button"
167+
onClick={() => setConfirmingDelete(true)}
168+
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
169+
style={{ color: "var(--text-3)" }}
170+
title="Delete"
171+
>
172+
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
173+
</button>
174+
)}
175+
</div>
176+
</motion.div>
177+
);
178+
}

0 commit comments

Comments
 (0)