You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
353 lines
8.9 KiB
353 lines
8.9 KiB
<script lang="ts"> |
|
import type { CalendarEvent } from "$lib/supabase/types"; |
|
import { getMonthDays, isSameDay } from "$lib/api/calendar"; |
|
|
|
type ViewType = "month" | "week" | "day"; |
|
|
|
interface Props { |
|
events: CalendarEvent[]; |
|
onDateClick?: (date: Date) => void; |
|
onEventClick?: (event: CalendarEvent) => void; |
|
initialView?: ViewType; |
|
} |
|
|
|
let { |
|
events, |
|
onDateClick, |
|
onEventClick, |
|
initialView = "month", |
|
}: Props = $props(); |
|
|
|
let currentDate = $state(new Date()); |
|
let currentView = $state<ViewType>(initialView); |
|
const today = new Date(); |
|
|
|
const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; |
|
|
|
const days = $derived( |
|
getMonthDays(currentDate.getFullYear(), currentDate.getMonth()), |
|
); |
|
|
|
function prevMonth() { |
|
currentDate = new Date( |
|
currentDate.getFullYear(), |
|
currentDate.getMonth() - 1, |
|
1, |
|
); |
|
} |
|
|
|
function nextMonth() { |
|
currentDate = new Date( |
|
currentDate.getFullYear(), |
|
currentDate.getMonth() + 1, |
|
1, |
|
); |
|
} |
|
|
|
function goToToday() { |
|
currentDate = new Date(); |
|
} |
|
|
|
function getEventsForDay(date: Date): CalendarEvent[] { |
|
return events.filter((event) => { |
|
const eventStart = new Date(event.start_time); |
|
return isSameDay(eventStart, date); |
|
}); |
|
} |
|
|
|
function isCurrentMonth(date: Date): boolean { |
|
return date.getMonth() === currentDate.getMonth(); |
|
} |
|
|
|
const monthYear = $derived( |
|
currentDate.toLocaleDateString("en-US", { |
|
month: "long", |
|
year: "numeric", |
|
}), |
|
); |
|
|
|
// Get week days for week view |
|
function getWeekDays(date: Date): Date[] { |
|
const startOfWeek = new Date(date); |
|
startOfWeek.setDate(date.getDate() - date.getDay()); |
|
return Array.from({ length: 7 }, (_, i) => { |
|
const d = new Date(startOfWeek); |
|
d.setDate(startOfWeek.getDate() + i); |
|
return d; |
|
}); |
|
} |
|
|
|
const weekDates = $derived(getWeekDays(currentDate)); |
|
|
|
// Navigation functions for different views |
|
function prev() { |
|
if (currentView === "month") { |
|
currentDate = new Date( |
|
currentDate.getFullYear(), |
|
currentDate.getMonth() - 1, |
|
1, |
|
); |
|
} else if (currentView === "week") { |
|
currentDate = new Date( |
|
currentDate.getTime() - 7 * 24 * 60 * 60 * 1000, |
|
); |
|
} else { |
|
currentDate = new Date(currentDate.getTime() - 24 * 60 * 60 * 1000); |
|
} |
|
} |
|
|
|
function next() { |
|
if (currentView === "month") { |
|
currentDate = new Date( |
|
currentDate.getFullYear(), |
|
currentDate.getMonth() + 1, |
|
1, |
|
); |
|
} else if (currentView === "week") { |
|
currentDate = new Date( |
|
currentDate.getTime() + 7 * 24 * 60 * 60 * 1000, |
|
); |
|
} else { |
|
currentDate = new Date(currentDate.getTime() + 24 * 60 * 60 * 1000); |
|
} |
|
} |
|
|
|
const headerTitle = $derived(() => { |
|
if (currentView === "day") { |
|
return currentDate.toLocaleDateString("en-US", { |
|
weekday: "long", |
|
month: "long", |
|
day: "numeric", |
|
year: "numeric", |
|
}); |
|
} else if (currentView === "week") { |
|
const start = weekDates[0]; |
|
const end = weekDates[6]; |
|
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} - ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`; |
|
} |
|
return monthYear; |
|
}); |
|
</script> |
|
|
|
<div class="bg-surface rounded-xl p-4"> |
|
<div class="flex items-center justify-between mb-4"> |
|
<h2 class="text-xl font-semibold text-light">{headerTitle()}</h2> |
|
<div class="flex items-center gap-2"> |
|
<!-- View Switcher --> |
|
<div class="flex bg-dark rounded-lg p-0.5"> |
|
<button |
|
class="px-3 py-1 text-sm rounded-md transition-colors {currentView === |
|
'day' |
|
? 'bg-primary text-white' |
|
: 'text-light/60 hover:text-light'}" |
|
onclick={() => (currentView = "day")} |
|
> |
|
Day |
|
</button> |
|
<button |
|
class="px-3 py-1 text-sm rounded-md transition-colors {currentView === |
|
'week' |
|
? 'bg-primary text-white' |
|
: 'text-light/60 hover:text-light'}" |
|
onclick={() => (currentView = "week")} |
|
> |
|
Week |
|
</button> |
|
<button |
|
class="px-3 py-1 text-sm rounded-md transition-colors {currentView === |
|
'month' |
|
? 'bg-primary text-white' |
|
: 'text-light/60 hover:text-light'}" |
|
onclick={() => (currentView = "month")} |
|
> |
|
Month |
|
</button> |
|
</div> |
|
<button |
|
class="px-3 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" |
|
onclick={goToToday} |
|
> |
|
Today |
|
</button> |
|
<button |
|
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" |
|
onclick={prev} |
|
aria-label="Previous" |
|
> |
|
<svg |
|
class="w-5 h-5" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
> |
|
<path d="m15 18-6-6 6-6" /> |
|
</svg> |
|
</button> |
|
<button |
|
class="p-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors" |
|
onclick={next} |
|
aria-label="Next" |
|
> |
|
<svg |
|
class="w-5 h-5" |
|
viewBox="0 0 24 24" |
|
fill="none" |
|
stroke="currentColor" |
|
stroke-width="2" |
|
> |
|
<path d="m9 18 6-6-6-6" /> |
|
</svg> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<!-- Month View --> |
|
{#if currentView === "month"} |
|
<div |
|
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden" |
|
> |
|
{#each weekDays as day} |
|
<div |
|
class="bg-dark px-2 py-2 text-center text-sm font-medium text-light/50" |
|
> |
|
{day} |
|
</div> |
|
{/each} |
|
|
|
{#each days as day} |
|
{@const dayEvents = getEventsForDay(day)} |
|
{@const isToday = isSameDay(day, today)} |
|
{@const inMonth = isCurrentMonth(day)} |
|
<button |
|
class="bg-dark min-h-[80px] p-1 text-left transition-colors hover:bg-light/5" |
|
class:opacity-40={!inMonth} |
|
onclick={() => onDateClick?.(day)} |
|
> |
|
<div class="flex items-center justify-center w-7 h-7 mb-1"> |
|
<span |
|
class="text-sm {isToday |
|
? 'bg-primary text-white rounded-full w-7 h-7 flex items-center justify-center' |
|
: 'text-light/80'}" |
|
> |
|
{day.getDate()} |
|
</span> |
|
</div> |
|
<div class="space-y-0.5"> |
|
{#each dayEvents.slice(0, 3) as event} |
|
<button |
|
class="w-full text-xs px-1 py-0.5 rounded truncate text-left" |
|
style="background-color: {event.color ?? |
|
'#6366f1'}20; color: {event.color ?? |
|
'#6366f1'}" |
|
onclick={(e) => { |
|
e.stopPropagation(); |
|
onEventClick?.(event); |
|
}} |
|
> |
|
{event.title} |
|
</button> |
|
{/each} |
|
{#if dayEvents.length > 3} |
|
<p class="text-xs text-light/40 px-1"> |
|
+{dayEvents.length - 3} more |
|
</p> |
|
{/if} |
|
</div> |
|
</button> |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
<!-- Week View --> |
|
{#if currentView === "week"} |
|
<div |
|
class="grid grid-cols-7 gap-px bg-light/10 rounded-lg overflow-hidden" |
|
> |
|
{#each weekDates as day} |
|
{@const dayEvents = getEventsForDay(day)} |
|
{@const isToday = isSameDay(day, today)} |
|
<div class="bg-dark"> |
|
<div class="px-2 py-2 text-center border-b border-light/10"> |
|
<div class="text-xs text-light/50"> |
|
{weekDays[day.getDay()]} |
|
</div> |
|
<div |
|
class="text-lg font-medium {isToday |
|
? 'text-primary' |
|
: 'text-light'}" |
|
> |
|
{day.getDate()} |
|
</div> |
|
</div> |
|
<div class="min-h-[300px] p-1 space-y-1"> |
|
{#each dayEvents as event} |
|
<button |
|
class="w-full text-xs px-2 py-1.5 rounded text-left" |
|
style="background-color: {event.color ?? |
|
'#6366f1'}20; color: {event.color ?? |
|
'#6366f1'}" |
|
onclick={() => onEventClick?.(event)} |
|
> |
|
<div class="font-medium truncate"> |
|
{event.title} |
|
</div> |
|
<div class="text-[10px] opacity-70"> |
|
{new Date( |
|
event.start_time, |
|
).toLocaleTimeString("en-US", { |
|
hour: "numeric", |
|
minute: "2-digit", |
|
})} |
|
</div> |
|
</button> |
|
{/each} |
|
</div> |
|
</div> |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
<!-- Day View --> |
|
{#if currentView === "day"} |
|
{@const dayEvents = getEventsForDay(currentDate)} |
|
<div class="bg-dark rounded-lg p-4 min-h-[400px]"> |
|
{#if dayEvents.length === 0} |
|
<div class="text-center text-light/40 py-12"> |
|
<p>No events for this day</p> |
|
</div> |
|
{:else} |
|
<div class="space-y-2"> |
|
{#each dayEvents as event} |
|
<button |
|
class="w-full text-left p-3 rounded-lg transition-colors hover:opacity-80" |
|
style="background-color: {event.color ?? |
|
'#6366f1'}20; border-left: 3px solid {event.color ?? |
|
'#6366f1'}" |
|
onclick={() => onEventClick?.(event)} |
|
> |
|
<div class="font-medium text-light"> |
|
{event.title} |
|
</div> |
|
<div class="text-sm text-light/60 mt-1"> |
|
{new Date(event.start_time).toLocaleTimeString( |
|
"en-US", |
|
{ hour: "numeric", minute: "2-digit" }, |
|
)} |
|
- {new Date(event.end_time).toLocaleTimeString( |
|
"en-US", |
|
{ hour: "numeric", minute: "2-digit" }, |
|
)} |
|
</div> |
|
{#if event.description} |
|
<div class="text-sm text-light/50 mt-2"> |
|
{event.description} |
|
</div> |
|
{/if} |
|
</button> |
|
{/each} |
|
</div> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div>
|
|
|