349 lines
9.5 KiB
Svelte
349 lines
9.5 KiB
Svelte
<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 weekDayHeaders = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
|
const days = $derived(
|
|
getMonthDays(currentDate.getFullYear(), currentDate.getMonth()),
|
|
);
|
|
|
|
// Group days into weeks (rows of 7)
|
|
const weeks = $derived.by(() => {
|
|
const result: Date[][] = [];
|
|
for (let i = 0; i < days.length; i += 7) {
|
|
result.push(days.slice(i, i + 7));
|
|
}
|
|
return result;
|
|
});
|
|
|
|
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 (Mon-Sun)
|
|
function getWeekDays(date: Date): Date[] {
|
|
const startOfWeek = new Date(date);
|
|
const dayOfWeek = startOfWeek.getDay();
|
|
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
|
startOfWeek.setDate(date.getDate() + mondayOffset);
|
|
return Array.from({ length: 7 }, (_, i) => {
|
|
const d = new Date(startOfWeek);
|
|
d.setDate(startOfWeek.getDate() + i);
|
|
return d;
|
|
});
|
|
}
|
|
|
|
const weekDates = $derived(getWeekDays(currentDate));
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
function goToToday() {
|
|
currentDate = new Date();
|
|
}
|
|
|
|
const headerTitle = $derived.by(() => {
|
|
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="flex flex-col h-full gap-2">
|
|
<!-- Navigation bar -->
|
|
<div class="flex items-center justify-between px-2">
|
|
<div class="flex items-center gap-2">
|
|
<button
|
|
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
|
|
onclick={prev}
|
|
aria-label="Previous"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
|
>chevron_left</span
|
|
>
|
|
</button>
|
|
<span
|
|
class="font-heading text-h4 text-white min-w-[200px] text-center"
|
|
>{headerTitle}</span
|
|
>
|
|
<button
|
|
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
|
|
onclick={next}
|
|
aria-label="Next"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
|
>chevron_right</span
|
|
>
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 text-body-md font-body text-light/60 hover:text-white hover:bg-dark rounded-[32px] transition-colors ml-2"
|
|
onclick={goToToday}
|
|
>
|
|
Today
|
|
</button>
|
|
</div>
|
|
<div class="flex bg-dark rounded-[32px] p-0.5">
|
|
<button
|
|
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
|
|
'day'
|
|
? 'bg-primary text-night'
|
|
: 'text-light/60 hover:text-light'}"
|
|
onclick={() => (currentView = "day")}>Day</button
|
|
>
|
|
<button
|
|
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
|
|
'week'
|
|
? 'bg-primary text-night'
|
|
: 'text-light/60 hover:text-light'}"
|
|
onclick={() => (currentView = "week")}>Week</button
|
|
>
|
|
<button
|
|
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
|
|
'month'
|
|
? 'bg-primary text-night'
|
|
: 'text-light/60 hover:text-light'}"
|
|
onclick={() => (currentView = "month")}>Month</button
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Month View -->
|
|
{#if currentView === "month"}
|
|
<div
|
|
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
|
|
>
|
|
<!-- Day Headers -->
|
|
<div class="grid grid-cols-7 gap-2">
|
|
{#each weekDayHeaders as day}
|
|
<div class="flex items-center justify-center py-2 px-2">
|
|
<span
|
|
class="font-heading text-h4 text-white text-center"
|
|
>{day}</span
|
|
>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
|
|
<!-- Calendar Grid -->
|
|
<div
|
|
class="flex-1 flex flex-col gap-2 min-h-0 rounded-lg overflow-hidden"
|
|
>
|
|
{#each weeks as week}
|
|
<div class="grid grid-cols-7 gap-2 flex-1">
|
|
{#each week as day}
|
|
{@const dayEvents = getEventsForDay(day)}
|
|
{@const isToday = isSameDay(day, today)}
|
|
{@const inMonth = isCurrentMonth(day)}
|
|
<div
|
|
class="bg-night rounded-none flex flex-col items-start px-2 py-2.5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
|
|
{!inMonth ? 'opacity-50' : ''}"
|
|
onclick={() => onDateClick?.(day)}
|
|
>
|
|
<span
|
|
class="font-body text-body text-white {isToday
|
|
? 'text-primary font-bold'
|
|
: ''}"
|
|
>
|
|
{day.getDate()}
|
|
</span>
|
|
{#each dayEvents.slice(0, 2) as event}
|
|
<button
|
|
class="w-full mt-1 px-2 py-0.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
|
|
style="background-color: {event.color ??
|
|
'#00A3E0'}"
|
|
onclick={(e) => {
|
|
e.stopPropagation();
|
|
onEventClick?.(event);
|
|
}}
|
|
>
|
|
{event.title}
|
|
</button>
|
|
{/each}
|
|
{#if dayEvents.length > 2}
|
|
<span
|
|
class="text-body-sm text-light/40 mt-0.5"
|
|
>+{dayEvents.length - 2} more</span
|
|
>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Week View -->
|
|
{#if currentView === "week"}
|
|
<div
|
|
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
|
|
>
|
|
<div
|
|
class="grid grid-cols-7 gap-2 flex-1 rounded-lg overflow-hidden"
|
|
>
|
|
{#each weekDates as day}
|
|
{@const dayEvents = getEventsForDay(day)}
|
|
{@const isToday = isSameDay(day, today)}
|
|
<div class="flex flex-col overflow-hidden">
|
|
<div class="px-2 py-2 text-center">
|
|
<div
|
|
class="font-heading text-h4 {isToday
|
|
? 'text-primary'
|
|
: 'text-white'}"
|
|
>
|
|
{weekDayHeaders[(day.getDay() + 6) % 7]}
|
|
</div>
|
|
<div
|
|
class="font-body text-body-md {isToday
|
|
? 'text-primary'
|
|
: 'text-light/60'}"
|
|
>
|
|
{day.getDate()}
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="bg-night flex-1 px-2 pb-2 space-y-1 overflow-y-auto"
|
|
>
|
|
{#each dayEvents as event}
|
|
<button
|
|
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
|
|
style="background-color: {event.color ??
|
|
'#00A3E0'}"
|
|
onclick={() => onEventClick?.(event)}
|
|
>
|
|
{event.title}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Day View -->
|
|
{#if currentView === "day"}
|
|
{@const dayEvents = getEventsForDay(currentDate)}
|
|
<div class="flex-1 bg-night px-4 py-5 min-h-0 overflow-auto">
|
|
{#if dayEvents.length === 0}
|
|
<div class="text-center text-light/40 py-12">
|
|
<p class="font-body text-body">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-[8px] transition-colors hover:opacity-80"
|
|
style="background-color: {event.color ??
|
|
'#00A3E0'}20; border-left: 3px solid {event.color ??
|
|
'#00A3E0'}"
|
|
onclick={() => onEventClick?.(event)}
|
|
>
|
|
<div class="font-heading text-h5 text-white">
|
|
{event.title}
|
|
</div>
|
|
<div
|
|
class="font-body text-body-md 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="font-body text-body-md text-light/50 mt-2"
|
|
>
|
|
{event.description}
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|