Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
805 changes: 266 additions & 539 deletions package-lock.json

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,21 @@
"framer-motion": "^12.23.0",
"graphql": "^16.8.0",
"graphql-ws": "^5.14.0",
"i18next": "^24.0.0",
"idb": "^8.0.0",
"lucide-react": "^0.462.0",
"next": "15.3.1",
"next-themes": "^0.4.6",
"qrcode.react": "^3.2.0",
"react": "^18.3.1",
"react-big-calendar": "1.19.4",
"react-countdown": "^2.3.6",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.60.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.0.0",
"react-icons": "^5.5.0",
"react-intersection-observer": "^10.0.3",
"react-virtualized-auto-sizer": "^1.0.7",
Expand All @@ -67,14 +70,13 @@
"web-vitals": "^4.2.4",
"workbox-webpack-plugin": "^7.0.0",
"zod": "^3.25.75",
"i18next": "^24.0.0",
"react-i18next": "^15.0.0",
"zustand": "^5.0.10"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/vite": "^4.2.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
Expand All @@ -84,6 +86,7 @@
"@types/dompurify": "^3.0.5",
"@types/node": "^20",
"@types/react": "^18.3.27",
"@types/react-big-calendar": "1.16.3",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9",
Expand Down
84 changes: 84 additions & 0 deletions src/components/Calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

import { useCallback } from 'react';
import { Calendar as BigCalendar, dateFnsLocalizer, SlotInfo, Views } from 'react-big-calendar';
import {
format,
parse,
startOfWeek,
getDay,
addWeeks,
addDays,
} from 'date-fns';
import { enUS } from 'date-fns/locale/en-US';
import 'react-big-calendar/lib/css/react-big-calendar.css';
import type { CalendarEvent } from '@/types/event';

const localizer = dateFnsLocalizer({
format,
parse,
startOfWeek: () => startOfWeek(new Date(), { weekStartsOn: 0 }),
getDay,
locales: { 'en-US': enUS },
});

/** Expand a recurring event into instances within a 6-month window */
function expandRecurring(event: CalendarEvent): CalendarEvent[] {
if (!event.recurring || !event.recurrenceRule) return [event];

const instances: CalendarEvent[] = [event];
const duration = event.end.getTime() - event.start.getTime();
const windowEnd = addWeeks(new Date(), 26);

const freqMatch = event.recurrenceRule.match(/FREQ=(\w+)/);
const freq = freqMatch?.[1] ?? 'WEEKLY';

let current = event.start;
for (let i = 1; i < 52; i++) {
current = freq === 'DAILY' ? addDays(current, 1) : addWeeks(current, freq === 'MONTHLY' ? 4 : 1);
if (current > windowEnd) break;
instances.push({
...event,
id: `${event.id}_${i}`,
start: current,
end: new Date(current.getTime() + duration),
});
}
return instances;
}

interface CalendarProps {
events: CalendarEvent[];
onSelectSlot?: (slot: SlotInfo) => void;
onSelectEvent?: (event: CalendarEvent) => void;
}

export default function Calendar({ events, onSelectSlot, onSelectEvent }: CalendarProps) {
const expanded = events.flatMap(expandRecurring);

const eventStyleGetter = useCallback((event: CalendarEvent) => ({
style: {
backgroundColor: event.recurring ? '#7c3aed' : '#2563eb',
borderRadius: '4px',
border: 'none',
color: '#fff',
fontSize: '0.8rem',
},
}), []);

return (
<div className="h-[600px] bg-gray-800 rounded-xl p-4 text-white [&_.rbc-calendar]:text-gray-100 [&_.rbc-toolbar]:text-gray-100 [&_.rbc-toolbar_button]:text-gray-100 [&_.rbc-toolbar_button]:bg-gray-700 [&_.rbc-toolbar_button]:border-gray-600 [&_.rbc-header]:bg-gray-700 [&_.rbc-header]:text-gray-200 [&_.rbc-today]:bg-gray-700/50 [&_.rbc-off-range-bg]:bg-gray-900/30 [&_.rbc-day-bg]:border-gray-700 [&_.rbc-month-view]:border-gray-700 [&_.rbc-time-view]:border-gray-700 [&_.rbc-agenda-view_table]:border-gray-700">
<BigCalendar
localizer={localizer}
events={expanded}
defaultView={Views.MONTH}
views={[Views.MONTH, Views.WEEK, Views.DAY]}
selectable
onSelectSlot={onSelectSlot}
onSelectEvent={onSelectEvent}
eventPropGetter={eventStyleGetter}
style={{ height: '100%' }}
/>
</div>
);
}
220 changes: 220 additions & 0 deletions src/pages/events/[id].tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
'use client';

