mirror of
https://github.com/temetro/temetro.git
synced 2026-08-06 17:07:40 +00:00
frontend: runtime backend URL (LAN), voice dictation, version + update UI
Resolve the backend URL from the current host at runtime (lib/backend-url.ts) so one build works on localhost and any clinic LAN IP without a rebuild; used by the API client, auth client, and socket. Wire the AI-chat mic to the Web Speech API with graceful fallback. Add a Settings "About & updates" panel (current/latest version, update command, shareable network address) and an optional dismissible update banner. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+5
-2
@@ -13,8 +13,11 @@ RUN npm ci --no-audit --no-fund \
|
||||
# --- build (Next.js standalone output) -------------------------------------
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
# NEXT_PUBLIC_* values are inlined at build time.
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
# NEXT_PUBLIC_* values are inlined at build time. We deliberately leave the API
|
||||
# URL EMPTY by default: the app derives the backend URL from the browser host at
|
||||
# runtime (see lib/backend-url.ts), so one prebuilt image works on localhost and
|
||||
# any clinic LAN IP. Set NEXT_PUBLIC_API_URL only to pin a fixed/proxied URL.
|
||||
ARG NEXT_PUBLIC_API_URL=
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CommandPaletteProvider } from "@/components/command-palette";
|
||||
import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar";
|
||||
import { MobileSidebarTrigger } from "@/components/sidebar-02/mobile-sidebar-trigger";
|
||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { UpdateBanner } from "@/components/update-banner";
|
||||
|
||||
export default function AppLayout({
|
||||
children,
|
||||
@@ -17,6 +18,7 @@ export default function AppLayout({
|
||||
<DashboardSidebar />
|
||||
<MobileSidebarTrigger />
|
||||
{children}
|
||||
<UpdateBanner />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</CommandPaletteProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -25,7 +26,37 @@ type ChatInputProps = {
|
||||
};
|
||||
|
||||
const iconButton =
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40";
|
||||
|
||||
// Minimal Web Speech API typings — not in this TS lib.dom. We only use a slice.
|
||||
interface SpeechRecognitionEventLike {
|
||||
readonly results: {
|
||||
readonly length: number;
|
||||
[index: number]: { readonly [index: number]: { transcript: string } };
|
||||
};
|
||||
}
|
||||
interface SpeechRecognitionLike {
|
||||
lang: string;
|
||||
interimResults: boolean;
|
||||
continuous: boolean;
|
||||
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
}
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
|
||||
|
||||
// Web Speech API lives under a vendor prefix in Chromium-based browsers and is
|
||||
// absent in others (e.g. Firefox). Resolve the constructor or null.
|
||||
function getSpeechRecognition(): SpeechRecognitionCtor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as typeof window & {
|
||||
SpeechRecognition?: SpeechRecognitionCtor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
const pillButton =
|
||||
"flex h-8 items-center gap-1.5 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const contextPill =
|
||||
@@ -47,6 +78,53 @@ export function ChatInput({
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Voice dictation (Web Speech API). Detected client-side so SSR markup and the
|
||||
// first client render agree (button starts disabled, enabled by the effect).
|
||||
const [speechSupported, setSpeechSupported] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
// The textarea contents when dictation started; transcript is appended to it.
|
||||
const dictationBaseRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
setSpeechSupported(getSpeechRecognition() !== null);
|
||||
return () => recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
if (isListening) {
|
||||
recognitionRef.current?.stop();
|
||||
return;
|
||||
}
|
||||
const Recognition = getSpeechRecognition();
|
||||
if (!Recognition) return;
|
||||
|
||||
const recognition = new Recognition();
|
||||
recognitionRef.current = recognition;
|
||||
recognition.lang = navigator.language || "en-US";
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
// Continue from where the text leaves off, with a separating space.
|
||||
dictationBaseRef.current = value ? `${value.replace(/\s*$/, "")} ` : "";
|
||||
|
||||
recognition.onresult = (event) => {
|
||||
let transcript = "";
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i]?.[0]?.transcript ?? "";
|
||||
}
|
||||
setValue(dictationBaseRef.current + transcript);
|
||||
};
|
||||
const end = () => {
|
||||
setIsListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
recognition.onend = end;
|
||||
recognition.onerror = end;
|
||||
|
||||
recognition.start();
|
||||
setIsListening(true);
|
||||
}, [isListening, value]);
|
||||
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canSend =
|
||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||
@@ -183,8 +261,22 @@ export function ChatInput({
|
||||
triggerClassName={cn(pillButton, "mr-1")}
|
||||
/>
|
||||
<button
|
||||
aria-label={t("chat.input.dictate")}
|
||||
className={iconButton}
|
||||
aria-label={
|
||||
isListening ? t("chat.input.dictateStop") : t("chat.input.dictate")
|
||||
}
|
||||
aria-pressed={isListening}
|
||||
className={cn(
|
||||
iconButton,
|
||||
isListening &&
|
||||
"animate-pulse bg-destructive/10 text-destructive hover:bg-destructive/15 hover:text-destructive"
|
||||
)}
|
||||
disabled={!speechSupported}
|
||||
onClick={toggleDictation}
|
||||
title={
|
||||
speechSupported
|
||||
? t("chat.input.dictate")
|
||||
: t("chat.input.dictateUnsupported")
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Mic className="size-[18px]" />
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
CopyField,
|
||||
SettingsCard,
|
||||
SettingsSection,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { getNetworkInfo, getVersionInfo, type VersionInfo } from "@/lib/version";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Self-hosted update path: pull the new images and restart on the server.
|
||||
const UPDATE_COMMAND = "docker compose pull && docker compose up -d";
|
||||
|
||||
function isLocalHost(hostname: string): boolean {
|
||||
return (
|
||||
hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "ok" | "update" | "muted";
|
||||
children: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-xs font-medium",
|
||||
tone === "update"
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [info, setInfo] = useState<VersionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [networkUrls, setNetworkUrls] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getVersionInfo()
|
||||
.then(setInfo)
|
||||
.catch(() => setInfo(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// The most reliable shareable URL is the one the browser is already using —
|
||||
// unless that's localhost, in which case we fall back to the backend's
|
||||
// detected LAN addresses (accurate when not running in Docker's bridge net).
|
||||
const localShareUrl = useMemo(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (isLocalHost(window.location.hostname)) return null;
|
||||
return `${window.location.protocol}//${window.location.host}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (localShareUrl) return;
|
||||
getNetworkInfo()
|
||||
.then((n) => setNetworkUrls(n.urls))
|
||||
.catch(() => setNetworkUrls([]));
|
||||
}, [localShareUrl]);
|
||||
|
||||
const statusBadge = loading ? (
|
||||
<Badge tone="muted">{t("settings.version.checking")}</Badge>
|
||||
) : !info || info.latest === null ? (
|
||||
<Badge tone="muted">{t("settings.version.offline")}</Badge>
|
||||
) : info.updateAvailable ? (
|
||||
<Badge tone="update">{t("settings.version.updateAvailable")}</Badge>
|
||||
) : (
|
||||
<Badge tone="ok">{t("settings.version.upToDate")}</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<SettingsSection
|
||||
title={t("settings.version.title")}
|
||||
description={t("settings.version.description")}
|
||||
>
|
||||
<SettingsCard className="space-y-4 p-5">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.version.current")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{info?.current ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
{statusBadge}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border pt-4">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{t("settings.version.latest")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{loading
|
||||
? t("settings.version.checking")
|
||||
: (info?.latest ?? t("settings.version.offline"))}
|
||||
</p>
|
||||
</div>
|
||||
{info?.releaseUrl ? (
|
||||
<a
|
||||
className="shrink-0 text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
href={info.releaseUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{t("settings.version.viewRelease")}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t("settings.version.updateTitle")}
|
||||
description={t("settings.version.updateDescription")}
|
||||
>
|
||||
<SettingsCard className="p-5">
|
||||
<CopyField
|
||||
label={t("settings.version.updateCommandLabel")}
|
||||
value={UPDATE_COMMAND}
|
||||
/>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title={t("settings.version.networkTitle")}
|
||||
description={t("settings.version.networkDescription")}
|
||||
>
|
||||
<SettingsCard className="space-y-4 p-5">
|
||||
{localShareUrl ? (
|
||||
<CopyField
|
||||
label={t("settings.version.networkUrlLabel")}
|
||||
value={localShareUrl}
|
||||
/>
|
||||
) : networkUrls.length > 0 ? (
|
||||
networkUrls.map((url) => (
|
||||
<CopyField
|
||||
key={url}
|
||||
label={t("settings.version.networkUrlLabel")}
|
||||
value={url}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.version.networkLocalHint")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.version.networkFirewallHint")}
|
||||
</p>
|
||||
</SettingsCard>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { DevelopersPanel } from "@/components/settings/settings-developers";
|
||||
import { IntegrationsPanel } from "@/components/settings/settings-integrations";
|
||||
import { ProfilePanel } from "@/components/settings/settings-preferences";
|
||||
import { RecordsPanel } from "@/components/settings/settings-records";
|
||||
import { VersionPanel } from "@/components/settings/settings-version";
|
||||
import { useActiveRole } from "@/lib/roles";
|
||||
|
||||
const TABS = [
|
||||
@@ -22,10 +23,12 @@ const TABS = [
|
||||
{ id: "careTeam", labelKey: "settings.tabs.careTeam" },
|
||||
{ id: "integrations", labelKey: "settings.tabs.integrations" },
|
||||
{ id: "developers", labelKey: "settings.tabs.developers" },
|
||||
{ id: "version", labelKey: "settings.tabs.version" },
|
||||
] as const;
|
||||
|
||||
// Per-user tabs every clinician sees; the rest are clinic-wide (admin only).
|
||||
const PERSONAL_TABS = ["profile", "ai"];
|
||||
// "version" (about / updates / network access) is useful to everyone.
|
||||
const PERSONAL_TABS = ["profile", "ai", "version"];
|
||||
|
||||
type Tab = (typeof TABS)[number]["id"];
|
||||
|
||||
@@ -81,6 +84,7 @@ export function SettingsView() {
|
||||
)}
|
||||
{activeTab === "integrations" && <IntegrationsPanel />}
|
||||
{activeTab === "developers" && <DevelopersPanel />}
|
||||
{activeTab === "version" && <VersionPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { getVersionInfo } from "@/lib/version";
|
||||
|
||||
const DISMISS_KEY = "temetro:update-dismissed";
|
||||
|
||||
// A small, dismissible notice shown when a newer temetro release exists. Updating
|
||||
// is optional — dismissing remembers the version so we don't nag again until the
|
||||
// next release. Errors are swallowed (offline / private deployments).
|
||||
export function UpdateBanner() {
|
||||
const { t } = useTranslation();
|
||||
const [latest, setLatest] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getVersionInfo()
|
||||
.then((info) => {
|
||||
if (!active || !info.updateAvailable || !info.latest) return;
|
||||
if (localStorage.getItem(DISMISS_KEY) === info.latest) return;
|
||||
setLatest(info.latest);
|
||||
})
|
||||
.catch(() => {
|
||||
/* offline or no update server — stay silent */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!latest) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(DISMISS_KEY, latest);
|
||||
setLatest(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 bottom-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 shadow-lg">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm text-foreground">
|
||||
{t("settings.version.banner", { version: latest })}
|
||||
</p>
|
||||
<Link
|
||||
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
href="/settings?tab=version"
|
||||
onClick={dismiss}
|
||||
>
|
||||
{t("settings.version.bannerUpdate")}
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
aria-label={t("settings.version.bannerDismiss")}
|
||||
className="-mr-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={dismiss}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
// Thin fetch wrapper for the temetro backend's non-auth API (patients, …).
|
||||
// Auth itself goes through the Better Auth client (lib/auth-client.ts).
|
||||
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
// Derived from the current host in the browser (see lib/backend-url.ts), so the
|
||||
// app works over localhost and the clinic LAN without a per-deployment rebuild.
|
||||
export const API_BASE_URL = resolveBackendUrl();
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import { ac, roles } from "@/lib/access";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
// The backend (Express + Better Auth) is a separate origin. The client appends
|
||||
// `/api/auth` to this base URL and always sends credentials so the session
|
||||
// cookie set by the backend is included on every request.
|
||||
// cookie set by the backend is included on every request. The base URL is
|
||||
// derived from the current host (see lib/backend-url.ts) so login works over
|
||||
// localhost and the clinic LAN alike.
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000",
|
||||
baseURL: resolveBackendUrl(),
|
||||
// usernameClient enables signIn.username(...) so admin-provisioned staff can
|
||||
// log in with a username; organizationClient powers clinics + RBAC.
|
||||
plugins: [usernameClient(), organizationClient({ ac, roles })],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Resolves the backend's base URL at runtime instead of baking it in at build.
|
||||
//
|
||||
// temetro ships as a prebuilt image and is reached from many hosts — the
|
||||
// server's own machine (localhost) and other departments over the LAN (the
|
||||
// host's IP, e.g. http://192.168.1.20:3000). A URL baked at build time can't
|
||||
// serve all of them, so in the browser we derive the backend URL from the page
|
||||
// the user actually loaded (same hostname, the backend's port). Set
|
||||
// NEXT_PUBLIC_API_URL to override for custom/reverse-proxied deployments.
|
||||
|
||||
const ENV_URL = process.env.NEXT_PUBLIC_API_URL?.trim();
|
||||
// Backend port; override only if you publish the API on a non-default port.
|
||||
const API_PORT = process.env.NEXT_PUBLIC_API_PORT?.trim() || "4000";
|
||||
|
||||
/** The backend origin to call from the current context. */
|
||||
export function resolveBackendUrl(): string {
|
||||
if (ENV_URL) return ENV_URL;
|
||||
if (typeof window !== "undefined") {
|
||||
return `${window.location.protocol}//${window.location.hostname}:${API_PORT}`;
|
||||
}
|
||||
// Server-side fallback (SSR/build); real browser calls use the branch above.
|
||||
return "http://localhost:4000";
|
||||
}
|
||||
@@ -1032,6 +1032,8 @@
|
||||
"attachFiles": "Attach files",
|
||||
"removeFile": "Remove {{name}}",
|
||||
"dictate": "Dictate",
|
||||
"dictateStop": "Stop dictation",
|
||||
"dictateUnsupported": "Voice dictation isn't supported in this browser",
|
||||
"send": "Send",
|
||||
"stop": "Stop",
|
||||
"model": "Model",
|
||||
@@ -1485,7 +1487,30 @@
|
||||
"signing": "Signing",
|
||||
"careTeam": "Care team",
|
||||
"integrations": "Integrations",
|
||||
"developers": "Developers"
|
||||
"developers": "Developers",
|
||||
"version": "About & updates"
|
||||
},
|
||||
"version": {
|
||||
"title": "Version",
|
||||
"description": "The temetro version running on this server, and whether a newer release is available.",
|
||||
"current": "Current version",
|
||||
"latest": "Latest release",
|
||||
"checking": "Checking for updates…",
|
||||
"upToDate": "Up to date",
|
||||
"updateAvailable": "Update available",
|
||||
"offline": "Couldn't reach the update server",
|
||||
"updateTitle": "How to update",
|
||||
"updateDescription": "temetro is self-hosted with Docker. To update, pull the new images and restart on the server machine.",
|
||||
"updateCommandLabel": "Update command",
|
||||
"viewRelease": "View release notes",
|
||||
"networkTitle": "Network access",
|
||||
"networkDescription": "Let other departments open temetro from their own computers on the hospital network using this address.",
|
||||
"networkUrlLabel": "Network address",
|
||||
"networkLocalHint": "You're viewing temetro locally. To share it, open the app on the server machine using its network IP address (e.g. from ipconfig / ifconfig), then share that URL.",
|
||||
"networkFirewallHint": "The server's firewall must allow the port for other devices to connect.",
|
||||
"banner": "A new version of temetro ({{version}}) is available.",
|
||||
"bannerUpdate": "See how to update",
|
||||
"bannerDismiss": "Later"
|
||||
},
|
||||
"integrations": {
|
||||
"loading": "Loading integrations…",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api-client";
|
||||
import { resolveBackendUrl } from "@/lib/backend-url";
|
||||
|
||||
// A single shared Socket.io connection to the backend, authenticated by the
|
||||
// Better Auth session cookie (withCredentials). Used by messaging and
|
||||
@@ -9,7 +9,8 @@ let socket: Socket | null = null;
|
||||
|
||||
export function getSocket(): Socket {
|
||||
if (!socket) {
|
||||
socket = io(API_BASE_URL, {
|
||||
// Resolved lazily in the browser so it targets the host actually in use.
|
||||
socket = io(resolveBackendUrl(), {
|
||||
withCredentials: true,
|
||||
transports: ["websocket", "polling"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// Client helpers for the backend's version + network endpoints (both public).
|
||||
import { apiFetch } from "@/lib/api-client";
|
||||
|
||||
export type VersionInfo = {
|
||||
current: string;
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
releaseUrl: string | null;
|
||||
};
|
||||
|
||||
export type NetworkInfo = {
|
||||
port: number;
|
||||
addresses: string[];
|
||||
urls: string[];
|
||||
};
|
||||
|
||||
export function getVersionInfo(): Promise<VersionInfo> {
|
||||
return apiFetch<VersionInfo>("/api/version");
|
||||
}
|
||||
|
||||
export function getNetworkInfo(): Promise<NetworkInfo> {
|
||||
return apiFetch<NetworkInfo>("/api/network");
|
||||
}
|
||||
Reference in New Issue
Block a user