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:
74
routes/api/calendar/add-event.tsx
Normal file
74
routes/api/calendar/add-event.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { generateVCalendar, getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
calendarView: 'day' | 'week' | 'month';
|
||||
calendarStartDate: string;
|
||||
calendarId: string;
|
||||
title: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isAllDay?: boolean;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.title || !requestBody.startDate ||
|
||||
!requestBody.endDate || !requestBody.calendarView || !requestBody.calendarStartDate
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const userId = context.state.user.id;
|
||||
|
||||
const eventId = crypto.randomUUID();
|
||||
|
||||
const newEvent: CalendarEvent = {
|
||||
calendarId: requestBody.calendarId,
|
||||
title: requestBody.title,
|
||||
startDate: new Date(requestBody.startDate),
|
||||
endDate: new Date(requestBody.endDate),
|
||||
isAllDay: Boolean(requestBody.isAllDay),
|
||||
organizerEmail: context.state.user.email,
|
||||
transparency: 'opaque',
|
||||
url: `${calendar.url}/${eventId}.ics`,
|
||||
uid: eventId,
|
||||
};
|
||||
|
||||
const vCalendar = generateVCalendar([newEvent]);
|
||||
|
||||
await CalendarEventModel.create(userId, requestBody.calendarId, eventId, vCalendar);
|
||||
|
||||
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||
|
||||
const newCalendarEvents = await CalendarEventModel.list(userId, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
35
routes/api/calendar/add.tsx
Normal file
35
routes/api/calendar/add.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.name) {
|
||||
await CalendarModel.create(context.state.user.id, requestBody.name);
|
||||
}
|
||||
|
||||
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
64
routes/api/calendar/delete-event.tsx
Normal file
64
routes/api/calendar/delete-event.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
calendarView: 'day' | 'week' | 'month';
|
||||
calendarStartDate: string;
|
||||
calendarId: string;
|
||||
calendarEventId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.calendarEventId ||
|
||||
!requestBody.calendarEventId ||
|
||||
!requestBody.calendarView || !requestBody.calendarStartDate
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendarEvent = await CalendarEventModel.get(
|
||||
context.state.user.id,
|
||||
calendar.uid!,
|
||||
requestBody.calendarEventId,
|
||||
);
|
||||
|
||||
if (!calendarEvent || requestBody.calendarId !== calendarEvent.calendarId) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
await CalendarEventModel.delete(context.state.user.id, calendarEvent.url);
|
||||
|
||||
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||
|
||||
const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
41
routes/api/calendar/delete.tsx
Normal file
41
routes/api/calendar/delete.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.calendarId) {
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await CalendarModel.delete(context.state.user.id, requestBody.calendarId);
|
||||
}
|
||||
|
||||
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
38
routes/api/calendar/export-events.tsx
Normal file
38
routes/api/calendar/export-events.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
calendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (!requestBody.calendarIds) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendarEvents = await CalendarEventModel.list(
|
||||
context.state.user.id,
|
||||
requestBody.calendarIds,
|
||||
);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, calendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
67
routes/api/calendar/import.tsx
Normal file
67
routes/api/calendar/import.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||
import { getDateRangeForCalendarView, getIdFromVEvent, splitTextIntoVEvents } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
calendarView: 'day' | 'week' | 'month';
|
||||
calendarStartDate: string;
|
||||
icsToImport: string;
|
||||
calendarId: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarId || !requestBody.calendarIds || !requestBody.icsToImport ||
|
||||
!requestBody.calendarView || !requestBody.calendarStartDate
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.calendarId);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const vEvents = splitTextIntoVEvents(requestBody.icsToImport);
|
||||
|
||||
if (vEvents.length === 0) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await concurrentPromises(
|
||||
vEvents.map((vEvent) => async () => {
|
||||
const eventId = getIdFromVEvent(vEvent);
|
||||
|
||||
await CalendarEventModel.create(context.state.user!.id, calendar.uid!, eventId, vEvent);
|
||||
}),
|
||||
5,
|
||||
);
|
||||
|
||||
const dateRange = getDateRangeForCalendarView(requestBody.calendarStartDate, requestBody.calendarView);
|
||||
|
||||
const newCalendarEvents = await CalendarEventModel.list(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
53
routes/api/calendar/search-events.tsx
Normal file
53
routes/api/calendar/search-events.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { CalendarEvent, CalendarEventModel } from '/lib/models/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
matchingCalendarEvents: CalendarEvent[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.calendarIds || !requestBody.searchTerm
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const allCalendarEvents = await CalendarEventModel.list(
|
||||
context.state.user.id,
|
||||
requestBody.calendarIds,
|
||||
);
|
||||
|
||||
const lowerSearchTerm = requestBody.searchTerm.toLowerCase();
|
||||
|
||||
const matchingCalendarEvents = allCalendarEvents.filter((calendarEvent) =>
|
||||
calendarEvent.title.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.description?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.location?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.eventUrl?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.organizerEmail?.toLowerCase().includes(lowerSearchTerm) ||
|
||||
calendarEvent.attendees?.some((attendee) => attendee.email.toLowerCase().includes(lowerSearchTerm)) ||
|
||||
calendarEvent.reminders?.some((reminder) => reminder.description?.toLowerCase().includes(lowerSearchTerm))
|
||||
).sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
||||
|
||||
const responseBody: ResponseBody = { success: true, matchingCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
65
routes/api/calendar/update.tsx
Normal file
65
routes/api/calendar/update.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { UserModel } from '/lib/models/user.ts';
|
||||
import { getColorAsHex } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (requestBody.id) {
|
||||
const calendar = await CalendarModel.get(context.state.user.id, requestBody.id);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
calendar.displayName = requestBody.name;
|
||||
calendar.calendarColor = requestBody.color?.startsWith('#')
|
||||
? requestBody.color
|
||||
: getColorAsHex(requestBody.color || 'bg-gray-700');
|
||||
|
||||
await CalendarModel.update(context.state.user.id, calendar.url, calendar.displayName, calendar.calendarColor);
|
||||
|
||||
if (requestBody.isVisible !== calendar.isVisible) {
|
||||
const user = await UserModel.getById(context.state.user.id);
|
||||
|
||||
if (requestBody.isVisible) {
|
||||
user.extra.hidden_calendar_ids = user.extra.hidden_calendar_ids?.filter((id) => id !== calendar.uid!);
|
||||
} else if (Array.isArray(user.extra.hidden_calendar_ids)) {
|
||||
user.extra.hidden_calendar_ids.push(calendar.uid!);
|
||||
} else {
|
||||
user.extra.hidden_calendar_ids = [calendar.uid!];
|
||||
}
|
||||
|
||||
await UserModel.update(user);
|
||||
}
|
||||
}
|
||||
|
||||
const newCalendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
82
routes/calendar.tsx
Normal file
82
routes/calendar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import CalendarWrapper from '/islands/calendar/CalendarWrapper.tsx';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { getDateRangeForCalendarView } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {
|
||||
userCalendars: Calendar[];
|
||||
userCalendarEvents: CalendarEvent[];
|
||||
baseUrl: string;
|
||||
view: 'day' | 'week' | 'month';
|
||||
startDate: string;
|
||||
timezoneId: string;
|
||||
timezoneUtcOffset: number;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const baseUrl = (await AppConfig.getConfig()).auth.baseUrl;
|
||||
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||
|
||||
if (!calendarConfig.enableCalDavServer) {
|
||||
throw new Error('CalDAV server is not enabled');
|
||||
}
|
||||
|
||||
const userId = context.state.user.id;
|
||||
const timezoneId = context.state.user.extra.timezone?.id || 'UTC';
|
||||
const timezoneUtcOffset = context.state.user.extra.timezone?.utcOffset || 0;
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
const view = (searchParams.get('view') as Data['view']) || 'week';
|
||||
const startDate = searchParams.get('startDate') || new Date().toISOString().substring(0, 10);
|
||||
|
||||
let userCalendars = await CalendarModel.list(userId);
|
||||
|
||||
// Create default calendar if none exists
|
||||
if (userCalendars.length === 0) {
|
||||
await CalendarModel.create(userId, 'Calendar');
|
||||
|
||||
userCalendars = await CalendarModel.list(userId);
|
||||
}
|
||||
|
||||
const visibleCalendarIds = userCalendars.filter((calendar) => calendar.isVisible).map((calendar) => calendar.uid!);
|
||||
|
||||
const dateRange = getDateRangeForCalendarView(startDate, view);
|
||||
|
||||
const userCalendarEvents = await CalendarEventModel.list(userId, visibleCalendarIds, dateRange);
|
||||
|
||||
return await context.render({
|
||||
userCalendars,
|
||||
userCalendarEvents,
|
||||
baseUrl,
|
||||
view,
|
||||
startDate,
|
||||
timezoneId,
|
||||
timezoneUtcOffset,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<CalendarWrapper
|
||||
initialCalendars={data?.userCalendars || []}
|
||||
initialCalendarEvents={data?.userCalendarEvents || []}
|
||||
baseUrl={data.baseUrl}
|
||||
view={data.view}
|
||||
startDate={data.startDate}
|
||||
timezoneId={data.timezoneId}
|
||||
timezoneUtcOffset={data.timezoneUtcOffset}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
160
routes/calendar/[calendarEventId].tsx
Normal file
160
routes/calendar/[calendarEventId].tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarEvent, CalendarEventModel, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import { convertFormDataToObject } from '/lib/utils/misc.ts';
|
||||
import { updateIcs } from '/lib/utils/calendar.ts';
|
||||
import { getFormDataField } from '/lib/form-utils.tsx';
|
||||
import ViewCalendarEvent, { formFields } from '/islands/calendar/ViewCalendarEvent.tsx';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
|
||||
interface Data {
|
||||
calendarEvent: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
error?: string;
|
||||
notice?: string;
|
||||
formData: Record<string, any>;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||
|
||||
if (!calendarConfig.enableCalDavServer) {
|
||||
throw new Error('CalDAV server is not enabled');
|
||||
}
|
||||
|
||||
let { calendarEventId } = context.params;
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const calendarId = searchParams.get('calendarId') || undefined;
|
||||
|
||||
if (!calendarId) {
|
||||
return new Response('Bad request', { status: 400 });
|
||||
}
|
||||
|
||||
// When editing a recurring event, we only allow the master
|
||||
if (calendarEventId.includes(':')) {
|
||||
calendarEventId = calendarEventId.split(':')[0];
|
||||
}
|
||||
|
||||
const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId);
|
||||
|
||||
if (!calendarEvent) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
return await context.render({ calendarEvent, calendars, formData: {} });
|
||||
},
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||
|
||||
if (!calendarConfig.enableCalDavServer) {
|
||||
throw new Error('CalDAV server is not enabled');
|
||||
}
|
||||
|
||||
const { calendarEventId } = context.params;
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const calendarId = searchParams.get('calendarId') || undefined;
|
||||
|
||||
if (!calendarId) {
|
||||
return new Response('Bad request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendarEvent = await CalendarEventModel.get(context.state.user.id, calendarId, calendarEventId);
|
||||
|
||||
if (!calendarEvent) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const updateType = getFormDataField(formData, 'update-type') as 'raw' | 'ui';
|
||||
|
||||
calendarEvent.title = getFormDataField(formData, 'title');
|
||||
calendarEvent.startDate = new Date(`${getFormDataField(formData, 'startDate')}:00.000Z`);
|
||||
calendarEvent.endDate = new Date(`${getFormDataField(formData, 'endDate')}:00.000Z`);
|
||||
calendarEvent.isAllDay = getFormDataField(formData, 'isAllDay') === 'true';
|
||||
calendarEvent.status = getFormDataField(formData, 'status') as CalendarEvent['status'];
|
||||
|
||||
calendarEvent.description = getFormDataField(formData, 'description') || undefined;
|
||||
calendarEvent.eventUrl = getFormDataField(formData, 'eventUrl') || undefined;
|
||||
calendarEvent.location = getFormDataField(formData, 'location') || undefined;
|
||||
calendarEvent.transparency = getFormDataField(formData, 'transparency') as CalendarEvent['transparency'] ||
|
||||
'opaque';
|
||||
const rawIcs = getFormDataField(formData, 'ics');
|
||||
|
||||
try {
|
||||
if (!calendarEvent.title) {
|
||||
throw new Error(`Title is required.`);
|
||||
}
|
||||
|
||||
formFields(calendarEvent, calendars, updateType).forEach((field) => {
|
||||
if (field.required) {
|
||||
const value = formData.get(field.name);
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${field.label} is required`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let updatedIcs = '';
|
||||
|
||||
if (updateType === 'raw') {
|
||||
updatedIcs = rawIcs;
|
||||
} else if (updateType === 'ui') {
|
||||
if (!calendarEvent.title || !calendarEvent.startDate || !calendarEvent.endDate) {
|
||||
throw new Error(`Title, start date, and end date are required.`);
|
||||
}
|
||||
|
||||
updatedIcs = updateIcs(calendarEvent.data || '', calendarEvent);
|
||||
}
|
||||
|
||||
await CalendarEventModel.update(context.state.user.id, calendarEvent.url!, updatedIcs);
|
||||
|
||||
return await context.render({
|
||||
calendarEvent,
|
||||
calendars,
|
||||
notice: 'Event updated successfully!',
|
||||
formData: convertFormDataToObject(formData),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return await context.render({
|
||||
calendarEvent,
|
||||
calendars,
|
||||
error: (error as Error).toString(),
|
||||
formData: convertFormDataToObject(formData),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function CalendarEventPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<ViewCalendarEvent
|
||||
initialCalendarEvent={data.calendarEvent}
|
||||
calendars={data.calendars}
|
||||
formData={data.formData}
|
||||
error={data.error}
|
||||
notice={data.notice}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
36
routes/calendars.tsx
Normal file
36
routes/calendars.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { Calendar, CalendarModel } from '/lib/models/calendar.ts';
|
||||
import Calendars from '/islands/calendar/Calendars.tsx';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
|
||||
interface Data {
|
||||
userCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||
|
||||
if (!calendarConfig.enableCalDavServer) {
|
||||
throw new Error('CalDAV server is not enabled');
|
||||
}
|
||||
|
||||
const userCalendars = await CalendarModel.list(context.state.user.id);
|
||||
|
||||
return await context.render({ userCalendars });
|
||||
},
|
||||
};
|
||||
|
||||
export default function CalendarsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<Calendars initialCalendars={data.userCalendars || []} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,6 @@ interface Data {
|
||||
currentPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
isCalDavEnabled: boolean;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
@@ -41,9 +40,6 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
const userFiles = await FileModel.list(context.state.user.id, currentPath);
|
||||
|
||||
const isPublicFileSharingAllowed = await AppConfig.isPublicFileSharingAllowed();
|
||||
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||
|
||||
const isCalDavEnabled = calendarConfig.enableCalDavServer;
|
||||
|
||||
return await context.render({
|
||||
userDirectories,
|
||||
@@ -51,7 +47,6 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
currentPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed: isPublicFileSharingAllowed,
|
||||
isCalDavEnabled,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -65,7 +60,6 @@ export default function FilesPage({ data }: PageProps<Data, FreshContextState>)
|
||||
initialPath={data.currentPath}
|
||||
baseUrl={data.baseUrl}
|
||||
isFileSharingAllowed={data.isFileSharingAllowed}
|
||||
isCalDavEnabled={data.isCalDavEnabled}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getFormDataField } from '/lib/form-utils.tsx';
|
||||
import { EmailModel } from '/lib/models/email.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import Settings, { Action, actionWords } from '/islands/Settings.tsx';
|
||||
import { getTimeZones } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface Data {
|
||||
error?: {
|
||||
@@ -20,8 +21,10 @@ interface Data {
|
||||
};
|
||||
formData: Record<string, any>;
|
||||
currency?: SupportedCurrencySymbol;
|
||||
timezoneId?: string;
|
||||
isExpensesAppEnabled: boolean;
|
||||
isMultiFactorAuthEnabled: boolean;
|
||||
isCalendarAppEnabled: boolean;
|
||||
helpEmail: string;
|
||||
user: {
|
||||
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
||||
@@ -37,13 +40,16 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
|
||||
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
|
||||
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
||||
const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar');
|
||||
|
||||
return await context.render({
|
||||
formData: {},
|
||||
currency: context.state.user.extra.expenses_currency,
|
||||
timezoneId: context.state.user.extra.timezone?.id || 'UTC',
|
||||
isExpensesAppEnabled,
|
||||
helpEmail,
|
||||
isMultiFactorAuthEnabled,
|
||||
isCalendarAppEnabled,
|
||||
user: context.state.user,
|
||||
});
|
||||
},
|
||||
@@ -55,6 +61,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
const isExpensesAppEnabled = await AppConfig.isAppEnabled('expenses');
|
||||
const helpEmail = (await AppConfig.getConfig()).visuals.helpEmail;
|
||||
const isMultiFactorAuthEnabled = await AppConfig.isMultiFactorAuthEnabled();
|
||||
const isCalendarAppEnabled = await AppConfig.isAppEnabled('calendar');
|
||||
|
||||
let action: Action = 'change-email';
|
||||
let errorTitle = '';
|
||||
@@ -183,6 +190,24 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
|
||||
successTitle = 'Currency changed!';
|
||||
successMessage = 'Currency changed successfully.';
|
||||
} else if (action === 'change-timezone') {
|
||||
const timezones = getTimeZones();
|
||||
const newTimezoneId = getFormDataField(formData, 'timezone');
|
||||
const matchingTimezone = timezones.find((timezone) => timezone.id === newTimezoneId);
|
||||
|
||||
if (!matchingTimezone) {
|
||||
throw new Error(`Invalid timezone.`);
|
||||
}
|
||||
|
||||
user.extra.timezone = {
|
||||
id: newTimezoneId,
|
||||
utcOffset: matchingTimezone.utcOffset,
|
||||
};
|
||||
|
||||
await UserModel.update(user);
|
||||
|
||||
successTitle = 'Timezone changed!';
|
||||
successMessage = 'Timezone changed successfully.';
|
||||
}
|
||||
|
||||
const notice = successTitle
|
||||
@@ -196,9 +221,11 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
notice,
|
||||
formData: convertFormDataToObject(formData),
|
||||
currency: user.extra.expenses_currency,
|
||||
timezoneId: user.extra.timezone?.id || 'UTC',
|
||||
isExpensesAppEnabled,
|
||||
helpEmail,
|
||||
isMultiFactorAuthEnabled,
|
||||
isCalendarAppEnabled,
|
||||
user: user,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -210,9 +237,11 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
error: { title: errorTitle, message: errorMessage },
|
||||
formData: convertFormDataToObject(formData),
|
||||
currency: user.extra.expenses_currency,
|
||||
timezoneId: user.extra.timezone?.id || 'UTC',
|
||||
isExpensesAppEnabled,
|
||||
helpEmail,
|
||||
isMultiFactorAuthEnabled,
|
||||
isCalendarAppEnabled,
|
||||
user: user,
|
||||
});
|
||||
}
|
||||
@@ -227,8 +256,10 @@ export default function SettingsPage({ data }: PageProps<Data, FreshContextState
|
||||
error={data?.error}
|
||||
notice={data?.notice}
|
||||
currency={data?.currency}
|
||||
timezoneId={data?.timezoneId}
|
||||
isExpensesAppEnabled={data?.isExpensesAppEnabled}
|
||||
isMultiFactorAuthEnabled={data?.isMultiFactorAuthEnabled}
|
||||
isCalendarAppEnabled={data?.isCalendarAppEnabled}
|
||||
helpEmail={data?.helpEmail}
|
||||
user={data?.user}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user