Initial commit

This commit is contained in:
Doh
2024-12-30 13:20:12 -05:00
committed by dohsimpson
commit c4f0db329b
71 changed files with 11302 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Habit } from '@/lib/types'
interface AddEditHabitModalProps {
isOpen: boolean
onClose: () => void
onSave: (habit: Omit<Habit, 'id'>) => void
habit?: Habit | null
}
export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: AddEditHabitModalProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly'>('daily')
const [coinReward, setCoinReward] = useState(1)
useEffect(() => {
if (habit) {
setName(habit.name)
setDescription(habit.description)
setFrequency(habit.frequency)
setCoinReward(habit.coinReward)
} else {
setName('')
setDescription('')
setFrequency('daily')
setCoinReward(1)
}
}, [habit])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave({ name, description, frequency, coinReward, completions: habit?.completions || [] })
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{habit ? 'Edit Habit' : 'Add New Habit'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="frequency" className="text-right">
Frequency
</Label>
<Select value={frequency} onValueChange={(value: 'daily' | 'weekly' | 'monthly') => setFrequency(value)}>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily</SelectItem>
<SelectItem value="weekly">Weekly</SelectItem>
<SelectItem value="monthly">Monthly</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="coinReward" className="text-right">
Coin Reward
</Label>
<Input
id="coinReward"
type="number"
value={coinReward}
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
className="col-span-3"
min={1}
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit">{habit ? 'Save Changes' : 'Add Habit'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,92 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { WishlistItemType } from '@/lib/types'
interface AddEditWishlistItemModalProps {
isOpen: boolean
onClose: () => void
onSave: (item: Omit<WishlistItemType, 'id'>) => void
item?: WishlistItemType | null
}
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [coinCost, setCoinCost] = useState(1)
useEffect(() => {
if (item) {
setName(item.name)
setDescription(item.description)
setCoinCost(item.coinCost)
} else {
setName('')
setDescription('')
setCoinCost(1)
}
}, [item])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave({ name, description, coinCost })
}
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="col-span-3"
required
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
Description
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="coinCost" className="text-right">
Coin Cost
</Label>
<Input
id="coinCost"
type="number"
value={coinCost}
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
className="col-span-3"
min={1}
required
/>
</div>
</div>
<DialogFooter>
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,19 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Coins } from 'lucide-react'
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
return (
<Card>
<CardHeader>
<CardTitle>Coin Balance</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center">
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
<span className="text-4xl font-bold">{coinBalance}</span>
</div>
</CardContent>
</Card>
)
}

229
components/CoinsManager.tsx Normal file
View File

