mirror of
https://github.com/nagaoo0/HabbitGrid.git
synced 2026-01-11 23:44:55 +00:00
494 lines
22 KiB
JavaScript
494 lines
22 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
|
|
// ...existing code...
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Plus, Settings, TrendingUp, Flame, Calendar, Moon, Sun, Star } from 'lucide-react';
|
|
import { Button } from '../components/ui/button';
|
|
import { useToast } from '../components/ui/use-toast';
|
|
import HabitCard from '../components/HabitCard';
|
|
import AnimatedCounter from '../components/AnimatedCounter';
|
|
import GitActivityGrid from '../components/GitActivityGrid';
|
|
import { getGitEnabled } from '../lib/git';
|
|
import { getHabits, updateHabit, syncLocalToRemoteIfNeeded, syncRemoteToLocal, getAuthUser, hasEverLoggedIn } from '../lib/datastore';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
const HomePage = () => {
|
|
const navigate = useNavigate();
|
|
const { toast } = useToast();
|
|
const [habits, setHabits] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loggedIn, setLoggedIn] = useState(false);
|
|
const [collapsedGroups, setCollapsedGroups] = useState({});
|
|
const [everLoggedIn, setEverLoggedIn] = useState(false);
|
|
const [gitEnabled, setGitEnabled] = useState(getGitEnabled());
|
|
const [darkMode, setDarkMode] = useState(() => {
|
|
return localStorage.getItem('theme') === 'dark';
|
|
});
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
setLoading(true);
|
|
// On login, pull remote habits into localStorage
|
|
const user = await getAuthUser();
|
|
setLoggedIn(!!user);
|
|
// Mark whether this browser has seen a login before
|
|
try {
|
|
setEverLoggedIn(hasEverLoggedIn());
|
|
} catch (e) {
|
|
setEverLoggedIn(false);
|
|
}
|
|
if (user) {
|
|
await syncRemoteToLocal();
|
|
}
|
|
await loadHabits();
|
|
setGitEnabled(getGitEnabled());
|
|
setLoading(false);
|
|
})();
|
|
// Background sync every 10s if logged in
|
|
const interval = setInterval(() => {
|
|
syncLocalToRemoteIfNeeded();
|
|
}, 10000);
|
|
// Listen for remote sync event to reload habits
|
|
const syncListener = () => loadHabits();
|
|
window.addEventListener('habitgrid-sync-updated', syncListener);
|
|
return () => {
|
|
clearInterval(interval);
|
|
window.removeEventListener('habitgrid-sync-updated', syncListener);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (darkMode) {
|
|
document.documentElement.classList.add('dark');
|
|
localStorage.setItem('theme', 'dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
localStorage.setItem('theme', 'light');
|
|
}
|
|
}, [darkMode]);
|
|
|
|
const loadHabits = async () => {
|
|
// Always read from local for instant UI
|
|
const loadedHabits = JSON.parse(localStorage.getItem('habitgrid_data') || '[]');
|
|
loadedHabits.sort((a, b) => {
|
|
if (a.sortOrder !== undefined && b.sortOrder !== undefined) return a.sortOrder - b.sortOrder;
|
|
if (a.sortOrder !== undefined) return -1;
|
|
if (b.sortOrder !== undefined) return 1;
|
|
return new Date(a.createdAt || 0) - new Date(b.createdAt || 0);
|
|
});
|
|
setHabits(loadedHabits);
|
|
// Initialize collapsed state for new categories
|
|
const categories = Array.from(new Set(loadedHabits.map(h => h.category || 'Uncategorized')));
|
|
setCollapsedGroups(prev => {
|
|
const next = { ...prev };
|
|
categories.forEach(cat => {
|
|
if (!(cat in next)) next[cat] = false;
|
|
});
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleAddHabit = () => {
|
|
navigate('/add');
|
|
};
|
|
|
|
const handleLoginSync = () => {
|
|
navigate('/login-providers');
|
|
};
|
|
|
|
const handleManualSync = async () => {
|
|
await syncLocalToRemoteIfNeeded();
|
|
toast({ title: 'Synced!', description: 'Your habits have been synced to the cloud.' });
|
|
};
|
|
|
|
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">
|
|
<img src="/assets/logo.png" alt="HabitGrid Logo" className="w-12 h-12 rounded-xl shadow-lg object-cover" />
|
|
<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>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setDarkMode((prev) => !prev)}
|
|
className="rounded-full"
|
|
aria-label="Toggle dark mode"
|
|
>
|
|
{darkMode ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate('/settings')}
|
|
className="rounded-full"
|
|
>
|
|
<Settings className="w-5 h-5" />
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Prompt previously-signed-in users to re-authenticate if currently logged out */}
|
|
{!loggedIn && everLoggedIn && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="max-w-6xl mx-auto mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/40 rounded-lg border border-yellow-100 dark:border-yellow-800 flex items-center justify-between"
|
|
>
|
|
<div className="text-sm text-yellow-800 dark:text-yellow-200">Looks like you were previously signed in. Sign in again to keep your habits synced across devices.</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button size="sm" onClick={() => navigate('/login-providers')}>Sign in</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => { localStorage.removeItem('habitgrid_ever_logged_in'); setEverLoggedIn(false); }}>Dismiss</Button>
|
|
</div>
|
|
</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">
|
|
<AnimatedCounter value={habits.reduce((sum, h) => sum + (h.currentStreak || 0), 0)} duration={900} />
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Git Activity */}
|
|
{gitEnabled && (
|
|
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.05 }} className="mb-8">
|
|
<GitActivityGrid />
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Habits List */}
|
|
{/* Grouped Habits by Category, collapsible, and uncategorized habits outside */}
|
|
<DragDropContext
|
|
onDragEnd={result => {
|
|
if (!result.destination) return;
|
|
const { source, destination } = result;
|
|
// Get all habits grouped by category
|
|
const uncategorized = habits.filter(h => !h.category);
|
|
const categorized = habits.filter(h => h.category);
|
|
const grouped = categorized.reduce((acc, habit) => {
|
|
const cat = habit.category;
|
|
if (!acc[cat]) acc[cat] = [];
|
|
acc[cat].push(habit);
|
|
return acc;
|
|
}, {});
|
|
|
|
let newHabits = [...habits];
|
|
|
|
// Helper to update local storage and UI instantly
|
|
const updateLocalOrder = (habitsArr) => {
|
|
localStorage.setItem('habitgrid_data', JSON.stringify(habitsArr));
|
|
setHabits(habitsArr);
|
|
};
|
|
|
|
// Collect async remote updates to fire after local update
|
|
let remoteUpdates = [];
|
|
|
|
if (destination.droppableId === 'uncategorized') {
|
|
let items, removed;
|
|
if (source.droppableId === 'uncategorized') {
|
|
items = Array.from(uncategorized);
|
|
[removed] = items.splice(source.index, 1);
|
|
} else {
|
|
items = Array.from(uncategorized);
|
|
const sourceItems = Array.from(grouped[source.droppableId]);
|
|
[removed] = sourceItems.splice(source.index, 1);
|
|
removed.category = '';
|
|
grouped[source.droppableId] = sourceItems;
|
|
}
|
|
removed.category = '';
|
|
items.splice(destination.index, 0, removed);
|
|
items.forEach((h, i) => {
|
|
h.sortOrder = i;
|
|
h.category = '';
|
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: '' }));
|
|
});
|
|
newHabits = [
|
|
...items,
|
|
...Object.values(grouped).flat()
|
|
];
|
|
updateLocalOrder(newHabits);
|
|
} else if (source.droppableId === 'uncategorized' && grouped[destination.droppableId]) {
|
|
const items = Array.from(uncategorized);
|
|
const [removed] = items.splice(source.index, 1);
|
|
removed.category = destination.droppableId;
|
|
const destItems = Array.from(grouped[destination.droppableId] || []);
|
|
destItems.splice(destination.index, 0, removed);
|
|
destItems.forEach((h, i) => {
|
|
h.sortOrder = i;
|
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: h.category }));
|
|
});
|
|
newHabits = [
|
|
...items,
|
|
...Object.values({ ...grouped, [destination.droppableId]: destItems }).flat()
|
|
];
|
|
updateLocalOrder(newHabits);
|
|
} else if (grouped[source.droppableId] && grouped[destination.droppableId]) {
|
|
const sourceItems = Array.from(grouped[source.droppableId]);
|
|
const [removed] = sourceItems.splice(source.index, 1);
|
|
if (source.droppableId === destination.droppableId) {
|
|
sourceItems.splice(destination.index, 0, removed);
|
|
sourceItems.forEach((h, i) => {
|
|
h.sortOrder = i;
|
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: h.category }));
|
|
});
|
|
grouped[source.droppableId] = sourceItems;
|
|
} else {
|
|
const destItems = Array.from(grouped[destination.droppableId] || []);
|
|
removed.category = destination.droppableId;
|
|
destItems.splice(destination.index, 0, removed);
|
|
destItems.forEach((h, i) => {
|
|
h.sortOrder = i;
|
|
remoteUpdates.push(updateHabit(h.id, { sortOrder: i, category: h.category }));
|
|
});
|
|
grouped[source.droppableId] = sourceItems;
|
|
grouped[destination.droppableId] = destItems;
|
|
}
|
|
newHabits = [
|
|
...uncategorized,
|
|
...Object.values(grouped).flat()
|
|
];
|
|
updateLocalOrder(newHabits);
|
|
}
|
|
// Fire remote updates async, do not block UI
|
|
Promise.allSettled(remoteUpdates);
|
|
}}
|
|
>
|
|
<div className="space-y-6">
|
|
{/* Uncategorized habits (no group panel) */}
|
|
<Droppable droppableId="uncategorized" type="HABIT">
|
|
{(provided) => (
|
|
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-4">
|
|
{habits.filter(h => !h.category).map((habit, index) => (
|
|
<Draggable key={habit.id} draggableId={habit.id} index={index}>
|
|
{(provided, snapshot) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
{...provided.dragHandleProps}
|
|
style={{ ...provided.draggableProps.style, zIndex: snapshot.isDragging ? 10 : undefined }}
|
|
>
|
|
<motion.div
|
|
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>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
{provided.placeholder}
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
{/* Group panels for named categories */}
|
|
{Object.entries(
|
|
habits.filter(h => h.category).reduce((acc, habit) => {
|
|
const cat = habit.category;
|
|
if (!acc[cat]) acc[cat] = [];
|
|
acc[cat].push(habit);
|
|
return acc;
|
|
}, {})
|
|
).map(([category, groupHabits], groupIdx) => (
|
|
<div key={category} className="bg-white/60 dark:bg-slate-800/60 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700">
|
|
<button
|
|
className="w-full flex items-center justify-between px-6 py-3 text-lg font-semibold focus:outline-none select-none hover:bg-slate-100 dark:hover:bg-slate-900 rounded-2xl transition"
|
|
onClick={() => setCollapsedGroups(prev => ({ ...prev, [category]: !prev[category] }))}
|
|
aria-expanded={!collapsedGroups[category]}
|
|
>
|
|
<span>{category}</span>
|
|
<span className={`transition-transform ${collapsedGroups[category] ? 'rotate-90' : ''}`}>▶</span>
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{!collapsedGroups[category] && (
|
|
<motion.div
|
|
key="content"
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<Droppable droppableId={category} type="HABIT">
|
|
{(provided) => (
|
|
<div ref={provided.innerRef} {...provided.droppableProps} className="space-y-4 px-4 pb-4">
|
|
{groupHabits.map((habit, index) => (
|
|
<Draggable key={habit.id} draggableId={habit.id} index={index}>
|
|
{(provided, snapshot) => (
|
|
<div
|
|
ref={provided.innerRef}
|
|
{...provided.draggableProps}
|
|
{...provided.dragHandleProps}
|
|
style={{ ...provided.draggableProps.style, zIndex: snapshot.isDragging ? 10 : undefined }}
|
|
>
|
|
<motion.div
|
|
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>
|
|
</div>
|
|
)}
|
|
</Draggable>
|
|
))}
|
|
{provided.placeholder}
|
|
</div>
|
|
)}
|
|
</Droppable>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</DragDropContext>
|
|
|
|
{/* Empty State or Loading Buffer */}
|
|
{habits.length === 0 && (
|
|
loading && loggedIn ? (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="flex flex-col items-center justify-center py-20"
|
|
>
|
|
<div className="w-16 h-16 mb-6 flex items-center justify-center">
|
|
<span className="inline-block w-12 h-12 border-4 border-emerald-400 border-t-transparent rounded-full animate-spin"></span>
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-muted-foreground">Loading your habits...</h2>
|
|
</motion.div>
|
|
) : (
|
|
<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>
|
|
|
|
{/* Call to Action for Login/Sync */}
|
|
<div className="mb-6 flex flex-col items-center mt-8">
|
|
{!loggedIn ? (
|
|
<Button
|
|
onClick={handleLoginSync}
|
|
size="lg"
|
|
className="rounded-full shadow-lg bg-emerald-600 text-white hover:bg-emerald-700 transition"
|
|
>
|
|
<Flame className="w-5 h-5 mr-2" />
|
|
Login & Sync My Habits
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleManualSync}
|
|
size="lg"
|
|
className="rounded-full shadow-lg bg-blue-600 text-white hover:bg-blue-700 transition"
|
|
>
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Sync My Habits Now
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</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 shadow-lg hover:shadow-xl transition-shadow"
|
|
>
|
|
<Plus className="" />
|
|
</Button>
|
|
</motion.div>
|
|
)}
|
|
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ delay: 0.3 }}
|
|
className="fixed bottom-6 left-6"
|
|
>
|
|
<Button
|
|
onClick={() => window.open("https://github.com/nagaoo0/HabitGrid", "_blank")}
|
|
size="lg"
|
|
className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-gray-600 hover:bg-gray-700 text-white"
|
|
>
|
|
<a
|
|
href="https://github.com/nagaoo0/HabitGrid"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center space-x-2"
|
|
>
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white" className="w-7 h-7 text-slate-700 dark:text-slate-200">
|
|
<path d="M12 2C6.477 2 2 6.484 2 12.021c0 4.428 2.865 8.186 6.839 9.525.5.092.682-.217.682-.483 0-.237-.009-.868-.014-1.703-2.782.605-3.369-1.342-3.369-1.342-.454-1.155-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.004.07 1.532 1.032 1.532 1.032.892 1.53 2.341 1.088 2.91.832.091-.646.35-1.088.636-1.34-2.221-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.254-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.025A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.295 2.748-1.025 2.748-1.025.546 1.378.202 2.396.1 2.65.64.7 1.028 1.595 1.028 2.688 0 3.847-2.337 4.695-4.566 4.944.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.579.688.481C19.138 20.204 22 16.447 22 12.021 22 6.484 17.523 2 12 2z" />
|
|
</svg>
|
|
</a>
|
|
|
|
</Button>
|
|
|
|
</motion.div>
|
|
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default HomePage; |