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
+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>
);
}