mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Horizontal patient cards, dark scrollbar, functional input controls
- globals.css: thin dark-themed scrollbar (Firefox + WebKit) so scroll areas no longer show the white default; `no-scrollbar` regions still win. - patient-cards.tsx: render the cards in a horizontal scroll row (each w-80 shrink-0 snap-start), loading skeletons included. - chat-input.tsx: wire the controls — Attach opens a file picker with removable chips; Access / response mode / specialty / facility / time range are real DropdownMenu radio selectors (via a SelectPill helper) that remember the choice. Mic stays visual-only for now. Layout/styling unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -121,6 +121,22 @@
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: oklch(1 0 0 / 18%) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(1 0 0 / 18%);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(1 0 0 / 28%);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
@@ -11,9 +11,24 @@ import {
|
||||
Plus,
|
||||
Square,
|
||||
Stethoscope,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type KeyboardEvent, useCallback, useState } from "react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatInputProps = {
|
||||
@@ -22,6 +37,37 @@ type ChatInputProps = {
|
||||
onStop?: () => void;
|
||||
};
|
||||
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
const ACCESS_OPTIONS: Option[] = [
|
||||
{ value: "standard", label: "Standard access" },
|
||||
{ value: "break-glass", label: "Break-glass (emergency)" },
|
||||
{ value: "read-only", label: "Read-only" },
|
||||
];
|
||||
const RESPONSE_OPTIONS: Option[] = [
|
||||
{ value: "concise", label: "Concise" },
|
||||
{ value: "detailed", label: "Detailed" },
|
||||
{ value: "comprehensive", label: "Comprehensive" },
|
||||
];
|
||||
const SPECIALTY_OPTIONS: Option[] = [
|
||||
{ value: "internal-medicine", label: "Internal Medicine" },
|
||||
{ value: "cardiology", label: "Cardiology" },
|
||||
{ value: "pediatrics", label: "Pediatrics" },
|
||||
{ value: "emergency", label: "Emergency" },
|
||||
{ value: "all", label: "All specialties" },
|
||||
];
|
||||
const FACILITY_OPTIONS: Option[] = [
|
||||
{ value: "main-hospital", label: "Main Hospital" },
|
||||
{ value: "north-clinic", label: "North Clinic" },
|
||||
{ value: "telehealth", label: "Telehealth" },
|
||||
];
|
||||
const TIME_OPTIONS: Option[] = [
|
||||
{ value: "30d", label: "Last 30 days" },
|
||||
{ value: "12m", label: "Last 12 months" },
|
||||
{ value: "5y", label: "Last 5 years" },
|
||||
{ value: "all", label: "All time" },
|
||||
];
|
||||
|
||||
const iconButton =
|
||||
"flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground";
|
||||
const pillButton =
|
||||
@@ -29,19 +75,86 @@ const pillButton =
|
||||
const contextPill =
|
||||
"flex h-7 items-center gap-1.5 rounded-md px-2 text-[13px] text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground";
|
||||
|
||||
function SelectPill({
|
||||
ariaLabel,
|
||||
triggerClassName,
|
||||
chevronClassName,
|
||||
icon,
|
||||
prefix,
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
align = "start",
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
triggerClassName: string;
|
||||
chevronClassName: string;
|
||||
icon: ReactNode;
|
||||
prefix?: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
options: Option[];
|
||||
align?: "start" | "center" | "end";
|
||||
}) {
|
||||
const selected = options.find((option) => option.value === value);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button aria-label={ariaLabel} className={triggerClassName} type="button" />
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
{prefix ? (
|
||||
<span className="font-medium text-foreground">{prefix}</span>
|
||||
) : null}
|
||||
<span className="truncate">{selected?.label}</span>
|
||||
<ChevronDown className={chevronClassName} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align}>
|
||||
<DropdownMenuRadioGroup onValueChange={onValueChange} value={value}>
|
||||
{options.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [access, setAccess] = useState("standard");
|
||||
const [responseMode, setResponseMode] = useState("detailed");
|
||||
const [specialty, setSpecialty] = useState("internal-medicine");
|
||||
const [facility, setFacility] = useState("main-hospital");
|
||||
const [timeRange, setTimeRange] = useState("12m");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canSend = value.trim().length > 0 && !isGenerating;
|
||||
const canSend =
|
||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isGenerating) {
|
||||
if ((!trimmed && files.length === 0) || isGenerating) {
|
||||
return;
|
||||
}
|
||||
onSubmit(trimmed);
|
||||
const parts: string[] = [];
|
||||
if (trimmed) {
|
||||
parts.push(trimmed);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
parts.push(`[Attached: ${files.map((file) => file.name).join(", ")}]`);
|
||||
}
|
||||
onSubmit(parts.join("\n\n"));
|
||||
setValue("");
|
||||
}, [value, isGenerating, onSubmit]);
|
||||
setFiles([]);
|
||||
}, [value, files, isGenerating, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -57,6 +170,22 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
||||
[submit]
|
||||
);
|
||||
|
||||
const handleFilesSelected = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const selected = event.target.files;
|
||||
if (selected && selected.length > 0) {
|
||||
setFiles((prev) => [...prev, ...Array.from(selected)]);
|
||||
}
|
||||
// Reset so picking the same file again still fires onChange.
|
||||
event.target.value = "";
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const removeFile = useCallback((index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
@@ -77,24 +206,69 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
||||
value={value}
|
||||
/>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-3 pb-2">
|
||||
{files.map((file, index) => (
|
||||
<span
|
||||
className="flex items-center gap-1.5 rounded-lg bg-muted px-2 py-1 text-xs text-foreground"
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
>
|
||||
<span className="max-w-40 truncate">{file.name}</span>
|
||||
<button
|
||||
aria-label={`Remove ${file.name}`}
|
||||
className="text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => removeFile(index)}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
aria-label="Attach files"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFilesSelected}
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-3">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<button aria-label="Attach file" className={iconButton} type="button">
|
||||
<button
|
||||
aria-label="Attach file"
|
||||
className={iconButton}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-[18px]" />
|
||||
</button>
|
||||
<button className={pillButton} type="button">
|
||||
<Hand className="size-4" />
|
||||
<span className="truncate">Standard access</span>
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</button>
|
||||
<SelectPill
|
||||
ariaLabel="Access level"
|
||||
chevronClassName="size-4 opacity-70"
|
||||
icon={<Hand className="size-4" />}
|
||||
onValueChange={setAccess}
|
||||
options={ACCESS_OPTIONS}
|
||||
triggerClassName={pillButton}
|
||||
value={access}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button className={cn(pillButton, "mr-1")} type="button">
|
||||
<span className="font-medium text-foreground">Clinical</span>
|
||||
<span>Detailed</span>
|
||||
<ChevronDown className="size-4 opacity-70" />
|
||||
</button>
|
||||
<SelectPill
|
||||
align="end"
|
||||
ariaLabel="Response mode"
|
||||
chevronClassName="size-4 opacity-70"
|
||||
icon={null}
|
||||
onValueChange={setResponseMode}
|
||||
options={RESPONSE_OPTIONS}
|
||||
prefix="Clinical"
|
||||
triggerClassName={cn(pillButton, "mr-1")}
|
||||
value={responseMode}
|
||||
/>
|
||||
<button aria-label="Dictate" className={iconButton} type="button">
|
||||
<Mic className="size-[18px]" />
|
||||
</button>
|
||||
@@ -122,21 +296,33 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
||||
|
||||
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
|
||||
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
|
||||
<button className={contextPill} type="button">
|
||||
<Stethoscope className="size-4" />
|
||||
<span>Internal Medicine</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
<button className={contextPill} type="button">
|
||||
<Building2 className="size-4" />
|
||||
<span>Main Hospital</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
<button className={contextPill} type="button">
|
||||
<CalendarRange className="size-4" />
|
||||
<span>Last 12 months</span>
|
||||
<ChevronDown className="size-3.5 opacity-70" />
|
||||
</button>
|
||||
<SelectPill
|
||||
ariaLabel="Specialty"
|
||||
chevronClassName="size-3.5 opacity-70"
|
||||
icon={<Stethoscope className="size-4" />}
|
||||
onValueChange={setSpecialty}
|
||||
options={SPECIALTY_OPTIONS}
|
||||
triggerClassName={contextPill}
|
||||
value={specialty}
|
||||
/>
|
||||
<SelectPill
|
||||
ariaLabel="Facility"
|
||||
chevronClassName="size-3.5 opacity-70"
|
||||
icon={<Building2 className="size-4" />}
|
||||
onValueChange={setFacility}
|
||||
options={FACILITY_OPTIONS}
|
||||
triggerClassName={contextPill}
|
||||
value={facility}
|
||||
/>
|
||||
<SelectPill
|
||||
ariaLabel="Time range"
|
||||
chevronClassName="size-3.5 opacity-70"
|
||||
icon={<CalendarRange className="size-4" />}
|
||||
onValueChange={setTimeRange}
|
||||
options={TIME_OPTIONS}
|
||||
triggerClassName={contextPill}
|
||||
value={timeRange}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,9 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
|
||||
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
|
||||
|
||||
// Fixed width so the cards sit in a horizontal scroll row instead of squashing.
|
||||
const rowCard = "w-80 shrink-0 snap-start";
|
||||
|
||||
function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
@@ -52,7 +55,7 @@ function SectionLabel({ children }: { children: string }) {
|
||||
|
||||
function SummaryCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<Card size="sm">
|
||||
<Card className={rowCard} size="sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
@@ -79,7 +82,7 @@ function SummaryCard({ patient }: { patient: Patient }) {
|
||||
|
||||
function AllergiesCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<Card size="sm">
|
||||
<Card className={rowCard} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Allergies & alerts</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -127,7 +130,7 @@ function AllergiesCard({ patient }: { patient: Patient }) {
|
||||
|
||||
function MedicationsCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<Card size="sm">
|
||||
<Card className={rowCard} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Medications & problems</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -171,7 +174,7 @@ function VitalsCard({ patient }: { patient: Patient }) {
|
||||
];
|
||||
|
||||
return (
|
||||
<Card size="sm">
|
||||
<Card className={rowCard} size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Vitals, labs & visits</CardTitle>
|
||||
<CardDescription>Vitals taken {vitals.takenAt}</CardDescription>
|
||||
@@ -222,7 +225,7 @@ function VitalsCard({ patient }: { patient: Patient }) {
|
||||
function LoadingCards() {
|
||||
return (
|
||||
<>
|
||||
<Card size="sm">
|
||||
<Card className={rowCard} size="sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
@@ -234,7 +237,7 @@ function LoadingCards() {
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{[0, 1, 2].map((card) => (
|
||||
<Card key={card} size="sm">
|
||||
<Card className={rowCard} key={card} size="sm">
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-44" />
|
||||
</CardHeader>
|
||||
@@ -263,7 +266,7 @@ export function PatientResult({ status, fileNumber, patient }: PatientResultProp
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="flex w-full snap-x items-start gap-4 overflow-x-auto pb-2">
|
||||
{status === "loading" || !patient ? (
|
||||
<LoadingCards />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user