mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-01-11 15:34:54 +00:00
Add src
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# Node.js
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
/dist
|
||||
/build
|
||||
|
||||
# Vite
|
||||
.vite
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
|
||||
# Tailwind
|
||||
.tailwind*
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Others
|
||||
*.tgz
|
||||
|
||||
# Optional: lock files
|
||||
# Uncomment if you want to ignore lock files
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
# pnpm-lock.yaml
|
||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Habit Tracker App</title>
|
||||
<meta name="description" content="Track your habits and visualize your progress with HabitGrid." />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
</head>
|
||||
<body class="bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
10145
package-lock.json
generated
Normal file
10145
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
51
package.json
Normal file
51
package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "habitgrid",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host :: --port 3000",
|
||||
"build": "node tools/generate-llms.js || true && vite build",
|
||||
"preview": "vite preview --host :: --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-avatar": "^1.0.3",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.0.0",
|
||||
"framer-motion": "^10.16.4",
|
||||
"lucide-react": "^0.285.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/generator": "^7.27.0",
|
||||
"@babel/parser": "^7.27.0",
|
||||
"@babel/traverse": "^7.27.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@types/node": "^20.8.3",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"terser": "^5.39.0",
|
||||
"vite": "^7.1.9"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
31
src/App.jsx
Normal file
31
src/App.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import HomePage from './pages/HomePage';
|
||||
import HabitDetailPage from './pages/HabitDetailPage';
|
||||
import AddEditHabitPage from './pages/AddEditHabitPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Helmet>
|
||||
<title>HabitGrid - Commit to yourself, one square at a time</title>
|
||||
<meta name="description" content="Track your habits with a beautiful GitHub-style contribution grid. Build streaks, visualize progress, and commit to yourself daily." />
|
||||
</Helmet>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/habit/:id" element={<HabitDetailPage />} />
|
||||
<Route path="/add" element={<AddEditHabitPage />} />
|
||||
<Route path="/edit/:id" element={<AddEditHabitPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
17
src/components/CallToAction.jsx
Normal file
17
src/components/CallToAction.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const CallToAction = () => {
|
||||
return (
|
||||
<motion.p
|
||||
className='text-md text-white max-w-lg mx-auto'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.8 }}
|
||||
>
|
||||
Let's turn your ideas into reality.
|
||||
</motion.p>
|
||||
);
|
||||
};
|
||||
|
||||
export default CallToAction;
|
||||
65
src/components/ColorPicker.jsx
Normal file
65
src/components/ColorPicker.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#22c55e', // green
|
||||
'#3b82f6', // blue
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#06b6d4', // cyan
|
||||
'#10b981', // emerald
|
||||
'#6366f1', // indigo
|
||||
'#f97316', // orange
|
||||
];
|
||||
|
||||
const ColorPicker = ({ selectedColor, onColorChange }) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<motion.button
|
||||
key={color}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => onColorChange(color)}
|
||||
className="relative w-12 h-12 rounded-xl transition-all"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
boxShadow: selectedColor === color
|
||||
? `0 0 0 3px ${color}40`
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
>
|
||||
<Check className="w-6 h-6 text-white drop-shadow-lg" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={selectedColor}
|
||||
onChange={(e) => onColorChange(e.target.value)}
|
||||
className="w-12 h-12 rounded-xl cursor-pointer border-2 border-slate-200 dark:border-slate-700"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Custom Color</p>
|
||||
<p className="text-xs text-muted-foreground">{selectedColor}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
34
src/components/DeleteHabitDialog.jsx
Normal file
34
src/components/DeleteHabitDialog.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from './ui/alert-dialog';
|
||||
|
||||
const DeleteHabitDialog = ({ open, onOpenChange, onConfirm, habitName }) => {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Habit?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{habitName}"? This will permanently remove all progress and cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteHabitDialog;
|
||||
54
src/components/HabitCard.jsx
Normal file
54
src/components/HabitCard.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ChevronRight, Flame } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import MiniGrid from './MiniGrid';
|
||||
|
||||
const HabitCard = ({ habit, onUpdate }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl p-5 shadow-sm border border-slate-200 dark:border-slate-700 cursor-pointer transition-all hover:shadow-md"
|
||||
onClick={() => navigate(`/habit/${habit.id}`)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-3 h-3 rounded"
|
||||
style={{ backgroundColor: habit.color }}
|
||||
/>
|
||||
<h3 className="font-semibold text-lg">{habit.name}</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Flame className="w-4 h-4 text-orange-500" />
|
||||
<span>{habit.currentStreak || 0} day streak</span>
|
||||
</div>
|
||||
<span>•</span>
|
||||
<span>Personal Record: {habit.longestStreak || 0} days</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/habit/${habit.id}`);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<MiniGrid habit={habit} onUpdate={onUpdate} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitCard;
|
||||
120
src/components/HabitGrid.jsx
Normal file
120
src/components/HabitGrid.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getColorIntensity, isToday, formatDate, getWeekdayLabel } from '../lib/utils-habit';
|
||||
import { toggleCompletion } from '../lib/storage';
|
||||
|
||||
const HabitGrid = ({ habit, onUpdate, fullView = false }) => {
|
||||
const weeks = useMemo(() => {
|
||||
const today = new Date();
|
||||
// Find the Monday of the current week
|
||||
const todayDay = today.getDay(); // 0=Sun, 1=Mon, ...
|
||||
const daysSinceMonday = (todayDay + 6) % 7; // 0=Mon, 1=Tue, ..., 6=Sun
|
||||
const mondayThisWeek = new Date(today);
|
||||
mondayThisWeek.setDate(today.getDate() - daysSinceMonday);
|
||||
|
||||
const weeksArray = [];
|
||||
const totalWeeks = fullView ? 52 : 12;
|
||||
for (let week = totalWeeks - 1; week >= 0; week--) {
|
||||
const weekDays = [];
|
||||
// For each week, calculate Monday, then add 0..6 days for each row
|
||||
const monday = new Date(mondayThisWeek);
|
||||
monday.setDate(mondayThisWeek.getDate() - week * 7);
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const date = new Date(monday);
|
||||
date.setDate(monday.getDate() + day);
|
||||
weekDays.push(date);
|
||||
}
|
||||
weeksArray.push(weekDays);
|
||||
}
|
||||
return weeksArray;
|
||||
}, [fullView]);
|
||||
|
||||
const handleCellClick = (date) => {
|
||||
toggleCompletion(habit.id, formatDate(date));
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-lg font-semibold mb-1">Activity Calendar</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tap any day to mark it as complete
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto grid-scroll">
|
||||
<div className="inline-flex gap-1">
|
||||
{/* Weekday labels: Monday (top) to Sunday (bottom) */}
|
||||
<div className="flex flex-col gap-1 mr-2">
|
||||
<div className="h-3" />
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<div
|
||||
key={day}
|
||||
className="h-3 flex items-center justify-end text-xs text-muted-foreground pr-1"
|
||||
>
|
||||
{getWeekdayLabel(day)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grid: Monday (top) to Sunday (bottom) */}
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<div key={weekIndex} className="flex flex-col gap-1">
|
||||
{/* Month label */}
|
||||
<div className="h-3 text-xs text-muted-foreground text-center">
|
||||
{weekIndex % 4 === 0 && week[0].toLocaleDateString('en-US', { month: 'short' })}
|
||||
</div>
|
||||
{/* Days: Monday (top) to Sunday (bottom) */}
|
||||
{week.map((date, dayIndex) => {
|
||||
const dateStr = formatDate(date);
|
||||
const isCompleted = habit.completions.includes(dateStr);
|
||||
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
|
||||
const isTodayCell = isToday(date);
|
||||
const isFuture = date > new Date();
|
||||
return (
|
||||
<motion.button
|
||||
key={dayIndex}
|
||||
whileHover={{ scale: 1.15 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={() => handleCellClick(date)}
|
||||
className="habit-cell w-3 h-3 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: isCompleted ? habit.color : 'transparent',
|
||||
opacity: isFuture ? 0 : (isCompleted ? 0.3 + (intensity * 0.7) : 1),
|
||||
border: isTodayCell ? `2px solid ${habit.color}` : `1px solid ${habit.color}20`,
|
||||
pointerEvents: isFuture ? 'none' : 'auto',
|
||||
visibility: isFuture ? 'hidden' : 'visible',
|
||||
}}
|
||||
title={`${dateStr}${isCompleted ? ' ✓' : ''}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-2 mt-4 text-xs text-muted-foreground">
|
||||
<span>Less</span>
|
||||
<div className="flex gap-1">
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((intensity, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-3 h-3 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: habit.color,
|
||||
opacity: 0.3 + (intensity * 0.7),
|
||||
border: `1px solid ${habit.color}20`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitGrid;
|
||||
54
src/components/MiniGrid.jsx
Normal file
54
src/components/MiniGrid.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getColorIntensity, isToday, formatDate } from '../lib/utils-habit';
|
||||
import { toggleCompletion } from '../lib/storage';
|
||||
|
||||
const MiniGrid = ({ habit, onUpdate }) => {
|
||||
const today = new Date();
|
||||
const days = [];
|
||||
|
||||
for (let i = 27; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
days.push(date);
|
||||
}
|
||||
|
||||
const handleCellClick = (e, date) => {
|
||||
e.stopPropagation();
|
||||
toggleCompletion(habit.id, formatDate(date));
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto grid-scroll pb-2">
|
||||
{days.map((date, index) => {
|
||||
const dateStr = formatDate(date);
|
||||
const isCompleted = habit.completions.includes(dateStr);
|
||||
const intensity = isCompleted ? getColorIntensity(habit.completions, dateStr) : 0;
|
||||
const isTodayCell = isToday(date);
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={{ scale: 0.9 }}
|
||||
whileTap={{ scale: 0.5 }}
|
||||
onClick={(e) => handleCellClick(e, date)}
|
||||
className="habit-cell flex w-8 h-8 rounded-2xl transition-all"
|
||||
style={{
|
||||
backgroundColor: isCompleted
|
||||
? habit.color
|
||||
: 'transparent',
|
||||
opacity: isCompleted ? 0.3 + (intensity * 0.7) : 1,
|
||||
border: isTodayCell
|
||||
? `2px solid ${habit.color}`
|
||||
: `1px solid ${habit.color}20`,
|
||||
}}
|
||||
title={dateStr}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniGrid;
|
||||
103
src/components/ui/alert-dialog.jsx
Normal file
103
src/components/ui/alert-dialog.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { buttonVariants } from './button';
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = ({ ...props }) => (
|
||||
<AlertDialogPrimitive.Portal {...props} />
|
||||
);
|
||||
AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.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}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||
|
||||
const AlertDialogFooter = ({ className, ...props }) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||
|
||||
const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
47
src/components/ui/button.jsx
Normal file
47
src/components/ui/button.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef(({ 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 };
|
||||
19
src/components/ui/input.jsx
Normal file
19
src/components/ui/input.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
19
src/components/ui/label.jsx
Normal file
19
src/components/ui/label.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva } 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(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
23
src/components/ui/switch.jsx
Normal file
23
src/components/ui/switch.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Switch = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
101
src/components/ui/toast.jsx
Normal file
101
src/components/ui/toast.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef(({ 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(
|
||||
'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background border',
|
||||
destructive:
|
||||
'group destructive border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef(({ 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 ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-destructive/30 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(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 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(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
};
|
||||
34
src/components/ui/toaster.jsx
Normal file
34
src/components/ui/toaster.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from './toast';
|
||||
import { useToast } from './use-toast';
|
||||
import React from 'react';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ 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>
|
||||
);
|
||||
}
|
||||
103
src/components/ui/use-toast.js
Normal file
103
src/components/ui/use-toast.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
|
||||
let count = 0
|
||||
function generateId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
const toastStore = {
|
||||
state: {
|
||||
toasts: [],
|
||||
},
|
||||
listeners: [],
|
||||
|
||||
getState: () => toastStore.state,
|
||||
|
||||
setState: (nextState) => {
|
||||
if (typeof nextState === 'function') {
|
||||
toastStore.state = nextState(toastStore.state)
|
||||
} else {
|
||||
toastStore.state = { ...toastStore.state, ...nextState }
|
||||
}
|
||||
|
||||
toastStore.listeners.forEach(listener => listener(toastStore.state))
|
||||
},
|
||||
|
||||
subscribe: (listener) => {
|
||||
toastStore.listeners.push(listener)
|
||||
return () => {
|
||||
toastStore.listeners = toastStore.listeners.filter(l => l !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toast = ({ ...props }) => {
|
||||
const id = generateId()
|
||||
|
||||
const update = (props) =>
|
||||
toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === id ? { ...t, ...props } : t
|
||||
),
|
||||
}))
|
||||
|
||||
const dismiss = () => toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}))
|
||||
|
||||
toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: [
|
||||
{ ...props, id, dismiss },
|
||||
...state.toasts,
|
||||
].slice(0, TOAST_LIMIT),
|
||||
}))
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [state, setState] = useState(toastStore.getState())
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = toastStore.subscribe((state) => {
|
||||
setState(state)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = []
|
||||
|
||||
state.toasts.forEach((toast) => {
|
||||
if (toast.duration === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toast.dismiss()
|
||||
}, toast.duration || 5000)
|
||||
|
||||
timeouts.push(timeout)
|
||||
})
|
||||
|
||||
return () => {
|
||||
timeouts.forEach((timeout) => clearTimeout(timeout))
|
||||
}
|
||||
}, [state.toasts])
|
||||
|
||||
return {
|
||||
toast,
|
||||
toasts: state.toasts,
|
||||
}
|
||||
}
|
||||
97
src/index.css
Normal file
97
src/index.css
Normal file
@@ -0,0 +1,97 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 142 76% 36%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 142 76% 36%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 30% 8%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 30% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 30% 8%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 142 76% 36%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 217.2 20% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 20% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 20% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 20% 17.5%;
|
||||
--input: 217.2 20% 17.5%;
|
||||
--ring: 142 76% 36%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
.habit-cell {
|
||||
transition: all 0.1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.habit-cell:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.habit-cell:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.grid-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.grid-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .grid-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
143
src/lib/storage.js
Normal file
143
src/lib/storage.js
Normal file
@@ -0,0 +1,143 @@
|
||||
const STORAGE_KEY = 'habitgrid_data';
|
||||
|
||||
export const getHabits = () => {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (error) {
|
||||
console.error('Error loading habits:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getHabit = (id) => {
|
||||
const habits = getHabits();
|
||||
return habits.find(h => h.id === id);
|
||||
};
|
||||
|
||||
export const saveHabit = (habit) => {
|
||||
const habits = getHabits();
|
||||
const newHabit = {
|
||||
...habit,
|
||||
id: Date.now().toString(),
|
||||
};
|
||||
habits.push(newHabit);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||
return newHabit;
|
||||
};
|
||||
|
||||
export const updateHabit = (id, updates) => {
|
||||
const habits = getHabits();
|
||||
const index = habits.findIndex(h => h.id === id);
|
||||
if (index !== -1) {
|
||||
habits[index] = { ...habits[index], ...updates };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteHabit = (id) => {
|
||||
const habits = getHabits();
|
||||
const filtered = habits.filter(h => h.id !== id);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
};
|
||||
|
||||
export const toggleCompletion = (habitId, dateStr) => {
|
||||
const habits = getHabits();
|
||||
const habit = habits.find(h => h.id === habitId);
|
||||
|
||||
if (!habit) return;
|
||||
|
||||
const completions = habit.completions || [];
|
||||
const index = completions.indexOf(dateStr);
|
||||
|
||||
if (index > -1) {
|
||||
completions.splice(index, 1);
|
||||
} else {
|
||||
completions.push(dateStr);
|
||||
}
|
||||
|
||||
const { currentStreak, longestStreak } = calculateStreaks(completions);
|
||||
|
||||
updateHabit(habitId, {
|
||||
completions,
|
||||
currentStreak,
|
||||
longestStreak: Math.max(longestStreak, habit.longestStreak || 0),
|
||||
});
|
||||
};
|
||||
|
||||
const calculateStreaks = (completions) => {
|
||||
if (completions.length === 0) {
|
||||
return { currentStreak: 0, longestStreak: 0 };
|
||||
}
|
||||
|
||||
const sortedDates = completions
|
||||
.map(d => new Date(d))
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
let currentStreak = 0;
|
||||
let longestStreak = 0;
|
||||
let tempStreak = 1;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const mostRecent = sortedDates[0];
|
||||
mostRecent.setHours(0, 0, 0, 0);
|
||||
|
||||
if (mostRecent.getTime() === today.getTime() || mostRecent.getTime() === yesterday.getTime()) {
|
||||
currentStreak = 1;
|
||||
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const current = new Date(sortedDates[i]);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const previous = new Date(sortedDates[i - 1]);
|
||||
previous.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
currentStreak++;
|
||||
tempStreak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tempStreak = 1;
|
||||
for (let i = 1; i < sortedDates.length; i++) {
|
||||
const current = new Date(sortedDates[i]);
|
||||
current.setHours(0, 0, 0, 0);
|
||||
const previous = new Date(sortedDates[i - 1]);
|
||||
previous.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffDays = Math.floor((previous - current) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 1) {
|
||||
tempStreak++;
|
||||
longestStreak = Math.max(longestStreak, tempStreak);
|
||||
} else {
|
||||
tempStreak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
longestStreak = Math.max(longestStreak, currentStreak, 1);
|
||||
|
||||
return { currentStreak, longestStreak };
|
||||
};
|
||||
|
||||
export const exportData = () => {
|
||||
const habits = getHabits();
|
||||
return JSON.stringify(habits, null, 2);
|
||||
};
|
||||
|
||||
export const importData = (jsonString) => {
|
||||
const habits = JSON.parse(jsonString);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(habits));
|
||||
};
|
||||
|
||||
export const clearAllData = () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
};
|
||||
42
src/lib/utils-habit.js
Normal file
42
src/lib/utils-habit.js
Normal file
@@ -0,0 +1,42 @@
|
||||
export const formatDate = (date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export const isToday = (date) => {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
export const getColorIntensity = (completions, dateStr) => {
|
||||
const index = completions.indexOf(dateStr);
|
||||
if (index === -1) return 0;
|
||||
|
||||
let streak = 1;
|
||||
const date = new Date(dateStr);
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const prevDate = new Date(date);
|
||||
prevDate.setDate(prevDate.getDate() - i);
|
||||
const prevDateStr = formatDate(prevDate);
|
||||
|
||||
if (completions.includes(prevDateStr)) {
|
||||
streak++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(streak / 10, 1);
|
||||
};
|
||||
|
||||
export const getWeekdayLabel = (dayIndex) => {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return labels[dayIndex];
|
||||
};
|
||||
6
src/lib/utils.js
Normal file
6
src/lib/utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
8
src/main.jsx
Normal file
8
src/main.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<App />
|
||||
);
|
||||
182
src/pages/AddEditHabitPage.jsx
Normal file
182
src/pages/AddEditHabitPage.jsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Save } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import ColorPicker from '../components/ColorPicker';
|
||||
import { getHabit, saveHabit, updateHabit } from '../lib/storage';
|
||||
|
||||
const AddEditHabitPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const isEdit = !!id;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#22c55e');
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
const habit = getHabit(id);
|
||||
if (habit) {
|
||||
setName(habit.name);
|
||||
setColor(habit.color);
|
||||
} else {
|
||||
toast({
|
||||
title: "Habit not found",
|
||||
description: "This habit doesn't exist or was deleted.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate('/');
|
||||
}
|
||||
}
|
||||
}, [id, isEdit, navigate, toast]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!name.trim()) {
|
||||
toast({
|
||||
title: "Name required",
|
||||
description: "Please enter a habit name.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
updateHabit(id, { name: name.trim(), color });
|
||||
toast({
|
||||
title: "✅ Habit updated",
|
||||
description: "Your habit has been updated successfully.",
|
||||
});
|
||||
} else {
|
||||
saveHabit({
|
||||
name: name.trim(),
|
||||
color,
|
||||
completions: [],
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
toast({
|
||||
title: "✅ Habit created",
|
||||
description: "Your new habit is ready to track!",
|
||||
});
|
||||
}
|
||||
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-3 mb-8"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{isEdit ? 'Edit Habit' : 'Create New Habit'}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isEdit ? 'Update your habit details' : 'Start building a new habit'}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Form */}
|
||||
<motion.form
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 space-y-6">
|
||||
{/* Name Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Habit Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="e.g., Morning Exercise, Read 30 Minutes, Meditate"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-lg"
|
||||
maxLength={50}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{name.length}/50 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Color Picker */}
|
||||
<div className="space-y-2">
|
||||
<Label>Habit Color</Label>
|
||||
<ColorPicker selectedColor={color} onColorChange={setColor} />
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label>Preview</Label>
|
||||
<div className="bg-slate-50 dark:bg-slate-900 rounded-xl p-4 border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="font-medium">{name || 'Your Habit Name'}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[...Array(14)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-3 h-3 rounded-sm"
|
||||
style={{
|
||||
backgroundColor: i < 7 ? color : 'transparent',
|
||||
opacity: i < 7 ? 0.3 + (i * 0.1) : 1,
|
||||
border: `1px solid ${color}20`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate(-1)}
|
||||
className="flex-1 rounded-xl"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 rounded-xl"
|
||||
>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{isEdit ? 'Update Habit' : 'Create Habit'}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddEditHabitPage;
|
||||
167
src/pages/HabitDetailPage.jsx
Normal file
167
src/pages/HabitDetailPage.jsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Edit2, Trash2, TrendingUp, Target, Calendar } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import HabitGrid from '../components/HabitGrid';
|
||||
import DeleteHabitDialog from '../components/DeleteHabitDialog';
|
||||
import { getHabit, deleteHabit } from '../lib/storage';
|
||||
|
||||
const HabitDetailPage = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [habit, setHabit] = useState(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadHabit();
|
||||
}, [id]);
|
||||
|
||||
const loadHabit = () => {
|
||||
const loadedHabit = getHabit(id);
|
||||
if (!loadedHabit) {
|
||||
toast({
|
||||
title: "Habit not found",
|
||||
description: "This habit doesn't exist or was deleted.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate('/');
|
||||
return;
|
||||
}
|
||||
setHabit(loadedHabit);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteHabit(id);
|
||||
toast({
|
||||
title: "✅ Habit deleted",
|
||||
description: "Your habit has been removed successfully.",
|
||||
});
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
if (!habit) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-pulse text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Find the oldest completion date
|
||||
let oldestDate = new Date();
|
||||
if (habit.completions.length > 0) {
|
||||
oldestDate = new Date(habit.completions.reduce((min, d) => d < min ? d : min, habit.completions[0]));
|
||||
}
|
||||
|
||||
const completionRate = habit.completions.length > 0
|
||||
? Math.round((habit.completions.length / Math.max(1, Math.ceil((Date.now() - oldestDate.getTime()) / (1000 * 60 * 60 * 24)))) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
|
||||
<div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between mb-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{habit.name}</h1>
|
||||
<p className="text-sm text-muted-foreground">Track your daily progress</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/edit/${id}`)}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Edit2 className="w-5 h-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
className="rounded-full text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8"
|
||||
>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-400 to-red-500 flex items-center justify-center">
|
||||
<TrendingUp className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Current Streak</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold">{habit.currentStreak || 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">days in a row</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-400 to-pink-500 flex items-center justify-center">
|
||||
<Target className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Longest Streak</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold">{habit.longestStreak || 0}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">personal best</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-400 to-cyan-500 flex items-center justify-center">
|
||||
<Calendar className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Consistency Score!</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold">{completionRate}%</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">overall progress</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Habit Grid */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<HabitGrid habit={habit} onUpdate={loadHabit} fullView />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<DeleteHabitDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
habitName={habit.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HabitDetailPage;
|
||||
162
src/pages/HomePage.jsx
Normal file
162
src/pages/HomePage.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Plus, Settings, TrendingUp, Flame, Calendar } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import HabitCard from '../components/HabitCard';
|
||||
import { getHabits } from '../lib/storage';
|
||||
|
||||
const HomePage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [habits, setHabits] = useState([]);
|
||||
const [isPremium] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadHabits();
|
||||
}, []);
|
||||
|
||||
const loadHabits = () => {
|
||||
const loadedHabits = getHabits();
|
||||
setHabits(loadedHabits);
|
||||
};
|
||||
|
||||
const handleAddHabit = () => {
|
||||
if (!isPremium && habits.length >= 1000) {
|
||||
toast({
|
||||
title: "🔒 Premium Feature",
|
||||
description: "Free tier limited to 1000 habits. Upgrade to unlock unlimited habits!",
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate('/add');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950">
|
||||
<div className="max-w-6xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center justify-between mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-green-400 to-emerald-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<div className="grid grid-cols-4 gap-0.5">
|
||||
{[...Array(11)].map((_, i) => (
|
||||
<div key={i} className="w-1.5 h-1.5 bg-white rounded-sm opacity-90" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-slate-900 to-slate-700 dark:from-white dark:to-slate-300 bg-clip-text text-transparent">
|
||||
HabitGrid
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">Commit to yourself, one square at a time</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/settings')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-2 sm:grid-cols-2 gap-4 mb-8"
|
||||
>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-4 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground">Active Habits</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">{habits.length}</p>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl p-4 shadow-sm border border-slate-200 dark:border-slate-700">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TrendingUp className="w-4 h-4 text-green-500" />
|
||||
<span className="text-xs font-medium text-muted-foreground">Total Streaks</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold">
|
||||
{habits.reduce((sum, h) => sum + (h.currentStreak || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Habits List */}
|
||||
<div className="space-y-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{habits.map((habit, index) => (
|
||||
<motion.div
|
||||
key={habit.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<HabitCard habit={habit} onUpdate={loadHabits} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{habits.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-16"
|
||||
>
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-green-100 to-emerald-100 dark:from-green-900/20 dark:to-emerald-900/20 rounded-3xl flex items-center justify-center mx-auto mb-6">
|
||||
<Flame className="w-12 h-12 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold mb-2">Create your grid!</h2>
|
||||
<p className="text-muted-foreground mb-8 max-w-md mx-auto">
|
||||
Create your first habit and watch your progress every day as you fill in the squares. Small steps lead to big changes!
|
||||
</p>
|
||||
<Button
|
||||
onClick={handleAddHabit}
|
||||
size="lg"
|
||||
className="rounded-full shadow-lg hover:shadow-xl transition-shadow"
|
||||
>
|
||||
<Plus className="w-5 h-5 mr-2" />
|
||||
Create First Habit
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Add Button */}
|
||||
{habits.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="fixed bottom-6 right-6"
|
||||
>
|
||||
<Button
|
||||
onClick={handleAddHabit}
|
||||
size="lg"
|
||||
className="rounded-full w-14 h-14 shadow-2xl hover:shadow-3xl transition-all hover:scale-110"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
228
src/pages/SettingsPage.jsx
Normal file
228
src/pages/SettingsPage.jsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowLeft, Moon, Sun, Bell, Download, Upload, Trash2 } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Switch } from '../components/ui/switch';
|
||||
import { Label } from '../components/ui/label';
|
||||
import { useToast } from '../components/ui/use-toast';
|
||||
import { exportData, importData, clearAllData } from '../lib/storage';
|
||||
|
||||
const SettingsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [notifications, setNotifications] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
setDarkMode(isDark);
|
||||
}, []);
|
||||
|
||||
const toggleDarkMode = (enabled) => {
|
||||
setDarkMode(enabled);
|
||||
if (enabled) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const data = exportData();
|
||||
const blob = new Blob([data], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `habitgrid-backup-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast({
|
||||
title: "✅ Data exported",
|
||||
description: "Your habits have been exported successfully.",
|
||||
});
|
||||
};
|
||||
|
||||
const handleImport = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/json';
|
||||
input.onchange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
importData(event.target.result);
|
||||
toast({
|
||||
title: "✅ Data imported",
|
||||
description: "Your habits have been imported successfully.",
|
||||
});
|
||||
setTimeout(() => navigate('/'), 500);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Import failed",
|
||||
description: "Invalid backup file format.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const handleClearData = () => {
|
||||
if (window.confirm('Are you sure you want to delete all habits? This action cannot be undone.')) {
|
||||
clearAllData();
|
||||
toast({
|
||||
title: "✅ Data cleared",
|
||||
description: "All habits have been deleted.",
|
||||
});
|
||||
setTimeout(() => navigate('/'), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationToggle = (enabled) => {
|
||||
setNotifications(enabled);
|
||||
toast({
|
||||
title: "🚧 Feature coming soon",
|
||||
description: "Daily reminders will be available in a future update!",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-3 mb-8"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate('/')}
|
||||
className="rounded-full"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">Customize your experience</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Appearance */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4">Appearance</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{darkMode ? <Moon className="w-5 h-5" /> : <Sun className="w-5 h-5" />}
|
||||
<div>
|
||||
<Label htmlFor="dark-mode" className="text-base">Dark Mode</Label>
|
||||
<p className="text-sm text-muted-foreground">Toggle dark theme</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="dark-mode"
|
||||
checked={darkMode}
|
||||
onCheckedChange={toggleDarkMode}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Notifications */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4">Notifications</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="w-5 h-5" />
|
||||
<div>
|
||||
<Label htmlFor="notifications" className="text-base">Daily Reminders</Label>
|
||||
<p className="text-sm text-muted-foreground">Get reminded to track habits</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifications"
|
||||
checked={notifications}
|
||||
onCheckedChange={handleNotificationToggle}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Data Management */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700 space-y-3"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-4">Data Management</h2>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Data
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start"
|
||||
onClick={handleImport}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import Data
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-destructive hover:text-destructive"
|
||||
onClick={handleClearData}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Clear All Data
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* About */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white dark:bg-slate-800 rounded-2xl p-6 shadow-sm border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<h2 className="text-lg font-semibold mb-2">About HabitGrid</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Version 1.0.0 • Built with ❤️ for habit builders
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Track your habits with a beautiful GitHub-style contribution grid.
|
||||
Build streaks, visualize progress, and commit to yourself daily.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
76
tailwind.config.js
Normal file
76
tailwind.config.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,jsx}',
|
||||
'./components/**/*.{js,jsx}',
|
||||
'./app/**/*.{js,jsx}',
|
||||
'./src/**/*.{js,jsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
Reference in New Issue
Block a user