Files
open-webui-ai4me/src/lib/components/calendar/CalendarView.svelte
T
2026-04-19 19:15:05 +09:00

381 lines
15 KiB
Svelte

<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { mobile, showSidebar } from '$lib/stores';
import type { CalendarEventModel, CalendarModel } from '$lib/apis/calendar';
import CalendarEventChip from './CalendarEventChip.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
import Select from '$lib/components/common/Select.svelte';
import Check from '$lib/components/icons/Check.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let events: CalendarEventModel[] = [];
export let calendars: CalendarModel[] = [];
export let visibleCalendarIds: Set<string> = new Set();
export let view: 'month' | 'week' | 'day' = 'month';
export let currentDate: Date = new Date();
const NS = 1_000_000;
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
$: calColorMap = calendars.reduce((acc, c) => ({ ...acc, [c.id]: c.color }), {} as Record<string, string | null>);
$: filteredEvents = events.filter((e) => visibleCalendarIds.has(e.calendar_id));
// Pre-group events by day key so the template reactively updates when events change
$: eventsByDay = (() => {
const map: Record<string, CalendarEventModel[]> = {};
for (const e of filteredEvents) {
const startMs = e.start_at / NS;
const endMs = (e.end_at || e.start_at) / NS;
// Get local midnight for event start/end
const startDate = new Date(startMs);
const endDate = new Date(endMs);
const d = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
const last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate()).getTime();
while (d.getTime() <= last) {
const key = d.getTime().toString();
(map[key] ??= []).push(e);
d.setDate(d.getDate() + 1);
}
}
return map;
})();
$: monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1);
$: calendarStart = (() => {
const d = new Date(monthStart);
d.setDate(d.getDate() - d.getDay());
return d;
})();
$: monthDays = (() => {
const days: Date[] = [];
const d = new Date(calendarStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return days;
})();
$: weekStart = (() => {
const d = new Date(currentDate);
d.setDate(d.getDate() - d.getDay());
d.setHours(0, 0, 0, 0);
return d;
})();
$: weekDays = (() => {
const days: Date[] = [];
const d = new Date(weekStart);
for (let i = 0; i < 7; i++) {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return days;
})();
$: hours = Array.from({ length: 24 }, (_, i) => i);
function isToday(d: Date): boolean {
return d.toDateString() === new Date().toDateString();
}
function isCurrentMonth(d: Date): boolean {
return d.getMonth() === currentDate.getMonth();
}
function getEventsForDay(day: Date): CalendarEventModel[] {
const dayStartMs = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime();
const dayEndMs = dayStartMs + 86_400_000;
return filteredEvents.filter((e) => {
const startMs = e.start_at / NS;
const endMs = (e.end_at || e.start_at) / NS;
return startMs < dayEndMs && endMs >= dayStartMs;
});
}
function getEventsForHour(day: Date, hour: number, eventsList: CalendarEventModel[] = filteredEvents): CalendarEventModel[] {
const hourStartMs = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour).getTime();
const hourEndMs = hourStartMs + 3_600_000;
return eventsList.filter((e) => {
const startMs = e.start_at / NS;
return startMs >= hourStartMs && startMs < hourEndMs;
});
}
function formatHour(h: number): string {
if (h === 0) return '12 AM';
if (h < 12) return `${h} AM`;
if (h === 12) return '12 PM';
return `${h - 12} PM`;
}
function navigate(delta: number) {
const d = new Date(currentDate);
if (view === 'month') {
d.setDate(1);
d.setMonth(d.getMonth() + delta);
} else if (view === 'week') d.setDate(d.getDate() + delta * 7);
else d.setDate(d.getDate() + delta);
currentDate = d;
dispatch('navigate', { date: currentDate });
}
function goToToday() {
currentDate = new Date();
dispatch('navigate', { date: currentDate });
}
function handleDayClick(day: Date) {
currentDate = day;
const ms = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 9).getTime();
dispatch('createEvent', { start_at: ms * NS });
}
function goToDayView(day: Date) {
currentDate = day;
view = 'day';
dispatch('viewChange', view);
dispatch('navigate', { date: currentDate });
}
function handleHourClick(day: Date, hour: number) {
currentDate = day;
const ms = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour).getTime();
dispatch('createEvent', { start_at: ms * NS });
}
function handleEventClick(event: CalendarEventModel) {
dispatch('eventClick', event);
}
$: headerText = view === 'day'
? `${DAY_NAMES[currentDate.getDay()]}, ${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getDate()}, ${currentDate.getFullYear()}`
: `${MONTH_NAMES[currentDate.getMonth()]} ${currentDate.getFullYear()}`;
</script>
<div class="flex flex-col h-full w-full min-h-0 min-w-0">
<!-- Navbar — matches admin/workspace/notes pattern -->
<nav class="px-2.5 pt-1.5 pb-2 backdrop-blur-xl drag-region select-none shrink-0">
<div class="flex items-center gap-1">
{#if $mobile}
<div class="{$showSidebar ? 'md:hidden' : ''} flex flex-none items-center">
<Tooltip content={$showSidebar ? $i18n.t('Close Sidebar') : $i18n.t('Open Sidebar')} interactive={true}>
<button
id="sidebar-toggle-button"
class="cursor-pointer flex rounded-lg hover:bg-gray-100 dark:hover:bg-gray-850 transition"
on:click={() => showSidebar.set(!$showSidebar)}
>
<div class="self-center p-1.5">
<SidebarIcon />
</div>
</button>
</Tooltip>
</div>
{/if}
<div class="flex w-full items-center">
<div class="flex items-center gap-0.5 py-1">
<span class="min-w-fit px-1 text-sm select-none">{headerText}</span>
<button class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition" on:click={() => navigate(-1)} aria-label="Previous">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3.5 text-gray-400"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
</button>
<button class="p-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition" on:click={() => navigate(1)} aria-label="Next">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-3.5 text-gray-400"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
</button>
</div>
<div class="ml-auto flex items-center gap-1">
<button
class="text-xs px-2 py-1 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-850 transition text-gray-500 hover:text-gray-700 dark:hover:text-white"
on:click={goToToday}
>
{$i18n.t('Today')}
</button>
<Select
bind:value={view}
items={[
{ value: 'day', label: $i18n.t('Day') },
{ value: 'week', label: $i18n.t('Week') },
{ value: 'month', label: $i18n.t('Month') }
]}
onChange={() => dispatch('viewChange', view)}
triggerClass="relative flex items-center gap-1.5 px-3 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl text-xs"
contentClass="rounded-2xl w-40 p-1 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
align="end"
>
<svelte:fragment slot="trigger" let:selectedLabel>
<span class="inline-flex h-input px-0.5 outline-hidden bg-transparent">
{selectedLabel}
</span>
<ChevronDown className="size-3.5" strokeWidth="2.5" />
</svelte:fragment>
<svelte:fragment slot="item" let:item let:selected>
{item.label}
<div class="ml-auto {selected ? '' : 'invisible'}">
<Check />
</div>
</svelte:fragment>
</Select>
<button
class="md:hidden px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition text-sm flex items-center"
on:click={() => dispatch('newEvent')}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="size-3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
</button>
</div>
</div>
</div>
</nav>
<!-- Month View -->
{#if view === 'month'}
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
<div class="grid grid-cols-7">
{#each DAY_NAMES as day}
<div class="px-2 py-1.5 text-xs text-gray-400 dark:text-gray-500 text-left">{$i18n.t(day)}</div>
{/each}
</div>
<div class="flex-1 grid grid-cols-7 auto-rows-fr min-h-0 rounded-2xl overflow-hidden bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30">
{#each monthDays as day, i}
{@const dayKey = new Date(day.getFullYear(), day.getMonth(), day.getDate()).getTime().toString()}
{@const dayEvents = eventsByDay[dayKey] || []}
{@const col = i % 7}
{@const row = Math.floor(i / 7)}
<button
class="p-1 min-h-0 text-left overflow-hidden transition cursor-pointer flex flex-col
{isCurrentMonth(day) ? '' : 'opacity-40'}
hover:bg-gray-50/80 dark:hover:bg-gray-850/30
{col > 0 ? 'border-l border-gray-100/20 dark:border-gray-850/20' : ''}
{row > 0 ? 'border-t border-gray-100/20 dark:border-gray-850/20' : ''}"
on:click={() => handleDayClick(day)}
>
<div class="flex justify-start px-0.5 mb-0.5">
<span
class="text-xs w-6 h-6 flex items-center justify-center rounded-full
{isToday(day) ? 'bg-blue-500 text-white' : 'text-gray-500 dark:text-gray-400'}"
>
{day.getDate()}
</span>
</div>
<div class="flex flex-col gap-0 flex-1 overflow-hidden">
{#each dayEvents.slice(0, 3) as evt (evt.instance_id || evt.id)}
<CalendarEventChip
event={evt}
calendarColor={calColorMap[evt.calendar_id]}
on:click={() => handleEventClick(evt)}
/>
{/each}
{#if dayEvents.length > 3}
<!-- svelte-ignore a11y-click-events-have-key-events --><!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-[10px] text-gray-400 dark:text-gray-500 px-1 mt-auto hover:text-gray-700 dark:hover:text-gray-200 text-left w-full truncate z-10"
on:click|stopPropagation={() => goToDayView(day)}
>
+{dayEvents.length - 3} more
</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
<!-- Week View -->
{:else if view === 'week'}
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
<div class="flex-1 rounded-2xl bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30 overflow-hidden relative">
<div class="absolute inset-0 overflow-x-auto flex flex-col">
<div class="min-w-[700px] flex flex-col flex-1">
<div class="grid grid-cols-[52px_repeat(7,1fr)] shrink-0 border-b border-gray-100/30 dark:border-gray-850/30">
<div></div>
{#each weekDays as day}
<div class="text-center py-2.5 {day.getDay() > 0 ? 'border-l border-gray-100/20 dark:border-gray-850/20' : ''}">
<div class="text-[11px] text-gray-400 dark:text-gray-500">{DAY_NAMES[day.getDay()]}</div>
<div class="text-sm mt-0.5 w-7 h-7 flex items-center justify-center mx-auto rounded-full {isToday(day) ? 'bg-blue-500 text-white' : ''}">
{day.getDate()}
</div>
</div>
{/each}
</div>
<div class="flex-1 overflow-y-auto">
{#each hours as hour}
<div class="grid grid-cols-[52px_repeat(7,1fr)] min-h-[52px] {hour > 0 ? 'border-t border-gray-100/15 dark:border-gray-850/15' : ''}">
<div class="text-[10px] text-gray-400 dark:text-gray-500 text-right pr-2 select-none -mt-1.5 z-10">{hour > 0 ? formatHour(hour) : ''}</div>
{#each weekDays as day}
{@const hourEvents = getEventsForHour(day, hour, filteredEvents)}
<button
class="px-0.5 py-0.5 {day.getDay() > 0 ? 'border-l border-gray-100/15 dark:border-gray-850/15' : ''} hover:bg-gray-50/50 dark:hover:bg-gray-850/20 transition cursor-pointer min-w-0 flex flex-col"
on:click={() => handleHourClick(day, hour)}
>
<div class="flex flex-col gap-0.5 w-full min-h-0">
{#each hourEvents.slice(0, 3) as evt (evt.instance_id || evt.id)}
<CalendarEventChip
event={evt}
calendarColor={calColorMap[evt.calendar_id]}
on:click={() => handleEventClick(evt)}
/>
{/each}
{#if hourEvents.length > 3}
<!-- svelte-ignore a11y-click-events-have-key-events --><!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="text-[10px] text-gray-400 dark:text-gray-500 px-1 mt-auto hover:text-gray-700 dark:hover:text-gray-200 text-left w-full truncate z-10"
on:click|stopPropagation={() => goToDayView(day)}
>
+{hourEvents.length - 3} more
</div>
{/if}
</div>
</button>
{/each}
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
<!-- Day View -->
{:else}
<div class="flex-1 flex flex-col min-h-0 px-3 pb-3">
<div class="flex-1 rounded-2xl overflow-hidden bg-white dark:bg-gray-900 border border-gray-100/30 dark:border-gray-850/30 overflow-y-auto">
{#each hours as hour}
{@const hourEvents = getEventsForHour(currentDate, hour, filteredEvents)}
<div class="flex min-h-[52px] {hour > 0 ? 'border-t border-gray-100/15 dark:border-gray-850/15' : ''}">
<div class="w-14 shrink-0 text-[10px] text-gray-400 dark:text-gray-500 text-right pr-3 -mt-1.5 select-none">{hour > 0 ? formatHour(hour) : ''}</div>
<button
class="flex-1 border-l border-gray-100/15 dark:border-gray-850/15 px-1.5 py-0.5
hover:bg-gray-50/50 dark:hover:bg-gray-850/20 transition cursor-pointer flex flex-col text-left justify-start"
on:click={() => handleHourClick(currentDate, hour)}
>
<div class="flex flex-col gap-0.5 w-full">
{#each hourEvents as evt (evt.instance_id || evt.id)}
<CalendarEventChip
event={evt}
calendarColor={calColorMap[evt.calendar_id]}
on:click={() => handleEventClick(evt)}
/>
{/each}
</div>
</button>
</div>
{/each}
</div>
</div>
{/if}
</div>