mirror of
https://github.com/temetro/temetro.git
synced 2026-08-27 19:06:52 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user