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:
Carlos Escalante
2026-01-18 19:01:00 -06:00
parent 184c8330a7
commit bd91eb4171
58 changed files with 4607 additions and 1414 deletions

1
.gitignore vendored
View File

@@ -29,6 +29,7 @@ yarn-error.log
dist/
build/
.output/
frontend/catalyst-ui-kit 2/
# Docker
postgres_data/

View File

@@ -61,9 +61,7 @@ class NutritionModule(dspy.Module):
pred = self.extract(description=description)
# Assertion: Check Macro Consistency
calc_cals = (
(pred.nutritional_info.protein * 4) + (pred.nutritional_info.carbs * 4) + (pred.nutritional_info.fats * 9)
)
# calc_cals calculation removed as dspy.Suggest is disabled
# dspy.Suggest is not available in dspy>=3.1.0
# dspy.Suggest(
@@ -78,9 +76,7 @@ class NutritionModule(dspy.Module):
pred = self.analyze_image(image=image, description=description)
# Assertion: Check Macro Consistency
calc_cals = (
(pred.nutritional_info.protein * 4) + (pred.nutritional_info.carbs * 4) + (pred.nutritional_info.fats * 9)
)
# calc_cals calculation removed as dspy.Suggest is disabled
# dspy.Suggest is not available in dspy>=3.1.0
# dspy.Suggest(

View File

@@ -1,7 +1,6 @@
import litellm
import dspy
from typing import Any
import litellm
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from pydantic import BaseModel
from sqlmodel import Session
@@ -70,3 +69,25 @@ def log_food(
session.commit()
session.refresh(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()

View File

@@ -6,7 +6,7 @@ from sqlmodel import Session, select
from app.api import deps
from app.core import security
from app.models.user import User
from app.schemas.user import UserCreate, UserRead
from app.schemas.user import UserCreate, UserRead, UserUpdate
router = APIRouter()
@@ -36,3 +36,31 @@ def create_user(
session.commit()
session.refresh(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

View File

@@ -9,4 +9,14 @@ class User(SQLModel, table=True):
username: str = Field(index=True, unique=True)
email: str = Field(index=True, unique=True)
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)

View File

@@ -14,9 +14,23 @@ class UserCreate(UserBase):
class UserRead(UserBase):
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):
email: Optional[str] = None
username: 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

View 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()

View File

@@ -8,6 +8,6 @@
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@
"dependencies": {
"axios": "^1.13.2",
"lucide-react": "^0.562.0",
"motion": "^12.27.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
@@ -20,16 +21,20 @@
},
"devDependencies": {
"@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",
"@vitejs/plugin-react-swc": "^4.2.2",
"autoprefixer": "^10.4.23",
"clsx": "^2.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.2.4"
}
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -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
View 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;

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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;

View 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;

View 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>
)
})

View 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>
)
})

View 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}
</>
)
}

View 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>
)
}

View 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'
)}
/>
)
}

View 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')}
/>
)
}

View 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')}
/>
)
}

View 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>
)
})

View 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>
)
})

View 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')} />
}

View 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>
)
}

View 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>
)
})

View 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>
)
}

View 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')} />
}

View 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>
)
}

View 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>
)
}

View 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'
)}
/>
)
}

View 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>
)
})

View File

@@ -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>
);
};

View 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>
);
};

View 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;
};

View File

@@ -1,3 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@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;
}
}

View File

@@ -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;

View 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;

View File

