mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
Merge frontend history into monorepo
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# 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.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
@@ -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 <file#>` (or a bare `/<file#>`) 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 <noreply@anthropic.com>`
|
||||
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/<name>`.
|
||||
`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/<lng>/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 `<MenuGroup>` (or `<MenuRadioGroup>`)** — 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 `<form className="contents">`, 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`.
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ActivityView } from "@/components/activity/activity-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function ActivityPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
|
||||
<ActivityView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AnalysisView } from "@/components/analysis/analysis-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function AnalysisPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
|
||||
<AnalysisView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AppointmentsView } from "@/components/appointments/appointments-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
|
||||
<AppointmentsView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AppAuthGuard>
|
||||
<CommandPaletteProvider>
|
||||
<SidebarProvider>
|
||||
<div className="relative flex h-dvh w-full">
|
||||
<DashboardSidebar />
|
||||
{children}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</CommandPaletteProvider>
|
||||
</AppAuthGuard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MessagesView } from "@/components/messages/messages-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function MessagesPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
|
||||
<MessagesView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { NotesView } from "@/components/notes/notes-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function NotesPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
|
||||
<NotesView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* ChatPanel reads the `?patient=` search param, so it needs a Suspense boundary. */}
|
||||
<Suspense>
|
||||
<ChatPanel />
|
||||
</Suspense>
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
import { PatientsView } from "@/components/patients/patients-view";
|
||||
|
||||
export default function PatientsPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
|
||||
<PatientsView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PrescriptionsView } from "@/components/prescriptions/prescriptions-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function PrescriptionsPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
|
||||
<PrescriptionsView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
import { SettingsView } from "@/components/settings/settings-view";
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-y-auto">
|
||||
<SettingsView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { TasksView } from "@/components/tasks/tasks-view";
|
||||
import { SidebarInset } from "@/components/ui/sidebar";
|
||||
|
||||
export default function TasksPage() {
|
||||
return (
|
||||
<SidebarInset className="flex flex-1 flex-col overflow-hidden">
|
||||
<TasksView />
|
||||
</SidebarInset>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<AuthShell title="Invitation not found">
|
||||
<FormAlert>This invitation link is missing or invalid.</FormAlert>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
// Must be signed in (with the invited email) to accept.
|
||||
if (!isPending && !session?.user) {
|
||||
const back = `/accept-invite?id=${encodeURIComponent(invitationId)}`;
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Sign in with the email this invitation was sent to, then return to this link to accept."
|
||||
title="You've been invited"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push("/login")}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push("/signup")}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
After signing in, reopen{" "}
|
||||
<Link className="hover:underline" href={back}>
|
||||
this invitation link
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Join the clinic you were invited to on temetro."
|
||||
title="Accept your invitation"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={accepting}
|
||||
onClick={accept}
|
||||
type="button"
|
||||
>
|
||||
{accepting ? "Joining…" : "Accept invitation"}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AcceptInviteInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
</Link>
|
||||
}
|
||||
subtitle="We'll email you a link to reset your password"
|
||||
title="Reset your password"
|
||||
>
|
||||
{sent ? (
|
||||
<FormAlert tone="success">
|
||||
If an account exists for {email}, a reset link is on its way. Check
|
||||
your inbox.
|
||||
</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="email" label="Email">
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@clinic.org"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Sending…" : "Send reset link"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -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 <main className="flex-1 overflow-y-auto">{children}</main>;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AuthLayout } from "@/components/auth/auth-ui";
|
||||
import { LoginForm } from "@/components/login-form";
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<LoginForm />
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<AuthShell
|
||||
subtitle="Create your clinic to start organizing patient records"
|
||||
title="Set up your clinic"
|
||||
>
|
||||
<CreateClinicForm onCreated={() => router.push("/")} />
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
</Link>
|
||||
}
|
||||
subtitle="Choose a new password for your account"
|
||||
title="Set a new password"
|
||||
>
|
||||
{done ? (
|
||||
<FormAlert tone="success">
|
||||
Your password has been reset. Redirecting you to sign in…
|
||||
</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field
|
||||
hint={`At least ${MIN_PASSWORD} characters.`}
|
||||
htmlFor="password"
|
||||
label="New password"
|
||||
>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id="password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="confirm" label="Confirm new password">
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id="confirm"
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••••••"
|
||||
required
|
||||
type="password"
|
||||
value={confirm}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Saving…" : "Reset password"}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AuthLayout } from "@/components/auth/auth-ui";
|
||||
import { SignupForm } from "@/components/signup-form";
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<SignupForm />
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
</Link>
|
||||
}
|
||||
subtitle={
|
||||
email ? (
|
||||
<>
|
||||
We sent a verification link to{" "}
|
||||
<span className="text-foreground">{email}</span>. Open it to activate
|
||||
your account.
|
||||
</>
|
||||
) : (
|
||||
"Open the verification link we emailed you to activate your account."
|
||||
)
|
||||
}
|
||||
title="Check your inbox"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice && <FormAlert tone="success">{notice}</FormAlert>}
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => router.push("/")}
|
||||
type="button"
|
||||
>
|
||||
I've verified — continue
|
||||
</Button>
|
||||
{email && (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={sending}
|
||||
onClick={resend}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{sending ? "Sending…" : "Resend email"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<VerifyEmailInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
@@ -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;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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 (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"h-full",
|
||||
"antialiased",
|
||||
inter.variable,
|
||||
interHeading.variable,
|
||||
geistMono.variable,
|
||||
"font-sans"
|
||||
)}
|
||||
>
|
||||
{/* suppressHydrationWarning: next-themes sets the theme class on <html>
|
||||
before hydration, and browser extensions (e.g. ColorZilla's
|
||||
cz-shortcut-listen) mutate <body>. Only ignores attribute diffs on
|
||||
those elements, not their children. */}
|
||||
<body
|
||||
className="h-dvh overflow-hidden flex flex-col"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ToastProvider>
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
</ToastProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="flex-row items-center gap-3 p-4">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="font-semibold text-foreground text-lg tracking-tight">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActivityView() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">Activity</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
A signed, tamper-evident log of record changes awaiting patient
|
||||
approval. Sample data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<Kpi key={k.label} {...k} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ol className="flex flex-col">
|
||||
{entries.map((entry, i) => {
|
||||
const Icon = entry.icon;
|
||||
const isLast = i === entries.length - 1;
|
||||
return (
|
||||
<li className="flex gap-4" key={entry.id}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
{!isLast && <div className="mt-1 w-px flex-1 bg-border" />}
|
||||
</div>
|
||||
|
||||
<div className={cn("flex-1", isLast ? "pb-0" : "pb-6")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{entry.action}
|
||||
</span>
|
||||
{entry.status === "signed" ? (
|
||||
<Badge
|
||||
className="shrink-0 border-success/40 text-success"
|
||||
variant="outline"
|
||||
>
|
||||
<ShieldCheck className="size-3" />
|
||||
Signed
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
className="shrink-0 border-warning/40 text-warning"
|
||||
variant="outline"
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
Pending approval
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Avatar className="size-5">
|
||||
<AvatarFallback className="text-[10px]">
|
||||
{entry.initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{entry.actor} · {entry.patient} (#{entry.fileNumber})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{entry.time}</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-md border bg-muted/50 px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
|
||||
<Hash className="size-3" />
|
||||
{entry.hash}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<div
|
||||
className={cn("not-prose w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
export type AgentHeaderProps = ComponentProps<"div"> & {
|
||||
name: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export const AgentHeader = memo(
|
||||
({ className, name, model, ...props }: AgentHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BotIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{name}</span>
|
||||
{model && (
|
||||
<Badge className="font-mono text-xs" variant="secondary">
|
||||
{model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentContentProps = ComponentProps<"div">;
|
||||
|
||||
export const AgentContent = memo(
|
||||
({ className, ...props }: AgentContentProps) => (
|
||||
<div className={cn("space-y-4 p-4 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentInstructionsProps = ComponentProps<"div"> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const AgentInstructions = memo(
|
||||
({ className, children, ...props }: AgentInstructionsProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
Instructions
|
||||
</span>
|
||||
<div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentToolsProps = ComponentProps<typeof Accordion>;
|
||||
|
||||
export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<span className="font-medium text-muted-foreground text-sm">Tools</span>
|
||||
<Accordion className="rounded-md border" {...props} />
|
||||
</div>
|
||||
));
|
||||
|
||||
export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
|
||||
tool: Tool;
|
||||
};
|
||||
|
||||
export const AgentTool = memo(
|
||||
({ className, tool, value, ...props }: AgentToolProps) => {
|
||||
const schema =
|
||||
"jsonSchema" in tool && tool.jsonSchema
|
||||
? tool.jsonSchema
|
||||
: tool.inputSchema;
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
|
||||
{tool.description ?? "No description"}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3">
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(schema, null, 2)} language="json" />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type AgentOutputProps = ComponentProps<"div"> & {
|
||||
schema: string;
|
||||
};
|
||||
|
||||
export const AgentOutput = memo(
|
||||
({ className, schema, ...props }: AgentOutputProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
Output Schema
|
||||
</span>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={schema} language="typescript" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
Agent.displayName = "Agent";
|
||||
AgentHeader.displayName = "AgentHeader";
|
||||
AgentContent.displayName = "AgentContent";
|
||||
AgentInstructions.displayName = "AgentInstructions";
|
||||
AgentTools.displayName = "AgentTools";
|
||||
AgentTool.displayName = "AgentTool";
|
||||
AgentOutput.displayName = "AgentOutput";
|
||||
@@ -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<HTMLDivElement>;
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactCloseProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<p
|
||||
className={cn("font-medium text-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
|
||||
);
|
||||
@@ -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<AttachmentMediaCategory, typeof ImageIcon> = {
|
||||
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 ? (
|
||||
<img
|
||||
alt={filename || "Image"}
|
||||
className="size-full object-cover"
|
||||
height={96}
|
||||
src={url}
|
||||
width={96}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
alt={filename || "Image"}
|
||||
className="size-full rounded object-cover"
|
||||
height={20}
|
||||
src={url}
|
||||
width={20}
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Contexts
|
||||
// ============================================================================
|
||||
|
||||
interface AttachmentsContextValue {
|
||||
variant: AttachmentVariant;
|
||||
}
|
||||
|
||||
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
|
||||
|
||||
interface AttachmentContextValue {
|
||||
data: AttachmentData;
|
||||
mediaCategory: AttachmentMediaCategory;
|
||||
onRemove?: () => void;
|
||||
variant: AttachmentVariant;
|
||||
}
|
||||
|
||||
const AttachmentContext = createContext<AttachmentContextValue | null>(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 <Attachment>");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachments - Container
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: AttachmentVariant;
|
||||
};
|
||||
|
||||
export const Attachments = ({
|
||||
variant = "grid",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentsProps) => {
|
||||
const contextValue = useMemo(() => ({ variant }), [variant]);
|
||||
|
||||
return (
|
||||
<AttachmentsContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start",
|
||||
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
|
||||
variant === "grid" && "ml-auto w-fit",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AttachmentsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachment - Item
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: AttachmentData;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export const Attachment = ({
|
||||
data,
|
||||
onRemove,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentProps) => {
|
||||
const { variant } = useAttachmentsContext();
|
||||
const mediaCategory = getMediaCategory(data);
|
||||
|
||||
const contextValue = useMemo<AttachmentContextValue>(
|
||||
() => ({ data, mediaCategory, onRemove, variant }),
|
||||
[data, mediaCategory, onRemove, variant]
|
||||
);
|
||||
|
||||
return (
|
||||
<AttachmentContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative",
|
||||
variant === "grid" && "size-24 overflow-hidden rounded-lg",
|
||||
variant === "inline" && [
|
||||
"flex h-8 cursor-pointer select-none items-center gap-1.5",
|
||||
"rounded-md border border-border px-1.5",
|
||||
"font-medium text-sm transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
],
|
||||
variant === "list" && [
|
||||
"flex w-full items-center gap-3 rounded-lg border p-3",
|
||||
"hover:bg-accent/50",
|
||||
],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AttachmentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentPreview - Media preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
||||
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) => (
|
||||
<Icon className={cn(iconSize, "text-muted-foreground")} />
|
||||
);
|
||||
|
||||
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 <video className="size-full object-cover" muted src={data.url} />;
|
||||
}
|
||||
|
||||
const Icon = mediaCategoryIcons[mediaCategory];
|
||||
return fallbackIcon ?? renderIcon(Icon);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden",
|
||||
variant === "grid" && "size-full bg-muted",
|
||||
variant === "inline" && "size-5 rounded bg-background",
|
||||
variant === "list" && "size-12 rounded bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentInfo - Name and type display
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||
showMediaType?: boolean;
|
||||
};
|
||||
|
||||
export const AttachmentInfo = ({
|
||||
showMediaType = false,
|
||||
className,
|
||||
...props
|
||||
}: AttachmentInfoProps) => {
|
||||
const { data, variant } = useAttachmentContext();
|
||||
const label = getAttachmentLabel(data);
|
||||
|
||||
if (variant === "grid") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 flex-1", className)} {...props}>
|
||||
<span className="block truncate">{label}</span>
|
||||
{showMediaType && data.mediaType && (
|
||||
<span className="block truncate text-muted-foreground text-xs">
|
||||
{data.mediaType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentRemove - Remove button
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const AttachmentRemove = ({
|
||||
label = "Remove",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentRemoveProps) => {
|
||||
const { onRemove, variant } = useAttachmentContext();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.();
|
||||
},
|
||||
[onRemove]
|
||||
);
|
||||
|
||||
if (!onRemove) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
variant === "grid" && [
|
||||
"absolute top-2 right-2 size-6 rounded-full p-0",
|
||||
"bg-background/80 backdrop-blur-sm",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
"hover:bg-background",
|
||||
"[&>svg]:size-3",
|
||||
],
|
||||
variant === "inline" && [
|
||||
"size-5 rounded p-0",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
"[&>svg]:size-2.5",
|
||||
],
|
||||
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon />}
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentHoverCard - Hover preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const AttachmentHoverCard = ({
|
||||
openDelay = 0,
|
||||
closeDelay = 0,
|
||||
...props
|
||||
}: AttachmentHoverCardProps) => (
|
||||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||
);
|
||||
|
||||
export type AttachmentHoverCardTriggerProps = ComponentProps<
|
||||
typeof HoverCardTrigger
|
||||
>;
|
||||
|
||||
export const AttachmentHoverCardTrigger = (
|
||||
props: AttachmentHoverCardTriggerProps
|
||||
) => <HoverCardTrigger {...props} />;
|
||||
|
||||
export type AttachmentHoverCardContentProps = ComponentProps<
|
||||
typeof HoverCardContent
|
||||
>;
|
||||
|
||||
export const AttachmentHoverCardContent = ({
|
||||
align = "start",
|
||||
className,
|
||||
...props
|
||||
}: AttachmentHoverCardContentProps) => (
|
||||
<HoverCardContent
|
||||
align={align}
|
||||
className={cn("w-auto p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentEmpty - Empty state
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const AttachmentEmpty = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentEmptyProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center p-4 text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? "No attachments"}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Group as ButtonGroup,
|
||||
GroupText as ButtonGroupText,
|
||||
} from "@/components/ui/group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_SpeechResult as SpeechResult } from "ai";
|
||||
import {
|
||||
MediaControlBar,
|
||||
MediaController,
|
||||
MediaDurationDisplay,
|
||||
MediaMuteButton,
|
||||
MediaPlayButton,
|
||||
MediaSeekBackwardButton,
|
||||
MediaSeekForwardButton,
|
||||
MediaTimeDisplay,
|
||||
MediaTimeRange,
|
||||
MediaVolumeRange,
|
||||
} from "media-chrome/react";
|
||||
import type { ComponentProps, CSSProperties } from "react";
|
||||
|
||||
export type AudioPlayerProps = Omit<
|
||||
ComponentProps<typeof MediaController>,
|
||||
"audio"
|
||||
>;
|
||||
|
||||
export const AudioPlayer = ({
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: AudioPlayerProps) => (
|
||||
<MediaController
|
||||
audio
|
||||
data-slot="audio-player"
|
||||
style={
|
||||
{
|
||||
"--media-background-color": "transparent",
|
||||
"--media-button-icon-height": "1rem",
|
||||
"--media-button-icon-width": "1rem",
|
||||
"--media-control-background": "transparent",
|
||||
"--media-control-hover-background": "var(--color-accent)",
|
||||
"--media-control-padding": "0",
|
||||
"--media-font": "var(--font-sans)",
|
||||
"--media-font-size": "10px",
|
||||
"--media-icon-color": "currentColor",
|
||||
"--media-preview-time-background": "var(--color-background)",
|
||||
"--media-preview-time-border-radius": "var(--radius-md)",
|
||||
"--media-preview-time-text-shadow": "none",
|
||||
"--media-primary-color": "var(--color-primary)",
|
||||
"--media-range-bar-color": "var(--color-primary)",
|
||||
"--media-range-track-background": "var(--color-secondary)",
|
||||
"--media-secondary-color": "var(--color-secondary)",
|
||||
"--media-text-color": "var(--color-foreground)",
|
||||
"--media-tooltip-arrow-display": "none",
|
||||
"--media-tooltip-background": "var(--color-background)",
|
||||
"--media-tooltip-border-radius": "var(--radius-md)",
|
||||
...style,
|
||||
} as CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MediaController>
|
||||
);
|
||||
|
||||
export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
|
||||
(
|
||||
| {
|
||||
data: SpeechResult["audio"];
|
||||
}
|
||||
| {
|
||||
src: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
|
||||
// oxlint-disable-next-line eslint-plugin-jsx-a11y(media-has-caption) -- audio player captions are provided by consumer
|
||||
<audio
|
||||
data-slot="audio-player-element"
|
||||
slot="media"
|
||||
src={
|
||||
"src" in props
|
||||
? props.src
|
||||
: `data:${props.data.mediaType};base64,${props.data.base64}`
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
|
||||
|
||||
export const AudioPlayerControlBar = ({
|
||||
children,
|
||||
...props
|
||||
}: AudioPlayerControlBarProps) => (
|
||||
<MediaControlBar data-slot="audio-player-control-bar" {...props}>
|
||||
<ButtonGroup orientation="horizontal">{children}</ButtonGroup>
|
||||
</MediaControlBar>
|
||||
);
|
||||
|
||||
export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
|
||||
|
||||
export const AudioPlayerPlayButton = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerPlayButtonProps) => (
|
||||
<Button size="icon-sm" variant="outline" render={<MediaPlayButton className={cn("bg-transparent", className)} data-slot="audio-player-play-button" {...props} />}></Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerSeekBackwardButtonProps = ComponentProps<
|
||||
typeof MediaSeekBackwardButton
|
||||
>;
|
||||
|
||||
export const AudioPlayerSeekBackwardButton = ({
|
||||
seekOffset = 10,
|
||||
...props
|
||||
}: AudioPlayerSeekBackwardButtonProps) => (
|
||||
<Button size="icon-sm" variant="outline" render={<MediaSeekBackwardButton data-slot="audio-player-seek-backward-button" seekOffset={seekOffset} {...props} />}></Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerSeekForwardButtonProps = ComponentProps<
|
||||
typeof MediaSeekForwardButton
|
||||
>;
|
||||
|
||||
export const AudioPlayerSeekForwardButton = ({
|
||||
seekOffset = 10,
|
||||
...props
|
||||
}: AudioPlayerSeekForwardButtonProps) => (
|
||||
<Button size="icon-sm" variant="outline" render={<MediaSeekForwardButton data-slot="audio-player-seek-forward-button" seekOffset={seekOffset} {...props} />}></Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerTimeDisplayProps = ComponentProps<
|
||||
typeof MediaTimeDisplay
|
||||
>;
|
||||
|
||||
export const AudioPlayerTimeDisplay = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerTimeDisplayProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaTimeDisplay className={cn("tabular-nums", className)} data-slot="audio-player-time-display" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
|
||||
|
||||
export const AudioPlayerTimeRange = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerTimeRangeProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaTimeRange className={cn("", className)} data-slot="audio-player-time-range" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerDurationDisplayProps = ComponentProps<
|
||||
typeof MediaDurationDisplay
|
||||
>;
|
||||
|
||||
export const AudioPlayerDurationDisplay = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerDurationDisplayProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaDurationDisplay className={cn("tabular-nums", className)} data-slot="audio-player-duration-display" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
|
||||
|
||||
export const AudioPlayerMuteButton = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerMuteButtonProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaMuteButton className={cn("", className)} data-slot="audio-player-mute-button" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerVolumeRangeProps = ComponentProps<
|
||||
typeof MediaVolumeRange
|
||||
>;
|
||||
|
||||
export const AudioPlayerVolumeRange = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerVolumeRangeProps) => (
|
||||
<ButtonGroupText className="bg-transparent" render={<MediaVolumeRange className={cn("", className)} data-slot="audio-player-volume-range" {...props} />}></ButtonGroupText>
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactFlowProps } from "@xyflow/react";
|
||||
import { Background, ReactFlow } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const deleteKeyCode = ["Backspace", "Delete"];
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={deleteKeyCode}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
|
||||
interface ChainOfThoughtContextValue {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
const stepStatusStyles = {
|
||||
active: "text-foreground",
|
||||
complete: "text-muted-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
stepStatusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
<Icon className="size-4" />
|
||||
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
||||
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { BookmarkIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Checkpoint = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 overflow-hidden text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CheckpointIconProps = LucideProps;
|
||||
|
||||
export const CheckpointIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointIconProps) =>
|
||||
children ?? (
|
||||
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const CheckpointTrigger = ({
|
||||
children,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
tooltip,
|
||||
...props
|
||||
}: CheckpointTriggerProps) =>
|
||||
tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button size={size} type="button" variant={variant} {...props} />}>{children}</TooltipTrigger>
|
||||
<TooltipContent align="start" side="bottom">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
key: string;
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||
lines.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
key: `line-${lineIdx}-${tokenIdx}`,
|
||||
token,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Token rendering component
|
||||
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||
<span
|
||||
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
|
||||
style={
|
||||
{
|
||||
backgroundColor: token.bgColor,
|
||||
color: token.color,
|
||||
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||
...token.htmlStyle,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Line number styles using CSS counters
|
||||
const LINE_NUMBER_CLASSES = cn(
|
||||
"block",
|
||||
"before:content-[counter(line)]",
|
||||
"before:inline-block",
|
||||
"before:[counter-increment:line]",
|
||||
"before:w-8",
|
||||
"before:mr-4",
|
||||
"before:text-right",
|
||||
"before:text-muted-foreground/50",
|
||||
"before:font-mono",
|
||||
"before:select-none"
|
||||
);
|
||||
|
||||
// Line rendering component
|
||||
const LineSpan = ({
|
||||
keyedLine,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
keyedLine: KeyedLine;
|
||||
showLineNumbers: boolean;
|
||||
}) => (
|
||||
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||
{keyedLine.tokens.length === 0
|
||||
? "\n"
|
||||
: keyedLine.tokens.map(({ token, key }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
interface TokenizedCode {
|
||||
tokens: ThemedToken[][];
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
interface CodeBlockContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
const createRawTokens = (code: string): TokenizedCode => ({
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "inherit",
|
||||
content: line,
|
||||
} as ThemedToken,
|
||||
]
|
||||
),
|
||||
});
|
||||
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Subscribe callback if provided
|
||||
if (callback) {
|
||||
if (!subscribers.has(tokensCacheKey)) {
|
||||
subscribers.set(tokensCacheKey, new Set());
|
||||
}
|
||||
subscribers.get(tokensCacheKey)?.add(callback);
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
|
||||
const tokenized: TokenizedCode = {
|
||||
bg: result.bg ?? "transparent",
|
||||
fg: result.fg ?? "inherit",
|
||||
tokens: result.tokens,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
tokensCache.set(tokensCacheKey, tokenized);
|
||||
|
||||
// Notify all subscribers
|
||||
const subs = subscribers.get(tokensCacheKey);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
sub(tokenized);
|
||||
}
|
||||
subscribers.delete(tokensCacheKey);
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
.catch((error) => {
|
||||
console.error("Failed to highlight code:", error);
|
||||
subscribers.delete(tokensCacheKey);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CodeBlockBody = memo(
|
||||
({
|
||||
tokenized,
|
||||
showLineNumbers,
|
||||
className,
|
||||
}: {
|
||||
tokenized: TokenizedCode;
|
||||
showLineNumbers: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const preStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: tokenized.bg,
|
||||
color: tokenized.fg,
|
||||
}),
|
||||
[tokenized.bg, tokenized.fg]
|
||||
);
|
||||
|
||||
const keyedLines = useMemo(
|
||||
() => addKeysToTokens(tokenized.tokens),
|
||||
[tokenized.tokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
|
||||
className
|
||||
)}
|
||||
style={preStyle}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
|
||||
)}
|
||||
>
|
||||
{keyedLines.map((keyedLine) => (
|
||||
<LineSpan
|
||||
key={keyedLine.key}
|
||||
keyedLine={keyedLine}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.tokenized === nextProps.tokenized &&
|
||||
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
|
||||
CodeBlockBody.displayName = "CodeBlockBody";
|
||||
|
||||
export const CodeBlockContainer = ({
|
||||
className,
|
||||
language,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
data-language={language}
|
||||
style={{
|
||||
containIntrinsicSize: "auto 200px",
|
||||
contentVisibility: "auto",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CodeBlockHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockTitle = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockFilename = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const CodeBlockActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||
|
||||
// Synchronous cache lookup — avoids setState in effect for cached results
|
||||
const syncTokens = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens]
|
||||
);
|
||||
|
||||
// Async highlighting result (populated after shiki loads)
|
||||
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
|
||||
const asyncKeyRef = useRef({ code, language });
|
||||
|
||||
// Invalidate stale async tokens synchronously during render
|
||||
if (
|
||||
asyncKeyRef.current.code !== code ||
|
||||
asyncKeyRef.current.language !== language
|
||||
) {
|
||||
asyncKeyRef.current = { code, language };
|
||||
setAsyncTokens(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncTokens(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
const tokenized = asyncTokens ?? syncTokens;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-auto">
|
||||
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={contextValue}>
|
||||
<CodeBlockContainer className={className} language={language} {...props}>
|
||||
{children}
|
||||
<CodeBlockContent
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const CodeBlockLanguageSelector = (
|
||||
props: CodeBlockLanguageSelectorProps
|
||||
) => <Select {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||
className
|
||||
)}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorValue = (
|
||||
props: CodeBlockLanguageSelectorValueProps
|
||||
) => <SelectValue {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorContent = ({
|
||||
align = "end",
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorContentProps) => (
|
||||
<SelectContent align={align} {...props} />
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||
typeof SelectItem
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorItem = (
|
||||
props: CodeBlockLanguageSelectorItemProps
|
||||
) => <SelectItem {...props} />;
|
||||
@@ -0,0 +1,452 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FileIcon,
|
||||
GitCommitIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type CommitProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Commit = ({ className, children, ...props }: CommitProps) => (
|
||||
<Collapsible
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const CommitHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHeaderProps) => (
|
||||
<CollapsibleTrigger {...props} render={<div className={cn(
|
||||
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
|
||||
className
|
||||
)} />}>{children}</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitHash = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHashProps) => (
|
||||
<span className={cn("font-mono text-xs", className)} {...props}>
|
||||
<GitCommitIcon className="mr-1 inline-block size-3" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMessageProps) => (
|
||||
<span className={cn("font-medium text-sm", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitMetadata = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMetadataProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitSeparator = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitSeparatorProps) => (
|
||||
<span className={className} {...props}>
|
||||
{children ?? "•"}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitInfoProps) => (
|
||||
<div className={cn("flex flex-1 flex-col", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitAuthor = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitAuthorProps) => (
|
||||
<div className={cn("flex items-center", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
initials: string;
|
||||
};
|
||||
|
||||
export const CommitAuthorAvatar = ({
|
||||
initials,
|
||||
className,
|
||||
...props
|
||||
}: CommitAuthorAvatarProps) => (
|
||||
<Avatar className={cn("size-8", className)} {...props}>
|
||||
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
|
||||
date: Date;
|
||||
};
|
||||
|
||||
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
const formatRelativeDate = (date: Date) => {
|
||||
const days = Math.round(
|
||||
(date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
return relativeTimeFormat.format(days, "day");
|
||||
};
|
||||
|
||||
export const CommitTimestamp = ({
|
||||
date,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitTimestampProps) => {
|
||||
const [formatted, setFormatted] = useState("");
|
||||
|
||||
const updateFormatted = useCallback(() => {
|
||||
setFormatted(formatRelativeDate(date));
|
||||
}, [date]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFormatted();
|
||||
}, [updateFormatted]);
|
||||
|
||||
return (
|
||||
<time
|
||||
className={cn("text-xs", className)}
|
||||
dateTime={date.toISOString()}
|
||||
{...props}
|
||||
>
|
||||
{children ?? formatted}
|
||||
</time>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const handleActionsKeyDown = (e: React.KeyboardEvent) => e.stopPropagation();
|
||||
|
||||
export const CommitActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitActionsProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1", className)}
|
||||
onClick={handleActionsClick}
|
||||
onKeyDown={handleActionsKeyDown}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
hash: string;
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CommitCopyButton = ({
|
||||
hash,
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CommitCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [hash, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-7 shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const CommitContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFiles = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilesProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFile = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFileInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileInfoProps) => (
|
||||
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const fileStatusStyles = {
|
||||
added: "text-green-600 dark:text-green-400",
|
||||
deleted: "text-red-600 dark:text-red-400",
|
||||
modified: "text-yellow-600 dark:text-yellow-400",
|
||||
renamed: "text-blue-600 dark:text-blue-400",
|
||||
};
|
||||
|
||||
const fileStatusLabels = {
|
||||
added: "A",
|
||||
deleted: "D",
|
||||
modified: "M",
|
||||
renamed: "R",
|
||||
};
|
||||
|
||||
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
status: "added" | "modified" | "deleted" | "renamed";
|
||||
};
|
||||
|
||||
export const CommitFileStatus = ({
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileStatusProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium font-mono text-xs",
|
||||
fileStatusStyles[status],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? fileStatusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitFileIconProps = ComponentProps<typeof FileIcon>;
|
||||
|
||||
export const CommitFileIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: CommitFileIconProps) => (
|
||||
<FileIcon
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitFilePath = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilePathProps) => (
|
||||
<span className={cn("truncate font-mono text-xs", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFileChanges = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileChangesProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 font-mono text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const CommitFileAdditions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileAdditionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-green-600 dark:text-green-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<PlusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const CommitFileDeletions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileDeletionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-red-600 dark:text-red-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<MinusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type ToolUIPartApproval =
|
||||
| {
|
||||
id: string;
|
||||
approved?: never;
|
||||
reason?: never;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: false;
|
||||
reason?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
interface ConfirmationContextValue {
|
||||
approval: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
}
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useConfirmation = () => {
|
||||
const context = useContext(ConfirmationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Confirmation components must be used within Confirmation");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
||||
approval?: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
};
|
||||
|
||||
export const Confirmation = ({
|
||||
className,
|
||||
approval,
|
||||
state,
|
||||
...props
|
||||
}: ConfirmationProps) => {
|
||||
const contextValue = useMemo(() => ({ approval, state }), [approval, state]);
|
||||
|
||||
if (!approval || state === "input-streaming" || state === "input-available") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={contextValue}>
|
||||
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
</ConfirmationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
|
||||
|
||||
export const ConfirmationTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationTitleProps) => (
|
||||
<AlertDescription className={cn("inline", className)} {...props} />
|
||||
);
|
||||
|
||||
export interface ConfirmationRequestProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export interface ConfirmationAcceptedProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationAccepted = ({
|
||||
children,
|
||||
}: ConfirmationAcceptedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when approved and in response states
|
||||
if (
|
||||
!approval?.approved ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export interface ConfirmationRejectedProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationRejected = ({
|
||||
children,
|
||||
}: ConfirmationRejectedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when rejected and in response states
|
||||
if (
|
||||
approval?.approved !== false ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const ConfirmationActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationActionsProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-2 self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className="h-8 px-3 text-sm" type="button" {...props} />
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
PreviewCard as HoverCard,
|
||||
PreviewCardPopup as HoverCardContent,
|
||||
PreviewCardTrigger as HoverCardTrigger,
|
||||
} from "@/components/ui/preview-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
interface ContextSchema {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
}
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ maxTokens, modelId, usage, usedTokens }),
|
||||
[maxTokens, modelId, usage, usedTokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextContext.Provider value={contextValue}>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
style: "percent",
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
style: "percent",
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="ml-2 text-muted-foreground">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
|
||||
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageText = (message: UIMessage): string =>
|
||||
message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
export type ConversationDownloadProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"onClick"
|
||||
> & {
|
||||
messages: UIMessage[];
|
||||
filename?: string;
|
||||
formatMessage?: (message: UIMessage, index: number) => string;
|
||||
};
|
||||
|
||||
const defaultFormatMessage = (message: UIMessage): string => {
|
||||
const roleLabel =
|
||||
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
||||
return `**${roleLabel}:** ${getMessageText(message)}`;
|
||||
};
|
||||
|
||||
export const messagesToMarkdown = (
|
||||
messages: UIMessage[],
|
||||
formatMessage: (
|
||||
message: UIMessage,
|
||||
index: number
|
||||
) => string = defaultFormatMessage
|
||||
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
||||
|
||||
export const ConversationDownload = ({
|
||||
messages,
|
||||
filename = "conversation.md",
|
||||
formatMessage = defaultFormatMessage,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ConversationDownloadProps) => {
|
||||
const handleDownload = useCallback(() => {
|
||||
const markdown = messagesToMarkdown(messages, formatMessage);
|
||||
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [messages, filename, formatMessage]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleDownload}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <DownloadIcon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { EdgeProps, InternalNode, Node } from "@xyflow/react";
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetPosition,
|
||||
targetX,
|
||||
targetY,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className="stroke-1 stroke-ring"
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? "target" : "source";
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2;
|
||||
let offsetY = handle.height / 2;
|
||||
|
||||
// this is a tiny detail to make the markerEnd of an edge visible.
|
||||
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
|
||||
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
|
||||
switch (handlePosition) {
|
||||
case Position.Left: {
|
||||
offsetX = 0;
|
||||
break;
|
||||
}
|
||||
case Position.Right: {
|
||||
offsetX = handle.width;
|
||||
break;
|
||||
}
|
||||
case Position.Top: {
|
||||
offsetY = 0;
|
||||
break;
|
||||
}
|
||||
case Position.Bottom: {
|
||||
offsetY = handle.height;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`);
|
||||
}
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
||||
|
||||
return [x, y] as const;
|
||||
};
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>
|
||||
) => {
|
||||
const sourcePos = Position.Right;
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
|
||||
const targetPos = Position.Left;
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
||||
|
||||
return {
|
||||
sourcePos,
|
||||
sx,
|
||||
sy,
|
||||
targetPos,
|
||||
tx,
|
||||
ty,
|
||||
};
|
||||
};
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourcePosition: sourcePos,
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
targetPosition: targetPos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill="var(--primary)" r="4">
|
||||
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Edge = {
|
||||
Animated,
|
||||
Temporary,
|
||||
};
|
||||
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface EnvironmentVariablesContextType {
|
||||
showValues: boolean;
|
||||
setShowValues: (show: boolean) => void;
|
||||
}
|
||||
|
||||
// Default noop for context default value
|
||||
// oxlint-disable-next-line eslint(no-empty-function)
|
||||
const noop = () => {};
|
||||
|
||||
const EnvironmentVariablesContext =
|
||||
createContext<EnvironmentVariablesContextType>({
|
||||
setShowValues: noop,
|
||||
showValues: false,
|
||||
});
|
||||
|
||||
export type EnvironmentVariablesProps = HTMLAttributes<HTMLDivElement> & {
|
||||
showValues?: boolean;
|
||||
defaultShowValues?: boolean;
|
||||
onShowValuesChange?: (show: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnvironmentVariables = ({
|
||||
showValues: controlledShowValues,
|
||||
defaultShowValues = false,
|
||||
onShowValuesChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesProps) => {
|
||||
const [internalShowValues, setInternalShowValues] =
|
||||
useState(defaultShowValues);
|
||||
const showValues = controlledShowValues ?? internalShowValues;
|
||||
|
||||
const setShowValues = useCallback(
|
||||
(show: boolean) => {
|
||||
setInternalShowValues(show);
|
||||
onShowValuesChange?.(show);
|
||||
},
|
||||
[onShowValuesChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ setShowValues, showValues }),
|
||||
[setShowValues, showValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</EnvironmentVariablesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariablesHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type EnvironmentVariablesTitleProps = HTMLAttributes<HTMLHeadingElement>;
|
||||
|
||||
export const EnvironmentVariablesTitle = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesTitleProps) => (
|
||||
<h3 className={cn("font-medium text-sm", className)} {...props}>
|
||||
{children ?? "Environment Variables"}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export type EnvironmentVariablesToggleProps = ComponentProps<typeof Switch>;
|
||||
|
||||
export const EnvironmentVariablesToggle = ({
|
||||
className,
|
||||
...props
|
||||
}: EnvironmentVariablesToggleProps) => {
|
||||
const { showValues, setShowValues } = useContext(EnvironmentVariablesContext);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{showValues ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />}
|
||||
</span>
|
||||
<Switch
|
||||
aria-label="Toggle value visibility"
|
||||
checked={showValues}
|
||||
onCheckedChange={setShowValues}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariablesContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesContentProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface EnvironmentVariableContextType {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const EnvironmentVariableContext =
|
||||
createContext<EnvironmentVariableContextType>({
|
||||
name: "",
|
||||
value: "",
|
||||
});
|
||||
|
||||
export type EnvironmentVariableGroupProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariableGroup = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableGroupProps) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type EnvironmentVariableNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const EnvironmentVariableName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableNameProps) => {
|
||||
const { name } = useContext(EnvironmentVariableContext);
|
||||
|
||||
return (
|
||||
<span className={cn("font-mono text-sm", className)} {...props}>
|
||||
{children ?? name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableValueProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const EnvironmentVariableValue = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableValueProps) => {
|
||||
const { value } = useContext(EnvironmentVariableContext);
|
||||
const { showValues } = useContext(EnvironmentVariablesContext);
|
||||
|
||||
const displayValue = showValues
|
||||
? value
|
||||
: "•".repeat(Math.min(value.length, 20));
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-muted-foreground text-sm",
|
||||
!showValues && "select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? displayValue}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const EnvironmentVariable = ({
|
||||
name,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableProps) => {
|
||||
const envVarContextValue = useMemo(() => ({ name, value }), [name, value]);
|
||||
|
||||
return (
|
||||
<EnvironmentVariableContext.Provider value={envVarContextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvironmentVariableName />
|
||||
</div>
|
||||
<EnvironmentVariableValue />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</EnvironmentVariableContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableCopyButtonProps = ComponentProps<
|
||||
typeof Button
|
||||
> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
copyFormat?: "name" | "value" | "export";
|
||||
};
|
||||
|
||||
export const EnvironmentVariableCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
copyFormat = "value",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: EnvironmentVariableCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { name, value } = useContext(EnvironmentVariableContext);
|
||||
|
||||
const getTextToCopy = useCallback((): string => {
|
||||
const formatMap = {
|
||||
export: () => `export ${name}="${value}"`,
|
||||
name: () => name,
|
||||
value: () => value,
|
||||
};
|
||||
return formatMap[copyFormat]();
|
||||
}, [name, value, copyFormat]);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(getTextToCopy());
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [getTextToCopy, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-6 shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={12} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableRequiredProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const EnvironmentVariableRequired = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableRequiredProps) => (
|
||||
<Badge className={cn("text-xs", className)} variant="secondary" {...props}>
|
||||
{children ?? "Required"}
|
||||
</Badge>
|
||||
);
|
||||
@@ -0,0 +1,297 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
} from "lucide-react";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface FileTreeContextType {
|
||||
expandedPaths: Set<string>;
|
||||
togglePath: (path: string) => void;
|
||||
selectedPath?: string;
|
||||
onSelect?: (path: string) => void;
|
||||
}
|
||||
|
||||
// Default noop for context default value
|
||||
// oxlint-disable-next-line eslint(no-empty-function)
|
||||
const noop = () => {};
|
||||
|
||||
const FileTreeContext = createContext<FileTreeContextType>({
|
||||
// oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)
|
||||
expandedPaths: new Set(),
|
||||
togglePath: noop,
|
||||
});
|
||||
|
||||
export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
|
||||
expanded?: Set<string>;
|
||||
defaultExpanded?: Set<string>;
|
||||
selectedPath?: string;
|
||||
onSelect?: (path: string) => void;
|
||||
onExpandedChange?: (expanded: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const FileTree = ({
|
||||
expanded: controlledExpanded,
|
||||
defaultExpanded = new Set(),
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onExpandedChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeProps) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
||||
const expandedPaths = controlledExpanded ?? internalExpanded;
|
||||
|
||||
const togglePath = useCallback(
|
||||
(path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setInternalExpanded(newExpanded);
|
||||
onExpandedChange?.(newExpanded);
|
||||
},
|
||||
[expandedPaths, onExpandedChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
|
||||
[expandedPaths, onSelect, selectedPath, togglePath]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileTreeContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-background font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
role="tree"
|
||||
{...props}
|
||||
>
|
||||
<div className="p-2">{children}</div>
|
||||
</div>
|
||||
</FileTreeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const FileTreeIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeIconProps) => (
|
||||
<span className={cn("shrink-0", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const FileTreeName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeNameProps) => (
|
||||
<span className={cn("truncate", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
interface FileTreeFolderContextType {
|
||||
path: string;
|
||||
name: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
|
||||
isExpanded: false,
|
||||
name: "",
|
||||
path: "",
|
||||
});
|
||||
|
||||
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const FileTreeFolder = ({
|
||||
path,
|
||||
name,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFolderProps) => {
|
||||
const { expandedPaths, togglePath, selectedPath, onSelect } =
|
||||
useContext(FileTreeContext);
|
||||
const isExpanded = expandedPaths.has(path);
|
||||
const isSelected = selectedPath === path;
|
||||
|
||||
const handleOpenChange = useCallback(() => {
|
||||
togglePath(path);
|
||||
}, [togglePath, path]);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelect?.(path);
|
||||
}, [onSelect, path]);
|
||||
|
||||
const folderContextValue = useMemo(
|
||||
() => ({ isExpanded, name, path }),
|
||||
[isExpanded, name, path]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileTreeFolderContext.Provider value={folderContextValue}>
|
||||
<Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
|
||||
<div
|
||||
className={cn("", className)}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger render={<button className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0" type="button" />}><ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/></CollapsibleTrigger>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
|
||||
onClick={handleSelect}
|
||||
type="button"
|
||||
>
|
||||
<FileTreeIcon>
|
||||
{isExpanded ? (
|
||||
<FolderOpenIcon className="size-4 text-blue-500" />
|
||||
) : (
|
||||
<FolderIcon className="size-4 text-blue-500" />
|
||||
)}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName>{name}</FileTreeName>
|
||||
</button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-4 border-l pl-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</FileTreeFolderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileTreeFileContextType {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FileTreeFileContext = createContext<FileTreeFileContextType>({
|
||||
name: "",
|
||||
path: "",
|
||||
});
|
||||
|
||||
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string;
|
||||
name: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export const FileTreeFile = ({
|
||||
path,
|
||||
name,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFileProps) => {
|
||||
const { selectedPath, onSelect } = useContext(FileTreeContext);
|
||||
const isSelected = selectedPath === path;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect?.(path);
|
||||
}, [onSelect, path]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onSelect?.(path);
|
||||
}
|
||||
},
|
||||
[onSelect, path]
|
||||
);
|
||||
|
||||
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
|
||||
|
||||
return (
|
||||
<FileTreeFileContext.Provider value={fileContextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{/* Spacer for alignment */}
|
||||
<span className="size-4 shrink-0" />
|
||||
<FileTreeIcon>
|
||||
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName>{name}</FileTreeName>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FileTreeFileContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
export const FileTreeActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeActionsProps) => (
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-1", className)}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_GeneratedImage } from "ai";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array: _uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { CarouselApi } from "@/components/ui/carousel";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import {
|
||||
PreviewCard as HoverCard,
|
||||
PreviewCardPopup as HoverCardContent,
|
||||
PreviewCardTrigger as HoverCardTrigger,
|
||||
} from "@/components/ui/preview-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type InlineCitationProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn("group inline items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn("transition-colors group-hover:bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger render={<Badge className={cn("ml-1 rounded-full", className)} variant="secondary" {...props} />}>{sources[0] ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{" "}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
"unknown"
|
||||
)}</HoverCardTrigger>
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
||||
);
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />;
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn("w-full space-y-2 p-4 pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const syncState = useCallback(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncState();
|
||||
|
||||
api.on("select", syncState);
|
||||
|
||||
return () => {
|
||||
api.off("select", syncState);
|
||||
};
|
||||
}, [api, syncState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TProps as JsxParserProps } from "react-jsx-parser";
|
||||
import JsxParser from "react-jsx-parser";
|
||||
|
||||
interface JSXPreviewContextValue {
|
||||
jsx: string;
|
||||
processedJsx: string;
|
||||
isStreaming: boolean;
|
||||
error: Error | null;
|
||||
setError: (error: Error | null) => void;
|
||||
setLastGoodJsx: (jsx: string) => void;
|
||||
components: JsxParserProps["components"];
|
||||
bindings: JsxParserProps["bindings"];
|
||||
onErrorProp?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
|
||||
|
||||
const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
|
||||
|
||||
export const useJSXPreview = () => {
|
||||
const context = useContext(JSXPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("JSXPreview components must be used within JSXPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const matchJsxTag = (code: string) => {
|
||||
if (code.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = code.match(TAG_REGEX);
|
||||
|
||||
if (!match || match.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [fullMatch, tagName, attributes, selfClosing] = match;
|
||||
|
||||
let type: "self-closing" | "closing" | "opening";
|
||||
if (selfClosing) {
|
||||
type = "self-closing";
|
||||
} else if (fullMatch.startsWith("</")) {
|
||||
type = "closing";
|
||||
} else {
|
||||
type = "opening";
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: attributes.trim(),
|
||||
endIndex: match.index + fullMatch.length,
|
||||
startIndex: match.index,
|
||||
tag: fullMatch,
|
||||
tagName,
|
||||
type,
|
||||
};
|
||||
};
|
||||
|
||||
const stripIncompleteTag = (text: string) => {
|
||||
// Find the last '<' that isn't part of a complete tag
|
||||
const lastOpen = text.lastIndexOf("<");
|
||||
if (lastOpen === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const afterOpen = text.slice(lastOpen);
|
||||
// If there's no closing '>' after the last '<', it's an incomplete tag
|
||||
if (!afterOpen.includes(">")) {
|
||||
return text.slice(0, lastOpen);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const completeJsxTag = (code: string) => {
|
||||
const stack: string[] = [];
|
||||
let result = "";
|
||||
let currentPosition = 0;
|
||||
|
||||
while (currentPosition < code.length) {
|
||||
const match = matchJsxTag(code.slice(currentPosition));
|
||||
if (!match) {
|
||||
// No more tags found, strip any trailing incomplete tag
|
||||
result += stripIncompleteTag(code.slice(currentPosition));
|
||||
break;
|
||||
}
|
||||
const { tagName, type, endIndex } = match;
|
||||
|
||||
// Include any text content before this tag
|
||||
result += code.slice(currentPosition, currentPosition + endIndex);
|
||||
|
||||
if (type === "opening") {
|
||||
stack.push(tagName);
|
||||
} else if (type === "closing") {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
currentPosition += endIndex;
|
||||
}
|
||||
|
||||
return (
|
||||
result +
|
||||
stack
|
||||
.toReversed()
|
||||
.map((tag) => `</${tag}>`)
|
||||
.join("")
|
||||
);
|
||||
};
|
||||
|
||||
export type JSXPreviewProps = ComponentProps<"div"> & {
|
||||
jsx: string;
|
||||
isStreaming?: boolean;
|
||||
components?: JsxParserProps["components"];
|
||||
bindings?: JsxParserProps["bindings"];
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export const JSXPreview = memo(
|
||||
({
|
||||
jsx,
|
||||
isStreaming = false,
|
||||
components,
|
||||
bindings,
|
||||
onError,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: JSXPreviewProps) => {
|
||||
const [prevJsx, setPrevJsx] = useState(jsx);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [_lastGoodJsx, setLastGoodJsx] = useState("");
|
||||
|
||||
// Clear error when jsx changes (derived state pattern)
|
||||
if (jsx !== prevJsx) {
|
||||
setPrevJsx(jsx);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const processedJsx = useMemo(
|
||||
() => (isStreaming ? completeJsxTag(jsx) : jsx),
|
||||
[jsx, isStreaming]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
bindings,
|
||||
components,
|
||||
error,
|
||||
isStreaming,
|
||||
jsx,
|
||||
onErrorProp: onError,
|
||||
processedJsx,
|
||||
setError,
|
||||
setLastGoodJsx,
|
||||
}),
|
||||
[
|
||||
bindings,
|
||||
components,
|
||||
error,
|
||||
isStreaming,
|
||||
jsx,
|
||||
onError,
|
||||
processedJsx,
|
||||
setError,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<JSXPreviewContext.Provider value={contextValue}>
|
||||
<div className={cn("relative", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</JSXPreviewContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreview.displayName = "JSXPreview";
|
||||
|
||||
export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
|
||||
|
||||
export const JSXPreviewContent = memo(
|
||||
({ className, ...props }: JSXPreviewContentProps) => {
|
||||
const {
|
||||
processedJsx,
|
||||
isStreaming,
|
||||
components,
|
||||
bindings,
|
||||
setError,
|
||||
setLastGoodJsx,
|
||||
onErrorProp,
|
||||
} = useJSXPreview();
|
||||
const errorReportedRef = useRef<string | null>(null);
|
||||
const lastGoodJsxRef = useRef("");
|
||||
const [hadError, setHadError] = useState(false);
|
||||
|
||||
// Reset error tracking when jsx changes
|
||||
useEffect(() => {
|
||||
errorReportedRef.current = null;
|
||||
setHadError(false);
|
||||
}, [processedJsx]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(err: Error) => {
|
||||
// Prevent duplicate error reports for the same jsx
|
||||
if (errorReportedRef.current === processedJsx) {
|
||||
return;
|
||||
}
|
||||
errorReportedRef.current = processedJsx;
|
||||
|
||||
// During streaming, suppress errors and fall back to last good JSX
|
||||
if (isStreaming) {
|
||||
setHadError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err);
|
||||
onErrorProp?.(err);
|
||||
},
|
||||
[processedJsx, isStreaming, onErrorProp, setError]
|
||||
);
|
||||
|
||||
// Track the last JSX that rendered without error
|
||||
useEffect(() => {
|
||||
if (!errorReportedRef.current) {
|
||||
lastGoodJsxRef.current = processedJsx;
|
||||
setLastGoodJsx(processedJsx);
|
||||
}
|
||||
}, [processedJsx, setLastGoodJsx]);
|
||||
|
||||
// During streaming, if the current JSX errored, re-render with last good version
|
||||
const displayJsx =
|
||||
isStreaming && hadError ? lastGoodJsxRef.current : processedJsx;
|
||||
|
||||
return (
|
||||
<div className={cn("jsx-preview-content", className)} {...props}>
|
||||
<JsxParser
|
||||
bindings={bindings}
|
||||
components={components}
|
||||
jsx={displayJsx}
|
||||
onError={handleError}
|
||||
renderInWrapper={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreviewContent.displayName = "JSXPreviewContent";
|
||||
|
||||
export type JSXPreviewErrorProps = ComponentProps<"div"> & {
|
||||
children?: ReactNode | ((error: Error) => ReactNode);
|
||||
};
|
||||
|
||||
const renderChildren = (
|
||||
children: ReactNode | ((error: Error) => ReactNode),
|
||||
error: Error
|
||||
): ReactNode => {
|
||||
if (typeof children === "function") {
|
||||
return children(error);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export const JSXPreviewError = memo(
|
||||
({ className, children, ...props }: JSXPreviewErrorProps) => {
|
||||
const { error } = useJSXPreview();
|
||||
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ? (
|
||||
renderChildren(children, error)
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
<span>{error.message}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreviewError.displayName = "JSXPreviewError";
|
||||
@@ -0,0 +1,357 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Group, GroupText } from "@/components/ui/group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipPopup,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipPopup>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipPopup>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
interface MessageBranchContextType {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
}
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = useCallback(
|
||||
(newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
},
|
||||
[onBranchChange]
|
||||
);
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const contextValue = useMemo<MessageBranchContextType>(
|
||||
() => ({
|
||||
branches,
|
||||
currentBranch,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
setBranches,
|
||||
totalBranches: branches.length,
|
||||
}),
|
||||
[branches, currentBranch, goToNext, goToPrevious]
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = ComponentProps<typeof Group>;
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group
|
||||
className={cn(
|
||||
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||
className
|
||||
)}
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<GroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</GroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,370 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/;
|
||||
|
||||
interface MicSelectorContextType {
|
||||
data: MediaDeviceInfo[];
|
||||
value: string | undefined;
|
||||
onValueChange?: (value: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
width: number;
|
||||
setWidth?: (width: number) => void;
|
||||
}
|
||||
|
||||
const MicSelectorContext = createContext<MicSelectorContextType>({
|
||||
data: [],
|
||||
onOpenChange: undefined,
|
||||
onValueChange: undefined,
|
||||
open: false,
|
||||
setWidth: undefined,
|
||||
value: undefined,
|
||||
width: 200,
|
||||
});
|
||||
|
||||
export const useAudioDevices = () => {
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
|
||||
const loadDevicesWithoutPermission = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const deviceList = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = deviceList.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
|
||||
setDevices(audioInputs);
|
||||
} catch (caughtError) {
|
||||
const message =
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to get audio devices";
|
||||
|
||||
setError(message);
|
||||
console.error("Error getting audio devices:", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDevicesWithPermission = useCallback(async () => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tempStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
|
||||
for (const track of tempStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
|
||||
const deviceList = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = deviceList.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
|
||||
setDevices(audioInputs);
|
||||
setHasPermission(true);
|
||||
} catch (caughtError) {
|
||||
const message =
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to get audio devices";
|
||||
|
||||
setError(message);
|
||||
console.error("Error getting audio devices:", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevicesWithoutPermission();
|
||||
}, [loadDevicesWithoutPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeviceChange = () => {
|
||||
if (hasPermission) {
|
||||
loadDevicesWithPermission();
|
||||
} else {
|
||||
loadDevicesWithoutPermission();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||||
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
"devicechange",
|
||||
handleDeviceChange
|
||||
);
|
||||
};
|
||||
}, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission]);
|
||||
|
||||
return {
|
||||
devices,
|
||||
error,
|
||||
hasPermission,
|
||||
loadDevices: loadDevicesWithPermission,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
|
||||
export type MicSelectorProps = ComponentProps<typeof Popover> & {
|
||||
defaultValue?: string;
|
||||
value?: string | undefined;
|
||||
onValueChange?: (value: string | undefined) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MicSelector = ({
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onValueChange: controlledOnValueChange,
|
||||
defaultOpen = false,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
...props
|
||||
}: MicSelectorProps) => {
|
||||
const [value, onValueChange] = useControllableState<string | undefined>({
|
||||
defaultProp: defaultValue,
|
||||
onChange: controlledOnValueChange,
|
||||
prop: controlledValue,
|
||||
});
|
||||
const [open, onOpenChange] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: controlledOnOpenChange,
|
||||
prop: controlledOpen,
|
||||
});
|
||||
const [width, setWidth] = useState(200);
|
||||
const { devices, loading, hasPermission, loadDevices } = useAudioDevices();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !hasPermission && !loading) {
|
||||
loadDevices();
|
||||
}
|
||||
}, [open, hasPermission, loading, loadDevices]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
data: devices,
|
||||
onOpenChange,
|
||||
onValueChange,
|
||||
open,
|
||||
setWidth,
|
||||
value,
|
||||
width,
|
||||
}),
|
||||
[devices, onOpenChange, onValueChange, open, setWidth, value, width]
|
||||
);
|
||||
|
||||
return (
|
||||
<MicSelectorContext.Provider value={contextValue}>
|
||||
<Popover {...props} onOpenChange={onOpenChange} open={open} />
|
||||
</MicSelectorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MicSelectorTrigger = ({
|
||||
children,
|
||||
...props
|
||||
}: MicSelectorTriggerProps) => {
|
||||
const { setWidth } = useContext(MicSelectorContext);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Create a ResizeObserver to detect width changes
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newWidth = (entry.target as HTMLElement).offsetWidth;
|
||||
if (newWidth) {
|
||||
setWidth?.(newWidth);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
// Clean up the observer when component unmounts
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [setWidth]);
|
||||
|
||||
return (
|
||||
<PopoverTrigger render={<Button variant="outline" {...props} ref={ref} />}>{children}<ChevronsUpDownIcon
|
||||
className="shrink-0 text-muted-foreground"
|
||||
size={16}
|
||||
/></PopoverTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorContentProps = ComponentProps<typeof Command> & {
|
||||
popoverOptions?: ComponentProps<typeof PopoverContent>;
|
||||
};
|
||||
|
||||
export const MicSelectorContent = ({
|
||||
className,
|
||||
popoverOptions,
|
||||
...props
|
||||
}: MicSelectorContentProps) => {
|
||||
const { width, onValueChange, value } = useContext(MicSelectorContext);
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
className={cn("p-0", className)}
|
||||
style={{ width }}
|
||||
{...popoverOptions}
|
||||
>
|
||||
<Command onValueChange={onValueChange} value={value} {...props} />
|
||||
</PopoverContent>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorInputProps = ComponentProps<typeof CommandInput> & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => (
|
||||
<CommandInput placeholder="Search microphones..." {...props} />
|
||||
);
|
||||
|
||||
export type MicSelectorListProps = Omit<
|
||||
ComponentProps<typeof CommandList>,
|
||||
"children"
|
||||
> & {
|
||||
children: (devices: MediaDeviceInfo[]) => ReactNode;
|
||||
};
|
||||
|
||||
export const MicSelectorList = ({
|
||||
children,
|
||||
...props
|
||||
}: MicSelectorListProps) => {
|
||||
const { data } = useContext(MicSelectorContext);
|
||||
|
||||
return <CommandList {...props}>{children(data)}</CommandList>;
|
||||
};
|
||||
|
||||
export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const MicSelectorEmpty = ({
|
||||
children = "No microphone found.",
|
||||
...props
|
||||
}: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>;
|
||||
|
||||
export type MicSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const MicSelectorItem = (props: MicSelectorItemProps) => {
|
||||
const { onValueChange, onOpenChange } = useContext(MicSelectorContext);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(currentValue: string) => {
|
||||
onValueChange?.(currentValue);
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
[onValueChange, onOpenChange]
|
||||
);
|
||||
|
||||
return <CommandItem onSelect={handleSelect} {...props} />;
|
||||
};
|
||||
|
||||
export type MicSelectorLabelProps = ComponentProps<"span"> & {
|
||||
device: MediaDeviceInfo;
|
||||
};
|
||||
|
||||
export const MicSelectorLabel = ({
|
||||
device,
|
||||
className,
|
||||
...props
|
||||
}: MicSelectorLabelProps) => {
|
||||
const matches = device.label.match(deviceIdRegex);
|
||||
|
||||
if (!matches) {
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
{device.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const [, deviceId] = matches;
|
||||
const name = device.label.replace(deviceIdRegex, "");
|
||||
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
<span>{name}</span>
|
||||
<span className="text-muted-foreground"> ({deviceId})</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorValueProps = ComponentProps<"span">;
|
||||
|
||||
export const MicSelectorValue = ({
|
||||
className,
|
||||
...props
|
||||
}: MicSelectorValueProps) => {
|
||||
const { data, value } = useContext(MicSelectorContext);
|
||||
const currentDevice = data.find((d) => d.deviceId === value);
|
||||
|
||||
if (!currentDevice) {
|
||||
return (
|
||||
<span className={cn("flex-1 text-left", className)} {...props}>
|
||||
Select microphone...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MicSelectorLabel
|
||||
className={cn("flex-1 text-left", className)}
|
||||
device={currentDevice}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
"outline! border-none! p-0 outline-border! outline-solid!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean;
|
||||
source: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type="target" />}
|
||||
{handles.source && <Handle position={Position.Right} type="source" />}
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
);
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Menu as DropdownMenu,
|
||||
MenuPopup as DropdownMenuContent,
|
||||
MenuItem as DropdownMenuItem,
|
||||
MenuGroupLabel as DropdownMenuLabel,
|
||||
MenuSeparator as DropdownMenuSeparator,
|
||||
MenuTrigger as DropdownMenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
const providers = {
|
||||
chatgpt: {
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in ChatGPT",
|
||||
},
|
||||
claude: {
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Claude",
|
||||
},
|
||||
cursor: {
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL("https://cursor.com/link/prompt");
|
||||
url.searchParams.set("text", text);
|
||||
return url.toString();
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Cursor",
|
||||
},
|
||||
github: {
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in GitHub",
|
||||
},
|
||||
scira: {
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
width="910"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="30"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Scira",
|
||||
},
|
||||
t3: {
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
title: "Open in T3 Chat",
|
||||
},
|
||||
v0: {
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 147 70"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
|
||||
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in v0",
|
||||
},
|
||||
};
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext);
|
||||
if (!context) {
|
||||
throw new Error("OpenIn components must be used within an OpenIn provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => {
|
||||
const contextValue = useMemo(() => ({ query }), [query]);
|
||||
|
||||
return (
|
||||
<OpenInContext.Provider value={contextValue}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[240px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
);
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
);
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
);
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
<DropdownMenuTrigger {...props}>
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.chatgpt.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.chatgpt.icon}</span><span className="flex-1">{providers.chatgpt.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.claude.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.claude.icon}</span><span className="flex-1">{providers.claude.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.t3.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.t3.icon}</span><span className="flex-1">{providers.t3.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.scira.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.scira.icon}</span><span className="flex-1">{providers.scira.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.v0.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.v0.icon}</span><span className="flex-1">{providers.v0.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem {...props} render={<a className="flex items-center gap-2" href={providers.cursor.createUrl(query)} rel="noopener" target="_blank" />}><span className="shrink-0">{providers.cursor.icon}</span><span className="flex-1">{providers.cursor.title}</span><ExternalLinkIcon className="size-4 shrink-0" /></DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowRightIcon, MinusIcon, PackageIcon, PlusIcon } from "lucide-react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type ChangeType = "major" | "minor" | "patch" | "added" | "removed";
|
||||
|
||||
interface PackageInfoContextType {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
changeType?: ChangeType;
|
||||
}
|
||||
|
||||
const PackageInfoContext = createContext<PackageInfoContextType>({
|
||||
name: "",
|
||||
});
|
||||
|
||||
export type PackageInfoHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoHeaderProps) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-between gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoNameProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoNameProps) => {
|
||||
const { name } = useContext(PackageInfoContext);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<PackageIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium font-mono text-sm">{children ?? name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const changeTypeStyles: Record<ChangeType, string> = {
|
||||
added: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
major: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
minor:
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
patch: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
removed: "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const changeTypeIcons: Record<ChangeType, React.ReactNode> = {
|
||||
added: <PlusIcon className="size-3" />,
|
||||
major: <ArrowRightIcon className="size-3" />,
|
||||
minor: <ArrowRightIcon className="size-3" />,
|
||||
patch: <ArrowRightIcon className="size-3" />,
|
||||
removed: <MinusIcon className="size-3" />,
|
||||
};
|
||||
|
||||
export type PackageInfoChangeTypeProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoChangeType = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoChangeTypeProps) => {
|
||||
const { changeType } = useContext(PackageInfoContext);
|
||||
|
||||
if (!changeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 text-xs capitalize",
|
||||
changeTypeStyles[changeType],
|
||||
className
|
||||
)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{changeTypeIcons[changeType]}
|
||||
{children ?? changeType}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoVersionProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoVersion = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoVersionProps) => {
|
||||
const { currentVersion, newVersion } = useContext(PackageInfoContext);
|
||||
|
||||
if (!(currentVersion || newVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex items-center gap-2 font-mono text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{currentVersion && <span>{currentVersion}</span>}
|
||||
{currentVersion && newVersion && (
|
||||
<ArrowRightIcon className="size-3" />
|
||||
)}
|
||||
{newVersion && (
|
||||
<span className="font-medium text-foreground">{newVersion}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
changeType?: ChangeType;
|
||||
};
|
||||
|
||||
export const PackageInfo = ({
|
||||
name,
|
||||
currentVersion,
|
||||
newVersion,
|
||||
changeType,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ changeType, currentVersion, name, newVersion }),
|
||||
[changeType, currentVersion, name, newVersion]
|
||||
);
|
||||
|
||||
return (
|
||||
<PackageInfoContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background p-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<PackageInfoHeader>
|
||||
<PackageInfoName />
|
||||
{changeType && <PackageInfoChangeType />}
|
||||
</PackageInfoHeader>
|
||||
{(currentVersion || newVersion) && <PackageInfoVersion />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PackageInfoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const PackageInfoDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDescriptionProps) => (
|
||||
<p className={cn("mt-2 text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export type PackageInfoContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoContentProps) => (
|
||||
<div className={cn("mt-3 border-t pt-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoDependenciesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoDependencies = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDependenciesProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Dependencies
|
||||
</span>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoDependencyProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export const PackageInfoDependency = ({
|
||||
name,
|
||||
version,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDependencyProps) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="font-mono text-muted-foreground">{name}</span>
|
||||
{version && <span className="font-mono text-xs">{version}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>;
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
"m-4 overflow-hidden rounded-md border bg-card p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||
import {
|
||||
useRive,
|
||||
useStateMachineInput,
|
||||
useViewModel,
|
||||
useViewModelInstance,
|
||||
useViewModelInstanceColor,
|
||||
} from "@rive-app/react-webgl2";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// Delays Rive initialization by one frame so that React Strict Mode's
|
||||
// immediate unmount cycle never creates a WebGL2 context. Only the
|
||||
// second (real) mount will initialise, avoiding context exhaustion.
|
||||
const useStrictModeSafeInit = () => {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setReady(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
setReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ready;
|
||||
};
|
||||
|
||||
export type PersonaState =
|
||||
| "idle"
|
||||
| "listening"
|
||||
| "thinking"
|
||||
| "speaking"
|
||||
| "asleep";
|
||||
|
||||
interface PersonaProps {
|
||||
state: PersonaState;
|
||||
onLoad?: RiveParameters["onLoad"];
|
||||
onLoadError?: RiveParameters["onLoadError"];
|
||||
onReady?: () => void;
|
||||
onPause?: RiveParameters["onPause"];
|
||||
onPlay?: RiveParameters["onPlay"];
|
||||
onStop?: RiveParameters["onStop"];
|
||||
className?: string;
|
||||
variant?: keyof typeof sources;
|
||||
}
|
||||
|
||||
// The state machine name is always 'default' for Elements AI visuals
|
||||
const stateMachine = "default";
|
||||
|
||||
const sources = {
|
||||
command: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
|
||||
},
|
||||
glint: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
|
||||
},
|
||||
halo: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
|
||||
},
|
||||
mana: {
|
||||
dynamicColor: false,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
|
||||
},
|
||||
obsidian: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
|
||||
},
|
||||
opal: {
|
||||
dynamicColor: false,
|
||||
hasModel: false,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
|
||||
},
|
||||
};
|
||||
|
||||
const getCurrentTheme = (): "light" | "dark" => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (document.documentElement.classList.contains("dark")) {
|
||||
return "dark";
|
||||
}
|
||||
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
const useTheme = (enabled: boolean) => {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(getCurrentTheme);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if not enabled (avoids unnecessary observers for non-dynamic-color variants)
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for classList changes
|
||||
const observer = new MutationObserver(() => {
|
||||
setTheme(getCurrentTheme());
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributeFilter: ["class"],
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
// Watch for OS-level theme changes
|
||||
let mql: MediaQueryList | null = null;
|
||||
const handleMediaChange = () => {
|
||||
setTheme(getCurrentTheme());
|
||||
};
|
||||
|
||||
if (window.matchMedia) {
|
||||
mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mql.addEventListener("change", handleMediaChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (mql) {
|
||||
mql.removeEventListener("change", handleMediaChange);
|
||||
}
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return theme;
|
||||
};
|
||||
|
||||
interface PersonaWithModelProps {
|
||||
rive: ReturnType<typeof useRive>["rive"];
|
||||
source: (typeof sources)[keyof typeof sources];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PersonaWithModel = memo(
|
||||
({ rive, source, children }: PersonaWithModelProps) => {
|
||||
const theme = useTheme(source.dynamicColor);
|
||||
const viewModel = useViewModel(rive, { useDefault: true });
|
||||
const viewModelInstance = useViewModelInstance(viewModel, {
|
||||
rive,
|
||||
useDefault: true,
|
||||
});
|
||||
const viewModelInstanceColor = useViewModelInstanceColor(
|
||||
"color",
|
||||
viewModelInstance
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(viewModelInstanceColor && source.dynamicColor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
|
||||
viewModelInstanceColor.setRgb(r, g, b);
|
||||
}, [viewModelInstanceColor, theme, source.dynamicColor]);
|
||||
|
||||
return children;
|
||||
}
|
||||
);
|
||||
|
||||
PersonaWithModel.displayName = "PersonaWithModel";
|
||||
|
||||
interface PersonaWithoutModelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const PersonaWithoutModel = memo(
|
||||
({ children }: PersonaWithoutModelProps) => children
|
||||
);
|
||||
|
||||
PersonaWithoutModel.displayName = "PersonaWithoutModel";
|
||||
|
||||
export const Persona: FC<PersonaProps> = memo(
|
||||
({
|
||||
variant = "obsidian",
|
||||
state = "idle",
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onReady,
|
||||
onPause,
|
||||
onPlay,
|
||||
onStop,
|
||||
className,
|
||||
}) => {
|
||||
const source = sources[variant];
|
||||
|
||||
if (!source) {
|
||||
throw new Error(`Invalid variant: ${variant}`);
|
||||
}
|
||||
|
||||
// Stabilize callbacks to prevent useRive from reinitializing
|
||||
const callbacksRef = useRef({
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onPause,
|
||||
onPlay,
|
||||
onReady,
|
||||
onStop,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onPause,
|
||||
onPlay,
|
||||
onReady,
|
||||
onStop,
|
||||
};
|
||||
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
|
||||
|
||||
const stableCallbacks = useMemo(
|
||||
() => ({
|
||||
onLoad: ((loadedRive) =>
|
||||
callbacksRef.current.onLoad?.(
|
||||
loadedRive
|
||||
)) as RiveParameters["onLoad"],
|
||||
onLoadError: ((err) =>
|
||||
callbacksRef.current.onLoadError?.(
|
||||
err
|
||||
)) as RiveParameters["onLoadError"],
|
||||
onPause: ((event) =>
|
||||
callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
|
||||
onPlay: ((event) =>
|
||||
callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
|
||||
onReady: () => callbacksRef.current.onReady?.(),
|
||||
onStop: ((event) =>
|
||||
callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Delay initialisation by one frame to avoid creating (and leaking)
|
||||
// a WebGL2 context during React Strict Mode's first throw-away mount.
|
||||
const ready = useStrictModeSafeInit();
|
||||
|
||||
const { rive, RiveComponent } = useRive(
|
||||
ready
|
||||
? {
|
||||
autoplay: true,
|
||||
onLoad: stableCallbacks.onLoad,
|
||||
onLoadError: stableCallbacks.onLoadError,
|
||||
onPause: stableCallbacks.onPause,
|
||||
onPlay: stableCallbacks.onPlay,
|
||||
onRiveReady: stableCallbacks.onReady,
|
||||
onStop: stableCallbacks.onStop,
|
||||
src: source.source,
|
||||
stateMachines: stateMachine,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
const listeningInput = useStateMachineInput(
|
||||
rive,
|
||||
stateMachine,
|
||||
"listening"
|
||||
);
|
||||
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
|
||||
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
|
||||
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
|
||||
|
||||
// Rive state machine inputs are mutable objects that must be set via direct
|
||||
// property assignment — this is the intended Rive API, not a React anti-pattern.
|
||||
useEffect(() => {
|
||||
if (listeningInput) {
|
||||
listeningInput.value = state === "listening";
|
||||
}
|
||||
if (thinkingInput) {
|
||||
thinkingInput.value = state === "thinking";
|
||||
}
|
||||
if (speakingInput) {
|
||||
speakingInput.value = state === "speaking";
|
||||
}
|
||||
if (asleepInput) {
|
||||
asleepInput.value = state === "asleep";
|
||||
}
|
||||
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
||||
|
||||
const Component = source.hasModel ? PersonaWithModel : PersonaWithoutModel;
|
||||
|
||||
return (
|
||||
<Component rive={rive} source={source}>
|
||||
<RiveComponent className={cn("size-16 shrink-0", className)} />
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Persona.displayName = "Persona";
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface PlanContextValue {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => {
|
||||
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
|
||||
|
||||
return (
|
||||
<PlanContext.Provider value={contextValue}>
|
||||
<Collapsible data-slot="plan" {...props} render={<Card className={cn("shadow-none", className)} />}>{children}</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent render={<CardContent data-slot="plan-content" {...props} />}></CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger render={<Button className={cn("size-8", className)} data-slot="plan-trigger" size="icon" variant="ghost" {...props} />}><ChevronsUpDownIcon className="size-4" /><span className="sr-only">Toggle plan</span></CollapsibleTrigger>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export interface QueueMessagePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
}
|
||||
|
||||
export interface QueueMessage {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
}
|
||||
|
||||
export interface QueueTodo {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: "pending" | "completed";
|
||||
}
|
||||
|
||||
export type QueueItemProps = ComponentProps<"li">;
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-block size-2.5 rounded-full border",
|
||||
completed
|
||||
? "border-muted-foreground/20 bg-muted-foreground/10"
|
||||
: "border-muted-foreground/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 grow break-words",
|
||||
completed
|
||||
? "text-muted-foreground/50 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-6 text-xs",
|
||||
completed
|
||||
? "text-muted-foreground/40 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn("flex gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"variant" | "size"
|
||||
>;
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<"img">;
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=""
|
||||
className={cn("h-8 w-8 rounded border object-cover", className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<"span">;
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger render={<button className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)} type="button" {...props} />}>{children}</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
||||
count?: number;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueProps = ComponentProps<"div">;
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||
const isExplicitlyClosed = defaultOpen === false;
|
||||
|
||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||
defaultProp: resolvedDefaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||
defaultProp: undefined,
|
||||
prop: durationProp,
|
||||
});
|
||||
|
||||
const hasEverStreamedRef = useRef(isStreaming);
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true;
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
} else if (startTimeRef.current !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||
startTimeRef.current = null;
|
||||
}
|
||||
}, [isStreaming, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
},
|
||||
[setIsOpen]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||
[duration, isOpen, isStreaming, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { ChevronDownIcon, Code } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { getStatusBadge } from "./tool";
|
||||
|
||||
export type SandboxRootProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Sandbox = ({ className, ...props }: SandboxRootProps) => (
|
||||
<Collapsible
|
||||
className={cn(
|
||||
"not-prose group mb-4 w-full overflow-hidden rounded-md border",
|
||||
className
|
||||
)}
|
||||
defaultOpen
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface SandboxHeaderProps {
|
||||
title?: string;
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SandboxHeader = ({
|
||||
className,
|
||||
title,
|
||||
state,
|
||||
...props
|
||||
}: SandboxHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SandboxContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SandboxContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsProps = ComponentProps<typeof Tabs>;
|
||||
|
||||
export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => (
|
||||
<Tabs className={cn("w-full gap-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type SandboxTabsBarProps = ComponentProps<"div">;
|
||||
|
||||
export const SandboxTabsBar = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsBarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center border-border border-t border-b",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsListProps = ComponentProps<typeof TabsList>;
|
||||
|
||||
export const SandboxTabsList = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsListProps) => (
|
||||
<TabsList
|
||||
className={cn("h-auto rounded-none border-0 bg-transparent p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsTriggerProps = ComponentProps<typeof TabsTrigger>;
|
||||
|
||||
export const SandboxTabsTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsTriggerProps) => (
|
||||
<TabsTrigger
|
||||
className={cn(
|
||||
"rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-sm transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabContentProps = ComponentProps<typeof TabsContent>;
|
||||
|
||||
export const SandboxTabContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabContentProps) => (
|
||||
<TabsContent className={cn("mt-0 text-sm", className)} {...props} />
|
||||
);
|
||||
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface SchemaParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
location?: "path" | "query" | "header";
|
||||
}
|
||||
|
||||
interface SchemaProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
properties?: SchemaProperty[];
|
||||
items?: SchemaProperty;
|
||||
}
|
||||
|
||||
interface SchemaDisplayContextType {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
description?: string;
|
||||
parameters?: SchemaParameter[];
|
||||
requestBody?: SchemaProperty[];
|
||||
responseBody?: SchemaProperty[];
|
||||
}
|
||||
|
||||
const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
|
||||
method: "GET",
|
||||
path: "",
|
||||
});
|
||||
|
||||
const methodStyles: Record<HttpMethod, string> = {
|
||||
DELETE: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
GET: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
PATCH:
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
POST: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
PUT: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
||||
};
|
||||
|
||||
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayHeaderProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-3 border-b px-4 py-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayMethodProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const SchemaDisplayMethod = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayMethodProps) => {
|
||||
const { method } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn("font-mono text-xs", methodStyles[method], className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children ?? method}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const SchemaDisplayPath = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayPathProps) => {
|
||||
const { path } = useContext(SchemaDisplayContext);
|
||||
|
||||
// Highlight path parameters
|
||||
const highlightedPath = path.replaceAll(
|
||||
/\{([^}]+)\}/g,
|
||||
'<span class="text-blue-600 dark:text-blue-400">{$1}</span>'
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("font-mono text-sm", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(no-danger)
|
||||
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayDescriptionProps =
|
||||
HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const SchemaDisplayDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayDescriptionProps) => {
|
||||
const { description } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"border-b px-4 py-3 text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? description}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayContentProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayParameterProps = HTMLAttributes<HTMLDivElement> &
|
||||
SchemaParameter;
|
||||
|
||||
export const SchemaDisplayParameter = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
location,
|
||||
className,
|
||||
...props
|
||||
}: SchemaDisplayParameterProps) => (
|
||||
<div className={cn("px-4 py-3 pl-10", className)} {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{location && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayParametersProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayParameters = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayParametersProps) => {
|
||||
const { parameters } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Parameters</span>
|
||||
<Badge className="ml-auto text-xs" variant="secondary">
|
||||
{parameters?.length}
|
||||
</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y border-t">
|
||||
{children ??
|
||||
parameters?.map((param) => (
|
||||
<SchemaDisplayParameter key={param.name} {...param} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayPropertyProps = HTMLAttributes<HTMLDivElement> &
|
||||
SchemaProperty & {
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export const SchemaDisplayProperty = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
properties,
|
||||
items,
|
||||
depth = 0,
|
||||
className,
|
||||
...props
|
||||
}: SchemaDisplayPropertyProps) => {
|
||||
const hasChildren = properties || items;
|
||||
const paddingLeft = 40 + depth * 16;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Collapsible defaultOpen={depth < 2}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
{description && (
|
||||
<p
|
||||
className="pb-2 text-muted-foreground text-sm"
|
||||
style={{ paddingLeft: paddingLeft + 24 }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y border-t">
|
||||
{properties?.map((prop) => (
|
||||
<SchemaDisplayProperty
|
||||
key={prop.name}
|
||||
{...prop}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
{items && (
|
||||
<SchemaDisplayProperty
|
||||
{...items}
|
||||
depth={depth + 1}
|
||||
name={`${name}[]`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("py-3 pr-4", className)}
|
||||
style={{ paddingLeft }}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Spacer for alignment */}
|
||||
<span className="size-4" />
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 pl-6 text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayRequestProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayRequest = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayRequestProps) => {
|
||||
const { requestBody } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Request Body</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{children ??
|
||||
requestBody?.map((prop) => (
|
||||
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayResponseProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayResponse = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayResponseProps) => {
|
||||
const { responseBody } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Response</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{children ??
|
||||
responseBody?.map((prop) => (
|
||||
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayProps = HTMLAttributes<HTMLDivElement> & {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
description?: string;
|
||||
parameters?: SchemaParameter[];
|
||||
requestBody?: SchemaProperty[];
|
||||
responseBody?: SchemaProperty[];
|
||||
};
|
||||
|
||||
export const SchemaDisplay = ({
|
||||
method,
|
||||
path,
|
||||
description,
|
||||
parameters,
|
||||
requestBody,
|
||||
responseBody,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
description,
|
||||
method,
|
||||
parameters,
|
||||
path,
|
||||
requestBody,
|
||||
responseBody,
|
||||
}),
|
||||
[description, method, parameters, path, requestBody, responseBody]
|
||||
);
|
||||
|
||||
return (
|
||||
<SchemaDisplayContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<SchemaDisplayHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<SchemaDisplayMethod />
|
||||
<SchemaDisplayPath />
|
||||
</div>
|
||||
</SchemaDisplayHeader>
|
||||
{description && <SchemaDisplayDescription />}
|
||||
<SchemaDisplayContent>
|
||||
{parameters && parameters.length > 0 && (
|
||||
<SchemaDisplayParameters />
|
||||
)}
|
||||
{requestBody && requestBody.length > 0 && (
|
||||
<SchemaDisplayRequest />
|
||||
)}
|
||||
{responseBody && responseBody.length > 0 && (
|
||||
<SchemaDisplayResponse />
|
||||
)}
|
||||
</SchemaDisplayContent>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SchemaDisplayContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayBodyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayBody = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayBodyProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayExampleProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const SchemaDisplayExample = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayExampleProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import type { CSSProperties, ElementType, JSX } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||
|
||||
// Cache motion components at module level to avoid creating during render
|
||||
const motionComponentCache = new Map<
|
||||
keyof JSX.IntrinsicElements,
|
||||
React.ComponentType<MotionHTMLProps>
|
||||
>();
|
||||
|
||||
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||
let component = motionComponentCache.get(element);
|
||||
if (!component) {
|
||||
component = motion.create(element);
|
||||
motionComponentCache.set(element, component);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = getMotionComponent(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import { Button as InputGroupButton } from "@/components/ui/button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface SnippetContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
const SnippetContext = createContext<SnippetContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
export type SnippetProps = ComponentProps<typeof InputGroup> & {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const Snippet = ({
|
||||
code,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SnippetProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<SnippetContext.Provider value={contextValue}>
|
||||
<InputGroup className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</InputGroup>
|
||||
</SnippetContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type SnippetAddonProps = ComponentProps<typeof InputGroupAddon>;
|
||||
|
||||
export const SnippetAddon = (props: SnippetAddonProps) => (
|
||||
<InputGroupAddon {...props} />
|
||||
);
|
||||
|
||||
export type SnippetTextProps = ComponentProps<typeof InputGroupText>;
|
||||
|
||||
export const SnippetText = ({ className, ...props }: SnippetTextProps) => (
|
||||
<InputGroupText
|
||||
className={cn("pl-2 font-normal text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SnippetInputProps = Omit<
|
||||
ComponentProps<typeof InputGroupInput>,
|
||||
"readOnly" | "value"
|
||||
>;
|
||||
|
||||
export const SnippetInput = ({ className, ...props }: SnippetInputProps) => {
|
||||
const { code } = useContext(SnippetContext);
|
||||
|
||||
return (
|
||||
<InputGroupInput
|
||||
className={cn("text-foreground", className)}
|
||||
readOnly
|
||||
value={code}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type SnippetCopyButtonProps = ComponentProps<typeof InputGroupButton> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const SnippetCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: SnippetCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(SnippetContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label="Copy"
|
||||
className={className}
|
||||
onClick={copyToClipboard}
|
||||
size="icon-sm"
|
||||
title="Copy"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon className="size-3.5" size={14} />}
|
||||
</InputGroupButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 text-primary text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-3 flex w-fit flex-col gap-2",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<"a">;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MicIcon, SquareIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||
onresult:
|
||||
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void)
|
||||
| null;
|
||||
onerror:
|
||||
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void)
|
||||
| null;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
results: SpeechRecognitionResultList;
|
||||
resultIndex: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionResult;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionAlternative;
|
||||
[index: number]: SpeechRecognitionAlternative;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: new () => SpeechRecognition;
|
||||
webkitSpeechRecognition: new () => SpeechRecognition;
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechInputMode = "speech-recognition" | "media-recorder" | "none";
|
||||
|
||||
export type SpeechInputProps = ComponentProps<typeof Button> & {
|
||||
onTranscriptionChange?: (text: string) => void;
|
||||
/**
|
||||
* Callback for when audio is recorded using MediaRecorder fallback.
|
||||
* This is called in browsers that don't support the Web Speech API (Firefox, Safari).
|
||||
* The callback receives an audio Blob that should be sent to a transcription service.
|
||||
* Return the transcribed text, which will be passed to onTranscriptionChange.
|
||||
*/
|
||||
onAudioRecorded?: (audioBlob: Blob) => Promise<string>;
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
const detectSpeechInputMode = (): SpeechInputMode => {
|
||||
if (typeof window === "undefined") {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
|
||||
return "speech-recognition";
|
||||
}
|
||||
|
||||
if ("MediaRecorder" in window && "mediaDevices" in navigator) {
|
||||
return "media-recorder";
|
||||
}
|
||||
|
||||
return "none";
|
||||
};
|
||||
|
||||
export const SpeechInput = ({
|
||||
className,
|
||||
onTranscriptionChange,
|
||||
onAudioRecorded,
|
||||
lang = "en-US",
|
||||
...props
|
||||
}: SpeechInputProps) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [mode] = useState<SpeechInputMode>(detectSpeechInputMode);
|
||||
const [isRecognitionReady, setIsRecognitionReady] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const onTranscriptionChangeRef = useRef<
|
||||
SpeechInputProps["onTranscriptionChange"]
|
||||
>(onTranscriptionChange);
|
||||
const onAudioRecordedRef =
|
||||
useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
|
||||
|
||||
// Keep refs in sync
|
||||
onTranscriptionChangeRef.current = onTranscriptionChange;
|
||||
onAudioRecordedRef.current = onAudioRecorded;
|
||||
|
||||
// Initialize Speech Recognition when mode is speech-recognition
|
||||
useEffect(() => {
|
||||
if (mode !== "speech-recognition") {
|
||||
return;
|
||||
}
|
||||
|
||||
const SpeechRecognition =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const speechRecognition = new SpeechRecognition();
|
||||
|
||||
speechRecognition.continuous = true;
|
||||
speechRecognition.interimResults = true;
|
||||
speechRecognition.lang = lang;
|
||||
|
||||
const handleStart = () => {
|
||||
setIsListening(true);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
const handleResult = (event: Event) => {
|
||||
const speechEvent = event as SpeechRecognitionEvent;
|
||||
let finalTranscript = "";
|
||||
|
||||
for (
|
||||
let i = speechEvent.resultIndex;
|
||||
i < speechEvent.results.length;
|
||||
i += 1
|
||||
) {
|
||||
const result = speechEvent.results[i];
|
||||
if (result.isFinal) {
|
||||
finalTranscript += result[0]?.transcript ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
onTranscriptionChangeRef.current?.(finalTranscript);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
speechRecognition.addEventListener("start", handleStart);
|
||||
speechRecognition.addEventListener("end", handleEnd);
|
||||
speechRecognition.addEventListener("result", handleResult);
|
||||
speechRecognition.addEventListener("error", handleError);
|
||||
|
||||
recognitionRef.current = speechRecognition;
|
||||
setIsRecognitionReady(true);
|
||||
|
||||
return () => {
|
||||
speechRecognition.removeEventListener("start", handleStart);
|
||||
speechRecognition.removeEventListener("end", handleEnd);
|
||||
speechRecognition.removeEventListener("result", handleResult);
|
||||
speechRecognition.removeEventListener("error", handleError);
|
||||
speechRecognition.stop();
|
||||
recognitionRef.current = null;
|
||||
setIsRecognitionReady(false);
|
||||
};
|
||||
}, [mode, lang]);
|
||||
|
||||
// Cleanup MediaRecorder and stream on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Start MediaRecorder recording
|
||||
const startMediaRecorder = useCallback(async () => {
|
||||
if (!onAudioRecordedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunksRef.current = [];
|
||||
|
||||
const handleDataAvailable = (event: BlobEvent) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
|
||||
if (audioBlob.size > 0 && onAudioRecordedRef.current) {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const transcript = await onAudioRecordedRef.current(audioBlob);
|
||||
if (transcript) {
|
||||
onTranscriptionChangeRef.current?.(transcript);
|
||||
}
|
||||
} catch {
|
||||
// Error handling delegated to the onAudioRecorded caller
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsListening(false);
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", handleDataAvailable);
|
||||
mediaRecorder.addEventListener("stop", handleStop);
|
||||
mediaRecorder.addEventListener("error", handleError);
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
mediaRecorder.start();
|
||||
setIsListening(true);
|
||||
} catch {
|
||||
setIsListening(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop MediaRecorder recording
|
||||
const stopMediaRecorder = useCallback(() => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (mode === "speech-recognition" && recognitionRef.current) {
|
||||
if (isListening) {
|
||||
recognitionRef.current.stop();
|
||||
} else {
|
||||
recognitionRef.current.start();
|
||||
}
|
||||
} else if (mode === "media-recorder") {
|
||||
if (isListening) {
|
||||
stopMediaRecorder();
|
||||
} else {
|
||||
startMediaRecorder();
|
||||
}
|
||||
}
|
||||
}, [mode, isListening, startMediaRecorder, stopMediaRecorder]);
|
||||
|
||||
// Determine if button should be disabled
|
||||
const isDisabled =
|
||||
mode === "none" ||
|
||||
(mode === "speech-recognition" && !isRecognitionReady) ||
|
||||
(mode === "media-recorder" && !onAudioRecorded) ||
|
||||
isProcessing;
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
{/* Animated pulse rings */}
|
||||
{isListening &&
|
||||
[0, 1, 2].map((index) => (
|
||||
<div
|
||||
className="absolute inset-0 animate-ping rounded-full border-2 border-red-400/30"
|
||||
key={index}
|
||||
style={{
|
||||
animationDelay: `${index * 0.3}s`,
|
||||
animationDuration: "2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Main record button */}
|
||||
<Button
|
||||
className={cn(
|
||||
"relative z-10 rounded-full transition-all duration-300",
|
||||
isListening
|
||||
? "bg-destructive text-white hover:bg-destructive/80 hover:text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={toggleListening}
|
||||
{...props}
|
||||
>
|
||||
{isProcessing && <Spinner />}
|
||||
{!isProcessing && isListening && <SquareIcon className="size-4" />}
|
||||
{!(isProcessing || isListening) && <MicIcon className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,522 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// Regex patterns for parsing stack traces
|
||||
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
|
||||
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
|
||||
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
|
||||
const AT_PREFIX_REGEX = /^at\s+/;
|
||||
|
||||
interface StackFrame {
|
||||
raw: string;
|
||||
functionName: string | null;
|
||||
filePath: string | null;
|
||||
lineNumber: number | null;
|
||||
columnNumber: number | null;
|
||||
isInternal: boolean;
|
||||
}
|
||||
|
||||
interface ParsedStackTrace {
|
||||
errorType: string | null;
|
||||
errorMessage: string;
|
||||
frames: StackFrame[];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface StackTraceContextValue {
|
||||
trace: ParsedStackTrace;
|
||||
raw: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
||||
}
|
||||
|
||||
const StackTraceContext = createContext<StackTraceContextValue | null>(null);
|
||||
|
||||
const useStackTrace = () => {
|
||||
const context = useContext(StackTraceContext);
|
||||
if (!context) {
|
||||
throw new Error("StackTrace components must be used within StackTrace");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const parseStackFrame = (line: string): StackFrame => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Pattern: at functionName (filePath:line:column)
|
||||
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
|
||||
if (withParensMatch) {
|
||||
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
|
||||
const isInternal =
|
||||
filePath.includes("node_modules") ||
|
||||
filePath.startsWith("node:") ||
|
||||
filePath.includes("internal/");
|
||||
return {
|
||||
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
||||
filePath: filePath ?? null,
|
||||
functionName: functionName ?? null,
|
||||
isInternal,
|
||||
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern: at filePath:line:column (no function name)
|
||||
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
|
||||
if (withoutFnMatch) {
|
||||
const [, filePath, lineNum, colNum] = withoutFnMatch;
|
||||
const isInternal =
|
||||
(filePath?.includes("node_modules") ?? false) ||
|
||||
(filePath?.startsWith("node:") ?? false) ||
|
||||
(filePath?.includes("internal/") ?? false);
|
||||
return {
|
||||
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
||||
filePath: filePath ?? null,
|
||||
functionName: null,
|
||||
isInternal,
|
||||
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: unparseable line
|
||||
return {
|
||||
columnNumber: null,
|
||||
filePath: null,
|
||||
functionName: null,
|
||||
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
|
||||
lineNumber: null,
|
||||
raw: trimmed,
|
||||
};
|
||||
};
|
||||
|
||||
const parseStackTrace = (trace: string): ParsedStackTrace => {
|
||||
const lines = trace.split("\n").filter((line) => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
errorMessage: trace,
|
||||
errorType: null,
|
||||
frames: [],
|
||||
raw: trace,
|
||||
};
|
||||
}
|
||||
|
||||
const firstLine = lines[0].trim();
|
||||
let errorType: string | null = null;
|
||||
let errorMessage = firstLine;
|
||||
|
||||
// Try to extract error type from "ErrorType: message" format
|
||||
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
|
||||
if (errorMatch) {
|
||||
const [, type, msg] = errorMatch;
|
||||
errorType = type;
|
||||
errorMessage = msg || "";
|
||||
}
|
||||
|
||||
// Parse stack frames (lines starting with "at")
|
||||
const frames = lines
|
||||
.slice(1)
|
||||
.filter((line) => line.trim().startsWith("at "))
|
||||
.map(parseStackFrame);
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
errorType,
|
||||
frames,
|
||||
raw: trace,
|
||||
};
|
||||
};
|
||||
|
||||
export type StackTraceProps = ComponentProps<"div"> & {
|
||||
trace: string;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
||||
};
|
||||
|
||||
export const StackTrace = memo(
|
||||
({
|
||||
trace,
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
onFilePathClick,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
|
||||
const parsedTrace = useMemo(() => parseStackTrace(trace), [trace]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
onFilePathClick,
|
||||
raw: trace,
|
||||
setIsOpen,
|
||||
trace: parsedTrace,
|
||||
}),
|
||||
[parsedTrace, trace, isOpen, setIsOpen, onFilePathClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<StackTraceContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StackTraceContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const StackTraceHeader = memo(
|
||||
({ className, children, ...props }: StackTraceHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger {...props} render={<div className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 p-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)} />}>{children}</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceErrorProps = ComponentProps<"div">;
|
||||
|
||||
export const StackTraceError = memo(
|
||||
({ className, children, ...props }: StackTraceErrorProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type StackTraceErrorTypeProps = ComponentProps<"span">;
|
||||
|
||||
export const StackTraceErrorType = memo(
|
||||
({ className, children, ...props }: StackTraceErrorTypeProps) => {
|
||||
const { trace } = useStackTrace();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0 font-semibold text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? trace.errorType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceErrorMessageProps = ComponentProps<"span">;
|
||||
|
||||
export const StackTraceErrorMessage = memo(
|
||||
({ className, children, ...props }: StackTraceErrorMessageProps) => {
|
||||
const { trace } = useStackTrace();
|
||||
|
||||
return (
|
||||
<span className={cn("truncate text-foreground", className)} {...props}>
|
||||
{children ?? trace.errorMessage}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceActionsProps = ComponentProps<"div">;
|
||||
|
||||
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const handleActionsKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
export const StackTraceActions = memo(
|
||||
({ className, children, ...props }: StackTraceActionsProps) => (
|
||||
<div
|
||||
className={cn("flex shrink-0 items-center gap-1", className)}
|
||||
onClick={handleActionsClick}
|
||||
onKeyDown={handleActionsKeyDown}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type StackTraceCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const StackTraceCopyButton = memo(
|
||||
({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { raw } = useStackTrace();
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [raw, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-7", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceExpandButtonProps = ComponentProps<"div">;
|
||||
|
||||
export const StackTraceExpandButton = memo(
|
||||
({ className, ...props }: StackTraceExpandButtonProps) => {
|
||||
const { isOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex size-7 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
maxHeight?: number;
|
||||
};
|
||||
|
||||
export const StackTraceContent = memo(
|
||||
({
|
||||
className,
|
||||
maxHeight = 400,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceContentProps) => {
|
||||
const { isOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"overflow-auto border-t bg-muted/30",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
style={{ maxHeight }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceFramesProps = ComponentProps<"div"> & {
|
||||
showInternalFrames?: boolean;
|
||||
};
|
||||
|
||||
interface FilePathButtonProps {
|
||||
frame: StackFrame;
|
||||
onFilePathClick?: (
|
||||
filePath: string,
|
||||
lineNumber?: number,
|
||||
columnNumber?: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
const FilePathButton = memo(
|
||||
({ frame, onFilePathClick }: FilePathButtonProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (frame.filePath) {
|
||||
onFilePathClick?.(
|
||||
frame.filePath,
|
||||
frame.lineNumber ?? undefined,
|
||||
frame.columnNumber ?? undefined
|
||||
);
|
||||
}
|
||||
}, [frame, onFilePathClick]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"underline decoration-dotted hover:text-primary",
|
||||
onFilePathClick && "cursor-pointer"
|
||||
)}
|
||||
disabled={!onFilePathClick}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{frame.filePath}
|
||||
{frame.lineNumber !== null && `:${frame.lineNumber}`}
|
||||
{frame.columnNumber !== null && `:${frame.columnNumber}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FilePathButton.displayName = "FilePathButton";
|
||||
|
||||
export const StackTraceFrames = memo(
|
||||
({
|
||||
className,
|
||||
showInternalFrames = true,
|
||||
...props
|
||||
}: StackTraceFramesProps) => {
|
||||
const { trace, onFilePathClick } = useStackTrace();
|
||||
|
||||
const framesToShow = showInternalFrames
|
||||
? trace.frames
|
||||
: trace.frames.filter((f) => !f.isInternal);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1 p-3", className)} {...props}>
|
||||
{framesToShow.map((frame) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
frame.isInternal
|
||||
? "text-muted-foreground/50"
|
||||
: "text-foreground/90"
|
||||
)}
|
||||
key={frame.raw}
|
||||
>
|
||||
<span className="text-muted-foreground">at </span>
|
||||
{frame.functionName && (
|
||||
<span className={frame.isInternal ? "" : "text-foreground"}>
|
||||
{frame.functionName}{" "}
|
||||
</span>
|
||||
)}
|
||||
{frame.filePath && (
|
||||
<>
|
||||
<span className="text-muted-foreground">(</span>
|
||||
<FilePathButton
|
||||
frame={frame}
|
||||
onFilePathClick={onFilePathClick}
|
||||
/>
|
||||
<span className="text-muted-foreground">)</span>
|
||||
</>
|
||||
)}
|
||||
{!(frame.filePath || frame.functionName) && (
|
||||
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{framesToShow.length === 0 && (
|
||||
<div className="text-muted-foreground text-xs">No stack frames</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StackTrace.displayName = "StackTrace";
|
||||
StackTraceHeader.displayName = "StackTraceHeader";
|
||||
StackTraceError.displayName = "StackTraceError";
|
||||
StackTraceErrorType.displayName = "StackTraceErrorType";
|
||||
StackTraceErrorMessage.displayName = "StackTraceErrorMessage";
|
||||
StackTraceActions.displayName = "StackTraceActions";
|
||||
StackTraceCopyButton.displayName = "StackTraceCopyButton";
|
||||
StackTraceExpandButton.displayName = "StackTraceExpandButton";
|
||||
StackTraceContent.displayName = "StackTraceContent";
|
||||
StackTraceFrames.displayName = "StackTraceFrames";
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollBar,
|
||||
} from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||
suggestion: string;
|
||||
onClick?: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
onClick?.(suggestion);
|
||||
}, [onClick, suggestion]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<SearchIcon className="size-4" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Ansi from "ansi-to-react";
|
||||
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface TerminalContextType {
|
||||
output: string;
|
||||
isStreaming: boolean;
|
||||
autoScroll: boolean;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
const TerminalContext = createContext<TerminalContextType>({
|
||||
autoScroll: true,
|
||||
isStreaming: false,
|
||||
output: "",
|
||||
});
|
||||
|
||||
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-zinc-800 border-b px-4 py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalTitle = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalTitleProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-2 text-sm text-zinc-400", className)}
|
||||
{...props}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
{children ?? "Terminal"}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalStatus = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalStatusProps) => {
|
||||
const { isStreaming } = useContext(TerminalContext);
|
||||
|
||||
if (!isStreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center gap-2 text-xs text-zinc-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const TerminalCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { output } = useContext(TerminalContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [output, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
|
||||
className
|
||||
)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalClearButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const TerminalClearButton = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalClearButtonProps) => {
|
||||
const { onClear } = useContext(TerminalContext);
|
||||
|
||||
if (!onClear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
|
||||
className
|
||||
)}
|
||||
onClick={onClear}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Trash2Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalContentProps) => {
|
||||
const { output, isStreaming, autoScroll } = useContext(TerminalContext);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [output, autoScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed",
|
||||
className
|
||||
)}
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
<Ansi>{output}</Ansi>
|
||||
{isStreaming && (
|
||||
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
|
||||
output: string;
|
||||
isStreaming?: boolean;
|
||||
autoScroll?: boolean;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export const Terminal = ({
|
||||
output,
|
||||
isStreaming = false,
|
||||
autoScroll = true,
|
||||
onClear,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ autoScroll, isStreaming, onClear, output }),
|
||||
[autoScroll, isStreaming, onClear, output]
|
||||
);
|
||||
|
||||
return (
|
||||
<TerminalContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TerminalHeader>
|
||||
<TerminalTitle />
|
||||
<div className="flex items-center gap-1">
|
||||
<TerminalStatus />
|
||||
<TerminalActions>
|
||||
<TerminalCopyButton />
|
||||
{onClear && <TerminalClearButton />}
|
||||
</TerminalActions>
|
||||
</div>
|
||||
</TerminalHeader>
|
||||
<TerminalContent />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TerminalContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type TestStatus = "passed" | "failed" | "skipped" | "running";
|
||||
|
||||
interface TestResultsSummary {
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface TestResultsContextType {
|
||||
summary?: TestResultsSummary;
|
||||
}
|
||||
|
||||
const TestResultsContext = createContext<TestResultsContextType>({});
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestResultsDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsDurationProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary?.duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children ?? formatDuration(summary.duration)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsSummary = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsSummaryProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<Badge
|
||||
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
{summary.passed} passed
|
||||
</Badge>
|
||||
{summary.failed > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<XCircleIcon className="size-3" />
|
||||
{summary.failed} failed
|
||||
</Badge>
|
||||
)}
|
||||
{summary.skipped > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CircleIcon className="size-3" />
|
||||
{summary.skipped} skipped
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
summary?: TestResultsSummary;
|
||||
};
|
||||
|
||||
export const TestResults = ({
|
||||
summary,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProps) => {
|
||||
const contextValue = useMemo(() => ({ summary }), [summary]);
|
||||
|
||||
return (
|
||||
<TestResultsContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ??
|
||||
(summary && (
|
||||
<TestResultsHeader>
|
||||
<TestResultsSummary />
|
||||
<TestResultsDuration />
|
||||
</TestResultsHeader>
|
||||
))}
|
||||
</div>
|
||||
</TestResultsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsProgress = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProgressProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passedPercent = (summary.passed / summary.total) * 100;
|
||||
const failedPercent = (summary.failed / summary.total) * 100;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-green-500 transition-all"
|
||||
style={{ width: `${passedPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${failedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground text-xs">
|
||||
<span>
|
||||
{summary.passed}/{summary.total} tests passed
|
||||
</span>
|
||||
<span>{passedPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsContentProps) => (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TestSuiteContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
}
|
||||
|
||||
const TestSuiteContext = createContext<TestSuiteContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
const statusStyles: Record<TestStatus, string> = {
|
||||
failed: "text-red-600 dark:text-red-400",
|
||||
passed: "text-green-600 dark:text-green-400",
|
||||
running: "text-blue-600 dark:text-blue-400",
|
||||
skipped: "text-yellow-600 dark:text-yellow-400",
|
||||
};
|
||||
|
||||
const statusIcons: Record<TestStatus, React.ReactNode> = {
|
||||
failed: <XCircleIcon className="size-4" />,
|
||||
passed: <CheckCircle2Icon className="size-4" />,
|
||||
running: <CircleDotIcon className="size-4 animate-pulse" />,
|
||||
skipped: <CircleIcon className="size-4" />,
|
||||
};
|
||||
|
||||
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
|
||||
<span className={cn("shrink-0", statusStyles[status])}>
|
||||
{statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
};
|
||||
|
||||
export const TestSuite = ({
|
||||
name,
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteProps) => {
|
||||
const contextValue = useMemo(() => ({ name, status }), [name, status]);
|
||||
|
||||
return (
|
||||
<TestSuiteContext.Provider value={contextValue}>
|
||||
<Collapsible className={cn("rounded-lg border", className)} {...props}>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</TestSuiteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const TestSuiteName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteNameProps) => {
|
||||
const { name, status } = useContext(TestSuiteContext);
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<TestStatusIcon status={status} />
|
||||
<span className="font-medium text-sm">{children ?? name}</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
passed?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
};
|
||||
|
||||
export const TestSuiteStats = ({
|
||||
passed = 0,
|
||||
failed = 0,
|
||||
skipped = 0,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteStatsProps) => (
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-2 text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{passed > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{passed} passed
|
||||
</span>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
{failed} failed
|
||||
</span>
|
||||
)}
|
||||
{skipped > 0 && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
{skipped} skipped
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TestSuiteContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t", className)} {...props}>
|
||||
<div className="divide-y">{children}</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
interface TestContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const TestContext = createContext<TestContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestName = ({ className, children, ...props }: TestNameProps) => {
|
||||
const { name } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span className={cn("flex-1", className)} {...props}>
|
||||
{children ?? name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestDurationProps) => {
|
||||
const { duration } = useContext(TestContext);
|
||||
|
||||
if (duration === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-muted-foreground text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${duration}ms`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestStatus = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestStatusProps) => {
|
||||
const { status } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0", statusStyles[status], className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const Test = ({
|
||||
name,
|
||||
status,
|
||||
duration,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, name, status }),
|
||||
[duration, name, status]
|
||||
);
|
||||
|
||||
return (
|
||||
<TestContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TestStatus />
|
||||
<TestName />
|
||||
{duration !== undefined && <TestDuration />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TestContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestError = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const TestErrorMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorMessageProps) => (
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium text-red-700 text-sm dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const TestErrorStack = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorStackProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DynamicToolUIPart, ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolPart = ToolUIPart | DynamicToolUIPart;
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
className?: string;
|
||||
} & (
|
||||
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
|
||||
| {
|
||||
type: DynamicToolUIPart["type"];
|
||||
state: DynamicToolUIPart["state"];
|
||||
toolName: string;
|
||||
}
|
||||
);
|
||||
|
||||
const statusLabels: Record<ToolPart["state"], string> = {
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"input-available": "Running",
|
||||
"input-streaming": "Pending",
|
||||
"output-available": "Completed",
|
||||
"output-denied": "Denied",
|
||||
"output-error": "Error",
|
||||
};
|
||||
|
||||
const statusIcons: Record<ToolPart["state"], ReactNode> = {
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
};
|
||||
|
||||
export const getStatusBadge = (status: ToolPart["state"]) => (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{statusIcons[status]}
|
||||
{statusLabels[status]}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
toolName,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const derivedName =
|
||||
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title ?? derivedName}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolPart["output"];
|
||||
errorText: ToolPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
|
||||
className
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
type TranscriptionSegment = TranscriptionResult["segments"][number];
|
||||
|
||||
interface TranscriptionContextValue {
|
||||
segments: TranscriptionSegment[];
|
||||
currentTime: number;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
const TranscriptionContext = createContext<TranscriptionContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useTranscription = () => {
|
||||
const context = useContext(TranscriptionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Transcription components must be used within Transcription"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & {
|
||||
segments: TranscriptionSegment[];
|
||||
currentTime?: number;
|
||||
onSeek?: (time: number) => void;
|
||||
children: (segment: TranscriptionSegment, index: number) => ReactNode;
|
||||
};
|
||||
|
||||
export const Transcription = ({
|
||||
segments,
|
||||
currentTime: externalCurrentTime,
|
||||
onSeek,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TranscriptionProps) => {
|
||||
const [currentTime, setCurrentTime] = useControllableState({
|
||||
defaultProp: 0,
|
||||
onChange: onSeek,
|
||||
prop: externalCurrentTime,
|
||||
});
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }),
|
||||
[currentTime, onSeek, setCurrentTime, segments]
|
||||
);
|
||||
|
||||
return (
|
||||
<TranscriptionContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-1 text-sm leading-relaxed",
|
||||
className
|
||||
)}
|
||||
data-slot="transcription"
|
||||
{...props}
|
||||
>
|
||||
{segments
|
||||
.filter((segment) => segment.text.trim())
|
||||
.map((segment, index) => children(segment, index))}
|
||||
</div>
|
||||
</TranscriptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TranscriptionSegmentProps = ComponentProps<"button"> & {
|
||||
segment: TranscriptionSegment;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export const TranscriptionSegment = ({
|
||||
segment,
|
||||
index,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: TranscriptionSegmentProps) => {
|
||||
const { currentTime, onSeek } = useTranscription();
|
||||
|
||||
const isActive =
|
||||
currentTime >= segment.startSecond && currentTime < segment.endSecond;
|
||||
const isPast = currentTime >= segment.endSecond;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onSeek) {
|
||||
onSeek(segment.startSecond);
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[onSeek, segment.startSecond, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline text-left",
|
||||
isActive && "text-primary",
|
||||
isPast && "text-muted-foreground",
|
||||
!(isActive || isPast) && "text-muted-foreground/60",
|
||||
onSeek && "cursor-pointer hover:text-foreground",
|
||||
!onSeek && "cursor-default",
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
data-index={index}
|
||||
data-slot="transcription-segment"
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{segment.text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CircleSmallIcon,
|
||||
MarsIcon,
|
||||
MarsStrokeIcon,
|
||||
NonBinaryIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
TransgenderIcon,
|
||||
VenusAndMarsIcon,
|
||||
VenusIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
interface VoiceSelectorContextValue {
|
||||
value: string | undefined;
|
||||
setValue: (value: string | undefined) => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const VoiceSelectorContext = createContext<VoiceSelectorContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export const useVoiceSelector = () => {
|
||||
const context = useContext(VoiceSelectorContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"VoiceSelector components must be used within VoiceSelector"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type VoiceSelectorProps = ComponentProps<typeof Dialog> & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string | undefined) => void;
|
||||
};
|
||||
|
||||
export const VoiceSelector = ({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorProps) => {
|
||||
const [value, setValue] = useControllableState({
|
||||
defaultProp: defaultValue,
|
||||
onChange: onValueChange,
|
||||
prop: valueProp,
|
||||
});
|
||||
|
||||
const [open, setOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: openProp,
|
||||
});
|
||||
|
||||
const voiceSelectorContext = useMemo(
|
||||
() => ({ open, setOpen, setValue, value }),
|
||||
[value, setValue, open, setOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<VoiceSelectorContext.Provider value={voiceSelectorContext}>
|
||||
<Dialog onOpenChange={setOpen} open={open} {...props}>
|
||||
{children}
|
||||
</Dialog>
|
||||
</VoiceSelectorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const VoiceSelectorTrigger = (props: VoiceSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const VoiceSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Voice Selector",
|
||||
...props
|
||||
}: VoiceSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn("p-0", className)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type VoiceSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const VoiceSelectorDialog = (props: VoiceSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const VoiceSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const VoiceSelectorList = (props: VoiceSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const VoiceSelectorEmpty = (props: VoiceSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const VoiceSelectorGroup = (props: VoiceSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const VoiceSelectorItem = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorItemProps) => (
|
||||
<CommandItem className={cn("px-4 py-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const VoiceSelectorShortcut = (props: VoiceSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const VoiceSelectorSeparator = (props: VoiceSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorGenderProps = ComponentProps<"span"> & {
|
||||
value?:
|
||||
| "male"
|
||||
| "female"
|
||||
| "transgender"
|
||||
| "androgyne"
|
||||
| "non-binary"
|
||||
| "intersex";
|
||||
};
|
||||
|
||||
export const VoiceSelectorGender = ({
|
||||
className,
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorGenderProps) => {
|
||||
let icon: ReactNode | null = null;
|
||||
|
||||
switch (value) {
|
||||
case "male": {
|
||||
icon = <MarsIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "female": {
|
||||
icon = <VenusIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "transgender": {
|
||||
icon = <TransgenderIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "androgyne": {
|
||||
icon = <MarsStrokeIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "non-binary": {
|
||||
icon = <NonBinaryIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "intersex": {
|
||||
icon = <VenusAndMarsIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
icon = <CircleSmallIcon className="size-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
|
||||
{children ?? icon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorAccentProps = ComponentProps<"span"> & {
|
||||
value?:
|
||||
| "american"
|
||||
| "british"
|
||||
| "australian"
|
||||
| "canadian"
|
||||
| "irish"
|
||||
| "scottish"
|
||||
| "indian"
|
||||
| "south-african"
|
||||
| "new-zealand"
|
||||
| "spanish"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "portuguese"
|
||||
| "brazilian"
|
||||
| "mexican"
|
||||
| "argentinian"
|
||||
| "japanese"
|
||||
| "chinese"
|
||||
| "korean"
|
||||
| "russian"
|
||||
| "arabic"
|
||||
| "dutch"
|
||||
| "swedish"
|
||||
| "norwegian"
|
||||
| "danish"
|
||||
| "finnish"
|
||||
| "polish"
|
||||
| "turkish"
|
||||
| "greek"
|
||||
| string;
|
||||
};
|
||||
|
||||
export const VoiceSelectorAccent = ({
|
||||
className,
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorAccentProps) => {
|
||||
let emoji: string | null = null;
|
||||
|
||||
switch (value) {
|
||||
case "american": {
|
||||
emoji = "🇺🇸";
|
||||
break;
|
||||
}
|
||||
case "british": {
|
||||
emoji = "🇬🇧";
|
||||
break;
|
||||
}
|
||||
case "australian": {
|
||||
emoji = "🇦🇺";
|
||||
break;
|
||||
}
|
||||
case "canadian": {
|
||||
emoji = "🇨🇦";
|
||||
break;
|
||||
}
|
||||
case "irish": {
|
||||
emoji = "🇮🇪";
|
||||
break;
|
||||
}
|
||||
case "scottish": {
|
||||
emoji = "🏴";
|
||||
break;
|
||||
}
|
||||
case "indian": {
|
||||
emoji = "🇮🇳";
|
||||
break;
|
||||
}
|
||||
case "south-african": {
|
||||
emoji = "🇿🇦";
|
||||
break;
|
||||
}
|
||||
case "new-zealand": {
|
||||
emoji = "🇳🇿";
|
||||
break;
|
||||
}
|
||||
case "spanish": {
|
||||
emoji = "🇪🇸";
|
||||
break;
|
||||
}
|
||||
case "french": {
|
||||
emoji = "🇫🇷";
|
||||
break;
|
||||
}
|
||||
case "german": {
|
||||
emoji = "🇩🇪";
|
||||
break;
|
||||
}
|
||||
case "italian": {
|
||||
emoji = "🇮🇹";
|
||||
break;
|
||||
}
|
||||
case "portuguese": {
|
||||
emoji = "🇵🇹";
|
||||
break;
|
||||
}
|
||||
case "brazilian": {
|
||||
emoji = "🇧🇷";
|
||||
break;
|
||||
}
|
||||
case "mexican": {
|
||||
emoji = "🇲🇽";
|
||||
break;
|
||||
}
|
||||
case "argentinian": {
|
||||
emoji = "🇦🇷";
|
||||
break;
|
||||
}
|
||||
case "japanese": {
|
||||
emoji = "🇯🇵";
|
||||
break;
|
||||
}
|
||||
case "chinese": {
|
||||
emoji = "🇨🇳";
|
||||
break;
|
||||
}
|
||||
case "korean": {
|
||||
emoji = "🇰🇷";
|
||||
break;
|
||||
}
|
||||
case "russian": {
|
||||
emoji = "🇷🇺";
|
||||
break;
|
||||
}
|
||||
case "arabic": {
|
||||
emoji = "🇸🇦";
|
||||
break;
|
||||
}
|
||||
case "dutch": {
|
||||
emoji = "🇳🇱";
|
||||
break;
|
||||
}
|
||||
case "swedish": {
|
||||
emoji = "🇸🇪";
|
||||
break;
|
||||
}
|
||||
case "norwegian": {
|
||||
emoji = "🇳🇴";
|
||||
break;
|
||||
}
|
||||
case "danish": {
|
||||
emoji = "🇩🇰";
|
||||
break;
|
||||
}
|
||||
case "finnish": {
|
||||
emoji = "🇫🇮";
|
||||
break;
|
||||
}
|
||||
case "polish": {
|
||||
emoji = "🇵🇱";
|
||||
break;
|
||||
}
|
||||
case "turkish": {
|
||||
emoji = "🇹🇷";
|
||||
break;
|
||||
}
|
||||
case "greek": {
|
||||
emoji = "🇬🇷";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
emoji = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
|
||||
{children ?? emoji}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorAgeProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorAge = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorAgeProps) => (
|
||||
<span
|
||||
className={cn("text-muted-foreground text-xs tabular-nums", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type VoiceSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorNameProps) => (
|
||||
<span
|
||||
className={cn("flex-1 truncate text-left font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type VoiceSelectorDescriptionProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorDescriptionProps) => (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorAttributesProps = ComponentProps<"div">;
|
||||
|
||||
export const VoiceSelectorAttributes = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorAttributesProps) => (
|
||||
<div className={cn("flex items-center text-xs", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type VoiceSelectorBulletProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorBullet = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorBulletProps) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("select-none text-border", className)}
|
||||
{...props}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
);
|
||||
|
||||
export type VoiceSelectorPreviewProps = Omit<
|
||||
ComponentProps<"button">,
|
||||
"children"
|
||||
> & {
|
||||
playing?: boolean;
|
||||
loading?: boolean;
|
||||
onPlay?: () => void;
|
||||
};
|
||||
|
||||
export const VoiceSelectorPreview = ({
|
||||
className,
|
||||
playing,
|
||||
loading,
|
||||
onPlay,
|
||||
onClick,
|
||||
...props
|
||||
}: VoiceSelectorPreviewProps) => {
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onClick?.(event);
|
||||
onPlay?.();
|
||||
},
|
||||
[onClick, onPlay]
|
||||
);
|
||||
|
||||
let icon = <PlayIcon className="size-3" />;
|
||||
|
||||
if (loading) {
|
||||
icon = <Spinner className="size-3" />;
|
||||
} else if (playing) {
|
||||
icon = <PauseIcon className="size-3" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={playing ? "Pause preview" : "Play preview"}
|
||||
className={cn("size-6", className)}
|
||||
disabled={loading}
|
||||
onClick={handleClick}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface WebPreviewContextValue {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("WebPreview components must be used within a WebPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<"div"> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = "",
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
},
|
||||
[onUrlChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo<WebPreviewContextValue>(
|
||||
() => ({
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
setUrl: handleUrlChange,
|
||||
url,
|
||||
}),
|
||||
[consoleOpen, handleUrlChange, url]
|
||||
);
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col rounded-lg border bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<"div">;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1 border-b p-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<Button className="h-8 w-8 p-0 hover:text-foreground" disabled={disabled} onClick={onClick} size="sm" variant="ghost" {...props} />}>{children}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
const [prevUrl, setPrevUrl] = useState(url);
|
||||
const [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally (derived state pattern)
|
||||
if (url !== prevUrl) {
|
||||
setPrevUrl(url);
|
||||
setInputValue(url);
|
||||
}
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
},
|
||||
[setUrl, onKeyDown]
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn("size-full", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(iframe-missing-sandbox)
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
|
||||
logs?: {
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger render={<Button className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50" variant="ghost" />}>Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180"
|
||||
)}
|
||||
/></CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"px-4 pb-4",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
log.level === "error" && "text-destructive",
|
||||
log.level === "warn" && "text-yellow-600",
|
||||
log.level === "log" && "text-foreground"
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${log.level}-${log.message}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client";
|
||||
|
||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// All figures here are mock/placeholder data — there is no analytics backend.
|
||||
// They illustrate the dashboard layout (clinic profits, patient volume, etc.).
|
||||
|
||||
type Metric = {
|
||||
label: string;
|
||||
value: string;
|
||||
// % change vs the previous period; sign drives the up/down badge.
|
||||
delta?: number;
|
||||
points?: number[];
|
||||
// Tailwind text-color class tinting the sparkline (via currentColor).
|
||||
tone?: string;
|
||||
};
|
||||
|
||||
function DeltaBadge({ delta }: { delta: number }) {
|
||||
const up = delta >= 0;
|
||||
return (
|
||||
<Badge variant={up ? "secondary" : "destructive"}>
|
||||
{up ? (
|
||||
<TrendingUp className="size-3" />
|
||||
) : (
|
||||
<TrendingDown className="size-3" />
|
||||
)}
|
||||
{up ? "+" : ""}
|
||||
{delta}%
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, delta, points, tone }: Metric) {
|
||||
return (
|
||||
<Card className="gap-3 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
{typeof delta === "number" && <DeltaBadge delta={delta} />}
|
||||
</div>
|
||||
<div className="font-semibold text-2xl text-foreground tracking-tight">
|
||||
{value}
|
||||
</div>
|
||||
{points && (
|
||||
<div className={cn("h-10", tone ?? "text-primary")}>
|
||||
<Sparkline points={points} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">{title}</h2>
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const revenue: Metric[] = [
|
||||
{
|
||||
label: "Revenue (this month)",
|
||||
value: "$48.2k",
|
||||
delta: 12,
|
||||
points: [31, 34, 33, 38, 41, 44, 48.2],
|
||||
tone: "text-emerald-500",
|
||||
},
|
||||
{
|
||||
label: "Profit margin",
|
||||
value: "32%",
|
||||
delta: 4,
|
||||
points: [24, 26, 25, 28, 30, 31, 32],
|
||||
tone: "text-emerald-500",
|
||||
},
|
||||
{
|
||||
label: "Outstanding balances",
|
||||
value: "$6.4k",
|
||||
delta: -8,
|
||||
points: [9.1, 8.4, 8.8, 7.6, 7.0, 6.7, 6.4],
|
||||
tone: "text-amber-500",
|
||||
},
|
||||
];
|
||||
|
||||
const volume: Metric[] = [
|
||||
{
|
||||
label: "New patients",
|
||||
value: "38",
|
||||
delta: 9,
|
||||
points: [22, 27, 25, 30, 33, 35, 38],
|
||||
tone: "text-sky-500",
|
||||
},
|
||||
{
|
||||
label: "Returning patients",
|
||||
value: "212",
|
||||
delta: 3,
|
||||
points: [188, 196, 201, 199, 205, 209, 212],
|
||||
tone: "text-sky-500",
|
||||
},
|
||||
{
|
||||
label: "Active patients",
|
||||
value: "1,284",
|
||||
delta: 2,
|
||||
points: [1190, 1210, 1230, 1242, 1260, 1271, 1284],
|
||||
tone: "text-sky-500",
|
||||
},
|
||||
];
|
||||
|
||||
const appointments: Metric[] = [
|
||||
{
|
||||
label: "Appointments this week",
|
||||
value: "146",
|
||||
delta: 6,
|
||||
points: [120, 128, 131, 134, 139, 142, 146],
|
||||
tone: "text-violet-500",
|
||||
},
|
||||
{ label: "No-show rate", value: "4.1%", delta: -2 },
|
||||
{ label: "Schedule utilization", value: "87%", delta: 5 },
|
||||
];
|
||||
|
||||
const operations: Metric[] = [
|
||||
{
|
||||
label: "Avg. wait time",
|
||||
value: "14 min",
|
||||
delta: -11,
|
||||
points: [22, 21, 19, 18, 17, 15, 14],
|
||||
tone: "text-amber-500",
|
||||
},
|
||||
{
|
||||
label: "Prescriptions issued",
|
||||
value: "318",
|
||||
delta: 7,
|
||||
points: [270, 281, 290, 297, 305, 312, 318],
|
||||
tone: "text-primary",
|
||||
},
|
||||
{ label: "Top diagnosis", value: "Hypertension" },
|
||||
];
|
||||
|
||||
export function AnalysisView() {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">Analysis</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Clinic performance at a glance. Figures are sample data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Section
|
||||
description="Earnings, margin and receivables"
|
||||
title="Revenue & profit"
|
||||
>
|
||||
{revenue.map((m) => (
|
||||
<StatCard key={m.label} {...m} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
description="New, returning and active patients"
|
||||
title="Patient volume"
|
||||
>
|
||||
{volume.map((m) => (
|
||||
<StatCard key={m.label} {...m} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
description="Bookings, attendance and capacity"
|
||||
title="Appointments & schedule"
|
||||
>
|
||||
{appointments.map((m) => (
|
||||
<StatCard key={m.label} {...m} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
description="Throughput, prescribing and case mix"
|
||||
title="Clinic operations"
|
||||
>
|
||||
{operations.map((m) => (
|
||||
<StatCard key={m.label} {...m} />
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarDays, Search } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { TODAY } from "@/components/appointments/appointments-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { listPatients, type Patient } from "@/lib/patients";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
export type NewAppointment = {
|
||||
fileNumber: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
date: string; // ISO YYYY-MM-DD
|
||||
time: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
// Local-date ISO key (avoids UTC drift from toISOString).
|
||||
const keyOf = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
const TYPES = [
|
||||
"Follow-up",
|
||||
"New patient",
|
||||
"Consultation",
|
||||
"Lab review",
|
||||
"Vaccination",
|
||||
];
|
||||
|
||||
const controlClass =
|
||||
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// Compact "New appointment" dialog. The patient is chosen via a quick search by
|
||||
// name or file number; the rest is the slot. Appointments are mock-only, so a
|
||||
// confirmed entry is handed back to the page via onAdd.
|
||||
export function AddAppointmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdd,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (appt: NewAppointment) => void;
|
||||
}) {
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Patient | null>(null);
|
||||
const [date, setDate] = useState<Date>(() => new Date(`${TODAY}T00:00:00`));
|
||||
const [dateOpen, setDateOpen] = useState(false);
|
||||
const [time, setTime] = useState("09:00");
|
||||
const [type, setType] = useState(TYPES[0]);
|
||||
const [provider, setProvider] = useState("");
|
||||
|
||||
// Load patients lazily when the dialog opens (for the quick search).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
listPatients()
|
||||
.then((data) => {
|
||||
if (active) setPatients(data);
|
||||
})
|
||||
.catch(() => {
|
||||
/* search just stays empty */
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
return patients
|
||||
.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) || p.fileNumber.includes(q),
|
||||
)
|
||||
.slice(0, 6);
|
||||
}, [patients, query]);
|
||||
|
||||
const reset = () => {
|
||||
setQuery("");
|
||||
setSelected(null);
|
||||
setDate(new Date(`${TODAY}T00:00:00`));
|
||||
setDateOpen(false);
|
||||
setTime("09:00");
|
||||
setType(TYPES[0]);
|
||||
setProvider("");
|
||||
};
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected) {
|
||||
notify.error("Pick a patient", "Search and select a patient first.");
|
||||
return;
|
||||
}
|
||||
onAdd({
|
||||
fileNumber: selected.fileNumber,
|
||||
name: selected.name,
|
||||
initials: selected.initials,
|
||||
date: keyOf(date),
|
||||
time,
|
||||
type,
|
||||
provider: provider.trim() || selected.pcp,
|
||||
});
|
||||
notify.success("Appointment added", `${selected.name} at ${time}`);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(o) => {
|
||||
onOpenChange(o);
|
||||
if (!o) reset();
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New appointment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for a patient by name or file number, then set the slot.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="contents" onSubmit={submit}>
|
||||
<DialogPanel className="flex flex-col gap-4">
|
||||
<Field label="Patient">
|
||||
{selected ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-2xl border bg-input/30 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
File #{selected.fileNumber}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
setQuery("");
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Change
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search name or file number"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
{query.trim() && (
|
||||
<div className="max-h-56 overflow-y-auto rounded-2xl border bg-popover p-1">
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2 py-2 text-muted-foreground text-sm">
|
||||
No patients found.
|
||||
</p>
|
||||
) : (
|
||||
matches.map((p) => (
|
||||
<button
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent"
|
||||
key={p.fileNumber}
|
||||
onClick={() => {
|
||||
setSelected(p);
|
||||
setQuery("");
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate text-foreground text-sm">
|
||||
{p.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
#{p.fileNumber}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">Date</span>
|
||||
<Popover onOpenChange={setDateOpen} open={dateOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
className="w-full justify-start font-normal"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<CalendarDays className="size-4" />
|
||||
{date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverPopup>
|
||||
<Calendar
|
||||
mode="single"
|
||||
onSelect={(d) => {
|
||||
if (d) {
|
||||
setDate(d);
|
||||
setDateOpen(false);
|
||||
}
|
||||
}}
|
||||
selected={date}
|
||||
/>
|
||||
</PopoverPopup>
|
||||
</Popover>
|
||||
</div>
|
||||
<Field label="Time">
|
||||
<Input
|
||||
onChange={(event) => setTime(event.target.value)}
|
||||
type="time"
|
||||
value={time}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Type">
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(event) => setType(event.target.value)}
|
||||
value={type}
|
||||
>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Provider">
|
||||
<Input
|
||||
onChange={(event) => setProvider(event.target.value)}
|
||||
placeholder="e.g. Dr. Okafor"
|
||||
value={provider}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button disabled={!selected} type="submit">
|
||||
Add appointment
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Plus,
|
||||
Search,
|
||||
Stethoscope,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
AddAppointmentDialog,
|
||||
type NewAppointment,
|
||||
} from "@/components/appointments/add-appointment-dialog";
|
||||
import { CalendarDialog } from "@/components/appointments/calendar-dialog";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
// All figures here are mock/placeholder data — there is no scheduling backend.
|
||||
// They illustrate the Appointments & Schedule layout.
|
||||
|
||||
// Anchor "today" to a fixed date so the mock copy ("Wednesday, June 5") lines up
|
||||
// across the page, the calendar dialog, and the add dialog. ISO YYYY-MM-DD.
|
||||
export const TODAY = "2026-06-05";
|
||||
|
||||
type ApptStatus = "confirmed" | "checked-in" | "completed" | "cancelled";
|
||||
|
||||
export type Appointment = {
|
||||
date: string; // ISO YYYY-MM-DD
|
||||
time: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
status: ApptStatus;
|
||||
};
|
||||
|
||||
const statusVariant: Record<
|
||||
ApptStatus,
|
||||
"default" | "secondary" | "destructive" | "outline"
|
||||
> = {
|
||||
"checked-in": "default",
|
||||
confirmed: "secondary",
|
||||
completed: "outline",
|
||||
cancelled: "destructive",
|
||||
};
|
||||
|
||||
const statusLabel: Record<ApptStatus, string> = {
|
||||
"checked-in": "Checked in",
|
||||
confirmed: "Confirmed",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
const kpis = [
|
||||
{ label: "Today", value: "12", icon: CalendarClock },
|
||||
{ label: "This week", value: "146", icon: Users },
|
||||
{ label: "Avg. visit", value: "22 min", icon: Clock },
|
||||
{ label: "Utilization", value: "87%", icon: Stethoscope },
|
||||
];
|
||||
|
||||
// Mock schedule spread across June 2026 so the month calendar looks populated.
|
||||
const seed: Appointment[] = [
|
||||
{
|
||||
date: TODAY,
|
||||
time: "09:00",
|
||||
name: "Amina Yusuf",
|
||||
initials: "AY",
|
||||
type: "Follow-up",
|
||||
provider: "Dr. Okafor",
|
||||
status: "completed",
|
||||
},
|
||||
{
|
||||
date: TODAY,
|
||||
time: "09:30",
|
||||
name: "Daniel Mensah",
|
||||
initials: "DM",
|
||||
type: "New patient",
|
||||
provider: "Dr. Okafor",
|
||||
status: "checked-in",
|
||||
},
|
||||
{
|
||||
date: TODAY,
|
||||
time: "10:15",
|
||||
name: "Leila Haddad",
|
||||
initials: "LH",
|
||||
type: "Lab review",
|
||||
provider: "Dr. Stein",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
date: TODAY,
|
||||
time: "11:00",
|
||||
name: "Carlos Rivera",
|
||||
initials: "CR",
|
||||
type: "Consultation",
|
||||
provider: "Dr. Okafor",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
date: TODAY,
|
||||
time: "13:30",
|
||||
name: "Priya Nair",
|
||||
initials: "PN",
|
||||
type: "Follow-up",
|
||||
provider: "Dr. Stein",
|
||||
status: "cancelled",
|
||||
},
|
||||
{
|
||||
date: TODAY,
|
||||
time: "14:45",
|
||||
name: "Tom Becker",
|
||||
initials: "TB",
|
||||
type: "Vaccination",
|
||||
provider: "Dr. Okafor",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
date: "2026-06-06",
|
||||
time: "08:45",
|
||||
name: "Grace Lin",
|
||||
initials: "GL",
|
||||
type: "Follow-up",
|
||||
provider: "Dr. Stein",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
date: "2026-06-06",
|
||||
time: "10:00",
|
||||
name: "Omar Farouk",
|
||||
initials: "OF",
|
||||
type: "New patient",
|
||||
provider: "Dr. Okafor",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
date: "2026-06-09",
|
||||
time: "11:30",
|
||||
name: "Sofia Marin",
|
||||
initials: "SM",
|
||||
type: "Consultation",
|
||||
provider: "Dr. Stein",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
date: "2026-06-12",
|
||||
time: "15:00",
|
||||
name: "Henry Adeyemi",
|
||||
initials: "HA",
|
||||
type: "Lab review",
|
||||
provider: "Dr. Okafor",
|
||||
status: "confirmed",
|
||||
},
|
||||
{
|
||||
date: "2026-06-18",
|
||||
time: "09:15",
|
||||
name: "Nadia Petrova",
|
||||
initials: "NP",
|
||||
type: "Follow-up",
|
||||
provider: "Dr. Stein",
|
||||
status: "confirmed",
|
||||
},
|
||||
];
|
||||
|
||||
// "2026-06-05" -> "Wednesday, June 5"
|
||||
function formatDayKey(key: string): string {
|
||||
return new Date(`${key}T00:00:00`).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function byTime(a: Appointment, b: Appointment) {
|
||||
return a.time.localeCompare(b.time);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: typeof CalendarClock;
|
||||
}) {
|
||||
return (
|
||||
<Card className="flex-row items-center gap-3 p-4">
|
||||
<div className="flex size-9 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className="font-semibold text-foreground text-lg tracking-tight">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ApptRow({ appt }: { appt: Appointment }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<span className="w-12 shrink-0 font-medium text-foreground text-sm tabular-nums">
|
||||
{appt.time}
|
||||
</span>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{appt.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{appt.name}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{appt.type} · {appt.provider}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={statusVariant[appt.status]}>{statusLabel[appt.status]}</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleList({ items }: { items: Appointment[] }) {
|
||||
return (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{items.map((appt) => (
|
||||
<ApptRow appt={appt} key={appt.time + appt.name} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg tracking-tight">{title}</h2>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppointmentsView() {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [calendarOpen, setCalendarOpen] = useState(false);
|
||||
const [appointments, setAppointments] = useState<Appointment[]>(seed);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// Insert a new (mock) appointment at the date/time chosen in the dialog.
|
||||
const addAppointment = (appt: NewAppointment) => {
|
||||
setAppointments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
date: appt.date,
|
||||
time: appt.time,
|
||||
name: appt.name,
|
||||
initials: appt.initials,
|
||||
type: appt.type,
|
||||
provider: appt.provider,
|
||||
status: "confirmed" as const,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const todayItems = useMemo(
|
||||
() => appointments.filter((a) => a.date === TODAY).sort(byTime),
|
||||
[appointments],
|
||||
);
|
||||
|
||||
// Group future dates (after TODAY) into day sections, soonest first.
|
||||
const upcoming = useMemo(() => {
|
||||
const keys = [
|
||||
...new Set(appointments.map((a) => a.date).filter((d) => d > TODAY)),
|
||||
].sort();
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
items: appointments.filter((a) => a.date === key).sort(byTime),
|
||||
}));
|
||||
}, [appointments]);
|
||||
|
||||
const search = query.trim().toLowerCase();
|
||||
|
||||
// While searching, match name/type/provider across every date and group the
|
||||
// hits by date (soonest first) so each section keeps its day header.
|
||||
const results = useMemo(() => {
|
||||
if (!search) return [];
|
||||
const hits = appointments.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(search) ||
|
||||
a.type.toLowerCase().includes(search) ||
|
||||
a.provider.toLowerCase().includes(search),
|
||||
);
|
||||
const keys = [...new Set(hits.map((a) => a.date))].sort();
|
||||
return keys.map((key) => ({
|
||||
key,
|
||||
items: hits.filter((a) => a.date === key).sort(byTime),
|
||||
}));
|
||||
}, [appointments, search]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
Appointments & Schedule
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Today's clinic schedule and what's coming up. Sample data.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="w-full pl-9 sm:w-64"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search patient, type, provider"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setCalendarOpen(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<CalendarDays className="size-4" />
|
||||
Calendar
|
||||
</Button>
|
||||
<Button
|
||||
className="rounded-3xl"
|
||||
onClick={() => setAddOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{search ? (
|
||||
results.length > 0 ? (
|
||||
results.map((group) => (
|
||||
<Section key={group.key} title={formatDayKey(group.key)}>
|
||||
<ScheduleList items={group.items} />
|
||||
</Section>
|
||||
))
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
|
||||
No matching appointments.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
{kpis.map((k) => (
|
||||
<Kpi key={k.label} {...k} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Section description={formatDayKey(TODAY)} title="Today">
|
||||
{todayItems.length > 0 ? (
|
||||
<ScheduleList items={todayItems} />
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
|
||||
Nothing scheduled today.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{upcoming.map((group) => (
|
||||
<Section key={group.key} title={formatDayKey(group.key)}>
|
||||
<ScheduleList items={group.items} />
|
||||
</Section>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AddAppointmentDialog
|
||||
onAdd={addAppointment}
|
||||
onOpenChange={setAddOpen}
|
||||
open={addOpen}
|
||||
/>
|
||||
|
||||
<CalendarDialog
|
||||
appointments={appointments}
|
||||
onOpenChange={setCalendarOpen}
|
||||
open={calendarOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
type Appointment,
|
||||
ScheduleList,
|
||||
TODAY,
|
||||
} from "@/components/appointments/appointments-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
|
||||
// Local-date ISO key, e.g. "2026-06-05" (avoids UTC drift from toISOString).
|
||||
const keyOf = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
|
||||
const parseKey = (key: string) => new Date(`${key}T00:00:00`);
|
||||
|
||||
const formatDayKey = (key: string) =>
|
||||
parseKey(key).toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const byTime = (a: Appointment, b: Appointment) => a.time.localeCompare(b.time);
|
||||
|
||||
// Event chip color by status, using semantic tokens.
|
||||
const chipClass: Record<Appointment["status"], string> = {
|
||||
confirmed: "bg-secondary text-secondary-foreground",
|
||||
"checked-in": "bg-success/15 text-success",
|
||||
completed: "bg-muted text-muted-foreground",
|
||||
cancelled: "bg-destructive/15 text-destructive line-through",
|
||||
};
|
||||
|
||||
// A Google-Calendar-style month grid in a dialog: a 6×7 grid of day cells with
|
||||
// color-coded event chips; navigate months and click a day to list its
|
||||
// appointments. Mock-only — there's no per-date scheduling backend.
|
||||
export function CalendarDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
appointments,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appointments: Appointment[];
|
||||
}) {
|
||||
// First-of-month for the displayed month; defaults to TODAY's month.
|
||||
const [viewMonth, setViewMonth] = useState<Date>(() => {
|
||||
const t = parseKey(TODAY);
|
||||
return new Date(t.getFullYear(), t.getMonth(), 1);
|
||||
});
|
||||
const [selectedKey, setSelectedKey] = useState<string>(TODAY);
|
||||
|
||||
const byDate = useMemo(() => {
|
||||
const map = new Map<string, Appointment[]>();
|
||||
for (const a of appointments) {
|
||||
const list = map.get(a.date) ?? [];
|
||||
list.push(a);
|
||||
map.set(a.date, list);
|
||||
}
|
||||
return map;
|
||||
}, [appointments]);
|
||||
|
||||
// 42 cells (6 weeks) starting from the Sunday on/before the 1st.
|
||||
const cells = useMemo(() => {
|
||||
const first = new Date(viewMonth.getFullYear(), viewMonth.getMonth(), 1);
|
||||
const start = new Date(
|
||||
first.getFullYear(),
|
||||
first.getMonth(),
|
||||
1 - first.getDay(),
|
||||
);
|
||||
return Array.from(
|
||||
{ length: 42 },
|
||||
(_, i) =>
|
||||
new Date(start.getFullYear(), start.getMonth(), start.getDate() + i),
|
||||
);
|
||||
}, [viewMonth]);
|
||||
|
||||
const monthLabel = viewMonth.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const selectedItems = (byDate.get(selectedKey) ?? []).slice().sort(byTime);
|
||||
|
||||
const shiftMonth = (delta: number) =>
|
||||
setViewMonth(
|
||||
(m) => new Date(m.getFullYear(), m.getMonth() + delta, 1),
|
||||
);
|
||||
|
||||
const goToday = () => {
|
||||
const t = parseKey(TODAY);
|
||||
setViewMonth(new Date(t.getFullYear(), t.getMonth(), 1));
|
||||
setSelectedKey(TODAY);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3 pe-8">
|
||||
<DialogTitle>{monthLabel}</DialogTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={goToday}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Previous month"
|
||||
onClick={() => shiftMonth(-1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Next month"
|
||||
onClick={() => shiftMonth(1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogPanel className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="grid grid-cols-7 gap-1 pb-1">
|
||||
{WEEKDAYS.map((d) => (
|
||||
<div
|
||||
className="px-1 text-center font-medium text-muted-foreground text-xs"
|
||||
key={d}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((date) => {
|
||||
const key = keyOf(date);
|
||||
const inMonth = date.getMonth() === viewMonth.getMonth();
|
||||
const isToday = key === TODAY;
|
||||
const isSelected = key === selectedKey;
|
||||
const items = (byDate.get(key) ?? []).slice().sort(byTime);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-left align-top transition-colors hover:bg-accent/50",
|
||||
inMonth
|
||||
? "bg-card/30"
|
||||
: "bg-transparent text-muted-foreground/40",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
)}
|
||||
key={key}
|
||||
onClick={() => setSelectedKey(key)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-6 items-center justify-center rounded-full text-xs",
|
||||
isToday && "bg-primary font-semibold text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 overflow-hidden">
|
||||
{items.slice(0, 3).map((a) => (
|
||||
<span
|
||||
className={cn(
|
||||
"truncate rounded px-1 py-0.5 text-[10px] leading-tight",
|
||||
chipClass[a.status],
|
||||
)}
|
||||
key={a.time + a.name}
|
||||
>
|
||||
{a.time} {a.name}
|
||||
</span>
|
||||
))}
|
||||
{items.length > 3 && (
|
||||
<span className="px-1 text-[10px] text-muted-foreground">
|
||||
+{items.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground text-sm">
|
||||
{formatDayKey(selectedKey)}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{selectedItems.length === 1
|
||||
? "1 appointment"
|
||||
: `${selectedItems.length} appointments`}
|
||||
</p>
|
||||
</div>
|
||||
{selectedItems.length > 0 ? (
|
||||
<ScheduleList items={selectedItems} />
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
|
||||
No appointments on this day.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// Authoritative client-side gate for the app shell. Requires a session and an
|
||||
// active clinic. If the user is signed in without an active clinic but already
|
||||
// belongs to one, we select it automatically; onboarding is only for users
|
||||
// with no clinics at all. The API enforces the same access rules server-side.
|
||||
export function AppAuthGuard({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const { data: orgs, isPending: orgsPending } =
|
||||
authClient.useListOrganizations();
|
||||
const settingActive = useRef(false);
|
||||
|
||||
const hasUser = Boolean(session?.user);
|
||||
const activeOrgId = session?.session?.activeOrganizationId ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
if (!hasUser) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
if (activeOrgId) return;
|
||||
|
||||
// Signed in but no active clinic selected yet.
|
||||
if (orgsPending) return;
|
||||
const first = orgs?.[0];
|
||||
if (first) {
|
||||
if (!settingActive.current) {
|
||||
settingActive.current = true;
|
||||
void authClient.organization.setActive({ organizationId: first.id });
|
||||
}
|
||||
} else {
|
||||
router.replace("/onboarding");
|
||||
}
|
||||
}, [isPending, hasUser, activeOrgId, orgsPending, orgs, router]);
|
||||
|
||||
const ready = hasUser && Boolean(activeOrgId);
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// temetro logo + wordmark, centered.
|
||||
export function AuthBrand() {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Image
|
||||
alt="temetro"
|
||||
className="size-11"
|
||||
height={44}
|
||||
priority
|
||||
src="/temetro-logo.png"
|
||||
width={44}
|
||||
/>
|
||||
<span className="text-2xl font-semibold tracking-tight">temetro</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Centered page wrapper for every auth screen: brand on top, content below.
|
||||
export function AuthLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-full w-full items-center justify-center px-4 py-12">
|
||||
<div className="flex w-full max-w-sm flex-col gap-6">
|
||||
<AuthBrand />
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Card-based shell for the non-block auth pages (onboarding, verify, reset,
|
||||
// forgot-password, accept-invite).
|
||||
export function AuthShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-xl">{title}</CardTitle>
|
||||
{subtitle && <CardDescription>{subtitle}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
{footer && (
|
||||
<div className="text-center text-sm text-muted-foreground">{footer}</div>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
hint?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormAlert({
|
||||
tone = "error",
|
||||
children,
|
||||
}: {
|
||||
tone?: "error" | "success";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"rounded-2xl px-3 py-2 text-sm",
|
||||
tone === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
ArrowUp,
|
||||
Building2,
|
||||
CalendarRange,
|
||||
ChevronDown,
|
||||
Hand,
|
||||
Mic,
|
||||
Plus,
|
||||
Square,
|
||||
Stethoscope,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import {
|
||||
Menu,
|
||||
MenuPopup,
|
||||
MenuRadioGroup,
|
||||
MenuRadioItem,
|
||||
MenuTrigger,
|
||||
} from "@/components/ui/menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatInputProps = {
|
||||
onSubmit: (text: string) => void;
|
||||
status: ChatStatus;
|
||||
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 =
|
||||
"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 =
|
||||
"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 (
|
||||
<Menu>
|
||||
<MenuTrigger
|
||||
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} />
|
||||
</MenuTrigger>
|
||||
<MenuPopup align={align}>
|
||||
<MenuRadioGroup onValueChange={onValueChange} value={value}>
|
||||
{options.map((option) => (
|
||||
<MenuRadioItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuRadioItem>
|
||||
))}
|
||||
</MenuRadioGroup>
|
||||
</MenuPopup>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
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 [addOpen, setAddOpen] = useState(false);
|
||||
// Bumped on each open so the dialog remounts with a fresh file number + form.
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isGenerating = status === "submitted" || status === "streaming";
|
||||
const canSend =
|
||||
(value.trim().length > 0 || files.length > 0) && !isGenerating;
|
||||
|
||||
const submit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if ((!trimmed && files.length === 0) || isGenerating) {
|
||||
return;
|
||||
}
|
||||
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("");
|
||||
setFiles([]);
|
||||
}, [value, files, isGenerating, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!event.nativeEvent.isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}
|
||||
},
|
||||
[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) => {
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
className="w-full overflow-hidden rounded-[28px] border border-border/60 bg-muted shadow-sm"
|
||||
>
|
||||
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
|
||||
<div className="rounded-b-[22px] bg-input">
|
||||
<textarea
|
||||
aria-label="Message"
|
||||
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Look up a patient — try /patient 10293"
|
||||
rows={1}
|
||||
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}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-[18px]" />
|
||||
</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">
|
||||
<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>
|
||||
<button
|
||||
aria-label={isGenerating ? "Stop" : "Send"}
|
||||
className={cn(
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
|
||||
canSend || isGenerating
|
||||
? "bg-foreground text-background hover:bg-foreground/90"
|
||||
: "bg-muted-foreground/30 text-foreground/70"
|
||||
)}
|
||||
disabled={!(canSend || isGenerating)}
|
||||
onClick={isGenerating && onStop ? onStop : undefined}
|
||||
type={isGenerating && onStop ? "button" : "submit"}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<Square className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUp className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<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}
|
||||
/>
|
||||
<button
|
||||
className={cn(contextPill, "ml-auto")}
|
||||
onClick={() => {
|
||||
setAddKey((k) => k + 1);
|
||||
setAddOpen(true);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
<span>Add patient</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<PatientFormDialog
|
||||
key={addKey}
|
||||
mode="create"
|
||||
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`)}
|
||||
onOpenChange={setAddOpen}
|
||||
open={addOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import type { ChatStatus } from "ai";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from "@/components/ai-elements/conversation";
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from "@/components/ai-elements/message";
|
||||
import { ChatInput } from "@/components/chat/chat-input";
|
||||
import { PatientResult } from "@/components/chat/patient-cards";
|
||||
import { getPatient, type Patient } from "@/lib/patients";
|
||||
|
||||
type ChatMessage =
|
||||
| { id: string; role: "user"; text: string }
|
||||
| { id: string; role: "assistant"; kind: "text"; text: string }
|
||||
| {
|
||||
id: string;
|
||||
role: "assistant";
|
||||
kind: "patient";
|
||||
fileNumber: string;
|
||||
status: "loading" | "ready" | "not-found";
|
||||
patient?: Patient;
|
||||
};
|
||||
|
||||
const HEADING = "Which patient would you like to look up?";
|
||||
|
||||
// Trigger: `/patient 10293` or just `/10293` pulls up records.
|
||||
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
|
||||
|
||||
export function ChatPanel() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [status, setStatus] = useState<ChatStatus>("ready");
|
||||
|
||||
const send = useCallback(async (text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ id: nanoid(), role: "user", text: trimmed },
|
||||
]);
|
||||
|
||||
const match = trimmed.match(PATIENT_COMMAND);
|
||||
|
||||
// Patient lookup: append a loading card set, then fill it in once the
|
||||
// (mock) record comes back.
|
||||
if (match) {
|
||||
const fileNumber = match[1];
|
||||
const resultId = nanoid();
|
||||
setStatus("submitted");
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: resultId,
|
||||
role: "assistant",
|
||||
kind: "patient",
|
||||
fileNumber,
|
||||
status: "loading",
|
||||
},
|
||||
]);
|
||||
|
||||
let patient: Patient | null = null;
|
||||
try {
|
||||
patient = await getPatient(fileNumber);
|
||||
} catch {
|
||||
// Network / auth errors fall through as "not found"; a 401 will have
|
||||
// already redirected to /login via the API client.
|
||||
patient = null;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === resultId &&
|
||||
message.role === "assistant" &&
|
||||
message.kind === "patient"
|
||||
? {
|
||||
...message,
|
||||
status: patient ? "ready" : "not-found",
|
||||
patient: patient ?? undefined,
|
||||
}
|
||||
: message
|
||||
)
|
||||
);
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
|
||||
// UI-only: mock assistant reply for anything that isn't a command.
|
||||
setStatus("submitted");
|
||||
window.setTimeout(() => {
|
||||
setStatus("streaming");
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: nanoid(),
|
||||
role: "assistant",
|
||||
kind: "text",
|
||||
text: `This is a **UI-only preview** of temetro. Try \`/patient 10293\` to pull up a patient's records.\n\nYou asked:\n\n> ${trimmed}`,
|
||||
},
|
||||
]);
|
||||
window.setTimeout(() => setStatus("ready"), 250);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(() => setStatus("ready"), []);
|
||||
|
||||
// Opening a patient from the Patients page lands here as `/?patient=<file#>`;
|
||||
// run the lookup once on arrival.
|
||||
const searchParams = useSearchParams();
|
||||
const requestedPatient = searchParams.get("patient");
|
||||
const handledPatientRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (requestedPatient && handledPatientRef.current !== requestedPatient) {
|
||||
handledPatientRef.current = requestedPatient;
|
||||
send(`/patient ${requestedPatient}`);
|
||||
}
|
||||
}, [requestedPatient, send]);
|
||||
|
||||
const promptInput = (
|
||||
<ChatInput onStop={handleStop} onSubmit={send} status={status} />
|
||||
);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4">
|
||||
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
|
||||
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
|
||||
{HEADING}
|
||||
</h1>
|
||||
{promptInput}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Conversation>
|
||||
<ConversationContent className="mx-auto w-full max-w-3xl">
|
||||
{messages.map((message) => {
|
||||
if (message.role === "assistant" && message.kind === "patient") {
|
||||
return (
|
||||
<Message from="assistant" key={message.id}>
|
||||
<MessageContent className="w-full">
|
||||
<PatientResult
|
||||
fileNumber={message.fileNumber}
|
||||
onPatientUpdated={(updated) =>
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === message.id &&
|
||||
m.role === "assistant" &&
|
||||
m.kind === "patient"
|
||||
? { ...m, patient: updated, status: "ready" }
|
||||
: m
|
||||
)
|
||||
)
|
||||
}
|
||||
patient={message.patient}
|
||||
status={message.status}
|
||||
/>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Message from={message.role} key={message.id}>
|
||||
<MessageContent>
|
||||
{message.role === "user" ? (
|
||||
<span className="whitespace-pre-wrap">{message.text}</span>
|
||||
) : (
|
||||
<MessageResponse>{message.text}</MessageResponse>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
})}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
<div className="mx-auto w-full max-w-3xl px-4 pb-4">{promptInput}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
|
||||
|
||||
type BadgeVariant = "default" | "secondary" | "destructive" | "outline";
|
||||
|
||||
type PatientResultProps = {
|
||||
status: "loading" | "ready" | "not-found";
|
||||
fileNumber: string;
|
||||
patient?: Patient;
|
||||
onPatientUpdated?: (patient: Patient) => void;
|
||||
// "row" = horizontal scroll (chat); "column" = full-width vertical stack
|
||||
// (the Patients detail Sheet).
|
||||
layout?: "row" | "column";
|
||||
};
|
||||
|
||||
const severityVariant: Record<AllergySeverity, BadgeVariant> = {
|
||||
mild: "outline",
|
||||
moderate: "secondary",
|
||||
severe: "destructive",
|
||||
};
|
||||
|
||||
const labFlagVariant: Record<LabFlag, BadgeVariant> = {
|
||||
normal: "outline",
|
||||
low: "secondary",
|
||||
high: "secondary",
|
||||
critical: "destructive",
|
||||
};
|
||||
|
||||
const statusVariant: Record<Patient["status"], BadgeVariant> = {
|
||||
active: "secondary",
|
||||
inpatient: "destructive",
|
||||
discharged: "outline",
|
||||
};
|
||||
|
||||
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
|
||||
|
||||
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
|
||||
// plus a subtle clickable affordance (they open a detail dialog).
|
||||
const rowCard =
|
||||
"w-80 shrink-0 cursor-pointer text-left outline-none transition hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
|
||||
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
|
||||
const compactCard =
|
||||
"[&_[data-slot=card-header]]:p-4 [&_[data-slot=card-panel]]:px-4 [&_[data-slot=card-panel]]:pb-4";
|
||||
|
||||
function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: ReactNode; value: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-foreground">{label}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ children }: { children: ReactNode }) {
|
||||
return <p className="text-muted-foreground">{children}</p>;
|
||||
}
|
||||
|
||||
function TrendBlock({ trend }: { trend: Trend }) {
|
||||
if (trend.points.length === 0) {
|
||||
return <Empty>No trend data yet.</Empty>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<SectionLabel>{`${trend.label} · last ${trend.points.length}`}</SectionLabel>
|
||||
<span className="text-foreground">
|
||||
{trend.points.at(-1)}
|
||||
<span className="text-muted-foreground"> {trend.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Sparkline points={trend.points} unit={trend.unit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendDetail({ trend }: { trend: Trend }) {
|
||||
if (trend.points.length === 0) {
|
||||
return <Empty>No trend data yet.</Empty>;
|
||||
}
|
||||
const min = Math.min(...trend.points);
|
||||
const max = Math.max(...trend.points);
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<SectionLabel>{`${trend.label} · last ${trend.points.length} readings`}</SectionLabel>
|
||||
<div className="flex gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Latest{" "}
|
||||
<span className="text-foreground">
|
||||
{trend.points.at(-1)} {trend.unit}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Min <span className="text-foreground">{min}</span>
|
||||
</span>
|
||||
<span>
|
||||
Max <span className="text-foreground">{max}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline className="h-24" points={trend.points} unit={trend.unit} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertBadges({ alerts }: { alerts: string[] }) {
|
||||
if (alerts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{alerts.map((alert) => (
|
||||
<Badge key={alert} variant="outline">
|
||||
{alert}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A card that previews `children` and opens a roomier dialog of `detail` on click.
|
||||
function ExpandableCard({
|
||||
title,
|
||||
description,
|
||||
detail,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
detail: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
nativeButton={false}
|
||||
render={<Card className={cn(rowCard, compactCard)} />}
|
||||
>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
|
||||
{detail}
|
||||
</DialogPanel>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
patient,
|
||||
onEdit,
|
||||
}: {
|
||||
patient: Patient;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={idLine}
|
||||
detail={
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<Stat label="Full name" value={patient.name} />
|
||||
<Stat label="MRN" value={patient.fileNumber} />
|
||||
<Stat label="Age" value={patient.age} />
|
||||
<Stat label="Sex" value={sexLabel[patient.sex]} />
|
||||
<Stat label="Primary care" value={patient.pcp} />
|
||||
<Stat
|
||||
label="Status"
|
||||
value={<span className="capitalize">{patient.status}</span>}
|
||||
/>
|
||||
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
|
||||
<Stat
|
||||
label="Allergies"
|
||||
value={patient.allergies.length || "None"}
|
||||
/>
|
||||
</div>
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
</div>
|
||||
}
|
||||
title={patient.name}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-10">
|
||||
<AvatarFallback>{patient.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 gap-0.5">
|
||||
<CardTitle>{patient.name}</CardTitle>
|
||||
<CardDescription>{idLine}</CardDescription>
|
||||
</div>
|
||||
<Badge className="capitalize" variant={statusVariant[patient.status]}>
|
||||
{patient.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
|
||||
<Stat label="Primary care" value={patient.pcp} />
|
||||
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
|
||||
<Stat label="Active meds" value={patient.medications.length} />
|
||||
<Stat label="Open problems" value={patient.problems.length} />
|
||||
</div>
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
<button
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit record
|
||||
</button>
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function VitalsCard({ patient }: { patient: Patient }) {
|
||||
const { vitals } = patient;
|
||||
const vitalItems = [
|
||||
{ label: "BP", value: vitals.bp },
|
||||
{ label: "HR", value: vitals.hr },
|
||||
{ label: "Temp", value: vitals.temp },
|
||||
{ label: "SpO₂", value: vitals.spo2 },
|
||||
];
|
||||
const vitalsGrid = (gapY: string) => (
|
||||
<div className={cn("grid grid-cols-2 gap-x-4", gapY)}>
|
||||
{vitalItems.map((item) => (
|
||||
<Stat key={item.label} label={item.label} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={`Taken ${vitals.takenAt}`}
|
||||
detail={
|
||||
<div className="flex flex-col gap-4">
|
||||
{vitalsGrid("gap-y-3")}
|
||||
<Separator />
|
||||
<TrendDetail trend={patient.vitalsTrend} />
|
||||
</div>
|
||||
}
|
||||
title="Vitals"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Vitals</CardTitle>
|
||||
<CardDescription>Taken {vitals.takenAt}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{vitalsGrid("gap-y-3")}
|
||||
<Separator />
|
||||
<TrendBlock trend={patient.vitalsTrend} />
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function labValue(value: string, flag: LabFlag) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{value}
|
||||
<Badge className="capitalize" variant={labFlagVariant[flag]}>
|
||||
{flag}
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LabsCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={`As of ${patient.labs[0]?.takenAt ?? "—"}`}
|
||||
detail={
|
||||
patient.labs.length === 0 ? (
|
||||
<Empty>No labs on file.</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{patient.labs.map((lab) => (
|
||||
<div
|
||||
className="flex items-center justify-between gap-3"
|
||||
key={lab.name}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-foreground">{lab.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{lab.takenAt}
|
||||
</span>
|
||||
</div>
|
||||
{labValue(lab.value, lab.flag)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<TrendDetail trend={patient.labTrend} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
title="Labs"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Labs</CardTitle>
|
||||
<CardDescription>As of {patient.labs[0]?.takenAt ?? "—"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{patient.labs.length === 0 ? (
|
||||
<Empty>No labs on file.</Empty>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
{patient.labs.map((lab) => (
|
||||
<Row
|
||||
key={lab.name}
|
||||
label={lab.name}
|
||||
value={labValue(lab.value, lab.flag)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
<TrendBlock trend={patient.labTrend} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function MedicationsCard({ patient }: { patient: Patient }) {
|
||||
const list =
|
||||
patient.medications.length === 0 ? (
|
||||
<Empty>No active medications.</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{patient.medications.map((med) => (
|
||||
<Row
|
||||
key={med.name}
|
||||
label={med.name}
|
||||
value={`${med.dose} · ${med.frequency}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={`${patient.medications.length} active`}
|
||||
detail={list}
|
||||
title="Medications"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Medications</CardTitle>
|
||||
<CardDescription>{patient.medications.length} active</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{list}</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ProblemsCard({ patient }: { patient: Patient }) {
|
||||
const list =
|
||||
patient.problems.length === 0 ? (
|
||||
<Empty>No active problems.</Empty>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{patient.problems.map((problem) => (
|
||||
<Row
|
||||
key={problem.label}
|
||||
label={problem.label}
|
||||
value={`since ${problem.since}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={`${patient.problems.length} active`}
|
||||
detail={list}
|
||||
title="Problems"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Problems</CardTitle>
|
||||
<CardDescription>{patient.problems.length} active</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{list}</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AllergiesList({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionLabel>Allergies</SectionLabel>
|
||||
{patient.allergies.length === 0 ? (
|
||||
<p className="text-muted-foreground">No known allergies.</p>
|
||||
) : (
|
||||
patient.allergies.map((allergy) => (
|
||||
<Row
|
||||
key={allergy.substance}
|
||||
label={
|
||||
<>
|
||||
{allergy.substance}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
— {allergy.reaction}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
value={
|
||||
<Badge
|
||||
className="capitalize"
|
||||
variant={severityVariant[allergy.severity]}
|
||||
>
|
||||
{allergy.severity}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AllergiesCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<ExpandableCard
|
||||
detail={<AllergiesList patient={patient} />}
|
||||
title="Allergies & alerts"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Allergies & alerts</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AllergiesList patient={patient} />
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function VisitsList({ patient }: { patient: Patient }) {
|
||||
if (patient.encounters.length === 0) {
|
||||
return <Empty>No visits yet.</Empty>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{patient.encounters.map((encounter) => (
|
||||
<div
|
||||
className="flex flex-col gap-0.5"
|
||||
key={encounter.date + encounter.type}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-foreground">{encounter.type}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{encounter.date}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{encounter.summary}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{encounter.provider}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VisitsCard({ patient }: { patient: Patient }) {
|
||||
return (
|
||||
<ExpandableCard
|
||||
description={`${patient.encounters.length} recent`}
|
||||
detail={<VisitsList patient={patient} />}
|
||||
title="Recent visits"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent visits</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<VisitsList patient={patient} />
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingCards() {
|
||||
return (
|
||||
<>
|
||||
<Card className={cn("w-80 shrink-0", compactCard)}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-52" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3">
|
||||
{[0, 1, 2, 3].map((cell) => (
|
||||
<Skeleton className="h-8 w-full" key={cell} />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{[0, 1, 2, 3, 4, 5].map((card) => (
|
||||
<Card className={cn("w-80 shrink-0", compactCard)} key={card}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2.5">
|
||||
{[0, 1, 2].map((row) => (
|
||||
<Skeleton className="h-3.5 w-full" key={row} />
|
||||
))}
|
||||
{card % 2 === 1 && <Skeleton className="mt-1.5 h-10 w-full" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatientResult({
|
||||
status,
|
||||
fileNumber,
|
||||
patient,
|
||||
onPatientUpdated,
|
||||
layout = "row",
|
||||
}: PatientResultProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
// Bumped on open so the editor remounts with the latest patient data.
|
||||
const [editKey, setEditKey] = useState(0);
|
||||
|
||||
if (status === "not-found") {
|
||||
return (
|
||||
<Card className={compactCard}>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
No patient found for file #{fileNumber}.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full gap-4 p-2",
|
||||
layout === "column"
|
||||
? "flex-col [&_[data-slot=card]]:w-full"
|
||||
: "no-scrollbar items-stretch overflow-x-auto",
|
||||
)}
|
||||
>
|
||||
{status === "loading" || !patient ? (
|
||||
<LoadingCards />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCard
|
||||
onEdit={() => {
|
||||
setEditKey((k) => k + 1);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
patient={patient}
|
||||
/>
|
||||
<VitalsCard patient={patient} />
|
||||
<LabsCard patient={patient} />
|
||||
<MedicationsCard patient={patient} />
|
||||
<ProblemsCard patient={patient} />
|
||||
<AllergiesCard patient={patient} />
|
||||
<VisitsCard patient={patient} />
|
||||
<PatientFormDialog
|
||||
key={editKey}
|
||||
mode="edit"
|
||||
onOpenChange={setEditOpen}
|
||||
onSaved={(updated) => onPatientUpdated?.(updated)}
|
||||
open={editOpen}
|
||||
patient={patient}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverPopup,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogPanel,
|
||||
DialogPopup,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type AllergySeverity,
|
||||
createPatient,
|
||||
generateFileNumber,
|
||||
type LabFlag,
|
||||
type Patient,
|
||||
updatePatient,
|
||||
} from "@/lib/patients";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
type PatientFormDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: "create" | "edit";
|
||||
patient?: Patient;
|
||||
onCreated?: (fileNumber: string) => void;
|
||||
onSaved?: (patient: Patient) => void;
|
||||
};
|
||||
|
||||
type AllergyDraft = { substance: string; reaction: string; severity: AllergySeverity };
|
||||
type MedicationDraft = { name: string; dose: string; frequency: string };
|
||||
type ProblemDraft = { label: string; since: string };
|
||||
type LabDraft = { name: string; value: string; flag: LabFlag; takenAt: string };
|
||||
type VisitDraft = { type: string; date: string; provider: string; summary: string };
|
||||
|
||||
const controlClass =
|
||||
"h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionList<T>({
|
||||
label,
|
||||
rows,
|
||||
blank,
|
||||
onChange,
|
||||
render,
|
||||
}: {
|
||||
label: string;
|
||||
rows: T[];
|
||||
blank: T;
|
||||
onChange: (rows: T[]) => void;
|
||||
render: (row: T, set: (patch: Partial<T>) => void) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{label}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => onChange([...rows, blank])}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{rows.map((row, index) => (
|
||||
<div className="flex items-center gap-2" key={index}>
|
||||
{render(row, (patch) =>
|
||||
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
|
||||
)}
|
||||
<button
|
||||
aria-label={`Remove ${label} row`}
|
||||
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => onChange(rows.filter((_, i) => i !== index))}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function initialsFromName(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return "?";
|
||||
}
|
||||
return parts
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const today = () => formatDate(new Date());
|
||||
|
||||
// Patient dates are stored as formatted strings (e.g. "Jun 02, 2026"); parse one
|
||||
// back to a Date so the calendar can highlight the current selection.
|
||||
function parseDate(value: string): Date | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = new Date(trimmed);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
|
||||
// Calendar-backed date field that reads/writes the same formatted string the rest
|
||||
// of the form uses.
|
||||
function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
placeholder = "Pick a date",
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"justify-start font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<CalendarIcon className="size-4" />
|
||||
<span className="truncate">{value || placeholder}</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverPopup className="w-auto p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
onSelect={(date) => {
|
||||
onChange(date ? formatDate(date) : "");
|
||||
setOpen(false);
|
||||
}}
|
||||
selected={parseDate(value)}
|
||||
/>
|
||||
</PopoverPopup>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function PatientFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
patient,
|
||||
onCreated,
|
||||
onSaved,
|
||||
}: PatientFormDialogProps) {
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [fileNumber, setFileNumber] = useState(() =>
|
||||
isEdit && patient ? patient.fileNumber : generateFileNumber()
|
||||
);
|
||||
const [name, setName] = useState(patient?.name ?? "");
|
||||
const [age, setAge] = useState(patient ? String(patient.age) : "");
|
||||
const [sex, setSex] = useState<Patient["sex"]>(patient?.sex ?? "F");
|
||||
const [status, setStatus] = useState<Patient["status"]>(
|
||||
patient?.status ?? "active"
|
||||
);
|
||||
const [pcp, setPcp] = useState(patient?.pcp ?? "");
|
||||
const [bp, setBp] = useState(patient?.vitals.bp ?? "");
|
||||
const [hr, setHr] = useState(patient?.vitals.hr ?? "");
|
||||
const [temp, setTemp] = useState(patient?.vitals.temp ?? "");
|
||||
const [spo2, setSpo2] = useState(patient?.vitals.spo2 ?? "");
|
||||
const [allergies, setAllergies] = useState<AllergyDraft[]>(
|
||||
() => patient?.allergies.map((a) => ({ ...a })) ?? []
|
||||
);
|
||||
const [medications, setMedications] = useState<MedicationDraft[]>(
|
||||
() => patient?.medications.map((m) => ({ ...m })) ?? []
|
||||
);
|
||||
const [problems, setProblems] = useState<ProblemDraft[]>(
|
||||
() => patient?.problems.map((p) => ({ ...p })) ?? []
|
||||
);
|
||||
const [labs, setLabs] = useState<LabDraft[]>(
|
||||
() => patient?.labs.map((l) => ({ ...l })) ?? []
|
||||
);
|
||||
const [visits, setVisits] = useState<VisitDraft[]>(
|
||||
() =>
|
||||
patient?.encounters.map((e) => ({
|
||||
type: e.type,
|
||||
date: e.date,
|
||||
provider: e.provider,
|
||||
summary: e.summary,
|
||||
})) ?? []
|
||||
);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const built: Patient = {
|
||||
fileNumber,
|
||||
name: name.trim(),
|
||||
age: Number(age) || 0,
|
||||
sex,
|
||||
pcp: pcp.trim() || "—",
|
||||
status,
|
||||
initials: initialsFromName(name),
|
||||
allergies: allergies.filter((a) => a.substance.trim()),
|
||||
alerts: patient?.alerts ?? [],
|
||||
medications: medications.filter((m) => m.name.trim()),
|
||||
problems: problems.filter((p) => p.label.trim()),
|
||||
vitals: {
|
||||
bp: bp.trim() || "—",
|
||||
hr: hr.trim() || "—",
|
||||
temp: temp.trim() || "—",
|
||||
spo2: spo2.trim() || "—",
|
||||
takenAt: isEdit ? (patient?.vitals.takenAt ?? today()) : today(),
|
||||
},
|
||||
vitalsTrend: patient?.vitalsTrend ?? {
|
||||
label: "Heart rate",
|
||||
unit: "bpm",
|
||||
points: [],
|
||||
},
|
||||
labs: labs
|
||||
.filter((l) => l.name.trim())
|
||||
.map((l) => ({ ...l, takenAt: l.takenAt.trim() || today() })),
|
||||
labTrend: patient?.labTrend ?? { label: "—", unit: "", points: [] },
|
||||
encounters: visits
|
||||
.filter((v) => v.type.trim() || v.summary.trim())
|
||||
.map((v) => ({
|
||||
type: v.type.trim() || "Visit",
|
||||
date: v.date.trim() || today(),
|
||||
provider: v.provider,
|
||||
summary: v.summary,
|
||||
})),
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = isEdit
|
||||
? await updatePatient(built)
|
||||
: await createPatient(built);
|
||||
if (isEdit) {
|
||||
onSaved?.(saved);
|
||||
notify.success("Record updated", `${saved.name}'s chart was saved.`);
|
||||
} else {
|
||||
onCreated?.(saved.fileNumber);
|
||||
notify.success("Patient added", `${saved.name} (${saved.fileNumber}).`);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : "Could not save the patient.";
|
||||
setError(message);
|
||||
notify.error("Couldn't save patient", message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogPopup className="max-h-[85dvh] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit record" : "Add patient"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? `Update ${patient?.name ?? "this"}'s chart and add new data.`
|
||||
: "Create a new chart. A file number has been generated for you."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="contents" onSubmit={handleSubmit}>
|
||||
<DialogPanel
|
||||
scrollFade={false}
|
||||
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
|
||||
>
|
||||
<Field label="File number">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input readOnly value={fileNumber} />
|
||||
{!isEdit && (
|
||||
<Button
|
||||
aria-label="Regenerate file number"
|
||||
onClick={() => setFileNumber(generateFileNumber())}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="Full name">
|
||||
<Input
|
||||
autoFocus={!isEdit}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="e.g. Jordan Pierce"
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Field label="Age">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
onChange={(event) => setAge(event.target.value)}
|
||||
placeholder="—"
|
||||
value={age}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Sex">
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(event) =>
|
||||
setSex(event.target.value as Patient["sex"])
|
||||
}
|
||||
value={sex}
|
||||
>
|
||||
<option value="F">Female</option>
|
||||
<option value="M">Male</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(event) =>
|
||||
setStatus(event.target.value as Patient["status"])
|
||||
}
|
||||
value={status}
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inpatient">Inpatient</option>
|
||||
<option value="discharged">Discharged</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Primary care">
|
||||
<Input
|
||||
onChange={(event) => setPcp(event.target.value)}
|
||||
placeholder="e.g. Dr. Lena Ortiz"
|
||||
value={pcp}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
||||
Current vitals
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Input
|
||||
aria-label="Blood pressure"
|
||||
onChange={(event) => setBp(event.target.value)}
|
||||
placeholder="BP"
|
||||
value={bp}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Heart rate"
|
||||
onChange={(event) => setHr(event.target.value)}
|
||||
placeholder="HR"
|
||||
value={hr}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Temperature"
|
||||
onChange={(event) => setTemp(event.target.value)}
|
||||
placeholder="Temp"
|
||||
value={temp}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Oxygen saturation"
|
||||
onChange={(event) => setSpo2(event.target.value)}
|
||||
placeholder="SpO₂"
|
||||
value={spo2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SectionList<AllergyDraft>
|
||||
blank={{ substance: "", reaction: "", severity: "mild" }}
|
||||
label="Allergies"
|
||||
onChange={setAllergies}
|
||||
render={(row, set) => (
|
||||
<>
|
||||
<Input
|
||||
aria-label="Substance"
|
||||
onChange={(event) => set({ substance: event.target.value })}
|
||||
placeholder="Substance"
|
||||
value={row.substance}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Reaction"
|
||||
onChange={(event) => set({ reaction: event.target.value })}
|
||||
placeholder="Reaction"
|
||||
value={row.reaction}
|
||||
/>
|
||||
<select
|
||||
aria-label="Severity"
|
||||
className={cn(controlClass, "w-auto")}
|
||||
onChange={(event) =>
|
||||
set({ severity: event.target.value as AllergySeverity })
|
||||
}
|
||||
value={row.severity}
|
||||
>
|
||||
<option value="mild">Mild</option>
|
||||
<option value="moderate">Moderate</option>
|
||||
<option value="severe">Severe</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
rows={allergies}
|
||||
/>
|
||||
|
||||
<SectionList<MedicationDraft>
|
||||
blank={{ name: "", dose: "", frequency: "" }}
|
||||
label="Medications"
|
||||
onChange={setMedications}
|
||||
render={(row, set) => (
|
||||
<>
|
||||
<Input
|
||||
aria-label="Medication name"
|
||||
onChange={(event) => set({ name: event.target.value })}
|
||||
placeholder="Name"
|
||||
value={row.name}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Dose"
|
||||
onChange={(event) => set({ dose: event.target.value })}
|
||||
placeholder="Dose"
|
||||
value={row.dose}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Frequency"
|
||||
onChange={(event) => set({ frequency: event.target.value })}
|
||||
placeholder="Frequency"
|
||||
value={row.frequency}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
rows={medications}
|
||||
/>
|
||||
|
||||
<SectionList<ProblemDraft>
|
||||
blank={{ label: "", since: "" }}
|
||||
label="Problems"
|
||||
onChange={setProblems}
|
||||
render={(row, set) => (
|
||||
<>
|
||||
<Input
|
||||
aria-label="Problem"
|
||||
onChange={(event) => set({ label: event.target.value })}
|
||||
placeholder="Diagnosis"
|
||||
value={row.label}
|
||||
/>
|
||||
<DatePicker
|
||||
ariaLabel="Since"
|
||||
className="w-40 shrink-0"
|
||||
onChange={(since) => set({ since })}
|
||||
placeholder="Since"
|
||||
value={row.since}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
rows={problems}
|
||||
/>
|
||||
|
||||
<SectionList<LabDraft>
|
||||
blank={{ name: "", value: "", flag: "normal", takenAt: "" }}
|
||||
label="Labs"
|
||||
onChange={setLabs}
|
||||
render={(row, set) => (
|
||||
<>
|
||||
<Input
|
||||
aria-label="Lab name"
|
||||
onChange={(event) => set({ name: event.target.value })}
|
||||
placeholder="Test"
|
||||
value={row.name}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Value"
|
||||
onChange={(event) => set({ value: event.target.value })}
|
||||
placeholder="Value"
|
||||
value={row.value}
|
||||
/>
|
||||
<select
|
||||
aria-label="Flag"
|
||||
className={cn(controlClass, "w-auto")}
|
||||
onChange={(event) => set({ flag: event.target.value as LabFlag })}
|
||||
value={row.flag}
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
rows={labs}
|
||||
/>
|
||||
|
||||
<SectionList<VisitDraft>
|
||||
blank={{ type: "", date: "", provider: "", summary: "" }}
|
||||
label="Visits"
|
||||
onChange={setVisits}
|
||||
render={(row, set) => (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label="Visit type"
|
||||
onChange={(event) => set({ type: event.target.value })}
|
||||
placeholder="Type"
|
||||
value={row.type}
|
||||
/>
|
||||
<DatePicker
|
||||
ariaLabel="Visit date"
|
||||
className="w-40 shrink-0"
|
||||
onChange={(date) => set({ date })}
|
||||
placeholder="Date"
|
||||
value={row.date}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
aria-label="Provider"
|
||||
onChange={(event) => set({ provider: event.target.value })}
|
||||
placeholder="Provider"
|
||||
value={row.provider}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Summary"
|
||||
onChange={(event) => set({ summary: event.target.value })}
|
||||
placeholder="Summary"
|
||||
value={row.summary}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
rows={visits}
|
||||
/>
|
||||
</DialogPanel>
|
||||
|
||||
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
{error && (
|
||||
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
|
||||
)}
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button disabled={!name.trim() || submitting} type="submit">
|
||||
{submitting ? "Saving…" : isEdit ? "Save changes" : "Save patient"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogPopup>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { type MouseEvent, useId, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// A tiny dependency-free trend chart: a line with a soft shaded fill beneath it.
|
||||
// Stretches to its container width; colored via `currentColor` (default text-primary).
|
||||
// Moving the cursor over it reveals the value at the nearest reading.
|
||||
export function Sparkline({
|
||||
points,
|
||||
unit,
|
||||
className,
|
||||
}: {
|
||||
points: number[];
|
||||
unit?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const gradientId = useId();
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
if (points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const width = 100;
|
||||
const top = 3;
|
||||
const bottom = 29;
|
||||
const viewHeight = 32;
|
||||
const min = Math.min(...points);
|
||||
const max = Math.max(...points);
|
||||
const range = max - min;
|
||||
|
||||
const coords = points.map((value, index) => {
|
||||
const x =
|
||||
points.length === 1 ? width / 2 : (index / (points.length - 1)) * width;
|
||||
const y =
|
||||
range === 0
|
||||
? (top + bottom) / 2
|
||||
: bottom - ((value - min) / range) * (bottom - top);
|
||||
return { value, x, y, xPct: x, yPct: (y / viewHeight) * 100 };
|
||||
});
|
||||
|
||||
const line = coords
|
||||
.map((c, i) => `${i === 0 ? "M" : "L"}${c.x.toFixed(2)},${c.y.toFixed(2)}`)
|
||||
.join(" ");
|
||||
const area = `${line} L${width},${viewHeight} L0,${viewHeight} Z`;
|
||||
|
||||
const handleMove = (event: MouseEvent<HTMLDivElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const ratio = (event.clientX - rect.left) / rect.width;
|
||||
const index = Math.round(ratio * (points.length - 1));
|
||||
setActive(Math.max(0, Math.min(points.length - 1, index)));
|
||||
};
|
||||
|
||||
const point = active === null ? null : coords[active];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative h-10 w-full text-primary", className)}
|
||||
onMouseLeave={() => setActive(null)}
|
||||
onMouseMove={handleMove}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="size-full"
|
||||
preserveAspectRatio="none"
|
||||
viewBox={`0 0 ${width} ${viewHeight}`}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={area} fill={`url(#${gradientId})`} stroke="none" />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{point && (
|
||||
<>
|
||||
<span
|
||||
className="pointer-events-none absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary ring-2 ring-popover"
|
||||
style={{ left: `${point.xPct}%`, top: `${point.yPct}%` }}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-[calc(100%+6px)] rounded-md bg-popover px-1.5 py-0.5 text-xs whitespace-nowrap text-popover-foreground shadow ring-1 ring-foreground/10"
|
||||
style={{
|
||||
left: `${Math.min(85, Math.max(15, point.xPct))}%`,
|
||||
top: `${point.yPct}%`,
|
||||
}}
|
||||
>
|
||||
<span className="text-foreground">{point.value}</span>
|
||||
{unit ? (
|
||||
<span className="text-muted-foreground"> {unit}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
// Shared "create a clinic" form: name + auto-derived slug, creates the org and
|
||||
// makes it active. Used by both the onboarding page and the sidebar-footer
|
||||
// clinic menu's "Create clinic" dialog.
|
||||
export function CreateClinicForm({
|
||||
onCreated,
|
||||
submitLabel = "Create clinic",
|
||||
}: {
|
||||
onCreated?: (org: { id: string; name: string }) => void;
|
||||
submitLabel?: string;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugEdited, setSlugEdited] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const finalSlug = (slugEdited ? slug : slugify(name)) || slugify(name);
|
||||
const { data: org, error: createErr } =
|
||||
await authClient.organization.create({ name: name.trim(), slug: finalSlug });
|
||||
|
||||
if (createErr || !org) {
|
||||
const message = createErr?.message ?? "Could not create the clinic.";
|
||||
setError(message);
|
||||
notify.error("Couldn't create clinic", message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await authClient.organization.setActive({ organizationId: org.id });
|
||||
notify.success("Clinic created", `${org.name} is ready.`);
|
||||
onCreated?.(org);
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && (
|
||||
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="font-medium text-foreground text-sm"
|
||||
htmlFor="clinic-name"
|
||||
>
|
||||
Clinic name
|
||||
</label>
|
||||
<Input
|
||||
id="clinic-name"
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (!slugEdited) setSlug(slugify(e.target.value));
|
||||
}}
|
||||
placeholder="North Side Family Practice"
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
className="font-medium text-foreground text-sm"
|
||||
htmlFor="clinic-slug"
|
||||
>
|
||||
Clinic URL slug
|
||||
</label>
|
||||
<Input
|
||||
id="clinic-slug"
|
||||
onChange={(e) => {
|
||||
setSlugEdited(true);
|
||||
setSlug(slugify(e.target.value));
|
||||
}}
|
||||
placeholder="north-side-family-practice"
|
||||
required
|
||||
value={slug}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Used in links and invitations. Lowercase letters, numbers and dashes.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Creating clinic…" : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, CornerDownLeftIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandCollection,
|
||||
CommandDialog,
|
||||
CommandDialogPopup,
|
||||
CommandEmpty,
|
||||
CommandFooter,
|
||||
CommandGroup,
|
||||
CommandGroupLabel,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandPanel,
|
||||
} from "@/components/ui/command";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import { navItems } from "@/lib/nav";
|
||||
|
||||
type CommandPaletteContextValue = { open: () => void };
|
||||
|
||||
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function useCommandPalette(): CommandPaletteContextValue {
|
||||
const ctx = useContext(CommandPaletteContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useCommandPalette must be used within a CommandPaletteProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Holds the ⌘K palette open state, wires the global shortcut, and renders the
|
||||
// dialog. Wrap the app shell so the sidebar (and any child) can open it.
|
||||
export function CommandPaletteProvider({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
// One group ("Go to") matching the COSS command-palette particle shape.
|
||||
const groups = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: "pages",
|
||||
label: t("nav.commandGroup"),
|
||||
// Flatten sub-pages so e.g. "Appointments & Schedule" is reachable.
|
||||
items: navItems.flatMap((item) =>
|
||||
item.subs?.length
|
||||
? item.subs.map((sub) => ({
|
||||
id: sub.id,
|
||||
label: t(sub.labelKey),
|
||||
link: sub.link,
|
||||
Icon: sub.icon ?? item.icon,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
id: item.id,
|
||||
label: t(item.labelKey),
|
||||
link: item.link,
|
||||
Icon: item.icon,
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
type Group = (typeof groups)[number];
|
||||
type Item = Group["items"][number];
|
||||
|
||||
const value = useMemo<CommandPaletteContextValue>(
|
||||
() => ({ open: () => setOpen(true) }),
|
||||
[],
|
||||
);
|
||||
|
||||
const go = (link: string) => {
|
||||
setOpen(false);
|
||||
router.push(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<CommandPaletteContext.Provider value={value}>
|
||||
{children}
|
||||
<CommandDialog onOpenChange={setOpen} open={open}>
|
||||
<CommandDialogPopup>
|
||||
<Command items={groups}>
|
||||
<CommandInput placeholder={t("nav.commandPlaceholder")} />
|
||||
<CommandPanel>
|
||||
<CommandEmpty>{t("nav.commandEmpty")}</CommandEmpty>
|
||||
<CommandList>
|
||||
{(group: Group) => (
|
||||
<CommandGroup items={group.items} key={group.value}>
|
||||
<CommandGroupLabel>{group.label}</CommandGroupLabel>
|
||||
<CommandCollection>
|
||||
{(item: Item) => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
onClick={() => go(item.link)}
|
||||
value={item.label}
|
||||
>
|
||||
<item.Icon className="size-4 text-muted-foreground" />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandCollection>
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandPanel>
|
||||
<CommandFooter>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-2">
|
||||
<KbdGroup>
|
||||
<Kbd>
|
||||
<ArrowUpIcon />
|
||||
</Kbd>
|
||||
<Kbd>
|
||||
<ArrowDownIcon />
|
||||
</Kbd>
|
||||
</KbdGroup>
|
||||
{t("nav.commandNavigate")}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Kbd>
|
||||
<CornerDownLeftIcon />
|
||||
</Kbd>
|
||||
{t("nav.commandOpen")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="flex items-center gap-2">
|
||||
<Kbd>Esc</Kbd>
|
||||
{t("nav.commandClose")}
|
||||
</span>
|
||||
</CommandFooter>
|
||||
</Command>
|
||||
</CommandDialogPopup>
|
||||
</CommandDialog>
|
||||
</CommandPaletteContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import i18n from "@/lib/i18n/config";
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { notify } from "@/lib/toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function LoginForm({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(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.signIn.email({
|
||||
email: email.trim(),
|
||||
password,
|
||||
callbackURL: `${window.location.origin}/`,
|
||||
});
|
||||
|
||||
if (err) {
|
||||
const message = err.message ?? t("auth.login.error");
|
||||
setError(message);
|
||||
notify.error(t("auth.login.errorTitle"), message);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
notify.success(t("auth.login.welcomeBack"));
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-6", className)} {...props}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("auth.login.title")}</CardTitle>
|
||||
<CardDescription>{t("auth.login.subtitle")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-col gap-6">
|
||||
{error && (
|
||||
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">
|
||||
{t("auth.login.emailLabel")}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t("auth.login.emailPlaceholder")}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="w-full">
|
||||
<div className="flex w-full items-center">
|
||||
<FieldLabel htmlFor="password">
|
||||
{t("auth.login.passwordLabel")}
|
||||
</FieldLabel>
|
||||
<Link
|
||||
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
|
||||
href="/forgot-password"
|
||||
>
|
||||
{t("auth.login.forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
autoComplete="current-password"
|
||||
id="password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="w-full">
|
||||
<Button className="w-full" disabled={submitting} type="submit">
|
||||
{submitting ? t("auth.login.submitting") : t("auth.login.submit")}
|
||||
</Button>
|
||||
<FieldDescription className="w-full text-center">
|
||||
{t("auth.login.noAccount")}{" "}
|
||||
<Link
|
||||
className="font-medium text-foreground underline underline-offset-4"
|
||||
href="/signup"
|
||||
>
|
||||
{t("auth.login.signUpLink")}
|
||||
</Link>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { Mail, SendHorizonal } from "lucide-react";
|
||||
import { type FormEvent, useMemo, useState } from "react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// All conversations here are mock/placeholder data — there is no messaging
|
||||
// backend. They illustrate the inbox + chat-thread timeline layout.
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
direction: "in" | "out";
|
||||
text: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type Conversation = {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
role: string;
|
||||
unread: boolean;
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const seed: Conversation[] = [
|
||||
{
|
||||
id: "1",
|
||||
name: "Dr. Stein",
|
||||
initials: "DS",
|
||||
role: "Endocrinology",
|
||||
unread: true,
|
||||
messages: [
|
||||
{
|
||||
id: "1a",
|
||||
direction: "in",
|
||||
text: "The lab results for Amina Yusuf are back — lipid panel and HbA1c are both in range.",
|
||||
time: "10:24",
|
||||
},
|
||||
{
|
||||
id: "1b",
|
||||
direction: "in",
|
||||
text: "Want me to adjust her plan before the follow-up?",
|
||||
time: "10:25",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
name: "Dr. Okafor",
|
||||
initials: "DO",
|
||||
role: "Family medicine",
|
||||
unread: true,
|
||||
messages: [
|
||||
{
|
||||
id: "2a",
|
||||
direction: "out",
|
||||
text: "Sent over Daniel Mensah's intake notes — can you take a look?",
|
||||
time: "08:50",
|
||||
},
|
||||
{
|
||||
id: "2b",
|
||||
direction: "in",
|
||||
text: "Thanks! Added him to tomorrow at 10:00. Were his prior records imported?",
|
||||
time: "09:12",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
name: "Care team",
|
||||
initials: "CT",
|
||||
role: "Clinic-wide",
|
||||
unread: false,
|
||||
messages: [
|
||||
{
|
||||
id: "3a",
|
||||
direction: "in",
|
||||
text: "Seasonal vaccine stock has been replenished — slots are open all week.",
|
||||
time: "Yesterday",
|
||||
},
|
||||
{
|
||||
id: "3b",
|
||||
direction: "out",
|
||||
text: "Great, I'll direct eligible patients to the front desk.",
|
||||
time: "Yesterday",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
name: "Reception",
|
||||
initials: "RC",
|
||||
role: "Front desk",
|
||||
unread: false,
|
||||
messages: [
|
||||
{
|
||||
id: "4a",
|
||||
direction: "in",
|
||||
text: "Two Friday afternoon appointments were moved to next Monday at the patients' request.",
|
||||
time: "Mon",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const now = () =>
|
||||
new Date().toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export function MessagesView() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>(seed);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const unreadCount = conversations.filter((c) => c.unread).length;
|
||||
const selected = conversations.find((c) => c.id === selectedId) ?? null;
|
||||
|
||||
const visible = useMemo(
|
||||
() => (showUnreadOnly ? conversations.filter((c) => c.unread) : conversations),
|
||||
[conversations, showUnreadOnly],
|
||||
);
|
||||
|
||||
const open = (id: string) => {
|
||||
setSelectedId(id);
|
||||
setDraft("");
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, unread: false } : c)),
|
||||
);
|
||||
};
|
||||
|
||||
const send = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const text = draft.trim();
|
||||
if (!(text && selected)) return;
|
||||
const message: ChatMessage = {
|
||||
id: `${selected.id}-${Date.now()}`,
|
||||
direction: "out",
|
||||
text,
|
||||
time: now(),
|
||||
};
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === selected.id
|
||||
? { ...c, messages: [...c.messages, message] }
|
||||
: c,
|
||||
),
|
||||
);
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-4 p-4">
|
||||
{/* Left: conversation list */}
|
||||
<aside className="flex w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
|
||||
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
|
||||
<h1 className="font-semibold text-base tracking-tight">Inbox</h1>
|
||||
<Button
|
||||
aria-pressed={showUnreadOnly}
|
||||
onClick={() => setShowUnreadOnly((v) => !v)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={showUnreadOnly ? "secondary" : "ghost"}
|
||||
>
|
||||
Unread · {unreadCount}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
{visible.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-muted-foreground text-sm">
|
||||
No unread messages.
|
||||
</p>
|
||||
) : (
|
||||
visible.map((c) => {
|
||||
const last = c.messages.at(-1);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent",
|
||||
selected?.id === c.id && "bg-accent",
|
||||
)}
|
||||
key={c.id}
|
||||
onClick={() => open(c.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
{c.unread && (
|
||||
<span className="size-2 shrink-0 rounded-full bg-primary" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-sm",
|
||||
c.unread
|
||||
? "font-semibold text-foreground"
|
||||
: "font-medium text-foreground",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-muted-foreground text-xs">
|
||||
{last?.time}
|
||||
</span>
|
||||
</div>
|
||||
<span className="w-full truncate text-muted-foreground text-xs">
|
||||
{last?.direction === "out" && "You: "}
|
||||
{last?.text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right: conversation timeline or empty state */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{selected ? (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl border bg-card/30 p-4">
|
||||
<Avatar className="size-9">
|
||||
<AvatarFallback>{selected.initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium text-foreground text-sm">
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{selected.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border bg-card/30 p-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
{selected.messages.map((m) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1",
|
||||
m.direction === "out" ? "items-end" : "items-start",
|
||||
)}
|
||||
key={m.id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-2xl px-3 py-2 text-sm",
|
||||
m.direction === "out"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
<span className="px-1 text-muted-foreground text-[11px]">
|
||||
{m.time}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex items-center gap-2 rounded-2xl border bg-card/30 p-2"
|
||||
onSubmit={send}
|
||||
>
|
||||
<Input
|
||||
aria-label="Message"
|
||||
className="border-0 bg-transparent shadow-none before:hidden"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={`Message ${selected.name}…`}
|
||||
value={draft}
|
||||
/>
|
||||
<Button
|
||||
aria-label="Send"
|
||||
disabled={!draft.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizonal className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Mail />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No conversation selected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Choose a conversation from the inbox to read and reply.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { NotesEditor } from "@/components/notes/notes-editor";
|
||||
import {
|
||||
Sheet,
|
||||
SheetHeader,
|
||||
SheetPopup,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import type { Note } from "@/lib/notes";
|
||||
|
||||
// Right-side Sheet that holds the rich-text NotesEditor, opened from the Notes
|
||||
// list (mirrors the Patients table → side Sheet pattern). The save/delete logic
|
||||
// stays in NotesView and is passed down. `editorKey` remounts the editor when a
|
||||
// different note (or a fresh draft) is opened.
|
||||
export function NoteDetailSheet({
|
||||
note,
|
||||
editorKey,
|
||||
open,
|
||||
onOpenChange,
|
||||
saving,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
note: Note | null;
|
||||
editorKey: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
saving: boolean;
|
||||
onSave: (data: { title: string; content: string }) => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||
<SheetPopup className="sm:max-w-2xl" side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{note?.id ? "Edit note" : "New note"}</SheetTitle>
|
||||
</SheetHeader>
|
||||
{/* Plain flex container (not SheetPanel) so the editor gets a bounded
|
||||
height and scrolls internally rather than nesting two scroll areas. */}
|
||||
<div className="flex min-h-0 flex-1 flex-col px-6 pt-1 pb-6">
|
||||
{note && (
|
||||
<NotesEditor
|
||||
key={editorKey}
|
||||
note={note}
|
||||
onDelete={onDelete}
|
||||
onSave={onSave}
|
||||
saving={saving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetPopup>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import {
|
||||
Bold,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Redo2,
|
||||
Save,
|
||||
Trash2,
|
||||
Underline as UnderlineIcon,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useReducer, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Toolbar,
|
||||
ToolbarButton,
|
||||
ToolbarGroup,
|
||||
ToolbarSeparator,
|
||||
} from "@/components/ui/toolbar";
|
||||
import type { Note } from "@/lib/notes";
|
||||
|
||||
function FormatButton({
|
||||
active,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<ToolbarButton
|
||||
// Keep the editor selection while clicking the toolbar.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
render={
|
||||
<Button
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
// Word-like rich-text editor for a single note: a COSS Toolbar driving Tiptap
|
||||
// commands, a title field, and Save / Delete. Remount (via a `key` on the
|
||||
// parent) to load a different note.
|
||||
export function NotesEditor({
|
||||
note,
|
||||
saving,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
note: Note;
|
||||
saving: boolean;
|
||||
onSave: (data: { title: string; content: string }) => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState(note.title);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
// Force a re-render on every editor transaction so the toolbar reflects the
|
||||
// current formatting/undo state.
|
||||
const [, bump] = useReducer((n: number) => n + 1, 0);
|
||||
|
||||
const editor = useEditor({
|
||||
content: note.content,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder: "Write your note…" }),
|
||||
],
|
||||
// Required under Next's SSR to avoid a hydration mismatch.
|
||||
immediatelyRender: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
// `note-content` styles headings/lists/etc. (see globals.css); padding +
|
||||
// min-h-full make the whole card a click target, text anchored top-left.
|
||||
class: "note-content min-h-full p-4 focus:outline-none",
|
||||
},
|
||||
},
|
||||
onTransaction: bump,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
const save = () =>
|
||||
onSave({
|
||||
title: title.trim() || "Untitled note",
|
||||
content: editor.getHTML(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="font-medium text-base"
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Note title"
|
||||
value={title}
|
||||
/>
|
||||
{onDelete && (
|
||||
<Button
|
||||
aria-label="Delete note"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={saving} onClick={save} type="button">
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Toolbar className="w-fit max-w-full self-start overflow-x-auto">
|
||||
<ToolbarGroup>
|
||||
<FormatButton
|
||||
active={editor.isActive("bold")}
|
||||
label="Bold"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Bold />
|
||||
</FormatButton>
|
||||
<FormatButton
|
||||
active={editor.isActive("italic")}
|
||||
label="Italic"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Italic />
|
||||
</FormatButton>
|
||||
<FormatButton
|
||||
active={editor.isActive("underline")}
|
||||
label="Underline"
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<UnderlineIcon />
|
||||
</FormatButton>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSeparator orientation="vertical" />
|
||||
<ToolbarGroup>
|
||||
<FormatButton
|
||||
active={editor.isActive("heading", { level: 1 })}
|
||||
label="Heading 1"
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
>
|
||||
<Heading1 />
|
||||
</FormatButton>
|
||||
<FormatButton
|
||||
active={editor.isActive("heading", { level: 2 })}
|
||||
label="Heading 2"
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
>
|
||||
<Heading2 />
|
||||
</FormatButton>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSeparator orientation="vertical" />
|
||||
<ToolbarGroup>
|
||||
<FormatButton
|
||||
active={editor.isActive("bulletList")}
|
||||
label="Bullet list"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
>
|
||||
<List />
|
||||
</FormatButton>
|
||||
<FormatButton
|
||||
active={editor.isActive("orderedList")}
|
||||
label="Numbered list"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
>
|
||||
<ListOrdered />
|
||||
</FormatButton>
|
||||
</ToolbarGroup>
|
||||
<ToolbarSeparator orientation="vertical" />
|
||||
<ToolbarGroup>
|
||||
<FormatButton
|
||||
disabled={!editor.can().undo()}
|
||||
label="Undo"
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
>
|
||||
<Undo2 />
|
||||
</FormatButton>
|
||||
<FormatButton
|
||||
disabled={!editor.can().redo()}
|
||||
label="Redo"
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
>
|
||||
<Redo2 />
|
||||
</FormatButton>
|
||||
</ToolbarGroup>
|
||||
</Toolbar>
|
||||
|
||||
<div className="min-h-0 flex-1 cursor-text overflow-y-auto rounded-2xl border bg-card/30">
|
||||
<EditorContent className="h-full" editor={editor} />
|
||||
</div>
|
||||
|
||||
{onDelete && (
|
||||
<ConfirmDialog
|
||||
confirmLabel="Delete note"
|
||||
description={`"${title.trim() || "Untitled note"}" will be permanently deleted. This can't be undone.`}
|
||||
onConfirm={onDelete}
|
||||
onOpenChange={setConfirmOpen}
|
||||
open={confirmOpen}
|
||||
title="Delete this note?"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { NotebookPen, Plus } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { NoteDetailSheet } from "@/components/notes/note-detail-sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty";
|
||||
import {
|
||||
createNote,
|
||||
deleteNote,
|
||||
listNotes,
|
||||
type Note,
|
||||
updateNote,
|
||||
} from "@/lib/notes";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
const newDraft = (): Note => ({
|
||||
id: "",
|
||||
title: "",
|
||||
content: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
});
|
||||
|
||||
export function NotesView() {
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
// The note shown in the editor Sheet; null when the Sheet is closed.
|
||||
const [selected, setSelected] = useState<Note | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
listNotes()
|
||||
.then((data) => {
|
||||
if (active) setNotes(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (active) {
|
||||
notify.error(
|
||||
"Couldn't load notes",
|
||||
err instanceof Error ? err.message : "Please try again.",
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startNew = () => {
|
||||
setSelected(newDraft());
|
||||
setDraftKey((k) => k + 1);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const openNote = (note: Note) => {
|
||||
setSelected(note);
|
||||
setSheetOpen(true);
|
||||
};
|
||||
|
||||
const save = async (data: { title: string; content: string }) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = selected?.id
|
||||
? await updateNote(selected.id, data)
|
||||
: await createNote(data);
|
||||
const list = await listNotes();
|
||||
setNotes(list);
|
||||
setSelected(list.find((n) => n.id === saved.id) ?? saved);
|
||||
notify.success("Note saved");
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Couldn't save note",
|
||||
err instanceof Error ? err.message : "Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
try {
|
||||
await deleteNote(id);
|
||||
const list = await listNotes();
|
||||
setNotes(list);
|
||||
setSelected(null);
|
||||
setSheetOpen(false);
|
||||
notify.success("Note deleted");
|
||||
} catch (err) {
|
||||
notify.error(
|
||||
"Couldn't delete note",
|
||||
err instanceof Error ? err.message : "Please try again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">Notes</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Clinical notes. Click a note to open it.
|
||||
</p>
|
||||
</div>
|
||||
<Button className="rounded-3xl" onClick={startNew} type="button">
|
||||
<Plus className="size-4" />
|
||||
New note
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="rounded-2xl border bg-card/30 px-4 py-10 text-center text-muted-foreground text-sm">
|
||||
Loading…
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30 py-16">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<NotebookPen />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No notes yet</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Create a note to start writing.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button onClick={startNew} type="button">
|
||||
<Plus className="size-4" />
|
||||
New note
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{notes.map((n) => (
|
||||
<button
|
||||
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left transition-colors hover:bg-accent/50"
|
||||
key={n.id}
|
||||
onClick={() => openNote(n)}
|
||||
type="button"
|
||||
>
|
||||
<span className="w-full truncate font-medium text-foreground text-sm">
|
||||
{n.title || "Untitled note"}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Updated {new Date(n.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NoteDetailSheet
|
||||
editorKey={selected?.id || `draft-${draftKey}`}
|
||||
note={selected}
|
||||
onDelete={selected?.id ? () => remove(selected.id) : undefined}
|
||||
onOpenChange={(o) => {
|
||||
setSheetOpen(o);
|
||||
if (!o) setSelected(null);
|
||||
}}
|
||||
onSave={save}
|
||||
open={sheetOpen}
|
||||
saving={saving}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { PatientDetail } from "@/components/patients/patient-detail";
|
||||
import {
|
||||
Sheet,
|
||||
SheetHeader,
|
||||
SheetPanel,
|
||||
SheetPopup,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { getPatient, type Patient } from "@/lib/patients";
|
||||
|
||||
type Status = "loading" | "ready" | "not-found";
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-12 rounded-full" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-52" />
|
||||
</div>
|
||||
</div>
|
||||
{[0, 1, 2, 3].map((section) => (
|
||||
<div className="rounded-2xl border bg-card/30 p-4" key={section}>
|
||||
<Skeleton className="mb-3 h-4 w-24" />
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{[0, 1, 2].map((row) => (
|
||||
<Skeleton className="h-3.5 w-full" key={row} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Right-side Sheet showing a patient's full record, laid out to fit the sheet
|
||||
// width (see PatientDetail). Opened from the Patients table instead of routing
|
||||
// into the AI chat.
|
||||
export function PatientDetailSheet({
|
||||
fileNumber,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
fileNumber: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [patient, setPatient] = useState<Patient | null>(null);
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
// Bumped on open so the editor remounts with the latest patient data.
|
||||
const [editKey, setEditKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !fileNumber) return;
|
||||
let active = true;
|
||||
setStatus("loading");
|
||||
setPatient(null);
|
||||
getPatient(fileNumber)
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
setPatient(data);
|
||||
setStatus(data ? "ready" : "not-found");
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setStatus("not-found");
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, fileNumber]);
|
||||
|
||||
const title =
|
||||
status === "ready" && patient
|
||||
? patient.name
|
||||
: status === "not-found"
|
||||
? "Patient not found"
|
||||
: "Loading patient…";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||
<SheetPopup className="sm:max-w-xl" side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetPanel className="min-h-0 flex-1">
|
||||
{status === "loading" && <DetailSkeleton />}
|
||||
{status === "not-found" && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No patient found for file #{fileNumber}.
|
||||
</p>
|
||||
)}
|
||||
{status === "ready" && patient && (
|
||||
<PatientDetail
|
||||
onEdit={() => {
|
||||
setEditKey((k) => k + 1);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
patient={patient}
|
||||
/>
|
||||
)}
|
||||
</SheetPanel>
|
||||
</SheetPopup>
|
||||
</Sheet>
|
||||
|
||||
{patient && (
|
||||
<PatientFormDialog
|
||||
key={editKey}
|
||||
mode="edit"
|
||||
onOpenChange={setEditOpen}
|
||||
onSaved={(updated) => setPatient(updated)}
|
||||
open={editOpen}
|
||||
patient={patient}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user