mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-11 03:38:02 +00:00
369 lines
11 KiB
Svelte
369 lines
11 KiB
Svelte
<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}
|
||
/>
|