@@ -1,10 +1,30 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, FormEvent, ChangeEvent } from 'react';
import client from '../api/client';
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 [metrics, setMetrics] = useState([]);
const [goals, setGoals] = useState([]);
const [metrics, setMetrics] = useState<Metric[]>([]);
const [goals, setGoals] = useState<Goal[]>([]);
const [newMetric, setNewMetric] = useState({ metric_type: 'weight', value: '', unit: 'kg' });
const [newGoal, setNewGoal] = useState({ goal_type: 'lose_weight', target_value: '', target_date: '' });
const [loading, setLoading] = useState(false);
@@ -27,7 +47,7 @@ const Health = () => {
}
};
const handleAddMetric = async (e) => {
const handleAddMetric = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
try {
@@ -42,7 +62,7 @@ const Health = () => {
}
};
const handleAddGoal = async (e) => {
const handleAddGoal = async (e: FormEvent) => {
e.preventDefault();
setLoading(true);
try {
@@ -63,7 +83,7 @@ const Health = () => {
const chartData = useMemo(() => {
return metrics
.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 => ({
date: new Date(m.timestamp).toLocaleDateString(),
value: m.value
@@ -71,19 +91,18 @@ const Health = () => {
}, [metrics, selectedMetricType]);
return (
<div className="p-8 max-w-6xl mx-auto animated-fade-in">
<h1 className="text-3xl font-bold mb-8 text-white">Health Dashboard</h1>
<div className="max-w-6xl mx-auto space-y-6">
<Heading>Health Dashboard</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Metrics Section */}
<div className="space-y-6">
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<h2 className="text-xl font-bold mb-4 text-blue-400">Track New Metric</h2>
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
<Subheading className="mb-6 text-primary">Track New Metric</Subheading>
<form onSubmit={handleAddMetric} className="space-y-4">
<div>
<label className="block text-gray-400 mb-1">Type</label>
<select
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-blue-500 outline-none"
<Field>
<Label>Type</Label>
<Select
value={newMetric.metric_type}
onChange={(e) => setNewMetric({ ...newMetric, metric_type: e.target.value })}
>
@@ -91,70 +110,77 @@ const Health = () => {
<option value="cholesterol">Cholesterol</option>
<option value="vitamin_d">Vitamin D</option>
<option value="testosterone">Testosterone</option>
</select>
</div>
</Select>
</Field>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-gray-400 mb-1">Value</label>
<input
<Field>
<Label>Value</Label>
<Input
type="number"
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}
onChange={(e) => setNewMetric({ ...newMetric, value: e.target.value })}
required
/>
</div>
<div>
<label className="block text-gray-400 mb-1">Unit</label>
<input
</Field>
<Field>
<Label>Unit</Label>
<Input
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}
onChange={(e) => setNewMetric({ ...newMetric, unit: e.target.value })}
required
/>
</div>
</Field>
</div>
<button
<Button
type="submit"
color="dark/zinc"
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'}
</button>
</Button>
</form>
</div>
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-white">Progress Chart</h2>
<select
className="bg-gray-700 text-sm text-gray-300 rounded p-1 border border-gray-600 outline-none"
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
<div className="flex justify-between items-center mb-6">
<Subheading>Progress Chart</Subheading>
<Select
value={selectedMetricType}
onChange={(e) => setSelectedMetricType(e.target.value)}
className="text-sm"
>
<option value="weight">Weight</option>
<option value="cholesterol">Cholesterol</option>
<option value="vitamin_d">Vitamin D</option>
<option value="testosterone">Testosterone</option>
</select>
</Select>
</div>
<div className="h-64 w-full">
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis dataKey="date" stroke="#9CA3AF" />
<YAxis stroke="#9CA3AF" />
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-border)" opacity={0.5} />
<XAxis dataKey="date" stroke="var(--color-text-muted)" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
<YAxis stroke="var(--color-text-muted)" tick={{ fontSize: 12 }} tickLine={false} axisLine={false} />
<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>
</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
</div>
)}
@@ -164,65 +190,63 @@ const Health = () => {
{/* Goals Section */}
<div className="space-y-6">
<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">Set New Goal</h2>
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
<Subheading className="mb-6 text-primary">Set New Goal</Subheading>
<form onSubmit={handleAddGoal} className="space-y-4">
<div>
<label className="block text-gray-400 mb-1">Goal Type</label>
<select
className="w-full p-2 bg-gray-700 rounded text-white border border-gray-600 focus:border-purple-500 outline-none"
<Field>
<Label>Goal Type</Label>
<Select
value={newGoal.goal_type}
onChange={(e) => setNewGoal({ ...newGoal, goal_type: e.target.value })}
>
<option value="lose_weight">Lose Weight</option>
<option value="gain_muscle">Gain Muscle</option>
<option value="improve_health">Improve Indicators</option>
</select>
</div>
<div>
<label className="block text-gray-400 mb-1">Target Value</label>
<input
</Select>
</Field>
<Field>
<Label>Target Value</Label>
<Input
type="number"
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}
onChange={(e) => setNewGoal({ ...newGoal, target_value: e.target.value })}
required
/>
</div>
<div>
<label className="block text-gray-400 mb-1">Target Date (Optional)</label>
<input
</Field>
<Field>
<Label>Target Date (Optional)</Label>
<Input
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}
onChange={(e) => setNewGoal({ ...newGoal, target_date: e.target.value })}
/>
</div>
<button
</Field>
<Button
type="submit"
color="dark/zinc"
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'}
</button>
</Button>
</form>
</div>
<div className="bg-gray-800 p-6 rounded-lg shadow-lg border border-gray-700">
<h2 className="text-xl font-bold mb-4 text-white">Active Goals</h2>
<div className="bg-surface p-6 rounded-2xl shadow-sm border border-border">
<Subheading className="mb-4">Active Goals</Subheading>
<div className="space-y-3">
{goals.length === 0 ? (
<p className="text-gray-500">No active goals.</p>
<p className="text-content-muted">No active goals.</p>
) : (
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">
<span className="font-bold text-white capitalize">{g.goal_type.replace('_', ' ')}</span>
<span className="text-purple-300 font-mono text-lg">{g.target_value}</span>
<span className="font-bold text-content capitalize">{g.goal_type.replace('_', ' ')}</span>
<span className="text-primary font-mono text-lg font-bold">{g.target_value}</span>
</div>
{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()}
</p>
)}

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View 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;

View File

@@ -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
View 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"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -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
View 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'),
},
},
});