cleanup
This commit is contained in:
@@ -1,3 +1,224 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
addMonths, subMonths, format, isSameDay,
|
||||
startOfMonth, endOfMonth,
|
||||
} from 'date-fns';
|
||||
import { ChevronLeft, ChevronRight, Plus, CalendarDays } from 'lucide-react';
|
||||
import { api, type CalendarEvent } from '@/lib/api';
|
||||
import { useMembers } from '@/hooks/useMembers';
|
||||
import { CalendarGrid } from '@/features/calendar/CalendarGrid';
|
||||
import { EventModal } from '@/features/calendar/EventModal';
|
||||
import { DayEventsModal } from '@/features/calendar/DayEventsModal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
|
||||
export default function CalendarPage() {
|
||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Calendar</h1><p className="text-secondary mt-2">Phase 2</p></div>;
|
||||
const [month, setMonth] = useState(new Date());
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
const [dayModal, setDayModal] = useState<Date | null>(null);
|
||||
const [editEvent, setEditEvent] = useState<CalendarEvent | null>(null);
|
||||
const [addDate, setAddDate] = useState<Date | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const { data: members = [] } = useMembers();
|
||||
|
||||
const { data: events = [] } = useQuery<CalendarEvent[]>({
|
||||
queryKey: ['events', format(month, 'yyyy-MM')],
|
||||
queryFn: () => {
|
||||
const start = startOfMonth(month).toISOString();
|
||||
const end = endOfMonth(month).toISOString();
|
||||
return api.get('/events', { params: { start, end } }).then((r) => r.data);
|
||||
},
|
||||
});
|
||||
|
||||
function navigate(dir: number) {
|
||||
setDirection(dir);
|
||||
setMonth((m) => dir > 0 ? addMonths(m, 1) : subMonths(m, 1));
|
||||
}
|
||||
|
||||
function handleDayClick(date: Date) {
|
||||
setDayModal(date);
|
||||
}
|
||||
|
||||
function handleAddFromDay() {
|
||||
setAddDate(dayModal);
|
||||
setAddOpen(true);
|
||||
}
|
||||
|
||||
function handleAddNew() {
|
||||
setAddDate(null);
|
||||
setAddOpen(true);
|
||||
}
|
||||
|
||||
const dayEvents = useMemo(() => {
|
||||
if (!dayModal) return [];
|
||||
return events.filter((e) => isSameDay(new Date(e.start_at), dayModal));
|
||||
}, [dayModal, events]);
|
||||
|
||||
// Upcoming events strip (next 5 from today)
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date();
|
||||
return [...events]
|
||||
.filter((e) => new Date(e.start_at) >= now)
|
||||
.sort((a, b) => +new Date(a.start_at) - +new Date(b.start_at))
|
||||
.slice(0, 5);
|
||||
}, [events]);
|
||||
|
||||
const variants = {
|
||||
enter: (d: number) => ({ x: d > 0 ? 40 : -40, opacity: 0 }),
|
||||
center: { x: 0, opacity: 1 },
|
||||
exit: (d: number) => ({ x: d > 0 ? -40 : 40, opacity: 0 }),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ── Header ────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDays size={22} className="text-accent" />
|
||||
<AnimatePresence mode="wait" custom={direction}>
|
||||
<motion.h1
|
||||
key={format(month, 'yyyy-MM')}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.18 }}
|
||||
className="text-xl font-bold text-primary w-44"
|
||||
>
|
||||
{format(month, 'MMMM yyyy')}
|
||||
</motion.h1>
|
||||
</AnimatePresence>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="p-1.5 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDirection(0); setMonth(new Date()); }}
|
||||
className="px-3 py-1 rounded-lg text-xs font-medium text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(1)}
|
||||
className="p-1.5 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Member legend */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
{members.map((m) => (
|
||||
<div key={m.id} className="flex items-center gap-1.5">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: m.color }} />
|
||||
<span className="text-xs text-secondary">{m.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button size="sm" onClick={handleAddNew}>
|
||||
<Plus size={15} /> Event
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Main: grid + sidebar ──────────────────────────────────── */}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* Calendar grid */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<AnimatePresence mode="wait" custom={direction}>
|
||||
<motion.div
|
||||
key={format(month, 'yyyy-MM')}
|
||||
custom={direction}
|
||||
variants={variants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{ duration: 0.2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<CalendarGrid
|
||||
month={month}
|
||||
events={events}
|
||||
members={members}
|
||||
onDayClick={handleDayClick}
|
||||
onEventClick={(ev) => setEditEvent(ev)}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Upcoming sidebar */}
|
||||
{upcoming.length > 0 && (
|
||||
<aside className="hidden lg:flex flex-col w-64 shrink-0 border-l border-theme bg-surface overflow-y-auto">
|
||||
<div className="px-4 py-3 border-b border-theme">
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide">Upcoming</p>
|
||||
</div>
|
||||
<div className="p-3 flex flex-col gap-2">
|
||||
{upcoming.map((ev) => {
|
||||
const color = ev.color ?? members.find((m) => m.id === ev.member_id)?.color ?? '#6366f1';
|
||||
const member = members.find((m) => m.id === ev.member_id);
|
||||
return (
|
||||
<button
|
||||
key={ev.id}
|
||||
onClick={() => setEditEvent(ev)}
|
||||
className="w-full text-left p-3 rounded-xl border border-theme hover:border-accent/40 hover:bg-surface-raised transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-1.5 h-2 w-2 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{ev.title}</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
{format(new Date(ev.start_at), 'MMM d')}
|
||||
{!ev.all_day && ` · ${format(new Date(ev.start_at), 'h:mm a')}`}
|
||||
</p>
|
||||
{member && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<Avatar name={member.name} color={member.color} size="xs" />
|
||||
<span className="text-xs text-secondary">{member.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Modals ─────────────────────────────────────────────────── */}
|
||||
<DayEventsModal
|
||||
open={!!dayModal}
|
||||
onClose={() => setDayModal(null)}
|
||||
date={dayModal}
|
||||
events={dayEvents}
|
||||
members={members}
|
||||
onAdd={handleAddFromDay}
|
||||
onEdit={(ev) => { setDayModal(null); setEditEvent(ev); }}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
open={addOpen}
|
||||
onClose={() => { setAddOpen(false); setAddDate(null); }}
|
||||
defaultDate={addDate ?? undefined}
|
||||
/>
|
||||
|
||||
<EventModal
|
||||
open={!!editEvent}
|
||||
onClose={() => setEditEvent(null)}
|
||||
event={editEvent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,155 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, CheckSquare, ListFilter } from 'lucide-react';
|
||||
import { api, type Chore } from '@/lib/api';
|
||||
import { useMembers } from '@/hooks/useMembers';
|
||||
import { ChoreCard } from '@/features/chores/ChoreCard';
|
||||
import { ChoreModal } from '@/features/chores/ChoreModal';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type Filter = 'all' | 'pending' | 'done' | number; // number = member_id
|
||||
|
||||
export default function ChoresPage() {
|
||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Chores</h1><p className="text-secondary mt-2">Phase 2</p></div>;
|
||||
const [filter, setFilter] = useState<Filter>('pending');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editChore, setEditChore] = useState<Chore | null>(null);
|
||||
|
||||
const { data: members = [] } = useMembers();
|
||||
|
||||
const { data: chores = [], isLoading } = useQuery<Chore[]>({
|
||||
queryKey: ['chores'],
|
||||
queryFn: () => api.get('/chores').then((r) => r.data),
|
||||
});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return chores;
|
||||
if (filter === 'pending') return chores.filter((c) => c.status !== 'done');
|
||||
if (filter === 'done') return chores.filter((c) => c.status === 'done');
|
||||
return chores.filter((c) => c.member_id === filter);
|
||||
}, [chores, filter]);
|
||||
|
||||
const pendingCount = chores.filter((c) => c.status !== 'done').length;
|
||||
const doneCount = chores.filter((c) => c.status === 'done').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ── Header ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckSquare size={22} className="text-accent" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-primary leading-tight">Chores</h1>
|
||||
<p className="text-xs text-muted">
|
||||
{pendingCount} pending · {doneCount} done
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setModalOpen(true)}>
|
||||
<Plus size={15} /> Add Chore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ── Filter bar ──────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-2 px-6 py-3 border-b border-theme bg-surface shrink-0 overflow-x-auto">
|
||||
<ListFilter size={15} className="text-muted shrink-0" />
|
||||
{(
|
||||
[
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'done', label: 'Done' },
|
||||
] as { key: Filter; label: string }[]
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={String(key)}
|
||||
onClick={() => setFilter(key)}
|
||||
className={clsx(
|
||||
'px-3 py-1.5 rounded-full text-sm font-medium transition-colors whitespace-nowrap',
|
||||
filter === key
|
||||
? 'bg-accent text-white'
|
||||
: 'text-secondary hover:bg-surface-raised hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{members.length > 0 && <span className="text-muted text-xs mx-1">|</span>}
|
||||
{members.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => setFilter(filter === m.id ? 'all' : m.id)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all whitespace-nowrap',
|
||||
filter === m.id
|
||||
? 'text-white'
|
||||
: 'text-secondary hover:bg-surface-raised hover:text-primary'
|
||||
)}
|
||||
style={filter === m.id ? { backgroundColor: m.color } : {}}
|
||||
>
|
||||
<Avatar name={m.name} color={m.color} size="xs" />
|
||||
{m.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── List ────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-20 rounded-2xl bg-surface-raised animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col items-center justify-center py-20 text-center"
|
||||
>
|
||||
<div className="text-5xl mb-4">
|
||||
{filter === 'done' ? '🎉' : '✅'}
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-primary mb-1">
|
||||
{filter === 'done' ? 'Nothing completed yet' : 'All caught up!'}
|
||||
</p>
|
||||
<p className="text-secondary text-sm mb-6">
|
||||
{filter === 'done'
|
||||
? 'Complete a chore and it will appear here.'
|
||||
: 'No chores match this filter.'}
|
||||
</p>
|
||||
{filter === 'pending' && (
|
||||
<Button onClick={() => setModalOpen(true)}>
|
||||
<Plus size={16} /> Add First Chore
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<AnimatePresence initial={false}>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filtered.map((chore) => (
|
||||
<ChoreCard
|
||||
key={chore.id}
|
||||
chore={chore}
|
||||
onEdit={(c) => setEditChore(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Modals ──────────────────────────────────────────────── */}
|
||||
<ChoreModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
<ChoreModal
|
||||
open={!!editChore}
|
||||
onClose={() => setEditChore(null)}
|
||||
chore={editChore}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,292 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Plus, ShoppingCart, Trash2, ListPlus, X } from 'lucide-react';
|
||||
import { api, type ShoppingList, type ShoppingItem } from '@/lib/api';
|
||||
import { useMembers } from '@/hooks/useMembers';
|
||||
import { ShoppingItemRow } from '@/features/shopping/ShoppingItemRow';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
export default function ShoppingPage() {
|
||||
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Shopping</h1><p className="text-secondary mt-2">Phase 2</p></div>;
|
||||
const qc = useQueryClient();
|
||||
const { data: members = [] } = useMembers();
|
||||
|
||||
const [activeListId, setActiveListId] = useState<number | null>(null);
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [newItemQty, setNewItemQty] = useState('');
|
||||
const [newListOpen, setNewListOpen] = useState(false);
|
||||
const [newListName, setNewListName] = useState('');
|
||||
const [deleteListOpen, setDeleteListOpen] = useState(false);
|
||||
|
||||
const addInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── Lists ──────────────────────────────────────────────────────
|
||||
const { data: lists = [] } = useQuery<ShoppingList[]>({
|
||||
queryKey: ['shopping-lists'],
|
||||
queryFn: () => api.get('/shopping/lists').then((r) => r.data),
|
||||
});
|
||||
|
||||
// Auto-select first list
|
||||
useEffect(() => {
|
||||
if (lists.length > 0 && activeListId === null) {
|
||||
setActiveListId(lists[0].id);
|
||||
}
|
||||
}, [lists, activeListId]);
|
||||
|
||||
const createListMutation = useMutation({
|
||||
mutationFn: (name: string) => api.post('/shopping/lists', { name }).then((r) => r.data),
|
||||
onSuccess: (list: ShoppingList) => {
|
||||
qc.invalidateQueries({ queryKey: ['shopping-lists'] });
|
||||
setActiveListId(list.id);
|
||||
setNewListOpen(false);
|
||||
setNewListName('');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteListMutation = useMutation({
|
||||
mutationFn: (id: number) => api.delete(`/shopping/lists/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['shopping-lists'] });
|
||||
setActiveListId(null);
|
||||
setDeleteListOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Items ──────────────────────────────────────────────────────
|
||||
const { data: items = [], isLoading: itemsLoading } = useQuery<ShoppingItem[]>({
|
||||
queryKey: ['shopping-items', activeListId],
|
||||
queryFn: () =>
|
||||
activeListId
|
||||
? api.get(`/shopping/lists/${activeListId}/items`).then((r) => r.data)
|
||||
: Promise.resolve([]),
|
||||
enabled: !!activeListId,
|
||||
});
|
||||
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: (body: { name: string; quantity?: string }) =>
|
||||
api.post(`/shopping/lists/${activeListId}/items`, body).then((r) => r.data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['shopping-items', activeListId] });
|
||||
setNewItemText('');
|
||||
setNewItemQty('');
|
||||
addInputRef.current?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
const clearCheckedMutation = useMutation({
|
||||
mutationFn: () => api.delete(`/shopping/lists/${activeListId}/items/checked`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping-items', activeListId] }),
|
||||
});
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (!newItemText.trim() || !activeListId) return;
|
||||
addItemMutation.mutate({
|
||||
name: newItemText.trim(),
|
||||
quantity: newItemQty.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const checkedCount = items.filter((i) => i.checked).length;
|
||||
const pendingCount = items.length - checkedCount;
|
||||
const currentList = lists.find((l) => l.id === activeListId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* ── Header ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShoppingCart size={22} className="text-accent" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-primary leading-tight">Shopping</h1>
|
||||
{currentList && (
|
||||
<p className="text-xs text-muted">
|
||||
{pendingCount} item{pendingCount !== 1 ? 's' : ''} remaining
|
||||
{checkedCount > 0 && ` · ${checkedCount} checked`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{checkedCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => clearCheckedMutation.mutate()}
|
||||
loading={clearCheckedMutation.isPending}
|
||||
className="text-muted hover:text-red-500"
|
||||
>
|
||||
<Trash2 size={14} /> Clear checked
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={() => setNewListOpen(true)}>
|
||||
<ListPlus size={15} /> New List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── List tabs ────────────────────────────────────────────── */}
|
||||
{lists.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-6 py-2 border-b border-theme bg-surface shrink-0 overflow-x-auto">
|
||||
{lists.map((list) => (
|
||||
<button
|
||||
key={list.id}
|
||||
onClick={() => setActiveListId(list.id)}
|
||||
className={clsx(
|
||||
'px-4 py-1.5 rounded-full text-sm font-medium transition-all whitespace-nowrap',
|
||||
activeListId === list.id
|
||||
? 'bg-accent text-white shadow-sm'
|
||||
: 'text-secondary hover:bg-surface-raised hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{list.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Items ────────────────────────────────────────────────── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{lists.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<div className="text-5xl mb-4">🛒</div>
|
||||
<p className="text-lg font-semibold text-primary mb-1">No shopping lists yet</p>
|
||||
<p className="text-secondary text-sm mb-6">Create a list to start adding items.</p>
|
||||
<Button onClick={() => setNewListOpen(true)}>
|
||||
<Plus size={16} /> Create List
|
||||
</Button>
|
||||
</div>
|
||||
) : itemsLoading ? (
|
||||
<div className="p-6 space-y-2">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-12 rounded-xl bg-surface-raised animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center px-6">
|
||||
<div className="text-4xl mb-3">📝</div>
|
||||
<p className="text-primary font-semibold mb-1">List is empty</p>
|
||||
<p className="text-secondary text-sm">Add items using the field below.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="bg-surface mx-4 my-4 rounded-2xl border border-theme overflow-hidden">
|
||||
{/* Unchecked items first */}
|
||||
<AnimatePresence initial={false}>
|
||||
{items
|
||||
.filter((i) => !i.checked)
|
||||
.map((item) => (
|
||||
<ShoppingItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
members={members}
|
||||
listId={activeListId!}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Checked items section */}
|
||||
{checkedCount > 0 && (
|
||||
<>
|
||||
<li className="px-4 py-2 bg-surface-raised border-t border-theme">
|
||||
<span className="text-xs font-semibold text-muted uppercase tracking-wide">
|
||||
In cart ({checkedCount})
|
||||
</span>
|
||||
</li>
|
||||
<AnimatePresence initial={false}>
|
||||
{items
|
||||
.filter((i) => i.checked)
|
||||
.map((item) => (
|
||||
<ShoppingItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
members={members}
|
||||
listId={activeListId!}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Quick-add bar (pinned to bottom) ──────────────────────── */}
|
||||
{activeListId && (
|
||||
<div className="shrink-0 bg-surface border-t border-theme px-4 py-3">
|
||||
<div className="flex gap-2 max-w-2xl mx-auto">
|
||||
<input
|
||||
ref={addInputRef}
|
||||
type="text"
|
||||
value={newItemText}
|
||||
onChange={(e) => setNewItemText(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddItem()}
|
||||
placeholder="Add item…"
|
||||
className="flex-1 rounded-xl border border-theme bg-surface-raised px-4 py-2.5 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newItemQty}
|
||||
onChange={(e) => setNewItemQty(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddItem()}
|
||||
placeholder="Qty"
|
||||
className="w-20 rounded-xl border border-theme bg-surface-raised px-3 py-2.5 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleAddItem}
|
||||
disabled={!newItemText.trim()}
|
||||
loading={addItemMutation.isPending}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── New list modal ─────────────────────────────────────────── */}
|
||||
<Modal open={newListOpen} onClose={() => setNewListOpen(false)} title="New Shopping List" size="sm">
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="List Name"
|
||||
value={newListName}
|
||||
onChange={(e) => setNewListName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && newListName.trim() && createListMutation.mutate(newListName.trim())}
|
||||
placeholder="e.g. Groceries, Hardware Store…"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" onClick={() => setNewListOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => createListMutation.mutate(newListName.trim())}
|
||||
disabled={!newListName.trim()}
|
||||
loading={createListMutation.isPending}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* ── Delete list confirmation ───────────────────────────────── */}
|
||||
<Modal open={deleteListOpen} onClose={() => setDeleteListOpen(false)} title="Delete List?" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-secondary text-sm">
|
||||
This will permanently delete <strong className="text-primary">{currentList?.name}</strong> and all
|
||||
its items. This cannot be undone.
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" onClick={() => setDeleteListOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
loading={deleteListMutation.isPending}
|
||||
onClick={() => activeListId && deleteListMutation.mutate(activeListId)}
|
||||
>
|
||||
Delete List
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user