From fe4b354b93cd05faadabef7292017ca87b89fc6b Mon Sep 17 00:00:00 2001 From: Media Date: Wed, 15 Apr 2026 20:39:14 -0500 Subject: [PATCH] Initial working calendar system - Full CRUD events (create, edit, delete API) - Day + Event modal UX flow - Timezone-safe event handling - Dockerized API + frontend + Postgres - Vite + Fastify integration working --- apps/api/Dockerfile | 16 ++ apps/api/src/db/index.ts | 2 +- apps/api/src/server.ts | 4 +- apps/web/Dockerfile | 16 ++ apps/web/src/CalendarPage.tsx | 66 +++++-- apps/web/src/components/DayCell.tsx | 2 +- apps/web/src/components/EventModal.tsx | 252 +++++++++++++++++++++++++ apps/web/src/lib/api.ts | 46 ++++- docker-compose.yml | 32 ++++ 9 files changed, 418 insertions(+), 18 deletions(-) create mode 100644 apps/api/Dockerfile create mode 100644 apps/web/Dockerfile create mode 100644 apps/web/src/components/EventModal.tsx diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..228edf0 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/api/package.json apps/api/package.json + +RUN corepack enable && pnpm install --filter ./apps/api... --frozen-lockfile=false + +COPY apps/api ./apps/api + +WORKDIR /app/apps/api + +EXPOSE 3000 + +CMD ["pnpm", "dev"] \ No newline at end of file diff --git a/apps/api/src/db/index.ts b/apps/api/src/db/index.ts index 1fea92c..8606588 100644 --- a/apps/api/src/db/index.ts +++ b/apps/api/src/db/index.ts @@ -3,7 +3,7 @@ import pg from "pg"; const { Pool } = pg; export const pool = new Pool({ - host: "localhost", + host: "postgres", port: 5432, user: "postgres", password: "postgres", diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b5b93cf..fc275f1 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -7,7 +7,9 @@ const app = Fastify(); await app.register(cors, { origin: true, -}); + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type'], +}) pool .query("SELECT NOW()") diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..b0d9a21 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20 + +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/package.json + +RUN corepack enable && pnpm install --filter ./apps/web... --frozen-lockfile=false + +COPY apps/web ./apps/web + +WORKDIR /app/apps/web + +EXPOSE 5173 + +CMD ["pnpm", "dev", "--host", "0.0.0.0"] \ No newline at end of file diff --git a/apps/web/src/CalendarPage.tsx b/apps/web/src/CalendarPage.tsx index 96c3d16..e82d888 100644 --- a/apps/web/src/CalendarPage.tsx +++ b/apps/web/src/CalendarPage.tsx @@ -2,8 +2,9 @@ import { useEffect, useMemo, useRef, useState } from 'react' import MonthGrid from './components/MonthGrid' import { buildMonthGrid } from './utils/calendarMath' import type { CalendarEvent, GridLayout } from './types/calendar' -import { getEvents } from './lib/api' import DayModal from './components/DayModal' +import EventModal from './components/EventModal' +import { getEvents, createEvent, updateEvent } from './lib/api' export default function CalendarPage() { @@ -13,17 +14,24 @@ export default function CalendarPage() { const [gridWidth, setGridWidth] = useState(0) const [isDayModalOpen, setIsDayModalOpen] = useState(false) const gridWrapperRef = useRef(null) - + const [isEventModalOpen, setIsEventModalOpen] = useState(false) + const [eventModalMode, setEventModalMode] = useState<'create' | 'edit'>('create') + const [activeEvent, setActiveEvent] = useState(null) const days = useMemo(() => buildMonthGrid(visibleMonth), [visibleMonth]) - useEffect(() => { - getEvents() - .then(setEvents) - .catch((err) => { - console.error('Failed to load events:', err) - setEvents([]) - }) - }, []) + async function loadEvents() { + try { + const data = await getEvents() + setEvents(data) + } catch (err) { + console.error('Failed to load events:', err) + setEvents([]) + } + } + + useEffect(() => { + loadEvents() + }, []) const eventsForSelectedDay = useMemo(() => { if (!selectedDate) return [] @@ -174,14 +182,48 @@ export default function CalendarPage() { events={eventsForSelectedDay} onClose={() => setIsDayModalOpen(false)} onCreateEvent={() => { - console.log('Create event for ISO:', selectedDate?.toISOString()) + setIsDayModalOpen(false) + setEventModalMode('create') + setActiveEvent(null) + setIsEventModalOpen(true) }} onEventClick={(event) => { - console.log('Edit event:', event) + setIsDayModalOpen(false) + setEventModalMode('edit') + setActiveEvent(event) + setIsEventModalOpen(true) }} onPreviousDay={goToPreviousDay} onNextDay={goToNextDay} /> + { + setIsDayModalOpen(true) + setIsEventModalOpen(false) + }} + onSubmit={async (values) => { + try { + console.log('Submitting mode:', eventModalMode) + console.log('Active event:', activeEvent) + console.log('Values:', values) + + if (eventModalMode === 'create') { + await createEvent(values) + } else if (activeEvent) { + await updateEvent(activeEvent.id, values) + } + + await loadEvents() + setIsEventModalOpen(false) + } catch (err) { + console.error('Failed to save event:', err) + } + }} + /> ) diff --git a/apps/web/src/components/DayCell.tsx b/apps/web/src/components/DayCell.tsx index 3787a2b..09a415f 100644 --- a/apps/web/src/components/DayCell.tsx +++ b/apps/web/src/components/DayCell.tsx @@ -62,7 +62,7 @@ export default function DayCell({ borderRadius: '999px', display: 'grid', placeItems: 'center', - background: isToday ? '#3b82f6' : 'transparent', + background: isToday ? 'var(--accent)' : 'transparent', color: isToday ? '#fff' : 'var(--text)', fontWeight: 600, fontSize: `${layout.dateSize}px`, diff --git a/apps/web/src/components/EventModal.tsx b/apps/web/src/components/EventModal.tsx new file mode 100644 index 0000000..92b4531 --- /dev/null +++ b/apps/web/src/components/EventModal.tsx @@ -0,0 +1,252 @@ +import { useEffect, useState } from 'react' +import type { CalendarEvent } from '../types/calendar' + +type EventModalProps = { + isOpen: boolean + mode: 'create' | 'edit' + selectedDate: Date | null + event: CalendarEvent | null + onClose: () => void + onSubmit: (values: { title: string; starts_at: string }) => void +} + +export default function EventModal({ + isOpen, + mode, + selectedDate, + event, + onClose, + onSubmit, +}: EventModalProps) { + const [title, setTitle] = useState('') + const [dateValue, setDateValue] = useState('') + const [timeValue, setTimeValue] = useState('12:00') + + useEffect(() => { + if (!isOpen) return + + if (mode === 'edit' && event) { + const startsAt = new Date(event.starts_at) + setTitle(event.title) + setDateValue(formatDateForInput(startsAt)) + setTimeValue(formatTimeForInput(startsAt)) + return + } + + if (mode === 'create' && selectedDate) { + setTitle('') + setDateValue(formatDateForInput(selectedDate)) + setTimeValue('12:00') + } + }, [isOpen, mode, event, selectedDate]) + + if (!isOpen) return null + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + if (!title.trim() || !dateValue || !timeValue) return + + const local = new Date(`${dateValue}T${timeValue}:00`) + + const offset = -local.getTimezoneOffset() // minutes → convert sign + const sign = offset >= 0 ? '+' : '-' + const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0') + const minutes = String(Math.abs(offset) % 60).padStart(2, '0') + + const isoWithOffset = `${dateValue}T${timeValue}:00${sign}${hours}:${minutes}` + + onSubmit({ + title: title.trim(), + starts_at: isoWithOffset, + }) + } + + return ( +
+
e.stopPropagation()} + style={{ + width: '100%', + maxWidth: '460px', + background: 'var(--surface-panel)', + color: 'var(--text)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius)', + padding: '20px', + boxShadow: 'var(--shadow-md)', + }} + > +
+

+ {mode === 'create' ? 'New Event' : 'Edit Event'} +

+ + +
+ +
+ + +
+ + + +
+ +
+ + + +
+
+
+
+ ) +} + +function formatDateForInput(date: 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}` +} + +function formatTimeForInput(date: Date) { + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${hours}:${minutes}` +} + +const fieldStyle: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: '6px', +} + +const labelStyle: React.CSSProperties = { + fontSize: '14px', + color: 'var(--text-muted)', + fontWeight: 500, +} + +const inputStyle: React.CSSProperties = { + border: '1px solid var(--border)', + background: 'var(--surface-item)', + color: 'var(--text)', + borderRadius: 'var(--radius)', + padding: '10px 12px', + font: 'inherit', + outline: 'none', +} + +const iconButtonStyle: React.CSSProperties = { + border: '1px solid var(--border)', + background: 'var(--surface-panel)', + color: 'var(--text)', + borderRadius: 'var(--radius)', + padding: '8px 10px', + cursor: 'pointer', + boxShadow: 'var(--shadow-sm)', +} + +const secondaryButtonStyle: React.CSSProperties = { + border: '1px solid var(--border)', + background: 'var(--surface-item)', + color: 'var(--text)', + borderRadius: 'var(--radius)', + padding: '10px 14px', + cursor: 'pointer', + fontWeight: 600, +} + +const primaryButtonStyle: React.CSSProperties = { + border: '1px solid var(--accent)', + background: 'var(--accent-soft)', + color: 'var(--text-strong)', + borderRadius: 'var(--radius)', + padding: '10px 14px', + cursor: 'pointer', + fontWeight: 600, + boxShadow: 'var(--shadow-sm)', +} \ No newline at end of file diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 04b0f73..9c410aa 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,9 +1,49 @@ +const API_BASE = 'http://localhost:3000' + export async function getEvents() { - const res = await fetch("http://localhost:3000/events"); + const res = await fetch(`${API_BASE}/events`) if (!res.ok) { - throw new Error("Failed to fetch events"); + throw new Error('Failed to fetch events') } - return res.json(); + return res.json() +} + +export async function createEvent(values: { + title: string + starts_at: string +}) { + const res = await fetch(`${API_BASE}/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + }) + + if (!res.ok) { + throw new Error('Failed to create event') + } + + return res.json() +} + +export async function updateEvent( + id: string, + values: { title: string; starts_at: string } +) { + const res = await fetch(`${API_BASE}/events/${id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(values), + }) + + if (!res.ok) { + throw new Error('Failed to update event') + } + + return res.json() } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index e47d942..256dcdd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,5 +12,37 @@ services: volumes: - postgres_data:/var/lib/postgresql/data + api: + build: + context: . + dockerfile: apps/api/Dockerfile + container_name: church-platform-api + restart: unless-stopped + depends_on: + - postgres + ports: + - "3000:3000" + volumes: + - ./apps/api:/app/apps/api + - /app/apps/api/node_modules + working_dir: /app/apps/api + command: pnpm dev + + web: + build: + context: . + dockerfile: apps/web/Dockerfile + container_name: church-platform-web + restart: unless-stopped + depends_on: + - api + ports: + - "5173:5173" + volumes: + - ./apps/web:/app/apps/web + - /app/apps/web/node_modules + working_dir: /app/apps/web + command: pnpm dev --host 0.0.0.0 + volumes: postgres_data: \ No newline at end of file