Files
open-webui/src/lib/components/calendar/CalendarEventModal.svelte
T

343 lines
10 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 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 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 = '';
}
}
$: 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,
rrule: getRepeatRrule(),
location: location.trim() || undefined,
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="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>
<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}
/>