feat: 初版
knowledge-base / deploy (push) Failing after 11s

This commit is contained in:
2026-06-23 19:09:11 +08:00
commit 73b9c9fb7a
61 changed files with 9549 additions and 0 deletions
+238
View File
@@ -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>
)
}
+296
View File
@@ -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>
)
}
+200
View File
@@ -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>
)
}
+221
View File
@@ -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>
)
}
+38
View File
@@ -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
}
+49
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+250
View File
@@ -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>
)
}
+387
View File
@@ -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>
)
}
+87
View File
@@ -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>
)
}
+355
View File
@@ -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>
)
}
+36
View File
@@ -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>
)
}
+52
View File
@@ -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 }
+58
View File
@@ -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 }
+103
View File
@@ -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,
}
+29
View File
@@ -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 }
+160
View File
@@ -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,
}
+268
View File
@@ -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,
}
+20
View File
@@ -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 }
+20
View File
@@ -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 }
+201
View File
@@ -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,
}
+49
View File
@@ -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 }
+32
View File
@@ -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 }
+82
View File
@@ -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 }
+18
View File
@@ -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 }