@@ -0,0 +1,238 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { Loader2, Sparkles, X, Database, AlertTriangle } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAISettings } from "./ai-settings-provider"
|
||||
import { PromptEditor } from "./prompt-editor"
|
||||
import { AIResultPreview, type DraftRow } from "./ai-result-preview"
|
||||
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||
import { validateForRequest } from "@/lib/ai/settings"
|
||||
import { BATCH_IMPORT_PROMPT } from "@/lib/ai/prompts"
|
||||
import { safeParseAIJson } from "@/lib/ai/json"
|
||||
import type { KnowledgeItem, KnowledgeItemInput } from "@/lib/types"
|
||||
|
||||
export function AIBatchImportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onInserted,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
onInserted: (items: KnowledgeItem[]) => void
|
||||
}) {
|
||||
const { settings } = useAISettings()
|
||||
const [rawInput, setRawInput] = useState("")
|
||||
const [customPrompt, setCustomPrompt] = useState(BATCH_IMPORT_PROMPT)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [inserting, setInserting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [rawResponse, setRawResponse] = useState<string | null>(null)
|
||||
const [rows, setRows] = useState<DraftRow[]>([])
|
||||
const [controller, setController] = useState<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setError(null)
|
||||
setRawResponse(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const selectedCount = useMemo(() => rows.filter((r) => r._selected).length, [rows])
|
||||
|
||||
async function handleGenerate() {
|
||||
const err = validateForRequest(settings)
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
if (!rawInput.trim()) {
|
||||
toast.error("请先粘贴题目内容")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setRawResponse(null)
|
||||
setRows([])
|
||||
const ac = new AbortController()
|
||||
setController(ac)
|
||||
try {
|
||||
const content = await requestOpenAICompatible({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
model: settings.model,
|
||||
systemPrompt: settings.systemPrompt,
|
||||
customPrompt,
|
||||
messages: [{ role: "user", content: rawInput }],
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
signal: ac.signal,
|
||||
})
|
||||
const parsed = safeParseAIJson(content)
|
||||
if (!parsed.ok) {
|
||||
setError(parsed.error)
|
||||
setRawResponse(parsed.raw)
|
||||
return
|
||||
}
|
||||
setRows(
|
||||
parsed.items.map((it, i) => ({
|
||||
...it,
|
||||
_id: `${Date.now()}-${i}`,
|
||||
_selected: true,
|
||||
})),
|
||||
)
|
||||
toast.success(`已生成 ${parsed.items.length} 条`)
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") {
|
||||
toast.message("已取消生成")
|
||||
return
|
||||
}
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setController(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
controller?.abort()
|
||||
}
|
||||
|
||||
async function handleInsert() {
|
||||
const selected = rows.filter((r) => r._selected)
|
||||
if (selected.length === 0) {
|
||||
toast.error("请至少勾选一条")
|
||||
return
|
||||
}
|
||||
setInserting(true)
|
||||
try {
|
||||
const { createItems } = await import("@/lib/knowledge")
|
||||
const inputs: KnowledgeItemInput[] = selected.map((r) => ({
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
content: r.content,
|
||||
code_snippet: r.code_snippet,
|
||||
tags: r.tags,
|
||||
category: r.category,
|
||||
difficulty: r.difficulty,
|
||||
mastery: r.mastery,
|
||||
is_favorite: false,
|
||||
source_url: r.source_url,
|
||||
notes: r.notes,
|
||||
}))
|
||||
const created = await createItems(inputs)
|
||||
onInserted(created)
|
||||
toast.success(`成功插入 ${created.length} 条知识点`)
|
||||
// remove inserted rows from preview
|
||||
setRows((prev) => prev.filter((r) => !r._selected))
|
||||
} catch (e) {
|
||||
toast.error(`插入失败:${(e as Error).message}`)
|
||||
console.log("[v0] batch insert error:", e)
|
||||
} finally {
|
||||
setInserting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (loading ? null : onOpenChange(o))}>
|
||||
<DialogContent className="glass-panel flex max-h-[92vh] flex-col gap-0 overflow-hidden sm:max-w-3xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="size-5 text-primary" />
|
||||
AI 批量整理
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
粘贴多个题目 / 面试题 / 知识点,AI 会整理成结构化数据。插入前可预览、编辑并勾选。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/30 px-3 py-2 font-mono text-xs text-muted-foreground">
|
||||
<span className="text-primary">model</span> {settings.model || "—"}
|
||||
<span className="mx-1 opacity-40">|</span>
|
||||
<span className="text-primary">base</span>
|
||||
<span className="truncate">{settings.baseUrl || "—"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="rawInput">题目内容</Label>
|
||||
<Textarea
|
||||
id="rawInput"
|
||||
value={rawInput}
|
||||
onChange={(e) => setRawInput(e.target.value)}
|
||||
placeholder={"1. 什么是闭包?\n2. Vue2 和 Vue3 的区别?\n3. Promise.all 和 Promise.race 的区别?\n4. 实现防抖函数"}
|
||||
className="min-h-28 bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PromptEditor value={customPrompt} onChange={setCustomPrompt} rows={5} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
{loading ? (
|
||||
<Button variant="outline" onClick={handleCancel} className="gap-1.5">
|
||||
<X className="size-4" />
|
||||
取消生成
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleGenerate} disabled={!rawInput.trim()} className="ai-gradient gap-1.5">
|
||||
<Sparkles className="size-4" />
|
||||
生成
|
||||
</Button>
|
||||
)}
|
||||
{loading && (
|
||||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
AI 整理中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="neon-error grid gap-2 rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<AlertTriangle className="size-4" />
|
||||
{error}
|
||||
</div>
|
||||
{rawResponse && (
|
||||
<div className="grid gap-1">
|
||||
<span className="text-xs opacity-80">原始返回内容(可复制修复):</span>
|
||||
<Textarea readOnly value={rawResponse} className="min-h-24 bg-background/40 font-mono text-xs" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && <AIResultPreview rows={rows} onChange={setRows} />}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<p className="font-mono text-xs text-muted-foreground">所有内容确认后才会写入数据库</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={loading || inserting}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInsert}
|
||||
disabled={selectedCount === 0 || inserting}
|
||||
className={cn("gap-1.5")}
|
||||
>
|
||||
{inserting ? <Loader2 className="size-4 animate-spin" /> : <Database className="size-4" />}
|
||||
插入选中 ({selectedCount})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Send, Loader2, Copy, RefreshCw, Trash2, Plus, MessageSquare, X, Sparkles } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAISettings } from "./ai-settings-provider"
|
||||
import { Markdown } from "./markdown"
|
||||
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||
import { validateForRequest } from "@/lib/ai/settings"
|
||||
import { buildChatSystemPrompt } from "@/lib/ai/prompts"
|
||||
import {
|
||||
addMessage,
|
||||
clearMessages,
|
||||
createConversation,
|
||||
deleteLastAssistantMessage,
|
||||
fetchMessages,
|
||||
getLatestConversation,
|
||||
type AIConversation,
|
||||
} from "@/lib/ai/conversations"
|
||||
import type { ChatMessage } from "@/lib/ai/types"
|
||||
import type { KnowledgeItem } from "@/lib/types"
|
||||
|
||||
type UIMessage = { role: "user" | "assistant"; content: string }
|
||||
|
||||
export function AIChatPanel({ item }: { item: KnowledgeItem }) {
|
||||
const { settings } = useAISettings()
|
||||
const [conversation, setConversation] = useState<AIConversation | null>(null)
|
||||
const [messages, setMessages] = useState<UIMessage[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [booting, setBooting] = useState(true)
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// load latest conversation + messages on mount / item change
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setBooting(true)
|
||||
setMessages([])
|
||||
setConversation(null)
|
||||
;(async () => {
|
||||
try {
|
||||
const conv = await getLatestConversation(item.id)
|
||||
if (!active) return
|
||||
if (conv) {
|
||||
setConversation(conv)
|
||||
const rows = await fetchMessages(conv.id)
|
||||
if (!active) return
|
||||
setMessages(rows.filter((r) => r.role !== "system").map((r) => ({ role: r.role as "user" | "assistant", content: r.content })))
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[v0] load conversation error:", e)
|
||||
} finally {
|
||||
if (active) setBooting(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
controllerRef.current?.abort()
|
||||
}
|
||||
}, [item.id])
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" })
|
||||
}, [messages, loading])
|
||||
|
||||
async function ensureConversation(): Promise<AIConversation> {
|
||||
if (conversation) return conversation
|
||||
const conv = await createConversation(item.id, item.title)
|
||||
setConversation(conv)
|
||||
return conv
|
||||
}
|
||||
|
||||
async function runCompletion(history: UIMessage[]): Promise<string> {
|
||||
const chatMessages: ChatMessage[] = history.map((m) => ({ role: m.role, content: m.content }))
|
||||
const ac = new AbortController()
|
||||
controllerRef.current = ac
|
||||
return requestOpenAICompatible({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
model: settings.model,
|
||||
systemPrompt: buildChatSystemPrompt(item),
|
||||
messages: chatMessages,
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
signal: ac.signal,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const err = validateForRequest(settings)
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
const text = input.trim()
|
||||
if (!text || loading) return
|
||||
|
||||
const userMsg: UIMessage = { role: "user", content: text }
|
||||
const next = [...messages, userMsg]
|
||||
setMessages(next)
|
||||
setInput("")
|
||||
setLoading(true)
|
||||
try {
|
||||
const conv = await ensureConversation()
|
||||
await addMessage(conv.id, "user", text)
|
||||
const reply = await runCompletion(next)
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
|
||||
await addMessage(conv.id, "assistant", reply)
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") {
|
||||
toast.message("已取消回复")
|
||||
setMessages((prev) => prev.slice(0, -1)) // remove the user msg we optimistically added? keep it instead
|
||||
return
|
||||
}
|
||||
toast.error((e as Error).message)
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: `[出错] ${(e as Error).message}` }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
controllerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegenerate() {
|
||||
if (loading) return
|
||||
// find last user message
|
||||
const lastUserIdx = [...messages].reverse().findIndex((m) => m.role === "user")
|
||||
if (lastUserIdx === -1) return
|
||||
const historyEnd = messages.length - lastUserIdx // index just after last user message
|
||||
const history = messages.slice(0, historyEnd)
|
||||
setMessages(history)
|
||||
setLoading(true)
|
||||
try {
|
||||
const conv = await ensureConversation()
|
||||
await deleteLastAssistantMessage(conv.id)
|
||||
const reply = await runCompletion(history)
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply }])
|
||||
await addMessage(conv.id, "assistant", reply)
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") return
|
||||
toast.error((e as Error).message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
controllerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNewConversation() {
|
||||
controllerRef.current?.abort()
|
||||
try {
|
||||
const conv = await createConversation(item.id, item.title)
|
||||
setConversation(conv)
|
||||
setMessages([])
|
||||
toast.success("已新建会话")
|
||||
} catch (e) {
|
||||
toast.error("新建会话失败")
|
||||
console.log("[v0] new conversation error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!conversation) {
|
||||
setMessages([])
|
||||
return
|
||||
}
|
||||
try {
|
||||
await clearMessages(conversation.id)
|
||||
setMessages([])
|
||||
toast.success("已清空当前会话")
|
||||
} catch (e) {
|
||||
toast.error("清空失败")
|
||||
console.log("[v0] clear error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function copyMessage(content: string) {
|
||||
navigator.clipboard.writeText(content)
|
||||
toast.success("已复制")
|
||||
}
|
||||
|
||||
const hasAssistant = messages.some((m) => m.role === "assistant")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/40 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="flex items-center gap-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">
|
||||
<Sparkles className="size-3.5 text-primary" />
|
||||
AI 追问
|
||||
</h4>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={handleNewConversation} className="h-7 gap-1 px-2 text-xs">
|
||||
<Plus className="size-3.5" />
|
||||
新建
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
disabled={messages.length === 0}
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex max-h-72 flex-col gap-3 overflow-y-auto">
|
||||
{booting ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载会话...
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-6 text-center text-sm text-muted-foreground">
|
||||
<MessageSquare className="size-6" />
|
||||
针对「{item.title}」向 AI 提问,开始复习对话
|
||||
</div>
|
||||
) : (
|
||||
messages.map((m, i) => (
|
||||
<div key={i} className={cn("flex", m.role === "user" ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed",
|
||||
m.role === "user"
|
||||
? "bg-primary/15 text-foreground"
|
||||
: "border border-border bg-secondary/50 text-foreground",
|
||||
)}
|
||||
>
|
||||
{m.role === "assistant" ? (
|
||||
<Markdown content={m.content} />
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words">{m.content}</p>
|
||||
)}
|
||||
{m.role === "assistant" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyMessage(m.content)}
|
||||
aria-label="复制回复"
|
||||
className="absolute -right-2 -top-2 hidden rounded-md border border-border bg-background p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-secondary/50 px-3 py-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
AI 正在思考...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAssistant && !loading && (
|
||||
<div>
|
||||
<Button variant="ghost" size="sm" onClick={handleRegenerate} className="h-7 gap-1 px-2 text-xs">
|
||||
<RefreshCw className="size-3.5" />
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="输入你的问题... (Ctrl/Cmd + Enter 发送)"
|
||||
className="min-h-10 flex-1 bg-input/60 text-sm"
|
||||
/>
|
||||
{loading ? (
|
||||
<Button variant="outline" size="icon" onClick={() => controllerRef.current?.abort()} aria-label="取消">
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" onClick={handleSend} disabled={!input.trim()} aria-label="发送">
|
||||
<Send className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight, Copy, Download } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { DIFFICULTIES, DIFFICULTY_META } from "@/lib/types"
|
||||
import { AI_CATEGORY_ENUM, type AIDraftItem } from "@/lib/ai/types"
|
||||
|
||||
export type DraftRow = AIDraftItem & { _id: string; _selected: boolean }
|
||||
|
||||
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
|
||||
const CATEGORY_OPTIONS = AI_CATEGORY_ENUM.map((c) => ({ value: c, label: c }))
|
||||
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
|
||||
|
||||
export function AIResultPreview({
|
||||
rows,
|
||||
onChange,
|
||||
}: {
|
||||
rows: DraftRow[]
|
||||
onChange: (rows: DraftRow[]) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
function patch(id: string, patch: Partial<DraftRow>) {
|
||||
onChange(rows.map((r) => (r._id === id ? { ...r, ...patch } : r)))
|
||||
}
|
||||
|
||||
function toggleAll(value: boolean) {
|
||||
onChange(rows.map((r) => ({ ...r, _selected: value })))
|
||||
}
|
||||
|
||||
function copyJson() {
|
||||
const items = rows.map(({ _id, _selected, ...rest }) => rest)
|
||||
navigator.clipboard.writeText(JSON.stringify({ items }, null, 2))
|
||||
toast.success("已复制 JSON")
|
||||
}
|
||||
|
||||
function exportJson() {
|
||||
const items = rows.map(({ _id, _selected, ...rest }) => rest)
|
||||
const blob = new Blob([JSON.stringify({ items }, null, 2)], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `knowledge-items-${Date.now()}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const allSelected = rows.length > 0 && rows.every((r) => r._selected)
|
||||
const selectedCount = rows.filter((r) => r._selected).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} />
|
||||
全选 · 已选 {selectedCount}/{rows.length}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={copyJson} className="gap-1.5">
|
||||
<Copy className="size-3.5" />
|
||||
复制 JSON
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={exportJson} className="gap-1.5">
|
||||
<Download className="size-3.5" />
|
||||
导出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row) => {
|
||||
const open = expanded[row._id]
|
||||
return (
|
||||
<div
|
||||
key={row._id}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card/60 transition-colors",
|
||||
row._selected ? "border-primary/40" : "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<Checkbox checked={row._selected} onCheckedChange={(v) => patch(row._id, { _selected: !!v })} />
|
||||
<Input
|
||||
value={row.title}
|
||||
onChange={(e) => patch(row._id, { title: e.target.value })}
|
||||
className="h-8 flex-1 bg-input/60 text-sm font-medium"
|
||||
/>
|
||||
<Badge variant="outline" className="hidden font-mono text-[10px] sm:inline-flex">
|
||||
{row.category}
|
||||
</Badge>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => ({ ...e, [row._id]: !e[row._id] }))}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={open ? "收起" : "展开"}
|
||||
>
|
||||
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="grid gap-3 border-t border-border p-3">
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">摘要</span>
|
||||
<Input
|
||||
value={row.summary}
|
||||
onChange={(e) => patch(row._id, { summary: e.target.value })}
|
||||
className="bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">正文</span>
|
||||
<Textarea
|
||||
value={row.content}
|
||||
onChange={(e) => patch(row._id, { content: e.target.value })}
|
||||
className="min-h-20 bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
{row.code_snippet && (
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">代码片段</span>
|
||||
<Textarea
|
||||
value={row.code_snippet}
|
||||
onChange={(e) => patch(row._id, { code_snippet: e.target.value })}
|
||||
className="min-h-20 bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">分类</span>
|
||||
<Select
|
||||
value={row.category}
|
||||
onValueChange={(v) => patch(row._id, { category: v })}
|
||||
items={CATEGORY_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-input/60 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="font-mono text-xs text-muted-foreground">难度</span>
|
||||
<Select
|
||||
value={row.difficulty}
|
||||
onValueChange={(v) => patch(row._id, { difficulty: v as AIDraftItem["difficulty"] })}
|
||||
items={DIFFICULTY_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="h-8 bg-input/60 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{row.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{row.tags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="font-mono text-xs">
|
||||
#{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Eye, EyeOff, Loader2, RotateCcw, Trash2, Zap, CheckCircle2, XCircle } from "lucide-react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAISettings } from "./ai-settings-provider"
|
||||
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
|
||||
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||
import { validateForRequest } from "@/lib/ai/settings"
|
||||
|
||||
type TestState = { status: "idle" | "loading" | "ok" | "error"; message: string }
|
||||
|
||||
export function AISettingsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
|
||||
const { settings, setSettings } = useAISettings()
|
||||
const [draft, setDraft] = useState<AISettings>(settings)
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [test, setTest] = useState<TestState>({ status: "idle", message: "" })
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDraft(settings)
|
||||
setTest({ status: "idle", message: "" })
|
||||
}
|
||||
}, [open, settings])
|
||||
|
||||
function update<K extends keyof AISettings>(key: K, value: AISettings[K]) {
|
||||
setDraft((d) => ({ ...d, [key]: value }))
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSettings(draft)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setDraft({ ...DEFAULT_AI_SETTINGS })
|
||||
setTest({ status: "idle", message: "" })
|
||||
}
|
||||
|
||||
function handleClearKey() {
|
||||
update("apiKey", "")
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
const err = validateForRequest(draft)
|
||||
if (err) {
|
||||
setTest({ status: "error", message: err })
|
||||
return
|
||||
}
|
||||
setTest({ status: "loading", message: "正在测试连接..." })
|
||||
try {
|
||||
const reply = await requestOpenAICompatible({
|
||||
baseUrl: draft.baseUrl,
|
||||
apiKey: draft.apiKey,
|
||||
model: draft.model,
|
||||
messages: [{ role: "user", content: "ping,请只回复 ok" }],
|
||||
temperature: 0,
|
||||
maxTokens: 16,
|
||||
})
|
||||
setTest({ status: "ok", message: `连接成功 · 模型回复:${reply.slice(0, 40)}` })
|
||||
} catch (e) {
|
||||
setTest({ status: "error", message: (e as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="glass-panel flex max-h-[90vh] flex-col gap-0 overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Zap className="size-5 text-primary" />
|
||||
AI 设置
|
||||
</DialogTitle>
|
||||
<DialogDescription>配置 OpenAI-compatible 接口。API Key 仅保存在本地浏览器,不会写入数据库。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-3 pr-1">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
value={draft.baseUrl}
|
||||
onChange={(e) => update("baseUrl", e.target.value)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="apiKey">API Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showKey ? "text" : "password"}
|
||||
value={draft.apiKey}
|
||||
onChange={(e) => update("apiKey", e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="bg-input/60 pr-10 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
aria-label={showKey ? "隐藏" : "显示"}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showKey ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={handleClearKey} className="gap-1.5">
|
||||
<Trash2 className="size-4" />
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="model">Model</Label>
|
||||
<Input
|
||||
id="model"
|
||||
value={draft.model}
|
||||
onChange={(e) => update("model", e.target.value)}
|
||||
placeholder="gpt-4o-mini / deepseek-chat / qwen-plus"
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="temp">Temperature</Label>
|
||||
<Input
|
||||
id="temp"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="2"
|
||||
value={draft.temperature}
|
||||
onChange={(e) => update("temperature", Number(e.target.value))}
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="maxTokens">Max Tokens</Label>
|
||||
<Input
|
||||
id="maxTokens"
|
||||
type="number"
|
||||
min="1"
|
||||
value={draft.maxTokens}
|
||||
onChange={(e) => update("maxTokens", Number(e.target.value))}
|
||||
className="bg-input/60 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="systemPrompt">System Prompt</Label>
|
||||
<Textarea
|
||||
id="systemPrompt"
|
||||
value={draft.systemPrompt}
|
||||
onChange={(e) => update("systemPrompt", e.target.value)}
|
||||
className="min-h-24 bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||
<div>
|
||||
<Label htmlFor="stream" className="cursor-pointer">
|
||||
启用流式输出 (stream)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">部分接口需要关闭以兼容</p>
|
||||
</div>
|
||||
<Switch id="stream" checked={draft.stream} onCheckedChange={(v) => update("stream", v)} />
|
||||
</div>
|
||||
|
||||
{test.status !== "idle" && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-lg border px-3 py-2 text-sm",
|
||||
test.status === "ok" && "neon-success",
|
||||
test.status === "error" && "neon-error",
|
||||
test.status === "loading" && "border-border bg-muted/30 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{test.status === "loading" && <Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />}
|
||||
{test.status === "ok" && <CheckCircle2 className="mt-0.5 size-4 shrink-0" />}
|
||||
{test.status === "error" && <XCircle className="mt-0.5 size-4 shrink-0" />}
|
||||
<span className="break-words">{test.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleReset} className="gap-1.5">
|
||||
<RotateCcw className="size-4" />
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleTest} disabled={test.status === "loading"} className="gap-1.5">
|
||||
{test.status === "loading" ? <Loader2 className="size-4 animate-spin" /> : <Zap className="size-4" />}
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={handleSave}>保存设置</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
import { loadAISettings, saveAISettings } from "@/lib/ai/settings"
|
||||
import { DEFAULT_AI_SETTINGS, type AISettings } from "@/lib/ai/types"
|
||||
|
||||
type Ctx = {
|
||||
settings: AISettings
|
||||
setSettings: (next: AISettings) => void
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
const AISettingsContext = createContext<Ctx | null>(null)
|
||||
|
||||
export function AISettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [settings, setSettingsState] = useState<AISettings>(DEFAULT_AI_SETTINGS)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setSettingsState(loadAISettings())
|
||||
setLoaded(true)
|
||||
}, [])
|
||||
|
||||
const setSettings = useCallback((next: AISettings) => {
|
||||
setSettingsState(next)
|
||||
saveAISettings(next)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AISettingsContext.Provider value={{ settings, setSettings, loaded }}>{children}</AISettingsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAISettings() {
|
||||
const ctx = useContext(AISettingsContext)
|
||||
if (!ctx) throw new Error("useAISettings must be used within AISettingsProvider")
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* 统一的 AI Markdown 渲染组件,适配深色科技风主题。
|
||||
* 用于面试回答版与 AI 追问的回复内容。
|
||||
*/
|
||||
export function Markdown({ content, className }: { content: string; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm leading-relaxed break-words",
|
||||
// 段落与标题
|
||||
"[&_p]:my-2 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0",
|
||||
"[&_h1]:mt-3 [&_h1]:mb-2 [&_h1]:text-base [&_h1]:font-semibold",
|
||||
"[&_h2]:mt-3 [&_h2]:mb-2 [&_h2]:text-sm [&_h2]:font-semibold",
|
||||
"[&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-sm [&_h3]:font-semibold",
|
||||
// 列表
|
||||
"[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
|
||||
"[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5",
|
||||
"[&_li]:my-0.5 [&_li]:marker:text-muted-foreground",
|
||||
// 强调
|
||||
"[&_strong]:font-semibold [&_strong]:text-foreground",
|
||||
"[&_em]:italic",
|
||||
// 链接
|
||||
"[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",
|
||||
// 行内代码
|
||||
"[&_code]:rounded [&_code]:bg-secondary/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",
|
||||
// 代码块
|
||||
"[&_pre]:my-2 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:border-border [&_pre]:bg-secondary/40 [&_pre]:p-3",
|
||||
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:text-foreground",
|
||||
// 引用
|
||||
"[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-primary/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground",
|
||||
// 表格
|
||||
"[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_table]:text-xs",
|
||||
"[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-semibold",
|
||||
"[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1",
|
||||
// 分隔线
|
||||
"[&_hr]:my-3 [&_hr]:border-border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
export function PromptEditor({
|
||||
label = "自定义提示词 (customPrompt)",
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows = 6,
|
||||
}: {
|
||||
label?: string
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
rows?: number
|
||||
}) {
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(value)
|
||||
toast.success("已复制 Prompt")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{label}</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copy}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{ minHeight: `${rows * 1.6}rem` }}
|
||||
className="bg-input/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Star, Pencil, Trash2, Repeat, ExternalLink, Maximize2, Minimize2, Mic, Loader2, Copy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { KnowledgeItem } from "@/lib/types"
|
||||
import { DIFFICULTY_META, MASTERY_META } from "@/lib/types"
|
||||
import { AIChatPanel } from "@/components/ai/ai-chat-panel"
|
||||
import { Markdown } from "@/components/ai/markdown"
|
||||
import { useAISettings } from "@/components/ai/ai-settings-provider"
|
||||
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||
import { validateForRequest } from "@/lib/ai/settings"
|
||||
import { buildInterviewPrompt } from "@/lib/ai/prompts"
|
||||
import { saveInterviewAnswer } from "@/lib/knowledge"
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return "—"
|
||||
return new Date(value).toLocaleDateString("zh-CN", { month: "short", day: "numeric" })
|
||||
}
|
||||
|
||||
export function DetailDialog({
|
||||
item,
|
||||
onOpenChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onReview,
|
||||
onToggleFav,
|
||||
onItemUpdated,
|
||||
}: {
|
||||
item: KnowledgeItem | null
|
||||
onOpenChange: (open: boolean) => void
|
||||
onEdit: (item: KnowledgeItem) => void
|
||||
onDelete: (id: string) => void
|
||||
onReview: (item: KnowledgeItem) => void
|
||||
onToggleFav: (item: KnowledgeItem) => void
|
||||
onItemUpdated?: (item: KnowledgeItem) => void
|
||||
}) {
|
||||
const { settings } = useAISettings()
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [interview, setInterview] = useState<string | null>(null)
|
||||
const [interviewLoading, setInterviewLoading] = useState(false)
|
||||
|
||||
// 切换知识点时加载该卡片已保存的面试回答,避免不同卡片间串味
|
||||
useEffect(() => {
|
||||
setInterview(item?.interview_answer ?? null)
|
||||
setInterviewLoading(false)
|
||||
}, [item?.id, item?.interview_answer])
|
||||
|
||||
async function handleInterview(current: KnowledgeItem) {
|
||||
const err = validateForRequest(settings)
|
||||
if (err) return toast.error(err)
|
||||
setInterviewLoading(true)
|
||||
try {
|
||||
const reply = await requestOpenAICompatible({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
model: settings.model,
|
||||
customPrompt: buildInterviewPrompt(current),
|
||||
messages: [],
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
})
|
||||
const answer = reply.trim()
|
||||
setInterview(answer)
|
||||
// 持久化到数据库,新回答覆盖旧回答
|
||||
await saveInterviewAnswer(current.id, answer)
|
||||
onItemUpdated?.({ ...current, interview_answer: answer })
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
} finally {
|
||||
setInterviewLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!item) return null
|
||||
const diff = DIFFICULTY_META[item.difficulty]
|
||||
const mastery = MASTERY_META[item.mastery]
|
||||
|
||||
return (
|
||||
<Dialog open={!!item} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"flex flex-col gap-0 overflow-hidden",
|
||||
fullscreen
|
||||
? "left-0 top-0 h-screen max-h-screen w-screen max-w-none translate-x-0 translate-y-0 rounded-none sm:max-w-none"
|
||||
: "max-h-[90vh] sm:max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFullscreen((v) => !v)}
|
||||
aria-label={fullscreen ? "退出全屏" : "全屏"}
|
||||
className="absolute right-12 top-2.5 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
||||
</button>
|
||||
<DialogHeader className="shrink-0 pr-16">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-[10px] uppercase">
|
||||
{item.category}
|
||||
</Badge>
|
||||
<span className={cn("inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px]", mastery.color)}>
|
||||
<span className={cn("size-1.5 rounded-full", mastery.dot)} />
|
||||
{mastery.label}
|
||||
</span>
|
||||
<span className={cn("rounded-md border px-2 py-0.5 text-[10px]", diff.color)}>{diff.label}</span>
|
||||
</div>
|
||||
<DialogTitle className="text-pretty text-xl">{item.title}</DialogTitle>
|
||||
{item.summary && <p className="text-sm leading-relaxed text-muted-foreground">{item.summary}</p>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-w-0 flex-1 gap-5 overflow-y-auto py-2">
|
||||
{item.content && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="mb-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">详细内容</h4>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-relaxed">{item.content}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{item.code_snippet && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="mb-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">代码片段</h4>
|
||||
<pre className="max-w-full overflow-x-auto rounded-lg border border-border bg-secondary/40 p-3 text-sm">
|
||||
<code className="font-mono text-foreground">{item.code_snippet}</code>
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{item.notes && (
|
||||
<section className="min-w-0">
|
||||
<h4 className="mb-1.5 font-mono text-xs uppercase tracking-wide text-muted-foreground">个人笔记</h4>
|
||||
<p className="whitespace-pre-wrap break-words rounded-lg border border-border bg-muted/30 p-3 text-sm leading-relaxed">
|
||||
{item.notes}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{item.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{item.tags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="font-mono text-xs">
|
||||
#{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 rounded-lg border border-border bg-card/50 p-3 text-center font-mono">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-primary">{item.review_count}</p>
|
||||
<p className="text-[11px] text-muted-foreground">复习次数</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{formatDate(item.last_reviewed_at)}</p>
|
||||
<p className="text-[11px] text-muted-foreground">上次复习</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{formatDate(item.next_review_at)}</p>
|
||||
<p className="text-[11px] text-muted-foreground">下次复习</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.source_url && (
|
||||
<a
|
||||
href={item.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
查看来源
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* AI 面试回答版 */}
|
||||
<section className="min-w-0">
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<h4 className="font-mono text-xs uppercase tracking-wide text-muted-foreground">面试回答版</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleInterview(item)}
|
||||
disabled={interviewLoading}
|
||||
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
|
||||
>
|
||||
{interviewLoading ? <Loader2 className="size-3.5 animate-spin" /> : <Mic className="size-3.5" />}
|
||||
{interview ? "重新生成" : "AI 生成面试回答"}
|
||||
</Button>
|
||||
</div>
|
||||
{interview && (
|
||||
<div className="group relative rounded-lg border border-border bg-secondary/40 p-3">
|
||||
<Markdown content={interview} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(interview)
|
||||
toast.success("已复制")
|
||||
}}
|
||||
aria-label="复制面试回答"
|
||||
className="absolute right-2 top-2 hidden rounded-md border border-border bg-background p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* AI 追问聊天 */}
|
||||
<AIChatPanel key={item.id} item={item} />
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => onReview(item)} className="gap-1.5">
|
||||
<Repeat className="size-4" />
|
||||
完成复习
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onToggleFav(item)} className="gap-1.5">
|
||||
<Star className={cn("size-4", item.is_favorite && "fill-amber-400 text-amber-400")} />
|
||||
{item.is_favorite ? "已收藏" : "收藏"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => onEdit(item)} aria-label="编辑">
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(item.id)}
|
||||
aria-label="删除"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { X, Maximize2, Minimize2, Sparkles, Code2, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { Difficulty, KnowledgeItem, KnowledgeItemInput, Mastery } from "@/lib/types"
|
||||
import {
|
||||
CATEGORIES,
|
||||
DIFFICULTIES,
|
||||
DIFFICULTY_META,
|
||||
MASTERY_LEVELS,
|
||||
MASTERY_META,
|
||||
emptyItemInput,
|
||||
} from "@/lib/types"
|
||||
import { useAISettings } from "@/components/ai/ai-settings-provider"
|
||||
import { requestOpenAICompatible } from "@/lib/ai/client"
|
||||
import { validateForRequest } from "@/lib/ai/settings"
|
||||
import { buildCodeGenPrompt, buildOptimizePrompt } from "@/lib/ai/prompts"
|
||||
|
||||
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
|
||||
const CATEGORY_OPTIONS = CATEGORIES.map((c) => ({ value: c, label: c }))
|
||||
const DIFFICULTY_OPTIONS = DIFFICULTIES.map((d) => ({ value: d, label: DIFFICULTY_META[d].label }))
|
||||
const MASTERY_OPTIONS = MASTERY_LEVELS.map((m) => ({ value: m, label: MASTERY_META[m].label }))
|
||||
|
||||
export function ItemDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
initial: KnowledgeItem | null
|
||||
onSubmit: (input: KnowledgeItemInput) => void | Promise<void>
|
||||
}) {
|
||||
const { settings } = useAISettings()
|
||||
const [form, setForm] = useState<KnowledgeItemInput>(emptyItemInput())
|
||||
const [tagInput, setTagInput] = useState("")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [fullscreen, setFullscreen] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
const [genCode, setGenCode] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (initial) {
|
||||
setForm({
|
||||
title: initial.title,
|
||||
summary: initial.summary ?? "",
|
||||
content: initial.content ?? "",
|
||||
code_snippet: initial.code_snippet ?? "",
|
||||
tags: initial.tags ?? [],
|
||||
category: initial.category,
|
||||
difficulty: initial.difficulty,
|
||||
mastery: initial.mastery,
|
||||
is_favorite: initial.is_favorite,
|
||||
source_url: initial.source_url ?? "",
|
||||
notes: initial.notes ?? "",
|
||||
})
|
||||
} else {
|
||||
setForm(emptyItemInput())
|
||||
}
|
||||
setTagInput("")
|
||||
}, [open, initial])
|
||||
|
||||
function update<K extends keyof KnowledgeItemInput>(key: K, value: KnowledgeItemInput[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
const t = tagInput.trim().replace(/^#/, "")
|
||||
if (t && !form.tags.includes(t)) update("tags", [...form.tags, t])
|
||||
setTagInput("")
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.title.trim()) return
|
||||
setSubmitting(true)
|
||||
await onSubmit({ ...form, title: form.title.trim() })
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
async function handleOptimize() {
|
||||
const err = validateForRequest(settings)
|
||||
if (err) return toast.error(err)
|
||||
if (!form.title.trim()) return toast.error("请先填写标题")
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const content = await requestOpenAICompatible({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
model: settings.model,
|
||||
customPrompt: buildOptimizePrompt({ title: form.title, summary: form.summary, content: form.content }),
|
||||
messages: [],
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
})
|
||||
const cleaned = content.replace(/```(?:json)?/gi, "").trim()
|
||||
try {
|
||||
const parsed = JSON.parse(cleaned)
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
summary: typeof parsed.summary === "string" ? parsed.summary : f.summary,
|
||||
content: typeof parsed.content === "string" ? parsed.content : f.content,
|
||||
}))
|
||||
toast.success("已用 AI 优化内容")
|
||||
} catch {
|
||||
// not JSON: treat whole thing as content
|
||||
setForm((f) => ({ ...f, content: cleaned }))
|
||||
toast.success("已用 AI 优化内容")
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateCode() {
|
||||
const err = validateForRequest(settings)
|
||||
if (err) return toast.error(err)
|
||||
if (!form.title.trim()) return toast.error("请先填写标题")
|
||||
setGenCode(true)
|
||||
try {
|
||||
const content = await requestOpenAICompatible({
|
||||
baseUrl: settings.baseUrl,
|
||||
apiKey: settings.apiKey,
|
||||
model: settings.model,
|
||||
customPrompt: buildCodeGenPrompt({ title: form.title, summary: form.summary, content: form.content }),
|
||||
messages: [],
|
||||
temperature: settings.temperature,
|
||||
maxTokens: settings.maxTokens,
|
||||
})
|
||||
const code = content.replace(/```[a-z]*\n?/gi, "").replace(/```$/g, "").trim()
|
||||
setForm((f) => ({ ...f, code_snippet: code }))
|
||||
toast.success("已生成代码示例")
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message)
|
||||
} finally {
|
||||
setGenCode(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"flex flex-col gap-0 overflow-hidden",
|
||||
fullscreen
|
||||
? "left-0 top-0 h-screen max-h-screen w-screen max-w-none translate-x-0 translate-y-0 rounded-none sm:max-w-none"
|
||||
: "max-h-[90vh] sm:max-w-3xl",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFullscreen((v) => !v)}
|
||||
aria-label={fullscreen ? "退出全屏" : "全屏"}
|
||||
className="absolute right-12 top-2.5 inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
|
||||
</button>
|
||||
<DialogHeader className="shrink-0 pr-16 pl-2 pb-2">
|
||||
<DialogTitle>{initial ? "编辑知识点" : "新增知识点"}</DialogTitle>
|
||||
<DialogDescription>记录前端核心概念、代码片段与个人笔记。</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-w-0 flex-1 gap-4 overflow-y-auto py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">标题 *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={form.title}
|
||||
onChange={(e) => update("title", e.target.value)}
|
||||
placeholder="例如:事件循环 Event Loop"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="summary">一句话摘要</Label>
|
||||
<Input
|
||||
id="summary"
|
||||
value={form.summary}
|
||||
onChange={(e) => update("summary", e.target.value)}
|
||||
placeholder="简短描述这个知识点"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>分类</Label>
|
||||
<Select value={form.category} onValueChange={(v) => update("category", v)} items={CATEGORY_OPTIONS}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>难度</Label>
|
||||
<Select
|
||||
value={form.difficulty}
|
||||
onValueChange={(v) => update("difficulty", v as Difficulty)}
|
||||
items={DIFFICULTY_OPTIONS}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIFFICULTY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>掌握度</Label>
|
||||
<Select
|
||||
value={form.mastery}
|
||||
onValueChange={(v) => update("mastery", v as Mastery)}
|
||||
items={MASTERY_OPTIONS}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MASTERY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="content">详细内容</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing}
|
||||
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
|
||||
>
|
||||
{optimizing ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
|
||||
AI 优化内容
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={form.content}
|
||||
onChange={(e) => update("content", e.target.value)}
|
||||
placeholder="详细说明、原理、注意事项..."
|
||||
className="min-h-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="code">代码片段</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGenerateCode}
|
||||
disabled={genCode}
|
||||
className="h-7 gap-1 px-2 text-xs text-primary hover:text-primary"
|
||||
>
|
||||
{genCode ? <Loader2 className="size-3.5 animate-spin" /> : <Code2 className="size-3.5" />}
|
||||
AI 生成代码示例
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="code"
|
||||
value={form.code_snippet}
|
||||
onChange={(e) => update("code_snippet", e.target.value)}
|
||||
placeholder="// 相关示例代码"
|
||||
className="min-h-24 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tags">标签</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="tags"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}}
|
||||
placeholder="输入标签后回车"
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={addTag}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
{form.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{form.tags.map((t) => (
|
||||
<Badge key={t} variant="secondary" className="gap-1 font-mono text-xs">
|
||||
#{t}
|
||||
<button onClick={() => update("tags", form.tags.filter((x) => x !== t))}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="source">来源链接</Label>
|
||||
<Input
|
||||
id="source"
|
||||
value={form.source_url}
|
||||
onChange={(e) => update("source_url", e.target.value)}
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="notes">个人笔记</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={form.notes}
|
||||
onChange={(e) => update("notes", e.target.value)}
|
||||
placeholder="自己的理解、踩坑记录..."
|
||||
className="min-h-16"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-3 py-2">
|
||||
<Label htmlFor="fav" className="cursor-pointer">
|
||||
加入收藏
|
||||
</Label>
|
||||
<Switch id="fav" checked={form.is_favorite} onCheckedChange={(v) => update("is_favorite", v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!form.title.trim() || submitting}>
|
||||
{submitting ? "保存中..." : initial ? "保存修改" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { Star, Code2, Repeat } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { KnowledgeItem } from "@/lib/types"
|
||||
import { DIFFICULTY_META, MASTERY_META } from "@/lib/types"
|
||||
|
||||
export function KnowledgeCard({
|
||||
item,
|
||||
onOpen,
|
||||
onToggleFav,
|
||||
onTagClick,
|
||||
}: {
|
||||
item: KnowledgeItem
|
||||
onOpen: () => void
|
||||
onToggleFav: () => void
|
||||
onTagClick: (tag: string) => void
|
||||
}) {
|
||||
const diff = DIFFICULTY_META[item.difficulty]
|
||||
const mastery = MASTERY_META[item.mastery]
|
||||
|
||||
return (
|
||||
<article
|
||||
onClick={onOpen}
|
||||
className="group flex cursor-pointer flex-col rounded-xl border border-border bg-card/70 p-4 backdrop-blur-sm transition-colors hover:border-primary/40 hover:bg-card"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="font-mono text-[10px] uppercase tracking-wide">
|
||||
{item.category}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn("inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[10px]", mastery.color)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", mastery.dot)} />
|
||||
{mastery.label}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFav()
|
||||
}}
|
||||
className="text-muted-foreground transition-colors hover:text-amber-400"
|
||||
aria-label={item.is_favorite ? "取消收藏" : "收藏"}
|
||||
>
|
||||
<Star className={cn("size-4", item.is_favorite && "fill-amber-400 text-amber-400")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="mt-3 text-pretty font-semibold leading-snug group-hover:text-primary">{item.title}</h3>
|
||||
{item.summary && (
|
||||
<p className="flex-1 mt-1.5 line-clamp-2 text-sm leading-relaxed text-muted-foreground">{item.summary}</p>
|
||||
)}
|
||||
|
||||
{item.tags.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{item.tags.slice(0, 4).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onTagClick(t)
|
||||
}}
|
||||
>
|
||||
<Badge variant="secondary" className="font-mono text-[10px]">
|
||||
#{t}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<span className={cn("rounded-md border px-1.5 py-0.5", diff.color)}>{diff.label}</span>
|
||||
<div className="flex items-center gap-3 font-mono">
|
||||
{item.code_snippet && <Code2 className="size-3.5" />}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Repeat className="size-3.5" />
|
||||
{item.review_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { Plus, Search, Boxes, X, Settings, Sparkles } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import type { KnowledgeItem, KnowledgeItemInput } from "@/lib/types"
|
||||
import { CATEGORIES, MASTERY_LEVELS, MASTERY_META } from "@/lib/types"
|
||||
import {
|
||||
createItem,
|
||||
deleteItem,
|
||||
reviewItem,
|
||||
toggleFavorite,
|
||||
updateItem,
|
||||
} from "@/lib/knowledge"
|
||||
import { StatsBar } from "@/components/stats-bar"
|
||||
import { KnowledgeCard } from "@/components/knowledge-card"
|
||||
import { ItemDialog } from "@/components/item-dialog"
|
||||
import { DetailDialog } from "@/components/detail-dialog"
|
||||
import { AISettingsDialog } from "@/components/ai/ai-settings-dialog"
|
||||
import { AIBatchImportDialog } from "@/components/ai/ai-batch-import-dialog"
|
||||
|
||||
type SortKey = "recent" | "review" | "title"
|
||||
|
||||
type SelectOption<T extends string = string> = { value: T; label: string }
|
||||
|
||||
// 通过对象定义 value 与展示文案,下方循环渲染,便于维护
|
||||
const CATEGORY_OPTIONS: SelectOption[] = [
|
||||
{ value: "all", label: "全部分类" },
|
||||
...CATEGORIES.map((c) => ({ value: c, label: c })),
|
||||
]
|
||||
|
||||
const MASTERY_OPTIONS: SelectOption[] = [
|
||||
{ value: "all", label: "全部状态" },
|
||||
...MASTERY_LEVELS.map((m) => ({ value: m, label: MASTERY_META[m].label })),
|
||||
]
|
||||
|
||||
const SORT_OPTIONS: SelectOption<SortKey>[] = [
|
||||
{ value: "recent", label: "最近添加" },
|
||||
{ value: "review", label: "待复习" },
|
||||
{ value: "title", label: "按标题" },
|
||||
]
|
||||
|
||||
export function KnowledgeDashboard({ initialItems }: { initialItems: KnowledgeItem[] }) {
|
||||
const [items, setItems] = useState<KnowledgeItem[]>(initialItems)
|
||||
const [query, setQuery] = useState("")
|
||||
const [category, setCategory] = useState<string>("all")
|
||||
const [mastery, setMastery] = useState<string>("all")
|
||||
const [favoritesOnly, setFavoritesOnly] = useState(false)
|
||||
const [sort, setSort] = useState<SortKey>("recent")
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null)
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<KnowledgeItem | null>(null)
|
||||
const [detail, setDetail] = useState<KnowledgeItem | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [batchOpen, setBatchOpen] = useState(false)
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
for (const it of items) for (const t of it.tags) map.set(t, (map.get(t) ?? 0) + 1)
|
||||
return [...map.entries()].sort((a, b) => b[1] - a[1])
|
||||
}, [items])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = items.filter((it) => {
|
||||
if (category !== "all" && it.category !== category) return false
|
||||
if (mastery !== "all" && it.mastery !== mastery) return false
|
||||
if (favoritesOnly && !it.is_favorite) return false
|
||||
if (activeTag && !it.tags.includes(activeTag)) return false
|
||||
if (query) {
|
||||
const q = query.toLowerCase()
|
||||
const hay = [it.title, it.summary, it.content, it.notes, it.tags.join(" ")]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
if (!hay.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
list = [...list].sort((a, b) => {
|
||||
if (sort === "title") return a.title.localeCompare(b.title, "zh")
|
||||
if (sort === "review") {
|
||||
const av = a.next_review_at ? new Date(a.next_review_at).getTime() : Infinity
|
||||
const bv = b.next_review_at ? new Date(b.next_review_at).getTime() : Infinity
|
||||
return av - bv
|
||||
}
|
||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
})
|
||||
return list
|
||||
}, [items, category, mastery, favoritesOnly, activeTag, query, sort])
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
function openEdit(item: KnowledgeItem) {
|
||||
setEditing(item)
|
||||
setDetail(null)
|
||||
setFormOpen(true)
|
||||
}
|
||||
|
||||
async function handleSubmit(input: KnowledgeItemInput) {
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await updateItem(editing.id, input)
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||
toast.success("知识点已更新")
|
||||
} else {
|
||||
const created = await createItem(input)
|
||||
setItems((prev) => [created, ...prev])
|
||||
toast.success("知识点已添加")
|
||||
}
|
||||
setFormOpen(false)
|
||||
setEditing(null)
|
||||
} catch (e) {
|
||||
toast.error("操作失败,请重试")
|
||||
console.log("[v0] submit error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleBatchInserted(created: KnowledgeItem[]) {
|
||||
setItems((prev) => [...created, ...prev])
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await deleteItem(id)
|
||||
setItems((prev) => prev.filter((it) => it.id !== id))
|
||||
setDetail(null)
|
||||
toast.success("已删除")
|
||||
} catch (e) {
|
||||
toast.error("删除失败")
|
||||
console.log("[v0] delete error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleFav(item: KnowledgeItem) {
|
||||
const value = !item.is_favorite
|
||||
setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, is_favorite: value } : it)))
|
||||
try {
|
||||
await toggleFavorite(item.id, value)
|
||||
} catch (e) {
|
||||
setItems((prev) => prev.map((it) => (it.id === item.id ? { ...it, is_favorite: !value } : it)))
|
||||
toast.error("收藏失败")
|
||||
console.log("[v0] fav error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReview(item: KnowledgeItem) {
|
||||
try {
|
||||
const updated = await reviewItem(item.id, item.review_count, item.mastery)
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||
setDetail((d) => (d && d.id === updated.id ? updated : d))
|
||||
toast.success(`已复习 · 累计 ${updated.review_count} 次`)
|
||||
} catch (e) {
|
||||
toast.error("记录复习失败")
|
||||
console.log("[v0] review error:", e)
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = category !== "all" || mastery !== "all" || favoritesOnly || activeTag || query
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-30 border-b border-border/60 bg-background/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4 sm:px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg border border-primary/30 bg-primary/10 text-primary glow-ring">
|
||||
<Boxes className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-balance text-lg font-semibold tracking-tight">前端知识库</h1>
|
||||
<p className="font-mono text-xs text-muted-foreground">DevVault · 收集 · 整理 · 复习</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBatchOpen(true)}
|
||||
className="gap-2 border-primary/30 text-primary hover:text-primary"
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
<span className="hidden sm:inline">AI 批量整理</span>
|
||||
<span className="sm:hidden">AI</span>
|
||||
</Button>
|
||||
<Button onClick={openCreate} className="gap-2">
|
||||
<Plus className="size-4" />
|
||||
<span className="hidden sm:inline">新增知识点</span>
|
||||
<span className="sm:hidden">新增</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setSettingsOpen(true)} aria-label="设置">
|
||||
<Settings className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="tech-grid-bg flex-1">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
|
||||
<StatsBar items={items} />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="搜索标题、内容、标签..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={category} onValueChange={setCategory} items={CATEGORY_OPTIONS}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="分类" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={mastery} onValueChange={setMastery} items={MASTERY_OPTIONS}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="掌握度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MASTERY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sort} onValueChange={(v) => setSort(v as SortKey)} items={SORT_OPTIONS}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="排序" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant={favoritesOnly ? "default" : "outline"}
|
||||
onClick={() => setFavoritesOnly((v) => !v)}
|
||||
>
|
||||
收藏
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag cloud */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{allTags.slice(0, 16).map(([tag, count]) => (
|
||||
<button key={tag} onClick={() => setActiveTag((t) => (t === tag ? null : tag))}>
|
||||
<Badge
|
||||
variant={activeTag === tag ? "default" : "secondary"}
|
||||
className="cursor-pointer font-mono text-xs"
|
||||
>
|
||||
#{tag}
|
||||
<span className="ml-1 opacity-60">{count}</span>
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuery("")
|
||||
setCategory("all")
|
||||
setMastery("all")
|
||||
setFavoritesOnly(false)
|
||||
setActiveTag(null)
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3" /> 清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div className="mt-6">
|
||||
<p className="mb-3 font-mono text-xs text-muted-foreground">
|
||||
{filtered.length} / {items.length} 条记录
|
||||
</p>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<Boxes className="mb-3 size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{items.length === 0 ? "还没有知识点,点击「新增」开始收集吧" : "没有匹配的结果"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((item) => (
|
||||
<KnowledgeCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onOpen={() => setDetail(item)}
|
||||
onToggleFav={() => handleToggleFav(item)}
|
||||
onTagClick={(t) => setActiveTag((cur) => (cur === t ? null : t))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ItemDialog
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
initial={editing}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
<DetailDialog
|
||||
item={detail}
|
||||
onOpenChange={(o) => !o && setDetail(null)}
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
onReview={handleReview}
|
||||
onToggleFav={handleToggleFav}
|
||||
onItemUpdated={(updated) => {
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||
setDetail((d) => (d && d.id === updated.id ? updated : d))
|
||||
}}
|
||||
/>
|
||||
<AISettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<AIBatchImportDialog open={batchOpen} onOpenChange={setBatchOpen} onInserted={handleBatchInserted} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Library, GraduationCap, Star, Repeat } from "lucide-react"
|
||||
import type { KnowledgeItem } from "@/lib/types"
|
||||
|
||||
export function StatsBar({ items }: { items: KnowledgeItem[] }) {
|
||||
const total = items.length
|
||||
const mastered = items.filter((i) => i.mastery === "mastered").length
|
||||
const favorites = items.filter((i) => i.is_favorite).length
|
||||
const now = Date.now()
|
||||
const due = items.filter((i) => i.next_review_at && new Date(i.next_review_at).getTime() <= now).length
|
||||
const masteredPct = total ? Math.round((mastered / total) * 100) : 0
|
||||
|
||||
const stats = [
|
||||
{ label: "知识点总数", value: total, icon: Library, hint: "全部收录" },
|
||||
{ label: "已掌握", value: mastered, icon: GraduationCap, hint: `${masteredPct}% 掌握率` },
|
||||
{ label: "已收藏", value: favorites, icon: Star, hint: "重点关注" },
|
||||
{ label: "待复习", value: due, icon: Repeat, hint: "今日到期" },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rounded-xl border border-border bg-card/60 p-4 backdrop-blur-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{s.label}</span>
|
||||
<s.icon className="size-4 text-primary" />
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-2xl font-semibold tracking-tight">{s.value}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{s.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from '@base-ui/react/button'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
||||
outline:
|
||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||
ghost:
|
||||
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||
destructive:
|
||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||
icon: 'size-8',
|
||||
'icon-xs':
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm':
|
||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
||||
'icon-lg': 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
Reference in New Issue
Block a user