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:
Khalid Abdi
2026-06-26 21:34:30 +03:00
parent 2454bb4c2b
commit 403e0e38e9
12 changed files with 425 additions and 13 deletions
+5 -2
View File
@@ -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 . .
+2
View File
@@ -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>
+95 -3
View File
@@ -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>
);
+66
View File
@@ -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>
);
}
+5 -2
View File
@@ -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 -2
View File
@@ -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 })],
+22
View File
@@ -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";
}
+26 -1
View File
@@ -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…",
+3 -2
View File
@@ -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"],
});
+23
View File
@@ -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");
}