Files
family-planner/apps/client/src/components/ui/Modal.tsx
T
jason 35ed5223a0 Phase 1 & 2: full-stack family dashboard scaffold
- pnpm monorepo (apps/client + apps/server)
- Server: Express + node:sqlite with numbered migration runner,
  REST API for all 9 features (members, events, chores, shopping,
  meals, messages, countdowns, photos, settings)
- Client: React 18 + Vite + TypeScript + Tailwind + Framer Motion + Zustand
- Theme system: dark/light + 5 accent colors, CSS custom properties,
  anti-FOUC script, ThemeToggle on every surface
- AppShell: collapsible sidebar, animated route transitions, mobile drawer
- Phase 2 features: Calendar (custom month grid, event chips, add/edit modal),
  Chores (card grid, complete/reset, member filter, streaks),
  Shopping (multi-list tabs, animated check-off, quick-add bar, member assign)
- Family member CRUD with avatar, color picker
- Settings page: theme/accent, photo folder, slideshow, weather, date/time
- Docker: multi-stage Dockerfile, docker-compose.yml, entrypoint with PUID/PGID
- Unraid: CA XML template, CLI install script, UNRAID.md guide
- .gitignore covering node_modules, dist, db files, secrets, build artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:56:30 -05:00

84 lines
2.7 KiB
TypeScript

import { ReactNode, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { clsx } from 'clsx';
import { useThemeStore } from '@/store/themeStore';
interface ModalProps {
open: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
}
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
};
export function Modal({ open, onClose, title, children, size = 'md', className }: ModalProps) {
// Ensure dark class is on root so modal portal inherits theme
const mode = useThemeStore((s) => s.mode);
useEffect(() => {
if (!open) return;
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [open, onClose]);
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [open]);
return createPortal(
<AnimatePresence>
{open && (
<div className={clsx('fixed inset-0 z-50 flex items-center justify-center p-4', mode === 'dark' ? 'dark' : '')}>
{/* Backdrop */}
<motion.div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Panel */}
<motion.div
className={clsx(
'relative w-full rounded-2xl shadow-2xl z-10',
'bg-surface border border-theme',
sizeClasses[size],
className
)}
initial={{ opacity: 0, scale: 0.95, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
{title && (
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b border-theme">
<h2 className="text-lg font-semibold text-primary">{title}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
>
<X size={18} />
</button>
</div>
)}
<div className="p-6">{children}</div>
</motion.div>
</div>
)}
</AnimatePresence>,
document.body
);
}