diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..83c7961 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +.next +.git +.env +.env.local +npm-debug.log* +.DS_Store +Dockerfile +.dockerignore diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..5486d61 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,3 @@ +# Base URL of the temetro backend (Express + Better Auth). The Better Auth +# client appends /api/auth; the patient API lives under /api/patients. +NEXT_PUBLIC_API_URL=http://localhost:4000 diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..7b8da95 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/frontend/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 0000000..dff7749 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,151 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +@AGENTS.md + +> The import above is load-bearing: this is a **customized Next.js 16** with breaking changes vs. +> public docs. Before writing any Next.js code (routing, metadata, `next/image`, `next/font`, +> route handlers, config), read the relevant guide under `node_modules/next/dist/docs/01-app/`. + +## What this is + +`temetro` — an **open-source** clinical "AI middleman" (see the project vision in the root +`../CLAUDE.md`). This app is the clinician-facing chat UI. It is now **wired to the real +`../backend/`** for authentication and patient data (no longer a standalone UI-only demo); the AI +chat replies are **still mocked** (no LLM call yet). + +- The chat parses `/patient ` (or a bare `/`) in `components/chat/chat-panel.tsx` and + renders the record as a horizontal row of cards (`components/chat/patient-cards.tsx`), with small + dependency-free trend sparklines (`components/chat/sparkline.tsx`). Each card opens a detail + dialog; the Summary card has an **Edit record** button. +- Patients can be **created and edited** via the shared `components/chat/patient-form-dialog.tsx` + (mode `create` | `edit`). The "Add patient" pill in `chat-input.tsx` opens it. +- Non-command messages still get a mock assistant reply. + +### Auth & data (talks to `../backend`) + +- **`lib/patients.ts`** keeps the canonical `Patient` types but its data functions (`getPatient`, + `listPatients`, `createPatient`, `updatePatient`) now call the backend via **`lib/api-client.ts`** + (`fetch` with `credentials: "include"`; 401 → `/login`). The old in-memory fixture is gone. +- **`lib/auth-client.ts`** — Better Auth React client (`useSession`, `signIn/up/out`, + `organization.*`, `useActiveOrganization`, `useListOrganizations`). Base URL from + `NEXT_PUBLIC_API_URL` (default `http://localhost:4000`); it appends `/api/auth`. **`lib/access.ts`** + mirrors the backend's RBAC roles for the org client. +- **`app/(auth)/`** — designed auth pages (login, signup, verify-email, forgot/reset-password, + onboarding = create clinic, accept-invite), in their own chrome-less layout. +- **Route protection:** this Next renames `middleware` → **`proxy.ts`** (root) — an optimistic + redirect to `/login` when no session cookie. The authoritative gate is client-side: + `components/auth/app-auth-guard.tsx` (wrapping `app/(app)/layout.tsx`) requires a session **and** + an active clinic, else redirects to `/login` / `/onboarding`. +- **Clinics (organizations):** `components/sidebar-02/team-switcher.tsx` is now the `OrgSwitcher`; + `components/settings/settings-care-team.tsx` manages members + invitations. +- `.env.local` / `.env.example` hold `NEXT_PUBLIC_API_URL`. + +The signing / patient-owned-storage / approval features from the root vision are **still not built**. + +## Commands + +```bash +npm run dev # Next dev server (Turbopack) on http://localhost:3000 +npm run build # production build (runs a full TypeScript typecheck — see caveat below) +npm run start # serve the production build +npm run lint # eslint (eslint-config-next: core-web-vitals + typescript) +``` + +There is **no test runner** configured (no `test` script, no vitest/jest/playwright). Don't invent +test commands; verify changes by running the dev server. + +**Version control:** this folder is its own git repo. Commit after every change +(`git -C . add -A && git commit`), with the `Co-Authored-By: Claude Opus 4.8 ` +trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit policy. + +## Architecture + +- **Stack:** Next.js 16.2.6 (App Router) · React 19 · TypeScript · Tailwind CSS **v4**. Path alias + `@/*` → repo root (`tsconfig.json`). `cn()` (clsx + tailwind-merge) lives in `lib/utils.ts`. +- **`app/`** — App Router. `app/(app)/page.tsx` is the product chat page (sidebar + chat panel); + `app/layout.tsx` loads the fonts and wraps children in `ThemeProvider` (next-themes) + + `I18nProvider` (see Theming and i18n below). +- **`components/ui/`** — **COSS components** (built on **Base UI `@base-ui/react`, not Radix**), + installed via the shadcn CLI from the `@coss/*` registry. APIs follow Base UI: composition uses + the `render` prop and `useRender`/`mergeProps`, **not `asChild`**, and popups use canonical COSS + names (`DialogPopup`/`DialogPanel`, `MenuPopup`, `SelectPopup`, `TooltipPopup`, `PreviewCard`, + `Group`) — COSS also ships back-compat aliases (`DialogContent`, `CardContent`, `SelectContent`, + `TooltipContent`, …). Add/update primitives with `npx shadcn@latest add @coss/`. + `carousel.tsx` is the one **non-COSS** primitive kept (no COSS equivalent). Config in + `components.json` (baseColor `neutral`). +- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`, + `Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`, + `ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state + is managed with local React state. +- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` / + `SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav config (New chat + · Patients · Settings) + notifications; `team-switcher.tsx` is the `OrgSwitcher` (clinic switch). +- **`components/chat/`** — the product chat UI. `chat-panel.tsx` owns message state + empty/active + layouts; `chat-input.tsx` is a bespoke (non–ai-elements) input matching a specific design. + +## Theming + +Tailwind v4 with `@theme inline` in `app/globals.css`, using **COSS's default neutral tokens** +(`@coss/colors-neutral`; values reference Tailwind palette vars like `--color-neutral-*` plus +`--alpha()`/`color-mix()`). Both **light (`:root`) and dark (`.dark`)** palettes are defined; +`next-themes` (`components/theme-provider.tsx`) toggles them with **`defaultTheme="dark"`** + +`enableSystem`, so the app still defaults to dark. The **theme toggle** is the _Theme_ item in the +sidebar-footer user menu (`components/sidebar-02/nav-user.tsx`, via `useTheme()`). Fonts follow the +COSS variable contract (`--font-sans`, `--font-heading` = Inter; `--font-mono` = Geist Mono). The +radius scale (`rounded-2xl` … `rounded-4xl`) is derived from `--radius`, so those utilities are +larger than stock Tailwind. + +**Always use semantic tokens** (`bg-muted`, `bg-input`, `border-border`, `text-muted-foreground`, +…), never hardcoded `oklch`/hex colors — the latter break the light theme. Dark surface tokens are +subtle alphas (`--muted`/`--secondary`/`--accent` ≈ white/4%, `--input` ≈ white/8%, `--border` ≈ +white/6%), so layered surfaces stay close in lightness. + +## i18n + +`i18next` + `react-i18next` (config in `lib/i18n/config.ts`, English resources in +`lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in +`app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`) +in **client** components. To add a language, drop a `locales//translation.json` and register it +in `resources`/`supportedLngs` in `config.ts`. Auth forms, sidebar nav, and settings tabs are +converted as the reference pattern; other strings can be migrated incrementally. + +## Gotchas + +- **Route protection lives in `proxy.ts`, not `middleware.ts`** — this customized Next renamed the + convention (`export function proxy(request)` + `config.matcher`, Node runtime). See + `node_modules/next/dist/docs/01-app/.../proxy.md`. +- `components/ai-elements/*` has **pre-existing type/lint errors** (Base UI drift) that used to fail + `next build`. `next.config.ts` now sets `output: "standalone"` plus + `typescript.ignoreBuildErrors` / `eslint.ignoreDuringBuilds` so production/Docker builds succeed — + **type-check app code with `npx tsc --noEmit`** (filter out `components/ai-elements/`), not via + `next build`. There's a `Dockerfile` for the standalone build. +- **`lucide-react@1.17` dropped brand glyphs** (e.g. `Github`, `Discord`) — import them and you get + a build error. Use inline SVGs instead. +- A sibling **`../landing-page/`** directory is a copy of this app with a marketing landing page + (`components/landing/`); it is a separate project, not part of this git repo. +- Multiple lockfiles in the tree produce a harmless Turbopack "inferred workspace root" warning. + +### COSS composition gotchas (hit during the shadcn→COSS migration) + +- **`MenuGroupLabel` must live inside `` (or ``)** — there is no standalone + `MenuLabel`. Using it bare throws `MenuGroupContext is missing` at runtime. +- **`DialogPopup`/`SheetPopup` have no inner padding** — padding comes from + `DialogHeader`/`DialogPanel`/`DialogFooter`. For a form dialog: keep the header outside the form, + wrap **panel + footer** in `
`, and put the body in `DialogPanel` + (see `components/chat/patient-form-dialog.tsx`). Content placed directly in the popup is flush. +- **`Card` has no `size` prop** and **`Avatar` has no `size` prop**. For compact cards, override the + section padding via data-slot selectors (`compactCard` in `patient-cards.tsx`); for avatars use a + `size-*` class. +- **Solid destructive buttons:** use `variant="destructive"`, not the default variant + a `bg-*` + override — the default variant keeps `border-primary`, which is near-white in dark mode. +- **COSS `Field` is `items-start`** (children don't stretch). Add `w-full` to buttons/rows meant to + fill the field. There is **no `FieldGroup`** (use a flex column) and **no `InputGroupButton`** + (use `Button`). +- COSS keeps back-compat aliases (`DialogContent`, `CardContent` → `CardPanel`, `SelectContent`, + `TooltipContent`, `PopoverContent`, `SheetContent`), so old imports still resolve — but prefer the + canonical `*Popup`/`*Panel` names in new/edited code. +- The vendored `ai-elements/*` were repointed to COSS (live `conversation`/`message` fully migrated; + dormant files via aliased imports). Their remaining `tsc` errors are **pre-existing Base UI drift**, + not regressions — they stay behind `ignoreBuildErrors`. diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..72af667 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +# --- deps ------------------------------------------------------------------ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +# --- 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 +ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# --- runtime --------------------------------------------------------------- +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +COPY --from=build /app/public ./public +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/app/(app)/activity/page.tsx b/frontend/app/(app)/activity/page.tsx new file mode 100644 index 0000000..c0b3f2f --- /dev/null +++ b/frontend/app/(app)/activity/page.tsx @@ -0,0 +1,10 @@ +import { ActivityView } from "@/components/activity/activity-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function ActivityPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/analysis/page.tsx b/frontend/app/(app)/analysis/page.tsx new file mode 100644 index 0000000..739134d --- /dev/null +++ b/frontend/app/(app)/analysis/page.tsx @@ -0,0 +1,10 @@ +import { AnalysisView } from "@/components/analysis/analysis-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function AnalysisPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/appointments/page.tsx b/frontend/app/(app)/appointments/page.tsx new file mode 100644 index 0000000..695e757 --- /dev/null +++ b/frontend/app/(app)/appointments/page.tsx @@ -0,0 +1,10 @@ +import { AppointmentsView } from "@/components/appointments/appointments-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function AppointmentsPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/layout.tsx b/frontend/app/(app)/layout.tsx new file mode 100644 index 0000000..b797bd8 --- /dev/null +++ b/frontend/app/(app)/layout.tsx @@ -0,0 +1,23 @@ +import { AppAuthGuard } from "@/components/auth/app-auth-guard"; +import { CommandPaletteProvider } from "@/components/command-palette"; +import { DashboardSidebar } from "@/components/sidebar-02/app-sidebar"; +import { SidebarProvider } from "@/components/ui/sidebar"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+ + {children} +
+
+
+
+ ); +} diff --git a/frontend/app/(app)/messages/page.tsx b/frontend/app/(app)/messages/page.tsx new file mode 100644 index 0000000..0ab9e5a --- /dev/null +++ b/frontend/app/(app)/messages/page.tsx @@ -0,0 +1,10 @@ +import { MessagesView } from "@/components/messages/messages-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function MessagesPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/notes/page.tsx b/frontend/app/(app)/notes/page.tsx new file mode 100644 index 0000000..dd4450a --- /dev/null +++ b/frontend/app/(app)/notes/page.tsx @@ -0,0 +1,10 @@ +import { NotesView } from "@/components/notes/notes-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function NotesPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/page.tsx b/frontend/app/(app)/page.tsx new file mode 100644 index 0000000..7b145a2 --- /dev/null +++ b/frontend/app/(app)/page.tsx @@ -0,0 +1,15 @@ +import { Suspense } from "react"; + +import { SidebarInset } from "@/components/ui/sidebar"; +import { ChatPanel } from "@/components/chat/chat-panel"; + +export default function Home() { + return ( + + {/* ChatPanel reads the `?patient=` search param, so it needs a Suspense boundary. */} + + + + + ); +} diff --git a/frontend/app/(app)/patients/page.tsx b/frontend/app/(app)/patients/page.tsx new file mode 100644 index 0000000..cee76a5 --- /dev/null +++ b/frontend/app/(app)/patients/page.tsx @@ -0,0 +1,10 @@ +import { SidebarInset } from "@/components/ui/sidebar"; +import { PatientsView } from "@/components/patients/patients-view"; + +export default function PatientsPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/prescriptions/page.tsx b/frontend/app/(app)/prescriptions/page.tsx new file mode 100644 index 0000000..cc4c1cd --- /dev/null +++ b/frontend/app/(app)/prescriptions/page.tsx @@ -0,0 +1,10 @@ +import { PrescriptionsView } from "@/components/prescriptions/prescriptions-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function PrescriptionsPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/settings/page.tsx b/frontend/app/(app)/settings/page.tsx new file mode 100644 index 0000000..6c392a4 --- /dev/null +++ b/frontend/app/(app)/settings/page.tsx @@ -0,0 +1,10 @@ +import { SidebarInset } from "@/components/ui/sidebar"; +import { SettingsView } from "@/components/settings/settings-view"; + +export default function SettingsPage() { + return ( + + + + ); +} diff --git a/frontend/app/(app)/tasks/page.tsx b/frontend/app/(app)/tasks/page.tsx new file mode 100644 index 0000000..c67eca5 --- /dev/null +++ b/frontend/app/(app)/tasks/page.tsx @@ -0,0 +1,10 @@ +import { TasksView } from "@/components/tasks/tasks-view"; +import { SidebarInset } from "@/components/ui/sidebar"; + +export default function TasksPage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/accept-invite/page.tsx b/frontend/app/(auth)/accept-invite/page.tsx new file mode 100644 index 0000000..aaa6ddb --- /dev/null +++ b/frontend/app/(auth)/accept-invite/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useState } from "react"; + +import { AuthShell, FormAlert } from "@/components/auth/auth-ui"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; + +function AcceptInviteInner() { + const router = useRouter(); + const params = useSearchParams(); + const invitationId = params.get("id") ?? ""; + const { data: session, isPending } = authClient.useSession(); + const [error, setError] = useState(null); + const [accepting, setAccepting] = useState(false); + + const accept = async () => { + if (!invitationId || accepting) return; + setAccepting(true); + setError(null); + const { data, error: err } = await authClient.organization.acceptInvitation({ + invitationId, + }); + if (err || !data) { + setError(err?.message ?? "This invitation is invalid or has expired."); + setAccepting(false); + return; + } + const orgId = data.invitation?.organizationId; + if (orgId) { + await authClient.organization.setActive({ organizationId: orgId }); + } + router.push("/"); + }; + + if (!invitationId) { + return ( + + This invitation link is missing or invalid. + + ); + } + + // Must be signed in (with the invited email) to accept. + if (!isPending && !session?.user) { + const back = `/accept-invite?id=${encodeURIComponent(invitationId)}`; + return ( + +
+ + +

