diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..c138a60
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,17 @@
+# v0 sandbox internal files
+__v0_runtime_loader.js
+__v0_devtools.tsx
+__v0_jsx-dev-runtime.ts
+.npmrc
+.snowflake/
+.v0-trash/
+.vercel/
+next.user-config.*
+
+# Environment variables
+.env*.local
+
+# Common ignores
+node_modules/
+.next/
+.DS_Store
\ No newline at end of file
diff --git a/web/app/globals.css b/web/app/globals.css
new file mode 100644
index 0000000..95c7b00
--- /dev/null
+++ b/web/app/globals.css
@@ -0,0 +1,135 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ /* Surface 层级(沿用 BrandKit) */
+ --background: oklch(0.985 0.005 260);
+ --surface: oklch(0.985 0.005 260);
+ --surface-elevated: oklch(1 0 0);
+ --surface-muted: oklch(0.96 0.01 260);
+
+ --foreground: oklch(0.15 0.02 260);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.15 0.02 260);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.15 0.02 260);
+
+ /* 主色 - 蓝紫中性(候选 A) */
+ --primary: oklch(0.58 0.18 275);
+ --primary-foreground: oklch(0.98 0 0);
+ --primary-light: oklch(0.68 0.15 275);
+ --primary-subtle: oklch(0.96 0.03 275);
+
+ --secondary: oklch(0.96 0.01 260);
+ --secondary-foreground: oklch(0.13 0.02 260);
+ --muted: oklch(0.94 0.01 260);
+ --muted-foreground: oklch(0.50 0.02 260);
+ --accent: oklch(0.58 0.18 275);
+ --accent-foreground: oklch(0.98 0 0);
+
+ /* 状态色 */
+ --success: oklch(0.65 0.16 155);
+ --warning: oklch(0.72 0.15 75);
+ --danger: oklch(0.60 0.22 25);
+ --info: oklch(0.65 0.12 230);
+
+ --destructive: oklch(0.60 0.22 25);
+ --destructive-foreground: oklch(0.985 0 0);
+ --border: oklch(0.92 0.01 260);
+ --border-strong: oklch(0.86 0.01 260);
+ --input: oklch(0.92 0.01 260);
+ --ring: oklch(0.58 0.18 275);
+
+ --chart-1: oklch(0.58 0.18 275);
+ --chart-2: oklch(0.70 0.18 260);
+ --chart-3: oklch(0.55 0.14 200);
+ --chart-4: oklch(0.65 0.20 30);
+ --chart-5: oklch(0.50 0.15 160);
+
+ --radius: 1rem;
+}
+
+.dark {
+ --background: oklch(0.14 0.01 260);
+ --surface: oklch(0.14 0.01 260);
+ --surface-elevated: oklch(0.18 0.01 260);
+ --surface-muted: oklch(0.22 0.01 260);
+ --foreground: oklch(0.95 0.005 260);
+ --card: oklch(0.18 0.01 260);
+ --card-foreground: oklch(0.95 0.005 260);
+ --popover: oklch(0.18 0.01 260);
+ --popover-foreground: oklch(0.95 0.005 260);
+ --primary: oklch(0.70 0.18 275);
+ --primary-foreground: oklch(0.14 0.01 260);
+ --muted: oklch(0.22 0.01 260);
+ --muted-foreground: oklch(0.65 0.01 260);
+ --border: oklch(0.28 0.01 260);
+ --border-strong: oklch(0.36 0.01 260);
+ --input: oklch(0.28 0.01 260);
+ --ring: oklch(0.70 0.18 275);
+}
+
+@theme inline {
+ --font-sans: var(--font-inter), 'Noto Sans SC', system-ui, sans-serif;
+ --font-mono: 'Geist Mono', 'Sarasa Mono SC', ui-monospace, monospace;
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 6px);
+ --radius-md: calc(var(--radius) - 4px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 6px);
+
+ --color-success: var(--success);
+ --color-warning: var(--warning);
+ --color-danger: var(--danger);
+ --color-info: var(--info);
+ --color-surface: var(--surface);
+ --color-surface-elevated: var(--surface-elevated);
+ --color-surface-muted: var(--surface-muted);
+ --color-primary-light: var(--primary-light);
+ --color-primary-subtle: var(--primary-subtle);
+ --color-border-strong: var(--border-strong);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground font-sans antialiased;
+ }
+}
+
+@layer utilities {
+ .scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+ .scrollbar-hide::-webkit-scrollbar {
+ display: none;
+ }
+}
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
new file mode 100644
index 0000000..2a967f1
--- /dev/null
+++ b/web/app/layout.tsx
@@ -0,0 +1,34 @@
+import type { Metadata } from 'next'
+import { Inter, Noto_Sans_SC } from 'next/font/google'
+import './globals.css'
+
+const inter = Inter({
+ subsets: ['latin'],
+ variable: '--font-inter',
+ weight: ['400', '500', '600', '700'],
+ display: 'swap',
+})
+
+const notoSC = Noto_Sans_SC({
+ subsets: ['latin'],
+ variable: '--font-noto-sc',
+ weight: ['400', '500', '600', '700'],
+ display: 'swap',
+})
+
+export const metadata: Metadata = {
+ title: 'MeetNote · 会议转写总结',
+ description: '手机录音一键上传,云端 Whisper 转写 + Claude 总结,中英混说精准。',
+}
+
+export default function RootLayout({
+ children,
+}: Readonly<{ children: React.ReactNode }>) {
+ return (
+
+
+ {children}
+
+
+ )
+}
diff --git a/web/app/meetings/[id]/page.tsx b/web/app/meetings/[id]/page.tsx
new file mode 100644
index 0000000..4123966
--- /dev/null
+++ b/web/app/meetings/[id]/page.tsx
@@ -0,0 +1,256 @@
+'use client'
+
+import Link from 'next/link'
+import { use, useState } from 'react'
+import {
+ ArrowLeft,
+ Play,
+ Pause,
+ MoreHorizontal,
+ Star,
+ CheckSquare,
+ Target,
+ Hash,
+ Download,
+} from 'lucide-react'
+import { AppShell } from '@/components/app-shell'
+import { getMeeting, formatDuration, formatTime, mockTranscript } from '@/lib/mock-data'
+import { cn } from '@/lib/utils'
+import { notFound } from 'next/navigation'
+
+export default function MeetingDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>
+}) {
+ const { id } = use(params)
+ const meeting = getMeeting(id)
+ const [playing, setPlaying] = useState(false)
+ const [tab, setTab] = useState<'transcript' | 'summary'>('transcript')
+
+ if (!meeting) notFound()
+
+ const done = meeting.status === 'done'
+ const summary = meeting.summary
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+ {meeting.title}
+
+
+ {new Date(meeting.date).toLocaleString('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })}
+ {' · '}
+ {formatDuration(meeting.duration)}
+ {meeting.participants && ' · ' + meeting.participants.join(' ')}
+
+
+
+
+
+
+ {/* Player */}
+
+
+
+
+
+ {formatTime(600)}
+ {formatTime(meeting.duration)}
+
+
+
+
+
+
+
+ {/* Tabs mobile */}
+
+ {(['transcript', 'summary'] as const).map((t) => (
+
+ ))}
+
+
+ {/* Content */}
+ {done ? (
+
+
+
+ {/* Transcript */}
+
+
+
转写全文
+
+ {mockTranscript.length} 段
+
+
+
+
+ {mockTranscript.map((seg, i) => (
+
+
+
+ {seg.speaker}
+
+
+
+
{seg.text}
+
+ ))}
+
+
+
+ {/* Summary - 4 cards */}
+
+
+
+
+ ) : (
+
+
+
+ 会议正在处理中,状态:{meeting.status}
+
+
+
+ )}
+
+
+ )
+}
+
+function SummaryCard({
+ icon: Icon,
+ title,
+ accent,
+ children,
+}: {
+ icon: any
+ title: string
+ accent: string
+ children: React.ReactNode
+}) {
+ return (
+
+
+
+
{title}
+
+ {children}
+
+ )
+}
diff --git a/web/app/page.tsx b/web/app/page.tsx
new file mode 100644
index 0000000..9cb40c5
--- /dev/null
+++ b/web/app/page.tsx
@@ -0,0 +1,51 @@
+import { Plus } from 'lucide-react'
+import Link from 'next/link'
+import { AppShell } from '@/components/app-shell'
+import { MeetingCard } from '@/components/meeting-card'
+import { mockMeetings, groupByDate, dateLabel } from '@/lib/mock-data'
+
+export default function HomePage() {
+ const groups = groupByDate(mockMeetings)
+ const days = Object.keys(groups).sort().reverse()
+
+ return (
+
+
+
+
+
+ {days.map((day) => (
+
+
+
+ {dateLabel(day)}
+
+
+
+
+ {groups[day].map((m) => (
+
+ ))}
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/web/app/upload/page.tsx b/web/app/upload/page.tsx
new file mode 100644
index 0000000..993b3db
--- /dev/null
+++ b/web/app/upload/page.tsx
@@ -0,0 +1,146 @@
+'use client'
+
+import { useState, useRef } from 'react'
+import { UploadCloud, FileAudio, X } from 'lucide-react'
+import Link from 'next/link'
+import { AppShell } from '@/components/app-shell'
+import { cn } from '@/lib/utils'
+
+export default function UploadPage() {
+ const [file, setFile] = useState(null)
+ const [title, setTitle] = useState('')
+ const [participants, setParticipants] = useState('')
+ const [dragOver, setDragOver] = useState(false)
+ const inputRef = useRef(null)
+
+ const pickFile = (f: File | null | undefined) => {
+ if (f) setFile(f)
+ }
+
+ const humanSize = (bytes: number) => {
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB'
+ return (bytes / 1024 / 1024).toFixed(1) + ' MB'
+ }
+
+ return (
+
+
+
+
+ {/* Dropzone */}
+ {!file ? (
+
{
+ e.preventDefault()
+ setDragOver(true)
+ }}
+ onDragLeave={() => setDragOver(false)}
+ onDrop={(e) => {
+ e.preventDefault()
+ setDragOver(false)
+ pickFile(e.dataTransfer.files?.[0])
+ }}
+ onClick={() => inputRef.current?.click()}
+ className={cn(
+ 'flex flex-col items-center justify-center rounded-2xl border-2 border-dashed px-6 py-16 transition-all cursor-pointer',
+ dragOver
+ ? 'border-primary bg-primary-subtle'
+ : 'border-border bg-surface-elevated hover:border-primary/50 hover:bg-primary-subtle/50',
+ )}
+ >
+
+
+
+
拖拽音频文件到这里
+
或点击选择文件
+
+ 支持 m4a · mp3 · wav · mp4 · opus · 单文件最大 500 MB
+
+
pickFile(e.target.files?.[0])}
+ />
+
+ ) : (
+
+
+
+
+
+
+
+
{file.name}
+
+
+
{humanSize(file.size)}
+
+
+
+ )}
+
+ {/* Form */}
+
+
+ {/* Actions */}
+
+
+ 取消
+
+
+
+
+
+ 上传后 Groq Whisper 转写 · Claude 自动生成要点 / 待办 / 决议
+
+
+
+ )
+}
diff --git a/web/components.json b/web/components.json
new file mode 100644
index 0000000..4ee62ee
--- /dev/null
+++ b/web/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/web/components/app-shell.tsx b/web/components/app-shell.tsx
new file mode 100644
index 0000000..339b8e8
--- /dev/null
+++ b/web/components/app-shell.tsx
@@ -0,0 +1,96 @@
+'use client'
+
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { Upload, List, Search, Settings, AudioLines } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+const navItems = [
+ { href: '/', label: '会议', icon: List },
+ { href: '/upload', label: '上传', icon: Upload },
+ { href: '/search', label: '搜索', icon: Search, disabled: true },
+ { href: '/settings', label: '设置', icon: Settings, disabled: true },
+]
+
+export function AppShell({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname()
+
+ return (
+
+ {/* Sidebar - Desktop */}
+
+
+ {/* Main */}
+
{children}
+
+ {/* Bottom Tab - Mobile */}
+
+
+ )
+}
diff --git a/web/components/meeting-card.tsx b/web/components/meeting-card.tsx
new file mode 100644
index 0000000..3683ade
--- /dev/null
+++ b/web/components/meeting-card.tsx
@@ -0,0 +1,100 @@
+'use client'
+
+import Link from 'next/link'
+import { Clock, Users, Mic } from 'lucide-react'
+import type { Meeting } from '@/lib/mock-data'
+import { formatDuration } from '@/lib/mock-data'
+import { cn } from '@/lib/utils'
+
+const statusConfig = {
+ pending: { label: '排队中', color: 'text-muted-foreground', dot: 'bg-muted-foreground/60' },
+ uploading: { label: '上传中', color: 'text-info', dot: 'bg-info' },
+ transcribing: { label: '转写中', color: 'text-warning', dot: 'bg-warning animate-pulse' },
+ summarizing: { label: '总结中', color: 'text-warning', dot: 'bg-warning animate-pulse' },
+ done: { label: '已完成', color: 'text-success', dot: 'bg-success' },
+ failed: { label: '失败 · 重试', color: 'text-danger', dot: 'bg-danger' },
+}
+
+export function MeetingCard({ meeting }: { meeting: Meeting }) {
+ const cfg = statusConfig[meeting.status]
+ const time = new Date(meeting.date).toLocaleTimeString('zh-CN', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+
+ const isProcessing =
+ meeting.status === 'transcribing' || meeting.status === 'summarizing'
+
+ return (
+
+
+
+
+
+
{meeting.title}
+
+
+
+
+
+ {time} · {formatDuration(meeting.duration)}
+
+ {meeting.participants && meeting.participants.length > 0 && (
+
+
+ {meeting.participants.join(' · ')}
+
+ )}
+
+
+
+
+
+
+ {cfg.label}
+ {isProcessing && meeting.chunksTotal
+ ? ` ${meeting.chunksDone}/${meeting.chunksTotal} 片`
+ : ''}
+
+
+
+
+ {/* Progress bar for processing */}
+ {isProcessing && meeting.progress != null && (
+
+ )}
+
+ {/* Summary preview for done */}
+ {meeting.status === 'done' && meeting.summary && (
+ <>
+
+ {meeting.summary.preview}
+
+
+ {meeting.summary.keywords.map((kw) => (
+
+ #{kw}
+
+ ))}
+
+ >
+ )}
+
+ )
+}
diff --git a/web/components/theme-provider.tsx b/web/components/theme-provider.tsx
new file mode 100644
index 0000000..55c2f6e
--- /dev/null
+++ b/web/components/theme-provider.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ThemeProvider as NextThemesProvider,
+ type ThemeProviderProps,
+} from 'next-themes'
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children}
+}
diff --git a/web/components/ui/accordion.tsx b/web/components/ui/accordion.tsx
new file mode 100644
index 0000000..e538a33
--- /dev/null
+++ b/web/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+'use client'
+
+import * as React from 'react'
+import * as AccordionPrimitive from '@radix-ui/react-accordion'
+import { ChevronDownIcon } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180',
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ )
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/web/components/ui/alert-dialog.tsx b/web/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..9704452
--- /dev/null
+++ b/web/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+'use client'
+
+import * as React from 'react'
+import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
+
+import { cn } from '@/lib/utils'
+import { buttonVariants } from '@/components/ui/button'
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+}
diff --git a/web/components/ui/alert.tsx b/web/components/ui/alert.tsx
new file mode 100644
index 0000000..e6751ab
--- /dev/null
+++ b/web/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const alertVariants = cva(
+ 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
+ {
+ variants: {
+ variant: {
+ default: 'bg-card text-card-foreground',
+ destructive:
+ 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription }
diff --git a/web/components/ui/aspect-ratio.tsx b/web/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..40bb120
--- /dev/null
+++ b/web/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+'use client'
+
+import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+export { AspectRatio }
diff --git a/web/components/ui/avatar.tsx b/web/components/ui/avatar.tsx
new file mode 100644
index 0000000..aa98465
--- /dev/null
+++ b/web/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+'use client'
+
+import * as React from 'react'
+import * as AvatarPrimitive from '@radix-ui/react-avatar'
+
+import { cn } from '@/lib/utils'
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/web/components/ui/badge.tsx b/web/components/ui/badge.tsx
new file mode 100644
index 0000000..fc4126b
--- /dev/null
+++ b/web/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const badgeVariants = cva(
+ 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
+ {
+ variants: {
+ variant: {
+ default:
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
+ secondary:
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
+ destructive:
+ 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ },
+)
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'span'> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : 'span'
+
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/web/components/ui/breadcrumb.tsx b/web/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..1750ff2
--- /dev/null
+++ b/web/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { ChevronRight, MoreHorizontal } from 'lucide-react'
+
+import { cn } from '@/lib/utils'
+
+function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
+ return
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<'a'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'a'
+
+ return (
+
+ )
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
+ return (
+
+ )
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<'li'>) {
+ return (
+ svg]:size-3.5', className)}
+ {...props}
+ >
+ {children ?? }
+
+ )
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<'span'>) {
+ return (
+
+
+ More
+
+ )
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+}
diff --git a/web/components/ui/button-group.tsx b/web/components/ui/button-group.tsx
new file mode 100644
index 0000000..09d4430
--- /dev/null
+++ b/web/components/ui/button-group.tsx
@@ -0,0 +1,83 @@
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+import { Separator } from '@/components/ui/separator'
+
+const buttonGroupVariants = cva(
+ "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
+ {
+ variants: {
+ orientation: {
+ horizontal:
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
+ vertical:
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
+ },
+ },
+ defaultVariants: {
+ orientation: 'horizontal',
+ },
+ },
+)
+
+function ButtonGroup({
+ className,
+ orientation,
+ ...props
+}: React.ComponentProps<'div'> & VariantProps) {
+ return (
+
+ )
+}
+
+function ButtonGroupText({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'div'> & {
+ asChild?: boolean
+}) {
+ const Comp = asChild ? Slot : 'div'
+
+ return (
+
+ )
+}
+
+function ButtonGroupSeparator({
+ className,
+ orientation = 'vertical',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ ButtonGroup,
+ ButtonGroupSeparator,
+ ButtonGroupText,
+ buttonGroupVariants,
+}
diff --git a/web/components/ui/button.tsx b/web/components/ui/button.tsx
new file mode 100644
index 0000000..f64632d
--- /dev/null
+++ b/web/components/ui/button.tsx
@@ -0,0 +1,60 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/lib/utils'
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary:
+ 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost:
+ 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ 'icon-sm': 'size-8',
+ 'icon-lg': 'size-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : 'button'
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/web/components/ui/calendar.tsx b/web/components/ui/calendar.tsx
new file mode 100644
index 0000000..eaa373e
--- /dev/null
+++ b/web/components/ui/calendar.tsx
@@ -0,0 +1,213 @@
+'use client'
+
+import * as React from 'react'
+import {
+ ChevronDownIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+} from 'lucide-react'
+import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
+
+import { cn } from '@/lib/utils'
+import { Button, buttonVariants } from '@/components/ui/button'
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ captionLayout = 'label',
+ buttonVariant = 'ghost',
+ formatters,
+ components,
+ ...props
+}: React.ComponentProps & {
+ buttonVariant?: React.ComponentProps['variant']
+}) {
+ const defaultClassNames = getDefaultClassNames()
+
+ return (
+ svg]:rotate-180`,
+ String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
+ className,
+ )}
+ captionLayout={captionLayout}
+ formatters={{
+ formatMonthDropdown: (date) =>
+ date.toLocaleString('default', { month: 'short' }),
+ ...formatters,
+ }}
+ classNames={{
+ root: cn('w-fit', defaultClassNames.root),
+ months: cn(
+ 'flex gap-4 flex-col md:flex-row relative',
+ defaultClassNames.months,
+ ),
+ month: cn('flex flex-col w-full gap-4', defaultClassNames.month),
+ nav: cn(
+ 'flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between',
+ defaultClassNames.nav,
+ ),
+ button_previous: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_previous,
+ ),
+ button_next: cn(
+ buttonVariants({ variant: buttonVariant }),
+ 'size-(--cell-size) aria-disabled:opacity-50 p-0 select-none',
+ defaultClassNames.button_next,
+ ),
+ month_caption: cn(
+ 'flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)',
+ defaultClassNames.month_caption,
+ ),
+ dropdowns: cn(
+ 'w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5',
+ defaultClassNames.dropdowns,
+ ),
+ dropdown_root: cn(
+ 'relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md',
+ defaultClassNames.dropdown_root,
+ ),
+ dropdown: cn(
+ 'absolute bg-popover inset-0 opacity-0',
+ defaultClassNames.dropdown,
+ ),
+ caption_label: cn(
+ 'select-none font-medium',
+ captionLayout === 'label'
+ ? 'text-sm'
+ : 'rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5',
+ defaultClassNames.caption_label,
+ ),
+ table: 'w-full border-collapse',
+ weekdays: cn('flex', defaultClassNames.weekdays),
+ weekday: cn(
+ 'text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none',
+ defaultClassNames.weekday,
+ ),
+ week: cn('flex w-full mt-2', defaultClassNames.week),
+ week_number_header: cn(
+ 'select-none w-(--cell-size)',
+ defaultClassNames.week_number_header,
+ ),
+ week_number: cn(
+ 'text-[0.8rem] select-none text-muted-foreground',
+ defaultClassNames.week_number,
+ ),
+ day: cn(
+ 'relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none',
+ defaultClassNames.day,
+ ),
+ range_start: cn(
+ 'rounded-l-md bg-accent',
+ defaultClassNames.range_start,
+ ),
+ range_middle: cn('rounded-none', defaultClassNames.range_middle),
+ range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
+ today: cn(
+ 'bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none',
+ defaultClassNames.today,
+ ),
+ outside: cn(
+ 'text-muted-foreground aria-selected:text-muted-foreground',
+ defaultClassNames.outside,
+ ),
+ disabled: cn(
+ 'text-muted-foreground opacity-50',
+ defaultClassNames.disabled,
+ ),
+ hidden: cn('invisible', defaultClassNames.hidden),
+ ...classNames,
+ }}
+ components={{
+ Root: ({ className, rootRef, ...props }) => {
+ return (
+
+ )
+ },
+ Chevron: ({ className, orientation, ...props }) => {
+ if (orientation === 'left') {
+ return (
+
+ )
+ }
+
+ if (orientation === 'right') {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+ },
+ DayButton: CalendarDayButton,
+ WeekNumber: ({ children, ...props }) => {
+ return (
+
+
+ {children}
+
+ |
+ )
+ },
+ ...components,
+ }}
+ {...props}
+ />
+ )
+}
+
+function CalendarDayButton({
+ className,
+ day,
+ modifiers,
+ ...props
+}: React.ComponentProps) {
+ const defaultClassNames = getDefaultClassNames()
+
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ if (modifiers.focused) ref.current?.focus()
+ }, [modifiers.focused])
+
+ return (
+