mirror of
https://github.com/escalante29/healthy-fit.git
synced 2026-03-21 10:48:46 +01:00
Migrate frontend to TypeScript and extend user profile
Converted frontend codebase from JavaScript to TypeScript, including pages, components, and context. Added new layout and UI kit components. Updated backend user model and schemas to support profile fields (firstname, lastname, age, gender, height, weight, unit_preference) and added endpoints for reading/updating current user. Introduced food log listing endpoint and migration script for user table. Updated dependencies and build configs for TypeScript and Tailwind v4.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,6 +29,7 @@ yarn-error.log
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
.output/
|
.output/
|
||||||
|
frontend/catalyst-ui-kit 2/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
postgres_data/
|
postgres_data/
|
||||||
|
|||||||
@@ -61,9 +61,7 @@ class NutritionModule(dspy.Module):
|
|||||||
pred = self.extract(description=description)
|
pred = self.extract(description=description)
|
||||||
|
|
||||||
# Assertion: Check Macro Consistency
|
# Assertion: Check Macro Consistency
|
||||||
calc_cals = (
|
# calc_cals calculation removed as dspy.Suggest is disabled
|
||||||
(pred.nutritional_info.protein * 4) + (pred.nutritional_info.carbs * 4) + (pred.nutritional_info.fats * 9)
|
|
||||||
)
|
|
||||||
|
|
||||||
# dspy.Suggest is not available in dspy>=3.1.0
|
# dspy.Suggest is not available in dspy>=3.1.0
|
||||||
# dspy.Suggest(
|
# dspy.Suggest(
|
||||||
@@ -78,9 +76,7 @@ class NutritionModule(dspy.Module):
|
|||||||
pred = self.analyze_image(image=image, description=description)
|
pred = self.analyze_image(image=image, description=description)
|
||||||
|
|
||||||
# Assertion: Check Macro Consistency
|
# Assertion: Check Macro Consistency
|
||||||
calc_cals = (
|
# calc_cals calculation removed as dspy.Suggest is disabled
|
||||||
(pred.nutritional_info.protein * 4) + (pred.nutritional_info.carbs * 4) + (pred.nutritional_info.fats * 9)
|
|
||||||
)
|
|
||||||
|
|
||||||
# dspy.Suggest is not available in dspy>=3.1.0
|
# dspy.Suggest is not available in dspy>=3.1.0
|
||||||
# dspy.Suggest(
|
# dspy.Suggest(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import litellm
|
|
||||||
import dspy
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import litellm
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
@@ -70,3 +69,25 @@ def log_food(
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(food_log)
|
session.refresh(food_log)
|
||||||
return food_log
|
return food_log
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logs", response_model=list[FoodLog])
|
||||||
|
def read_logs(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Get food logs for current user.
|
||||||
|
"""
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
statement = (
|
||||||
|
select(FoodLog)
|
||||||
|
.where(FoodLog.user_id == current_user.id)
|
||||||
|
.order_by(FoodLog.timestamp.desc())
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return session.exec(statement).all()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlmodel import Session, select
|
|||||||
from app.api import deps
|
from app.api import deps
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.user import UserCreate, UserRead
|
from app.schemas.user import UserCreate, UserRead, UserUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -36,3 +36,31 @@ def create_user(
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
def read_user_me(
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Get current user.
|
||||||
|
"""
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me", response_model=UserRead)
|
||||||
|
def update_user_me(
|
||||||
|
*,
|
||||||
|
session: Session = Depends(deps.get_session),
|
||||||
|
user_in: UserUpdate,
|
||||||
|
current_user: deps.CurrentUser,
|
||||||
|
) -> Any:
|
||||||
|
"""
|
||||||
|
Update own user.
|
||||||
|
"""
|
||||||
|
user_data = user_in.model_dump(exclude_unset=True)
|
||||||
|
current_user.sqlmodel_update(user_data)
|
||||||
|
session.add(current_user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(current_user)
|
||||||
|
return current_user
|
||||||
|
|||||||
@@ -9,4 +9,14 @@ class User(SQLModel, table=True):
|
|||||||
username: str = Field(index=True, unique=True)
|
username: str = Field(index=True, unique=True)
|
||||||
email: str = Field(index=True, unique=True)
|
email: str = Field(index=True, unique=True)
|
||||||
password_hash: str
|
password_hash: str
|
||||||
|
|
||||||
|
# Profile Info
|
||||||
|
firstname: Optional[str] = None
|
||||||
|
lastname: Optional[str] = None
|
||||||
|
age: Optional[int] = None
|
||||||
|
gender: Optional[str] = None
|
||||||
|
height: Optional[float] = None
|
||||||
|
weight: Optional[float] = None
|
||||||
|
unit_preference: str = Field(default="metric") # "metric" or "imperial"
|
||||||
|
|
||||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
|||||||
@@ -14,9 +14,23 @@ class UserCreate(UserBase):
|
|||||||
|
|
||||||
class UserRead(UserBase):
|
class UserRead(UserBase):
|
||||||
id: int
|
id: int
|
||||||
|
firstname: Optional[str] = None
|
||||||
|
lastname: Optional[str] = None
|
||||||
|
age: Optional[int] = None
|
||||||
|
gender: Optional[str] = None
|
||||||
|
height: Optional[float] = None
|
||||||
|
weight: Optional[float] = None
|
||||||
|
unit_preference: str = "metric"
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(SQLModel):
|
class UserUpdate(SQLModel):
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
|
firstname: Optional[str] = None
|
||||||
|
lastname: Optional[str] = None
|
||||||
|
age: Optional[int] = None
|
||||||
|
gender: Optional[str] = None
|
||||||
|
height: Optional[float] = None
|
||||||
|
weight: Optional[float] = None
|
||||||
|
unit_preference: Optional[str] = None
|
||||||
|
|||||||
32
backend/migrate_user_table.py
Normal file
32
backend/migrate_user_table.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from sqlmodel import Session, text
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
print("Starting migration...")
|
||||||
|
with Session(engine) as session:
|
||||||
|
columns = [
|
||||||
|
("firstname", "VARCHAR"),
|
||||||
|
("lastname", "VARCHAR"),
|
||||||
|
("age", "INTEGER"),
|
||||||
|
("gender", "VARCHAR"),
|
||||||
|
("height", "FLOAT"),
|
||||||
|
("weight", "FLOAT"),
|
||||||
|
("unit_preference", "VARCHAR DEFAULT 'metric'"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for col, type_ in columns:
|
||||||
|
try:
|
||||||
|
print(f"Adding {col}...")
|
||||||
|
# Using "user" with quotes to avoid reserved keyword issues, though SQLModel usually handles it
|
||||||
|
session.exec(text(f'ALTER TABLE "user" ADD COLUMN IF NOT EXISTS {col} {type_}'))
|
||||||
|
session.commit()
|
||||||
|
print(f"Added {col}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding {col}: {e}")
|
||||||
|
session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -8,6 +8,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
1698
frontend/package-lock.json
generated
1698
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
|
"motion": "^12.27.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
@@ -20,16 +21,20 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/react": "^19.2.5",
|
"@headlessui/react": "^2.2.9",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^25.0.9",
|
||||||
|
"@types/react": "^19.2.8",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.2",
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
"autoprefixer": "^10.4.23",
|
"clsx": "^2.1.1",
|
||||||
"eslint": "^9.39.1",
|
"eslint": "^9.39.1",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"postcss": "^8.5.6",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
|
||||||
import { AuthProvider } from './context/AuthContext';
|
|
||||||
import Login from './pages/Login';
|
|
||||||
import Dashboard from './pages/Dashboard';
|
|
||||||
import Nutrition from './pages/Nutrition';
|
|
||||||
import Health from './pages/Health';
|
|
||||||
import Plans from './pages/Plans';
|
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<Router>
|
|
||||||
<div className="min-h-screen bg-gray-900 text-white font-sans">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
<Route path="/" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Dashboard />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/nutrition" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Nutrition />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/health" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Health />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/plans" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Plans />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
</Router>
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
66
frontend/src/App.tsx
Normal file
66
frontend/src/App.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
|
import { AuthProvider } from './context/AuthContext';
|
||||||
|
import { ThemeProvider } from './context/ThemeContext';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Nutrition from './pages/Nutrition';
|
||||||
|
import Health from './pages/Health';
|
||||||
|
import Plans from './pages/Plans';
|
||||||
|
import Profile from './pages/Profile';
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
|
import AppLayout from './components/Layout/AppLayout';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<Router>
|
||||||
|
<div className="min-h-screen font-sans bg-base text-content transition-colors duration-200">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
{/* Protected Routes Wrapper */}
|
||||||
|
<Route path="/" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Dashboard />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/nutrition" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Nutrition />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/health" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Health />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/plans" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Plans />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/profile" element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Profile />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
14
frontend/src/components/Layout/AppLayout.tsx
Normal file
14
frontend/src/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { SidebarLayout } from '../catalyst/sidebar-layout'
|
||||||
|
import { AppSidebar } from './AppSidebar'
|
||||||
|
import { AppNavbar } from './AppNavbar'
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<SidebarLayout
|
||||||
|
sidebar={<AppSidebar />}
|
||||||
|
navbar={<AppNavbar />}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SidebarLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/components/Layout/AppNavbar.tsx
Normal file
13
frontend/src/components/Layout/AppNavbar.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Navbar, NavbarItem, NavbarSection, NavbarSpacer } from '../catalyst/navbar'
|
||||||
|
|
||||||
|
export function AppNavbar() {
|
||||||
|
return (
|
||||||
|
<Navbar>
|
||||||
|
<NavbarSpacer />
|
||||||
|
<NavbarSection>
|
||||||
|
<NavbarItem href="/" className="font-bold text-primary">HealthyFit</NavbarItem>
|
||||||
|
</NavbarSection>
|
||||||
|
<NavbarSpacer />
|
||||||
|
</Navbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
97
frontend/src/components/Layout/AppSidebar.tsx
Normal file
97
frontend/src/components/Layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarBody,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarItem,
|
||||||
|
SidebarLabel,
|
||||||
|
SidebarSection,
|
||||||
|
SidebarSpacer,
|
||||||
|
SidebarDivider,
|
||||||
|
} from '../catalyst/sidebar'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Utensils,
|
||||||
|
Heart,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
LogOut,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { useTheme } from '../../context/ThemeContext'
|
||||||
|
import { useContext } from 'react'
|
||||||
|
import { AuthContext } from '../../context/AuthContext'
|
||||||
|
import { Avatar } from '../catalyst/avatar'
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const location = useLocation()
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const { user, logout } = useContext(AuthContext)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
|
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
|
||||||
|
{ to: "/health", icon: Heart, label: "Health" },
|
||||||
|
{ to: "/plans", icon: Calendar, label: "Plans" },
|
||||||
|
{ to: "/profile", icon: User, label: "Profile" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarItem href="/" className="mb-2">
|
||||||
|
<SidebarLabel className="text-2xl font-bold text-primary">HealthyFit</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarBody>
|
||||||
|
<SidebarSection>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<SidebarItem
|
||||||
|
key={item.to}
|
||||||
|
href={item.to}
|
||||||
|
current={location.pathname === item.to}
|
||||||
|
>
|
||||||
|
<item.icon data-slot="icon" />
|
||||||
|
<SidebarLabel>{item.label}</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
))}
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarSpacer />
|
||||||
|
</SidebarBody>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarDivider />
|
||||||
|
<SidebarSection>
|
||||||
|
<SidebarItem onClick={toggleTheme}>
|
||||||
|
{theme === 'dark' ? <Moon data-slot="icon" /> : <Sun data-slot="icon" />}
|
||||||
|
<SidebarLabel>Appearance</SidebarLabel>
|
||||||
|
</SidebarItem>
|
||||||
|
</SidebarSection>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 p-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
initials={user?.firstname?.[0] || user?.email?.[0]?.toUpperCase() || 'U'}
|
||||||
|
className="bg-primary/20 text-primary size-10"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-zinc-950 dark:text-white truncate">
|
||||||
|
{user?.firstname ? `${user.firstname} ${user.lastname || ''}` : user?.username}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 dark:text-zinc-400 truncate">
|
||||||
|
{user?.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={logout} className="ml-auto text-zinc-500 hover:text-red-500 transition-colors">
|
||||||
|
<LogOut size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SidebarFooter>
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
frontend/src/components/Layout/MainLayout.tsx
Normal file
17
frontend/src/components/Layout/MainLayout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Sidebar from './Sidebar';
|
||||||
|
|
||||||
|
const MainLayout = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-base transition-colors duration-200">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 lg:ml-64 min-h-screen w-full">
|
||||||
|
<div className="container mx-auto px-4 py-8 lg:px-8 max-w-7xl animate-fade-in">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
124
frontend/src/components/Layout/Sidebar.tsx
Normal file
124
frontend/src/components/Layout/Sidebar.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Utensils,
|
||||||
|
Heart,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useTheme } from '../../context/ThemeContext';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { AuthContext } from '../../context/AuthContext';
|
||||||
|
|
||||||
|
const Sidebar = () => {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const { user, logout } = useContext(AuthContext);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: "/", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
|
{ to: "/nutrition", icon: Utensils, label: "Nutrition" },
|
||||||
|
{ to: "/health", icon: Heart, label: "Health" },
|
||||||
|
{ to: "/plans", icon: Calendar, label: "Plans" },
|
||||||
|
{ to: "/profile", icon: User, label: "Profile" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toggleSidebar = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Menu Button */}
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="lg:hidden fixed top-4 left-4 z-50 p-2 rounded-lg bg-surface text-content shadow-md hover:bg-opacity-80 transition-colors"
|
||||||
|
>
|
||||||
|
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Overlay for mobile */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="lg:hidden fixed inset-0 bg-black bg-opacity-50 z-40 backdrop-blur-sm"
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar Container */}
|
||||||
|
<aside className={`
|
||||||
|
fixed top-0 left-0 z-40 h-screen w-64
|
||||||
|
bg-surface border-r border-border
|
||||||
|
transform transition-transform duration-300 ease-in-out
|
||||||
|
${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||||
|
`}>
|
||||||
|
<div className="flex flex-col h-full bg-surface text-content">
|
||||||
|
|
||||||
|
{/* Logo / Brand */}
|
||||||
|
<div className="h-16 flex items-center px-6 border-b border-border">
|
||||||
|
<span className="text-2xl font-bold text-primary">HealthyFit</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 px-4 py-6 space-y-2 overflow-y-auto">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className={({ isActive }) => `
|
||||||
|
flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 group
|
||||||
|
${isActive
|
||||||
|
? 'bg-primary text-white shadow-lg shadow-primary/20'
|
||||||
|
: 'text-content-muted hover:bg-black/5 dark:hover:bg-white/5 hover:text-primary'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<item.icon size={20} className="stroke-[2.5px] transition-transform group-hover:scale-110" />
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Bottom Actions */}
|
||||||
|
<div className="p-4 border-t border-border space-y-4">
|
||||||
|
|
||||||
|
{/* Theme Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-content-muted hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">Appearance</span>
|
||||||
|
{theme === 'dark' ? <Moon size={18} /> : <Sun size={18} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Profile */}
|
||||||
|
<div className="flex items-center gap-3 px-2">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-bold">
|
||||||
|
{user?.firstname?.[0] || user?.email?.[0]?.toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold truncate text-content">
|
||||||
|
{user?.firstname ? `${user.firstname} ${user.lastname || ''}` : user?.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-content-muted truncate">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="p-2 text-content-muted hover:text-red-500 transition-colors"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
87
frontend/src/components/catalyst/avatar.tsx
Normal file
87
frontend/src/components/catalyst/avatar.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
type AvatarProps = {
|
||||||
|
src?: string | null
|
||||||
|
square?: boolean
|
||||||
|
initials?: string
|
||||||
|
alt?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
src = null,
|
||||||
|
square = false,
|
||||||
|
initials,
|
||||||
|
alt = '',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarProps & React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'inline-grid shrink-0 align-middle [--avatar-radius:20%] *:col-start-1 *:row-start-1',
|
||||||
|
'outline -outline-offset-1 outline-black/10 dark:outline-white/10',
|
||||||
|
// Border radius
|
||||||
|
square ? 'rounded-(--avatar-radius) *:rounded-(--avatar-radius)' : 'rounded-full *:rounded-full'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{initials && (
|
||||||
|
<svg
|
||||||
|
className="size-full fill-current p-[5%] text-[48px] font-medium uppercase select-none"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
aria-hidden={alt ? undefined : 'true'}
|
||||||
|
>
|
||||||
|
{alt && <title>{alt}</title>}
|
||||||
|
<text x="50%" y="50%" alignmentBaseline="middle" dominantBaseline="middle" textAnchor="middle" dy=".125em">
|
||||||
|
{initials}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{src && <img className="size-full" src={src} alt={alt} />}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AvatarButton = forwardRef(function AvatarButton(
|
||||||
|
{
|
||||||
|
src,
|
||||||
|
square = false,
|
||||||
|
initials,
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarProps &
|
||||||
|
(
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
square ? 'rounded-[20%]' : 'rounded-full',
|
||||||
|
'relative inline-grid focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||||
|
</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={classes} ref={ref}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Avatar src={src} square={square} initials={initials} alt={alt} />
|
||||||
|
</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
82
frontend/src/components/catalyst/badge.tsx
Normal file
82
frontend/src/components/catalyst/badge.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
red: 'bg-red-500/15 text-red-700 group-data-hover:bg-red-500/25 dark:bg-red-500/10 dark:text-red-400 dark:group-data-hover:bg-red-500/20',
|
||||||
|
orange:
|
||||||
|
'bg-orange-500/15 text-orange-700 group-data-hover:bg-orange-500/25 dark:bg-orange-500/10 dark:text-orange-400 dark:group-data-hover:bg-orange-500/20',
|
||||||
|
amber:
|
||||||
|
'bg-amber-400/20 text-amber-700 group-data-hover:bg-amber-400/30 dark:bg-amber-400/10 dark:text-amber-400 dark:group-data-hover:bg-amber-400/15',
|
||||||
|
yellow:
|
||||||
|
'bg-yellow-400/20 text-yellow-700 group-data-hover:bg-yellow-400/30 dark:bg-yellow-400/10 dark:text-yellow-300 dark:group-data-hover:bg-yellow-400/15',
|
||||||
|
lime: 'bg-lime-400/20 text-lime-700 group-data-hover:bg-lime-400/30 dark:bg-lime-400/10 dark:text-lime-300 dark:group-data-hover:bg-lime-400/15',
|
||||||
|
green:
|
||||||
|
'bg-green-500/15 text-green-700 group-data-hover:bg-green-500/25 dark:bg-green-500/10 dark:text-green-400 dark:group-data-hover:bg-green-500/20',
|
||||||
|
emerald:
|
||||||
|
'bg-emerald-500/15 text-emerald-700 group-data-hover:bg-emerald-500/25 dark:bg-emerald-500/10 dark:text-emerald-400 dark:group-data-hover:bg-emerald-500/20',
|
||||||
|
teal: 'bg-teal-500/15 text-teal-700 group-data-hover:bg-teal-500/25 dark:bg-teal-500/10 dark:text-teal-300 dark:group-data-hover:bg-teal-500/20',
|
||||||
|
cyan: 'bg-cyan-400/20 text-cyan-700 group-data-hover:bg-cyan-400/30 dark:bg-cyan-400/10 dark:text-cyan-300 dark:group-data-hover:bg-cyan-400/15',
|
||||||
|
sky: 'bg-sky-500/15 text-sky-700 group-data-hover:bg-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300 dark:group-data-hover:bg-sky-500/20',
|
||||||
|
blue: 'bg-blue-500/15 text-blue-700 group-data-hover:bg-blue-500/25 dark:text-blue-400 dark:group-data-hover:bg-blue-500/25',
|
||||||
|
indigo:
|
||||||
|
'bg-indigo-500/15 text-indigo-700 group-data-hover:bg-indigo-500/25 dark:text-indigo-400 dark:group-data-hover:bg-indigo-500/20',
|
||||||
|
violet:
|
||||||
|
'bg-violet-500/15 text-violet-700 group-data-hover:bg-violet-500/25 dark:text-violet-400 dark:group-data-hover:bg-violet-500/20',
|
||||||
|
purple:
|
||||||
|
'bg-purple-500/15 text-purple-700 group-data-hover:bg-purple-500/25 dark:text-purple-400 dark:group-data-hover:bg-purple-500/20',
|
||||||
|
fuchsia:
|
||||||
|
'bg-fuchsia-400/15 text-fuchsia-700 group-data-hover:bg-fuchsia-400/25 dark:bg-fuchsia-400/10 dark:text-fuchsia-400 dark:group-data-hover:bg-fuchsia-400/20',
|
||||||
|
pink: 'bg-pink-400/15 text-pink-700 group-data-hover:bg-pink-400/25 dark:bg-pink-400/10 dark:text-pink-400 dark:group-data-hover:bg-pink-400/20',
|
||||||
|
rose: 'bg-rose-400/15 text-rose-700 group-data-hover:bg-rose-400/25 dark:bg-rose-400/10 dark:text-rose-400 dark:group-data-hover:bg-rose-400/20',
|
||||||
|
zinc: 'bg-zinc-600/10 text-zinc-700 group-data-hover:bg-zinc-600/20 dark:bg-white/5 dark:text-zinc-400 dark:group-data-hover:bg-white/10',
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeProps = { color?: keyof typeof colors }
|
||||||
|
|
||||||
|
export function Badge({ color = 'zinc', className, ...props }: BadgeProps & React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'inline-flex items-center gap-x-1.5 rounded-md px-1.5 py-0.5 text-sm/5 font-medium sm:text-xs/5 forced-colors:outline',
|
||||||
|
colors[color]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BadgeButton = forwardRef(function BadgeButton(
|
||||||
|
{
|
||||||
|
color = 'zinc',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: BadgeProps & { className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
'group relative inline-flex rounded-md focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500'
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Badge color={color}>{children}</Badge>
|
||||||
|
</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={classes} ref={ref}>
|
||||||
|
<TouchTarget>
|
||||||
|
<Badge color={color}>{children}</Badge>
|
||||||
|
</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
204
frontend/src/components/catalyst/button.tsx
Normal file
204
frontend/src/components/catalyst/button.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
base: [
|
||||||
|
// Base
|
||||||
|
'relative isolate inline-flex items-baseline justify-center gap-x-2 rounded-lg border text-base/6 font-semibold',
|
||||||
|
// Sizing
|
||||||
|
'px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)] sm:text-sm/6',
|
||||||
|
// Focus
|
||||||
|
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:opacity-50',
|
||||||
|
// Icon
|
||||||
|
'*:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:my-0.5 *:data-[slot=icon]:size-5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-(--btn-icon) sm:*:data-[slot=icon]:my-1 sm:*:data-[slot=icon]:size-4 forced-colors:[--btn-icon:ButtonText] forced-colors:data-hover:[--btn-icon:ButtonText]',
|
||||||
|
],
|
||||||
|
solid: [
|
||||||
|
// Optical border, implemented as the button background to avoid corner artifacts
|
||||||
|
'border-transparent bg-(--btn-border)',
|
||||||
|
// Dark mode: border is rendered on `after` so background is set to button background
|
||||||
|
'dark:bg-(--btn-bg)',
|
||||||
|
// Button background, implemented as foreground layer to stack on top of pseudo-border layer
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(var(--radius-lg)-1px)] before:bg-(--btn-bg)',
|
||||||
|
// Drop shadow, applied to the inset `before` layer so it blends with the border
|
||||||
|
'before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Dark mode: Subtle white outline is applied using a border
|
||||||
|
'dark:border-white/5',
|
||||||
|
// Shim/overlay, inset to match button foreground and used for hover state + highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:-z-10 after:rounded-[calc(var(--radius-lg)-1px)]',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
// White overlay on hover
|
||||||
|
'data-active:after:bg-(--btn-hover-overlay) data-hover:after:bg-(--btn-hover-overlay)',
|
||||||
|
// Dark mode: `after` layer expands to cover entire button
|
||||||
|
'dark:after:-inset-px dark:after:rounded-lg',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:before:shadow-none data-disabled:after:shadow-none',
|
||||||
|
],
|
||||||
|
outline: [
|
||||||
|
// Base
|
||||||
|
'border-zinc-950/10 text-zinc-950 data-active:bg-zinc-950/2.5 data-hover:bg-zinc-950/2.5',
|
||||||
|
// Dark mode
|
||||||
|
'dark:border-white/15 dark:text-white dark:[--btn-bg:transparent] dark:data-active:bg-white/5 dark:data-hover:bg-white/5',
|
||||||
|
// Icon
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
plain: [
|
||||||
|
// Base
|
||||||
|
'border-transparent text-zinc-950 data-active:bg-zinc-950/5 data-hover:bg-zinc-950/5',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:data-active:bg-white/10 dark:data-hover:bg-white/10',
|
||||||
|
// Icon
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
'dark/zinc': [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:text-white dark:[--btn-bg:var(--color-zinc-600)] dark:[--btn-hover-overlay:var(--color-white)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
light: [
|
||||||
|
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
||||||
|
'dark:text-white dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
||||||
|
'[--btn-icon:var(--color-zinc-500)] data-active:[--btn-icon:var(--color-zinc-700)] data-hover:[--btn-icon:var(--color-zinc-700)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:text-zinc-950 dark:[--btn-bg:white] dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)] dark:[--btn-icon:var(--color-zinc-500)] dark:data-active:[--btn-icon:var(--color-zinc-400)] dark:data-hover:[--btn-icon:var(--color-zinc-400)]',
|
||||||
|
],
|
||||||
|
dark: [
|
||||||
|
'text-white [--btn-bg:var(--color-zinc-900)] [--btn-border:var(--color-zinc-950)]/90 [--btn-hover-overlay:var(--color-white)]/10',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-white)]/5 dark:[--btn-bg:var(--color-zinc-800)]',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
'text-zinc-950 [--btn-bg:white] [--btn-border:var(--color-zinc-950)]/10 [--btn-hover-overlay:var(--color-zinc-950)]/2.5 data-active:[--btn-border:var(--color-zinc-950)]/15 data-hover:[--btn-border:var(--color-zinc-950)]/15',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-zinc-950)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-500)] data-hover:[--btn-icon:var(--color-zinc-500)]',
|
||||||
|
],
|
||||||
|
zinc: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-zinc-600)] [--btn-border:var(--color-zinc-700)]/90',
|
||||||
|
'dark:[--btn-hover-overlay:var(--color-white)]/5',
|
||||||
|
'[--btn-icon:var(--color-zinc-400)] data-active:[--btn-icon:var(--color-zinc-300)] data-hover:[--btn-icon:var(--color-zinc-300)]',
|
||||||
|
],
|
||||||
|
indigo: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-indigo-500)] [--btn-border:var(--color-indigo-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-indigo-300)] data-active:[--btn-icon:var(--color-indigo-200)] data-hover:[--btn-icon:var(--color-indigo-200)]',
|
||||||
|
],
|
||||||
|
cyan: [
|
||||||
|
'text-cyan-950 [--btn-bg:var(--color-cyan-300)] [--btn-border:var(--color-cyan-400)]/80 [--btn-hover-overlay:var(--color-white)]/25',
|
||||||
|
'[--btn-icon:var(--color-cyan-500)]',
|
||||||
|
],
|
||||||
|
red: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-red-600)] [--btn-border:var(--color-red-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-red-300)] data-active:[--btn-icon:var(--color-red-200)] data-hover:[--btn-icon:var(--color-red-200)]',
|
||||||
|
],
|
||||||
|
orange: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-orange-500)] [--btn-border:var(--color-orange-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-orange-300)] data-active:[--btn-icon:var(--color-orange-200)] data-hover:[--btn-icon:var(--color-orange-200)]',
|
||||||
|
],
|
||||||
|
amber: [
|
||||||
|
'text-amber-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-amber-400)] [--btn-border:var(--color-amber-500)]/80',
|
||||||
|
'[--btn-icon:var(--color-amber-600)]',
|
||||||
|
],
|
||||||
|
yellow: [
|
||||||
|
'text-yellow-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-yellow-300)] [--btn-border:var(--color-yellow-400)]/80',
|
||||||
|
'[--btn-icon:var(--color-yellow-600)] data-active:[--btn-icon:var(--color-yellow-700)] data-hover:[--btn-icon:var(--color-yellow-700)]',
|
||||||
|
],
|
||||||
|
lime: [
|
||||||
|
'text-lime-950 [--btn-hover-overlay:var(--color-white)]/25 [--btn-bg:var(--color-lime-300)] [--btn-border:var(--color-lime-400)]/80',
|
||||||
|
'[--btn-icon:var(--color-lime-600)] data-active:[--btn-icon:var(--color-lime-700)] data-hover:[--btn-icon:var(--color-lime-700)]',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-green-600)] [--btn-border:var(--color-green-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
emerald: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-emerald-600)] [--btn-border:var(--color-emerald-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
teal: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-teal-600)] [--btn-border:var(--color-teal-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
sky: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-sky-500)] [--btn-border:var(--color-sky-600)]/80',
|
||||||
|
'[--btn-icon:var(--color-white)]/60 data-active:[--btn-icon:var(--color-white)]/80 data-hover:[--btn-icon:var(--color-white)]/80',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-blue-600)] [--btn-border:var(--color-blue-700)]/90',
|
||||||
|
'[--btn-icon:var(--color-blue-400)] data-active:[--btn-icon:var(--color-blue-300)] data-hover:[--btn-icon:var(--color-blue-300)]',
|
||||||
|
],
|
||||||
|
violet: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-violet-500)] [--btn-border:var(--color-violet-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-violet-300)] data-active:[--btn-icon:var(--color-violet-200)] data-hover:[--btn-icon:var(--color-violet-200)]',
|
||||||
|
],
|
||||||
|
purple: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-purple-500)] [--btn-border:var(--color-purple-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-purple-300)] data-active:[--btn-icon:var(--color-purple-200)] data-hover:[--btn-icon:var(--color-purple-200)]',
|
||||||
|
],
|
||||||
|
fuchsia: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-fuchsia-500)] [--btn-border:var(--color-fuchsia-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-fuchsia-300)] data-active:[--btn-icon:var(--color-fuchsia-200)] data-hover:[--btn-icon:var(--color-fuchsia-200)]',
|
||||||
|
],
|
||||||
|
pink: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-pink-500)] [--btn-border:var(--color-pink-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-pink-300)] data-active:[--btn-icon:var(--color-pink-200)] data-hover:[--btn-icon:var(--color-pink-200)]',
|
||||||
|
],
|
||||||
|
rose: [
|
||||||
|
'text-white [--btn-hover-overlay:var(--color-white)]/10 [--btn-bg:var(--color-rose-500)] [--btn-border:var(--color-rose-600)]/90',
|
||||||
|
'[--btn-icon:var(--color-rose-300)] data-active:[--btn-icon:var(--color-rose-200)] data-hover:[--btn-icon:var(--color-rose-200)]',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = (
|
||||||
|
| { color?: keyof typeof styles.colors; outline?: never; plain?: never }
|
||||||
|
| { color?: never; outline: true; plain?: never }
|
||||||
|
| { color?: never; outline?: never; plain: true }
|
||||||
|
) & { className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Button = forwardRef(function Button(
|
||||||
|
{ color, outline, plain, className, children, ...props }: ButtonProps,
|
||||||
|
ref: React.ForwardedRef<HTMLElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
className,
|
||||||
|
styles.base,
|
||||||
|
outline ? styles.outline : plain ? styles.plain : clsx(styles.solid, styles.colors[color ?? 'dark/zinc'])
|
||||||
|
)
|
||||||
|
|
||||||
|
return typeof props.href === 'string' ? (
|
||||||
|
<Link {...props} className={classes} ref={ref as React.ForwardedRef<HTMLAnchorElement>}>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button {...props} className={clsx(classes, 'cursor-default')} ref={ref}>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand the hit area to at least 44×44px on touch devices
|
||||||
|
*/
|
||||||
|
export function TouchTarget({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="absolute top-1/2 left-1/2 size-[max(100%,2.75rem)] -translate-x-1/2 -translate-y-1/2 pointer-fine:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
157
frontend/src/components/catalyst/checkbox.tsx
Normal file
157
frontend/src/components/catalyst/checkbox.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function CheckboxGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = [
|
||||||
|
// Basic layout
|
||||||
|
'relative isolate flex size-4.5 items-center justify-center rounded-[0.3125rem] sm:size-4',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-[calc(0.3125rem-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color when checked
|
||||||
|
'group-data-checked:before:bg-(--checkbox-checked-bg)',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Background color applied to control in dark mode
|
||||||
|
'dark:bg-white/5 dark:group-data-checked:bg-(--checkbox-checked-bg)',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--checkbox-checked-border)',
|
||||||
|
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:rounded-[calc(0.3125rem-1px)] after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
'dark:after:-inset-px dark:after:hidden dark:after:rounded-[0.3125rem] dark:group-data-checked:after:block',
|
||||||
|
// Focus ring
|
||||||
|
'group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:opacity-50',
|
||||||
|
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--checkbox-check:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
|
||||||
|
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--checkbox-check:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-colors:[--checkbox-check:HighlightText] forced-colors:[--checkbox-checked-bg:Highlight] forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
||||||
|
'dark:forced-colors:[--checkbox-check:HighlightText] dark:forced-colors:[--checkbox-checked-bg:Highlight] dark:forced-colors:group-data-disabled:[--checkbox-check:Highlight]',
|
||||||
|
]
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
'dark:[--checkbox-checked-bg:var(--color-zinc-600)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
'dark:[--checkbox-check:var(--color-zinc-900)] dark:[--checkbox-checked-bg:var(--color-white)] dark:[--checkbox-checked-border:var(--color-zinc-950)]/15',
|
||||||
|
],
|
||||||
|
white:
|
||||||
|
'[--checkbox-check:var(--color-zinc-900)] [--checkbox-checked-bg:var(--color-white)] [--checkbox-checked-border:var(--color-zinc-950)]/15',
|
||||||
|
dark: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-900)] [--checkbox-checked-border:var(--color-zinc-950)]/90',
|
||||||
|
zinc: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-zinc-600)] [--checkbox-checked-border:var(--color-zinc-700)]/90',
|
||||||
|
red: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-red-600)] [--checkbox-checked-border:var(--color-red-700)]/90',
|
||||||
|
orange:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-orange-500)] [--checkbox-checked-border:var(--color-orange-600)]/90',
|
||||||
|
amber:
|
||||||
|
'[--checkbox-check:var(--color-amber-950)] [--checkbox-checked-bg:var(--color-amber-400)] [--checkbox-checked-border:var(--color-amber-500)]/80',
|
||||||
|
yellow:
|
||||||
|
'[--checkbox-check:var(--color-yellow-950)] [--checkbox-checked-bg:var(--color-yellow-300)] [--checkbox-checked-border:var(--color-yellow-400)]/80',
|
||||||
|
lime: '[--checkbox-check:var(--color-lime-950)] [--checkbox-checked-bg:var(--color-lime-300)] [--checkbox-checked-border:var(--color-lime-400)]/80',
|
||||||
|
green:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-green-600)] [--checkbox-checked-border:var(--color-green-700)]/90',
|
||||||
|
emerald:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-emerald-600)] [--checkbox-checked-border:var(--color-emerald-700)]/90',
|
||||||
|
teal: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-teal-600)] [--checkbox-checked-border:var(--color-teal-700)]/90',
|
||||||
|
cyan: '[--checkbox-check:var(--color-cyan-950)] [--checkbox-checked-bg:var(--color-cyan-300)] [--checkbox-checked-border:var(--color-cyan-400)]/80',
|
||||||
|
sky: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-sky-500)] [--checkbox-checked-border:var(--color-sky-600)]/80',
|
||||||
|
blue: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-blue-600)] [--checkbox-checked-border:var(--color-blue-700)]/90',
|
||||||
|
indigo:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-indigo-500)] [--checkbox-checked-border:var(--color-indigo-600)]/90',
|
||||||
|
violet:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-violet-500)] [--checkbox-checked-border:var(--color-violet-600)]/90',
|
||||||
|
purple:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-purple-500)] [--checkbox-checked-border:var(--color-purple-600)]/90',
|
||||||
|
fuchsia:
|
||||||
|
'[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-fuchsia-500)] [--checkbox-checked-border:var(--color-fuchsia-600)]/90',
|
||||||
|
pink: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-pink-500)] [--checkbox-checked-border:var(--color-pink-600)]/90',
|
||||||
|
rose: '[--checkbox-check:var(--color-white)] [--checkbox-checked-bg:var(--color-rose-500)] [--checkbox-checked-border:var(--color-rose-600)]/90',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Checkbox({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
color?: Color
|
||||||
|
className?: string
|
||||||
|
} & Omit<Headless.CheckboxProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Checkbox
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'group inline-flex focus:outline-hidden')}
|
||||||
|
>
|
||||||
|
<span className={clsx([base, colors[color]])}>
|
||||||
|
<svg
|
||||||
|
className="size-4 stroke-(--checkbox-check) opacity-0 group-data-checked:opacity-100 sm:h-3.5 sm:w-3.5"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{/* Checkmark icon */}
|
||||||
|
<path
|
||||||
|
className="opacity-100 group-data-indeterminate:opacity-0"
|
||||||
|
d="M3 8L6 11L11 3.5"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{/* Indeterminate icon */}
|
||||||
|
<path
|
||||||
|
className="opacity-0 group-data-indeterminate:opacity-100"
|
||||||
|
d="M3 7H11"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Headless.Checkbox>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
frontend/src/components/catalyst/dialog.tsx
Normal file
86
frontend/src/components/catalyst/dialog.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { Text } from './text'
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
xs: 'sm:max-w-xs',
|
||||||
|
sm: 'sm:max-w-sm',
|
||||||
|
md: 'sm:max-w-md',
|
||||||
|
lg: 'sm:max-w-lg',
|
||||||
|
xl: 'sm:max-w-xl',
|
||||||
|
'2xl': 'sm:max-w-2xl',
|
||||||
|
'3xl': 'sm:max-w-3xl',
|
||||||
|
'4xl': 'sm:max-w-4xl',
|
||||||
|
'5xl': 'sm:max-w-5xl',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({
|
||||||
|
size = 'lg',
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { size?: keyof typeof sizes; className?: string; children: React.ReactNode } & Omit<
|
||||||
|
Headless.DialogProps,
|
||||||
|
'as' | 'className'
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog {...props}>
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 flex w-screen justify-center overflow-y-auto bg-zinc-950/25 px-2 py-2 transition duration-100 focus:outline-0 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:px-6 sm:py-8 lg:px-8 lg:py-16 dark:bg-zinc-950/50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 w-screen overflow-y-auto pt-6 sm:pt-0">
|
||||||
|
<div className="grid min-h-full grid-rows-[1fr_auto] justify-items-center sm:grid-rows-[1fr_auto_3fr] sm:p-4">
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
sizes[size],
|
||||||
|
'row-start-2 w-full min-w-0 rounded-t-3xl bg-white p-(--gutter) shadow-lg ring-1 ring-zinc-950/10 [--gutter:--spacing(8)] sm:mb-auto sm:rounded-2xl dark:bg-zinc-900 dark:ring-white/10 forced-colors:outline',
|
||||||
|
'transition duration-100 will-change-transform data-closed:translate-y-12 data-closed:opacity-0 data-enter:ease-out data-leave:ease-in sm:data-closed:translate-y-0 sm:data-closed:data-enter:scale-95'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DialogTitleProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.DialogTitle
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-lg/6 font-semibold text-balance text-zinc-950 sm:text-base/6 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps<typeof Text>, 'as' | 'className'>) {
|
||||||
|
return <Headless.Description as={Text} {...props} className={clsx(className, 'mt-2 text-pretty')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div {...props} className={clsx(className, 'mt-6')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogActions({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'mt-8 flex flex-col-reverse items-center justify-end gap-3 *:w-full sm:flex-row sm:*:w-auto'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
frontend/src/components/catalyst/fieldset.tsx
Normal file
91
frontend/src/components/catalyst/fieldset.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function Fieldset({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldsetProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Fieldset
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, '*:data-[slot=text]:mt-1 [&>*+[data-slot=control]]:mt-6')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Legend({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.LegendProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Legend
|
||||||
|
data-slot="legend"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-base/6 font-semibold text-zinc-950 data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div data-slot="control" {...props} className={clsx(className, 'space-y-8')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Field({ className, ...props }: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'[&>[data-slot=label]+[data-slot=control]]:mt-3',
|
||||||
|
'[&>[data-slot=label]+[data-slot=description]]:mt-1',
|
||||||
|
'[&>[data-slot=description]+[data-slot=control]]:mt-3',
|
||||||
|
'[&>[data-slot=control]+[data-slot=description]]:mt-3',
|
||||||
|
'[&>[data-slot=control]+[data-slot=error]]:mt-3',
|
||||||
|
'*:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Label({ className, ...props }: { className?: string } & Omit<Headless.LabelProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Label
|
||||||
|
data-slot="label"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-base/6 text-zinc-950 select-none data-disabled:opacity-50 sm:text-sm/6 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Description({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="description"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-zinc-500 data-disabled:opacity-50 sm:text-sm/6 dark:text-zinc-400')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorMessage({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.DescriptionProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Description
|
||||||
|
data-slot="error"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-red-600 data-disabled:opacity-50 sm:text-sm/6 dark:text-red-500')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
28
frontend/src/components/catalyst/heading.tsx
Normal file
28
frontend/src/components/catalyst/heading.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
type HeadingProps = { level?: 1 | 2 | 3 | 4 | 5 | 6 } & React.ComponentPropsWithoutRef<
|
||||||
|
'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||||
|
>
|
||||||
|
|
||||||
|
export function Heading({ className, level = 1, ...props }: HeadingProps) {
|
||||||
|
let Element: `h${typeof level}` = `h${level}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-2xl/8 font-semibold text-zinc-950 sm:text-xl/8 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Subheading({ className, level = 2, ...props }: HeadingProps) {
|
||||||
|
let Element: `h${typeof level}` = `h${level}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Element
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/7 font-semibold text-zinc-950 sm:text-sm/6 dark:text-white')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
frontend/src/components/catalyst/input.tsx
Normal file
92
frontend/src/components/catalyst/input.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export function InputGroup({ children }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx(
|
||||||
|
'relative isolate block',
|
||||||
|
'has-[[data-slot=icon]:first-child]:[&_input]:pl-10 has-[[data-slot=icon]:last-child]:[&_input]:pr-10 sm:has-[[data-slot=icon]:first-child]:[&_input]:pl-8 sm:has-[[data-slot=icon]:last-child]:[&_input]:pr-8',
|
||||||
|
'*:data-[slot=icon]:pointer-events-none *:data-[slot=icon]:absolute *:data-[slot=icon]:top-3 *:data-[slot=icon]:z-10 *:data-[slot=icon]:size-5 sm:*:data-[slot=icon]:top-2.5 sm:*:data-[slot=icon]:size-4',
|
||||||
|
'[&>[data-slot=icon]:first-child]:left-3 sm:[&>[data-slot=icon]:first-child]:left-2.5 [&>[data-slot=icon]:last-child]:right-3 sm:[&>[data-slot=icon]:last-child]:right-2.5',
|
||||||
|
'*:data-[slot=icon]:text-zinc-500 dark:*:data-[slot=icon]:text-zinc-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateTypes = ['date', 'datetime-local', 'month', 'time', 'week']
|
||||||
|
type DateType = (typeof dateTypes)[number]
|
||||||
|
|
||||||
|
export const Input = forwardRef(function Input(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
className?: string
|
||||||
|
type?: 'email' | 'number' | 'password' | 'search' | 'tel' | 'text' | 'url' | DateType
|
||||||
|
} & Omit<Headless.InputProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLInputElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Input
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Date classes
|
||||||
|
props.type &&
|
||||||
|
dateTypes.includes(props.type) && [
|
||||||
|
'[&::-webkit-datetime-edit-fields-wrapper]:p-0',
|
||||||
|
'[&::-webkit-date-and-time-value]:min-h-[1.5em]',
|
||||||
|
'[&::-webkit-datetime-edit]:inline-flex',
|
||||||
|
'[&::-webkit-datetime-edit]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-year-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-month-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-day-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-hour-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-minute-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-second-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-millisecond-field]:p-0',
|
||||||
|
'[&::-webkit-datetime-edit-meridiem-field]:p-0',
|
||||||
|
],
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
// System icons
|
||||||
|
'dark:scheme-dark',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
18
frontend/src/components/catalyst/link.tsx
Normal file
18
frontend/src/components/catalyst/link.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Updated to use react-router-dom
|
||||||
|
*/
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import { Link as RouterLink, LinkProps as RouterLinkProps } from 'react-router-dom'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Link = forwardRef(function Link(
|
||||||
|
props: { href: string } & Omit<RouterLinkProps, 'to'>,
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement>
|
||||||
|
) {
|
||||||
|
const { href, ...rest } = props
|
||||||
|
return (
|
||||||
|
<Headless.DataInteractive>
|
||||||
|
<RouterLink to={href} {...rest} ref={ref} />
|
||||||
|
</Headless.DataInteractive>
|
||||||
|
)
|
||||||
|
})
|
||||||
96
frontend/src/components/catalyst/navbar.tsx
Normal file
96
frontend/src/components/catalyst/navbar.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { LayoutGroup, motion } from 'motion/react'
|
||||||
|
import React, { forwardRef, useId } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Navbar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav {...props} className={clsx(className, 'flex flex-1 items-center gap-4 py-2.5')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, 'h-6 w-px bg-zinc-950/10 dark:bg-white/10')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
let id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutGroup id={id}>
|
||||||
|
<div {...props} className={clsx(className, 'flex items-center gap-3')} />
|
||||||
|
</LayoutGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavbarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, '-ml-4 flex-1')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavbarItem = forwardRef(function NavbarItem(
|
||||||
|
{
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { current?: boolean; className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<React.ComponentPropsWithoutRef<typeof Link>, 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
// Base
|
||||||
|
'relative flex min-w-0 items-center gap-3 rounded-lg p-2 text-left text-base/6 font-medium text-zinc-950 sm:text-sm/5',
|
||||||
|
// Leading icon/icon-only
|
||||||
|
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
|
||||||
|
// Trailing icon (down chevron or similar)
|
||||||
|
'*:not-nth-2:last:data-[slot=icon]:ml-auto *:not-nth-2:last:data-[slot=icon]:size-5 sm:*:not-nth-2:last:data-[slot=icon]:size-4',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 *:data-[slot=avatar]:[--avatar-radius:var(--radius-md)] sm:*:data-[slot=avatar]:size-6',
|
||||||
|
// Hover
|
||||||
|
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Active
|
||||||
|
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
|
||||||
|
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'relative')}>
|
||||||
|
{current && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="current-indicator"
|
||||||
|
className="absolute inset-x-2 -bottom-2.5 h-0.5 rounded-full bg-zinc-950 dark:bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof props.href === 'string' ? (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref as React.ForwardedRef<HTMLAnchorElement>}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Headless.Button
|
||||||
|
{...props}
|
||||||
|
className={clsx('cursor-default', classes)}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function NavbarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'truncate')} />
|
||||||
|
}
|
||||||
142
frontend/src/components/catalyst/radio.tsx
Normal file
142
frontend/src/components/catalyst/radio.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.RadioGroupProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.RadioGroup
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3 **:data-[slot=label]:font-normal',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadioField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1.125rem_1fr] gap-x-4 gap-y-1 sm:grid-cols-[1rem_1fr]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-1 *:data-[slot=control]:row-start-1 *:data-[slot=control]:mt-0.75 sm:*:data-[slot=control]:mt-1',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-2 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-2 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = [
|
||||||
|
// Basic layout
|
||||||
|
'relative isolate flex size-4.75 shrink-0 rounded-full sm:size-4.25',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-0 before:-z-10 before:rounded-full before:bg-white before:shadow-sm',
|
||||||
|
// Background color when checked
|
||||||
|
'group-data-checked:before:bg-(--radio-checked-bg)',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Background color applied to control in dark mode
|
||||||
|
'dark:bg-white/5 dark:group-data-checked:bg-(--radio-checked-bg)',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/15 group-data-checked:border-transparent group-data-hover:group-data-checked:border-transparent group-data-hover:border-zinc-950/30 group-data-checked:bg-(--radio-checked-border)',
|
||||||
|
'dark:border-white/15 dark:group-data-checked:border-white/5 dark:group-data-hover:group-data-checked:border-white/5 dark:group-data-hover:border-white/30',
|
||||||
|
// Inner highlight shadow
|
||||||
|
'after:absolute after:inset-0 after:rounded-full after:shadow-[inset_0_1px_--theme(--color-white/15%)]',
|
||||||
|
'dark:after:-inset-px dark:after:hidden dark:after:rounded-full dark:group-data-checked:after:block',
|
||||||
|
// Indicator color (light mode)
|
||||||
|
'[--radio-indicator:transparent] group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] group-data-hover:[--radio-indicator:var(--color-zinc-900)]/10',
|
||||||
|
// Indicator color (dark mode)
|
||||||
|
'dark:group-data-hover:group-data-checked:[--radio-indicator:var(--radio-checked-indicator)] dark:group-data-hover:[--radio-indicator:var(--color-zinc-700)]',
|
||||||
|
// Focus ring
|
||||||
|
'group-data-focus:outline group-data-focus:outline-2 group-data-focus:outline-offset-2 group-data-focus:outline-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'group-data-disabled:opacity-50',
|
||||||
|
'group-data-disabled:border-zinc-950/25 group-data-disabled:bg-zinc-950/5 group-data-disabled:[--radio-checked-indicator:var(--color-zinc-950)]/50 group-data-disabled:before:bg-transparent',
|
||||||
|
'dark:group-data-disabled:border-white/20 dark:group-data-disabled:bg-white/2.5 dark:group-data-disabled:[--radio-checked-indicator:var(--color-white)]/50 dark:group-data-checked:group-data-disabled:after:hidden',
|
||||||
|
]
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
'dark:[--radio-checked-bg:var(--color-zinc-600)]',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
'dark:[--radio-checked-bg:var(--color-white)] dark:[--radio-checked-border:var(--color-zinc-950)]/15 dark:[--radio-checked-indicator:var(--color-zinc-900)]',
|
||||||
|
],
|
||||||
|
white:
|
||||||
|
'[--radio-checked-bg:var(--color-white)] [--radio-checked-border:var(--color-zinc-950)]/15 [--radio-checked-indicator:var(--color-zinc-900)]',
|
||||||
|
dark: '[--radio-checked-bg:var(--color-zinc-900)] [--radio-checked-border:var(--color-zinc-950)]/90 [--radio-checked-indicator:var(--color-white)]',
|
||||||
|
zinc: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-zinc-600)] [--radio-checked-border:var(--color-zinc-700)]/90',
|
||||||
|
red: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-red-600)] [--radio-checked-border:var(--color-red-700)]/90',
|
||||||
|
orange:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-orange-500)] [--radio-checked-border:var(--color-orange-600)]/90',
|
||||||
|
amber:
|
||||||
|
'[--radio-checked-bg:var(--color-amber-400)] [--radio-checked-border:var(--color-amber-500)]/80 [--radio-checked-indicator:var(--color-amber-950)]',
|
||||||
|
yellow:
|
||||||
|
'[--radio-checked-bg:var(--color-yellow-300)] [--radio-checked-border:var(--color-yellow-400)]/80 [--radio-checked-indicator:var(--color-yellow-950)]',
|
||||||
|
lime: '[--radio-checked-bg:var(--color-lime-300)] [--radio-checked-border:var(--color-lime-400)]/80 [--radio-checked-indicator:var(--color-lime-950)]',
|
||||||
|
green:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-green-600)] [--radio-checked-border:var(--color-green-700)]/90',
|
||||||
|
emerald:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-emerald-600)] [--radio-checked-border:var(--color-emerald-700)]/90',
|
||||||
|
teal: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-teal-600)] [--radio-checked-border:var(--color-teal-700)]/90',
|
||||||
|
cyan: '[--radio-checked-bg:var(--color-cyan-300)] [--radio-checked-border:var(--color-cyan-400)]/80 [--radio-checked-indicator:var(--color-cyan-950)]',
|
||||||
|
sky: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-sky-500)] [--radio-checked-border:var(--color-sky-600)]/80',
|
||||||
|
blue: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-blue-600)] [--radio-checked-border:var(--color-blue-700)]/90',
|
||||||
|
indigo:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-indigo-500)] [--radio-checked-border:var(--color-indigo-600)]/90',
|
||||||
|
violet:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-violet-500)] [--radio-checked-border:var(--color-violet-600)]/90',
|
||||||
|
purple:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-purple-500)] [--radio-checked-border:var(--color-purple-600)]/90',
|
||||||
|
fuchsia:
|
||||||
|
'[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-fuchsia-500)] [--radio-checked-border:var(--color-fuchsia-600)]/90',
|
||||||
|
pink: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-pink-500)] [--radio-checked-border:var(--color-pink-600)]/90',
|
||||||
|
rose: '[--radio-checked-indicator:var(--color-white)] [--radio-checked-bg:var(--color-rose-500)] [--radio-checked-border:var(--color-rose-600)]/90',
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Radio({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { color?: Color; className?: string } & Omit<Headless.RadioProps, 'as' | 'className' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Radio
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'group inline-flex focus:outline-hidden')}
|
||||||
|
>
|
||||||
|
<span className={clsx([base, colors[color]])}>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
'size-full rounded-full border-[4.5px] border-transparent bg-(--radio-indicator) bg-clip-padding',
|
||||||
|
// Forced colors mode
|
||||||
|
'forced-colors:border-[Canvas] forced-colors:group-data-checked:border-[Highlight]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Headless.Radio>
|
||||||
|
)
|
||||||
|
}
|
||||||
68
frontend/src/components/catalyst/select.tsx
Normal file
68
frontend/src/components/catalyst/select.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Select = forwardRef(function Select(
|
||||||
|
{ className, multiple, ...props }: { className?: string } & Omit<Headless.SelectProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLSelectElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'group relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset has-data-focus:after:ring-2 has-data-focus:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Select
|
||||||
|
ref={ref}
|
||||||
|
multiple={multiple}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full appearance-none rounded-lg py-[calc(--spacing(2.5)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Horizontal padding
|
||||||
|
multiple
|
||||||
|
? 'px-[calc(--spacing(3.5)-1px)] sm:px-[calc(--spacing(3)-1px)]'
|
||||||
|
: 'pr-[calc(--spacing(10)-1px)] pl-[calc(--spacing(3.5)-1px)] sm:pr-[calc(--spacing(9)-1px)] sm:pl-[calc(--spacing(3)-1px)]',
|
||||||
|
// Options (multi-select)
|
||||||
|
'[&_optgroup]:font-semibold',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white dark:*:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5 dark:*:bg-zinc-800',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'data-disabled:border-zinc-950/20 data-disabled:opacity-100 dark:data-disabled:border-white/15 dark:data-disabled:bg-white/2.5 dark:data-hover:data-disabled:border-white/15',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
{!multiple && (
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<svg
|
||||||
|
className="size-5 stroke-zinc-500 group-has-data-disabled:stroke-zinc-600 sm:size-4 dark:stroke-zinc-400 forced-colors:stroke-[CanvasText]"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M5.75 10.75L8 13L10.25 10.75" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M10.25 5.25L8 3L5.75 5.25" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
82
frontend/src/components/catalyst/sidebar-layout.tsx
Normal file
82
frontend/src/components/catalyst/sidebar-layout.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { NavbarItem } from './navbar'
|
||||||
|
|
||||||
|
function OpenMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M2 6.75C2 6.33579 2.33579 6 2.75 6H17.25C17.6642 6 18 6.33579 18 6.75C18 7.16421 17.6642 7.5 17.25 7.5H2.75C2.33579 7.5 2 7.16421 2 6.75ZM2 13.25C2 12.8358 2.33579 12.5 2.75 12.5H17.25C17.6642 12.5 18 12.8358 18 13.25C18 13.6642 17.6642 14 17.25 14H2.75C2.33579 14 2 13.6642 2 13.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseMenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg data-slot="icon" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileSidebar({ open, close, children }: React.PropsWithChildren<{ open: boolean; close: () => void }>) {
|
||||||
|
return (
|
||||||
|
<Headless.Dialog open={open} onClose={close} className="lg:hidden">
|
||||||
|
<Headless.DialogBackdrop
|
||||||
|
transition
|
||||||
|
className="fixed inset-0 bg-black/30 transition data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in"
|
||||||
|
/>
|
||||||
|
<Headless.DialogPanel
|
||||||
|
transition
|
||||||
|
className="fixed inset-y-0 w-full max-w-80 p-2 transition duration-300 ease-in-out data-closed:-translate-x-full"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col rounded-lg bg-white shadow-xs ring-1 ring-zinc-950/5 dark:bg-zinc-900 dark:ring-white/10">
|
||||||
|
<div className="-mb-3 px-4 pt-3">
|
||||||
|
<Headless.CloseButton as={NavbarItem} aria-label="Close navigation">
|
||||||
|
<CloseMenuIcon />
|
||||||
|
</Headless.CloseButton>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Headless.DialogPanel>
|
||||||
|
</Headless.Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarLayout({
|
||||||
|
navbar,
|
||||||
|
sidebar,
|
||||||
|
children,
|
||||||
|
}: React.PropsWithChildren<{ navbar: React.ReactNode; sidebar: React.ReactNode }>) {
|
||||||
|
let [showSidebar, setShowSidebar] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate flex min-h-svh w-full bg-white max-lg:flex-col lg:bg-zinc-100 dark:bg-zinc-900 dark:lg:bg-zinc-950">
|
||||||
|
{/* Sidebar on desktop */}
|
||||||
|
<div className="fixed inset-y-0 left-0 w-64 max-lg:hidden">{sidebar}</div>
|
||||||
|
|
||||||
|
{/* Sidebar on mobile */}
|
||||||
|
<MobileSidebar open={showSidebar} close={() => setShowSidebar(false)}>
|
||||||
|
{sidebar}
|
||||||
|
</MobileSidebar>
|
||||||
|
|
||||||
|
{/* Navbar on mobile */}
|
||||||
|
<header className="flex items-center px-4 lg:hidden">
|
||||||
|
<div className="py-2.5">
|
||||||
|
<NavbarItem onClick={() => setShowSidebar(true)} aria-label="Open navigation">
|
||||||
|
<OpenMenuIcon />
|
||||||
|
</NavbarItem>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">{navbar}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex flex-1 flex-col pb-2 lg:min-w-0 lg:pt-2 lg:pr-2 lg:pl-64">
|
||||||
|
<div className="grow p-6 lg:rounded-lg lg:bg-white lg:p-10 lg:shadow-xs lg:ring-1 lg:ring-zinc-950/5 dark:lg:bg-zinc-900 dark:lg:ring-white/10">
|
||||||
|
<div className="mx-auto max-w-6xl">{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
frontend/src/components/catalyst/sidebar.tsx
Normal file
142
frontend/src/components/catalyst/sidebar.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { LayoutGroup, motion } from 'motion/react'
|
||||||
|
import React, { forwardRef, useId } from 'react'
|
||||||
|
import { TouchTarget } from './button'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Sidebar({ className, ...props }: React.ComponentPropsWithoutRef<'nav'>) {
|
||||||
|
return <nav {...props} className={clsx(className, 'flex h-full min-h-0 flex-col')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarHeader({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-col border-b border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarBody({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-1 flex-col overflow-y-auto p-4 [&>[data-slot=section]+[data-slot=section]]:mt-8'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarFooter({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'flex flex-col border-t border-zinc-950/5 p-4 dark:border-white/5 [&>[data-slot=section]+[data-slot=section]]:mt-2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSection({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
let id = useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutGroup id={id}>
|
||||||
|
<div {...props} data-slot="section" className={clsx(className, 'flex flex-col gap-0.5')} />
|
||||||
|
</LayoutGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarDivider({ className, ...props }: React.ComponentPropsWithoutRef<'hr'>) {
|
||||||
|
return <hr {...props} className={clsx(className, 'my-4 border-t border-zinc-950/5 lg:-mx-4 dark:border-white/5')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarSpacer({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return <div aria-hidden="true" {...props} className={clsx(className, 'mt-8 flex-1')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarHeading({ className, ...props }: React.ComponentPropsWithoutRef<'h3'>) {
|
||||||
|
return (
|
||||||
|
<h3 {...props} className={clsx(className, 'mb-1 px-2 text-xs/6 font-medium text-zinc-500 dark:text-zinc-400')} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SidebarItem = forwardRef(function SidebarItem(
|
||||||
|
{
|
||||||
|
current,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { current?: boolean; className?: string; children: React.ReactNode } & (
|
||||||
|
| ({ href?: never } & Omit<Headless.ButtonProps, 'as' | 'className'>)
|
||||||
|
| ({ href: string } & Omit<Headless.ButtonProps<typeof Link>, 'as' | 'className'>)
|
||||||
|
),
|
||||||
|
ref: React.ForwardedRef<HTMLAnchorElement | HTMLButtonElement>
|
||||||
|
) {
|
||||||
|
let classes = clsx(
|
||||||
|
// Base
|
||||||
|
'flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base/6 font-medium text-zinc-950 sm:py-2 sm:text-sm/5',
|
||||||
|
// Leading icon/icon-only
|
||||||
|
'*:data-[slot=icon]:size-6 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:fill-zinc-500 sm:*:data-[slot=icon]:size-5',
|
||||||
|
// Trailing icon (down chevron or similar)
|
||||||
|
'*:last:data-[slot=icon]:ml-auto *:last:data-[slot=icon]:size-5 sm:*:last:data-[slot=icon]:size-4',
|
||||||
|
// Avatar
|
||||||
|
'*:data-[slot=avatar]:-m-0.5 *:data-[slot=avatar]:size-7 sm:*:data-[slot=avatar]:size-6',
|
||||||
|
// Hover
|
||||||
|
'data-hover:bg-zinc-950/5 data-hover:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Active
|
||||||
|
'data-active:bg-zinc-950/5 data-active:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Current
|
||||||
|
'data-current:*:data-[slot=icon]:fill-zinc-950',
|
||||||
|
// Dark mode
|
||||||
|
'dark:text-white dark:*:data-[slot=icon]:fill-zinc-400',
|
||||||
|
'dark:data-hover:bg-white/5 dark:data-hover:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-active:bg-white/5 dark:data-active:*:data-[slot=icon]:fill-white',
|
||||||
|
'dark:data-current:*:data-[slot=icon]:fill-white'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={clsx(className, 'relative')}>
|
||||||
|
{current && (
|
||||||
|
<motion.span
|
||||||
|
layoutId="current-indicator"
|
||||||
|
className="absolute inset-y-2 -left-4 w-0.5 rounded-full bg-zinc-950 dark:bg-white"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{typeof props.href === 'string' ? (
|
||||||
|
<Headless.CloseButton
|
||||||
|
as={Link}
|
||||||
|
{...props}
|
||||||
|
className={classes}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.CloseButton>
|
||||||
|
) : (
|
||||||
|
<Headless.Button
|
||||||
|
{...props}
|
||||||
|
className={clsx('cursor-default', classes)}
|
||||||
|
data-current={current ? 'true' : undefined}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<TouchTarget>{children}</TouchTarget>
|
||||||
|
</Headless.Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function SidebarLabel({ className, ...props }: React.ComponentPropsWithoutRef<'span'>) {
|
||||||
|
return <span {...props} className={clsx(className, 'truncate')} />
|
||||||
|
}
|
||||||
195
frontend/src/components/catalyst/switch.tsx
Normal file
195
frontend/src/components/catalyst/switch.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
|
||||||
|
export function SwitchGroup({ className, ...props }: React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Basic groups
|
||||||
|
'space-y-3 **:data-[slot=label]:font-normal',
|
||||||
|
// With descriptions
|
||||||
|
'has-data-[slot=description]:space-y-6 has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SwitchField({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { className?: string } & Omit<Headless.FieldProps, 'as' | 'className'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Field
|
||||||
|
data-slot="field"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base layout
|
||||||
|
'grid grid-cols-[1fr_auto] gap-x-8 gap-y-1 sm:grid-cols-[1fr_auto]',
|
||||||
|
// Control layout
|
||||||
|
'*:data-[slot=control]:col-start-2 *:data-[slot=control]:self-start sm:*:data-[slot=control]:mt-0.5',
|
||||||
|
// Label layout
|
||||||
|
'*:data-[slot=label]:col-start-1 *:data-[slot=label]:row-start-1',
|
||||||
|
// Description layout
|
||||||
|
'*:data-[slot=description]:col-start-1 *:data-[slot=description]:row-start-2',
|
||||||
|
// With description
|
||||||
|
'has-data-[slot=description]:**:data-[slot=label]:font-medium'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
'dark/zinc': [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]/25',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:var(--color-zinc-700)]/90',
|
||||||
|
],
|
||||||
|
'dark/white': [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:transparent] dark:[--switch-bg:var(--color-white)]',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white] dark:[--switch-ring:transparent] dark:[--switch:var(--color-zinc-900)]',
|
||||||
|
],
|
||||||
|
dark: [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-950)]/90 [--switch-bg:var(--color-zinc-900)] dark:[--switch-bg-ring:var(--color-white)]/15',
|
||||||
|
'[--switch-ring:var(--color-zinc-950)]/90 [--switch-shadow:var(--color-black)]/10 [--switch:white]',
|
||||||
|
],
|
||||||
|
zinc: [
|
||||||
|
'[--switch-bg-ring:var(--color-zinc-700)]/90 [--switch-bg:var(--color-zinc-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-shadow:var(--color-black)]/10 [--switch:white] [--switch-ring:var(--color-zinc-700)]/90',
|
||||||
|
],
|
||||||
|
white: [
|
||||||
|
'[--switch-bg-ring:var(--color-black)]/15 [--switch-bg:white] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-shadow:var(--color-black)]/10 [--switch-ring:transparent] [--switch:var(--color-zinc-950)]',
|
||||||
|
],
|
||||||
|
red: [
|
||||||
|
'[--switch-bg-ring:var(--color-red-700)]/90 [--switch-bg:var(--color-red-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-red-700)]/90 [--switch-shadow:var(--color-red-900)]/20',
|
||||||
|
],
|
||||||
|
orange: [
|
||||||
|
'[--switch-bg-ring:var(--color-orange-600)]/90 [--switch-bg:var(--color-orange-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-orange-600)]/90 [--switch-shadow:var(--color-orange-900)]/20',
|
||||||
|
],
|
||||||
|
amber: [
|
||||||
|
'[--switch-bg-ring:var(--color-amber-500)]/80 [--switch-bg:var(--color-amber-400)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-amber-950)]',
|
||||||
|
],
|
||||||
|
yellow: [
|
||||||
|
'[--switch-bg-ring:var(--color-yellow-400)]/80 [--switch-bg:var(--color-yellow-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-yellow-950)]',
|
||||||
|
],
|
||||||
|
lime: [
|
||||||
|
'[--switch-bg-ring:var(--color-lime-400)]/80 [--switch-bg:var(--color-lime-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-lime-950)]',
|
||||||
|
],
|
||||||
|
green: [
|
||||||
|
'[--switch-bg-ring:var(--color-green-700)]/90 [--switch-bg:var(--color-green-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-green-700)]/90 [--switch-shadow:var(--color-green-900)]/20',
|
||||||
|
],
|
||||||
|
emerald: [
|
||||||
|
'[--switch-bg-ring:var(--color-emerald-600)]/90 [--switch-bg:var(--color-emerald-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-emerald-600)]/90 [--switch-shadow:var(--color-emerald-900)]/20',
|
||||||
|
],
|
||||||
|
teal: [
|
||||||
|
'[--switch-bg-ring:var(--color-teal-700)]/90 [--switch-bg:var(--color-teal-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-teal-700)]/90 [--switch-shadow:var(--color-teal-900)]/20',
|
||||||
|
],
|
||||||
|
cyan: [
|
||||||
|
'[--switch-bg-ring:var(--color-cyan-400)]/80 [--switch-bg:var(--color-cyan-300)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch-ring:transparent] [--switch-shadow:transparent] [--switch:var(--color-cyan-950)]',
|
||||||
|
],
|
||||||
|
sky: [
|
||||||
|
'[--switch-bg-ring:var(--color-sky-600)]/80 [--switch-bg:var(--color-sky-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-sky-600)]/80 [--switch-shadow:var(--color-sky-900)]/20',
|
||||||
|
],
|
||||||
|
blue: [
|
||||||
|
'[--switch-bg-ring:var(--color-blue-700)]/90 [--switch-bg:var(--color-blue-600)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-blue-700)]/90 [--switch-shadow:var(--color-blue-900)]/20',
|
||||||
|
],
|
||||||
|
indigo: [
|
||||||
|
'[--switch-bg-ring:var(--color-indigo-600)]/90 [--switch-bg:var(--color-indigo-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-indigo-600)]/90 [--switch-shadow:var(--color-indigo-900)]/20',
|
||||||
|
],
|
||||||
|
violet: [
|
||||||
|
'[--switch-bg-ring:var(--color-violet-600)]/90 [--switch-bg:var(--color-violet-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-violet-600)]/90 [--switch-shadow:var(--color-violet-900)]/20',
|
||||||
|
],
|
||||||
|
purple: [
|
||||||
|
'[--switch-bg-ring:var(--color-purple-600)]/90 [--switch-bg:var(--color-purple-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-purple-600)]/90 [--switch-shadow:var(--color-purple-900)]/20',
|
||||||
|
],
|
||||||
|
fuchsia: [
|
||||||
|
'[--switch-bg-ring:var(--color-fuchsia-600)]/90 [--switch-bg:var(--color-fuchsia-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-fuchsia-600)]/90 [--switch-shadow:var(--color-fuchsia-900)]/20',
|
||||||
|
],
|
||||||
|
pink: [
|
||||||
|
'[--switch-bg-ring:var(--color-pink-600)]/90 [--switch-bg:var(--color-pink-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-pink-600)]/90 [--switch-shadow:var(--color-pink-900)]/20',
|
||||||
|
],
|
||||||
|
rose: [
|
||||||
|
'[--switch-bg-ring:var(--color-rose-600)]/90 [--switch-bg:var(--color-rose-500)] dark:[--switch-bg-ring:transparent]',
|
||||||
|
'[--switch:white] [--switch-ring:var(--color-rose-600)]/90 [--switch-shadow:var(--color-rose-900)]/20',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = keyof typeof colors
|
||||||
|
|
||||||
|
export function Switch({
|
||||||
|
color = 'dark/zinc',
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
color?: Color
|
||||||
|
className?: string
|
||||||
|
} & Omit<Headless.SwitchProps, 'as' | 'className' | 'children'>) {
|
||||||
|
return (
|
||||||
|
<Headless.Switch
|
||||||
|
data-slot="control"
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
// Base styles
|
||||||
|
'group relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8',
|
||||||
|
// Transitions
|
||||||
|
'transition duration-0 ease-in-out data-changing:duration-200',
|
||||||
|
// Outline and background color in forced-colors mode so switch is still visible
|
||||||
|
'forced-colors:outline forced-colors:[--switch-bg:Highlight] dark:forced-colors:[--switch-bg:Highlight]',
|
||||||
|
// Unchecked
|
||||||
|
'bg-zinc-200 ring-1 ring-black/5 ring-inset dark:bg-white/5 dark:ring-white/15',
|
||||||
|
// Checked
|
||||||
|
'data-checked:bg-(--switch-bg) data-checked:ring-(--switch-bg-ring) dark:data-checked:bg-(--switch-bg) dark:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
// Focus
|
||||||
|
'focus:not-data-focus:outline-hidden data-focus:outline-2 data-focus:outline-offset-2 data-focus:outline-blue-500',
|
||||||
|
// Hover
|
||||||
|
'data-hover:ring-black/15 data-hover:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
'dark:data-hover:ring-white/25 dark:data-hover:data-checked:ring-(--switch-bg-ring)',
|
||||||
|
// Disabled
|
||||||
|
'data-disabled:bg-zinc-200 data-disabled:opacity-50 data-disabled:data-checked:bg-zinc-200 data-disabled:data-checked:ring-black/5',
|
||||||
|
'dark:data-disabled:bg-white/15 dark:data-disabled:data-checked:bg-white/15 dark:data-disabled:data-checked:ring-white/15',
|
||||||
|
// Color specific styles
|
||||||
|
colors[color]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={clsx(
|
||||||
|
// Basic layout
|
||||||
|
'pointer-events-none relative inline-block size-4.5 rounded-full sm:size-3.5',
|
||||||
|
// Transition
|
||||||
|
'translate-x-0 transition duration-200 ease-in-out',
|
||||||
|
// Invisible border so the switch is still visible in forced-colors mode
|
||||||
|
'border border-transparent',
|
||||||
|
// Unchecked
|
||||||
|
'bg-white shadow-sm ring-1 ring-black/5',
|
||||||
|
// Checked
|
||||||
|
'group-data-checked:bg-(--switch) group-data-checked:shadow-(--switch-shadow) group-data-checked:ring-(--switch-ring)',
|
||||||
|
'group-data-checked:translate-x-4 sm:group-data-checked:translate-x-3',
|
||||||
|
// Disabled
|
||||||
|
'group-data-checked:group-data-disabled:bg-white group-data-checked:group-data-disabled:shadow-sm group-data-checked:group-data-disabled:ring-black/5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Headless.Switch>
|
||||||
|
)
|
||||||
|
}
|
||||||
124
frontend/src/components/catalyst/table.tsx
Normal file
124
frontend/src/components/catalyst/table.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import type React from 'react'
|
||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
const TableContext = createContext<{ bleed: boolean; dense: boolean; grid: boolean; striped: boolean }>({
|
||||||
|
bleed: false,
|
||||||
|
dense: false,
|
||||||
|
grid: false,
|
||||||
|
striped: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function Table({
|
||||||
|
bleed = false,
|
||||||
|
dense = false,
|
||||||
|
grid = false,
|
||||||
|
striped = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: { bleed?: boolean; dense?: boolean; grid?: boolean; striped?: boolean } & React.ComponentPropsWithoutRef<'div'>) {
|
||||||
|
return (
|
||||||
|
<TableContext.Provider value={{ bleed, dense, grid, striped } as React.ContextType<typeof TableContext>}>
|
||||||
|
<div className="flow-root">
|
||||||
|
<div {...props} className={clsx(className, '-mx-(--gutter) overflow-x-auto whitespace-nowrap')}>
|
||||||
|
<div className={clsx('inline-block min-w-full align-middle', !bleed && 'sm:px-(--gutter)')}>
|
||||||
|
<table className="min-w-full text-left text-sm/6 text-zinc-950 dark:text-white">{children}</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHead({ className, ...props }: React.ComponentPropsWithoutRef<'thead'>) {
|
||||||
|
return <thead {...props} className={clsx(className, 'text-zinc-500 dark:text-zinc-400')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBody(props: React.ComponentPropsWithoutRef<'tbody'>) {
|
||||||
|
return <tbody {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRowContext = createContext<{ href?: string; target?: string; title?: string }>({
|
||||||
|
href: undefined,
|
||||||
|
target: undefined,
|
||||||
|
title: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function TableRow({
|
||||||
|
href,
|
||||||
|
target,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: { href?: string; target?: string; title?: string } & React.ComponentPropsWithoutRef<'tr'>) {
|
||||||
|
let { striped } = useContext(TableContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRowContext.Provider value={{ href, target, title } as React.ContextType<typeof TableRowContext>}>
|
||||||
|
<tr
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
href &&
|
||||||
|
'has-[[data-row-link][data-focus]]:outline-2 has-[[data-row-link][data-focus]]:-outline-offset-2 has-[[data-row-link][data-focus]]:outline-blue-500 dark:focus-within:bg-white/2.5',
|
||||||
|
striped && 'even:bg-zinc-950/2.5 dark:even:bg-white/2.5',
|
||||||
|
href && striped && 'hover:bg-zinc-950/5 dark:hover:bg-white/5',
|
||||||
|
href && !striped && 'hover:bg-zinc-950/2.5 dark:hover:bg-white/2.5'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</TableRowContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHeader({ className, ...props }: React.ComponentPropsWithoutRef<'th'>) {
|
||||||
|
let { bleed, grid } = useContext(TableContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'border-b border-b-zinc-950/10 px-4 py-2 font-medium first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2)) dark:border-b-white/10',
|
||||||
|
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||||
|
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell({ className, children, ...props }: React.ComponentPropsWithoutRef<'td'>) {
|
||||||
|
let { bleed, dense, grid, striped } = useContext(TableContext)
|
||||||
|
let { href, target, title } = useContext(TableRowContext)
|
||||||
|
let [cellRef, setCellRef] = useState<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
ref={href ? setCellRef : undefined}
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'relative px-4 first:pl-(--gutter,--spacing(2)) last:pr-(--gutter,--spacing(2))',
|
||||||
|
!striped && 'border-b border-zinc-950/5 dark:border-white/5',
|
||||||
|
grid && 'border-l border-l-zinc-950/5 first:border-l-0 dark:border-l-white/5',
|
||||||
|
dense ? 'py-2.5' : 'py-4',
|
||||||
|
!bleed && 'sm:first:pl-1 sm:last:pr-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{href && (
|
||||||
|
<Link
|
||||||
|
data-row-link
|
||||||
|
href={href}
|
||||||
|
target={target}
|
||||||
|
aria-label={title}
|
||||||
|
tabIndex={cellRef?.previousElementSibling === null ? 0 : -1}
|
||||||
|
className="absolute inset-0 focus:outline-hidden"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/components/catalyst/text.tsx
Normal file
41
frontend/src/components/catalyst/text.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import clsx from 'clsx'
|
||||||
|
import React from 'react'
|
||||||
|
import { Link } from './link'
|
||||||
|
|
||||||
|
export function Text({ className, ...props }: React.ComponentPropsWithoutRef<'p'>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="text"
|
||||||
|
{...props}
|
||||||
|
className={clsx(className, 'text-base/6 text-zinc-500 sm:text-sm/6 dark:text-zinc-400')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextLink({ className, ...props }: React.ComponentPropsWithoutRef<typeof Link>) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'text-zinc-950 underline decoration-zinc-950/50 data-hover:decoration-zinc-950 dark:text-white dark:decoration-white/50 dark:data-hover:decoration-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Strong({ className, ...props }: React.ComponentPropsWithoutRef<'strong'>) {
|
||||||
|
return <strong {...props} className={clsx(className, 'font-medium text-zinc-950 dark:text-white')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Code({ className, ...props }: React.ComponentPropsWithoutRef<'code'>) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
{...props}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'rounded-sm border border-zinc-950/10 bg-zinc-950/2.5 px-0.5 text-sm font-medium text-zinc-950 sm:text-[0.8125rem] dark:border-white/20 dark:bg-white/5 dark:text-white'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
54
frontend/src/components/catalyst/textarea.tsx
Normal file
54
frontend/src/components/catalyst/textarea.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import * as Headless from '@headlessui/react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import React, { forwardRef } from 'react'
|
||||||
|
|
||||||
|
export const Textarea = forwardRef(function Textarea(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
resizable = true,
|
||||||
|
...props
|
||||||
|
}: { className?: string; resizable?: boolean } & Omit<Headless.TextareaProps, 'as' | 'className'>,
|
||||||
|
ref: React.ForwardedRef<HTMLTextAreaElement>
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="control"
|
||||||
|
className={clsx([
|
||||||
|
className,
|
||||||
|
// Basic layout
|
||||||
|
'relative block w-full',
|
||||||
|
// Background color + shadow applied to inset pseudo element, so shadow blends with border in light mode
|
||||||
|
'before:absolute before:inset-px before:rounded-[calc(var(--radius-lg)-1px)] before:bg-white before:shadow-sm',
|
||||||
|
// Background color is moved to control and shadow is removed in dark mode so hide `before` pseudo
|
||||||
|
'dark:before:hidden',
|
||||||
|
// Focus ring
|
||||||
|
'after:pointer-events-none after:absolute after:inset-0 after:rounded-lg after:ring-transparent after:ring-inset sm:focus-within:after:ring-2 sm:focus-within:after:ring-blue-500',
|
||||||
|
// Disabled state
|
||||||
|
'has-data-disabled:opacity-50 has-data-disabled:before:bg-zinc-950/5 has-data-disabled:before:shadow-none',
|
||||||
|
])}
|
||||||
|
>
|
||||||
|
<Headless.Textarea
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
className={clsx([
|
||||||
|
// Basic layout
|
||||||
|
'relative block h-full w-full appearance-none rounded-lg px-[calc(--spacing(3.5)-1px)] py-[calc(--spacing(2.5)-1px)] sm:px-[calc(--spacing(3)-1px)] sm:py-[calc(--spacing(1.5)-1px)]',
|
||||||
|
// Typography
|
||||||
|
'text-base/6 text-zinc-950 placeholder:text-zinc-500 sm:text-sm/6 dark:text-white',
|
||||||
|
// Border
|
||||||
|
'border border-zinc-950/10 data-hover:border-zinc-950/20 dark:border-white/10 dark:data-hover:border-white/20',
|
||||||
|
// Background color
|
||||||
|
'bg-transparent dark:bg-white/5',
|
||||||
|
// Hide default focus styles
|
||||||
|
'focus:outline-hidden',
|
||||||
|
// Invalid state
|
||||||
|
'data-invalid:border-red-500 data-invalid:data-hover:border-red-500 dark:data-invalid:border-red-600 dark:data-invalid:data-hover:border-red-600',
|
||||||
|
// Disabled state
|
||||||
|
'disabled:border-zinc-950/20 dark:disabled:border-white/15 dark:disabled:bg-white/2.5 dark:data-hover:disabled:border-white/15',
|
||||||
|
// Resizable
|
||||||
|
resizable ? 'resize-y' : 'resize-none',
|
||||||
|
])}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import { createContext, useState, useEffect } from 'react';
|
|
||||||
import client from '../api/client';
|
|
||||||
|
|
||||||
export const AuthContext = createContext();
|
|
||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
const [token, setToken] = useState(localStorage.getItem('token'));
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (token) {
|
|
||||||
// Fetch user profile if needed, or just decode token
|
|
||||||
// For now, we assume user is logged in if token exists
|
|
||||||
// Ideally call /users/me
|
|
||||||
setLoading(false);
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
const login = async (email, password) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('username', email);
|
|
||||||
formData.append('password', password);
|
|
||||||
|
|
||||||
const response = await client.post('/login/access-token', formData);
|
|
||||||
const { access_token } = response.data;
|
|
||||||
|
|
||||||
localStorage.setItem('token', access_token);
|
|
||||||
setToken(access_token);
|
|
||||||
// Ideally fetch user details here
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
localStorage.removeItem('token');
|
|
||||||
setToken(null);
|
|
||||||
setUser(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={{ user, token, login, logout, loading }}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
98
frontend/src/context/AuthContext.tsx
Normal file
98
frontend/src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { createContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import client from '../api/client';
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id?: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
firstname?: string;
|
||||||
|
lastname?: string;
|
||||||
|
age?: number;
|
||||||
|
gender?: string;
|
||||||
|
height?: number;
|
||||||
|
weight?: number;
|
||||||
|
unit_preference?: 'metric' | 'imperial';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (e: string, p: string) => Promise<boolean>;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (data: Partial<User>) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export const AuthContext = createContext<AuthContextType | any>(null);
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchUser = async () => {
|
||||||
|
try {
|
||||||
|
const response = await client.get('/users/me');
|
||||||
|
setUser(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch user", error);
|
||||||
|
// If fetch fails (e.g. 401), maybe logout?
|
||||||
|
// For now, keep it simple.
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
client.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||||
|
fetchUser();
|
||||||
|
} else {
|
||||||
|
delete client.defaults.headers.common['Authorization'];
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const login = async (email, password) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('username', email);
|
||||||
|
formData.append('password', password);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await client.post('/login/access-token', formData);
|
||||||
|
const { access_token } = response.data;
|
||||||
|
|
||||||
|
localStorage.setItem('token', access_token);
|
||||||
|
setToken(access_token);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login failed", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
setToken(null);
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateUser = async (userData) => {
|
||||||
|
try {
|
||||||
|
const response = await client.put('/users/me', userData);
|
||||||
|
setUser(response.data);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update profile", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, token, login, logout, updateUser, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
47
frontend/src/context/ThemeContext.tsx
Normal file
47
frontend/src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
// Check local storage or system preference
|
||||||
|
const saved = localStorage.getItem('theme');
|
||||||
|
if (saved === 'light' || saved === 'dark') {
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@@ -1,3 +1,50 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@theme {
|
||||||
|
--color-base: var(--color-bg-base);
|
||||||
|
--color-surface: var(--color-bg-surface);
|
||||||
|
--color-primary: var(--color-primary);
|
||||||
|
--color-content: var(--color-text-main);
|
||||||
|
--color-content-muted: var(--color-text-muted);
|
||||||
|
--color-border: var(--color-border);
|
||||||
|
--color-input: var(--color-bg-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Light Mode (Default) - Natural & Organic */
|
||||||
|
--color-bg-base: #FDFBF7;
|
||||||
|
/* Cream */
|
||||||
|
--color-bg-surface: #E1DBCB;
|
||||||
|
/* Oatmeal */
|
||||||
|
--color-primary: #556B2F;
|
||||||
|
/* Olive */
|
||||||
|
--color-text-main: #4B3621;
|
||||||
|
/* Sepia */
|
||||||
|
--color-text-muted: #6B5640;
|
||||||
|
--color-border: #D4CDB8;
|
||||||
|
--color-bg-input: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
/* Dark Mode - Modern Slate */
|
||||||
|
--color-bg-base: #0F172A;
|
||||||
|
/* Deep Slate */
|
||||||
|
--color-bg-surface: #1E293B;
|
||||||
|
/* Light Slate */
|
||||||
|
--color-primary: #10B981;
|
||||||
|
/* Emerald Green */
|
||||||
|
--color-text-main: #F1F5F9;
|
||||||
|
/* Off-White */
|
||||||
|
--color-text-muted: #94A3B8;
|
||||||
|
--color-border: #334155;
|
||||||
|
--color-bg-input: #020617;
|
||||||
|
/* Slate 950 */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-base text-content transition-colors duration-200;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const Dashboard = () => {
|
|
||||||
return (
|
|
||||||
<div className="p-8 text-white">
|
|
||||||
<h1 className="text-4xl font-bold mb-8">Dashboard</h1>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<Link to="/nutrition" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
|
|
||||||
<h2 className="text-2xl font-bold mb-2">Nutrition Tracker</h2>
|
|
||||||
<p className="text-gray-400">Log meals and view macros.</p>
|
|
||||||
</Link>
|
|
||||||
<Link to="/health" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
|
|
||||||
<h2 className="text-2xl font-bold mb-2">Health Metrics</h2>
|
|
||||||
<p className="text-gray-400">Track weight and blood indicators.</p>
|
|
||||||
</Link>
|
|
||||||
<Link to="/plans" className="p-6 bg-gray-800 rounded-lg shadow-lg hover:bg-gray-700 transition">
|
|
||||||
<h2 className="text-2xl font-bold mb-2">AI Coach</h2>
|
|
||||||
<p className="text-gray-400">Get personalized diet & workout plans.</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Dashboard;
|
|
||||||
227
frontend/src/pages/Dashboard.tsx
Normal file
227
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import client from '../api/client';
|
||||||
|
import {
|
||||||
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||||
|
BarChart, Bar, PieChart, Pie, Cell, Legend
|
||||||
|
} from 'recharts';
|
||||||
|
import { Activity, Flame, Footprints, Scale, Utensils } from 'lucide-react';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [metrics, setMetrics] = useState([]);
|
||||||
|
const [logs, setLogs] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [metricsRes, logsRes] = await Promise.all([
|
||||||
|
client.get('/health/metrics'),
|
||||||
|
client.get('/nutrition/logs')
|
||||||
|
]);
|
||||||
|
setMetrics(metricsRes.data);
|
||||||
|
setLogs(logsRes.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load dashboard data", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Process Data
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// 1. Calories Today
|
||||||
|
const todayLogs = logs.filter(l => l.timestamp.startsWith(today));
|
||||||
|
const caloriesToday = todayLogs.reduce((acc, curr) => acc + curr.calories, 0);
|
||||||
|
|
||||||
|
// 2. Weight (Latest)
|
||||||
|
const weightMetrics = metrics.filter(m => m.metric_type === 'weight' || m.metric_type === 'Weight');
|
||||||
|
const currentWeight = weightMetrics.length > 0 ? weightMetrics[0].value : '--';
|
||||||
|
|
||||||
|
// 3. Steps (Latest or Total Today) - Assuming Steps is a metric type
|
||||||
|
// If backend returns all metrics, we find latest steps entry
|
||||||
|
const stepsMetrics = metrics.filter(m => m.metric_type === 'steps' || m.metric_type === 'Steps');
|
||||||
|
const currentSteps = stepsMetrics.length > 0 ? stepsMetrics[0].value : '--';
|
||||||
|
|
||||||
|
// Charts Data
|
||||||
|
|
||||||
|
// Weight Trend (Last 7 entries)
|
||||||
|
const weightData = weightMetrics.slice(0, 7).reverse().map(m => ({
|
||||||
|
date: new Date(m.timestamp).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||||
|
weight: m.value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Macros Breakdown (Today)
|
||||||
|
const macros = todayLogs.reduce((acc, curr) => ({
|
||||||
|
Protein: acc.Protein + curr.protein,
|
||||||
|
Carbs: acc.Carbs + curr.carbs,
|
||||||
|
Fats: acc.Fats + curr.fats
|
||||||
|
}), { Protein: 0, Carbs: 0, Fats: 0 });
|
||||||
|
|
||||||
|
const macroData = [
|
||||||
|
{ name: 'Protein', value: Math.round(macros.Protein) },
|
||||||
|
{ name: 'Carbs', value: Math.round(macros.Carbs) },
|
||||||
|
{ name: 'Fats', value: Math.round(macros.Fats) },
|
||||||
|
];
|
||||||
|
// Filter out zero values for cleaner pie chart
|
||||||
|
const activeMacroData = macroData.filter(d => d.value > 0);
|
||||||
|
|
||||||
|
const COLORS = ['#10B981', '#F59E0B', '#EF4444']; // Emerald, Amber, Red
|
||||||
|
|
||||||
|
// Recent Activity
|
||||||
|
const recentActivity = logs.slice(0, 5);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-8 text-center text-content-muted">Loading dashboard...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 animate-fade-in">
|
||||||
|
<header>
|
||||||
|
<h1 className="text-3xl font-bold text-content">Dashboard</h1>
|
||||||
|
<p className="text-content-muted">Welcome back! Here's your daily overview.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-xl text-orange-600 dark:text-orange-400">
|
||||||
|
<Flame size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-content-muted font-medium">Calories Today</p>
|
||||||
|
<p className="text-2xl font-bold text-content">{Math.round(caloriesToday)} <span className="text-xs font-normal">kcal</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-xl text-blue-600 dark:text-blue-400">
|
||||||
|
<Footprints size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-content-muted font-medium">Daily Steps</p>
|
||||||
|
<p className="text-2xl font-bold text-content">{currentSteps}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-xl text-purple-600 dark:text-purple-400">
|
||||||
|
<Scale size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-content-muted font-medium">Current Weight</p>
|
||||||
|
<p className="text-2xl font-bold text-content">{currentWeight} <span className="text-xs font-normal">kg</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
|
||||||
|
{/* Weight Trend */}
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-content mb-6 flex items-center gap-2">
|
||||||
|
<Activity size={18} className="text-primary" />
|
||||||
|
Weight Trend
|
||||||
|
</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
{weightData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={weightData}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="colorWeight" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-primary)" stopOpacity={0.3} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-primary)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||||
|
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12 }} />
|
||||||
|
<YAxis hide domain={['auto', 'auto']} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="weight" stroke="var(--color-primary)" strokeWidth={3} fillOpacity={1} fill="url(#colorWeight)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center text-content-muted">
|
||||||
|
No weight data recorded yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macro Distribution */}
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-content mb-6 flex items-center gap-2">
|
||||||
|
<Utensils size={18} className="text-primary" />
|
||||||
|
Today's Macros
|
||||||
|
</h3>
|
||||||
|
<div className="h-64 flex items-center justify-center">
|
||||||
|
{activeMacroData.length > 0 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={activeMacroData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{activeMacroData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: 'var(--color-bg-surface)', borderColor: 'var(--color-border)', borderRadius: '8px' }}
|
||||||
|
itemStyle={{ color: 'var(--color-text-main)' }}
|
||||||
|
/>
|
||||||
|
<Legend verticalAlign="bottom" height={36} iconType="circle" />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="text-content-muted">
|
||||||
|
Log your meals to see nutrient breakdown.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<div className="bg-surface p-6 rounded-2xl border border-border shadow-sm">
|
||||||
|
<h3 className="text-lg font-semibold text-content mb-4">Recent Food Logs</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentActivity.length > 0 ? (
|
||||||
|
recentActivity.map((log, i) => (
|
||||||
|
<div key={log.id || i} className="flex items-center justify-between p-4 bg-base rounded-xl border border-border/50 hover:border-border transition-colors">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center text-green-600 dark:text-green-400">
|
||||||
|
<Utensils size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-content">{log.name}</p>
|
||||||
|
<p className="text-xs text-content-muted">{new Date(log.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-bold text-content">{Math.round(log.calories)} kcal</p>
|
||||||
|
<p className="text-xs text-content-muted text-green-500">
|
||||||
|
P: {Math.round(log.protein)}g • C: {Math.round(log.carbs)}g • F: {Math.round(log.fats)}g
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-content-muted">No recent activity found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
@@ -1,10 +1,30 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, FormEvent, ChangeEvent } from 'react';
|
||||||
import client from '../api/client';
|
import client from '../api/client';
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { Field, Label, Fieldset, Legend } from '../components/catalyst/fieldset';
|
||||||
|
import { Input } from '../components/catalyst/input';
|
||||||
|
import { Select } from '../components/catalyst/select';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import { Heading, Subheading } from '../components/catalyst/heading';
|
||||||
|
|
||||||
|
interface Metric {
|
||||||
|
id: number;
|
||||||
|
metric_type: string;
|
||||||
|
value: number;
|
||||||
|
unit: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Goal {
|
||||||
|
id: number;
|
||||||
|
goal_type: string;
|
||||||
|
target_value: number;
|
||||||
|
target_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const Health = () => {
|
const Health = () => {
|
||||||
const [metrics, setMetrics] = useState([]);
|
const [metrics, setMetrics] = useState<Metric[]>([]);
|
||||||
const [goals, setGoals] = useState([]);
|
const [goals, setGoals] = useState<Goal[]>([]);
|
||||||
const [newMetric, setNewMetric] = useState({ metric_type: 'weight', value: '', unit: 'kg' });
|
const [newMetric, setNewMetric] = useState({ metric_type: 'weight', value: '', unit: 'kg' });
|
||||||
const [newGoal, setNewGoal] = useState({ goal_type: 'lose_weight', target_value: '', target_date: '' });
|
const [newGoal, setNewGoal] = useState({ goal_type: 'lose_weight', target_value: '', target_date: '' });
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -27,7 +47,7 @@ const Health = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMetric = async (e) => {
|
const handleAddMetric = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -42,7 +62,7 @@ const Health = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddGoal = async (e) => {
|
const handleAddGoal = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -63,7 +83,7 @@ const Health = () => {
|
|||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
return metrics
|
return metrics
|
||||||
.filter(m => m.metric_type === selectedMetricType)
|
.filter(m => m.metric_type === selectedMetricType)
|
||||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
||||||
.map(m => ({
|
.map(m => ({
|
||||||
date: new Date(m.timestamp).toLocaleDateString(),
|
date: new Date(m.timestamp).toLocaleDateString(),
|
||||||
value: m.value
|
value: m.value
|
||||||
@@ -71,19 +91,18 @@ const Health = () => {
|
|||||||
}, [metrics, selectedMetricType]);
|
}, [metrics, selectedMetricType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-6xl mx-auto animated-fade-in">
|
<div className="max-w-6xl mx-auto space-y-6">
|
||||||
<h1 className="text-3xl font-bold mb-8 text-white">Health Dashboard</h1>
|
<Heading>Health Dashboard</Heading>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
{/* Metrics Section */}
|
{/* Metrics Section */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
|
||||||
<h2 className="text-xl font-bold mb-4 text-blue-400">Track New Metric</h2>
|
<Subheading className="mb-6 text-primary">Track New Metric</Subheading>
|
||||||
<form onSubmit={handleAddMetric} className="space-y-4">
|
<form onSubmit={handleAddMetric} className="space-y-4">
|
||||||
<div>
|
<Field>
|
||||||
<label className="block text-gray-400 mb-1">Type</label>
|
<Label>Type</Label>
|
||||||
<select
|
<Select
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
|
|
||||||
value={newMetric.metric_type}
|
value={newMetric.metric_type}
|
||||||
onChange={(e) => setNewMetric({ ...newMetric, metric_type: e.target.value })}
|
onChange={(e) => setNewMetric({ ...newMetric, metric_type: e.target.value })}
|
||||||
>
|
>
|
||||||
@@ -91,70 +110,77 @@ const Health = () => {
|
|||||||
<option value="cholesterol">Cholesterol</option>
|
<option value="cholesterol">Cholesterol</option>
|
||||||
<option value="vitamin_d">Vitamin D</option>
|
<option value="vitamin_d">Vitamin D</option>
|
||||||
<option value="testosterone">Testosterone</option>
|
<option value="testosterone">Testosterone</option>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<Field>
|
||||||
<label className="block text-gray-400 mb-1">Value</label>
|
<Label>Value</Label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
|
|
||||||
value={newMetric.value}
|
value={newMetric.value}
|
||||||
onChange={(e) => setNewMetric({ ...newMetric, value: e.target.value })}
|
onChange={(e) => setNewMetric({ ...newMetric, value: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div>
|
<Field>
|
||||||
<label className="block text-gray-400 mb-1">Unit</label>
|
<Label>Unit</Label>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
|
|
||||||
value={newMetric.unit}
|
value={newMetric.unit}
|
||||||
onChange={(e) => setNewMetric({ ...newMetric, unit: e.target.value })}
|
onChange={(e) => setNewMetric({ ...newMetric, unit: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
color="dark/zinc"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 p-2 rounded text-white font-bold transition-all shadow-lg"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{loading ? 'Adding...' : 'Add Metric'}
|
{loading ? 'Adding...' : 'Add Metric'}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-xl font-bold text-white">Progress Chart</h2>
|
<Subheading>Progress Chart</Subheading>
|
||||||
<select
|
<Select
|
||||||
className="bg-gray-700 text-sm text-gray-300 rounded p-1 border border-gray-600 outline-none"
|
|
||||||
value={selectedMetricType}
|
value={selectedMetricType}
|
||||||
onChange={(e) => setSelectedMetricType(e.target.value)}
|
onChange={(e) => setSelectedMetricType(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
>
|
>
|
||||||
<option value="weight">Weight</option>
|
<option value="weight">Weight</option>
|
||||||
<option value="cholesterol">Cholesterol</option>
|
<option value="cholesterol">Cholesterol</option>
|
||||||
<option value="vitamin_d">Vitamin D</option>
|
<option value="vitamin_d">Vitamin D</option>
|
||||||
<option value="testosterone">Testosterone</option>
|
<option value="testosterone">Testosterone</option>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-64 w-full">
|
<div className="h-64 w-full">
|
||||||
{chartData.length > 0 ? (
|
{chartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart data={chartData}>
|
<LineChart data={chartData}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
|
||||||
<XAxis dataKey="date" stroke="#9CA3AF" />
|
<XAxis dataKey="date" stroke="var(--color-text-muted)" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
||||||
<YAxis stroke="#9CA3AF" />
|
<YAxis stroke="var(--color-text-muted)" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{ backgroundColor: '#1F2937', border: 'none', color: '#F9FAFB' }}
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--color-bg-base)',
|
||||||
|
border: '1px solid var(--color-border)',
|
||||||
|
color: 'var(--color-text-main)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)'
|
||||||
|
}}
|
||||||
|
itemStyle={{ color: 'var(--color-primary)' }}
|
||||||
|
labelStyle={{ color: 'var(--color-text-muted)', marginBottom: '0.25rem' }}
|
||||||
/>
|
/>
|
||||||
<Line type="monotone" dataKey="value" stroke="#3B82F6" strokeWidth={2} dot={{ r: 4 }} activeDot={{ r: 8 }} />
|
<Line type="monotone" dataKey="value" stroke="var(--color-primary)" strokeWidth={3} dot={{ r: 4, fill: 'var(--color-primary)' }} activeDot={{ r: 6 }} />
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full flex items-center justify-center text-gray-500">
|
<div className="h-full flex items-center justify-center text-content-muted">
|
||||||
No data available for this metric
|
No data available for this metric
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -164,65 +190,63 @@ const Health = () => {
|
|||||||
|
|
||||||
{/* Goals Section */}
|
{/* Goals Section */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
|
||||||
<h2 className="text-xl font-bold mb-4 text-purple-400">Set New Goal</h2>
|
<Subheading className="mb-6 text-primary">Set New Goal</Subheading>
|
||||||
<form onSubmit={handleAddGoal} className="space-y-4">
|
<form onSubmit={handleAddGoal} className="space-y-4">
|
||||||
<div>
|
<Field>
|
||||||
<label className="block text-gray-400 mb-1">Goal Type</label>
|
<Label>Goal Type</Label>
|
||||||
<select
|
<Select
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
|
||||||
value={newGoal.goal_type}
|
value={newGoal.goal_type}
|
||||||
onChange={(e) => setNewGoal({ ...newGoal, goal_type: e.target.value })}
|
onChange={(e) => setNewGoal({ ...newGoal, goal_type: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="lose_weight">Lose Weight</option>
|
<option value="lose_weight">Lose Weight</option>
|
||||||
<option value="gain_muscle">Gain Muscle</option>
|
<option value="gain_muscle">Gain Muscle</option>
|
||||||
<option value="improve_health">Improve Indicators</option>
|
<option value="improve_health">Improve Indicators</option>
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</Field>
|
||||||
<div>
|
<Field>
|
||||||
<label className="block text-gray-400 mb-1">Target Value</label>
|
<Label>Target Value</Label>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
|
||||||
value={newGoal.target_value}
|
value={newGoal.target_value}
|
||||||
onChange={(e) => setNewGoal({ ...newGoal, target_value: e.target.value })}
|
onChange={(e) => setNewGoal({ ...newGoal, target_value: e.target.value })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div>
|
<Field>
|
||||||
<label className="block text-gray-400 mb-1">Target Date (Optional)</label>
|
<Label>Target Date (Optional)</Label>
|
||||||
<input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
|
||||||
value={newGoal.target_date}
|
value={newGoal.target_date}
|
||||||
onChange={(e) => setNewGoal({ ...newGoal, target_date: e.target.value })}
|
onChange={(e) => setNewGoal({ ...newGoal, target_date: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
color="dark/zinc"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 p-2 rounded text-white font-bold transition-all shadow-lg"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{loading ? 'Setting...' : 'Set Goal'}
|
{loading ? 'Setting...' : 'Set Goal'}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
|
||||||
<h2 className="text-xl font-bold mb-4 text-white">Active Goals</h2>
|
<Subheading className="mb-4">Active Goals</Subheading>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{goals.length === 0 ? (
|
{goals.length === 0 ? (
|
||||||
<p className="text-gray-500">No active goals.</p>
|
<p className="text-content-muted">No active goals.</p>
|
||||||
) : (
|
) : (
|
||||||
goals.map((g) => (
|
goals.map((g) => (
|
||||||
<div key={g.id} className="bg-gray-700/50 p-4 rounded border-l-4 border-purple-500 hover:bg-gray-700 transition-colors">
|
<div key={g.id} className="bg-base p-4 rounded-xl border-l-4 border-primary hover:bg-black/5 dark:hover:bg-white/5 transition-colors">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<span className="font-bold text-white capitalize">{g.goal_type.replace('_', ' ')}</span>
|
<span className="font-bold text-content capitalize">{g.goal_type.replace('_', ' ')}</span>
|
||||||
<span className="text-purple-300 font-mono text-lg">{g.target_value}</span>
|
<span className="text-primary font-mono text-lg font-bold">{g.target_value}</span>
|
||||||
</div>
|
</div>
|
||||||
{g.target_date && (
|
{g.target_date && (
|
||||||
<p className="text-xs text-gray-400 mt-1">
|
<p className="text-xs text-content-muted mt-1">
|
||||||
Target: {new Date(g.target_date).toLocaleDateString()}
|
Target: {new Date(g.target_date).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useState, useContext } from 'react';
|
|
||||||
import { AuthContext } from '../context/AuthContext';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
const Login = () => {
|
|
||||||
const { login } = useContext(AuthContext);
|
|
||||||
const [email, setEmail] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
await login(email, password);
|
|
||||||
navigate('/');
|
|
||||||
} catch (err) {
|
|
||||||
setError('Invalid credentials');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-900 text-white">
|
|
||||||
<div className="w-full max-w-md p-8 bg-gray-800 rounded-lg shadow-lg">
|
|
||||||
<h2 className="text-2xl font-bold mb-6 text-center text-purple-400">Healthy Fit Login</h2>
|
|
||||||
{error && <p className="text-red-500 mb-4">{error}</p>}
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block mb-2">Email</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="w-full p-2 rounded bg-gray-700 border border-gray-600 focus:outline-none focus:border-purple-500"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block mb-2">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="w-full p-2 rounded bg-gray-700 border border-gray-600 focus:outline-none focus:border-purple-500"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="w-full bg-purple-600 hover:bg-purple-700 text-white p-2 rounded font-bold transition">
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Login;
|
|
||||||
61
frontend/src/pages/Login.tsx
Normal file
61
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useContext, FormEvent } from 'react';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Field, Label, ErrorMessage } from '../components/catalyst/fieldset';
|
||||||
|
import { Input } from '../components/catalyst/input';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import { Heading } from '../components/catalyst/heading';
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const { login } = useContext(AuthContext);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setError('Invalid credentials');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-base text-content transition-colors">
|
||||||
|
<div className="w-full max-w-md p-8 bg-surface rounded-2xl shadow-lg border border-border">
|
||||||
|
<Heading level={2} className="mb-6 text-center text-primary">HealthyFit Login</Heading>
|
||||||
|
{error && <ErrorMessage className="mb-4 text-center">{error}</ErrorMessage>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<Field>
|
||||||
|
<Label>Email</Label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" color="dark/zinc" className="w-full">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { useState, useRef } from 'react';
|
|
||||||
import client from '../api/client';
|
|
||||||
|
|
||||||
const Nutrition = () => {
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [analysis, setAnalysis] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedFile, setSelectedFile] = useState(null);
|
|
||||||
const fileInputRef = useRef(null);
|
|
||||||
|
|
||||||
const handleFileSelect = (e) => {
|
|
||||||
if (e.target.files && e.target.files[0]) {
|
|
||||||
setSelectedFile(e.target.files[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
let res;
|
|
||||||
if (selectedFile) {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', selectedFile);
|
|
||||||
if (description) {
|
|
||||||
formData.append('description', description);
|
|
||||||
}
|
|
||||||
res = await client.post('/nutrition/analyze/image', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
res = await client.post('/nutrition/analyze', { description });
|
|
||||||
}
|
|
||||||
setAnalysis(res.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert('Failed to analyze');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!analysis) return;
|
|
||||||
try {
|
|
||||||
await client.post('/nutrition/log', analysis);
|
|
||||||
alert('Saved!');
|
|
||||||
setAnalysis(null);
|
|
||||||
setDescription('');
|
|
||||||
setSelectedFile(null);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert('Failed to save');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-4xl mx-auto">
|
|
||||||
<h1 className="text-3xl font-bold mb-6 text-white">Nutrition Tracker</h1>
|
|
||||||
<div className="bg-gray-800 p-6 rounded-lg mb-8 shadow-lg">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-gray-300 mb-2 font-medium">Describe your meal or upload a photo</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full p-4 bg-gray-700 rounded border border-gray-600 focus:border-blue-500 focus:outline-none text-white transition-colors"
|
|
||||||
rows="3"
|
|
||||||
placeholder="E.g. 'A chicken breast with a cup of rice'..."
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
ref={fileInputRef}
|
|
||||||
className="hidden"
|
|
||||||
id="food-image-upload"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="food-image-upload"
|
|
||||||
className={`cursor-pointer inline-flex items-center px-4 py-2 rounded-lg border transition-all ${selectedFile
|
|
||||||
? 'bg-blue-900 border-blue-500 text-blue-200'
|
|
||||||
: 'bg-gray-700 border-gray-600 text-gray-300 hover:bg-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
{selectedFile ? selectedFile.name : 'Upload Photo'}
|
|
||||||
</label>
|
|
||||||
{selectedFile && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedFile(null);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
|
||||||
}}
|
|
||||||
className="ml-2 text-red-400 hover:text-red-300 text-sm"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleAnalyze}
|
|
||||||
disabled={loading || (!description && !selectedFile)}
|
|
||||||
className={`px-8 py-3 rounded-lg font-bold text-white transition-all transform hover:scale-105 ${loading || (!description && !selectedFile)
|
|
||||||
? 'bg-gray-600 cursor-not-allowed'
|
|
||||||
: 'bg-gradient-to-r from-blue-600 to-indigo-600 shadow-lg hover:shadow-blue-500/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loading ? 'Analyzing...' : 'Analyze Meal'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{analysis && (
|
|
||||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg animated-fade-in">
|
|
||||||
<h2 className="text-2xl font-bold mb-6 text-white">Analysis Result</h2>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
|
||||||
{[
|
|
||||||
{ label: 'Calories', value: analysis.calories, unit: '' },
|
|
||||||
{ label: 'Protein', value: analysis.protein, unit: 'g' },
|
|
||||||
{ label: 'Carbs', value: analysis.carbs, unit: 'g' },
|
|
||||||
{ label: 'Fats', value: analysis.fats, unit: 'g' }
|
|
||||||
].map((item) => (
|
|
||||||
<div key={item.label} className="p-4 bg-gray-700/50 rounded-lg border border-gray-600 text-center">
|
|
||||||
<span className="block text-gray-400 text-sm">{item.label}</span>
|
|
||||||
<span className="text-2xl font-bold text-white">{item.value}{item.unit}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-green-600 hover:bg-green-700 px-8 py-2 rounded-lg text-white font-bold transition-colors shadow-lg hover:shadow-green-500/30"
|
|
||||||
>
|
|
||||||
Save to Log
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Nutrition;
|
|
||||||
288
frontend/src/pages/Nutrition.tsx
Normal file
288
frontend/src/pages/Nutrition.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { useState, useRef, FormEvent, ChangeEvent } from 'react';
|
||||||
|
import client from '../api/client';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Camera,
|
||||||
|
Loader2,
|
||||||
|
Beef,
|
||||||
|
Wheat,
|
||||||
|
Droplets,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
CheckCircle,
|
||||||
|
X,
|
||||||
|
Info,
|
||||||
|
Utensils
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Field, Label } from '../components/catalyst/fieldset';
|
||||||
|
import { Textarea } from '../components/catalyst/textarea';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import { Heading } from '../components/catalyst/heading';
|
||||||
|
|
||||||
|
interface NutritionalInfo {
|
||||||
|
name: string;
|
||||||
|
calories: number;
|
||||||
|
protein: number;
|
||||||
|
carbs: number;
|
||||||
|
fats: number;
|
||||||
|
reasoning?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Nutrition = () => {
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [analysis, setAnalysis] = useState<NutritionalInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
|
const [showReasoning, setShowReasoning] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
setSelectedFile(file);
|
||||||
|
setPreviewUrl(URL.createObjectURL(file));
|
||||||
|
setAnalysis(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearFile = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setPreviewUrl(null);
|
||||||
|
setAnalysis(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAnalyze = async () => {
|
||||||
|
if (!description && !selectedFile) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setAnalysis(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (selectedFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
if (description) {
|
||||||
|
formData.append('description', description);
|
||||||
|
}
|
||||||
|
res = await client.post('/nutrition/analyze/image', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await client.post('/nutrition/analyze', { description });
|
||||||
|
}
|
||||||
|
setAnalysis(res.data);
|
||||||
|
setShowReasoning(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Failed to analyze. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!analysis) return;
|
||||||
|
try {
|
||||||
|
await client.post('/nutrition/log', analysis);
|
||||||
|
setAnalysis(null);
|
||||||
|
setDescription('');
|
||||||
|
clearFile();
|
||||||
|
alert('Meal logged successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Failed to save log.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8 animate-fade-in">
|
||||||
|
<header>
|
||||||
|
<Heading>Nutrition AI</Heading>
|
||||||
|
<p className="text-content-muted">Analyze your meal instantly with AI. Upload a photo or describe it.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Input Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Image Upload Area */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative border-2 border-dashed rounded-2xl p-8 text-center transition-all duration-200
|
||||||
|
${previewUrl ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50 bg-surface'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{previewUrl ? (
|
||||||
|
<div className="relative group">
|
||||||
|
<img
|
||||||
|
src={previewUrl}
|
||||||
|
alt="Food preview"
|
||||||
|
className="w-full h-64 object-cover rounded-xl shadow-md"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={clearFile}
|
||||||
|
className="absolute top-2 right-2 p-2 bg-black/50 text-white rounded-full hover:bg-red-500 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="cursor-pointer space-y-4 py-8"
|
||||||
|
>
|
||||||
|
<div className="w-16 h-16 mx-auto bg-primary/10 rounded-full flex items-center justify-center text-primary">
|
||||||
|
<Camera size={32} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-content">Click to upload photo</p>
|
||||||
|
<p className="text-sm text-content-muted">or drag and drop here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Description */}
|
||||||
|
<Field>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Describe your meal (e.g., 'Grilled salmon with asparagus')..."
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={loading || (!description && !selectedFile)}
|
||||||
|
color="dark/zinc"
|
||||||
|
className={`w-full ${loading || (!description && !selectedFile) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
<span className="animate-pulse">Analyzing...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={20} /> Analyze Nutrition
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<p className="text-center text-sm text-content-muted animate-pulse">
|
||||||
|
Our AI is examining your food... this may take a few seconds.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Section */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{analysis ? (
|
||||||
|
<div className="bg-surface rounded-3xl p-8 border border-border shadow-xl ring-1 ring-border/50 animate-slide-up">
|
||||||
|
<div className="flex justify-between items-start mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-content">{analysis.name}</h2>
|
||||||
|
<p className="text-content-muted text-sm">AI Estimate</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-2 bg-green-100 dark:bg-green-900/30 rounded-full text-green-700 dark:text-green-400 font-bold text-xl">
|
||||||
|
{Math.round(analysis.calories)} kcal
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Macros Grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
|
{/* Protein */}
|
||||||
|
<div className="bg-base p-4 rounded-2xl flex flex-col items-center gap-2 border border-border">
|
||||||
|
<div className="p-2 bg-red-100 dark:bg-red-900/30 rounded-full text-red-600 dark:text-red-400">
|
||||||
|
<Beef size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-content">{analysis.protein}g</span>
|
||||||
|
<span className="text-xs font-medium text-content-muted uppercase tracking-wider">Protein</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carbs */}
|
||||||
|
<div className="bg-base p-4 rounded-2xl flex flex-col items-center gap-2 border border-border">
|
||||||
|
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-full text-amber-600 dark:text-amber-400">
|
||||||
|
<Wheat size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-content">{analysis.carbs}g</span>
|
||||||
|
<span className="text-xs font-medium text-content-muted uppercase tracking-wider">Carbs</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fats */}
|
||||||
|
<div className="bg-base p-4 rounded-2xl flex flex-col items-center gap-2 border border-border">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-full text-blue-600 dark:text-blue-400">
|
||||||
|
<Droplets size={24} />
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-content">{analysis.fats}g</span>
|
||||||
|
<span className="text-xs font-medium text-content-muted uppercase tracking-wider">Fats</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reasoning Collapsible */}
|
||||||
|
{analysis.reasoning && (
|
||||||
|
<div className="bg-base rounded-xl border border-border overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowReasoning(!showReasoning)}
|
||||||
|
className="w-full px-5 py-3 flex items-center justify-between text-sm font-medium text-content-muted hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Info size={16} /> AI Reasoning
|
||||||
|
</div>
|
||||||
|
{showReasoning ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showReasoning && (
|
||||||
|
<div className="px-5 py-4 text-sm text-content border-t border-border bg-black/5 dark:bg-white/5 leading-relaxed">
|
||||||
|
{analysis.reasoning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-8 flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
color="green"
|
||||||
|
className="flex-1 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<CheckCircle size={20} /> Log Meal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setAnalysis(null)}
|
||||||
|
outline
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center p-8 text-center bg-surface border-2 border-dashed border-border rounded-3xl opacity-50">
|
||||||
|
<div className="p-6 bg-base rounded-full mb-4">
|
||||||
|
<Utensils size={40} className="text-content-muted" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-content mb-2">Ready to Analyze</h3>
|
||||||
|
<p className="text-content-muted max-w-xs">Upload a photo or describe your meal to get detailed nutritional info.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Nutrition;
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import client from '../api/client';
|
|
||||||
import ReactMarkdown from 'react-markdown'; // Assuming we might want md support, but for now I'll use structured display or simple whitespace pre-line
|
|
||||||
|
|
||||||
const Plans = () => {
|
|
||||||
const [plans, setPlans] = useState([]);
|
|
||||||
const [goal, setGoal] = useState('');
|
|
||||||
const [userDetails, setUserDetails] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPlans();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
|
||||||
try {
|
|
||||||
const res = await client.get('/plans/');
|
|
||||||
setPlans(res.data);
|
|
||||||
if (res.data.length > 0) setSelectedPlan(res.data[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch plans', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerate = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await client.post('/plans/generate', { goal, user_details: userDetails });
|
|
||||||
setPlans([res.data, ...plans]);
|
|
||||||
setSelectedPlan(res.data);
|
|
||||||
setGoal('');
|
|
||||||
setUserDetails('');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert('Failed to generate plan');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-8 max-w-7xl mx-auto animated-fade-in">
|
|
||||||
<h1 className="text-3xl font-bold mb-8 text-white">AI Coach</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* Left Column: Generator & History */}
|
|
||||||
<div className="space-y-6 lg:col-span-1">
|
|
||||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
|
|
||||||
<h2 className="text-xl font-bold mb-4 text-purple-400">Request New Plan</h2>
|
|
||||||
<form onSubmit={handleGenerate} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 mb-1">Your Goal</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
|
||||||
placeholder="e.g. Lose 5kg in 2 months"
|
|
||||||
value={goal}
|
|
||||||
onChange={(e) => setGoal(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-400 mb-1">Your Details</label>
|
|
||||||
<textarea
|
|
||||||
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Male, 30, 80kg, access to gym..."
|
|
||||||
value={userDetails}
|
|
||||||
onChange={(e) => setUserDetails(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 p-2 rounded text-white font-bold transition-all shadow-lg"
|
|
||||||
>
|
|
||||||
{loading ? 'Generating Plan...' : 'Generate Plan'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700 h-96 overflow-y-auto">
|
|
||||||
<h2 className="text-xl font-bold mb-4 text-white">History</h2>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{plans.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
onClick={() => setSelectedPlan(p)}
|
|
||||||
className={`p-4 rounded cursor-pointer transition-colors border-l-4 ${selectedPlan?.id === p.id
|
|
||||||
? 'bg-gray-700 border-purple-500'
|
|
||||||
: 'bg-gray-700/30 border-gray-600 hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="font-bold text-white truncate">{p.goal}</p>
|
|
||||||
<p className="text-xs text-gray-400">{new Date(p.created_at).toLocaleDateString()}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{plans.length === 0 && <p className="text-gray-500">No plans yet.</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Plan View */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
{selectedPlan ? (
|
|
||||||
<div className="bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-700 min-h-[600px]">
|
|
||||||
{loading && selectedPlan === plans[0] && plans.length > 0 ? (
|
|
||||||
// Show loading logic if we just added it - actually layout handles this ok
|
|
||||||
// But better to check loading state
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-8 bg-gray-700 rounded w-1/3"></div>
|
|
||||||
<div className="h-4 bg-gray-700 rounded w-full"></div>
|
|
||||||
<div className="h-4 bg-gray-700 rounded w-full"></div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex justify-between items-start mb-6 border-b border-gray-700 pb-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
|
|
||||||
{selectedPlan.structured_content?.title || selectedPlan.goal}
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-400 mt-2">{selectedPlan.structured_content?.summary}</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-gray-500 bg-gray-900 px-3 py-1 rounded-full">
|
|
||||||
{new Date(selectedPlan.created_at).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedPlan.structured_content ? (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-blue-400 mb-3 flex items-center">
|
|
||||||
<span className="mr-2">🥗</span> Diet Plan
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{selectedPlan.structured_content.diet_plan?.map((item, i) => (
|
|
||||||
<li key={i} className="flex items-start text-gray-300">
|
|
||||||
<span className="mr-2 text-blue-500">•</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-green-400 mb-3 flex items-center">
|
|
||||||
<span className="mr-2">💪</span> Exercise Routine
|
|
||||||
</h3>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{selectedPlan.structured_content.exercise_plan?.map((item, i) => (
|
|
||||||
<li key={i} className="flex items-start text-gray-300">
|
|
||||||
<span className="mr-2 text-green-500">•</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-yellow-400 mb-3 flex items-center">
|
|
||||||
<span className="mr-2">💡</span> Coach Tips
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{selectedPlan.structured_content.tips?.map((item, i) => (
|
|
||||||
<div key={i} className="bg-gray-700/50 p-4 rounded border border-gray-600">
|
|
||||||
<p className="text-gray-300">{item}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-gray-300 whitespace-pre-wrap font-mono text-sm">
|
|
||||||
{selectedPlan.content}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-gray-800 p-8 rounded-lg shadow-lg border border-gray-700 h-full flex flex-col items-center justify-center text-center">
|
|
||||||
<div className="text-6xl mb-4">🤖</div>
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">Welcome to AI Coach</h2>
|
|
||||||
<p className="text-gray-400 max-w-md">
|
|
||||||
Describe your goals and get a personalized nutrition and workout plan generated by our advanced AI.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Plans;
|
|
||||||
203
frontend/src/pages/Plans.tsx
Normal file
203
frontend/src/pages/Plans.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState, useEffect, FormEvent } from 'react';
|
||||||
|
import client from '../api/client';
|
||||||
|
import { Field, Label } from '../components/catalyst/fieldset';
|
||||||
|
import { Input } from '../components/catalyst/input';
|
||||||
|
import { Textarea } from '../components/catalyst/textarea';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import { Heading, Subheading } from '../components/catalyst/heading';
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
id: number;
|
||||||
|
goal: string;
|
||||||
|
created_at: string;
|
||||||
|
content: string;
|
||||||
|
structured_content?: {
|
||||||
|
title?: string;
|
||||||
|
summary?: string;
|
||||||
|
diet_plan?: string[];
|
||||||
|
exercise_plan?: string[];
|
||||||
|
tips?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Plans = () => {
|
||||||
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [goal, setGoal] = useState('');
|
||||||
|
const [userDetails, setUserDetails] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const res = await client.get('/plans/');
|
||||||
|
setPlans(res.data);
|
||||||
|
if (res.data.length > 0) setSelectedPlan(res.data[0]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch plans', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerate = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await client.post('/plans/generate', { goal, user_details: userDetails });
|
||||||
|
setPlans([res.data, ...plans]);
|
||||||
|
setSelectedPlan(res.data);
|
||||||
|
setGoal('');
|
||||||
|
setUserDetails('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert('Failed to generate plan');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6">
|
||||||
|
<Heading>AI Coach</Heading>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
{/* Left Column: Generator & History */}
|
||||||
|
<div className="space-y-6 lg:col-span-1">
|
||||||
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
|
||||||
|
<Subheading className="mb-6 text-primary">Request New Plan</Subheading>
|
||||||
|
<form onSubmit={handleGenerate} className="space-y-4">
|
||||||
|
<Field>
|
||||||
|
<Label>Your Goal</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Lose 5kg in 2 months"
|
||||||
|
value={goal}
|
||||||
|
onChange={(e) => setGoal(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Your Details</Label>
|
||||||
|
<Textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Male, 30, 80kg, access to gym..."
|
||||||
|
value={userDetails}
|
||||||
|
onChange={(e) => setUserDetails(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="dark/zinc"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{loading ? 'Generating Plan...' : 'Generate Plan'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border h-96 overflow-y-auto">
|
||||||
|
<Subheading className="mb-4">History</Subheading>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{plans.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => setSelectedPlan(p)}
|
||||||
|
className={`p-4 rounded-xl cursor-pointer transition-colors border-l-4 ${selectedPlan?.id === p.id
|
||||||
|
? 'bg-base border-primary'
|
||||||
|
: 'bg-base/50 border-border hover:bg-base'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-bold text-content truncate">{p.goal}</p>
|
||||||
|
<p className="text-xs text-content-muted">{new Date(p.created_at).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{plans.length === 0 && <p className="text-content-muted">No plans yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Plan View */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{selectedPlan ? (
|
||||||
|
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border min-h-[600px]">
|
||||||
|
<div className="flex justify-between items-start mb-6 border-b border-border pb-4">
|
||||||
|
<div>
|
||||||
|
<Heading className="text-transparent bg-clip-text bg-gradient-to-r from-primary to-pink-400">
|
||||||
|
{selectedPlan.structured_content?.title || selectedPlan.goal}
|
||||||
|
</Heading>
|
||||||
|
<p className="text-content-muted mt-2">{selectedPlan.structured_content?.summary}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-content-muted bg-base px-3 py-1 rounded-full border border-border">
|
||||||
|
{new Date(selectedPlan.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedPlan.structured_content ? (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-blue-400 mb-3 flex items-center">
|
||||||
|
<span className="mr-2">🥗</span> Diet Plan
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{selectedPlan.structured_content.diet_plan?.map((item, i) => (
|
||||||
|
<li key={i} className="flex items-start text-content">
|
||||||
|
<span className="mr-2 text-blue-500">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-green-400 mb-3 flex items-center">
|
||||||
|
<span className="mr-2">💪</span> Exercise Routine
|
||||||
|
</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{selectedPlan.structured_content.exercise_plan?.map((item, i) => (
|
||||||
|
<li key={i} className="flex items-start text-content">
|
||||||
|
<span className="mr-2 text-green-500">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-yellow-400 mb-3 flex items-center">
|
||||||
|
<span className="mr-2">💡</span> Coach Tips
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{selectedPlan.structured_content.tips?.map((item, i) => (
|
||||||
|
<div key={i} className="bg-base/50 p-4 rounded-xl border border-border">
|
||||||
|
<p className="text-content">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-content whitespace-pre-wrap font-mono text-sm">
|
||||||
|
{selectedPlan.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border h-full flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="text-6xl mb-4">🤖</div>
|
||||||
|
<Heading level={2} className="mb-2">Welcome to AI Coach</Heading>
|
||||||
|
<p className="text-content-muted max-w-md">
|
||||||
|
Describe your goals and get a personalized nutrition and workout plan generated by our advanced AI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Plans;
|
||||||
318
frontend/src/pages/Profile.tsx
Normal file
318
frontend/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { useState, useEffect, useContext, FormEvent, ChangeEvent } from 'react';
|
||||||
|
import { AuthContext } from '../context/AuthContext';
|
||||||
|
import { User, Ruler, Weight, Activity, Save, AlertCircle } from 'lucide-react';
|
||||||
|
import { Field, Label, Fieldset, Legend } from '../components/catalyst/fieldset';
|
||||||
|
import { Input } from '../components/catalyst/input';
|
||||||
|
import { Select } from '../components/catalyst/select';
|
||||||
|
import { Button } from '../components/catalyst/button';
|
||||||
|
import { Heading } from '../components/catalyst/heading';
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
|
age: string;
|
||||||
|
gender: string;
|
||||||
|
height_cm: string | number;
|
||||||
|
weight_kg: string | number;
|
||||||
|
height_ft: string | number;
|
||||||
|
height_in: string | number;
|
||||||
|
weight_lb: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Profile = () => {
|
||||||
|
const { user, updateUser } = useContext(AuthContext);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [message, setMessage] = useState({ type: '', text: '' });
|
||||||
|
|
||||||
|
const [unitSystem, setUnitSystem] = useState('metric'); // 'metric' or 'imperial'
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
firstname: '',
|
||||||
|
lastname: '',
|
||||||
|
age: '',
|
||||||
|
gender: '',
|
||||||
|
height_cm: '',
|
||||||
|
weight_kg: '',
|
||||||
|
height_ft: '',
|
||||||
|
height_in: '',
|
||||||
|
weight_lb: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
const prefs = user.unit_preference || 'metric';
|
||||||
|
setUnitSystem(prefs);
|
||||||
|
|
||||||
|
const h_cm = user.height || '';
|
||||||
|
const w_kg = user.weight || '';
|
||||||
|
|
||||||
|
// Convert initial values
|
||||||
|
let h_ft: number | string = '', h_in: number | string = '', w_lb: number | string = '';
|
||||||
|
if (h_cm) {
|
||||||
|
const totalInches = h_cm / 2.54;
|
||||||
|
h_ft = Math.floor(totalInches / 12);
|
||||||
|
h_in = Math.round(totalInches % 12);
|
||||||
|
}
|
||||||
|
if (w_kg) {
|
||||||
|
w_lb = Math.round(w_kg * 2.20462);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData({
|
||||||
|
firstname: user.firstname || '',
|
||||||
|
lastname: user.lastname || '',
|
||||||
|
age: user.age || '',
|
||||||
|
gender: user.gender || '',
|
||||||
|
height_cm: h_cm,
|
||||||
|
weight_kg: w_kg,
|
||||||
|
height_ft: h_ft,
|
||||||
|
height_in: h_in,
|
||||||
|
weight_lb: w_lb,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const handleUnitChange = (system: string) => {
|
||||||
|
setUnitSystem(system);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => {
|
||||||
|
const next = { ...prev, [name]: value };
|
||||||
|
|
||||||
|
// Auto-convert when typing
|
||||||
|
if (name === 'height_cm') {
|
||||||
|
const cm = parseFloat(value) || 0;
|
||||||
|
const inches = cm / 2.54;
|
||||||
|
next.height_ft = Math.floor(inches / 12);
|
||||||
|
next.height_in = Math.round(inches % 12);
|
||||||
|
}
|
||||||
|
if (name === 'weight_kg') {
|
||||||
|
const kg = parseFloat(value) || 0;
|
||||||
|
next.weight_lb = Math.round(kg * 2.20462);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'height_ft' || name === 'height_in') {
|
||||||
|
const ft = parseFloat(String(name === 'height_ft' ? value : next.height_ft)) || 0;
|
||||||
|
const inch = parseFloat(String(name === 'height_in' ? value : next.height_in)) || 0;
|
||||||
|
next.height_cm = Math.round(((ft * 12) + inch) * 2.54);
|
||||||
|
}
|
||||||
|
if (name === 'weight_lb') {
|
||||||
|
const lb = parseFloat(value) || 0;
|
||||||
|
next.weight_kg = (lb / 2.20462).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setMessage({ type: '', text: '' });
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
firstname: formData.firstname,
|
||||||
|
lastname: formData.lastname,
|
||||||
|
age: parseInt(formData.age),
|
||||||
|
gender: formData.gender,
|
||||||
|
unit_preference: unitSystem,
|
||||||
|
height: parseFloat(String(formData.height_cm)),
|
||||||
|
weight: parseFloat(String(formData.weight_kg)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const success = await updateUser(payload);
|
||||||
|
if (success) {
|
||||||
|
setMessage({ type: 'success', text: 'Profile updated successfully!' });
|
||||||
|
} else {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to update profile.' });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
|
<header>
|
||||||
|
<Heading>My Profile</Heading>
|
||||||
|
<p className="text-content-muted mt-2">Manage your personal information and preferences.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="bg-surface p-8 rounded-2xl shadow-sm border border-border">
|
||||||
|
{message.text && (
|
||||||
|
<div className={`p-4 mb-6 rounded-lg flex items-center gap-3 ${message.type === 'success' ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' : 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
<AlertCircle size={20} />
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-8">
|
||||||
|
{/* Personal Info */}
|
||||||
|
<Fieldset>
|
||||||
|
<Legend className="flex items-center gap-2">
|
||||||
|
<User className="text-primary" size={24} />
|
||||||
|
Personal Details
|
||||||
|
</Legend>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||||
|
<Field>
|
||||||
|
<Label>First Name</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="firstname"
|
||||||
|
value={formData.firstname}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Jane"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Last Name</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="lastname"
|
||||||
|
value={formData.lastname}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="Doe"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Age</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="age"
|
||||||
|
value={formData.age}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="28"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<Label>Gender</Label>
|
||||||
|
<Select
|
||||||
|
name="gender"
|
||||||
|
value={formData.gender}
|
||||||
|
onChange={handleChange}
|
||||||
|
>
|
||||||
|
<option value="">Select Gender</option>
|
||||||
|
<option value="Male">Male</option>
|
||||||
|
<option value="Female">Female</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<div className="border-t border-border my-6"></div>
|
||||||
|
|
||||||
|
{/* Body Metrics */}
|
||||||
|
<Fieldset>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<Legend className="flex items-center gap-2">
|
||||||
|
<Activity className="text-primary" size={24} />
|
||||||
|
Body Metrics
|
||||||
|
</Legend>
|
||||||
|
|
||||||
|
{/* Unit Toggle */}
|
||||||
|
<div className="flex bg-base p-1 rounded-lg border border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUnitChange('metric')}
|
||||||
|
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${unitSystem === 'metric'
|
||||||
|
? 'bg-primary text-white shadow-sm'
|
||||||
|
: 'text-content-muted hover:text-content'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Metric
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUnitChange('imperial')}
|
||||||
|
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${unitSystem === 'imperial'
|
||||||
|
? 'bg-primary text-white shadow-sm'
|
||||||
|
: 'text-content-muted hover:text-content'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Imperial
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Height */}
|
||||||
|
<Field>
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Ruler size={16} /> Height
|
||||||
|
</Label>
|
||||||
|
{unitSystem === 'metric' ? (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="height_cm"
|
||||||
|
value={formData.height_cm}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="175"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-4 top-3.5 text-content-muted pointer-events-none">cm</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="height_ft"
|
||||||
|
value={formData.height_ft}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="5"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-3.5 text-content-muted pointer-events-none">ft</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="height_in"
|
||||||
|
value={formData.height_in}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder="9"
|
||||||
|
/>
|
||||||
|
<span className="absolute right-3 top-3.5 text-content-muted pointer-events-none">in</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Weight */}
|
||||||
|
<Field>
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Weight size={16} /> Weight
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name={unitSystem === 'metric' ? "weight_kg" : "weight_lb"}
|
||||||
|
value={unitSystem === 'metric' ? formData.weight_kg : formData.weight_lb}
|
||||||
|
onChange={handleChange}
|
||||||
|
placeholder={unitSystem === 'metric' ? "70" : "150"}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-4 top-3.5 text-content-muted pointer-events-none">
|
||||||
|
{unitSystem === 'metric' ? 'kg' : 'lbs'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
color="dark/zinc"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Save size={20} />
|
||||||
|
{loading ? 'Saving...' : 'Save Profile'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Profile;
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
40
frontend/tsconfig.json
Normal file
40
frontend/tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitAny": false, // Loose typing for migration
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
frontend/tsconfig.node.json
Normal file
12
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react-swc'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
})
|
|
||||||
16
frontend/vite.config.ts
Normal file
16
frontend/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react-swc';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user