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
+123
View File
@@ -0,0 +1,123 @@
name: knowledge-base
run-name: "${{ gitea.actor }} 正在部署知识库"
env:
IMAGE_NAME: knowledge-base
BASE_IMAGE: knowledge-base-base
HOST_PORT: 31106
DOCKER_BUILDKIT: "0"
on:
- push
jobs:
deploy:
runs-on:
- vps_jp
steps:
- name: 环境准备
run: |
STEP_START=$(date +%s)
if ! command -v docker &> /dev/null; then
echo "📦 安装 docker cli..."
apk add docker-cli
fi
if ! command -v git &> /dev/null; then
echo "📦 安装 git..."
apk add git
fi
docker --version
echo "🧱 BuildKit: ${DOCKER_BUILDKIT}"
echo "✅ 环境准备耗时: $(( $(date +%s) - STEP_START ))s"
- name: 克隆仓库代码
uses: actions/checkout@v4
- name: 构建基础镜像(首次或 Dockerfile.base 变更时)
run: |
STEP_START=$(date +%s)
BASE_HASH=$(sha256sum Dockerfile.base | cut -c1-12)
CURRENT_HASH=$(docker inspect $BASE_IMAGE:latest --format '{{index .Config.Labels "base.hash"}}' 2>/dev/null || echo "none")
if [ "$BASE_HASH" != "$CURRENT_HASH" ]; then
echo "📦 基础镜像需要构建 (hash: $BASE_HASH)"
docker build \
--label "base.hash=$BASE_HASH" \
-f Dockerfile.base \
-t $BASE_IMAGE:latest .
echo "✅ 基础镜像构建完成"
else
echo "✅ 基础镜像已是最新,跳过 (hash: $BASE_HASH)"
fi
echo "✅ 基础镜像步骤耗时: $(( $(date +%s) - STEP_START ))s"
- name: 构建应用镜像
run: |
STEP_START=$(date +%s)
echo "📦 开始构建应用镜像..."
docker build \
--build-arg NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }} \
--build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} \
-t $IMAGE_NAME:latest .
echo "✅ 应用镜像构建完成"
docker images $IMAGE_NAME:latest --format " 大小: {{.Size}}"
echo "✅ 应用镜像步骤耗时: $(( $(date +%s) - STEP_START ))s"
- name: 部署容器
run: |
STEP_START=$(date +%s)
echo "🔄 停止旧容器..."
if docker ps -a --filter "name=^${IMAGE_NAME}$" -q | grep -q .; then
docker stop --timeout 15 $IMAGE_NAME || true
docker rm $IMAGE_NAME || true
else
echo " 无旧容器需要清理"
fi
echo "🚀 启动新容器..."
docker run -d \
--name $IMAGE_NAME \
-p 127.0.0.1:$HOST_PORT:80 \
-e NGINX_USER=${{ secrets.NGINX_USER }} \
-e NGINX_PASSWORD=${{ secrets.NGINX_PASSWORD }} \
--restart unless-stopped \
$IMAGE_NAME:latest
echo "⏳ 等待容器启动/健康检查..."
MAX_WAIT=60
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
RUNNING_ID=$(docker ps --filter "name=^${IMAGE_NAME}$" --filter "status=running" -q)
if [ -n "$RUNNING_ID" ]; then
HEALTH=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $IMAGE_NAME 2>/dev/null || echo "unknown")
if [ "$HEALTH" = "healthy" ] || [ "$HEALTH" = "none" ]; then
break
fi
fi
sleep 2
WAITED=$((WAITED + 2))
done
if docker ps --filter "name=$IMAGE_NAME" --filter "status=running" -q | grep -q .; then
echo "✅ 容器运行中"
docker ps --filter "name=$IMAGE_NAME" --format " 状态: {{.Status}}"
echo " 启动等待: ${WAITED}s"
else
echo "❌ 容器启动失败"
docker inspect $IMAGE_NAME --format ' ExitCode={{.State.ExitCode}} Error={{.State.Error}} StartedAt={{.State.StartedAt}} FinishedAt={{.State.FinishedAt}}' 2>/dev/null || true
docker logs --tail 30 $IMAGE_NAME
exit 1
fi
echo "✅ 部署步骤耗时: $(( $(date +%s) - STEP_START ))s"
- name: 查看启动日志
run: |
STEP_START=$(date +%s)
echo "📋 容器最近日志:"
docker logs --tail 15 $IMAGE_NAME
echo "✅ 日志步骤耗时: $(( $(date +%s) - STEP_START ))s"
+15
View File
@@ -0,0 +1,15 @@
# v0 sandbox internal files
__v0_runtime_loader.js
__v0_devtools.tsx
__v0_jsx-dev-runtime.ts
.snowflake/
.v0-trash/
.vercel/
# Environment variables
.env*.local
# Common ignores
node_modules
.next/
.DS_Store
+32
View File
@@ -0,0 +1,32 @@
FROM knowledge-base-base:latest
# 安装 nginx 和 htpasswd 工具
RUN apk add --no-cache nginx apache2-utils
WORKDIR /app
# 复制包管理文件,利用 Docker 层缓存
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# 复制源码
COPY . .
# NEXT_PUBLIC_* 变量在构建时嵌入,通过 build-arg 传入
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
RUN pnpm build
# 配置 nginx
COPY nginx.conf /etc/nginx/http.d/default.conf
# 入口脚本:生成 htpasswd -> 启动 Next.js -> 启动 nginx
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/entrypoint.sh"]
+3
View File
@@ -0,0 +1,3 @@
FROM node:22-alpine
RUN npm install -g pnpm@latest
+182
View File
@@ -0,0 +1,182 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: var(--font-geist-sans), "Geist Fallback";
--font-mono: var(--font-geist-mono), "Geist Mono Fallback";
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
color-scheme: dark;
--background: oklch(0.16 0.012 240);
--foreground: oklch(0.94 0.005 240);
--card: oklch(0.2 0.014 240);
--card-foreground: oklch(0.94 0.005 240);
--popover: oklch(0.19 0.014 240);
--popover-foreground: oklch(0.94 0.005 240);
--primary: oklch(0.78 0.16 175);
--primary-foreground: oklch(0.16 0.02 200);
--secondary: oklch(0.26 0.015 240);
--secondary-foreground: oklch(0.92 0.005 240);
--muted: oklch(0.24 0.013 240);
--muted-foreground: oklch(0.65 0.012 240);
--accent: oklch(0.28 0.02 200);
--accent-foreground: oklch(0.94 0.005 240);
--destructive: oklch(0.62 0.21 18);
--border: oklch(0.3 0.014 240);
--input: oklch(0.26 0.014 240);
--ring: oklch(0.78 0.16 175);
--chart-1: oklch(0.78 0.16 175);
--chart-2: oklch(0.7 0.13 220);
--chart-3: oklch(0.78 0.15 90);
--chart-4: oklch(0.65 0.2 18);
--chart-5: oklch(0.6 0.02 240);
--radius: 0.625rem;
--sidebar: oklch(0.18 0.013 240);
--sidebar-foreground: oklch(0.94 0.005 240);
--sidebar-primary: oklch(0.78 0.16 175);
--sidebar-primary-foreground: oklch(0.16 0.02 200);
--sidebar-accent: oklch(0.26 0.015 240);
--sidebar-accent-foreground: oklch(0.94 0.005 240);
--sidebar-border: oklch(0.3 0.014 240);
--sidebar-ring: oklch(0.78 0.16 175);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
@layer utilities {
.tech-grid-bg {
background-image:
linear-gradient(
to right,
oklch(0.3 0.014 240 / 0.4) 1px,
transparent 1px
),
linear-gradient(
to bottom,
oklch(0.3 0.014 240 / 0.4) 1px,
transparent 1px
);
background-size: 44px 44px;
}
.glow-ring {
box-shadow:
0 0 0 1px oklch(0.78 0.16 175 / 0.25),
0 0 24px -6px oklch(0.78 0.16 175 / 0.35);
}
/* glassmorphism surface for AI dialogs */
.glass-panel {
background: oklch(0.2 0.014 240 / 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* gradient AI accent (cyan -> magenta) used only as a subtle accent on buttons */
.ai-gradient {
background-image: linear-gradient(
135deg,
oklch(0.78 0.16 175),
oklch(0.62 0.21 330)
);
color: oklch(0.16 0.02 200);
}
.ai-gradient:hover {
filter: brightness(1.08);
}
.neon-error {
color: oklch(0.72 0.2 18);
border-color: oklch(0.62 0.21 18 / 0.4);
background: oklch(0.62 0.21 18 / 0.08);
box-shadow: 0 0 16px -8px oklch(0.62 0.21 18 / 0.6);
}
.neon-success {
color: oklch(0.82 0.16 160);
border-color: oklch(0.78 0.16 160 / 0.4);
background: oklch(0.78 0.16 160 / 0.08);
box-shadow: 0 0 16px -8px oklch(0.78 0.16 160 / 0.6);
}
}
/* scrollbar styles */
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: oklch(0.18 0.013 240);
}
::-webkit-scrollbar-thumb {
background: oklch(0.78 0.16 175 / 0.6);
border-radius: 5px;
border: 2px solid oklch(0.18 0.013 240);
transition: background-color 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.78 0.16 175 / 0.9);
}
::-webkit-scrollbar-thumb:active {
background: oklch(0.78 0.16 175);
}
}
/* Firefox scrollbar */
* {
scrollbar-color: oklch(0.78 0.16 175 / 0.6) oklch(0.18 0.013 240);
scrollbar-width: thin;
}
+38
View File
@@ -0,0 +1,38 @@
import { Analytics } from '@vercel/analytics/next'
import type { Metadata, Viewport } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Toaster } from '@/components/ui/sonner'
import { AISettingsProvider } from '@/components/ai/ai-settings-provider'
import './globals.css'
const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] })
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
subsets: ['latin'],
})
export const metadata: Metadata = {
title: '前端知识库 · DevVault',
description: '收集、整理与复习前端核心知识点的个人知识库',
}
export const viewport: Viewport = {
colorScheme: 'dark',
themeColor: '#0c0f14',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="zh" className={`${geistSans.variable} ${geistMono.variable}`}>
<body className="font-sans antialiased bg-background">
<AISettingsProvider>{children}</AISettingsProvider>
<Toaster position="top-center" richColors />
{process.env.NODE_ENV === 'production' && <Analytics />}
</body>
</html>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { createClient } from "@/lib/supabase/server"
import type { KnowledgeItem } from "@/lib/types"
import { KnowledgeDashboard } from "@/components/knowledge-dashboard"
export const dynamic = "force-dynamic"
export default async function Page() {
const supabase = await createClient()
const { data } = await supabase
.from("knowledge_items")
.select("*")
.order("created_at", { ascending: false })
return <KnowledgeDashboard initialItems={(data ?? []) as KnowledgeItem[]} />
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+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 }
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
set -e
# 生成 nginx htpasswd
if [ -z "$NGINX_USER" ] || [ -z "$NGINX_PASSWORD" ]; then
echo "❌ 未设置 NGINX_USER 或 NGINX_PASSWORD,拒绝启动"
exit 1
fi
echo "🔐 生成访问密码..."
htpasswd -bc /etc/nginx/.htpasswd "$NGINX_USER" "$NGINX_PASSWORD"
# 后台启动 Next.js
echo "🚀 启动 Next.js..."
pnpm start &
# 等待 Next.js 就绪
echo "⏳ 等待 Next.js 就绪..."
MAX_WAIT=60
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
if wget -q --spider http://localhost:3000 2>/dev/null; then
echo "✅ Next.js 已就绪 (${WAITED}s)"
break
fi
sleep 1
WAITED=$((WAITED + 1))
done
# 前台启动 nginx
echo "🌐 启动 nginx..."
exec nginx -g 'daemon off;'
+70
View File
@@ -0,0 +1,70 @@
import type { ChatMessage, RequestAIParams } from "./types"
/**
* Generic, reusable OpenAI-compatible chat completion request.
* Not coupled to any specific business logic.
*/
export async function requestOpenAICompatible(params: RequestAIParams): Promise<string> {
const {
baseUrl,
apiKey,
model,
systemPrompt,
customPrompt,
messages,
temperature = 0.3,
maxTokens = 4000,
stream = false,
signal,
} = params
const finalMessages: ChatMessage[] = []
if (systemPrompt) {
finalMessages.push({ role: "system", content: systemPrompt })
}
if (customPrompt) {
finalMessages.push({ role: "user", content: customPrompt })
}
finalMessages.push(...messages)
const url = `${baseUrl.replace(/\/$/, "")}/chat/completions`
let response: Response
try {
response = await fetch(url, {
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model,
messages: finalMessages,
temperature,
max_tokens: maxTokens,
stream,
}),
})
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err
throw new Error(`无法连接到 AI 服务:${(err as Error).message}`)
}
if (!response.ok) {
const errorText = await response.text().catch(() => "")
throw new Error(`AI 请求失败:${response.status} ${errorText}`)
}
const data = await response.json()
const content = data?.choices?.[0]?.message?.content
if (!content) {
throw new Error("AI 没有返回有效内容")
}
return content as string
}
+91
View File
@@ -0,0 +1,91 @@
import { createClient } from "@/lib/supabase/client"
export type AIConversation = {
id: string
knowledge_item_id: string | null
title: string | null
created_at: string
updated_at: string
}
export type AIMessageRow = {
id: string
conversation_id: string
role: "user" | "assistant" | "system"
content: string
created_at: string
}
/** Get the latest conversation for a knowledge item, if any. */
export async function getLatestConversation(knowledgeItemId: string): Promise<AIConversation | null> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_conversations")
.select("*")
.eq("knowledge_item_id", knowledgeItemId)
.order("updated_at", { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
return (data as AIConversation) ?? null
}
export async function createConversation(knowledgeItemId: string, title: string): Promise<AIConversation> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_conversations")
.insert({ knowledge_item_id: knowledgeItemId, title })
.select("*")
.single()
if (error) throw error
return data as AIConversation
}
export async function fetchMessages(conversationId: string): Promise<AIMessageRow[]> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_messages")
.select("*")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: true })
if (error) throw error
return (data ?? []) as AIMessageRow[]
}
export async function addMessage(
conversationId: string,
role: "user" | "assistant",
content: string,
): Promise<AIMessageRow> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_messages")
.insert({ conversation_id: conversationId, role, content })
.select("*")
.single()
if (error) throw error
// touch conversation updated_at
await supabase.from("ai_conversations").update({ updated_at: new Date().toISOString() }).eq("id", conversationId)
return data as AIMessageRow
}
export async function clearMessages(conversationId: string): Promise<void> {
const supabase = createClient()
const { error } = await supabase.from("ai_messages").delete().eq("conversation_id", conversationId)
if (error) throw error
}
export async function deleteLastAssistantMessage(conversationId: string): Promise<void> {
const supabase = createClient()
const { data, error } = await supabase
.from("ai_messages")
.select("id, role")
.eq("conversation_id", conversationId)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
if (data && (data as { role: string }).role === "assistant") {
await supabase.from("ai_messages").delete().eq("id", (data as { id: string }).id)
}
}
+103
View File
@@ -0,0 +1,103 @@
import {
AI_CATEGORY_ENUM,
AI_DIFFICULTY_ENUM,
AI_MASTERY_ENUM,
AI_TAG_ENUM,
type AIDraftItem,
} from "./types"
export type ParseResult =
| { ok: true; items: AIDraftItem[] }
| { ok: false; error: string; raw: string }
/** Strip markdown code fences and locate the JSON payload. */
function stripFences(input: string): string {
let text = input.trim()
// remove ```json ... ``` or ``` ... ``` wrappers
const fenceMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
if (fenceMatch) {
text = fenceMatch[1].trim()
}
// fall back: slice from first { to last }
if (!text.startsWith("{")) {
const first = text.indexOf("{")
const last = text.lastIndexOf("}")
if (first !== -1 && last !== -1 && last > first) {
text = text.slice(first, last + 1)
}
}
return text
}
function toStr(value: unknown): string {
if (typeof value === "string") return value
if (value == null) return ""
return String(value)
}
function sanitizeTags(value: unknown): string[] {
if (!Array.isArray(value)) return []
const allowed = new Set<string>(AI_TAG_ENUM)
const out: string[] = []
for (const t of value) {
const tag = toStr(t).trim()
if (allowed.has(tag) && !out.includes(tag)) out.push(tag)
}
return out
}
function sanitizeItem(raw: Record<string, unknown>): AIDraftItem {
const category = toStr(raw.category).trim()
const difficulty = toStr(raw.difficulty).trim()
const mastery = toStr(raw.mastery).trim()
return {
title: toStr(raw.title).trim(),
summary: toStr(raw.summary).trim(),
content: toStr(raw.content).trim(),
code_snippet: toStr(raw.code_snippet),
tags: sanitizeTags(raw.tags),
category: (AI_CATEGORY_ENUM as readonly string[]).includes(category) ? category : "其他",
difficulty: (AI_DIFFICULTY_ENUM as readonly string[]).includes(difficulty)
? (difficulty as AIDraftItem["difficulty"])
: "medium",
mastery: (AI_MASTERY_ENUM as readonly string[]).includes(mastery)
? (mastery as AIDraftItem["mastery"])
: "new",
source_url: toStr(raw.source_url).trim(),
notes: toStr(raw.notes).trim(),
}
}
/**
* Safely parse AI output into a list of draft knowledge items.
* - tolerant of markdown fences
* - validates items is an array
* - filters/normalizes enum fields with sensible fallbacks
* - drops items without a title
*/
export function safeParseAIJson(input: string): ParseResult {
const cleaned = stripFences(input)
let data: unknown
try {
data = JSON.parse(cleaned)
} catch {
return { ok: false, error: "无法解析 AI 返回的 JSON", raw: input }
}
const itemsRaw = (data as { items?: unknown })?.items
if (!Array.isArray(itemsRaw)) {
return { ok: false, error: 'JSON 顶层缺少数组字段 "items"', raw: input }
}
const items = itemsRaw
.filter((it): it is Record<string, unknown> => !!it && typeof it === "object")
.map(sanitizeItem)
.filter((it) => it.title.length > 0)
if (items.length === 0) {
return { ok: false, error: "未能从返回内容中提取到有效条目", raw: input }
}
return { ok: true, items }
}
+119
View File
@@ -0,0 +1,119 @@
import type { KnowledgeItem } from "@/lib/types"
import { CATEGORIES, DIFFICULTIES, MASTERY_LEVELS, TAGS } from "@/lib/types"
const ITEM_FIELDS = [
"title",
"summary",
"content",
"code_snippet",
"tags",
"category",
"difficulty",
"mastery",
"source_url",
"notes",
] as const
export interface BatchImportPromptOptions {
/** tags 可选枚举,默认取 lib/types 中的 TAGS */
tags?: readonly string[]
/** category 可选枚举,默认取 lib/types 中的 CATEGORIES */
categories?: readonly string[]
/** difficulty 可选枚举,默认取 lib/types 中的 DIFFICULTIES */
difficulties?: readonly string[]
/** mastery 默认值,默认取 MASTERY_LEVELS 的第一项 */
defaultMastery?: string
}
/**
* 根据类型定义生成批量导入提示词。
* 枚举字段全部从 lib/types 的常量派生,修改类型后提示词会自动同步。
*/
export function buildBatchImportPrompt(options: BatchImportPromptOptions = {}): string {
const {
tags = TAGS,
categories = CATEGORIES,
difficulties = DIFFICULTIES,
defaultMastery = MASTERY_LEVELS[0],
} = options
const fieldsList = ITEM_FIELDS.map((f) => ` - ${f}`).join("\n")
return `请把用户输入的多个前端题目整理成知识库 JSON。
要求:
1. 只返回严格 JSON。
2. JSON 顶层格式必须是:{ "items": [] }
3. 每个 item 必须包含:
${fieldsList}
4. tags 只能从这些枚举中选择:
${tags.join("、")}
5. category 只能从这些枚举中选择:
${categories.join("、")}
6. difficulty 只能是:
${difficulties.join("、")}
7. mastery 默认是 ${defaultMastery}
8. 如果题目适合写代码示例,请放入 code_snippet
9. content 要适合后续复习,结构清晰
10. 不要返回 Markdown,不要返回解释`
}
/** 默认批量导入提示词(使用 lib/types 中的枚举常量) */
export const BATCH_IMPORT_PROMPT = buildBatchImportPrompt()
export function buildItemContext(item: KnowledgeItem): string {
return `当前知识点:
标题:${item.title}
摘要:${item.summary ?? ""}
正文:${item.content ?? ""}
代码:${item.code_snippet ?? ""}
标签:${item.tags.join("、")}
难度:${item.difficulty}`
}
export function buildChatSystemPrompt(item: KnowledgeItem): string {
return `你正在帮助用户复习一个前端知识点。
${buildItemContext(item)}
请基于这个知识点回答用户问题。
回答要求:
- 解释清晰
- 尽量结合面试场景
- 必要时给代码示例
- 如果用户回答错误,要指出问题并给出正确理解
- 不要编造不存在的上下文`
}
export function buildOptimizePrompt(item: { title: string; summary: string; content: string }): string {
return `请优化下面这个前端知识点,使其更适合复习记忆。
只返回严格 JSON,格式为:{ "summary": "...", "content": "..." }
不要返回 Markdown,不要返回解释。
标题:${item.title}
当前摘要:${item.summary}
当前正文:${item.content}
要求:
- summary 为一句话精炼摘要
- content 结构清晰、分点、突出重点与易错点,适合反复复习`
}
export function buildCodeGenPrompt(item: { title: string; summary: string; content: string }): string {
return `请为下面这个前端知识点生成一段简洁、可运行、有代表性的示例代码。
只返回代码本身,不要返回 Markdown 代码块标记,不要返回解释。
标题:${item.title}
摘要:${item.summary}
正文:${item.content}`
}
export function buildInterviewPrompt(item: KnowledgeItem): string {
return `请把下面这个前端知识点整理成"面试回答版"。
要求:
- 模拟面试场景下口语化但专业的回答
- 先给结论,再展开原理,最后补充延伸/注意点
- 必要时给简短代码示例
- 不要返回 Markdown 代码块以外的多余解释
${buildItemContext(item)}`
}
+41
View File
@@ -0,0 +1,41 @@
import { DEFAULT_AI_SETTINGS, type AISettings } from "./types"
const STORAGE_KEY = "devvault.ai-settings"
export function loadAISettings(): AISettings {
if (typeof window === "undefined") return { ...DEFAULT_AI_SETTINGS }
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return { ...DEFAULT_AI_SETTINGS }
const parsed = JSON.parse(raw) as Partial<AISettings>
return { ...DEFAULT_AI_SETTINGS, ...parsed }
} catch {
return { ...DEFAULT_AI_SETTINGS }
}
}
export function saveAISettings(settings: AISettings): void {
if (typeof window === "undefined") return
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
}
export function clearApiKey(): AISettings {
const current = loadAISettings()
const next = { ...current, apiKey: "" }
saveAISettings(next)
return next
}
export function resetAISettings(): AISettings {
const next = { ...DEFAULT_AI_SETTINGS }
saveAISettings(next)
return next
}
/** Returns an error message if the settings are not usable for a request, otherwise null. */
export function validateForRequest(settings: AISettings): string | null {
if (!settings.baseUrl.trim()) return "请先在设置中填写 API Base URL"
if (!settings.model.trim()) return "请先在设置中填写 Model"
if (!settings.apiKey.trim()) return "请先在设置中填写 API Key"
return null
}
+79
View File
@@ -0,0 +1,79 @@
export type AISettings = {
baseUrl: string
apiKey: string
model: string
temperature: number
maxTokens: number
systemPrompt: string
stream: boolean
}
export type ChatMessage = {
role: "system" | "user" | "assistant"
content: string
}
export type RequestAIParams = {
baseUrl: string
apiKey: string
model: string
systemPrompt?: string
customPrompt?: string
messages: ChatMessage[]
temperature?: number
maxTokens?: number
stream?: boolean
signal?: AbortSignal
}
export const DEFAULT_AI_SETTINGS: AISettings = {
baseUrl: "https://api.openai.com/v1",
apiKey: "",
model: "gpt-4o-mini",
temperature: 0.3,
maxTokens: 4000,
stream: false,
systemPrompt: `你是一个前端学习助手,擅长把零散的前端题目、知识点、面试题整理成结构化知识库数据。
你必须返回严格 JSON,不要返回 Markdown,不要返回解释。`,
}
// Allowed enums for AI-generated knowledge items (per spec)
export const AI_TAG_ENUM = [
"八股",
"JavaScript",
"TypeScript",
"Vue",
"React",
"Next.js",
"CSS",
"HTML",
"浏览器",
"工程化",
"性能优化",
"算法",
"网络",
"Node.js",
"面试",
"项目经验",
"其他",
] as const
export const AI_CATEGORY_ENUM = ["基础", "框架", "工程化", "算法", "面试", "项目", "其他"] as const
export const AI_DIFFICULTY_ENUM = ["easy", "medium", "hard"] as const
export const AI_MASTERY_ENUM = ["new", "learning", "mastered"] as const
// Shape of a single item produced by the batch-import flow.
export type AIDraftItem = {
title: string
summary: string
content: string
code_snippet: string
tags: string[]
category: string
difficulty: (typeof AI_DIFFICULTY_ENUM)[number]
mastery: (typeof AI_MASTERY_ENUM)[number]
source_url: string
notes: string
}
+85
View File
@@ -0,0 +1,85 @@
import { createClient } from "@/lib/supabase/client"
import type { KnowledgeItem, KnowledgeItemInput, Mastery } from "@/lib/types"
const TABLE = "knowledge_items"
export async function fetchItems(): Promise<KnowledgeItem[]> {
const supabase = createClient()
const { data, error } = await supabase.from(TABLE).select("*").order("created_at", { ascending: false })
if (error) throw error
return (data ?? []) as KnowledgeItem[]
}
export async function createItem(input: KnowledgeItemInput): Promise<KnowledgeItem> {
const supabase = createClient()
const { data, error } = await supabase
.from(TABLE)
.insert({ ...input })
.select("*")
.single()
if (error) throw error
return data as KnowledgeItem
}
export async function createItems(inputs: KnowledgeItemInput[]): Promise<KnowledgeItem[]> {
const supabase = createClient()
const { data, error } = await supabase
.from(TABLE)
.insert(inputs.map((i) => ({ ...i })))
.select("*")
if (error) throw error
return (data ?? []) as KnowledgeItem[]
}
export async function updateItem(id: string, input: Partial<KnowledgeItemInput>): Promise<KnowledgeItem> {
const supabase = createClient()
const { data, error } = await supabase
.from(TABLE)
.update({ ...input, updated_at: new Date().toISOString() })
.eq("id", id)
.select("*")
.single()
if (error) throw error
return data as KnowledgeItem
}
export async function saveInterviewAnswer(id: string, answer: string): Promise<void> {
const supabase = createClient()
const { error } = await supabase
.from(TABLE)
.update({ interview_answer: answer, updated_at: new Date().toISOString() })
.eq("id", id)
if (error) throw error
}
export async function deleteItem(id: string): Promise<void> {
const supabase = createClient()
const { error } = await supabase.from(TABLE).delete().eq("id", id)
if (error) throw error
}
export async function toggleFavorite(id: string, value: boolean): Promise<void> {
const supabase = createClient()
const { error } = await supabase.from(TABLE).update({ is_favorite: value }).eq("id", id)
if (error) throw error
}
export async function reviewItem(id: string, currentCount: number, mastery: Mastery): Promise<KnowledgeItem> {
const supabase = createClient()
const now = new Date()
// simple spaced-repetition: schedule next review based on mastery
const daysAhead = mastery === "mastered" ? 14 : mastery === "learning" ? 4 : 1
const next = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000)
const { data, error } = await supabase
.from(TABLE)
.update({
review_count: currentCount + 1,
last_reviewed_at: now.toISOString(),
next_review_at: next.toISOString(),
})
.eq("id", id)
.select("*")
.single()
if (error) throw error
return data as KnowledgeItem
}
+8
View File
@@ -0,0 +1,8 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
)
}
+34
View File
@@ -0,0 +1,34 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
/**
* Especially important if using Fluid compute: Don't put this client in a
* global variable. Always create a new client within each function when using
* it.
*/
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
)
} catch {
// The "setAll" method was called from a Server Component.
// This can be ignored if you have proxy refreshing
// user sessions.
}
},
},
},
)
}
+129
View File
@@ -0,0 +1,129 @@
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
export type Difficulty = (typeof DIFFICULTIES)[number];
export const MASTERY_LEVELS = ["new", "learning", "mastered"] as const;
export type Mastery = (typeof MASTERY_LEVELS)[number];
export interface KnowledgeItem {
id: string;
title: string;
summary: string | null;
content: string | null;
code_snippet: string | null;
tags: string[];
category: string;
difficulty: Difficulty;
mastery: Mastery;
is_favorite: boolean;
source_url: string | null;
notes: string | null;
interview_answer: string | null;
review_count: number;
last_reviewed_at: string | null;
next_review_at: string | null;
created_at: string;
updated_at: string;
}
export type KnowledgeItemInput = {
title: string;
summary: string;
content: string;
code_snippet: string;
tags: string[];
category: string;
difficulty: Difficulty;
mastery: Mastery;
is_favorite: boolean;
source_url: string;
notes: string;
};
export const CATEGORIES = [
"JavaScript",
"TypeScript",
"CSS",
"HTML",
"React",
"Vue",
"性能优化",
"网络",
"工程化",
"算法",
"其他"
] as const;
export const TAGS = [
"八股",
"JavaScript",
"TypeScript",
"Vue",
"React",
"Next.js",
"CSS",
"HTML",
"浏览器",
"工程化",
"性能优化",
"算法",
"网络",
"Node.js",
"面试",
"项目经验",
"其他"
] as const;
export const DIFFICULTY_META: Record<
Difficulty,
{ label: string; color: string }
> = {
easy: {
label: "简单",
color: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10"
},
medium: {
label: "中等",
color: "text-amber-400 border-amber-500/30 bg-amber-500/10"
},
hard: {
label: "困难",
color: "text-rose-400 border-rose-500/30 bg-rose-500/10"
}
};
export const MASTERY_META: Record<
Mastery,
{ label: string; color: string; dot: string }
> = {
new: {
label: "待学习",
color: "text-muted-foreground border-border bg-muted/40",
dot: "bg-muted-foreground"
},
learning: {
label: "学习中",
color: "text-sky-400 border-sky-500/30 bg-sky-500/10",
dot: "bg-sky-400"
},
mastered: {
label: "已掌握",
color: "text-primary border-primary/40 bg-primary/10",
dot: "bg-primary"
}
};
export function emptyItemInput(): KnowledgeItemInput {
return {
title: "",
summary: "",
content: "",
code_snippet: "",
tags: [],
category: "JavaScript",
difficulty: "easy",
mastery: "new",
is_favorite: false,
source_url: "",
notes: ""
};
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+11
View File
@@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
# 密码保护
auth_basic "请输入访问密码";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "my-project",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint ."
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.2",
"@vercel/analytics": "1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"next-themes": "^0.4.6",
"react": "^19",
"react-dom": "^19",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8.5",
"tailwindcss": "^4.2.0",
"typescript": "5.7.3"
},
"pnpm": {
"overrides": {
"hono": "4.12.25"
}
}
}
+4869
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

+26
View File
@@ -0,0 +1,26 @@
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
@media (prefers-color-scheme: light) {
.background { fill: black; }
.foreground { fill: white; }
}
@media (prefers-color-scheme: dark) {
.background { fill: white; }
.foreground { fill: black; }
}
</style>
<g clip-path="url(#clip0_7960_43945)">
<rect class="background" width="180" height="180" rx="37" />
<g style="transform: scale(95%); transform-origin: center">
<path class="foreground"
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
<path class="foreground"
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
</g>
</g>
<defs>
<clipPath id="clip0_7960_43945">
<rect width="180" height="180" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}