Compare commits
2
Commits
525022228a
...
fe4b354b93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe4b354b93 | ||
|
|
1fd223efd5 |
@@ -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;
|
const { Pool } = pg;
|
||||||
|
|
||||||
export const pool = new Pool({
|
export const pool = new Pool({
|
||||||
host: "localhost",
|
host: "postgres",
|
||||||
port: 5432,
|
port: 5432,
|
||||||
user: "postgres",
|
user: "postgres",
|
||||||
password: "postgres",
|
password: "postgres",
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ const app = Fastify();
|
|||||||
|
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
origin: true,
|
origin: true,
|
||||||
});
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type'],
|
||||||
|
})
|
||||||
|
|
||||||
pool
|
pool
|
||||||
.query("SELECT NOW()")
|
.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"]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.counter {
|
/* .counter {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -181,4 +181,4 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
border-right-color: var(--border);
|
border-right-color: var(--border);
|
||||||
}
|
}
|
||||||
}
|
} */
|
||||||
|
|||||||
+107
-10
@@ -2,27 +2,50 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import MonthGrid from './components/MonthGrid'
|
import MonthGrid from './components/MonthGrid'
|
||||||
import { buildMonthGrid } from './utils/calendarMath'
|
import { buildMonthGrid } from './utils/calendarMath'
|
||||||
import type { CalendarEvent, GridLayout } from './types/calendar'
|
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() {
|
export default function CalendarPage() {
|
||||||
const [visibleMonth, setVisibleMonth] = useState(new Date())
|
const [visibleMonth, setVisibleMonth] = useState(new Date())
|
||||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||||
const [events, setEvents] = useState<CalendarEvent[]>([])
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
const [gridWidth, setGridWidth] = useState(0)
|
const [gridWidth, setGridWidth] = useState(0)
|
||||||
|
const [isDayModalOpen, setIsDayModalOpen] = useState(false)
|
||||||
const gridWrapperRef = useRef<HTMLDivElement | null>(null)
|
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])
|
const days = useMemo(() => buildMonthGrid(visibleMonth), [visibleMonth])
|
||||||
|
|
||||||
useEffect(() => {
|
async function loadEvents() {
|
||||||
getEvents()
|
try {
|
||||||
.then(setEvents)
|
const data = await getEvents()
|
||||||
.catch((err) => {
|
setEvents(data)
|
||||||
|
} catch (err) {
|
||||||
console.error('Failed to load events:', err)
|
console.error('Failed to load events:', err)
|
||||||
setEvents([])
|
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(() => {
|
useEffect(() => {
|
||||||
if (!gridWrapperRef.current) return
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
background: 'var(--page-bg)',
|
background: 'var(--surface-page)',
|
||||||
color: 'var(--text)',
|
color: 'var(--text)',
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
@@ -123,11 +168,63 @@ export default function CalendarPage() {
|
|||||||
days={days}
|
days={days}
|
||||||
visibleMonth={visibleMonth}
|
visibleMonth={visibleMonth}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
onDayClick={setSelectedDate}
|
onDayClick={(date) => {
|
||||||
|
setSelectedDate(date)
|
||||||
|
setIsDayModalOpen(true)
|
||||||
|
}}
|
||||||
events={events}
|
events={events}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,8 @@ export default function DayCell({
|
|||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
aspectRatio: '1 / 1',
|
aspectRatio: '1 / 1',
|
||||||
border: isSelected ? '2px solid #3b82f6' : '1px solid var(--border)',
|
border: '1px solid var(--border)',
|
||||||
background: isSelected ? 'rgba(59,130,246,0.15)' : 'var(--card-bg)',
|
background: 'var(--surface-item)',
|
||||||
color: 'var(--text)',
|
color: 'var(--text)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
opacity: isCurrentMonth ? 1 : 0.35,
|
opacity: isCurrentMonth ? 1 : 0.35,
|
||||||
@@ -62,7 +62,7 @@ export default function DayCell({
|
|||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
placeItems: 'center',
|
placeItems: 'center',
|
||||||
background: isToday ? '#3b82f6' : 'transparent',
|
background: isToday ? 'var(--accent)' : 'transparent',
|
||||||
color: isToday ? '#fff' : 'var(--text)',
|
color: isToday ? '#fff' : 'var(--text)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: `${layout.dateSize}px`,
|
fontSize: `${layout.dateSize}px`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)',
|
||||||
|
}
|
||||||
+38
-98
@@ -1,111 +1,51 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
/* Radius */
|
||||||
--text-h: #08060d;
|
--radius: 10px;
|
||||||
--bg: #fff;
|
|
||||||
--border: #e5e4e7;
|
/* Surface system */
|
||||||
--code-bg: #f4f3ec;
|
--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: #aa3bff;
|
||||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
--accent-soft: 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;
|
|
||||||
|
|
||||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
/* Shadows */
|
||||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
--shadow-sm: 0 4px 10px rgba(0, 0, 0, 0.05);
|
||||||
--mono: ui-monospace, Consolas, monospace;
|
--shadow-md: 0 10px 25px rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
font: 18px/145% var(--sans);
|
/* App base */
|
||||||
letter-spacing: 0.18px;
|
|
||||||
color-scheme: light dark;
|
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg);
|
background: var(--surface-page);
|
||||||
font-synthesis: none;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--text: #9ca3af;
|
--surface-page: #1a1a1a;
|
||||||
--text-h: #f3f4f6;
|
--surface-panel: #252525ee;
|
||||||
--bg: #16171d;
|
--surface-item: #383838bc;
|
||||||
--border: #2e303a;
|
--surface-hover: #505050;
|
||||||
--code-bg: #1f2028;
|
|
||||||
|
--text: #dddddd;
|
||||||
|
--text-strong: #ffffff;
|
||||||
|
--text-muted: #aaaaaa;
|
||||||
|
|
||||||
|
--border: #3a3a3a;
|
||||||
|
|
||||||
--accent: #c084fc;
|
--accent: #c084fc;
|
||||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
--accent-soft: 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
--shadow-sm: 0 4px 10px rgba(0, 0, 0, 0.4);
|
||||||
filter: invert(1) brightness(2);
|
--shadow-md: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#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
@@ -1,9 +1,49 @@
|
|||||||
|
const API_BASE = 'http://localhost:3000'
|
||||||
|
|
||||||
export async function getEvents() {
|
export async function getEvents() {
|
||||||
const res = await fetch("http://localhost:3000/events");
|
const res = await fetch(`${API_BASE}/events`)
|
||||||
|
|
||||||
if (!res.ok) {
|
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:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- 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:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
Reference in New Issue
Block a user