From 403e0e38e95711486d0e024596a0e46e3e888f8b Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Fri, 26 Jun 2026 21:34:30 +0300 Subject: [PATCH] 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 --- frontend/Dockerfile | 7 +- frontend/app/(app)/layout.tsx | 2 + frontend/components/chat/chat-input.tsx | 98 +++++++++- .../components/settings/settings-version.tsx | 168 ++++++++++++++++++ .../components/settings/settings-view.tsx | 6 +- frontend/components/update-banner.tsx | 66 +++++++ frontend/lib/api-client.ts | 7 +- frontend/lib/auth-client.ts | 7 +- frontend/lib/backend-url.ts | 22 +++ frontend/lib/i18n/locales/en/translation.json | 27 ++- frontend/lib/socket.ts | 5 +- frontend/lib/version.ts | 23 +++ 12 files changed, 425 insertions(+), 13 deletions(-) create mode 100644 frontend/components/settings/settings-version.tsx create mode 100644 frontend/components/update-banner.tsx create mode 100644 frontend/lib/backend-url.ts create mode 100644 frontend/lib/version.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile index ba4049f..018defe 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 . . diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx index 597f04e..ddcf88e 100644 --- a/frontend/app/(app)/layout.tsx +++ b/frontend/app/(app)/layout.tsx @@ -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({ {children} + diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index fa5071a..e55df19 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -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(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(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")} /> + + ); +} diff --git a/frontend/lib/api-client.ts b/frontend/lib/api-client.ts index ef2d2e8..a00d980 100644 --- a/frontend/lib/api-client.ts +++ b/frontend/lib/api-client.ts @@ -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( diff --git a/frontend/lib/auth-client.ts b/frontend/lib/auth-client.ts index ec215c7..6a403a3 100644 --- a/frontend/lib/auth-client.ts +++ b/frontend/lib/auth-client.ts @@ -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 })], diff --git a/frontend/lib/backend-url.ts b/frontend/lib/backend-url.ts new file mode 100644 index 0000000..48a4910 --- /dev/null +++ b/frontend/lib/backend-url.ts @@ -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"; +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index f6fe1c6..7cc0685 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -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…", diff --git a/frontend/lib/socket.ts b/frontend/lib/socket.ts index 9aea06c..968b9fb 100644 --- a/frontend/lib/socket.ts +++ b/frontend/lib/socket.ts @@ -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"], }); diff --git a/frontend/lib/version.ts b/frontend/lib/version.ts new file mode 100644 index 0000000..e14de82 --- /dev/null +++ b/frontend/lib/version.ts @@ -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 { + return apiFetch("/api/version"); +} + +export function getNetworkInfo(): Promise { + return apiFetch("/api/network"); +}