import { useState, useEffect } from 'react';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { ArrowLeft, ExternalLink, Trash2 } from 'lucide-react';
import { apiClient } from '@/lib/api';
import type { CalendarEvent } from '@/types/event';
import { getGoogleCalendarUrl } from '@/utils/icalUtils';

export default function EditEventPage() {
const router = useRouter();
const { id } = router.query;

const [event, setEvent] = useState<CalendarEvent | null>(null);
const [title, setTitle] = useState('');
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const [recurring, setRecurring] = useState(false);
const [recurrenceRule, setRecurrenceRule] = useState('FREQ=WEEKLY');
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

const toLocal = (d: Date) => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
};

useEffect(() => {
if (!id) return;
apiClient
.get<CalendarEvent>(`/api/events/${id}`)
.then((data) => {
const ev = { ...data, start: new Date(data.start), end: new Date(data.end) };
setEvent(ev);
setTitle(ev.title);
setStart(toLocal(ev.start));
setEnd(toLocal(ev.end));
setRecurring(ev.recurring ?? false);
setRecurrenceRule(ev.recurrenceRule ?? 'FREQ=WEEKLY');
})
.catch((err: Error) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
await apiClient.patch(`/api/events/${id}`, {
title,
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
recurring,
recurrenceRule: recurring ? recurrenceRule : undefined,
});
router.push('/events');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to update event');
} finally {
setSubmitting(false);
}
};

const handleDelete = async () => {
if (!confirm('Delete this event?')) return;
setSubmitting(true);
try {
await apiClient.delete(`/api/events/${id}`);
router.push('/events');
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete event');
setSubmitting(false);
}
};

return (
<>
<Head>
<title>Edit Event – TeachLink</title>
</Head>
<div className="min-h-screen bg-gray-900 text-white">
<div className="max-w-xl mx-auto px-4 py-8">
<button
onClick={() => router.back()}
className="flex items-center gap-2 text-gray-400 hover:text-white mb-6 text-sm"
>
<ArrowLeft className="w-4 h-4" /> Back
</button>
<h1 className="text-2xl font-bold mb-6">Edit Event</h1>

{loading && (
<div className="flex justify-center py-20">
<div className="w-8 h-8 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
</div>
)}

{error && (
<div className="bg-red-900/40 border border-red-700 rounded-lg p-3 text-red-300 mb-4 text-sm">
{error}
</div>
)}

{!loading && event && (
<>
{/* Google Calendar sync link */}
<a
href={getGoogleCalendarUrl(event)}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-blue-400 hover:text-blue-300 text-sm mb-6"
>
<ExternalLink className="w-4 h-4" />
Sync with Google Calendar
</a>

<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-300 mb-1">
Title
</label>
<input
id="title"
type="text"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>

<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="start" className="block text-sm font-medium text-gray-300 mb-1">
Start
</label>
<input
id="start"
type="datetime-local"
required
value={start}
onChange={(e) => setStart(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="end" className="block text-sm font-medium text-gray-300 mb-1">
End
</label>
<input
id="end"
type="datetime-local"
required
value={end}
onChange={(e) => setEnd(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>

<div className="flex items-center gap-3">
<input
id="recurring"
type="checkbox"
checked={recurring}
onChange={(e) => setRecurring(e.target.checked)}
className="w-4 h-4 accent-blue-500"
/>
<label htmlFor="recurring" className="text-sm font-medium text-gray-300">
Recurring event
</label>
</div>

{recurring && (
<div>
<label htmlFor="rrule" className="block text-sm font-medium text-gray-300 mb-1">
Recurrence Rule (RRULE)
</label>
<select
id="rrule"
value={recurrenceRule}
onChange={(e) => setRecurrenceRule(e.target.value)}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="FREQ=DAILY">Daily</option>
<option value="FREQ=WEEKLY">Weekly</option>
<option value="FREQ=MONTHLY">Monthly</option>
</select>
</div>
)}

<div className="flex gap-3">
<button
type="submit"
disabled={submitting}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded-lg px-4 py-2 font-medium transition-colors"
>
{submitting ? 'Saving…' : 'Save Changes'}
</button>
<button
type="button"
onClick={handleDelete}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 bg-red-700 hover:bg-red-600 disabled:opacity-50 rounded-lg font-medium transition-colors"
aria-label="Delete event"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
</form>
</>
)}
</div>
</div>
</>
);
}
Loading
Loading