Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 93 additions & 10 deletions packages/web/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@ import { Link, useMatchRoute } from "@tanstack/react-router";
import { motion } from "framer-motion";
import {
Boxes,
Check,
ChevronRight,
ChevronsUpDown,
Eye,
EyeOff,
LayoutDashboard,
Moon,
Settings,
Sun,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useDemo } from "@/hooks/useDemo";
import { useInstances } from "@/hooks/useInstances";
import { useTheme } from "@/hooks/useTheme";
import { loadConfig } from "@/lib/config";
import { COLOR } from "@/lib/constants";

const navItems = [
Expand All @@ -23,9 +26,22 @@ const navItems = [

export function Sidebar() {
const matchRoute = useMatchRoute();
const config = loadConfig();
const { instances, active, activate } = useInstances();
const { theme, toggle } = useTheme();
const { demo, toggle: toggleDemo, mask } = useDemo();
const [switcherOpen, setSwitcherOpen] = useState(false);
const switcherRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
if (!switcherOpen) return;
function onClick(e: MouseEvent) {
if (!switcherRef.current?.contains(e.target as Node)) {
setSwitcherOpen(false);
}
}
window.addEventListener("mousedown", onClick);
return () => window.removeEventListener("mousedown", onClick);
}, [switcherOpen]);

return (
<motion.aside
Expand Down Expand Up @@ -58,14 +74,81 @@ export function Sidebar() {
</span>
</div>
</div>
{config && (
<p
className="text-xs mt-2 truncate font-mono hidden sm:block"
style={{ color: "var(--text-4)" }}
title={mask(config.baseUrl)}
>
{mask(config.baseUrl.replace(/^https?:\/\//, ""))}
</p>
{active && (
<div ref={switcherRef} className="relative mt-2 hidden sm:block">
<button
type="button"
onClick={() => setSwitcherOpen((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left transition-colors"
style={{
background: switcherOpen ? "var(--surface)" : "transparent",
border: `1px solid ${switcherOpen ? "var(--border)" : "transparent"}`,
}}
title={mask(active.baseUrl)}
>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate" style={{ color: "var(--text-2)" }}>
{active.name}
</p>
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}
</p>
</div>
{instances.length > 1 && (
<ChevronsUpDown
className="w-3.5 h-3.5 shrink-0"
style={{ color: "var(--text-4)" }}
strokeWidth={1.5}
/>
)}
</button>
{switcherOpen && instances.length > 1 && (
<div
className="absolute left-0 right-0 top-full mt-1 rounded-lg overflow-hidden z-20"
style={{
background: "var(--bg-2)",
border: "1px solid var(--border)",
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
}}
>
{instances.map((inst) => (
<button
key={inst.id}
type="button"
onClick={() => {
activate(inst.id);
setSwitcherOpen(false);
}}
className="w-full flex items-center gap-2 px-2.5 py-2 text-left transition-colors"
style={{
background: inst.id === active.id ? "var(--accent-dim)" : "transparent",
}}
>
<div className="min-w-0 flex-1">
<p
className="text-xs font-medium truncate"
style={{
color: inst.id === active.id ? "var(--accent-text)" : "var(--text-2)",
}}
>
{inst.name}
</p>
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
{mask(inst.baseUrl.replace(/^https?:\/\//, ""))}
</p>
</div>
{inst.id === active.id && (
<Check
className="w-3.5 h-3.5 shrink-0"
style={{ color: "var(--accent-text)" }}
strokeWidth={2}
/>
)}
</button>
))}
</div>
)}
</div>
)}
</div>

Expand Down
178 changes: 178 additions & 0 deletions packages/web/src/components/settings/InstancesManager.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { motion } from "framer-motion";
import { Check, Pencil, Plus, Server, Trash2 } from "lucide-react";
import { useState } from "react";
import { SettingsForm } from "@/components/settings/SettingsForm";
import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography";
import { useInstances } from "@/hooks/useInstances";
import type { Instance } from "@/lib/config";
import { COLOR } from "@/lib/constants";

type Mode = { kind: "list" } | { kind: "create" } | { kind: "edit"; id: string };

interface InstancesManagerProps {
onActivated?: () => void;
}

export function InstancesManager({ onActivated }: InstancesManagerProps) {
const { instances, activeId, activate, remove } = useInstances();
const [mode, setMode] = useState<Mode>({ kind: "list" });

if (mode.kind === "create") {
return (
<SettingsForm
instance={null}
onSaved={() => {
setMode({ kind: "list" });
onActivated?.();
}}
onCancel={instances.length > 0 ? () => setMode({ kind: "list" }) : undefined}
hideCancel={instances.length === 0}
/>
);
}

if (mode.kind === "edit") {
const target = instances.find((i) => i.id === mode.id);
if (!target) return null;
return (
<SettingsForm
instance={target}
onSaved={() => setMode({ kind: "list" })}
onCancel={() => setMode({ kind: "list" })}
/>
);
}

if (instances.length === 0) {
return (
<SettingsForm
instance={null}
onSaved={() => onActivated?.()}
hideCancel
submitLabel="Save Connection"
/>
);
}

return (
<div className="space-y-3">
<div className="space-y-2">
{instances.map((inst) => (
<InstanceRow
key={inst.id}
instance={inst}
active={inst.id === activeId}
onActivate={() => {
activate(inst.id);
onActivated?.();
}}
onEdit={() => setMode({ kind: "edit", id: inst.id })}
onDelete={() => remove(inst.id)}
/>
))}
</div>

<Button
type="button"
variant="ghost"
onClick={() => setMode({ kind: "create" })}
className="w-full py-2.5 px-4 rounded-xl flex items-center justify-center gap-2"
>
<Plus className="w-4 h-4" strokeWidth={1.5} />
Add another instance
</Button>
</div>
);
}

interface InstanceRowProps {
instance: Instance;
active: boolean;
onActivate: () => void;
onEdit: () => void;
onDelete: () => void;
}

function InstanceRow({ instance, active, onActivate, onEdit, onDelete }: InstanceRowProps) {
const [confirmingDelete, setConfirmingDelete] = useState(false);

return (
<motion.div
layout
className="rounded-xl p-3 flex items-center gap-3"
style={{
background: active ? "var(--accent-dim)" : "var(--bg-2)",
border: `1px solid ${active ? "var(--accent-border)" : "var(--border)"}`,
}}
>
<button
type="button"
onClick={onActivate}
className="flex-1 flex items-center gap-3 text-left"
disabled={active}
title={active ? "Active instance" : "Switch to this instance"}
>
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{
background: active ? "var(--accent)" : "var(--surface)",
color: active ? "white" : "var(--text-3)",
}}
>
{active ? (
<Check className="w-4 h-4" strokeWidth={2} />
) : (
<Server className="w-4 h-4" strokeWidth={1.5} />
)}
</div>
<div className="min-w-0 flex-1">
<p
className="text-sm font-medium truncate"
style={{ color: active ? "var(--accent-text)" : "var(--text-1)" }}
>
{instance.name}
</p>
<Muted className="text-xs font-mono truncate">
{instance.baseUrl.replace(/^https?:\/\//, "")}
</Muted>
</div>
</button>

<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onEdit}
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
style={{ color: "var(--text-3)" }}
title="Edit"
>
<Pencil className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
{confirmingDelete ? (
<button
type="button"
onClick={() => {
onDelete();
setConfirmingDelete(false);
}}
className="text-xs font-medium px-2 py-1 rounded-md"
style={{ color: COLOR.destructive, border: `1px solid ${COLOR.destructive}` }}
>
Confirm
</button>
) : (
<button
type="button"
onClick={() => setConfirmingDelete(true)}
className="w-7 h-7 rounded-md flex items-center justify-center transition-colors"
style={{ color: "var(--text-3)" }}
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
)}
</div>
</motion.div>
);
}
Loading
Loading