feat: support multiple Honcho instances (closes #2)
Replace single localStorage config with a named-instance store
({ instances: Instance[], activeId }). Adds an instances manager on
the settings page (list / add / edit / delete / activate) and a
sidebar switcher for quick swaps. Existing single-config users are
migrated transparently on first load.
Switching or deleting an instance clears the TanStack Query cache so
data from another deployment never bleeds into the active view.
This commit is contained in:
@@ -2,7 +2,9 @@ import { Link, useMatchRoute } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Boxes,
|
||||
Check,
|
||||
ChevronRight,
|
||||
ChevronsUpDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LayoutDashboard,
|
||||
@@ -10,9 +12,10 @@ import {
|
||||
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 = [
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
{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)}
|
||||
>
|
||||
{mask(config.baseUrl.replace(/^https?:\/\//, ""))}
|
||||
<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>
|
||||
|
||||
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Muted } from "@/components/ui/typography";
|
||||
import {
|
||||
type Config,
|
||||
checkConnection,
|
||||
configSchema,
|
||||
type HealthStatus,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
} from "@/lib/config";
|
||||
import { useInstances } from "@/hooks/useInstances";
|
||||
import { checkConnection, type HealthStatus, type Instance, instanceSchema } from "@/lib/config";
|
||||
import { COLOR } from "@/lib/constants";
|
||||
|
||||
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 = {
|
||||
@@ -26,15 +29,25 @@ const statusConfig = {
|
||||
checking: { icon: Loader, color: COLOR.accentText, label: "Checking..." },
|
||||
};
|
||||
|
||||
export function SettingsForm({ onSaved }: SettingsFormProps) {
|
||||
const existing = loadConfig();
|
||||
const [baseUrl, setBaseUrl] = useState(existing?.baseUrl ?? "http://localhost:8000");
|
||||
const [token, setToken] = useState(existing?.token ?? "");
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof Config, string>>>({});
|
||||
export function SettingsForm({
|
||||
instance,
|
||||
onSaved,
|
||||
onCancel,
|
||||
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 [health, setHealth] = useState<{ status: HealthStatus; message: string } | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
const isCreate = instance === null;
|
||||
|
||||
async function handleTest() {
|
||||
setChecking(true);
|
||||
setHealth({ status: "checking", message: "Connecting..." });
|
||||
@@ -49,22 +62,46 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
||||
|
||||
function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
|
||||
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) {
|
||||
const fieldErrors: typeof errors = {};
|
||||
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;
|
||||
}
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
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);
|
||||
setTimeout(() => {
|
||||
setSaved(false);
|
||||
onSaved?.();
|
||||
onSaved?.(id);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
@@ -79,6 +116,24 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
||||
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 */}
|
||||
<div>
|
||||
<Label className="mb-1.5 text-sm">Honcho Base URL</Label>
|
||||
@@ -202,14 +257,26 @@ export function SettingsForm({ onSaved }: SettingsFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!hideCancel && onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="py-2.5 px-4 rounded-xl"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full py-2.5 px-4 rounded-xl"
|
||||
className="flex-1 py-2.5 px-4 rounded-xl"
|
||||
style={saved ? { background: "#059669" } : undefined}
|
||||
>
|
||||
{saved ? "✓ Saved" : "Save Connection"}
|
||||
{saved ? "✓ Saved" : (submitLabel ?? (isCreate ? "Add Instance" : "Save Changes"))}
|
||||
</Button>
|
||||
</div>
|
||||
</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 { httpFetch } from "@/lib/http";
|
||||
|
||||
const CONFIG_KEY = "openconcho:config";
|
||||
const LEGACY_KEY = "openconcho:config";
|
||||
const STORE_KEY = "openconcho:instances";
|
||||
|
||||
export const configSchema = z.object({
|
||||
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 function loadConfig(): Config | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_KEY);
|
||||
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;
|
||||
const parsed = JSON.parse(raw);
|
||||
return configSchema.parse(parsed);
|
||||
try {
|
||||
const parsed = configSchema.parse(JSON.parse(raw));
|
||||
const inst: Instance = {
|
||||
id: newId(),
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
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")({
|
||||
component: SettingsPage,
|
||||
@@ -8,6 +9,8 @@ export const Route = createFileRoute("/settings")({
|
||||
|
||||
function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { instances } = useInstances();
|
||||
const isFirstRun = instances.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,10 +34,12 @@ function SettingsPage() {
|
||||
OpenConcho
|
||||
</h1>
|
||||
<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>
|
||||
</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)" }}>
|
||||
Connection details are stored locally on this device only
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user