+ After signing in, reopen{" "} + + this invitation link + + . +

+
+
+ ); + } + + return ( + +
+ {error && {error}} + +
+
+ ); +} + +export default function AcceptInvitePage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/forgot-password/page.tsx b/frontend/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..c3b421e --- /dev/null +++ b/frontend/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,76 @@ +"use client"; + +import Link from "next/link"; +import { type FormEvent, useState } from "react"; + +import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [sent, setSent] = useState(false); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting) return; + setSubmitting(true); + setError(null); + + const { error: err } = await authClient.requestPasswordReset({ + email: email.trim(), + redirectTo: `${window.location.origin}/reset-password`, + }); + + setSubmitting(false); + if (err) { + const message = err.message ?? "Could not send the reset email."; + setError(message); + notify.error("Couldn't send reset link", message); + return; + } + notify.success("Reset link sent", "Check your inbox for the link."); + setSent(true); + }; + + return ( + + Back to sign in + + } + subtitle="We'll email you a link to reset your password" + title="Reset your password" + > + {sent ? ( + + If an account exists for {email}, a reset link is on its way. Check + your inbox. + + ) : ( + + {error && {error}} + + setEmail(e.target.value)} + placeholder="you@clinic.org" + required + type="email" + value={email} + /> + + + + )} + + ); +} diff --git a/frontend/app/(auth)/layout.tsx b/frontend/app/(auth)/layout.tsx new file mode 100644 index 0000000..431930a --- /dev/null +++ b/frontend/app/(auth)/layout.tsx @@ -0,0 +1,8 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + // No app chrome (sidebar) on auth screens — just a scrollable centered area. + return
{children}
; +} diff --git a/frontend/app/(auth)/login/page.tsx b/frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000..67e43f8 --- /dev/null +++ b/frontend/app/(auth)/login/page.tsx @@ -0,0 +1,10 @@ +import { AuthLayout } from "@/components/auth/auth-ui"; +import { LoginForm } from "@/components/login-form"; + +export default function LoginPage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/onboarding/page.tsx b/frontend/app/(auth)/onboarding/page.tsx new file mode 100644 index 0000000..36412ce --- /dev/null +++ b/frontend/app/(auth)/onboarding/page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; + +import { AuthShell } from "@/components/auth/auth-ui"; +import { CreateClinicForm } from "@/components/clinic/create-clinic-form"; +import { authClient } from "@/lib/auth-client"; + +export default function OnboardingPage() { + const router = useRouter(); + const { data: session, isPending } = authClient.useSession(); + + // Send unauthenticated users to login. Authenticated users (whether brand + // new or creating an additional clinic) stay on this page. + useEffect(() => { + if (isPending) return; + if (!session?.user) router.replace("/login"); + }, [session, isPending, router]); + + return ( + + router.push("/")} /> + + ); +} diff --git a/frontend/app/(auth)/reset-password/page.tsx b/frontend/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..7259e56 --- /dev/null +++ b/frontend/app/(auth)/reset-password/page.tsx @@ -0,0 +1,118 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { type FormEvent, Suspense, useState } from "react"; + +import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { authClient } from "@/lib/auth-client"; +import { notify } from "@/lib/toast"; + +const MIN_PASSWORD = 12; + +function ResetPasswordInner() { + const router = useRouter(); + const params = useSearchParams(); + const token = params.get("token") ?? ""; + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const onSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting) return; + setError(null); + + if (!token) { + setError("This reset link is invalid or has expired."); + return; + } + if (password.length < MIN_PASSWORD) { + setError(`Password must be at least ${MIN_PASSWORD} characters.`); + return; + } + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + + setSubmitting(true); + const { error: err } = await authClient.resetPassword({ + newPassword: password, + token, + }); + setSubmitting(false); + if (err) { + const message = err.message ?? "Could not reset your password."; + setError(message); + notify.error("Couldn't reset password", message); + return; + } + notify.success("Password updated", "Redirecting you to sign in…"); + setDone(true); + setTimeout(() => router.push("/login"), 1500); + }; + + return ( + + Back to sign in + + } + subtitle="Choose a new password for your account" + title="Set a new password" + > + {done ? ( + + Your password has been reset. Redirecting you to sign in… + + ) : ( +
+ {error && {error}} + + setPassword(e.target.value)} + placeholder="••••••••••••" + required + type="password" + value={password} + /> + + + setConfirm(e.target.value)} + placeholder="••••••••••••" + required + type="password" + value={confirm} + /> + + +
+ )} +
+ ); +} + +export default function ResetPasswordPage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/signup/page.tsx b/frontend/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..dc43a9a --- /dev/null +++ b/frontend/app/(auth)/signup/page.tsx @@ -0,0 +1,10 @@ +import { AuthLayout } from "@/components/auth/auth-ui"; +import { SignupForm } from "@/components/signup-form"; + +export default function SignupPage() { + return ( + + + + ); +} diff --git a/frontend/app/(auth)/verify-email/page.tsx b/frontend/app/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..3aa4f83 --- /dev/null +++ b/frontend/app/(auth)/verify-email/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; + +import { AuthShell, FormAlert } from "@/components/auth/auth-ui"; +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; + +function VerifyEmailInner() { + const router = useRouter(); + const params = useSearchParams(); + const email = params.get("email") ?? ""; + const { data: session } = authClient.useSession(); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(null); + const [sending, setSending] = useState(false); + + // After the emailed link verifies the address, Better Auth auto-signs the + // user in and redirects here — at which point a session exists. + useEffect(() => { + if (session?.user) router.replace("/"); + }, [session, router]); + + const resend = async () => { + if (!email || sending) return; + setSending(true); + setError(null); + setNotice(null); + const { error: err } = await authClient.sendVerificationEmail({ + email, + callbackURL: `${window.location.origin}/verify-email`, + }); + setSending(false); + if (err) { + setError(err.message ?? "Could not resend the verification email."); + return; + } + setNotice("Verification email sent. Check your inbox."); + }; + + return ( + + Back to sign in + + } + subtitle={ + email ? ( + <> + We sent a verification link to{" "} + {email}. Open it to activate + your account. + + ) : ( + "Open the verification link we emailed you to activate your account." + ) + } + title="Check your inbox" + > +
+ {notice && {notice}} + {error && {error}} + + {email && ( + + )} +
+
+ ); +} + +export default function VerifyEmailPage() { + return ( + + + + ); +} diff --git a/frontend/app/apple-icon.png b/frontend/app/apple-icon.png new file mode 100644 index 0000000..a778694 Binary files /dev/null and b/frontend/app/apple-icon.png differ diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..82ec521 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,315 @@ +@import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn/tailwind.css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-heading: var(--font-heading); + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-border: var(--border); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); + --color-warning-foreground: var(--warning-foreground); + --color-warning: var(--warning); + --color-success-foreground: var(--success-foreground); + --color-success: var(--success); + --color-info-foreground: var(--info-foreground); + --color-info: var(--info); + --color-destructive-foreground: var(--destructive-foreground); + --animate-skeleton: skeleton 2s -1s infinite linear; + @keyframes skeleton { + to { + background-position: -200% 0; + } + } + --animate-toast-success-odd: toast-success-odd 0.32s cubic-bezier(0.5, 1, 0.89, 1); + --animate-toast-success-even: toast-success-even 0.32s cubic-bezier(0.5, 1, 0.89, 1); + --animate-toast-error-odd: toast-error-odd 0.28s cubic-bezier(0.5, 1, 0.89, 1); + --animate-toast-error-even: toast-error-even 0.28s cubic-bezier(0.5, 1, 0.89, 1) +; + @keyframes toast-success-odd { + 0% { + scale: 1;} + 30% { + scale: 1.025;} + 60% { + scale: 0.99;} + 100% { + scale: 1;}} + @keyframes toast-error-odd { + 0% { + translate: 0 0;} + 25% { + translate: -3px 0;} + 50% { + translate: 3px 0;} + 75% { + translate: -3px 0;} + 100% { + translate: 0 0;}} + @keyframes toast-success-even { + 0% { + scale: 1;} + 30% { + scale: 1.025;} + 60% { + scale: 0.99;} + 100% { + scale: 1;}} + @keyframes toast-error-even { + 0% { + translate: 0 0;} + 25% { + translate: -3px 0;} + 50% { + translate: 3px 0;} + 75% { + translate: -3px 0;} + 100% { + translate: 0 0;}}} + +:root { + --card: var(--color-white); + --card-foreground: var(--color-neutral-800); + --popover: var(--color-white); + --popover-foreground: var(--color-neutral-800); + --primary: var(--color-neutral-800); + --primary-foreground: var(--color-neutral-50); + --secondary: --alpha(var(--color-black) / 4%); + --secondary-foreground: var(--color-neutral-800); + --muted: --alpha(var(--color-black) / 4%); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-black)); + --accent: --alpha(var(--color-black) / 4%); + --accent-foreground: var(--color-neutral-800); + --destructive: var(--color-red-500); + --border: --alpha(var(--color-black) / 8%); + --input: --alpha(var(--color-black) / 10%); + --ring: var(--color-neutral-400); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --radius: 0.875rem; + --sidebar: var(--color-neutral-50); + --sidebar-foreground: var(--color-neutral-800); + --sidebar-primary: var(--color-neutral-800); + --sidebar-primary-foreground: var(--color-neutral-50); + --sidebar-accent: --alpha(var(--color-black) / 4%); + --sidebar-accent-foreground: var(--color-neutral-800); + --sidebar-border: --alpha(var(--color-black) / 8%); + --sidebar-ring: var(--color-neutral-400); + --background: var(--color-white); + --foreground: var(--color-neutral-800); + --destructive-foreground: var(--color-red-700); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-700); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-700); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-700); +} + +/* COSS neutral dark palette: near-black canvas, neutral accent, hairline borders. */ +.dark { + --background: color-mix(in srgb, var(--color-neutral-950) 95%, var(--color-white)); + --foreground: var(--color-neutral-100); + --card: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --card-foreground: var(--color-neutral-100); + --popover: color-mix(in srgb, var(--background) 98%, var(--color-white)); + --popover-foreground: var(--color-neutral-100); + --primary: var(--color-neutral-100); + --primary-foreground: var(--color-neutral-800); + --secondary: --alpha(var(--color-white) / 4%); + --secondary-foreground: var(--color-neutral-100); + --muted: --alpha(var(--color-white) / 4%); + --muted-foreground: color-mix(in srgb, var(--color-neutral-500) 90%, var(--color-white)); + --accent: --alpha(var(--color-white) / 4%); + --accent-foreground: var(--color-neutral-100); + --destructive: color-mix(in srgb, var(--color-red-500) 90%, var(--color-white)); + --border: --alpha(var(--color-white) / 6%); + --input: --alpha(var(--color-white) / 8%); + --ring: var(--color-neutral-500); + --chart-1: oklch(0.809 0.105 251.813); + --chart-2: oklch(0.623 0.214 259.815); + --chart-3: oklch(0.546 0.245 262.881); + --chart-4: oklch(0.488 0.243 264.376); + --chart-5: oklch(0.424 0.199 265.638); + --sidebar: var(--color-neutral-950); + --sidebar-foreground: var(--color-neutral-100); + --sidebar-primary: var(--color-neutral-100); + --sidebar-primary-foreground: var(--color-neutral-800); + --sidebar-accent: --alpha(var(--color-white) / 4%); + --sidebar-accent-foreground: var(--color-neutral-100); + --sidebar-border: --alpha(var(--color-white) / 6%); + --sidebar-ring: var(--color-neutral-500); + --destructive-foreground: var(--color-red-400); + --info: var(--color-blue-500); + --info-foreground: var(--color-blue-400); + --success: var(--color-emerald-500); + --success-foreground: var(--color-emerald-400); + --warning: var(--color-amber-500); + --warning-foreground: var(--color-amber-400); +} + +@layer base { + * { + @apply border-border outline-ring/50; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--foreground) 20%, transparent) transparent; + } + *::-webkit-scrollbar { + width: 8px; + height: 8px; + } + *::-webkit-scrollbar-track { + background: transparent; + } + *::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--foreground) 20%, transparent); + border-radius: 9999px; + } + *::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--foreground) 32%, transparent); + } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +/* + * Rich-text styling for the Notes Tiptap editor. The app has no typography + * plugin, and Tailwind's preflight resets headings/lists — so without this, + * H1/H2/lists render identically to body text. Scoped to `.note-content` + * (set on the editor's contenteditable) and themed via semantic tokens. + */ +.note-content { + @apply text-foreground; + font-size: 0.95rem; + line-height: 1.7; +} +.note-content:focus { + outline: none; +} +.note-content > :first-child { + margin-top: 0; +} +.note-content p { + margin: 0.5rem 0; +} +.note-content h1 { + @apply font-heading font-bold; + font-size: 1.6rem; + line-height: 1.25; + margin: 1.2rem 0 0.6rem; +} +.note-content h2 { + @apply font-heading font-semibold; + font-size: 1.3rem; + line-height: 1.3; + margin: 1rem 0 0.5rem; +} +.note-content h3 { + @apply font-heading font-semibold; + font-size: 1.1rem; + margin: 0.9rem 0 0.4rem; +} +.note-content ul, +.note-content ol { + margin: 0.5rem 0; + padding-left: 1.5rem; +} +.note-content ul { + list-style: disc; +} +.note-content ol { + list-style: decimal; +} +.note-content li { + margin: 0.2rem 0; +} +.note-content li > p { + margin: 0; +} +.note-content blockquote { + @apply border-border text-muted-foreground; + border-left-width: 3px; + margin: 0.75rem 0; + padding-left: 1rem; +} +.note-content a { + @apply text-primary underline underline-offset-2; +} +.note-content strong { + font-weight: 600; +} +.note-content code { + @apply bg-muted font-mono; + border-radius: 0.375rem; + font-size: 0.85em; + padding: 0.1rem 0.35rem; +} +.note-content pre { + @apply bg-muted font-mono; + border-radius: 0.6rem; + margin: 0.75rem 0; + overflow-x: auto; + padding: 0.75rem 1rem; +} +.note-content pre code { + background: transparent; + padding: 0; +} +.note-content hr { + @apply border-border; + border-top-width: 1px; + margin: 1.25rem 0; +} +/* Tiptap placeholder (empty document) */ +.note-content p.is-editor-empty:first-child::before { + @apply text-muted-foreground; + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} \ No newline at end of file diff --git a/frontend/app/icon.png b/frontend/app/icon.png new file mode 100644 index 0000000..cc911a9 Binary files /dev/null and b/frontend/app/icon.png differ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..6b533a1 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,60 @@ +import type { Metadata } from "next"; +import { Geist_Mono, Inter } from "next/font/google"; +import "./globals.css"; +import { cn } from "@/lib/utils"; +import { ThemeProvider } from "@/components/theme-provider"; +import { I18nProvider } from "@/components/i18n-provider"; +import { ToastProvider } from "@/components/ui/toast"; + +// COSS font-variable contract: --font-sans, --font-heading, --font-mono. +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); +const interHeading = Inter({ subsets: ["latin"], variable: "--font-heading" }); +const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" }); + +export const metadata: Metadata = { + title: "temetro — AI assistant for clinicians", + description: + "Retrieve patient information by simply asking. The open-source AI assistant for clinicians.", + // Icons come from the app/icon.png + app/apple-icon.png file conventions. +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {/* suppressHydrationWarning: next-themes sets the theme class on + before hydration, and browser extensions (e.g. ColorZilla's + cz-shortcut-listen) mutate . Only ignores attribute diffs on + those elements, not their children. */} + + + + {children} + + + + + ); +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..9521df1 --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "base-luma", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "menuColor": "default", + "menuAccent": "subtle", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@blocks-so": "https://blocks.so/r/{name}.json", + "@coss": "https://coss.com/ui/r/{name}.json" + } +} diff --git a/frontend/components/activity/activity-view.tsx b/frontend/components/activity/activity-view.tsx new file mode 100644 index 0000000..8376aea --- /dev/null +++ b/frontend/components/activity/activity-view.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { + Clock, + FileText, + Hash, + type LucideIcon, + NotebookPen, + Pill, + ShieldCheck, + Stethoscope, + TriangleAlert, +} from "lucide-react"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +// All entries here are mock/placeholder data — there is no signing/ledger +// backend yet. They illustrate temetro's patient-owned, signed-change vision: +// every record change is signed (blockchain-style) and awaits patient approval. + +type ActivityStatus = "signed" | "pending"; + +type ActivityEntry = { + id: string; + actor: string; + initials: string; + action: string; + patient: string; + fileNumber: string; + time: string; + hash: string; + status: ActivityStatus; + icon: LucideIcon; +}; + +const entries: ActivityEntry[] = [ + { + id: "1", + actor: "Dr. Okafor", + initials: "DO", + action: "Updated vitals", + patient: "Amina Yusuf", + fileNumber: "10293", + time: "Today, 10:24", + hash: "0x9f3a…c21", + status: "pending", + icon: Stethoscope, + }, + { + id: "2", + actor: "Dr. Okafor", + initials: "DO", + action: "Added prescription — Lisinopril 10mg", + patient: "Amina Yusuf", + fileNumber: "10293", + time: "Today, 10:21", + hash: "0x4b8e…7df", + status: "pending", + icon: Pill, + }, + { + id: "3", + actor: "Dr. Stein", + initials: "DS", + action: "Created note — Lab review", + patient: "Leila Haddad", + fileNumber: "10342", + time: "Today, 09:48", + hash: "0x1c07…a90", + status: "signed", + icon: NotebookPen, + }, + { + id: "4", + actor: "Dr. Stein", + initials: "DS", + action: "Edited allergies — added Penicillin", + patient: "Daniel Mensah", + fileNumber: "10311", + time: "Yesterday, 16:05", + hash: "0xab12…44e", + status: "signed", + icon: TriangleAlert, + }, + { + id: "5", + actor: "Dr. Okafor", + initials: "DO", + action: "Imported prior records", + patient: "Carlos Rivera", + fileNumber: "10358", + time: "Yesterday, 14:30", + hash: "0x77f0…b3c", + status: "signed", + icon: FileText, + }, +]; + +const kpis = [ + { label: "Pending approvals", value: "2", icon: Clock }, + { label: "Signed today", value: "1", icon: ShieldCheck }, + { label: "Changes this week", value: "37", icon: Hash }, +]; + +function Kpi({ + label, + value, + icon: Icon, +}: { + label: string; + value: string; + icon: LucideIcon; +}) { + return ( + +
+ +
+
+ {label} + + {value} + +
+
+ ); +} + +export function ActivityView() { + return ( +
+
+

Activity

+

+ A signed, tamper-evident log of record changes awaiting patient + approval. Sample data. +

+
+ +
+ {kpis.map((k) => ( + + ))} +
+ +
    + {entries.map((entry, i) => { + const Icon = entry.icon; + const isLast = i === entries.length - 1; + return ( +
  1. +
    +
    + +
    + {!isLast &&
    } +
    + +
    +
    + + {entry.action} + + {entry.status === "signed" ? ( + + + Signed + + ) : ( + + + Pending approval + + )} +
    + +
    + + + {entry.initials} + + + + {entry.actor} · {entry.patient} (#{entry.fileNumber}) + +
    + +
    + {entry.time} + + + {entry.hash} + +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/frontend/components/ai-elements/agent.tsx b/frontend/components/ai-elements/agent.tsx new file mode 100644 index 0000000..86dbcc0 --- /dev/null +++ b/frontend/components/ai-elements/agent.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Tool } from "ai"; +import { BotIcon } from "lucide-react"; +import type { ComponentProps } from "react"; +import { memo } from "react"; + +import { CodeBlock } from "./code-block"; + +export type AgentProps = ComponentProps<"div">; + +export const Agent = memo(({ className, ...props }: AgentProps) => ( +
+)); + +export type AgentHeaderProps = ComponentProps<"div"> & { + name: string; + model?: string; +}; + +export const AgentHeader = memo( + ({ className, name, model, ...props }: AgentHeaderProps) => ( +
+
+ + {name} + {model && ( + + {model} + + )} +
+
+ ) +); + +export type AgentContentProps = ComponentProps<"div">; + +export const AgentContent = memo( + ({ className, ...props }: AgentContentProps) => ( +
+ ) +); + +export type AgentInstructionsProps = ComponentProps<"div"> & { + children: string; +}; + +export const AgentInstructions = memo( + ({ className, children, ...props }: AgentInstructionsProps) => ( +
+ + Instructions + +
+

{children}

+
+
+ ) +); + +export type AgentToolsProps = ComponentProps; + +export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => ( +
+ Tools + +
+)); + +export type AgentToolProps = ComponentProps & { + tool: Tool; +}; + +export const AgentTool = memo( + ({ className, tool, value, ...props }: AgentToolProps) => { + const schema = + "jsonSchema" in tool && tool.jsonSchema + ? tool.jsonSchema + : tool.inputSchema; + + return ( + + + {tool.description ?? "No description"} + + +
+ +
+
+
+ ); + } +); + +export type AgentOutputProps = ComponentProps<"div"> & { + schema: string; +}; + +export const AgentOutput = memo( + ({ className, schema, ...props }: AgentOutputProps) => ( +
+ + Output Schema + +
+ +
+
+ ) +); + +Agent.displayName = "Agent"; +AgentHeader.displayName = "AgentHeader"; +AgentContent.displayName = "AgentContent"; +AgentInstructions.displayName = "AgentInstructions"; +AgentTools.displayName = "AgentTools"; +AgentTool.displayName = "AgentTool"; +AgentOutput.displayName = "AgentOutput"; diff --git a/frontend/components/ai-elements/artifact.tsx b/frontend/components/ai-elements/artifact.tsx new file mode 100644 index 0000000..0597d4a --- /dev/null +++ b/frontend/components/ai-elements/artifact.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { LucideIcon } from "lucide-react"; +import { XIcon } from "lucide-react"; +import type { ComponentProps, HTMLAttributes } from "react"; + +export type ArtifactProps = HTMLAttributes; + +export const Artifact = ({ className, ...props }: ArtifactProps) => ( +
+); + +export type ArtifactHeaderProps = HTMLAttributes; + +export const ArtifactHeader = ({ + className, + ...props +}: ArtifactHeaderProps) => ( +
+); + +export type ArtifactCloseProps = ComponentProps; + +export const ArtifactClose = ({ + className, + children, + size = "sm", + variant = "ghost", + ...props +}: ArtifactCloseProps) => ( + +); + +export type ArtifactTitleProps = HTMLAttributes; + +export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => ( +

+); + +export type ArtifactDescriptionProps = HTMLAttributes; + +export const ArtifactDescription = ({ + className, + ...props +}: ArtifactDescriptionProps) => ( +

+); + +export type ArtifactActionsProps = HTMLAttributes; + +export const ArtifactActions = ({ + className, + ...props +}: ArtifactActionsProps) => ( +

+); + +export type ArtifactActionProps = ComponentProps & { + tooltip?: string; + label?: string; + icon?: LucideIcon; +}; + +export const ArtifactAction = ({ + tooltip, + label, + icon: Icon, + children, + className, + size = "sm", + variant = "ghost", + ...props +}: ArtifactActionProps) => { + const button = ( + + ); + + if (tooltip) { + return ( + + + {button} + +

{tooltip}

+
+
+
+ ); + } + + return button; +}; + +export type ArtifactContentProps = HTMLAttributes; + +export const ArtifactContent = ({ + className, + ...props +}: ArtifactContentProps) => ( +
+); diff --git a/frontend/components/ai-elements/attachments.tsx b/frontend/components/ai-elements/attachments.tsx new file mode 100644 index 0000000..6a3c036 --- /dev/null +++ b/frontend/components/ai-elements/attachments.tsx @@ -0,0 +1,426 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + PreviewCard as HoverCard, + PreviewCardPopup as HoverCardContent, + PreviewCardTrigger as HoverCardTrigger, +} from "@/components/ui/preview-card"; +import { cn } from "@/lib/utils"; +import type { FileUIPart, SourceDocumentUIPart } from "ai"; +import { + FileTextIcon, + GlobeIcon, + ImageIcon, + Music2Icon, + PaperclipIcon, + VideoIcon, + XIcon, +} from "lucide-react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; +import { createContext, useCallback, useContext, useMemo } from "react"; + +// ============================================================================ +// Types +// ============================================================================ + +export type AttachmentData = + | (FileUIPart & { id: string }) + | (SourceDocumentUIPart & { id: string }); + +export type AttachmentMediaCategory = + | "image" + | "video" + | "audio" + | "document" + | "source" + | "unknown"; + +export type AttachmentVariant = "grid" | "inline" | "list"; + +const mediaCategoryIcons: Record = { + audio: Music2Icon, + document: FileTextIcon, + image: ImageIcon, + source: GlobeIcon, + unknown: PaperclipIcon, + video: VideoIcon, +}; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export const getMediaCategory = ( + data: AttachmentData +): AttachmentMediaCategory => { + if (data.type === "source-document") { + return "source"; + } + + const mediaType = data.mediaType ?? ""; + + if (mediaType.startsWith("image/")) { + return "image"; + } + if (mediaType.startsWith("video/")) { + return "video"; + } + if (mediaType.startsWith("audio/")) { + return "audio"; + } + if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { + return "document"; + } + + return "unknown"; +}; + +export const getAttachmentLabel = (data: AttachmentData): string => { + if (data.type === "source-document") { + return data.title || data.filename || "Source"; + } + + const category = getMediaCategory(data); + return data.filename || (category === "image" ? "Image" : "Attachment"); +}; + +const renderAttachmentImage = ( + url: string, + filename: string | undefined, + isGrid: boolean +) => + isGrid ? ( + {filename + ) : ( + {filename + ); + +// ============================================================================ +// Contexts +// ============================================================================ + +interface AttachmentsContextValue { + variant: AttachmentVariant; +} + +const AttachmentsContext = createContext(null); + +interface AttachmentContextValue { + data: AttachmentData; + mediaCategory: AttachmentMediaCategory; + onRemove?: () => void; + variant: AttachmentVariant; +} + +const AttachmentContext = createContext(null); + +// ============================================================================ +// Hooks +// ============================================================================ + +export const useAttachmentsContext = () => + useContext(AttachmentsContext) ?? { variant: "grid" as const }; + +export const useAttachmentContext = () => { + const ctx = useContext(AttachmentContext); + if (!ctx) { + throw new Error("Attachment components must be used within "); + } + return ctx; +}; + +// ============================================================================ +// Attachments - Container +// ============================================================================ + +export type AttachmentsProps = HTMLAttributes & { + variant?: AttachmentVariant; +}; + +export const Attachments = ({ + variant = "grid", + className, + children, + ...props +}: AttachmentsProps) => { + const contextValue = useMemo(() => ({ variant }), [variant]); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// Attachment - Item +// ============================================================================ + +export type AttachmentProps = HTMLAttributes & { + data: AttachmentData; + onRemove?: () => void; +}; + +export const Attachment = ({ + data, + onRemove, + className, + children, + ...props +}: AttachmentProps) => { + const { variant } = useAttachmentsContext(); + const mediaCategory = getMediaCategory(data); + + const contextValue = useMemo( + () => ({ data, mediaCategory, onRemove, variant }), + [data, mediaCategory, onRemove, variant] + ); + + return ( + +
+ {children} +
+
+ ); +}; + +// ============================================================================ +// AttachmentPreview - Media preview +// ============================================================================ + +export type AttachmentPreviewProps = HTMLAttributes & { + fallbackIcon?: ReactNode; +}; + +export const AttachmentPreview = ({ + fallbackIcon, + className, + ...props +}: AttachmentPreviewProps) => { + const { data, mediaCategory, variant } = useAttachmentContext(); + + const iconSize = variant === "inline" ? "size-3" : "size-4"; + + const renderIcon = (Icon: typeof ImageIcon) => ( + + ); + + const renderContent = () => { + if (mediaCategory === "image" && data.type === "file" && data.url) { + return renderAttachmentImage(data.url, data.filename, variant === "grid"); + } + + if (mediaCategory === "video" && data.type === "file" && data.url) { + return