first commit
This commit is contained in:
36
src/components/chat/chat-composer.tsx
Normal file
36
src/components/chat/chat-composer.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { PlusIcon, SendIcon } from "@/components/icons";
|
||||
|
||||
export function ChatComposer() {
|
||||
return (
|
||||
<div className="theme-composer rounded-[24px] border bg-white p-3">
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="给 AI 发送消息"
|
||||
className="min-h-[88px] w-full resize-none border-0 bg-transparent px-2 py-2 text-sm leading-6 text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-control flex h-9 w-9 items-center justify-center rounded-full border text-slate-500 transition duration-300 ease-out hover:-translate-y-[1px] hover:text-slate-800"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="theme-control rounded-full border px-3 py-2 text-xs font-medium text-slate-600 transition duration-300 ease-out hover:-translate-y-[1px]"
|
||||
>
|
||||
思考
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="theme-send flex h-10 w-10 items-center justify-center rounded-full transition duration-300 ease-out hover:-translate-y-[1px]"
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/chat/chat-page.tsx
Normal file
48
src/components/chat/chat-page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useOutletContext } from "react-router-dom";
|
||||
import { ChatComposer } from "@/components/chat/chat-composer";
|
||||
import { messages } from "@/components/chat/mock-data";
|
||||
import { MessageBubble } from "@/components/chat/message-bubble";
|
||||
import { ThemeSwitcher } from "@/components/layout/theme-switcher";
|
||||
import type { ThemeOutletContext } from "@/components/layout/theme";
|
||||
|
||||
export function ChatPage() {
|
||||
const { theme, setTheme } = useOutletContext<ThemeOutletContext>();
|
||||
|
||||
return (
|
||||
<div className="theme-page flex h-full min-h-0 flex-col">
|
||||
<header className="theme-header flex h-14 items-center justify-between border-b px-5 lg:px-6">
|
||||
<div className="text-sm font-semibold text-slate-900">聊天</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeSwitcher theme={theme} onChange={setTheme} />
|
||||
<div className="text-xs text-slate-400">高数错题复盘</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="theme-page relative flex min-h-0 flex-1 flex-col">
|
||||
<div className="theme-page min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col px-4 pb-44 pt-8 lg:px-6 lg:pt-10">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center text-center">
|
||||
<div>
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-slate-900">今天想聊什么?</h1>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{messages.map((message) => (
|
||||
<MessageBubble key={message.id} message={message} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="theme-page absolute inset-x-0 bottom-0 px-4 pb-3 pt-8 lg:px-6 lg:pb-5">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<ChatComposer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/components/chat/conversation-list.tsx
Normal file
87
src/components/chat/conversation-list.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ClockIcon, SearchIcon, StarIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Conversation } from "@/components/chat/types";
|
||||
|
||||
interface ConversationListProps {
|
||||
conversations: Conversation[];
|
||||
}
|
||||
|
||||
export function ConversationList({ conversations }: ConversationListProps) {
|
||||
return (
|
||||
<Card className="h-full bg-white/78">
|
||||
<CardHeader className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-slate-700">对话库</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">保留最近会话与学习主题</p>
|
||||
</div>
|
||||
<Badge variant="outline">12 条</Badge>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<SearchIcon className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input className="pl-11" placeholder="搜索对话、课程、作业题..." />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-[22px] border border-white/90 bg-gradient-to-r from-pastel-cream to-white p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-pastel-lavender/80 text-violet-700">
|
||||
<StarIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-slate-700">置顶学习流</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
高数错题、英语润色、课堂总结
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{conversations.map((conversation, index) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"hover-lift flex w-full items-start gap-3 rounded-[22px] border px-3 py-3 text-left",
|
||||
conversation.active
|
||||
? "border-white bg-white shadow-sm"
|
||||
: "border-transparent bg-white/55 hover:border-white/80",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
label={String(index + 1).padStart(2, "0")}
|
||||
tone={conversation.active ? "pink" : index % 2 === 0 ? "lavender" : "mint"}
|
||||
className="h-11 w-11 text-xs"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate font-semibold text-slate-700">{conversation.title}</p>
|
||||
<span className="text-[11px] text-muted-foreground">{conversation.time}</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{conversation.preview}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Badge variant={conversation.active ? "secondary" : "outline"}>
|
||||
{conversation.label}
|
||||
</Badge>
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<ClockIcon className="h-3 w-3" />
|
||||
继续编辑
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
26
src/components/chat/message-bubble.tsx
Normal file
26
src/components/chat/message-bubble.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Message } from "@/components/chat/types";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isAssistant = message.role === "assistant";
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3", !isAssistant && "justify-end")}>
|
||||
{isAssistant ? <Avatar label="AI" tone="mint" className="h-9 w-9 shrink-0" /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-[26px] border px-4 py-3 text-sm leading-7 shadow-[0_10px_28px_rgba(245,190,207,0.08)]",
|
||||
isAssistant ? "theme-assistant-bubble text-slate-800" : "theme-user-bubble text-slate-800",
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-line">{message.content}</p>
|
||||
</div>
|
||||
{!isAssistant ? <Avatar label="我" tone="lavender" className="h-9 w-9 shrink-0" /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/chat/mock-data.ts
Normal file
56
src/components/chat/mock-data.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Conversation, Message } from "@/components/chat/types";
|
||||
|
||||
export const conversations: Conversation[] = [
|
||||
{
|
||||
id: "1",
|
||||
title: "高数错题复盘",
|
||||
preview: "已整理极限、导数和积分的 6 道错误题。",
|
||||
label: "数学",
|
||||
time: "18:20",
|
||||
active: true
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "英语作文润色",
|
||||
preview: "科技与教育主题作文润色。",
|
||||
label: "英语",
|
||||
time: "昨天"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "课堂重点提炼",
|
||||
preview: "讲义重点整理成速记卡片。",
|
||||
label: "课堂",
|
||||
time: "周二"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "竞赛 Demo 头脑风暴",
|
||||
preview: "讨论前端作为主入口的交互方案。",
|
||||
label: "项目",
|
||||
time: "周一"
|
||||
}
|
||||
];
|
||||
|
||||
export const messages: Message[] = [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
author: "AI 助手",
|
||||
content:
|
||||
"我先帮你把今天的高数复习拆成 3 个小块:\n1. 先抓极限题的共性陷阱。\n2. 再把导数题按公式错误和计算跳步分类。\n3. 最后整理一版 15 分钟冲刺卡片。"
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
role: "user",
|
||||
author: "你",
|
||||
content: "可以,先帮我把这 6 道题里最容易重复犯错的点总结一下,语言别太学术。"
|
||||
},
|
||||
{
|
||||
id: "m3",
|
||||
role: "assistant",
|
||||
author: "AI 助手",
|
||||
content:
|
||||
"最容易重复出现的不是不会做,而是思路知道但写法不稳定:\n\n- 极限题容易在变形时急着约分。\n- 导数题常见问题是链式法则漏掉内部函数求导。\n- 积分题会把换元和凑微分混在一起。\n\n如果你愿意,下一步我可以把这 6 道题整理成错因标签卡片。"
|
||||
}
|
||||
];
|
||||
16
src/components/chat/types.ts
Normal file
16
src/components/chat/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
label: string;
|
||||
time: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
author: string;
|
||||
content: string;
|
||||
hint?: string;
|
||||
}
|
||||
91
src/components/icons.tsx
Normal file
91
src/components/icons.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode, SVGProps } from "react";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>;
|
||||
|
||||
function createIcon(path: ReactNode) {
|
||||
return function Icon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
{path}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const ChatBubbleIcon = createIcon(
|
||||
<>
|
||||
<path d="M7 10h10" />
|
||||
<path d="M7 14h6" />
|
||||
<path d="M5 19l-2 2v-4a8 8 0 118 8H5Z" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const BookIcon = createIcon(
|
||||
<>
|
||||
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||
<path d="M6.5 17A2.5 2.5 0 014 14.5v-9A2.5 2.5 0 016.5 3H20v14" />
|
||||
<path d="M8 7h8" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const SparklesIcon = createIcon(
|
||||
<>
|
||||
<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z" />
|
||||
<path d="M19 3v4" />
|
||||
<path d="M21 5h-4" />
|
||||
<path d="M5 17v4" />
|
||||
<path d="M7 19H3" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const SearchIcon = createIcon(
|
||||
<>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const SendIcon = createIcon(
|
||||
<>
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const PlusIcon = createIcon(
|
||||
<>
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const ClockIcon = createIcon(
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const PanelLeftIcon = createIcon(
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||
<path d="M9 4v16" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const StarIcon = createIcon(
|
||||
<>
|
||||
<path d="m12 3 2.6 5.3 5.9.9-4.2 4.1 1 5.8L12 16.5 6.7 19l1-5.8-4.2-4.1 5.9-.9L12 3Z" />
|
||||
</>,
|
||||
);
|
||||
|
||||
export const ChevronRightIcon = createIcon(<path d="m9 18 6-6-6-6" />);
|
||||
39
src/components/layout/app-shell.tsx
Normal file
39
src/components/layout/app-shell.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import type { ThemeMode } from "@/components/layout/theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STORAGE_KEY = "ai-chat-theme";
|
||||
|
||||
export function AppShell() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return "soft";
|
||||
}
|
||||
|
||||
const savedTheme = window.localStorage.getItem(STORAGE_KEY);
|
||||
return savedTheme === "classic" ? "classic" : "soft";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEY, theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<div data-theme={theme} className="h-screen bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full w-full transition-all duration-200",
|
||||
collapsed ? "lg:grid-cols-[64px,minmax(0,1fr)]" : "lg:grid-cols-[260px,minmax(0,1fr)]",
|
||||
)}
|
||||
>
|
||||
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((value) => !value)} />
|
||||
<main className="theme-page min-h-0 min-w-0 overflow-hidden">
|
||||
<Outlet context={{ theme, setTheme }} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/components/layout/sidebar.tsx
Normal file
120
src/components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { conversations } from "@/components/chat/mock-data";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { BookIcon, ChatBubbleIcon, ClockIcon, PanelLeftIcon, PlusIcon } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "聊天", icon: ChatBubbleIcon },
|
||||
{ to: "/review", label: "复习", icon: BookIcon }
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
return (
|
||||
<aside className="theme-sidebar hidden h-full min-h-0 overflow-hidden border-r lg:flex lg:flex-col">
|
||||
<div className={cn("flex items-center px-3 pt-3", collapsed ? "justify-center" : "justify-between")}>
|
||||
{!collapsed ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="theme-sidebar-card flex h-8 w-8 items-center justify-center rounded-xl text-slate-700">
|
||||
<ChatBubbleIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-slate-800">AI Chat</span>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="theme-sidebar-hover flex h-8 w-8 items-center justify-center rounded-lg text-slate-500 transition duration-300 ease-out hover:-translate-y-[1px] hover:text-slate-800"
|
||||
>
|
||||
<PanelLeftIcon className={cn("h-4 w-4 transition-transform", collapsed && "rotate-180")} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 pt-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"theme-sidebar-card h-10 w-full justify-start rounded-2xl border-0 text-slate-800",
|
||||
collapsed && "justify-center px-0",
|
||||
)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{!collapsed ? "新建对话" : null}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="space-y-1 px-2 pt-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex h-10 items-center gap-3 rounded-2xl px-3 text-sm text-slate-600 transition duration-300 ease-out",
|
||||
collapsed && "justify-center px-0",
|
||||
isActive ? "theme-sidebar-card text-slate-900" : "theme-sidebar-hover hover:-translate-y-[1px] hover:text-slate-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed ? <span>{item.label}</span> : null}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{!collapsed ? (
|
||||
<div className="min-h-0 flex-1 px-2 pb-2 pt-5">
|
||||
<div className="mb-2 flex items-center justify-between px-2">
|
||||
<span className="text-xs font-medium text-slate-400">最近</span>
|
||||
<Badge variant="outline" className="theme-sidebar-card border-0 text-[11px] text-slate-400">
|
||||
{conversations.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="h-full overflow-y-auto px-1 pb-2">
|
||||
<div className="space-y-1">
|
||||
{conversations.map((conversation) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full flex-col rounded-2xl px-3 py-2 text-left transition duration-300 ease-out",
|
||||
conversation.active
|
||||
? "theme-sidebar-card text-slate-900"
|
||||
: "theme-sidebar-hover text-slate-600 hover:-translate-y-[1px]",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm font-medium">{conversation.title}</span>
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<ClockIcon className="h-3 w-3" />
|
||||
{conversation.time}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 items-end justify-center pb-3">
|
||||
<button
|
||||
type="button"
|
||||
className="theme-sidebar-hover flex h-10 w-10 items-center justify-center rounded-2xl text-slate-500 transition duration-300 ease-out hover:-translate-y-[1px]"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
31
src/components/layout/theme-switcher.tsx
Normal file
31
src/components/layout/theme-switcher.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ThemeMode } from "@/components/layout/theme";
|
||||
|
||||
interface ThemeSwitcherProps {
|
||||
theme: ThemeMode;
|
||||
onChange: (theme: ThemeMode) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const options: Array<{ value: ThemeMode; label: string }> = [
|
||||
{ value: "soft", label: "柔和" },
|
||||
{ value: "classic", label: "简洁" }
|
||||
];
|
||||
|
||||
export function ThemeSwitcher({ theme, onChange, className }: ThemeSwitcherProps) {
|
||||
return (
|
||||
<div className={cn("theme-switch inline-flex items-center rounded-full border p-1", className)}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
data-active={theme === option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className="theme-switch-button rounded-full px-3 py-1.5 text-xs font-medium text-slate-500 transition duration-300 ease-out"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/components/layout/theme.ts
Normal file
6
src/components/layout/theme.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type ThemeMode = "soft" | "classic";
|
||||
|
||||
export interface ThemeOutletContext {
|
||||
theme: ThemeMode;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
}
|
||||
29
src/components/ui/avatar.tsx
Normal file
29
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AvatarProps {
|
||||
label: string;
|
||||
tone?: "pink" | "mint" | "lavender" | "cream";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const toneMap = {
|
||||
pink: "avatar-pink",
|
||||
mint: "avatar-mint",
|
||||
lavender: "avatar-lavender",
|
||||
cream: "avatar-cream"
|
||||
};
|
||||
|
||||
export function Avatar({ label, tone = "pink", className }: AvatarProps) {
|
||||
return (
|
||||
<div
|
||||
data-tone={tone}
|
||||
className={cn(
|
||||
"theme-avatar inline-flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-bold",
|
||||
toneMap[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/badge.tsx
Normal file
29
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-[#f5e4ff] text-violet-700",
|
||||
secondary: "bg-[#dbf4e7] text-emerald-700",
|
||||
outline: "border border-white/90 bg-white/88 text-foreground/75"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default"
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
46
src/components/ui/button.tsx
Normal file
46
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-semibold transition-all duration-300 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98] hover:-translate-y-0.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-gradient-to-r from-[#ffb8d2] via-[#ffd4e4] to-[#ffe7b8] text-slate-800 shadow-[0_10px_24px_rgba(244,175,200,0.28)] hover:brightness-[1.01]",
|
||||
secondary: "border border-white/80 bg-white/92 text-foreground shadow-sm hover:bg-white",
|
||||
ghost: "text-foreground hover:bg-white/70",
|
||||
outline: "border border-border bg-background/80 hover:bg-white/90"
|
||||
},
|
||||
size: {
|
||||
default: "h-11 px-5",
|
||||
sm: "h-9 px-4 text-xs",
|
||||
lg: "h-12 px-6",
|
||||
icon: "h-11 w-11"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default"
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
43
src/components/ui/card.tsx
Normal file
43
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("sticker-card", className)} {...props} />
|
||||
),
|
||||
);
|
||||
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
||||
),
|
||||
);
|
||||
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3 ref={ref} className={cn("text-lg font-semibold tracking-tight", className)} {...props} />
|
||||
),
|
||||
);
|
||||
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
||||
));
|
||||
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
|
||||
);
|
||||
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-full border border-input bg-white/85 px-4 py-2 text-sm text-foreground shadow-sm outline-none transition duration-200 placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
Reference in New Issue
Block a user