123 lines
4.2 KiB
Svelte
123 lines
4.2 KiB
Svelte
<script lang="ts">
|
|
import cx from "classnames"
|
|
import type {Readable} from "svelte/store"
|
|
import {LOCALE, chunk} from "@welshman/lib"
|
|
import type {TrustedEvent} from "@welshman/util"
|
|
import Button from "@lib/components/Button.svelte"
|
|
import CalendarDay from "@app/components/CalendarDay.svelte"
|
|
import CalendarEventChip from "@app/components/CalendarEventChip.svelte"
|
|
import type {FeedContext} from "@app/feeds"
|
|
import {
|
|
formatDay,
|
|
formatWeekday,
|
|
getMonthDays,
|
|
getSingleDayEvents,
|
|
isToday,
|
|
layoutMultiDayBars,
|
|
makeDayKey,
|
|
} from "@app/calendar"
|
|
import {pushModal} from "@app/modal"
|
|
|
|
type Props = {
|
|
url: string
|
|
events: Readable<TrustedEvent[]>
|
|
eventsByDay: Map<string, TrustedEvent[]>
|
|
date: Date
|
|
context: FeedContext
|
|
}
|
|
|
|
const {url, events, eventsByDay, date, context}: Props = $props()
|
|
|
|
// A cell only has room for a few events, so the rest of the day lives in a modal. The feed's
|
|
// own store is passed through rather than a pre-filtered list, since a modal's props are
|
|
// frozen at push time and wouldn't pick up an event created from inside it.
|
|
const showDay = (day: Date) =>
|
|
pushModal(CalendarDay, {
|
|
url,
|
|
date: day,
|
|
events,
|
|
context,
|
|
})
|
|
|
|
const weeks = $derived(chunk(7, getMonthDays(date)))
|
|
const weekBars = $derived(weeks.map(week => layoutMultiDayBars(week, eventsByDay)))
|
|
|
|
// Shared across every row so the grid stays a uniform height, not just tallest-in-its-own-week
|
|
const laneCount = $derived(
|
|
weekBars.reduce(
|
|
(max, bars) =>
|
|
Math.max(
|
|
max,
|
|
bars.reduce((m, bar) => Math.max(m, bar.lane + 1), 0),
|
|
),
|
|
0,
|
|
),
|
|
)
|
|
</script>
|
|
|
|
<div class="overflow-hidden rounded-2xl border border-solid border-line bg-surface">
|
|
<div class="grid grid-cols-7 bg-surface-more">
|
|
{#each weeks[0] as day (day.getTime())}
|
|
<div class="p-2 text-center text-xs uppercase opacity-75">{formatWeekday(day)}</div>
|
|
{/each}
|
|
</div>
|
|
{#each weeks as week, weekIndex (week[0].getTime())}
|
|
{@const bars = weekBars[weekIndex]}
|
|
<div
|
|
class="grid min-h-24 grid-cols-7 gap-y-1 border-t border-solid border-line pb-1"
|
|
style="grid-template-rows: auto {laneCount > 0 ? `repeat(${laneCount}, 1.5rem)` : ''} 1fr;">
|
|
{#each week as day, col (day.getTime())}
|
|
<Button
|
|
class={cx(
|
|
"mx-1 mt-1 self-start justify-self-start rounded-full px-1.5 text-xs transition-colors",
|
|
{
|
|
"bg-primary text-primary-content": isToday(day),
|
|
"hover:bg-surface-more": !isToday(day),
|
|
"opacity-40": day.getMonth() !== date.getMonth(),
|
|
},
|
|
)}
|
|
style="grid-column: {col + 1}; grid-row: 1;"
|
|
aria-label={formatDay(day)}
|
|
onclick={() => showDay(day)}>
|
|
{Intl.DateTimeFormat(LOCALE, {day: "numeric"}).format(day)}
|
|
</Button>
|
|
{/each}
|
|
{#each bars as bar (bar.event.id)}
|
|
<div
|
|
class={cx("flex min-w-0 items-stretch", {
|
|
"pl-1": !bar.continuesBefore,
|
|
"pr-1": !bar.continuesAfter,
|
|
})}
|
|
style="grid-column: {bar.startCol + 1} / {bar.startCol +
|
|
1 +
|
|
bar.span}; grid-row: {bar.lane + 2};">
|
|
<CalendarEventChip
|
|
{url}
|
|
event={bar.event}
|
|
day={week[bar.startCol]}
|
|
continuesAfter={bar.continuesAfter}
|
|
class="w-full" />
|
|
</div>
|
|
{/each}
|
|
{#each week as day, col (day.getTime())}
|
|
{@const dayEvents = getSingleDayEvents(eventsByDay.get(makeDayKey(day)) ?? [])}
|
|
<div
|
|
class={cx("flex min-w-0 flex-col gap-1 px-1", {
|
|
"opacity-40": day.getMonth() !== date.getMonth(),
|
|
})}
|
|
style="grid-column: {col + 1}; grid-row: {laneCount + 2} / -1;">
|
|
{#each dayEvents.slice(0, 3) as event (event.id)}
|
|
<CalendarEventChip {url} {event} {day} />
|
|
{/each}
|
|
{#if dayEvents.length > 3}
|
|
<Button
|
|
class="px-1.5 text-left text-xs opacity-75 hover:opacity-100"
|
|
onclick={() => showDay(day)}>
|
|
+{dayEvents.length - 3} more
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
</div>
|