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
This commit is contained in:
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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()")
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,16 +14,23 @@ export default function CalendarPage() {
|
||||
const [gridWidth, setGridWidth] = useState(0)
|
||||
const [isDayModalOpen, setIsDayModalOpen] = useState(false)
|
||||
const gridWrapperRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const [isEventModalOpen, setIsEventModalOpen] = useState(false)
|
||||
const [eventModalMode, setEventModalMode] = useState<'create' | 'edit'>('create')
|
||||
const [activeEvent, setActiveEvent] = useState<CalendarEvent | null>(null)
|
||||
const days = useMemo(() => buildMonthGrid(visibleMonth), [visibleMonth])
|
||||
|
||||
useEffect(() => {
|
||||
getEvents()
|
||||
.then(setEvents)
|
||||
.catch((err) => {
|
||||
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(() => {
|
||||
@@ -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}
|
||||
/>
|
||||
<EventModal
|
||||
isOpen={isEventModalOpen}
|
||||
mode={eventModalMode}
|
||||
selectedDate={selectedDate}
|
||||
event={activeEvent}
|
||||
onClose={() => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1100,
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => 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)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>
|
||||
{mode === 'create' ? 'New Event' : 'Edit Event'}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={iconButtonStyle}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '14px',
|
||||
}}
|
||||
>
|
||||
<label style={fieldStyle}>
|
||||
<span style={labelStyle}>Title</span>
|
||||
<input
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Event title"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<label style={fieldStyle}>
|
||||
<span style={labelStyle}>Date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={(e) => setDateValue(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={fieldStyle}>
|
||||
<span style={labelStyle}>Time</span>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => setTimeValue(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '10px',
|
||||
marginTop: '8px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={secondaryButtonStyle}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={primaryButtonStyle}
|
||||
>
|
||||
{mode === 'create' ? 'Create Event' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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)',
|
||||
}
|
||||
+43
-3
@@ -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()
|
||||
}
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user