diff --git a/backend/__init__.py b/backend/__init__.py
index 92fb946..64fe3ab 100644
--- a/backend/__init__.py
+++ b/backend/__init__.py
@@ -8,8 +8,6 @@ from .app.models import (
ChatMessage, Upload
)
-# 为了向后兼容,保留 get_session 别名
-get_session = get_db
__all__ = [
"engine",
diff --git a/backend/app/api/api_v1/endpoints/auth.py b/backend/app/api/api_v1/endpoints/auth.py
new file mode 100644
index 0000000..2c28776
--- /dev/null
+++ b/backend/app/api/api_v1/endpoints/auth.py
@@ -0,0 +1,77 @@
+"""
+认证相关 API 端点
+"""
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
+from sqlmodel import Session, select
+from datetime import timedelta
+
+from app.core.config import settings
+from app.core.security import verify_password, create_access_token, get_password_hash
+from app.db.session import get_db
+from app.models.user import User
+from app.schemas.user import Token, User as UserSchema, UserCreate
+
+router = APIRouter()
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
+
+
+@router.post("/register", response_model=UserSchema, status_code=status.HTTP_201_CREATED)
+def register(user_in: UserCreate, db: Session = Depends(get_db)):
+ """用户注册"""
+ # 检查用户名是否已存在
+ statement = select(User).where(User.username == user_in.username)
+ existing_user = db.exec(statement).first()
+ if existing_user:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="用户名已存在"
+ )
+
+ # 检查邮箱是否已存在
+ if user_in.email:
+ statement = select(User).where(User.email == user_in.email)
+ existing_email = db.exec(statement).first()
+ if existing_email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="邮箱已被注册"
+ )
+
+ # 创建新用户
+ hashed_password = get_password_hash(user_in.password)
+ db_user = User(
+ username=user_in.username,
+ email=user_in.email,
+ hashed_password=hashed_password
+ )
+ db.add(db_user)
+ db.commit()
+ db.refresh(db_user)
+
+ return db_user
+
+
+@router.post("/login", response_model=Token)
+def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
+ """用户登录"""
+ # 查找用户
+ statement = select(User).where(User.username == form_data.username)
+ user = db.exec(statement).first()
+
+ if not user or not verify_password(form_data.password, user.hashed_password):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="用户名或密码错误",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
+
+ # 创建访问令牌
+ access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+ access_token = create_access_token(
+ data={"sub": user.username}, expires_delta=access_token_expires
+ )
+
+ return {"access_token": access_token, "token_type": "bearer"}
+
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
new file mode 100644
index 0000000..655d1f1
--- /dev/null
+++ b/backend/app/core/config.py
@@ -0,0 +1,44 @@
+"""
+应用配置
+"""
+from pydantic_settings import BaseSettings
+from typing import Optional
+
+
+class Settings(BaseSettings):
+ """应用配置类"""
+
+ # 项目信息
+ PROJECT_NAME: str = "个人博客网站"
+ VERSION: str = "0.1.0"
+ API_V1_STR: str = "/api/v1"
+
+ # 数据库配置
+ DATABASE_URL: str = "sqlite:///./blogweb.db"
+
+ # 安全配置
+ SECRET_KEY: str = "your-secret-key-change-in-production" # 生产环境请修改
+ ALGORITHM: str = "HS256"
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7 # 7天
+
+ # CORS 配置
+ BACKEND_CORS_ORIGINS: list = [
+ "http://localhost:3000",
+ "http://localhost:5173",
+ "http://localhost:8080",
+ ]
+
+ # 文件上传配置
+ UPLOAD_DIR: str = "./uploads"
+ MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB
+
+ # 第三方 API 配置
+ OPENWEATHER_API_KEY: Optional[str] = None # OpenWeatherMap API Key
+
+ class Config:
+ env_file = ".env"
+ case_sensitive = True
+
+
+settings = Settings()
+
diff --git a/backend/app/db/base.py b/backend/app/db/base.py
new file mode 100644
index 0000000..d3cca23
--- /dev/null
+++ b/backend/app/db/base.py
@@ -0,0 +1,14 @@
+"""
+数据库基础配置
+"""
+from sqlmodel import SQLModel
+
+# 导入所有模型以确保表被注册到 metadata
+from app.models import (
+ User, Todo, Post, Transaction, Media, Tag, MediaTag,
+ ChatMessage, Upload
+)
+
+# SQLModel 的 Base 类
+Base = SQLModel
+
diff --git a/backend/app/models/chat.py b/backend/app/models/chat.py
new file mode 100644
index 0000000..c08d94e
--- /dev/null
+++ b/backend/app/models/chat.py
@@ -0,0 +1,22 @@
+"""
+聊天消息模型
+"""
+from datetime import datetime
+from typing import Optional
+from sqlmodel import SQLModel, Field, Relationship
+from .user import User
+
+
+class ChatMessage(SQLModel, table=True):
+ """聊天消息表"""
+ __tablename__ = "chat_messages"
+
+ id: Optional[int] = Field(default=None, primary_key=True)
+ content: str
+ sent_at: datetime = Field(default_factory=datetime.now)
+ user_id: int = Field(foreign_key="users.id", index=True)
+ room: str = Field(max_length=50, default="main", index=True)
+
+ # 关系
+ user: Optional[User] = Relationship()
+
diff --git a/backend/models.py b/backend/models.py
deleted file mode 100644
index af4dfbc..0000000
--- a/backend/models.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""
-数据库模型定义
-使用 SQLModel 定义所有表结构
-"""
-from datetime import datetime, date
-from typing import Optional
-from sqlmodel import SQLModel, Field, Relationship, Column
-from sqlalchemy import Numeric
-from decimal import Decimal
-
-
-# ==================== 用户表 ====================
-class User(SQLModel, table=True):
- """用户表"""
- __tablename__ = "users"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- username: str = Field(max_length=50, unique=True, index=True)
- email: Optional[str] = Field(default=None, max_length=100, unique=True, index=True)
- hashed_password: str
- created_at: datetime = Field(default_factory=datetime.now)
-
-
-# ==================== 待办事项表 ====================
-class Todo(SQLModel, table=True):
- """待办事项表"""
- __tablename__ = "todos"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- title: str = Field(max_length=200)
- done: bool = Field(default=False)
- created_at: datetime = Field(default_factory=datetime.now)
- user_id: int = Field(foreign_key="users.id", index=True)
-
- # 关系(可选,用于ORM查询)
- user: Optional[User] = Relationship()
-
-
-# ==================== 博客文章表 ====================
-class Post(SQLModel, table=True):
- """博客文章表"""
- __tablename__ = "posts"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- title: str = Field(max_length=200)
- slug: str = Field(max_length=200, index=True)
- content: str # 支持 Markdown
- created_at: datetime = Field(default_factory=datetime.now)
- updated_at: Optional[datetime] = Field(default=None)
- user_id: int = Field(foreign_key="users.id", index=True)
-
- # 关系
- user: Optional[User] = Relationship()
-
-
-# ==================== 记账记录表 ====================
-class Transaction(SQLModel, table=True):
- """记账记录表"""
- __tablename__ = "transactions"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- amount: Decimal = Field(sa_column=Column(Numeric(10, 2))) # 正数为收入,负数为支出
- category: str = Field(max_length=50)
- description: Optional[str] = Field(default=None, max_length=200)
- date: date
- user_id: int = Field(foreign_key="users.id", index=True)
-
- # 关系
- user: Optional[User] = Relationship()
-
-
-# ==================== 媒体-标签关联表(多对多)====================
-class MediaTag(SQLModel, table=True):
- """媒体-标签关联表"""
- __tablename__ = "media_tags"
-
- media_id: int = Field(foreign_key="media.id", primary_key=True)
- tag_id: int = Field(foreign_key="tags.id", primary_key=True)
-
-
-# ==================== 书影音收藏表 ====================
-class Media(SQLModel, table=True):
- """书影音收藏表"""
- __tablename__ = "media"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- title: str = Field(max_length=200)
- media_type: str = Field(max_length=20) # book / movie / music
- rating: Optional[float] = Field(default=None, ge=0.0, le=5.0)
- comment: Optional[str] = Field(default=None)
- external_id: Optional[str] = Field(default=None, max_length=100) # ISBN、IMDb ID等
- cover_url: Optional[str] = Field(default=None, max_length=300)
- created_at: datetime = Field(default_factory=datetime.now)
- user_id: int = Field(foreign_key="users.id", index=True)
-
- # 关系
- user: Optional[User] = Relationship()
- tags: list["Tag"] = Relationship(back_populates="media", link_model=MediaTag)
-
-
-# ==================== 媒体标签表 ====================
-class Tag(SQLModel, table=True):
- """媒体标签表"""
- __tablename__ = "tags"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- name: str = Field(max_length=50, unique=True, index=True)
-
- # 关系
- media: list[Media] = Relationship(back_populates="tags", link_model=MediaTag)
-
-
-# ==================== 聊天消息表 ====================
-class ChatMessage(SQLModel, table=True):
- """聊天消息表"""
- __tablename__ = "chat_messages"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- content: str
- sent_at: datetime = Field(default_factory=datetime.now)
- user_id: int = Field(foreign_key="users.id", index=True)
- room: str = Field(max_length=50, default="main", index=True)
-
- # 关系
- user: Optional[User] = Relationship()
-
-
-# ==================== 文件上传记录表 ====================
-class Upload(SQLModel, table=True):
- """文件上传记录表"""
- __tablename__ = "uploads"
-
- id: Optional[int] = Field(default=None, primary_key=True)
- filename: str = Field(max_length=200)
- stored_path: str = Field(max_length=300)
- file_size: int # 字节数
- mime_type: str = Field(max_length=100)
- uploaded_at: datetime = Field(default_factory=datetime.now)
- expires_at: Optional[datetime] = Field(default=None)
- user_id: int = Field(foreign_key="users.id", index=True)
-
- # 关系
- user: Optional[User] = Relationship()
-
diff --git a/frontend/ATTRIBUTIONS.md b/frontend/ATTRIBUTIONS.md
new file mode 100644
index 0000000..9b7cd4e
--- /dev/null
+++ b/frontend/ATTRIBUTIONS.md
@@ -0,0 +1,3 @@
+This Figma Make file includes components from [shadcn/ui](https://ui.shadcn.com/) used under [MIT license](https://github.com/shadcn-ui/ui/blob/main/LICENSE.md).
+
+This Figma Make file includes photos from [Unsplash](https://unsplash.com) used under [license](https://unsplash.com/license).
\ No newline at end of file
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..7cce22b
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,11 @@
+
+ # 个人博客网站
+
+ This is a code bundle for 个人博客网站. The original project is available at https://www.figma.com/design/BZRfx0JUvEQKYcNoSwKxtL/%E4%B8%AA%E4%BA%BA%E5%8D%9A%E5%AE%A2%E7%BD%91%E7%AB%99.
+
+ ## Running the code
+
+ Run `npm i` to install the dependencies.
+
+ Run `npm run dev` to start the development server.
+
\ No newline at end of file
diff --git a/frontend/guidelines/Guidelines.md b/frontend/guidelines/Guidelines.md
new file mode 100644
index 0000000..110f117
--- /dev/null
+++ b/frontend/guidelines/Guidelines.md
@@ -0,0 +1,61 @@
+**Add your own guidelines here**
+
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..bd29cba
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ 个人博客网站
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..1a47a88
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,89 @@
+{
+ "name": "@figma/my-make-file",
+ "private": true,
+ "version": "0.0.1",
+ "type": "module",
+ "scripts": {
+ "build": "vite build",
+ "dev": "vite"
+ },
+ "dependencies": {
+ "@emotion/react": "11.14.0",
+ "@emotion/styled": "11.14.1",
+ "@mui/icons-material": "7.3.5",
+ "@mui/material": "7.3.5",
+ "@popperjs/core": "2.11.8",
+ "@radix-ui/react-accordion": "1.2.3",
+ "@radix-ui/react-alert-dialog": "1.1.6",
+ "@radix-ui/react-aspect-ratio": "1.1.2",
+ "@radix-ui/react-avatar": "1.1.3",
+ "@radix-ui/react-checkbox": "1.1.4",
+ "@radix-ui/react-collapsible": "1.1.3",
+ "@radix-ui/react-context-menu": "2.2.6",
+ "@radix-ui/react-dialog": "1.1.6",
+ "@radix-ui/react-dropdown-menu": "2.1.6",
+ "@radix-ui/react-hover-card": "1.1.6",
+ "@radix-ui/react-label": "2.1.2",
+ "@radix-ui/react-menubar": "1.1.6",
+ "@radix-ui/react-navigation-menu": "1.2.5",
+ "@radix-ui/react-popover": "1.1.6",
+ "@radix-ui/react-progress": "1.1.2",
+ "@radix-ui/react-radio-group": "1.2.3",
+ "@radix-ui/react-scroll-area": "1.2.3",
+ "@radix-ui/react-select": "2.1.6",
+ "@radix-ui/react-separator": "1.1.2",
+ "@radix-ui/react-slider": "1.2.3",
+ "@radix-ui/react-slot": "1.1.2",
+ "@radix-ui/react-switch": "1.1.3",
+ "@radix-ui/react-tabs": "1.1.3",
+ "@radix-ui/react-toggle": "1.1.2",
+ "@radix-ui/react-toggle-group": "1.1.2",
+ "@radix-ui/react-tooltip": "1.1.8",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "cmdk": "1.1.1",
+ "date-fns": "3.6.0",
+ "embla-carousel-react": "8.6.0",
+ "input-otp": "1.4.2",
+ "lucide-react": "0.487.0",
+ "motion": "12.23.24",
+ "next-themes": "0.4.6",
+ "react-day-picker": "8.10.1",
+ "react-dnd": "16.0.1",
+ "react-dnd-html5-backend": "16.0.1",
+ "react-hook-form": "7.55.0",
+ "react-popper": "2.3.0",
+ "react-resizable-panels": "2.1.7",
+ "react-responsive-masonry": "2.7.1",
+ "react-router-dom": "^7.11.0",
+ "react-slick": "0.31.0",
+ "recharts": "2.15.2",
+ "sonner": "2.0.3",
+ "tailwind-merge": "3.2.0",
+ "tw-animate-css": "1.3.8",
+ "vaul": "1.1.2"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "4.1.12",
+ "@vitejs/plugin-react": "4.7.0",
+ "tailwindcss": "4.1.12",
+ "vite": "6.3.5"
+ },
+ "peerDependencies": {
+ "react": "18.3.1",
+ "react-dom": "18.3.1"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ },
+ "pnpm": {
+ "overrides": {
+ "vite": "6.3.5"
+ }
+ }
+}
\ No newline at end of file
diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs
new file mode 100644
index 0000000..531dbec
--- /dev/null
+++ b/frontend/postcss.config.mjs
@@ -0,0 +1,15 @@
+/**
+ * PostCSS Configuration
+ *
+ * Tailwind CSS v4 (via @tailwindcss/vite) automatically sets up all required
+ * PostCSS plugins — you do NOT need to include `tailwindcss` or `autoprefixer` here.
+ *
+ * This file only exists for adding additional PostCSS plugins, if needed.
+ * For example:
+ *
+ * import postcssNested from 'postcss-nested'
+ * export default { plugins: [postcssNested()] }
+ *
+ * Otherwise, you can leave this file empty.
+ */
+export default {}
diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx
new file mode 100644
index 0000000..c01c1b1
--- /dev/null
+++ b/frontend/src/app/App.tsx
@@ -0,0 +1,24 @@
+import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import HomePage from './components/HomePage';
+import TodoPage from './components/TodoPage';
+import WeatherPage from './components/WeatherPage';
+import MediaPage from './components/MediaPage';
+import AccountingPage from './components/AccountingPage';
+import ChatPage from './components/ChatPage';
+import FileSharingPage from './components/FileSharingPage';
+
+export default function App() {
+ return (
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
diff --git a/frontend/src/app/components/AccountingPage.tsx b/frontend/src/app/components/AccountingPage.tsx
new file mode 100644
index 0000000..279fbed
--- /dev/null
+++ b/frontend/src/app/components/AccountingPage.tsx
@@ -0,0 +1,204 @@
+import { useState } from 'react';
+import Navigation from './Navigation';
+import { Plus, TrendingUp, TrendingDown } from 'lucide-react';
+
+interface Transaction {
+ id: number;
+ type: '收入' | '支出';
+ amount: number;
+ category: string;
+ note: string;
+ date: string;
+}
+
+export default function AccountingPage() {
+ const [transactions, setTransactions] = useState([]);
+ const [type, setType] = useState<'收入' | '支出'>('支出');
+ const [amount, setAmount] = useState('');
+ const [category, setCategory] = useState('');
+ const [note, setNote] = useState('');
+
+ const addTransaction = () => {
+ if (amount && category) {
+ setTransactions([
+ {
+ id: Date.now(),
+ type,
+ amount: parseFloat(amount),
+ category,
+ note,
+ date: new Date().toLocaleDateString('zh-CN'),
+ },
+ ...transactions,
+ ]);
+ setAmount('');
+ setCategory('');
+ setNote('');
+ }
+ };
+
+ const totalIncome = transactions
+ .filter(t => t.type === '收入')
+ .reduce((sum, t) => sum + t.amount, 0);
+
+ const totalExpense = transactions
+ .filter(t => t.type === '支出')
+ .reduce((sum, t) => sum + t.amount, 0);
+
+ const balance = totalIncome - totalExpense;
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+ {/* Summary Cards */}
+
+
+
+ 总收入
+
+
+
¥{totalIncome.toFixed(2)}
+
+
+
+ 总支出
+
+
+
¥{totalExpense.toFixed(2)}
+
+
+
+
¥{balance.toFixed(2)}
+
+
+
+ {/* Add Transaction Card */}
+
+
+
+
+
+
+
setAmount(e.target.value)}
+ placeholder="金额"
+ className="px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+
+ setCategory(e.target.value)}
+ placeholder="类别 (如:餐饮、交通、工资等)"
+ className="px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+ setNote(e.target.value)}
+ placeholder="备注 (可选)"
+ className="px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+
+
+
+ {/* Transactions List */}
+
+
交易记录
+
+ {transactions.length === 0 ? (
+
+ 暂无交易记录
+
+ ) : (
+ transactions.map((transaction) => (
+
+
+
+ {transaction.type === '收入' ? (
+
+ ) : (
+
+ )}
+
+
+
{transaction.category}
+ {transaction.note && (
+
{transaction.note}
+ )}
+
{transaction.date}
+
+
+
+ {transaction.type === '收入' ? '+' : '-'}¥{transaction.amount.toFixed(2)}
+
+
+ ))
+ )}
+
+
+
+
+
+ {/* Floating Action Button */}
+
+
+ );
+}
diff --git a/frontend/src/app/components/ChatPage.tsx b/frontend/src/app/components/ChatPage.tsx
new file mode 100644
index 0000000..8037547
--- /dev/null
+++ b/frontend/src/app/components/ChatPage.tsx
@@ -0,0 +1,113 @@
+import { useState } from 'react';
+import Navigation from './Navigation';
+import { Send } from 'lucide-react';
+
+interface Message {
+ id: number;
+ text: string;
+ sender: 'me' | 'other';
+ time: string;
+}
+
+export default function ChatPage() {
+ const [messages, setMessages] = useState([
+ {
+ id: 1,
+ text: '欢迎来到聊天室!',
+ sender: 'other',
+ time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
+ },
+ ]);
+ const [inputValue, setInputValue] = useState('');
+
+ const sendMessage = () => {
+ if (inputValue.trim()) {
+ const newMessage: Message = {
+ id: Date.now(),
+ text: inputValue,
+ sender: 'me',
+ time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
+ };
+ setMessages([...messages, newMessage]);
+ setInputValue('');
+
+ // Simulate a reply
+ setTimeout(() => {
+ const reply: Message = {
+ id: Date.now() + 1,
+ text: '这是一个自动回复消息',
+ sender: 'other',
+ time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
+ };
+ setMessages(prev => [...prev, reply]);
+ }, 1000);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+ {/* Chat Container */}
+
+ {/* Messages Area */}
+
+ {messages.map((message) => (
+
+
+
{message.text}
+
+ {message.time}
+
+
+
+ ))}
+
+
+ {/* Input Area */}
+
+
+ setInputValue(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
+ placeholder="输入消息..."
+ className="flex-1 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/components/FileSharingPage.tsx b/frontend/src/app/components/FileSharingPage.tsx
new file mode 100644
index 0000000..699b895
--- /dev/null
+++ b/frontend/src/app/components/FileSharingPage.tsx
@@ -0,0 +1,115 @@
+import { useState } from 'react';
+import Navigation from './Navigation';
+import { Upload, File, Download, Trash2 } from 'lucide-react';
+
+interface FileItem {
+ id: number;
+ name: string;
+ size: string;
+ date: string;
+}
+
+export default function FileSharingPage() {
+ const [files, setFiles] = useState([]);
+
+ const handleFileUpload = (e: React.ChangeEvent) => {
+ const fileList = e.target.files;
+ if (fileList && fileList.length > 0) {
+ const newFiles = Array.from(fileList).map((file) => ({
+ id: Date.now() + Math.random(),
+ name: file.name,
+ size: (file.size / 1024).toFixed(2) + ' KB',
+ date: new Date().toLocaleDateString('zh-CN'),
+ }));
+ setFiles([...newFiles, ...files]);
+ }
+ };
+
+ const deleteFile = (id: number) => {
+ setFiles(files.filter(file => file.id !== id));
+ };
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+ {/* Upload Area */}
+
+
+
+
+ {/* Files List */}
+
+
文件列表
+
+ {files.length === 0 ? (
+
+ 暂无文件,开始上传吧!
+
+ ) : (
+ files.map((file) => (
+
+
+
+
+
+
+
{file.name}
+
+ {file.size} · {file.date}
+
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ {/* Floating Action Button */}
+
+
+ );
+}
diff --git a/frontend/src/app/components/HomePage.tsx b/frontend/src/app/components/HomePage.tsx
new file mode 100644
index 0000000..0efd5d4
--- /dev/null
+++ b/frontend/src/app/components/HomePage.tsx
@@ -0,0 +1,115 @@
+import { Link } from 'react-router-dom';
+import Navigation from './Navigation';
+import { CheckSquare, Cloud, Film, Wallet, MessageCircle, FolderOpen } from 'lucide-react';
+
+const features = [
+ {
+ title: 'Todo清单',
+ description: '管理你的待办事项',
+ path: '/todo',
+ icon: CheckSquare,
+ bgColor: 'bg-gradient-to-br from-purple-300 to-purple-400',
+ pattern: 'opacity-20',
+ },
+ {
+ title: '天气查询',
+ description: '查看实时天气信息',
+ path: '/weather',
+ icon: Cloud,
+ bgColor: 'bg-gradient-to-br from-blue-100 to-blue-200',
+ pattern: 'opacity-20',
+ },
+ {
+ title: '书影音清单',
+ description: '记录你的观影阅读',
+ path: '/media',
+ icon: Film,
+ bgColor: 'bg-gradient-to-br from-orange-300 to-orange-400',
+ pattern: 'opacity-20',
+ },
+ {
+ title: '记账本',
+ description: '追踪你的收支情况',
+ path: '/accounting',
+ icon: Wallet,
+ bgColor: 'bg-gradient-to-br from-purple-300 to-purple-400',
+ pattern: 'opacity-20',
+ },
+ {
+ title: '聊天室',
+ description: '与朋友愉快交流',
+ path: '/chat',
+ icon: MessageCircle,
+ bgColor: 'bg-gradient-to-br from-orange-300 to-orange-400',
+ pattern: 'opacity-20',
+ },
+ {
+ title: '文件分享',
+ description: '上传和分享文件',
+ path: '/files',
+ icon: FolderOpen,
+ bgColor: 'bg-gradient-to-br from-blue-100 to-blue-200',
+ pattern: 'opacity-20',
+ },
+];
+
+export default function HomePage() {
+ return (
+
+
+
+
+
+ {/* Hero Section */}
+
+
+ {/* Features Grid */}
+
+ {features.map((feature, index) => {
+ const Icon = feature.icon;
+ return (
+
+
+ {/* Decorative Pattern */}
+
+
+
+
+ {/* Icon */}
+
+
+
+
+ {/* Title */}
+
{feature.title}
+
+ {/* Description */}
+
{feature.description}
+
+
+ );
+ })}
+
+
+
+
+ {/* Floating Action Button */}
+
+
+ );
+}
diff --git a/frontend/src/app/components/MediaPage.tsx b/frontend/src/app/components/MediaPage.tsx
new file mode 100644
index 0000000..03cdc6a
--- /dev/null
+++ b/frontend/src/app/components/MediaPage.tsx
@@ -0,0 +1,271 @@
+import { useState } from 'react';
+import Navigation from './Navigation';
+import { Plus, X, Book, Film as FilmIcon, Music } from 'lucide-react';
+
+interface MediaItem {
+ id: number;
+ title: string;
+ description: string;
+ type: '书籍' | '电影' | '音乐';
+ rating: number;
+}
+
+export default function MediaPage() {
+ const [items, setItems] = useState([]);
+ const [showDialog, setShowDialog] = useState(false);
+ const [activeFilter, setActiveFilter] = useState<'全部' | '书籍' | '电影' | '音乐'>('全部');
+
+ // Form state
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [type, setType] = useState<'书籍' | '电影' | '音乐'>('电影');
+ const [rating, setRating] = useState(0);
+
+ const addItem = () => {
+ if (title.trim()) {
+ setItems([
+ {
+ id: Date.now(),
+ title,
+ description,
+ type,
+ rating,
+ },
+ ...items,
+ ]);
+ // Reset form
+ setTitle('');
+ setDescription('');
+ setType('电影');
+ setRating(0);
+ setShowDialog(false);
+ }
+ };
+
+ const filteredItems = activeFilter === '全部'
+ ? items
+ : items.filter(item => item.type === activeFilter);
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
书影音清单
+
记录你的阅读、观影和音乐体验
+
+
+ {/* Add Button */}
+
+
+
+
+ {/* Main Card */}
+
+ {/* Filter Tabs */}
+
+ {(['全部', '书籍', '电影', '音乐'] as const).map((filter) => (
+
+ ))}
+
+
+ {/* Items Grid */}
+
+ {filteredItems.length === 0 ? (
+
+ 暂无记录
+
+ ) : (
+
+ {filteredItems.map((item) => (
+
+ {/* Icon based on type */}
+
+
+ {item.type === '书籍' ? :
+ item.type === '电影' ? :
+ }
+
+
+ {item.type}
+
+
+
+
{item.title}
+
+ {item.description && (
+
{item.description}
+ )}
+
+ {/* Rating */}
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+
+
+ {/* Add Dialog */}
+ {showDialog && (
+
+
+
+
+
添加新记录
+
+ {/* Title Input */}
+
+
+ setTitle(e.target.value)}
+ placeholder="输入标题"
+ className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+
+ {/* Description Input */}
+
+
+
+
+ {/* Rating */}
+
+
+
+ {[1, 2, 3, 4, 5].map((star) => (
+
+ ))}
+
+
+
+ {/* Type Buttons */}
+
+
+
+
+
+
+ {/* Submit Button */}
+
+
+
+ )}
+
+ {/* Floating Action Button */}
+
+
+ );
+}
diff --git a/frontend/src/app/components/Navigation.tsx b/frontend/src/app/components/Navigation.tsx
new file mode 100644
index 0000000..d9e3265
--- /dev/null
+++ b/frontend/src/app/components/Navigation.tsx
@@ -0,0 +1,61 @@
+import { Link, useLocation } from 'react-router-dom';
+import { Home, User } from 'lucide-react';
+
+const navItems = [
+ { path: '/', label: '博客网站' },
+ { path: '/todo', label: 'Todo清单' },
+ { path: '/weather', label: '天气查询' },
+ { path: '/media', label: '书影音' },
+ { path: '/accounting', label: '记账本' },
+ { path: '/chat', label: '聊天室' },
+ { path: '/files', label: '文件分享' },
+];
+
+export default function Navigation() {
+ const location = useLocation();
+
+ return (
+
+ );
+}
diff --git a/frontend/src/app/components/TodoPage.tsx b/frontend/src/app/components/TodoPage.tsx
new file mode 100644
index 0000000..917e233
--- /dev/null
+++ b/frontend/src/app/components/TodoPage.tsx
@@ -0,0 +1,283 @@
+import { useState } from 'react';
+import Navigation from './Navigation';
+import { Trash2, Plus, Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
+
+interface Todo {
+ id: number;
+ text: string;
+ category: '全部' | '工作' | '个人' | '购物';
+ completed: boolean;
+ date: string; // Format: YYYY-MM-DD
+}
+
+const categories = ['全部', '工作', '个人', '购物'] as const;
+
+export default function TodoPage() {
+ const [todos, setTodos] = useState([]);
+ const [inputValue, setInputValue] = useState('');
+ const [activeCategory, setActiveCategory] = useState('全部');
+ const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
+ const [viewDate, setViewDate] = useState(new Date().toISOString().split('T')[0]);
+
+ const addTodo = () => {
+ if (inputValue.trim()) {
+ setTodos([
+ ...todos,
+ {
+ id: Date.now(),
+ text: inputValue,
+ category: activeCategory === '全部' ? '工作' : activeCategory,
+ completed: false,
+ date: selectedDate,
+ },
+ ]);
+ setInputValue('');
+ }
+ };
+
+ const toggleTodo = (id: number) => {
+ setTodos(todos.map(todo =>
+ todo.id === id ? { ...todo, completed: !todo.completed } : todo
+ ));
+ };
+
+ const deleteTodo = (id: number) => {
+ setTodos(todos.filter(todo => todo.id !== id));
+ };
+
+ // Filter by category and date
+ const filteredTodos = todos.filter(todo => {
+ const matchesCategory = activeCategory === '全部' || todo.category === activeCategory;
+ const matchesDate = todo.date === viewDate;
+ return matchesCategory && matchesDate;
+ });
+
+ // Navigate dates
+ const changeDate = (days: number) => {
+ const currentDate = new Date(viewDate);
+ currentDate.setDate(currentDate.getDate() + days);
+ setViewDate(currentDate.toISOString().split('T')[0]);
+ };
+
+ // Format date for display
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ const today = new Date();
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+ const tomorrow = new Date(today);
+ tomorrow.setDate(tomorrow.getDate() + 1);
+
+ if (dateString === today.toISOString().split('T')[0]) {
+ return '今天';
+ } else if (dateString === yesterday.toISOString().split('T')[0]) {
+ return '昨天';
+ } else if (dateString === tomorrow.toISOString().split('T')[0]) {
+ return '明天';
+ } else {
+ return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', weekday: 'short' });
+ }
+ };
+
+ // Count todos for each date
+ const getTodosCountForDate = (dateString: string) => {
+ return todos.filter(todo => todo.date === dateString).length;
+ };
+
+ const getCompletedCountForDate = (dateString: string) => {
+ return todos.filter(todo => todo.date === dateString && todo.completed).length;
+ };
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
Todo清单
+
管理你的待办事项,提高工作效率
+
+
+ {/* Date Navigator */}
+
+
+
+
+
+
{formatDate(viewDate)}
+
{viewDate}
+
+ {getCompletedCountForDate(viewDate)}/{getTodosCountForDate(viewDate)} 已完成
+
+
+
+
+
+
+ {/* Quick Date Selection */}
+
+ {[-2, -1, 0, 1, 2, 3, 4].map((offset) => {
+ const date = new Date();
+ date.setDate(date.getDate() + offset);
+ const dateString = date.toISOString().split('T')[0];
+ const isSelected = dateString === viewDate;
+ const todoCount = getTodosCountForDate(dateString);
+
+ return (
+
+ );
+ })}
+
+
+
+ {/* Main Card */}
+
+ {/* Input Area */}
+
+
+
setInputValue(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && addTodo()}
+ placeholder="添加新的待办事项..."
+ className="flex-1 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+
+
+ {/* Date Picker for new todo */}
+
+
+ setSelectedDate(e.target.value)}
+ className="px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-400 text-sm"
+ />
+ 选择日期
+
+
+
+ {/* Category Tabs */}
+
+ {categories.map((category) => (
+
+ ))}
+
+
+ {/* Todo List */}
+
+ {filteredTodos.length === 0 ? (
+
+ {formatDate(viewDate)}暂无待办事项
+
+ ) : (
+ filteredTodos.map((todo) => (
+
+
toggleTodo(todo.id)}
+ className="w-5 h-5 rounded border-gray-300 text-orange-400 focus:ring-orange-400"
+ />
+
+
+ {todo.text}
+
+
+
+ {todo.category}
+
+ {todo.date !== viewDate && (
+
+ {formatDate(todo.date)}
+
+ )}
+
+
+
+
+ ))
+ )}
+
+
+ {/* Statistics */}
+ {filteredTodos.length > 0 && (
+
+
+ 总计: {filteredTodos.length} 项
+ 已完成: {filteredTodos.filter(t => t.completed).length} 项
+ 未完成: {filteredTodos.filter(t => !t.completed).length} 项
+
+
+ )}
+
+
+
+
+ {/* Floating Action Button */}
+
+
+ );
+}
diff --git a/frontend/src/app/components/WeatherPage.tsx b/frontend/src/app/components/WeatherPage.tsx
new file mode 100644
index 0000000..1fa1c5c
--- /dev/null
+++ b/frontend/src/app/components/WeatherPage.tsx
@@ -0,0 +1,89 @@
+import { useState } from 'react';
+import Navigation from './Navigation';
+import { Search } from 'lucide-react';
+
+export default function WeatherPage() {
+ const [city, setCity] = useState('');
+ const [searchResult, setSearchResult] = useState(null);
+
+ const handleSearch = () => {
+ if (city.trim()) {
+ setSearchResult(city);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {/* Header */}
+
+
+ {/* Search Card */}
+
+ {/* Search Input */}
+
+ setCity(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
+ placeholder="输入城市名称..."
+ className="flex-1 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-orange-400"
+ />
+
+
+
+ {/* Result Area */}
+ {searchResult ? (
+
+
+
{searchResult}
+
23°C
+
多云
+
+
+ ) : (
+
+ 输入城市名称并点击查看天气
+
+ )}
+
+
+
+
+ {/* Floating Action Button */}
+
+
+ );
+}
diff --git a/frontend/src/app/components/figma/ImageWithFallback.tsx b/frontend/src/app/components/figma/ImageWithFallback.tsx
new file mode 100644
index 0000000..0e26139
--- /dev/null
+++ b/frontend/src/app/components/figma/ImageWithFallback.tsx
@@ -0,0 +1,27 @@
+import React, { useState } from 'react'
+
+const ERROR_IMG_SRC =
+ 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODgiIGhlaWdodD0iODgiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgc3Ryb2tlPSIjMDAwIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBvcGFjaXR5PSIuMyIgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIzLjciPjxyZWN0IHg9IjE2IiB5PSIxNiIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iNiIvPjxwYXRoIGQ9Im0xNiA1OCAxNi0xOCAzMiAzMiIvPjxjaXJjbGUgY3g9IjUzIiBjeT0iMzUiIHI9IjciLz48L3N2Zz4KCg=='
+
+export function ImageWithFallback(props: React.ImgHTMLAttributes) {
+ const [didError, setDidError] = useState(false)
+
+ const handleError = () => {
+ setDidError(true)
+ }
+
+ const { src, alt, style, className, ...rest } = props
+
+ return didError ? (
+
+
+

+
+
+ ) : (
+
+ )
+}
diff --git a/frontend/src/app/components/ui/accordion.tsx b/frontend/src/app/components/ui/accordion.tsx
new file mode 100644
index 0000000..bd6b1e3
--- /dev/null
+++ b/frontend/src/app/components/ui/accordion.tsx
@@ -0,0 +1,66 @@
+"use client";
+
+import * as React from "react";
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDownIcon } from "lucide-react";
+
+import { cn } from "./utils";
+
+function Accordion({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AccordionItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AccordionTrigger({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ svg]:rotate-180",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+ );
+}
+
+function AccordionContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/frontend/src/app/components/ui/alert-dialog.tsx b/frontend/src/app/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..875b8df
--- /dev/null
+++ b/frontend/src/app/components/ui/alert-dialog.tsx
@@ -0,0 +1,157 @@
+"use client";
+
+import * as React from "react";
+import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
+
+import { cn } from "./utils";
+import { buttonVariants } from "./button";
+
+function AlertDialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/frontend/src/app/components/ui/alert.tsx b/frontend/src/app/components/ui/alert.tsx
new file mode 100644
index 0000000..9c35976
--- /dev/null
+++ b/frontend/src/app/components/ui/alert.tsx
@@ -0,0 +1,66 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "./utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertTitle, AlertDescription };
diff --git a/frontend/src/app/components/ui/aspect-ratio.tsx b/frontend/src/app/components/ui/aspect-ratio.tsx
new file mode 100644
index 0000000..c16d6bc
--- /dev/null
+++ b/frontend/src/app/components/ui/aspect-ratio.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
+
+function AspectRatio({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+export { AspectRatio };
diff --git a/frontend/src/app/components/ui/avatar.tsx b/frontend/src/app/components/ui/avatar.tsx
new file mode 100644
index 0000000..c990451
--- /dev/null
+++ b/frontend/src/app/components/ui/avatar.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import * as React from "react";
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+
+import { cn } from "./utils";
+
+function Avatar({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarImage({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AvatarFallback({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/frontend/src/app/components/ui/badge.tsx b/frontend/src/app/components/ui/badge.tsx
new file mode 100644
index 0000000..2ccc2c4
--- /dev/null
+++ b/frontend/src/app/components/ui/badge.tsx
@@ -0,0 +1,46 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "./utils";
+
+const badgeVariants = cva(
+ "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
+ {
+ variants: {
+ variant: {
+ default:
+ "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
+ secondary:
+ "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
+ destructive:
+ "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Badge({
+ className,
+ variant,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"span"> &
+ VariantProps & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "span";
+
+ return (
+
+ );
+}
+
+export { Badge, badgeVariants };
diff --git a/frontend/src/app/components/ui/breadcrumb.tsx b/frontend/src/app/components/ui/breadcrumb.tsx
new file mode 100644
index 0000000..8f84d7e
--- /dev/null
+++ b/frontend/src/app/components/ui/breadcrumb.tsx
@@ -0,0 +1,109 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "./utils";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbList,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+ BreadcrumbEllipsis,
+};
diff --git a/frontend/src/app/components/ui/button.tsx b/frontend/src/app/components/ui/button.tsx
new file mode 100644
index 0000000..40ef7aa
--- /dev/null
+++ b/frontend/src/app/components/ui/button.tsx
@@ -0,0 +1,58 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "./utils";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background text-foreground hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9 rounded-md",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/frontend/src/app/components/ui/calendar.tsx b/frontend/src/app/components/ui/calendar.tsx
new file mode 100644
index 0000000..ee7b73f
--- /dev/null
+++ b/frontend/src/app/components/ui/calendar.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import * as React from "react";
+import { ChevronLeft, ChevronRight } from "lucide-react";
+import { DayPicker } from "react-day-picker";
+
+import { cn } from "./utils";
+import { buttonVariants } from "./button";
+
+function Calendar({
+ className,
+ classNames,
+ showOutsideDays = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+ .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
+ : "[&:has([aria-selected])]:rounded-md",
+ ),
+ day: cn(
+ buttonVariants({ variant: "ghost" }),
+ "size-8 p-0 font-normal aria-selected:opacity-100",
+ ),
+ day_range_start:
+ "day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
+ day_range_end:
+ "day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
+ day_selected:
+ "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
+ day_today: "bg-accent text-accent-foreground",
+ day_outside:
+ "day-outside text-muted-foreground aria-selected:text-muted-foreground",
+ day_disabled: "text-muted-foreground opacity-50",
+ day_range_middle:
+ "aria-selected:bg-accent aria-selected:text-accent-foreground",
+ day_hidden: "invisible",
+ ...classNames,
+ }}
+ components={{
+ IconLeft: ({ className, ...props }) => (
+
+ ),
+ IconRight: ({ className, ...props }) => (
+
+ ),
+ }}
+ {...props}
+ />
+ );
+}
+
+export { Calendar };
diff --git a/frontend/src/app/components/ui/card.tsx b/frontend/src/app/components/ui/card.tsx
new file mode 100644
index 0000000..5f9d58a
--- /dev/null
+++ b/frontend/src/app/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react";
+
+import { cn } from "./utils";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+};
diff --git a/frontend/src/app/components/ui/carousel.tsx b/frontend/src/app/components/ui/carousel.tsx
new file mode 100644
index 0000000..bb5ab13
--- /dev/null
+++ b/frontend/src/app/components/ui/carousel.tsx
@@ -0,0 +1,241 @@
+"use client";
+
+import * as React from "react";
+import useEmblaCarousel, {
+ type UseEmblaCarouselType,
+} from "embla-carousel-react";
+import { ArrowLeft, ArrowRight } from "lucide-react";
+
+import { cn } from "./utils";
+import { Button } from "./button";
+
+type CarouselApi = UseEmblaCarouselType[1];
+type UseCarouselParameters = Parameters;
+type CarouselOptions = UseCarouselParameters[0];
+type CarouselPlugin = UseCarouselParameters[1];
+
+type CarouselProps = {
+ opts?: CarouselOptions;
+ plugins?: CarouselPlugin;
+ orientation?: "horizontal" | "vertical";
+ setApi?: (api: CarouselApi) => void;
+};
+
+type CarouselContextProps = {
+ carouselRef: ReturnType[0];
+ api: ReturnType[1];
+ scrollPrev: () => void;
+ scrollNext: () => void;
+ canScrollPrev: boolean;
+ canScrollNext: boolean;
+} & CarouselProps;
+
+const CarouselContext = React.createContext(null);
+
+function useCarousel() {
+ const context = React.useContext(CarouselContext);
+
+ if (!context) {
+ throw new Error("useCarousel must be used within a ");
+ }
+
+ return context;
+}
+
+function Carousel({
+ orientation = "horizontal",
+ opts,
+ setApi,
+ plugins,
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & CarouselProps) {
+ const [carouselRef, api] = useEmblaCarousel(
+ {
+ ...opts,
+ axis: orientation === "horizontal" ? "x" : "y",
+ },
+ plugins,
+ );
+ const [canScrollPrev, setCanScrollPrev] = React.useState(false);
+ const [canScrollNext, setCanScrollNext] = React.useState(false);
+
+ const onSelect = React.useCallback((api: CarouselApi) => {
+ if (!api) return;
+ setCanScrollPrev(api.canScrollPrev());
+ setCanScrollNext(api.canScrollNext());
+ }, []);
+
+ const scrollPrev = React.useCallback(() => {
+ api?.scrollPrev();
+ }, [api]);
+
+ const scrollNext = React.useCallback(() => {
+ api?.scrollNext();
+ }, [api]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ scrollPrev();
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ scrollNext();
+ }
+ },
+ [scrollPrev, scrollNext],
+ );
+
+ React.useEffect(() => {
+ if (!api || !setApi) return;
+ setApi(api);
+ }, [api, setApi]);
+
+ React.useEffect(() => {
+ if (!api) return;
+ onSelect(api);
+ api.on("reInit", onSelect);
+ api.on("select", onSelect);
+
+ return () => {
+ api?.off("select", onSelect);
+ };
+ }, [api, onSelect]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
+ const { carouselRef, orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
+ const { orientation } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselPrevious({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollPrev, canScrollPrev } = useCarousel();
+
+ return (
+
+ );
+}
+
+function CarouselNext({
+ className,
+ variant = "outline",
+ size = "icon",
+ ...props
+}: React.ComponentProps) {
+ const { orientation, scrollNext, canScrollNext } = useCarousel();
+
+ return (
+
+ );
+}
+
+export {
+ type CarouselApi,
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselPrevious,
+ CarouselNext,
+};
diff --git a/frontend/src/app/components/ui/chart.tsx b/frontend/src/app/components/ui/chart.tsx
new file mode 100644
index 0000000..b49bc36
--- /dev/null
+++ b/frontend/src/app/components/ui/chart.tsx
@@ -0,0 +1,353 @@
+"use client";
+
+import * as React from "react";
+import * as RechartsPrimitive from "recharts";
+
+import { cn } from "./utils";
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const;
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode;
+ icon?: React.ComponentType;
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record }
+ );
+};
+
+type ChartContextProps = {
+ config: ChartConfig;
+};
+
+const ChartContext = React.createContext(null);
+
+function useChart() {
+ const context = React.useContext(ChartContext);
+
+ if (!context) {
+ throw new Error("useChart must be used within a ");
+ }
+
+ return context;
+}
+
+function ChartContainer({
+ id,
+ className,
+ children,
+ config,
+ ...props
+}: React.ComponentProps<"div"> & {
+ config: ChartConfig;
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"];
+}) {
+ const uniqueId = React.useId();
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color,
+ );
+
+ if (!colorConfig.length) {
+ return null;
+ }
+
+ return (
+
-
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..ad4dbd5
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import path from 'path'
+import tailwindcss from '@tailwindcss/vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [
+ // The React and Tailwind plugins are both required for Make, even if
+ // Tailwind is not being actively used – do not remove them
+ react(),
+ tailwindcss(),
+ ],
+ resolve: {
+ alias: {
+ // Alias @ to the src directory
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+})