Basic CalDav UI (Calendar)

This implements a basic CalDav UI, titled "Calendar". It allows creating new calendars and events with a start and end date, URL, location, and description.

You can also import and export ICS (VCALENDAR + VEVENT) files.

It allows editing the ICS directly, for power users.

Additionally, you can hide/display events from certain calendars, change their names and their colors. If there's no calendar created yet in your CalDav server (first-time setup), it'll automatically create one, titled "Calendar".

You can also change the display timezone for the calendar from the settings.

Finally, there's some minor documentation fixes and some other minor tweaks.

Closes #56
Closes #89
This commit is contained in:
Bruno Bernardino
2025-09-06 12:46:13 +01:00
parent f14c40d05d
commit 15dcc8803d
39 changed files with 6483 additions and 30 deletions

View File

@@ -3,6 +3,7 @@ import { convertObjectToFormData } from '/lib/utils/misc.ts';
import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts';
import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx';
import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts';
import { getTimeZones } from '/lib/utils/calendar.ts';
interface SettingsProps {
formData: Record<string, any>;
@@ -15,8 +16,10 @@ interface SettingsProps {
message: string;
};
currency?: SupportedCurrencySymbol;
timezoneId?: string;
isExpensesAppEnabled: boolean;
isMultiFactorAuthEnabled: boolean;
isCalendarAppEnabled: boolean;
helpEmail: string;
user: {
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
@@ -29,7 +32,8 @@ export type Action =
| 'change-password'
| 'change-dav-password'
| 'delete-account'
| 'change-currency';
| 'change-currency'
| 'change-timezone';
export const actionWords = new Map<Action, string>([
['change-email', 'change email'],
@@ -38,9 +42,10 @@ export const actionWords = new Map<Action, string>([
['change-dav-password', 'change WebDav password'],
['delete-account', 'delete account'],
['change-currency', 'change currency'],
['change-timezone', 'change timezone'],
]);
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol) {
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) {
const fields: FormField[] = [
{
name: 'action',
@@ -122,6 +127,20 @@ function formFields(action: Action, formData: FormData, currency?: SupportedCurr
value: getFormDataField(formData, 'currency') || currency,
required: true,
});
} else if (action === 'change-timezone') {
const timezones = getTimeZones();
fields.push({
name: 'timezone',
label: 'Timezone',
type: 'select',
options: timezones.map((timezone) => ({
value: timezone.id,
label: timezone.label,
})),
value: getFormDataField(formData, 'timezone') || timezoneId,
required: true,
});
}
return fields;
}
@@ -132,8 +151,10 @@ export default function Settings(
error,
notice,
currency,
timezoneId,
isExpensesAppEnabled,
isMultiFactorAuthEnabled,
isCalendarAppEnabled,
helpEmail,
user,
}: SettingsProps,
@@ -201,7 +222,9 @@ export default function Settings(
</p>
<form method='POST' class='mb-12'>
{formFields('change-currency', formData, currency).map((field) => generateFieldHtml(field, formData))}
{formFields('change-currency', formData, currency, timezoneId).map((field) =>
generateFieldHtml(field, formData)
)}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-secondary' type='submit'>Change currency</button>
</section>
@@ -210,6 +233,26 @@ export default function Settings(
)
: null}
{isCalendarAppEnabled
? (
<>
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your timezone</h2>
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
This is only used in the calendar app.
</p>
<form method='POST' class='mb-12'>
{formFields('change-timezone', formData, currency, timezoneId).map((field) =>
generateFieldHtml(field, formData)
)}
<section class='flex justify-end mt-8 mb-4'>
<button class='button-secondary' type='submit'>Change timezone</button>
</section>
</form>
</>
)
: null}
{isMultiFactorAuthEnabled
? (
<MultiFactorAuthSettings

View File

@@ -0,0 +1,30 @@
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import MainCalendar from '/components/calendar/MainCalendar.tsx';
interface CalendarWrapperProps {
initialCalendars: Calendar[];
initialCalendarEvents: CalendarEvent[];
view: 'day' | 'week' | 'month';
startDate: string;
baseUrl: string;
timezoneId: string;
timezoneUtcOffset: number;
}
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
export default function CalendarWrapper(
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId, timezoneUtcOffset }:
CalendarWrapperProps,
) {
return (
<MainCalendar
initialCalendars={initialCalendars}
initialCalendarEvents={initialCalendarEvents}
view={view}
startDate={startDate}
baseUrl={baseUrl}
timezoneId={timezoneId}
timezoneUtcOffset={timezoneUtcOffset}
/>
);
}

View File

@@ -0,0 +1,314 @@
import { useSignal } from '@preact/signals';
import { Calendar } from '/lib/models/calendar.ts';
import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/lib/utils/calendar.ts';
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add.tsx';
import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/routes/api/calendar/update.tsx';
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/calendar/delete.tsx';
interface CalendarsProps {
initialCalendars: Calendar[];
}
export default function Calendars({ initialCalendars }: CalendarsProps) {
const isAdding = useSignal<boolean>(false);
const isDeleting = useSignal<boolean>(false);
const isSaving = useSignal<boolean>(false);
const calendars = useSignal<Calendar[]>(initialCalendars);
const openCalendar = useSignal<Calendar | null>(null);
async function onClickAddCalendar() {
if (isAdding.value) {
return;
}
const name = (prompt(`What's the **name** for the new calendar?`) || '').trim();
if (!name) {
alert('A name is required for a new calendar!');
return;
}
isAdding.value = true;
try {
const requestBody: AddRequestBody = { name };
const response = await fetch(`/api/calendar/add`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as AddResponseBody;
if (!result.success) {
throw new Error('Failed to add calendar!');
}
calendars.value = [...result.newCalendars];
} catch (error) {
console.error(error);
}
isAdding.value = false;
}
async function onClickDeleteCalendar(calendarId: string) {
if (confirm('Are you sure you want to delete this calendar and all its events?')) {
if (isDeleting.value) {
return;
}
isDeleting.value = true;
try {
const requestBody: DeleteRequestBody = { calendarId };
const response = await fetch(`/api/calendar/delete`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as DeleteResponseBody;
if (!result.success) {
throw new Error('Failed to delete calendar!');
}
calendars.value = [...result.newCalendars];
} catch (error) {
console.error(error);
}
isDeleting.value = false;
}
}
async function onClickSaveOpenCalendar() {
if (isSaving.value) {
return;
}
if (!openCalendar.value?.uid) {
alert('A calendar is required to update one!');
return;
}
if (!openCalendar.value?.displayName) {
alert('A name is required to update the calendar!');
return;
}
if (!openCalendar.value?.calendarColor) {
alert('A color is required to update the calendar!');
return;
}
isSaving.value = true;
try {
const requestBody: UpdateRequestBody = {
id: openCalendar.value.uid!,
name: openCalendar.value.displayName!,
color: openCalendar.value.calendarColor!,
isVisible: openCalendar.value.isVisible!,
};
const response = await fetch(`/api/calendar/update`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as UpdateResponseBody;
if (!result.success) {
throw new Error('Failed to update calendar!');
}
calendars.value = [...result.newCalendars];
} catch (error) {
console.error(error);
}
isSaving.value = false;
openCalendar.value = null;
}
return (
<>
<section class='flex flex-row items-center justify-between mb-4'>
<a href='/calendar' class='mr-2'>View calendar</a>
<section class='flex items-center'>
<button
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
type='button'
title='Add new calendar'
onClick={() => onClickAddCalendar()}
>
<img
src='/images/add.svg'
alt='Add new calendar'
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
width={20}
height={20}
/>
</button>
</section>
</section>
<section class='mx-auto max-w-7xl my-8'>
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-white shadow-sm rounded-md'>
<thead>
<tr>
<th scope='col' class='px-6 py-4 font-medium'>Name</th>
<th scope='col' class='px-6 py-4 font-medium'>Color</th>
<th scope='col' class='px-6 py-4 font-medium'>Visible?</th>
<th scope='col' class='px-6 py-4 font-medium w-20'></th>
</tr>
</thead>
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
{calendars.value.map((calendar) => (
<tr class='bg-slate-700 hover:bg-slate-600 group'>
<td class='flex gap-3 px-6 py-4 font-medium'>
{calendar.displayName}
</td>
<td class='px-6 py-4 text-slate-200'>
<span
class={`w-5 h-5 inline-block rounded-full cursor-pointer`}
title={calendar.calendarColor}
style={{ backgroundColor: calendar.calendarColor }}
onClick={() => openCalendar.value = { ...calendar }}
>
</span>
</td>
<td class='px-6 py-4'>
{calendar.isVisible ? 'Yes' : 'No'}
</td>
<td class='px-6 py-4'>
<span
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
onClick={() => onClickDeleteCalendar(calendar.uid!)}
>
<img
src='/images/delete.svg'
class='red drop-shadow-md'
width={24}
height={24}
alt='Delete calendar'
title='Delete calendar'
/>
</span>
</td>
</tr>
))}
{calendars.value.length === 0
? (
<tr>
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
<div class='text-md'>
<div class='font-medium text-slate-400'>No calendars to show</div>
</div>
</td>
</tr>
)
: null}
</tbody>
</table>
<span
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
>
{isDeleting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</>
)
: null}
{isSaving.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
</>
)
: null}
{!isDeleting.value && !isSaving.value ? <>&nbsp;</> : null}
</span>
</section>
<section
class={`fixed ${
openCalendar.value ? 'block' : 'hidden'
} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
>
</section>
<section
class={`fixed ${
openCalendar.value ? 'block' : 'hidden'
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
>
<h1 class='text-2xl font-semibold my-5'>Edit Calendar</h1>
<section class='py-5 my-2 border-y border-slate-500'>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='calendar_name'>Name</label>
<input
class='input-field'
type='text'
name='calendar_name'
id='calendar_name'
value={openCalendar.value?.displayName || ''}
onInput={(event) =>
openCalendar.value = { ...openCalendar.value!, displayName: event.currentTarget.value }}
placeholder='Personal'
/>
</fieldset>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='calendar_color'>Color</label>
<section class='flex items-center justify-between'>
<select
class='input-field mr-2 !w-5/6'
name='calendar_color'
id='calendar_color'
value={openCalendar.value?.calendarColor || ''}
onChange={(event) =>
openCalendar.value = { ...openCalendar.value!, calendarColor: event.currentTarget.value }}
>
{CALENDAR_COLOR_OPTIONS.map((color) => <option key={color} value={getColorAsHex(color)}>{color}
</option>)}
</select>
<span
class={`w-5 h-5 block rounded-full`}
style={{ backgroundColor: openCalendar.value?.calendarColor }}
title={openCalendar.value?.calendarColor}
>
</span>
</section>
</fieldset>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='calendar_is_visible'>Visible?</label>
<input
type='checkbox'
name='calendar_is_visible'
id='calendar_is_visible'
value='true'
checked={openCalendar.value?.isVisible}
onChange={(event) =>
openCalendar.value = { ...openCalendar.value!, isVisible: event.currentTarget.checked }}
/>
</fieldset>
</section>
<footer class='flex justify-between'>
<button
type='button'
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClickSaveOpenCalendar()}
>
Save
</button>
<button
type='button'
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => openCalendar.value = null}
>
Close
</button>
</footer>
</section>
</>
);
}

View File

@@ -0,0 +1,263 @@
import { useSignal } from '@preact/signals';
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
import { capitalizeWord, convertObjectToFormData } from '/lib/utils/misc.ts';
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
import {
RequestBody as DeleteRequestBody,
ResponseBody as DeleteResponseBody,
} from '/routes/api/calendar/delete-event.tsx';
interface ViewCalendarEventProps {
initialCalendarEvent: CalendarEvent;
calendars: Calendar[];
formData: Record<string, any>;
error?: string;
notice?: string;
}
export function formFields(calendarEvent: CalendarEvent, calendars: Calendar[], updateType: 'raw' | 'ui') {
const fields: FormField[] = [
{
name: 'update-type',
label: 'Update type',
type: 'hidden',
value: updateType,
readOnly: true,
},
];
if (updateType === 'ui') {
fields.push({
name: 'title',
label: 'Title',
type: 'text',
placeholder: 'Dentis',
value: calendarEvent.title,
required: true,
}, {
name: 'calendarId',
label: 'Calendar',
type: 'select',
value: calendarEvent.calendarId,
options: calendars.map((calendar) => ({ label: calendar.displayName!, value: calendar.uid! })),
required: true,
description: 'Cannot be changed after the event has been created.',
}, {
name: 'startDate',
label: 'Start date',
type: 'datetime-local',
value: new Date(calendarEvent.startDate).toISOString().substring(0, 16),
required: true,
description: 'Dates are set in the default calendar timezone, controlled by Radicale.',
}, {
name: 'endDate',
label: 'End date',
type: 'datetime-local',
value: new Date(calendarEvent.endDate).toISOString().substring(0, 16),
required: true,
description: 'Dates are set in the default calendar timezone, controlled by Radicale.',
}, {
name: 'isAllDay',
label: 'All-day?',
type: 'checkbox',
placeholder: 'YYYYMMDD',
value: 'true',
required: false,
checked: calendarEvent.isAllDay,
}, {
name: 'status',
label: 'Status',
type: 'select',
value: calendarEvent.status,
options: (['scheduled', 'pending', 'canceled'] as CalendarEvent['status'][]).map((status) => ({
label: capitalizeWord(status),
value: status,
})),
required: true,
}, {
name: 'description',
label: 'Description',
type: 'textarea',
placeholder: 'Just a regular check-up.',
value: calendarEvent.description,
required: false,
}, {
name: 'eventUrl',
label: 'URL',
type: 'url',
placeholder: 'https://example.com',
value: calendarEvent.eventUrl,
required: false,
}, {
name: 'location',
label: 'Location',
type: 'text',
placeholder: 'Birmingham, UK',
value: calendarEvent.location,
required: false,
}, {
name: 'transparency',
label: 'Transparency',
type: 'select',
value: calendarEvent.transparency,
options: (['opaque', 'transparent'] as CalendarEvent['transparency'][]).map((
transparency,
) => ({
label: capitalizeWord(transparency),
value: transparency,
})),
required: true,
});
} else if (updateType === 'raw') {
fields.push({
name: 'ics',
label: 'Raw ICS',
type: 'textarea',
placeholder: 'Raw ICS...',
value: calendarEvent.data,
description:
'This is the raw ICS for this event. Use this to manually update the event _if_ you know what you are doing.',
rows: '10',
});
}
return fields;
}
export default function ViewCalendarEvent(
{ initialCalendarEvent, calendars, formData: formDataObject, error, notice }: ViewCalendarEventProps,
) {
const isDeleting = useSignal<boolean>(false);
const calendarEvent = useSignal<CalendarEvent>(initialCalendarEvent);
const formData = convertObjectToFormData(formDataObject);
async function onClickDeleteEvent() {
const message = calendarEvent.peek().isRecurring
? 'Are you sure you want to delete _all_ instances of this recurring event?'
: 'Are you sure you want to delete this event?';
if (confirm(message)) {
if (isDeleting.value) {
return;
}
isDeleting.value = true;
try {
const requestBody: DeleteRequestBody = {
calendarIds: calendars.map((calendar) => calendar.uid!),
calendarView: 'day',
calendarStartDate: new Date().toISOString().substring(0, 10),
calendarEventId: calendarEvent.value.uid!,
calendarId: calendarEvent.value.calendarId,
};
const response = await fetch(`/api/calendar/delete-event`, {
method: 'POST',
body: JSON.stringify(requestBody),
});
const result = await response.json() as DeleteResponseBody;
if (!result.success) {
throw new Error('Failed to delete event!');
}
window.location.href = '/calendar';
} catch (error) {
console.error(error);
}
isDeleting.value = false;
}
}
return (
<>
<section class='flex flex-row items-center justify-between mb-4'>
<a href='/calendar' class='mr-2'>View calendar</a>
<section class='flex items-center'>
<button
class='inline-block justify-center gap-x-1.5 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 ml-2'
type='button'
title='Delete event'
onClick={() => onClickDeleteEvent()}
>
<img
src='/images/delete.svg'
alt='Delete event'
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
width={20}
height={20}
/>
</button>
</section>
</section>
<section class='mx-auto max-w-7xl my-8'>
{error
? (
<section class='notification-error'>
<h3>Failed to update!</h3>
<p>{error}</p>
</section>
)
: null}
{notice
? (
<section class='notification-success'>
<h3>Success!</h3>
<p>{notice}</p>
</section>
)
: null}
<form method='POST' class='mb-12'>
{formFields(calendarEvent.peek(), calendars, 'ui').map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end items-center mt-8 mb-4'>
{calendarEvent.peek().isRecurring
? (
<p class='text-sm text-slate-400 mr-4'>
Note that you'll update all instances of this recurring event.
</p>
)
: null}
<button class='button' type='submit'>Update event</button>
</section>
</form>
<hr class='my-8 border-slate-700' />
<details class='mb-12 group'>
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
Edit Raw ICS{' '}
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' />
</span>
</summary>
<form method='POST' class='mb-12'>
{formFields(calendarEvent.peek(), calendars, 'raw').map((field) => generateFieldHtml(field, formData))}
<section class='flex justify-end mt-8 mb-4'>
<button class='button' type='submit'>Update ICS</button>
</section>
</form>
</details>
<span
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
>
{isDeleting.value
? (
<>
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
</>
)
: null}
{!isDeleting.value ? <>&nbsp;</> : null}
</span>
</section>
</>
);
}

View File

@@ -7,7 +7,6 @@ interface FilesWrapperProps {
initialPath: string;
baseUrl: string;
isFileSharingAllowed: boolean;
isCalDavEnabled?: boolean;
fileShareId?: string;
}
@@ -19,7 +18,6 @@ export default function FilesWrapper(
initialPath,
baseUrl,
isFileSharingAllowed,
isCalDavEnabled,
fileShareId,
}: FilesWrapperProps,
) {
@@ -30,7 +28,6 @@ export default function FilesWrapper(
initialPath={initialPath}
baseUrl={baseUrl}
isFileSharingAllowed={isFileSharingAllowed}
isCalDavEnabled={isCalDavEnabled}
fileShareId={fileShareId}
/>
);