Compare commits

..
2 Commits
Author SHA1 Message Date
Media fe4b354b93 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
2026-04-15 20:39:14 -05:00
Media 1fd223efd5 Created event day modal 2026-04-15 18:51:48 -05:00
12 changed files with 708 additions and 123 deletions
+16
View File
@@ -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"]
+1 -1
View File
@@ -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",
+3 -1
View File
@@ -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()")
+16
View File
@@ -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 -2
View File
@@ -1,4 +1,4 @@
.counter {
/* .counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
@@ -181,4 +181,4 @@
right: 0;
border-right-color: var(--border);
}
}
} */
+110 -13
View File
@@ -2,27 +2,50 @@ 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() {
const [visibleMonth, setVisibleMonth] = useState(new Date())
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
const [events, setEvents] = useState<CalendarEvent[]>([])
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) => {
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 []
return events.filter((event) => {
const d = new Date(event.starts_at)
return (
d.getFullYear() === selectedDate.getFullYear() &&
d.getMonth() === selectedDate.getMonth() &&
d.getDate() === selectedDate.getDate()
)
})
}, [events, selectedDate])
useEffect(() => {
if (!gridWrapperRef.current) return
@@ -103,11 +126,33 @@ export default function CalendarPage() {
)
}
function goToPreviousDay() {
if (!selectedDate) return
setSelectedDate(
new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate() - 1
)
)
}
function goToNextDay() {
if (!selectedDate) return
setSelectedDate(
new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate() + 1
)
)
}
return (
<div
style={{
padding: '24px',
background: 'var(--page-bg)',
background: 'var(--surface-page)',
color: 'var(--text)',
minHeight: '100vh',
}}
@@ -123,11 +168,63 @@ export default function CalendarPage() {
days={days}
visibleMonth={visibleMonth}
selectedDate={selectedDate}
onDayClick={setSelectedDate}
onDayClick={(date) => {
setSelectedDate(date)
setIsDayModalOpen(true)
}}
events={events}
layout={layout}
/>
</div>
<DayModal
isOpen={isDayModalOpen}
selectedDate={selectedDate}
events={eventsForSelectedDay}
onClose={() => setIsDayModalOpen(false)}
onCreateEvent={() => {
setIsDayModalOpen(false)
setEventModalMode('create')
setActiveEvent(null)
setIsEventModalOpen(true)
}}
onEventClick={(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>
)
}
+3 -3
View File
@@ -42,8 +42,8 @@ export default function DayCell({
style={{
position: 'relative',
aspectRatio: '1 / 1',
border: isSelected ? '2px solid #3b82f6' : '1px solid var(--border)',
background: isSelected ? 'rgba(59,130,246,0.15)' : 'var(--card-bg)',
border: '1px solid var(--border)',
background: 'var(--surface-item)',
color: 'var(--text)',
borderRadius: '12px',
opacity: isCurrentMonth ? 1 : 0.35,
@@ -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`,
+190
View File
@@ -0,0 +1,190 @@
import type { CalendarEvent } from '../types/calendar'
type DayModalProps = {
isOpen: boolean
selectedDate: Date | null
events: CalendarEvent[]
onClose: () => void
onCreateEvent: () => void
onEventClick?: (event: CalendarEvent) => void
onPreviousDay: () => void
onNextDay: () => void
}
export default function DayModal({
isOpen,
selectedDate,
events,
onClose,
onCreateEvent,
onEventClick,
onPreviousDay,
onNextDay,
}: DayModalProps) {
if (!isOpen || !selectedDate) return null
const sortedEvents = [...events].sort(
(a, b) =>
new Date(a.starts_at).getTime() - new Date(b.starts_at).getTime()
)
const modalIconButtonStyle: React.CSSProperties = {
border: '1px solid var(--border)',
background: 'var(--card-bg)',
color: 'var(--text)',
borderRadius: 'var(--radius)',
padding: '8px 10px',
cursor: 'pointer',
boxShadow: 'var(--shadow-sm)',
}
const modalPrimaryButtonStyle: 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)',
}
return (
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
padding: '16px',
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: '100%',
maxWidth: '500px',
maxHeight: '80vh',
background: 'var(--surface-panel)',
color: 'var(--text)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius)',
padding: '20px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
boxShadow: 'var(--shadow-md)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '12px',
}}
>
<button onClick={onPreviousDay} style={modalIconButtonStyle}>
←
</button>
<h2 style={{ margin: 0, textAlign: 'center', flex: 1 }}>
{selectedDate.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={onNextDay} style={modalIconButtonStyle}>
→
</button>
<button onClick={onClose} style={modalIconButtonStyle}>
✕
</button>
</div>
</div>
<div
style={{
flex: 1,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '10px',
minHeight: '120px',
padding: '20px',
margin: '-6px',
}}
>
{sortedEvents.length === 0 ? (
<div
style={{
opacity: 0.75,
padding: '8px 0',
}}
>
No events scheduled
</div>
) : (
sortedEvents.map((event) => (
<button
key={event.id}
onClick={() => onEventClick?.(event)}
style={{
border: '1px solid var(--border)',
background: 'var(--surface-item)',
color: 'var(--text)',
borderRadius: 'var(--radius)',
padding: '14px 14px 14px 16px',
textAlign: 'left',
cursor: 'pointer',
boxShadow: 'var(--shadow-sm)',
display: 'flex',
flexDirection: 'column',
gap: '4px',
transition:
'transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease',
}}
>
<div
style={{
fontSize: '13px',
color: 'var(--text-muted)',
fontWeight: 500,
}}
>
{new Date(event.starts_at).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
})}
</div>
<div
style={{
fontWeight: 600,
color: 'var(--text-strong)',
lineHeight: 1.25,
}}
>
{event.title}
</div>
</button>
))
)}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={onCreateEvent} style={modalPrimaryButtonStyle}>
New Event
</button>
</div>
</div>
</div>
)
}
+252
View File
@@ -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)',
}
+40 -100
View File
@@ -1,111 +1,51 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
/* Radius */
--radius: 10px;
/* Surface system */
--surface-page: #f8f8f8;
--surface-panel: #ffffff;
--surface-item: #f2f2f2;
--surface-hover: #eaeaea;
/* Text */
--text: #444444;
--text-strong: #111111;
--text-muted: #888888;
/* Borders */
--border: #dddddd;
/* Accent */
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--accent-soft: rgba(170, 59, 255, 0.1);
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
/* Shadows */
--shadow-sm: 0 4px 10px rgba(0, 0, 0, 0.05);
--shadow-md: 0 10px 25px rgba(0, 0, 0, 0.08);
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
/* App base */
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
background: var(--surface-page);
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--surface-page: #1a1a1a;
--surface-panel: #252525ee;
--surface-item: #383838bc;
--surface-hover: #505050;
--text: #dddddd;
--text-strong: #ffffff;
--text-muted: #aaaaaa;
--border: #3a3a3a;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
--accent-soft: rgba(192, 132, 252, 0.15);
--shadow-sm: 0 4px 10px rgba(0, 0, 0, 0.4);
--shadow-md: 0 10px 30px rgba(0, 0, 0, 0.5);
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
}
+43 -3
View File
@@ -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()
}
+32
View File
@@ -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: