35ed5223a0
- 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>
84 lines
2.7 KiB
TypeScript
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
|
|
);
|
|
}
|