Merge branch 'main' into feat/deep-linking
This commit is contained in:
@@ -2,7 +2,9 @@ import { Link, useMatchRoute } from "@tanstack/react-router";
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Boxes,
|
Boxes,
|
||||||
|
Check,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronsUpDown,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -10,9 +12,10 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
import { loadConfig } from "@/lib/config";
|
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -23,9 +26,22 @@ const navItems = [
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const matchRoute = useMatchRoute();
|
const matchRoute = useMatchRoute();
|
||||||
const config = loadConfig();
|
const { instances, active, activate } = useInstances();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const { demo, toggle: toggleDemo, mask } = useDemo();
|
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 (
|
return (
|
||||||
<motion.aside
|
<motion.aside
|
||||||
@@ -58,14 +74,81 @@ export function Sidebar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{config && (
|
{active && (
|
||||||
<p
|
<div ref={switcherRef} className="relative mt-2 hidden sm:block">
|
||||||
className="text-xs mt-2 truncate font-mono hidden sm:block"
|
<button
|
||||||
style={{ color: "var(--text-4)" }}
|
type="button"
|
||||||
title={mask(config.baseUrl)}
|
onClick={() => setSwitcherOpen((v) => !v)}
|
||||||
>
|
className="w-full flex items-center gap-1.5 px-2 py-1.5 rounded-md text-left transition-colors"
|
||||||
{mask(config.baseUrl.replace(/^https?:\/\//, ""))}
|
style={{
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
178
packages/web/src/components/settings/InstancesManager.tsx
Normal file
178
packages/web/src/components/settings/InstancesManager.tsx
Normal file
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,18 +5,21 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input, Textarea } from "@/components/ui/input";
|
import { Input, Textarea } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Muted } from "@/components/ui/typography";
|
import { Muted } from "@/components/ui/typography";
|
||||||
import {
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
type Config,
|
import { checkConnection, type HealthStatus, type Instance, instanceSchema } from "@/lib/config";
|
||||||
checkConnection,
|
|
||||||
configSchema,
|
|
||||||
type HealthStatus,
|
|
||||||
loadConfig,
|
|
||||||
saveConfig,
|
|
||||||
} from "@/lib/config";
|
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
|
||||||
interface SettingsFormProps {
|
interface SettingsFormProps {
|
||||||
onSaved?: () => void;
|
/** Instance to edit; pass `null` to create a new one. */
|
||||||
|
instance: Instance | null;
|
||||||
|
/** Called after a successful save. Receives the saved instance id. */
|
||||||
|
onSaved?: (id: string) => void;
|
||||||
|
/** Called when the user cancels (only meaningful when there's something to cancel back to). */
|
||||||
|
onCancel?: () => void;
|
||||||
|
/** Hide the cancel button. */
|
||||||
|
hideCancel?: boolean;
|
||||||
|
/** Override the submit button label. */
|
||||||
|
submitLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
@@ -26,15 +29,25 @@ const statusConfig = {
|
|||||||
checking: { icon: Loader, color: COLOR.accentText, label: "Checking..." },
|
checking: { icon: Loader, color: COLOR.accentText, label: "Checking..." },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsForm({ onSaved }: SettingsFormProps) {
|
export function SettingsForm({
|
||||||
const existing = loadConfig();
|
instance,
|
||||||
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "http://localhost:8000");
|
onSaved,
|
||||||
const [token, setToken] = useState(existing?.token ?? "");
|
onCancel,
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof Config, string>>>({});
|
hideCancel,
|
||||||
|
submitLabel,
|
||||||
|
}: SettingsFormProps) {
|
||||||
|
const { add, update, activate } = useInstances();
|
||||||
|
|
||||||
|
const [name, setName] = useState(instance?.name ?? "");
|
||||||
|
const [baseUrl, setBaseUrl] = useState(instance?.baseUrl ?? "http://localhost:8000");
|
||||||
|
const [token, setToken] = useState(instance?.token ?? "");
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof Instance, string>>>({});
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null);
|
const [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
|
||||||
|
const isCreate = instance === null;
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
setHealth({ status: "checking", message: "Connecting..." });
|
setHealth({ status: "checking", message: "Connecting..." });
|
||||||
@@ -49,22 +62,46 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
|
|
||||||
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const result = configSchema.safeParse({ baseUrl, token });
|
const candidate = {
|
||||||
|
id: instance?.id ?? "placeholder",
|
||||||
|
name: name.trim() || "Default",
|
||||||
|
baseUrl,
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
const result = instanceSchema.safeParse(candidate);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const fieldErrors: typeof errors = {};
|
const fieldErrors: typeof errors = {};
|
||||||
for (const issue of result.error.issues) {
|
for (const issue of result.error.issues) {
|
||||||
const key = issue.path[0] as keyof Config;
|
const key = issue.path[0] as keyof Instance;
|
||||||
fieldErrors[key] = issue.message;
|
fieldErrors[key] = issue.message;
|
||||||
}
|
}
|
||||||
setErrors(fieldErrors);
|
setErrors(fieldErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setErrors({});
|
setErrors({});
|
||||||
saveConfig(result.data);
|
|
||||||
|
let id: string;
|
||||||
|
if (isCreate) {
|
||||||
|
const created = add({
|
||||||
|
name: result.data.name,
|
||||||
|
baseUrl: result.data.baseUrl,
|
||||||
|
token: result.data.token,
|
||||||
|
});
|
||||||
|
activate(created.id);
|
||||||
|
id = created.id;
|
||||||
|
} else {
|
||||||
|
update(instance.id, {
|
||||||
|
name: result.data.name,
|
||||||
|
baseUrl: result.data.baseUrl,
|
||||||
|
token: result.data.token,
|
||||||
|
});
|
||||||
|
id = instance.id;
|
||||||
|
}
|
||||||
|
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
onSaved?.();
|
onSaved?.(id);
|
||||||
}, 600);
|
}, 600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +116,24 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
border: "1px solid var(--border)",
|
border: "1px solid var(--border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 text-sm">Instance Name</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Local, Staging, Production"
|
||||||
|
className="rounded-xl"
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-xs mt-1" style={{ color: COLOR.destructive }}>
|
||||||
|
{errors.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Muted className="text-xs mt-1.5">A short label to identify this connection</Muted>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Base URL */}
|
{/* Base URL */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
|
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
|
||||||
@@ -202,14 +257,26 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
type="submit"
|
{!hideCancel && onCancel && (
|
||||||
variant="primary"
|
<Button
|
||||||
className="w-full py-2.5 px-4 rounded-xl"
|
type="button"
|
||||||
style={saved ? { background: "#059669" } : undefined}
|
variant="ghost"
|
||||||
>
|
onClick={onCancel}
|
||||||
{saved ? "✓ Saved" : "Save Connection"}
|
className="py-2.5 px-4 rounded-xl"
|
||||||
</Button>
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
className="flex-1 py-2.5 px-4 rounded-xl"
|
||||||
|
style={saved ? { background: "#059669" } : undefined}
|
||||||
|
>
|
||||||
|
{saved ? "✓ Saved" : (submitLabel ?? (isCreate ? "Add Instance" : "Save Changes"))}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
90
packages/web/src/hooks/useInstances.ts
Normal file
90
packages/web/src/hooks/useInstances.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
import {
|
||||||
|
addInstance as addInstanceCore,
|
||||||
|
deleteInstance as deleteInstanceCore,
|
||||||
|
type Instance,
|
||||||
|
type InstanceStore,
|
||||||
|
loadStore,
|
||||||
|
setActiveInstance as setActiveInstanceCore,
|
||||||
|
updateInstance as updateInstanceCore,
|
||||||
|
} from "@/lib/config";
|
||||||
|
|
||||||
|
const EVENT = "openconcho:instances-changed";
|
||||||
|
|
||||||
|
function emit() {
|
||||||
|
window.dispatchEvent(new Event(EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(cb: () => void): () => void {
|
||||||
|
window.addEventListener(EVENT, cb);
|
||||||
|
window.addEventListener("storage", cb);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(EVENT, cb);
|
||||||
|
window.removeEventListener("storage", cb);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedKey = "";
|
||||||
|
let cachedSnapshot: InstanceStore = { instances: [], activeId: null };
|
||||||
|
|
||||||
|
function getSnapshot(): InstanceStore {
|
||||||
|
const next = loadStore();
|
||||||
|
const key = JSON.stringify(next);
|
||||||
|
if (key !== cachedKey) {
|
||||||
|
cachedKey = key;
|
||||||
|
cachedSnapshot = next;
|
||||||
|
}
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getServerSnapshot(): InstanceStore {
|
||||||
|
return cachedSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInstances() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const store = useSyncExternalStore<InstanceStore>(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
|
||||||
|
const add = useCallback((input: Omit<Instance, "id">) => {
|
||||||
|
const inst = addInstanceCore(input);
|
||||||
|
emit();
|
||||||
|
return inst;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = useCallback((id: string, patch: Partial<Omit<Instance, "id">>) => {
|
||||||
|
updateInstanceCore(id, patch);
|
||||||
|
emit();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
deleteInstanceCore(id);
|
||||||
|
qc.clear();
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
[qc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activate = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setActiveInstanceCore(id);
|
||||||
|
qc.clear();
|
||||||
|
emit();
|
||||||
|
},
|
||||||
|
[qc],
|
||||||
|
);
|
||||||
|
|
||||||
|
const active = store.instances.find((i) => i.id === store.activeId) ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
instances: store.instances,
|
||||||
|
activeId: store.activeId,
|
||||||
|
active,
|
||||||
|
add,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
activate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { httpFetch } from "@/lib/http";
|
import { httpFetch } from "@/lib/http";
|
||||||
|
|
||||||
const CONFIG_KEY = "openconcho:config";
|
const LEGACY_KEY = "openconcho:config";
|
||||||
|
const STORE_KEY = "openconcho:instances";
|
||||||
|
|
||||||
export const configSchema = z.object({
|
export const configSchema = z.object({
|
||||||
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
||||||
@@ -10,23 +11,127 @@ export const configSchema = z.object({
|
|||||||
|
|
||||||
export type Config = z.infer<typeof configSchema>;
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
|
||||||
export function loadConfig(): Config | null {
|
export const instanceSchema = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
name: z.string().min(1, { message: "Name is required" }),
|
||||||
|
baseUrl: z.string().url({ message: "Must be a valid URL" }),
|
||||||
|
token: z.string().optional().default(""),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Instance = z.infer<typeof instanceSchema>;
|
||||||
|
|
||||||
|
const storeSchema = z.object({
|
||||||
|
instances: z.array(instanceSchema),
|
||||||
|
activeId: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InstanceStore = z.infer<typeof storeSchema>;
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `inst_${Math.random().toString(36).slice(2)}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacy(): InstanceStore | null {
|
||||||
|
const raw = localStorage.getItem(LEGACY_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(CONFIG_KEY);
|
const parsed = configSchema.parse(JSON.parse(raw));
|
||||||
if (!raw) return null;
|
const inst: Instance = {
|
||||||
const parsed = JSON.parse(raw);
|
id: newId(),
|
||||||
return configSchema.parse(parsed);
|
name: "Default",
|
||||||
|
baseUrl: parsed.baseUrl,
|
||||||
|
token: parsed.token,
|
||||||
|
};
|
||||||
|
const store: InstanceStore = { instances: [inst], activeId: inst.id };
|
||||||
|
localStorage.setItem(STORE_KEY, JSON.stringify(store));
|
||||||
|
localStorage.removeItem(LEGACY_KEY);
|
||||||
|
return store;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function loadStore(): InstanceStore {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORE_KEY);
|
||||||
|
if (raw) return storeSchema.parse(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
const migrated = migrateLegacy();
|
||||||
|
if (migrated) return migrated;
|
||||||
|
return { instances: [], activeId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveStore(store: InstanceStore): void {
|
||||||
|
localStorage.setItem(STORE_KEY, JSON.stringify(store));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveInstance(): Instance | null {
|
||||||
|
const store = loadStore();
|
||||||
|
if (!store.activeId) return null;
|
||||||
|
return store.instances.find((i) => i.id === store.activeId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): Config | null {
|
||||||
|
const active = getActiveInstance();
|
||||||
|
if (!active) return null;
|
||||||
|
return { baseUrl: active.baseUrl, token: active.token ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backwards-compatible single-instance save: replaces or creates a "Default" instance. */
|
||||||
export function saveConfig(config: Config): void {
|
export function saveConfig(config: Config): void {
|
||||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
const store = loadStore();
|
||||||
|
if (store.activeId) {
|
||||||
|
const idx = store.instances.findIndex((i) => i.id === store.activeId);
|
||||||
|
if (idx >= 0) {
|
||||||
|
store.instances[idx] = { ...store.instances[idx], ...config };
|
||||||
|
saveStore(store);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inst: Instance = { id: newId(), name: "Default", ...config };
|
||||||
|
saveStore({ instances: [...store.instances, inst], activeId: inst.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearConfig(): void {
|
export function clearConfig(): void {
|
||||||
localStorage.removeItem(CONFIG_KEY);
|
localStorage.removeItem(STORE_KEY);
|
||||||
|
localStorage.removeItem(LEGACY_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addInstance(input: Omit<Instance, "id">): Instance {
|
||||||
|
const store = loadStore();
|
||||||
|
const inst: Instance = { id: newId(), ...input };
|
||||||
|
const next: InstanceStore = {
|
||||||
|
instances: [...store.instances, inst],
|
||||||
|
activeId: store.activeId ?? inst.id,
|
||||||
|
};
|
||||||
|
saveStore(next);
|
||||||
|
return inst;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInstance(id: string, patch: Partial<Omit<Instance, "id">>): void {
|
||||||
|
const store = loadStore();
|
||||||
|
const idx = store.instances.findIndex((i) => i.id === id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
store.instances[idx] = { ...store.instances[idx], ...patch };
|
||||||
|
saveStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteInstance(id: string): void {
|
||||||
|
const store = loadStore();
|
||||||
|
const remaining = store.instances.filter((i) => i.id !== id);
|
||||||
|
const activeId = store.activeId === id ? (remaining[0]?.id ?? null) : store.activeId;
|
||||||
|
saveStore({ instances: remaining, activeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setActiveInstance(id: string): void {
|
||||||
|
const store = loadStore();
|
||||||
|
if (!store.instances.some((i) => i.id === id)) return;
|
||||||
|
saveStore({ ...store, activeId: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
export type HealthStatus = "ok" | "auth-required" | "unreachable" | "checking";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { SettingsForm } from "@/components/settings/SettingsForm";
|
import { InstancesManager } from "@/components/settings/InstancesManager";
|
||||||
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
|
|
||||||
export const Route = createFileRoute("/settings")({
|
export const Route = createFileRoute("/settings")({
|
||||||
component: SettingsPage,
|
component: SettingsPage,
|
||||||
@@ -8,6 +9,8 @@ export const Route = createFileRoute("/settings")({
|
|||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { instances } = useInstances();
|
||||||
|
const isFirstRun = instances.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -31,10 +34,12 @@ function SettingsPage() {
|
|||||||
OpenConcho
|
OpenConcho
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm mt-1" style={{ color: "var(--text-3)" }}>
|
<p className="text-sm mt-1" style={{ color: "var(--text-3)" }}>
|
||||||
Connect to your self-hosted Honcho instance
|
{isFirstRun
|
||||||
|
? "Connect to your self-hosted Honcho instance"
|
||||||
|
: "Manage your Honcho connections"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<SettingsForm onSaved={() => navigate({ to: "/" as never })} />
|
<InstancesManager onActivated={() => navigate({ to: "/" as never })} />
|
||||||
<p className="text-xs text-center mt-4" style={{ color: "var(--text-4)" }}>
|
<p className="text-xs text-center mt-4" style={{ color: "var(--text-4)" }}>
|
||||||
Connection details are stored locally on this device only
|
Connection details are stored locally on this device only
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user