@@ -0,0 +1,229 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { History } from 'lucide-react'
import EmptyState from './EmptyState'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from '@/hooks/use-toast'
import { useCoins } from '@/hooks/useCoins'
import { format } from 'date-fns'
import Link from 'next/link'
export default function CoinsManager() {
const { balance, transactions, addAmount, removeAmount } = useCoins()
const DEFAULT_AMOUNT = '0'
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
const handleAddCoins = async () => {
const data = await addAmount(Number(amount), "Manual addition")
if (data) {
setAmount(DEFAULT_AMOUNT)
toast({ title: "Success", description: `Added ${amount} coins` })
}
}
const handleRemoveCoins = async () => {
const data = await removeAmount(Math.abs(Number(amount)), "Manual removal")
if (data) {
setAmount(DEFAULT_AMOUNT)
toast({ title: "Success", description: `Removed ${amount} coins` })
}
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Coins Management</h1>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl animate-bounce hover:animate-none cursor-default">🪙</span>
<div>
<div className="text-sm font-normal text-muted-foreground">Current Balance</div>
<div className="text-3xl font-bold">{balance} coins</div>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="icon"
className="h-10 w-10 text-lg"
onClick={() => setAmount(prev => (Number(prev) - 1).toString())}
>
-
</Button>
<div className="relative w-32">
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="text-center text-xl font-medium h-12"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
🪙
</div>
</div>
<Button
variant="outline"
size="icon"
className="h-10 w-10 text-lg"
onClick={() => setAmount(prev => (Number(prev) + 1).toString())}
>
+
</Button>
</div>
<Button
onClick={() => {
const numAmount = Number(amount);
if (numAmount > 0) {
handleAddCoins();
} else if (numAmount < 0) {
handleRemoveCoins();
}
}}
className="w-full h-14 transition-colors flex items-center justify-center font-medium"
variant="default"
>
<div className="flex items-center gap-2">
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
</div>
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
<div className="text-sm text-green-800 dark:text-green-100 mb-1">Total Earned</div>
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
{transactions
.filter(t => {
if (t.type === 'HABIT_COMPLETION' && t.relatedItemId) {
return !transactions.some(undoT =>
undoT.type === 'HABIT_UNDO' &&
undoT.relatedItemId === t.relatedItemId
)
}
return t.amount > 0 && t.type !== 'HABIT_UNDO'
})
.reduce((sum, t) => sum + t.amount, 0)
} 🪙
</div>
</div>
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
<div className="text-sm text-red-800 dark:text-red-100 mb-1">Total Spent</div>
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
{Math.abs(
transactions
.filter(t => t.type === 'WISH_REDEMPTION' || t.type === 'MANUAL_ADJUSTMENT')
.reduce((sum, t) => sum + (t.amount < 0 ? t.amount : 0), 0)
)} 🪙
</div>
</div>
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Transactions</div>
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
{transactions.filter(t =>
new Date(t.timestamp).toDateString() === new Date().toDateString()
).length} 📊
</div>
</div>
</div>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex justify-between items-center">
<span>Transaction History</span>
<span className="text-sm font-normal text-muted-foreground">
Total: {transactions.length} transactions
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{transactions.length === 0 ? (
<EmptyState
icon={History}
title="No transactions yet"
description="Your transaction history will appear here once you start earning or spending coins"
/>
) : (
transactions.map((transaction) => {
const getBadgeStyles = () => {
switch (transaction.type) {
case 'HABIT_COMPLETION':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
case 'HABIT_UNDO':
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100'
case 'WISH_REDEMPTION':
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100'
case 'MANUAL_ADJUSTMENT':
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100'
}
}
return (
<div
key={transaction.id}
className="flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
{transaction.relatedItemId ? (
<Link
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
className="font-medium hover:underline"
scroll={true}
>
{transaction.description}
</Link>
) : (
<p className="font-medium">{transaction.description}</p>
)}
<span
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
>
{transaction.type.split('_').join(' ')}
</span>
</div>
<p className="text-sm text-gray-500">
{format(new Date(transaction.timestamp), 'PPpp')}
</p>
</div>
<span
className={`font-mono ${transaction.amount >= 0
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
</span>
</div>
)
})
)}
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,49 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface ConfirmDialogProps {
isOpen: boolean
onClose: () => void
onConfirm: () => void
title: string
message: string
confirmText?: string
cancelText?: string
}
export default function ConfirmDialog({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "Confirm",
cancelText = "Cancel"
}: ConfirmDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p>{message}</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{cancelText}
</Button>
<Button variant="destructive" onClick={onConfirm}>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,187 @@
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { WishlistItemType } from '@/lib/types'
import { Habit } from '@/lib/types'
interface UpcomingItemsProps {
habits: Habit[]
wishlistItems: WishlistItemType[]
coinBalance: number
onComplete: (habit: Habit) => void
onUndo: (habit: Habit) => void
}
export default function DailyOverview({
habits,
wishlistItems,
coinBalance,
onComplete,
onUndo
}: UpcomingItemsProps) {
const today = new Date().toISOString().split('T')[0]
const todayCompletions = habits.filter(habit =>
habit.completions.includes(today)
)
// Filter daily habits
const dailyHabits = habits.filter(habit => habit.frequency === 'daily')
// Get achievable wishlist items sorted by coin cost
const achievableWishlistItems = wishlistItems
.filter(item => item.coinCost > coinBalance)
.sort((a, b) => a.coinCost - b.coinCost)
const [expandedHabits, setExpandedHabits] = useState(false)
const [expandedWishlist, setExpandedWishlist] = useState(false)
return (
<Card>
<CardHeader>
<CardTitle>Today's Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">Daily Habits</h3>
<Badge variant="secondary">
{todayCompletions.length}/{dailyHabits.length} Complete
</Badge>
</div>
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-[500px] opacity-100' : 'max-h-[200px] opacity-100'} overflow-hidden`}>
{dailyHabits
.sort((a, b) => {
const aCompleted = todayCompletions.includes(a);
const bCompleted = todayCompletions.includes(b);
return aCompleted === bCompleted ? 0 : aCompleted ? 1 : -1;
})
.slice(0, expandedHabits ? undefined : 3)
.map((habit) => {
const isCompleted = todayCompletions.includes(habit)
return (
<li
key={habit.id}
className={`flex items-center justify-between text-sm p-2 rounded-md
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
>
<span className="flex items-center gap-2">
<button
onClick={(e) => {
e.preventDefault();
if (isCompleted) {
onUndo(habit);
} else {
onComplete(habit);
}
}}
className="hover:opacity-70 transition-opacity"
>
{isCompleted ? (
<CircleCheck className="h-4 w-4 text-green-500" />
) : (
<Circle className="h-4 w-4" />
)}
</button>
<span className={isCompleted ? 'line-through' : ''}>
{habit.name}
</span>
</span>
<span className="flex items-center text-xs text-muted-foreground">
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
{habit.coinReward}
</span>
</li>
)
})}
</ul>
</div>
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedHabits(!expandedHabits)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedHabits ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/habits"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
<div className="space-y-2">
{achievableWishlistItems.length > 0 && (
<div>
<h3 className="font-semibold mb-2">Wishlist Goals</h3>
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
{achievableWishlistItems
.slice(0, expandedWishlist ? undefined : 1)
.map((item) => (
<div key={item.id} className="bg-secondary/20 p-3 rounded-md">
<div className="flex items-center justify-between mb-2">
<span className="text-sm">{item.name}</span>
<span className="text-xs flex items-center">
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
{item.coinCost}
</span>
</div>
<Progress
value={(coinBalance / item.coinCost) * 100}
className="h-2"
/>
<p className="text-xs text-muted-foreground mt-2">
{item.coinCost - coinBalance} coins to go
</p>
</div>
))}
</div>
</div>
)}
<div className="flex items-center justify-between">
<button
onClick={() => setExpandedWishlist(!expandedWishlist)}
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
{expandedWishlist ? (
<>
Show less
<ChevronUp className="h-3 w-3" />
</>
) : (
<>
Show all
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
<Link
href="/wishlist"
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
>
View
<ArrowRight className="h-3 w-3" />
</Link>
</div>
</div>
</div>
</CardContent>
</Card>
)
}

55
components/Dashboard.tsx Normal file
View File

@@ -0,0 +1,55 @@
'use client'
import { loadCoinsData } from '@/app/actions/data'
import { useHabits } from '@/hooks/useHabits'
import { useWishlist } from '@/hooks/useWishlist'
import { useEffect, useState } from 'react'
import CoinBalance from './CoinBalance'
import DailyOverview from './DailyOverview'
import HabitOverview from './HabitOverview'
import HabitStreak from './HabitStreak'
export default function Dashboard() {
const { habits, completeHabit, undoComplete } = useHabits()
const [coinBalance, setCoinBalance] = useState(0)
const { wishlistItems } = useWishlist()
useEffect(() => {
const loadData = async () => {
const coinsData = await loadCoinsData()
setCoinBalance(coinsData.balance)
}
loadData()
}, [])
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<CoinBalance coinBalance={coinBalance} />
{/* <HabitOverview /> */}
<HabitStreak habits={habits} />
<DailyOverview
wishlistItems={wishlistItems}
habits={habits}
coinBalance={coinBalance}
onComplete={async (habit) => {
const newBalance = await completeHabit(habit)
if (newBalance !== null) {
setCoinBalance(newBalance)
}
}}
onUndo={async (habit) => {
const newBalance = await undoComplete(habit)
if (newBalance !== null) {
setCoinBalance(newBalance)
}
}}
/>
{/* <HabitHeatmap habits={habits} /> */}
</div>
</div>
)
}

17
components/EmptyState.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { LucideIcon } from 'lucide-react'
interface EmptyStateProps {
icon: LucideIcon
title: string
description: string
}
export default function EmptyState({ icon: Icon, title, description }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<Icon className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import { useEffect, useState } from 'react'
import { Calendar } from '@/components/ui/calendar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
export default function HabitCalendar() {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date())
const [habits, setHabits] = useState<Habit[]>([])
useEffect(() => {
fetchHabitsData()
}, [])
const fetchHabitsData = async () => {
const data = await loadHabitsData()
setHabits(data.habits)
}
const getHabitsForDate = (date: Date) => {
const dateString = date.toISOString().split('T')[0]
return habits.filter(habit =>
habit.completions.includes(dateString)
)
}
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Calendar</CardTitle>
</CardHeader>
<CardContent>
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
className="rounded-md border"
modifiers={{
completed: (date) => getHabitsForDate(date).length > 0,
}}
modifiersClassNames={{
completed: 'bg-green-100 text-green-800 font-bold',
}}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
{selectedDate ? (
<>Habits for {selectedDate.toLocaleDateString()}</>
) : (
'Select a date'
)}
</CardTitle>
</CardHeader>
<CardContent>
{selectedDate && (
<ul className="space-y-2">
{habits.map((habit) => {
const isCompleted = getHabitsForDate(selectedDate).some(h => h.id === habit.id)
return (
<li key={habit.id} className="flex items-center justify-between">
<span>{habit.name}</span>
{isCompleted ? (
<Badge variant="default">Completed</Badge>
) : (
<Badge variant="secondary">Not Completed</Badge>
)}
</li>
)
})}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import HeatMap from '@uiw/react-heat-map'
import { Habit } from '@/lib/types'
interface HabitHeatmapProps {
habits: Habit[]
}
export default function HabitHeatmap({ habits }: HabitHeatmapProps) {
// Aggregate all habit completions into a count per day
const completionCounts = habits.reduce((acc: { [key: string]: number }, habit) => {
habit.completions.forEach(date => {
// Convert date format from ISO (YYYY-MM-DD) to YYYY/MM/DD for the heatmap
const formattedDate = date.replace(/-/g, '/')
acc[formattedDate] = (acc[formattedDate] || 0) + 1
})
return acc
}, {})
// Convert to the format expected by the heatmap
const value = Object.entries(completionCounts).map(([date, count]) => ({
date,
count
}))
// Get start date (30 days ago)
const startDate = new Date()
startDate.setDate(startDate.getDate() - 30)
return (
<div className="p-4 bg-white rounded-lg shadow">
<h2 className="text-lg font-semibold mb-4">Habit Completion Heatmap</h2>
<div className="overflow-x-auto">
<HeatMap
value={value}
startDate={startDate}
width={500}
style={{ color: '#047857' }}
panelColors={[
'#f0fdf4', // Very light green
'#dcfce7', // Light green
'#bbf7d0', // Medium light green
'#86efac', // Medium green
'#4ade80', // Bright green
'#22c55e' // Dark green
]}
rectProps={{ rx: 3 }} // Rounded corners
rectRender={(props, data) => {
return (
<title>{`${data.date}: ${data.count || 0} habits completed`}</title>
);
}}
/>
</div>
</div>
)
}

90
components/HabitItem.tsx Normal file
View File

@@ -0,0 +1,90 @@
import { Habit } from '@/lib/types'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react'
import { useEffect, useState } from 'react'
interface HabitItemProps {
habit: Habit
onEdit: () => void
onDelete: () => void
onComplete: () => void
onUndo: () => void
}
export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo }: HabitItemProps) {
const today = new Date().toISOString().split('T')[0]
const isCompletedToday = habit.completions?.includes(today)
const [isHighlighted, setIsHighlighted] = useState(false)
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const highlightId = params.get('highlight')
if (highlightId === habit.id) {
setIsHighlighted(true)
// Scroll the element into view after a short delay to ensure rendering
setTimeout(() => {
const element = document.getElementById(`habit-${habit.id}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, 100)
// Remove highlight after animation
const timer = setTimeout(() => setIsHighlighted(false), 2000)
return () => clearTimeout(timer)
}
}, [habit.id])
return (
<Card
id={`habit-${habit.id}`}
className={`transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''}`}
>
<CardHeader>
<CardTitle>{habit.name}</CardTitle>
<CardDescription>{habit.description}</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-500">Frequency: {habit.frequency}</p>
<div className="flex items-center mt-2">
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<div>
<Button variant="outline" size="sm" onClick={onEdit} className="mr-2">
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
<Button variant="destructive" size="sm" onClick={onDelete}>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
<div className="flex gap-2">
<Button
variant={isCompletedToday ? "secondary" : "default"}
size="sm"
onClick={onComplete}
disabled={isCompletedToday}
>
<Check className="h-4 w-4 mr-2" />
{isCompletedToday ? "Completed" : "Complete"}
</Button>
{isCompletedToday && (
<Button
variant="outline"
size="sm"
onClick={onUndo}
>
<Undo2 />
</Button>
)}
</div>
</CardFooter>
</Card>
)
}

89
components/HabitList.tsx Normal file
View File

@@ -0,0 +1,89 @@
'use client'
import { useState } from 'react'
import { useHabits } from '@/hooks/useHabits'
import { Plus, ListTodo } from 'lucide-react'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
import HabitItem from './HabitItem'
import AddEditHabitModal from './AddEditHabitModal'
import ConfirmDialog from './ConfirmDialog'
import { Habit } from '@/lib/types'
export default function HabitList() {
const { habits, addHabit, editHabit, deleteHabit, completeHabit, undoComplete } = useHabits()
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
isOpen: false,
habitId: null
})
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">My Habits</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Habit
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{habits.length === 0 ? (
<div className="col-span-2">
<EmptyState
icon={ListTodo}
title="No habits yet"
description="Create your first habit to start tracking your progress"
/>
</div>
) : (
habits.map((habit) => (
<HabitItem
key={habit.id}
habit={habit}
onEdit={() => {
setEditingHabit(habit)
setIsModalOpen(true)
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
onComplete={() => completeHabit(habit)}
onUndo={() => undoComplete(habit)}
/>
))
)}
</div>
<AddEditHabitModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingHabit(null)
}}
onSave={async (habit) => {
if (editingHabit) {
await editHabit(editingHabit)
} else {
await addHabit(habit)
}
setIsModalOpen(false)
setEditingHabit(null)
}}
habit={editingHabit}
/>
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, habitId: null })}
onConfirm={() => {
if (deleteConfirmation.habitId) {
deleteHabit(deleteConfirmation.habitId)
}
setDeleteConfirmation({ isOpen: false, habitId: null })
}}
title="Delete Habit"
message="Are you sure you want to delete this habit? This action cannot be undone."
confirmText="Delete"
/>
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { BarChart } from 'lucide-react'
import { useEffect, useState } from 'react'
import { loadHabitsData } from '@/app/actions/data'
import { Habit } from '@/lib/types'
export default function HabitOverview() {
const [habits, setHabits] = useState<Habit[]>([])
useEffect(() => {
const fetchHabits = async () => {
const data = await loadHabitsData()
setHabits(data.habits)
}
fetchHabits()
}, [])
const today = new Date().toISOString().split('T')[0]
const completedToday = habits.filter(habit =>
habit.completions.includes(today)
).length
const habitsByFrequency = habits.reduce((acc, habit) => ({
...acc,
[habit.frequency]: (acc[habit.frequency] || 0) + 1
}), {} as Record<string, number>)
return (
<Card>
<CardHeader>
<CardTitle>Habit Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Today's Progress */}
<div className="bg-secondary/20 p-4 rounded-lg">
<h3 className="font-semibold mb-2">Today&apos;s Progress</h3>
<div className="flex items-center justify-between">
<span>{completedToday}/{habits.length} completed</span>
<BarChart className="h-5 w-5" />
</div>
</div>
{/* Frequency Breakdown */}
<div>
<h3 className="font-semibold mb-2">Habit Frequency</h3>
<div className="space-y-2">
{Object.entries(habitsByFrequency).map(([frequency, count]) => (
<div key={frequency} className="flex items-center justify-between text-sm">
<span className="capitalize">{frequency}</span>
<span className="bg-secondary/30 px-2 py-1 rounded">
{count} habits
</span>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { Habit } from '@/lib/types'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
interface HabitStreakProps {
habits: Habit[]
}
export default function HabitStreak({ habits }: HabitStreakProps) {
// Get the last 30 days of data
const dates = Array.from({ length: 30 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() - i)
return d.toISOString().split('T')[0]
}).reverse()
// Count completed habits per day
const completions = dates.map(date => ({
date: new Date(date).toLocaleDateString(),
completed: habits.filter(habit =>
habit.completions.includes(date)
).length
}))
return (
<Card>
<CardHeader>
<CardTitle>Daily Habit Completion Streak</CardTitle>
</CardHeader>
<CardContent>
<div className="w-full aspect-[2/1]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={completions}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip formatter={(value) => [`${value} habits`, 'Completed']} />
<Line
type="monotone"
dataKey="completed"
stroke="#14b8a6"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)
}

28
components/Header.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Bell, Settings } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/Logo'
interface HeaderProps {
className?: string
}
export default function Header({ className }: HeaderProps) {
return (
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
<div className="mx-auto py-4 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<Logo />
{/* <div className="flex items-center"> */}
{/* <Button variant="ghost" size="icon" className="mr-2"> */}
{/* <Bell className="h-5 w-5" /> */}
{/* </Button> */}
{/* <Button variant="ghost" size="icon"> */}
{/* <Settings className="h-5 w-5" /> */}
{/* </Button> */}
{/* </div> */}
</div>
</div>
</header>
)
}

21
components/Layout.tsx Normal file
View File

@@ -0,0 +1,21 @@
import Header from './Header'
import Sidebar from './Sidebar'
import MobileNavigation from './MobileNavigation'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
<Header className="sticky top-0 z-50" />
<div className="flex flex-1 overflow-hidden">
<Sidebar />
<div className="flex-1 flex flex-col">
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900">
{children}
</main>
<MobileNavigation />
</div>
</div>
</div>
)
}

10
components/Logo.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Sparkles } from "lucide-react"
export function Logo() {
return (
<div className="flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
<span className="font-bold text-xl">HabitTrove</span>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins } from 'lucide-react'
const navItems = [
{ icon: Home, label: 'Dashboard', href: '/' },
{ icon: List, label: 'Habits', href: '/habits' },
{ icon: Calendar, label: 'Calendar', href: '/calendar' },
{ icon: Gift, label: 'Wishlist', href: '/wishlist' },
{ icon: Coins, label: 'Coins', href: '/coins' },
]
export default function MobileNavigation() {
return (
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg">
<div className="flex justify-around">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className="flex flex-col items-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
>
<item.icon className="h-6 w-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
)
}

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
// This would typically come from your backend or state management
const rewards = [
{ id: '1', name: 'Day off work', coinCost: 500 },
{ id: '2', name: 'Movie night', coinCost: 100 },
{ id: '3', name: 'New book', coinCost: 50 },
]
interface RewardProgressProps {
coinBalance: number
}
export default function RewardProgress({ coinBalance }: RewardProgressProps) {
return (
<Card>
<CardHeader>
<CardTitle>Progress Towards Rewards</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{rewards.map((reward) => {
const progress = Math.min((coinBalance / reward.coinCost) * 100, 100)
return (
<div key={reward.id}>
<div className="flex justify-between mb-1">
<span className="text-sm font-medium">{reward.name}</span>
<span className="text-sm font-medium">{coinBalance}/{reward.coinCost} coins</span>
</div>
<Progress value={progress} className="w-full" />
</div>
)
})}
</div>
</CardContent>
</Card>
)
}

36
components/Sidebar.tsx Normal file
View File

@@ -0,0 +1,36 @@
import Link from 'next/link'
import { Home, Calendar, List, Gift, Coins } from 'lucide-react'
const navItems = [
{ icon: Home, label: 'Dashboard', href: '/' },
{ icon: List, label: 'Habits', href: '/habits' },
// { icon: Calendar, label: 'Calendar', href: '/calendar' },
{ icon: Gift, label: 'Wishlist', href: '/wishlist' },
{ icon: Coins, label: 'Coins', href: '/coins' },
]
export default function Sidebar() {
return (
<div className="hidden lg:flex lg:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1 bg-gray-800">
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
<nav className="mt-5 flex-1 px-2 space-y-1">
{navItems.map((item) => (
<Link
key={item.label}
href={item.href}
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
>
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
{item.label}
</Link>
))}
</nav>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
import { WishlistItemType } from '@/lib/types'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Coins, Edit, Trash2, Gift } from 'lucide-react'
interface WishlistItemProps {
item: WishlistItemType
onEdit: () => void
onDelete: () => void
onRedeem: () => void
canRedeem: boolean
isHighlighted?: boolean
isRecentlyRedeemed?: boolean
}
export default function WishlistItem({
item,
onEdit,
onDelete,
onRedeem,
canRedeem,
isHighlighted,
isRecentlyRedeemed
}: WishlistItemProps) {
return (
<Card
id={`wishlist-${item.id}`}
className={`transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
}`}
>
<CardHeader>
<CardTitle>{item.name}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center">
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
<span className="text-sm font-medium">{item.coinCost} coins</span>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<div>
<Button variant="outline" size="sm" onClick={onEdit} className="mr-2">
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
<Button variant="destructive" size="sm" onClick={onDelete}>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
<Button
variant={canRedeem ? "default" : "secondary"}
size="sm"
onClick={onRedeem}
disabled={!canRedeem}
className={`transition-all duration-300 ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''
}`}
>
<Gift className={`h-4 w-4 mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''
}`} />
{isRecentlyRedeemed ? 'Redeemed!' : 'Redeem'}
</Button>
</CardFooter>
</Card>
)
}

View File

@@ -0,0 +1,129 @@
'use client'
import { useState, useEffect } from 'react'
import { Plus, Gift } from 'lucide-react'
import EmptyState from './EmptyState'
import { Button } from '@/components/ui/button'
import WishlistItem from './WishlistItem'
import AddEditWishlistItemModal from './AddEditWishlistItemModal'
import ConfirmDialog from './ConfirmDialog'
import { WishlistItemType } from '@/lib/types'
import { useWishlist } from '@/hooks/useWishlist'
export default function WishlistManager() {
const {
wishlistItems,
addWishlistItem,
editWishlistItem,
deleteWishlistItem,
redeemWishlistItem,
canRedeem
} = useWishlist()
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null)
const [recentlyRedeemedId, setRecentlyRedeemedId] = useState<string | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingItem, setEditingItem] = useState<WishlistItemType | null>(null)
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, itemId: string | null }>({
isOpen: false,
itemId: null
})
useEffect(() => {
// Check URL for highlight parameter
const params = new URLSearchParams(window.location.search)
const highlightId = params.get('highlight')
if (highlightId) {
setHighlightedItemId(highlightId)
// Scroll the element into view after a short delay to ensure rendering
setTimeout(() => {
const element = document.getElementById(`wishlist-${highlightId}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, 100)
// Remove highlight after animation
setTimeout(() => setHighlightedItemId(null), 2000)
}
}, [])
const handleRedeem = async (item: WishlistItemType) => {
const success = await redeemWishlistItem(item)
if (success) {
setRecentlyRedeemedId(item.id)
setTimeout(() => {
setRecentlyRedeemedId(null)
}, 3000)
}
}
return (
<div className="container mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold">My Wishlist</h1>
<Button onClick={() => setIsModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Add Reward
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{wishlistItems.length === 0 ? (
<div className="col-span-2">
<EmptyState
icon={Gift}
title="Your wishlist is empty"
description="Add rewards that you'd like to earn with your coins"
/>
</div>
) : (
wishlistItems.map((item) => (
<WishlistItem
key={item.id}
item={item}
isHighlighted={item.id === highlightedItemId}
isRecentlyRedeemed={item.id === recentlyRedeemedId}
onEdit={() => {
setEditingItem(item)
setIsModalOpen(true)
}}
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
onRedeem={() => handleRedeem(item)}
canRedeem={canRedeem(item.coinCost)}
/>
))
)}
</div>
<AddEditWishlistItemModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditingItem(null)
}}
onSave={(item) => {
if (editingItem) {
editWishlistItem({ ...item, id: editingItem.id })
} else {
addWishlistItem(item)
}
setIsModalOpen(false)
setEditingItem(null)
}}
item={editingItem}
/>
<ConfirmDialog
isOpen={deleteConfirmation.isOpen}
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
onConfirm={() => {
if (deleteConfirmation.itemId) {
deleteWishlistItem(deleteConfirmation.itemId)
}
setDeleteConfirmation({ isOpen: false, itemId: null })
}}
title="Delete Reward"
message="Are you sure you want to delete this reward? This action cannot be undone."
confirmText="Delete"
/>
</div>
)
}

39
components/badge.tsx Normal file
View File

@@ -0,0 +1,39 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-100 text-green-800 hover:bg-green-200",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

65
components/calendar.tsx Normal file
View File

@@ -0,0 +1,65 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
),
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

57
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
"use client"
import * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start: "day-range-start",
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
),
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

76
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

365
components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

122
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

22
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

26
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

159
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,159 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

129
components/ui/toast.tsx Normal file
View File

@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

35
components/ui/toaster.tsx Normal file
View File

@@ -0,0 +1,35 @@
"use client"
import { useToast } from "@/hooks/use-toast"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}