open-webui/src/lib/components/calendar/CalendarEventModal.svelte
Timothy Jaeryang Baek bb6f600e5c refac
2026-10-10 22:58:08 +04:00

369 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Modal from '$lib/components/common/Modal.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.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 endDayOffset = 0;
let overnight = false;
let endTime = '';
let allDay = false;
let location = '';
let alertMinutes: number = 10;
let repeatFrequency = '';
let loading = false;
let showDeleteConfirmDialog = false;
const REPEAT_RRULE_MAP: Record<string, string> = {
daily: 'FREQ=DAILY',
weekdays: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR',
weekly: 'FREQ=WEEKLY',
monthly: 'FREQ=MONTHLY',
yearly: 'FREQ=YEARLY'
};
function getRepeatRrule(): string | undefined {
return REPEAT_RRULE_MAP[repeatFrequency] || undefined;
}
function parseRepeatFromRrule(rrule: string | null): string {
if (!rrule) return '';
const normalized = rrule.toUpperCase().replace(/\s/g, '');
for (const [key, value] of Object.entries(REPEAT_RRULE_MAP)) {
if (normalized === value) return key;
}
return '';
}
const NS = 1_000_000;
function nsToDateStr(ns: number): string {
const d = new Date(ns / NS);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
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 endsPastMidnight(): boolean {
return !allDay && endTime < startTime;
}
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 || '';
alertMinutes = event.meta?.alert_minutes ?? 10;
repeatFrequency = parseRepeatFromRrule(event.rrule);
} 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 = nsToDateStr(now.getTime() * NS);
startTime = now.toTimeString().slice(0, 5);
const later = new Date(now.getTime() + 60 * 60 * 1000);
endDate = nsToDateStr(later.getTime() * NS);
endTime = later.toTimeString().slice(0, 5);
}
allDay = false;
location = '';
alertMinutes = 10;
repeatFrequency = '';
}
endDayOffset = endDate
? Math.round(
(dateTimeToNs(endDate, '12:00') - dateTimeToNs(startDate, '12:00')) /
(24 * 60 * 60 * 1000 * NS)
)
: 0;
overnight = endDayOffset === 1 && endsPastMidnight();
}
$: if (show) reset();
const submitHandler = async () => {
if (!title.trim()) {
toast.error($i18n.t('Title is required'));
return;
}
if (!startDate) {
toast.error($i18n.t('Date is required'));
return;
}
loading = true;
try {
const startNs = dateTimeToNs(startDate, allDay ? '00:00' : startTime);
const endDays = overnight && !endsPastMidnight() ? 0 : endDayOffset;
const shiftedEndDate = endDate
? nsToDateStr(dateTimeToNs(startDate, '12:00') + endDays * 24 * 60 * 60 * 1000 * NS)
: '';
let endNs = shiftedEndDate
? dateTimeToNs(shiftedEndDate, allDay ? '23:59' : endTime)
: undefined;
if (endNs !== undefined && endNs < startNs) {
const duration =
event?.end_at && event.end_at > event.start_at ? event.end_at - event.start_at : 0;
endNs = startNs + duration;
}
if (event && !event.meta?.automation_id) {
const result = await updateCalendarEvent(localStorage.token, event.id, {
calendar_id: calendarId,
title: title.trim(),
description: description.trim() || null,
start_at: startNs,
end_at: endNs,
all_day: allDay,
rrule: getRepeatRrule() ?? null,
location: location.trim() || null,
meta: { alert_minutes: alertMinutes }
});
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,
rrule: getRepeatRrule(),
location: location.trim() || undefined,
meta: { alert_minutes: alertMinutes }
};
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>
<div class="dark:text-gray-100 px-4 pt-3 pb-1">
<input
class="w-full text-base bg-transparent outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700"
type="text"
bind:value={title}
placeholder={$i18n.t('Event title')}
/>
</div>
<div class="px-4 pb-2 flex flex-col gap-2.5">
<!-- 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.id !== '__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 dark:scheme-dark"
bind:value={startDate}
/>
{#if !allDay}
<input
type="time"
class="bg-transparent outline-hidden dark:scheme-dark"
bind:value={startTime}
/>
<span class="text-gray-300 dark:text-gray-600">–</span>
<input
type="time"
class="bg-transparent outline-hidden dark:scheme-dark"
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>
<!-- Reminder -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Reminder')}</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={alertMinutes}
>
<option value={-1}>{$i18n.t('None')}</option>
<option value={0}>{$i18n.t('At time of event')}</option>
<option value={5}>{$i18n.t('5 minutes before')}</option>
<option value={10}>{$i18n.t('10 minutes before')}</option>
<option value={15}>{$i18n.t('15 minutes before')}</option>
<option value={30}>{$i18n.t('30 minutes before')}</option>
<option value={60}>{$i18n.t('1 hour before')}</option>
</select>
</div>
<!-- Repeat -->
<div>
<div class="mb-1 text-xs text-gray-500">{$i18n.t('Repeat')}</div>
<select
class="w-full text-sm bg-transparent outline-hidden cursor-pointer"
bind:value={repeatFrequency}
>
<option value="">{$i18n.t('No Repeat')}</option>
<option value="daily">{$i18n.t('Daily')}</option>
<option value="weekdays">{$i18n.t('Monday – Friday')}</option>
<option value="weekly">{$i18n.t('Weekly')}</option>
<option value="monthly">{$i18n.t('Monthly')}</option>
<option value="yearly">{$i18n.t('Yearly')}</option>
</select>
</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>
<div class="flex items-center justify-between px-4 pb-3 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-gray-700 dark:hover:text-gray-200 transition"
type="button"
on:click={() => (showDeleteConfirmDialog = true)}
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="flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white disabled: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 className="size-3" /></span>
{/if}
</button>
</div>
</div>
</div>
</Modal>
<DeleteConfirmDialog
bind:show={showDeleteConfirmDialog}
title={$i18n.t('Delete Event')}
message={$i18n.t('This action cannot be undone. Do you wish to continue?')}
on:confirm={deleteHandler}
/>