feat: calendar

This commit is contained in:
Timothy Jaeryang Baek
2026-04-19 19:15:05 +09:00
parent 5087492e25
commit 8d739e2aba
14 changed files with 2844 additions and 0 deletions
+427
View File
@@ -0,0 +1,427 @@
import { WEBUI_API_BASE_URL } from '$lib/constants';
export type CalendarModel = {
id: string;
user_id: string;
name: string;
color: string | null;
is_system: boolean;
data: Record<string, any> | null;
meta: Record<string, any> | null;
access_grants: any[];
created_at: number;
updated_at: number;
};
export type CalendarEventAttendeeModel = {
id: string;
event_id: string;
user_id: string;
status: string;
meta: Record<string, any> | null;
created_at: number;
updated_at: number;
};
export type CalendarEventModel = {
id: string;
calendar_id: string;
user_id: string;
title: string;
description: string | null;
start_at: number;
end_at: number | null;
all_day: boolean;
rrule: string | null;
color: string | null;
location: string | null;
data: Record<string, any> | null;
meta: Record<string, any> | null;
is_cancelled: boolean;
attendees: CalendarEventAttendeeModel[];
created_at: number;
updated_at: number;
// Set by expand_recurring_event for recurring instances
instance_id?: string;
};
export type CalendarEventForm = {
calendar_id: string;
title: string;
description?: string;
start_at: number;
end_at?: number;
all_day?: boolean;
rrule?: string;
color?: string;
location?: string;
data?: Record<string, any>;
meta?: Record<string, any>;
attendees?: { user_id: string; status?: string }[];
};
export type CalendarForm = {
name: string;
color?: string;
data?: Record<string, any>;
meta?: Record<string, any>;
access_grants?: { target_type: string; target_id: string; permission: string }[];
};
// ── Calendars ─────────────────────────────────
export const getCalendars = async (token: string): Promise<CalendarModel[]> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createCalendar = async (token: string, form: CalendarForm): Promise<CalendarModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateCalendar = async (
token: string,
calendarId: string,
form: Partial<CalendarForm>
): Promise<CalendarModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteCalendar = async (token: string, calendarId: string): Promise<boolean> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/${calendarId}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res?.status ?? false;
};
// ── Events ─────────────────────────────────
export const getCalendarEvents = async (
token: string,
start: string,
end: string,
calendarIds?: string[]
): Promise<CalendarEventModel[]> => {
let error = null;
const params = new URLSearchParams();
params.append('start', start);
params.append('end', end);
if (calendarIds && calendarIds.length > 0) {
params.append('calendar_ids', calendarIds.join(','));
}
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const createCalendarEvent = async (
token: string,
form: CalendarEventForm
): Promise<CalendarEventModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/create`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getCalendarEventById = async (
token: string,
eventId: string
): Promise<CalendarEventModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const updateCalendarEvent = async (
token: string,
eventId: string,
form: Partial<CalendarEventForm>
): Promise<CalendarEventModel> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/update`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify(form)
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const deleteCalendarEvent = async (token: string, eventId: string): Promise<boolean> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/delete`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res?.status ?? false;
};
export const rsvpCalendarEvent = async (
token: string,
eventId: string,
status: string
): Promise<{ status: boolean; rsvp: string }> => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/${eventId}/rsvp`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
},
body: JSON.stringify({ status })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const searchCalendarEvents = async (
token: string,
query: string | null,
skip: number = 0,
limit: number = 30
): Promise<{ items: CalendarEventModel[]; total: number }> => {
let error = null;
const params = new URLSearchParams();
if (query) params.append('query', query);
params.append('skip', skip.toString());
params.append('limit', limit.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/calendars/events/search?${params.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
@@ -0,0 +1,28 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { CalendarEventModel } from '$lib/apis/calendar';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let event: CalendarEventModel;
export let calendarColor: string | null = null;
const dispatch = createEventDispatcher();
</script>
<Tooltip content="{event.title}{event.location ? ` · ${event.location}` : ''}">
<button
class="w-full text-left text-xs flex items-start gap-1.5 py-[1px] px-0.5 rounded-md
{event.meta?.automation_id ? 'opacity-60' : ''}
hover:bg-gray-50 dark:hover:bg-gray-800/50 transition truncate"
on:click|stopPropagation={() => dispatch('click', event)}
>
<span
class="shrink-0 size-[7px] rounded-full mt-[5px]"
style="background-color: {event.color || calendarColor || '#3b82f6'};"
></span>
<span class="truncate">
{#if !event.all_day}<span class="text-gray-500 dark:text-gray-400">{new Date(event.start_at / 1_000_000).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }).replace(' ', '')}</span>{/if}
{event.title}
</span>
</button>
</Tooltip>
@@ -0,0 +1,260 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Modal from '$lib/components/common/Modal.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import type { CalendarModel, CalendarEventModel, CalendarEventForm } from '$lib/apis/calendar';
import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from '$lib/apis/calendar';
const i18n = getContext('i18n');
const dispatch = createEventDispatcher();
export let show = false;
export let event: CalendarEventModel | null = null;
export let calendars: CalendarModel[] = [];
export let defaultCalendarId: string = '';
export let defaultStartAt: number | null = null;
let title = '';
let description = '';
let calendarId = '';
let startDate = '';
let startTime = '';
let endDate = '';
let endTime = '';
let allDay = false;
let location = '';
let loading = false;
const NS = 1_000_000;
function nsToDateStr(ns: number): string {
return new Date(ns / NS).toISOString().slice(0, 10);
}
function nsToTimeStr(ns: number): string {
return new Date(ns / NS).toTimeString().slice(0, 5);
}
function dateTimeToNs(dateStr: string, timeStr: string): number {
return new Date(`${dateStr}T${timeStr || '00:00'}`).getTime() * NS;
}
function reset() {
if (event) {
title = event.title;
description = event.description || '';
calendarId = event.calendar_id;
startDate = nsToDateStr(event.start_at);
startTime = nsToTimeStr(event.start_at);
endDate = event.end_at ? nsToDateStr(event.end_at) : '';
endTime = event.end_at ? nsToTimeStr(event.end_at) : '';
allDay = event.all_day;
location = event.location || '';
} else {
title = '';
description = '';
calendarId = defaultCalendarId || (calendars.length > 0 ? calendars[0].id : '');
if (defaultStartAt) {
startDate = nsToDateStr(defaultStartAt);
startTime = nsToTimeStr(defaultStartAt);
const endNs = defaultStartAt + 60 * 60 * 1000 * NS;
endDate = nsToDateStr(endNs);
endTime = nsToTimeStr(endNs);
} else {
const now = new Date();
startDate = now.toISOString().slice(0, 10);
startTime = now.toTimeString().slice(0, 5);
const later = new Date(now.getTime() + 60 * 60 * 1000);
endDate = later.toISOString().slice(0, 10);
endTime = later.toTimeString().slice(0, 5);
}
allDay = false;
location = '';
}
}
$: if (show) reset();
const submitHandler = async () => {
if (!title.trim()) {
toast.error($i18n.t('Title is required'));
return;
}
loading = true;
try {
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
const endNs = endDate ? dateTimeToNs(endDate, allDay ? '23:59' : endTime) : undefined;
if (event && !event.meta?.automation_id) {
const result = await updateCalendarEvent(localStorage.token, event.id, {
calendar_id: calendarId,
title: title.trim(),
description: description.trim() || undefined,
start_at: startNs,
end_at: endNs,
all_day: allDay,
location: location.trim() || undefined
});
if (result) {
toast.success($i18n.t('Event updated'));
dispatch('save', result);
show = false;
}
} else {
const form: CalendarEventForm = {
calendar_id: calendarId,
title: title.trim(),
description: description.trim() || undefined,
start_at: startNs,
end_at: endNs,
all_day: allDay,
location: location.trim() || undefined
};
const result = await createCalendarEvent(localStorage.token, form);
if (result) {
toast.success($i18n.t('Event created'));
dispatch('save', result);
show = false;
}
}
} catch (err) {
toast.error(`${err}`);
} finally {
loading = false;
}
};
const deleteHandler = async () => {
if (!event || event.meta?.automation_id) return;
loading = true;
try {
await deleteCalendarEvent(localStorage.token, event.id);
toast.success($i18n.t('Event deleted'));
dispatch('delete', event);
show = false;
} catch (err) {
toast.error(`${err}`);
} finally {
loading = false;
}
};
</script>
<Modal size="md" bind:show>
<div>
<!-- Header -->
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
<input
class="w-full text-lg bg-transparent outline-hidden font-primary placeholder:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={title}
placeholder={$i18n.t('Event title')}
/>
<button
class="self-center shrink-0 ml-2"
aria-label={$i18n.t('Close')}
on:click={() => (show = false)}
>
<XMark className="size-5" />
</button>
</div>
<!-- Details -->
<div class="px-5 pb-2 flex flex-col gap-3">
<!-- Calendar -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Calendar')}</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={calendarId}
>
{#each calendars.filter((c) => c.name !== 'Scheduled Tasks') as cal (cal.id)}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>
</div>
<!-- Date / Time -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('When')}</div>
<div class="flex items-center gap-2 text-sm flex-wrap">
<input type="date" class="bg-transparent outline-hidden" bind:value={startDate} />
{#if !allDay}
<input type="time" class="bg-transparent outline-hidden" bind:value={startTime} />
<span class="text-gray-300 dark:text-gray-600">–</span>
<input type="time" class="bg-transparent outline-hidden" bind:value={endTime} />
{/if}
<label class="flex items-center gap-1.5 cursor-pointer text-xs text-gray-400 ml-auto">
<input type="checkbox" class="accent-blue-500" bind:checked={allDay} />
{$i18n.t('All day')}
</label>
</div>
</div>
<!-- Location -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Location')}</div>
<input
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
placeholder={$i18n.t('Add location')}
bind:value={location}
/>
</div>
<!-- Description -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<textarea
class="w-full text-sm bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700 resize-none min-h-[4rem]"
placeholder={$i18n.t('Add description')}
bind:value={description}
rows="3"
></textarea>
</div>
</div>
<!-- Bottom toolbar -->
<div class="flex items-center justify-between px-4 pb-3.5 pt-1 gap-2">
<div class="flex items-center gap-0.5 flex-1 min-w-0">
{#if event && !event.meta?.automation_id}
<button
class="px-3 py-1 text-xs text-gray-400 hover:text-red-500 transition"
type="button"
on:click={deleteHandler}
disabled={loading}
>
{$i18n.t('Delete')}
</button>
{/if}
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="px-3 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 transition"
type="button"
on:click={() => (show = false)}
>
{$i18n.t('Cancel')}
</button>
<button
class="px-3.5 py-1.5 text-sm bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center gap-2 {loading
? 'cursor-not-allowed'
: ''}"
on:click={submitHandler}
type="button"
disabled={loading}
>
{event && !event.meta?.automation_id ? $i18n.t('Save') : $i18n.t('Create')}
{#if loading}
<span class="shrink-0"><Spinner /></span>
{/if}
</button>
</div>
</div>
</div>
</Modal>
@@ -0,0 +1,174 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { CalendarModel } from '$lib/apis/calendar';
const i18n = getContext('i18n');
export let calendars: CalendarModel[] = [];
export let visibleCalendarIds: Set<string> = new Set();
export let currentDate: Date = new Date();
export let onToggle: (id: string) => void = () => {};
export let onCreateCalendar: () => void = () => {};
export let onDateSelect: (date: Date) => void = () => {};
// Mini calendar state
$: miniMonth = currentDate.getMonth();
$: miniYear = currentDate.getFullYear();
$: miniMonthStart = new Date(miniYear, miniMonth, 1);
$: miniCalStart = (() => {
const d = new Date(miniMonthStart);
d.setDate(d.getDate() - d.getDay());
return d;
})();
$: miniDays = (() => {
const days: Date[] = [];
const d = new Date(miniCalStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return days;
})();
$: miniMonthNames = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December'
];
function isToday(d: Date): boolean {
return d.toDateString() === new Date().toDateString();
}
function isSelected(d: Date): boolean {
return d.toDateString() === currentDate.toDateString();
}
function navigateMini(delta: number) {
if (miniMonth + delta > 11) {
miniMonth = 0;
miniYear++;
} else if (miniMonth + delta < 0) {
miniMonth = 11;
miniYear--;
} else {
miniMonth += delta;
}
}
</script>
<div class="flex flex-col gap-4">
<!-- Mini Month Calendar -->
<div>
<div class="flex items-center justify-between px-1 mb-1.5 mt-2">
<div class="text-xs font-medium">{miniMonthNames[miniMonth]} {miniYear}</div>
<div class="flex items-center gap-0.5">
<button
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => navigateMini(-1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/></svg
>
</button>
<button
class="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition"
on:click={() => navigateMini(1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="m8.25 4.5 7.5 7.5-7.5 7.5"
/></svg
>
</button>
</div>
</div>
<div class="grid grid-cols-7 text-center text-[10px] text-gray-400 dark:text-gray-500 mb-0.5">
{#each ['S', 'M', 'T', 'W', 'T', 'F', 'S'] as d}
<div class="py-0.5">{d}</div>
{/each}
</div>
<div class="grid grid-cols-7 text-center text-xs">
{#each miniDays as day}
<button
class="w-7 h-7 flex items-center justify-center rounded-full transition
{day.getMonth() !== miniMonth ? 'text-gray-300 dark:text-gray-600' : ''}
{isToday(day) ? 'bg-blue-500 text-white' : ''}
{day.toDateString() === currentDate.toDateString() && !isToday(day)
? 'bg-gray-200 dark:bg-gray-700'
: ''}
{!isToday(day) && day.toDateString() !== currentDate.toDateString()
? 'hover:bg-gray-100 dark:hover:bg-gray-800'
: ''}"
on:click={() => onDateSelect(day)}
>
{day.getDate()}
</button>
{/each}
</div>
</div>
<!-- Calendar List -->
<div>
<div class="flex items-center justify-between mb-1 px-1">
<div class="text-[11px] text-gray-400 dark:text-gray-500 uppercase tracking-wider">
{$i18n.t('Calendars')}
</div>
</div>
{#each calendars as cal (cal.id)}
<button
class="flex items-center gap-2 px-2 py-1 rounded-lg text-xs transition
hover:bg-gray-50 dark:hover:bg-gray-800/50 w-full text-left"
on:click={() => onToggle(cal.id)}
>
<span
class="shrink-0 size-2.5 rounded-full transition-opacity"
style="background-color: {cal.color || '#3b82f6'}; opacity: {visibleCalendarIds.has(
cal.id
)
? '1'
: '0.25'};"
></span>
<span
class="truncate flex-1 {visibleCalendarIds.has(cal.id)
? ''
: 'text-gray-400 dark:text-gray-500'}"
>
{cal.name}
</span>
</button>
{/each}
</div>
</div>
@@ -0,0 +1,380 @@
<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>
@@ -250,6 +250,38 @@
</a>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.features?.calendar}
<a
href="/calendar"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
goto('/calendar');
}}
>
<div class="self-center mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5"
/>
</svg>
</div>
<div class="self-center truncate">{$i18n.t('Calendar')}</div>
</a>
{/if}
{#if role === 'admin'}
<a
href="/playground"
+201
View File
@@ -0,0 +1,201 @@
<script lang="ts">
import { onMount, getContext, tick } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { WEBUI_NAME, mobile, showSidebar, user } from '$lib/stores';
import {
getCalendars,
getCalendarEvents,
type CalendarModel,
type CalendarEventModel
} from '$lib/apis/calendar';
import CalendarView from '$lib/components/calendar/CalendarView.svelte';
import CalendarSidebar from '$lib/components/calendar/CalendarSidebar.svelte';
import CalendarEventModal from '$lib/components/calendar/CalendarEventModal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
const i18n = getContext('i18n');
let loaded = false;
let calendars: CalendarModel[] = [];
let events: CalendarEventModel[] = [];
let visibleCalendarIds: Set<string> = new Set();
let view: 'month' | 'week' | 'day' = 'month';
let currentDate = new Date();
let showEventModal = false;
let editEvent: CalendarEventModel | null = null;
let defaultStartAt: number | null = null;
function getVisibleRange(): { start: string; end: string } {
const d = new Date(currentDate);
let start: Date;
let end: Date;
if (view === 'month') {
start = new Date(d.getFullYear(), d.getMonth(), 1);
start.setDate(start.getDate() - start.getDay());
end = new Date(start);
end.setDate(end.getDate() + 42);
} else if (view === 'week') {
start = new Date(d);
start.setDate(start.getDate() - start.getDay());
start.setHours(0, 0, 0, 0);
end = new Date(start);
end.setDate(end.getDate() + 7);
} else {
start = new Date(d.getFullYear(), d.getMonth(), d.getDate());
end = new Date(start);
end.setDate(end.getDate() + 1);
}
return {
start: start.toISOString(),
end: end.toISOString()
};
}
async function loadCalendars() {
try {
calendars = (await getCalendars(localStorage.token)) ?? [];
visibleCalendarIds = new Set(calendars.map((c) => c.id));
} catch (err) {
console.error('loadCalendars', err);
calendars = [];
}
}
async function loadEvents() {
try {
const { start, end } = getVisibleRange();
events = await getCalendarEvents(localStorage.token, start, end);
} catch (err) {
toast.error(`${err}`);
}
}
async function refresh() {
await loadEvents();
}
function toggleCalendar(id: string) {
const next = new Set(visibleCalendarIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
visibleCalendarIds = next;
}
function handleCreateEvent(e: CustomEvent<{ start_at: number }>) {
editEvent = null;
defaultStartAt = e.detail.start_at;
showEventModal = true;
}
function handleEventClick(e: CustomEvent<CalendarEventModel>) {
const evt = e.detail;
if (evt.meta?.automation_id) {
if (evt.meta?.chat_id) {
goto(`/c/${evt.meta.chat_id}`);
} else {
goto(`/automations/${evt.meta.automation_id}`);
}
return;
}
editEvent = evt;
defaultStartAt = null;
showEventModal = true;
}
async function handleNavigate() {
await tick();
refresh();
}
async function handleDateSelect(date: Date) {
currentDate = date;
await tick();
refresh();
}
function handleNewEvent() {
editEvent = null;
defaultStartAt = null;
showEventModal = true;
}
$: defaultCalendarId = calendars.find((c) => !c.is_system || c.name === 'Personal')?.id || calendars[0]?.id || '';
onMount(async () => {
await loadCalendars();
await refresh();
loaded = true;
});
</script>
<svelte:head>
<title>{$i18n.t('Calendar')} • {$WEBUI_NAME}</title>
</svelte:head>
<CalendarEventModal
bind:show={showEventModal}
event={editEvent}
{calendars}
{defaultCalendarId}
{defaultStartAt}
on:save={() => refresh()}
on:delete={() => refresh()}
/>
<div
class="flex flex-col w-full h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
? 'md:max-w-[calc(100%-var(--sidebar-width))]'
: ''} max-w-full"
>
{#if loaded}
<div class="flex flex-1 min-h-0">
<!-- Sidebar -->
<div class="hidden md:flex flex-col w-56 shrink-0 px-3 pt-3 overflow-y-auto">
<button
class="px-2 py-1.5 mt-0.5 mb-2.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition text-sm flex items-center justify-center gap-1"
on:click={handleNewEvent}
>
<Plus className="size-3" strokeWidth="2.5" />
<span class="text-xs">{$i18n.t('New Event')}</span>
</button>
<CalendarSidebar
{calendars}
{visibleCalendarIds}
{currentDate}
onToggle={toggleCalendar}
onDateSelect={handleDateSelect}
/>
</div>
<!-- Calendar -->
<div class="flex-1 flex flex-col min-h-0 pt-0.5">
<CalendarView
{events}
{calendars}
{visibleCalendarIds}
bind:view
bind:currentDate
on:createEvent={handleCreateEvent}
on:eventClick={handleEventClick}
on:navigate={handleNavigate}
on:viewChange={handleNavigate}
on:newEvent={handleNewEvent}
/>
</div>
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
</div>