feat:重构UI
This commit is contained in:
199
apps/web/components/LanguageSelect.tsx
Normal file
199
apps/web/components/LanguageSelect.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, Search, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { LanguageOption } from '@/lib/languages'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
onValueChange: (value: string) => void
|
||||
options: LanguageOption[]
|
||||
commonOptions?: LanguageOption[]
|
||||
placeholder?: string
|
||||
searchPlaceholder?: string
|
||||
disabled?: boolean
|
||||
allowAuto?: boolean
|
||||
autoOption?: LanguageOption
|
||||
triggerClassName?: string
|
||||
}
|
||||
|
||||
export default function LanguageSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
commonOptions,
|
||||
placeholder = '选择语言',
|
||||
searchPlaceholder = '搜索语言(名称 / 代码)',
|
||||
disabled,
|
||||
allowAuto,
|
||||
autoOption,
|
||||
triggerClassName,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const allOptions = useMemo(() => {
|
||||
const list = [...options]
|
||||
if (allowAuto && autoOption) {
|
||||
return [autoOption, ...list]
|
||||
}
|
||||
return list
|
||||
}, [allowAuto, autoOption, options])
|
||||
|
||||
const selected = useMemo(() => {
|
||||
return allOptions.find((o) => o.code === value) || null
|
||||
}, [allOptions, value])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return allOptions
|
||||
return allOptions.filter((o) => (o.search || `${o.name} ${o.code}`).toLowerCase().includes(q))
|
||||
}, [allOptions, query])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setQuery('')
|
||||
const id = window.setTimeout(() => inputRef.current?.focus(), 0)
|
||||
return () => window.clearTimeout(id)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (code: string) => {
|
||||
onValueChange(code)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(true)}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
triggerClassName
|
||||
)}
|
||||
>
|
||||
<span className={cn('line-clamp-1', selected ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{selected?.name || placeholder}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false)
|
||||
}}
|
||||
>
|
||||
<Card className="w-full max-w-3xl border-muted shadow-lg">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>选择语言</CardTitle>
|
||||
<CardDescription>支持 200+ 语言,支持搜索</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{query && (
|
||||
<Button variant="outline" onClick={() => setQuery('')}>
|
||||
清空
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!query.trim() && commonOptions && commonOptions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{commonOptions.map((o) => (
|
||||
<button
|
||||
key={o.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(o.code)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1 text-sm transition-colors',
|
||||
o.code === value
|
||||
? 'bg-primary text-primary-foreground border-transparent'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
{o.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="rounded-md border bg-background max-h-[60vh] overflow-y-auto">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">无匹配语言</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filtered.map((o) => (
|
||||
<button
|
||||
key={o.code}
|
||||
type="button"
|
||||
onClick={() => handleSelect(o.code)}
|
||||
className={cn(
|
||||
'w-full text-left px-4 py-3 hover:bg-accent transition-colors',
|
||||
'flex items-center justify-between gap-4'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{o.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{o.code}</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{o.code === value ? (
|
||||
<Badge variant="success">已选</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
选择
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { ArrowRightLeft, Copy, Sparkles, StopCircle, Check } from './icons'
|
||||
import { Button } from './ui/button'
|
||||
import { Textarea } from './ui/textarea'
|
||||
import { Card } from './ui/card'
|
||||
import { cn } from '../lib/utils'
|
||||
import LanguageSelect from './LanguageSelect'
|
||||
import {
|
||||
AUTO_LANGUAGE,
|
||||
COMMON_LANGUAGE_OPTIONS,
|
||||
getLanguageName,
|
||||
LANGUAGE_OPTIONS,
|
||||
} from '@/lib/languages'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
|
||||
|
||||
const LANGUAGES = [
|
||||
{ code: 'auto', name: '自动检测' },
|
||||
{ code: 'zh', name: '中文' },
|
||||
{ code: 'en', name: '英语' },
|
||||
{ code: 'ja', name: '日语' },
|
||||
{ code: 'ko', name: '韩语' },
|
||||
{ code: 'fr', name: '法语' },
|
||||
{ code: 'de', name: '德语' },
|
||||
{ code: 'es', name: '西班牙语' },
|
||||
]
|
||||
|
||||
const STYLES = [
|
||||
{ value: 'literal', name: '直译' },
|
||||
{ value: 'fluent', name: '意译' },
|
||||
{ value: 'casual', name: '口语化' },
|
||||
{ value: 'literal', name: '直译', description: '保留原文结构' },
|
||||
{ value: 'fluent', name: '意译', description: '自然流畅' },
|
||||
{ value: 'casual', name: '口语', description: '轻松对话' },
|
||||
]
|
||||
|
||||
export default function TranslatorForm() {
|
||||
@@ -29,6 +30,7 @@ export default function TranslatorForm() {
|
||||
const [style, setStyle] = useState('literal')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const handleTranslate = async () => {
|
||||
@@ -50,7 +52,9 @@ export default function TranslatorForm() {
|
||||
body: JSON.stringify({
|
||||
source_text: sourceText,
|
||||
source_lang: sourceLang,
|
||||
source_lang_name: getLanguageName(sourceLang),
|
||||
target_lang: targetLang,
|
||||
target_lang_name: getLanguageName(targetLang),
|
||||
style,
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
@@ -83,8 +87,8 @@ export default function TranslatorForm() {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') {
|
||||
} catch (err: any) {
|
||||
if (err.name !== 'AbortError') {
|
||||
setError(err.message || '翻译失败')
|
||||
}
|
||||
} finally {
|
||||
@@ -101,121 +105,135 @@ export default function TranslatorForm() {
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(translation)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleSwapLanguages = () => {
|
||||
if (sourceLang === 'auto') return
|
||||
setSourceLang(targetLang)
|
||||
setTargetLang(sourceLang)
|
||||
setSourceText(translation)
|
||||
setTranslation(sourceText)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* 源语言选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">源语言</label>
|
||||
<select
|
||||
value={sourceLang}
|
||||
onChange={(e) => setSourceLang(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 目标语言选择 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">目标语言</label>
|
||||
<select
|
||||
value={targetLang}
|
||||
onChange={(e) => setTargetLang(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
>
|
||||
{LANGUAGES.filter((l) => l.code !== 'auto').map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 风格选择 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">翻译风格</label>
|
||||
<div className="flex gap-4">
|
||||
{STYLES.map((s) => (
|
||||
<label key={s.value} className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="style"
|
||||
value={s.value}
|
||||
checked={style === s.value}
|
||||
onChange={(e) => setStyle(e.target.value)}
|
||||
className="mr-1"
|
||||
<div className="w-full max-w-5xl mx-auto space-y-6">
|
||||
{/* Controls Bar */}
|
||||
<Card className="p-4 bg-background/50 backdrop-blur-sm border-muted">
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between items-center">
|
||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||
<div className="w-[140px]">
|
||||
<LanguageSelect
|
||||
value={sourceLang}
|
||||
onValueChange={setSourceLang}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
commonOptions={[AUTO_LANGUAGE, ...COMMON_LANGUAGE_OPTIONS]}
|
||||
placeholder="源语言"
|
||||
allowAuto
|
||||
autoOption={AUTO_LANGUAGE}
|
||||
/>
|
||||
{s.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 输入输出区域 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">原文</label>
|
||||
<textarea
|
||||
value={sourceText}
|
||||
onChange={(e) => setSourceText(e.target.value)}
|
||||
placeholder="请输入要翻译的文本..."
|
||||
className="w-full h-48 p-3 border rounded resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">译文</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={translation}
|
||||
readOnly
|
||||
placeholder="翻译结果将显示在这里..."
|
||||
className="w-full h-48 p-3 border rounded resize-none bg-gray-50"
|
||||
/>
|
||||
{translation && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white border rounded hover:bg-gray-100"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwapLanguages}
|
||||
className="shrink-0"
|
||||
disabled={sourceLang === 'auto'}
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="w-[140px]">
|
||||
<LanguageSelect
|
||||
value={targetLang}
|
||||
onValueChange={setTargetLang}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
commonOptions={COMMON_LANGUAGE_OPTIONS}
|
||||
placeholder="目标语言"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-2 md:pb-0">
|
||||
{STYLES.map((s) => (
|
||||
<Button
|
||||
key={s.value}
|
||||
variant={style === s.value ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setStyle(s.value)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
{s.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{isLoading ? (
|
||||
<Button onClick={handleStop} variant="destructive" size="sm">
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleTranslate}
|
||||
disabled={!sourceText.trim()}
|
||||
className="bg-primary hover:bg-primary/90 text-white shadow-lg shadow-primary/20"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
翻译
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-600 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{/* Main Input/Output Area */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-[500px]">
|
||||
{/* Source Text */}
|
||||
<Card className="relative p-0 overflow-hidden border-muted focus-within:ring-2 focus-within:ring-primary/20 transition-all">
|
||||
<Textarea
|
||||
value={sourceText}
|
||||
onChange={(e) => setSourceText(e.target.value)}
|
||||
placeholder="在此输入要翻译的文本..."
|
||||
className="w-full h-full min-h-[400px] p-6 text-lg border-0 focus-visible:ring-0 resize-none bg-transparent"
|
||||
/>
|
||||
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground">
|
||||
{sourceText.length} 字
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 按钮 */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleTranslate}
|
||||
disabled={isLoading || !sourceText.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '翻译中...' : '翻译'}
|
||||
</button>
|
||||
{isLoading && (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="px-6 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
{/* Translation Result */}
|
||||
<Card className="relative p-0 overflow-hidden border-muted bg-muted/30">
|
||||
<Textarea
|
||||
value={translation}
|
||||
readOnly
|
||||
placeholder="翻译结果..."
|
||||
className="w-full h-full min-h-[400px] p-6 text-lg border-0 focus-visible:ring-0 resize-none bg-transparent cursor-default"
|
||||
/>
|
||||
|
||||
{translation && (
|
||||
<div className="absolute bottom-4 right-4 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shadow-sm"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="absolute bottom-4 left-4 right-14 p-2 bg-red-100 dark:bg-red-900/30 text-red-600 text-sm rounded border border-red-200 dark:border-red-900">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
143
apps/web/components/icons.tsx
Normal file
143
apps/web/components/icons.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import * as React from "react"
|
||||
|
||||
export function ArrowRightLeft({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m16 3 4 4-4 4" />
|
||||
<path d="M20 7H4" />
|
||||
<path d="m8 21-4-4 4-4" />
|
||||
<path d="M4 17h16" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Check({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChevronDown({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChevronUp({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Copy({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
||||
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Sparkles({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
<path d="M5 3v4" />
|
||||
<path d="M9 3v4" />
|
||||
<path d="M3 5h4" />
|
||||
<path d="M3 9h4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function StopCircle({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<rect width="6" height="6" x="9" y="9" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
36
apps/web/components/ui/badge.tsx
Normal file
36
apps/web/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "text-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||
success: "border-transparent bg-emerald-500 text-white",
|
||||
warning: "border-transparent bg-amber-500 text-white",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> { }
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
56
apps/web/components/ui/button.tsx
Normal file
56
apps/web/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
79
apps/web/components/ui/card.tsx
Normal file
79
apps/web/components/ui/card.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
26
apps/web/components/ui/input.tsx
Normal file
26
apps/web/components/ui/input.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> { }
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
||||
20
apps/web/components/ui/label.tsx
Normal file
20
apps/web/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
|
||||
160
apps/web/components/ui/select.tsx
Normal file
160
apps/web/components/ui/select.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "../icons"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out 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",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
118
apps/web/components/ui/table.tsx
Normal file
118
apps/web/components/ui/table.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
|
||||
24
apps/web/components/ui/textarea.tsx
Normal file
24
apps/web/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
Reference in New Issue
Block a user