Files CRUD.
Remove Contacts and Calendar + CardDav and CalDav.
This commit is contained in:
@@ -14,4 +14,5 @@ PASSWORD_SALT="fake"
|
||||
BREVO_API_KEY="fake"
|
||||
|
||||
CONFIG_ALLOW_SIGNUPS="false"
|
||||
CONFIG_ENABLED_APPS="dashboard,news,contacts,calendar,tasks,files,notes,photos"
|
||||
CONFIG_ENABLED_APPS="dashboard,news,files,notes,photos"
|
||||
CONFIG_FILES_ROOT_PATH="data-files"
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,3 +9,6 @@ db/
|
||||
|
||||
# Env var
|
||||
.env
|
||||
|
||||
# Files
|
||||
data-files/
|
||||
|
||||
29
README.md
29
README.md
@@ -4,13 +4,18 @@
|
||||
|
||||
This is the [bewCloud app](https://bewcloud.com) built using [Fresh](https://fresh.deno.dev) and deployed using [docker compose](https://docs.docker.com/compose/).
|
||||
|
||||
> [!CAUTION]
|
||||
> This is actively being built and should be considered pre-alpha. Bugs will exist. Code and models _can_ change without a good upgrade path (though I'll try to avoid that). **Don't use it as your only source of data!**
|
||||
|
||||
## Self-host it!
|
||||
|
||||
Check the [Development section below](#development).
|
||||
|
||||
> **NOTE:** You don't need to have emails (Brevo) setup to have the app work. Those are only setup and used for email verification and future needs. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly.
|
||||
> [!NOTE]
|
||||
> You don't need to have emails (Brevo) setup to have the app work. Those are only setup and used for email verification and future needs. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly.
|
||||
|
||||
> **NOTE 2:** Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin.
|
||||
> [!IMPORTANT]
|
||||
> Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -55,14 +60,20 @@ Just push to the `main` branch.
|
||||
|
||||
- [x] Dashboard with URLs and Notes
|
||||
- [x] News
|
||||
- [x] Contacts / CardDav
|
||||
- [ ] Calendar / CalDav
|
||||
- [ ] Tasks / CalDav
|
||||
- [ ] Files / WebDav
|
||||
- [ ] Notes / WebDav
|
||||
- [ ] Photos / WebDav
|
||||
- [ ] Desktop app for selective file sync (or potentially just `rclone`)
|
||||
- [ ] Files UI
|
||||
- [ ] Notes UI
|
||||
- [ ] Photos UI
|
||||
- [ ] Desktop app for selective file sync (WebDav or potentially just `rclone` or `rsync`)
|
||||
- [ ] Mobile app for offline file sync
|
||||
- [ ] Add notes support for mobile app
|
||||
- [ ] Add photos/sync support for mobile client
|
||||
- [ ] Address `TODO:`s in code
|
||||
- [ ] Basic Contacts UI via CardDav?
|
||||
- [ ] Basic Calendar UI via CalDav?
|
||||
- [ ] Basic Tasks UI via CalDav?
|
||||
|
||||
## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement?
|
||||
|
||||
[Check this tag/release for more info and the code where/when that was being done](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). Contacts/CardDav worked and Calendar/CalDav mostly worked as well at that point.
|
||||
|
||||
My focus is still to get me to replace Nextcloud for me and my family ASAP, but turns out it's not easy to do it all in a single, installable _thing_, so I'm focusing on the Files UI, sync, and sharing, since [Radicale](https://radicale.org/v3.html) solved my other issues better than my own solution (and it's already _very_ efficient).
|
||||
|
||||
@@ -31,18 +31,6 @@ export default function Header({ route, user }: Data) {
|
||||
url: '/news',
|
||||
label: 'News',
|
||||
},
|
||||
{
|
||||
url: '/contacts',
|
||||
label: 'Contacts',
|
||||
},
|
||||
{
|
||||
url: '/calendar',
|
||||
label: 'Calendar',
|
||||
},
|
||||
{
|
||||
url: '/tasks',
|
||||
label: 'Tasks',
|
||||
},
|
||||
{
|
||||
url: '/files',
|
||||
label: 'Files',
|
||||
@@ -70,10 +58,6 @@ export default function Header({ route, user }: Data) {
|
||||
pageLabel = 'Settings';
|
||||
}
|
||||
|
||||
if (route.startsWith('/calendars')) {
|
||||
pageLabel = 'Calendars';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
|
||||
export type NewCalendarEvent = Pick<
|
||||
CalendarEvent,
|
||||
'id' | 'calendar_id' | 'title' | 'start_date' | 'end_date' | 'is_all_day'
|
||||
>;
|
||||
|
||||
interface AddEventModalProps {
|
||||
isOpen: boolean;
|
||||
initialStartDate?: Date;
|
||||
initiallyAllDay?: boolean;
|
||||
calendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||
onClickSave: (newEvent: NewCalendarEvent) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddEventModal(
|
||||
{ isOpen, initialStartDate, initiallyAllDay, calendars, onClickSave, onClose }: AddEventModalProps,
|
||||
) {
|
||||
const newEvent = useSignal<NewCalendarEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
newEvent.value = null;
|
||||
} else {
|
||||
const startDate = new Date(initialStartDate || new Date());
|
||||
|
||||
startDate.setMinutes(0);
|
||||
startDate.setSeconds(0);
|
||||
startDate.setMilliseconds(0);
|
||||
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setHours(startDate.getHours() + 1);
|
||||
|
||||
if (initiallyAllDay) {
|
||||
startDate.setHours(9);
|
||||
endDate.setHours(18);
|
||||
}
|
||||
|
||||
newEvent.value = {
|
||||
id: 'new',
|
||||
title: '',
|
||||
calendar_id: calendars[0]!.id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
is_all_day: initiallyAllDay || false,
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
newEvent.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 overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>New Event</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='event_title'>Title</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='event_title'
|
||||
id='event_title'
|
||||
value={newEvent.value?.title || ''}
|
||||
onInput={(event) => newEvent.value = { ...newEvent.value!, title: event.currentTarget.value }}
|
||||
placeholder='Dentist'
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 !w-5/6'
|
||||
name='event_calendar'
|
||||
id='event_calendar'
|
||||
value={newEvent.value?.calendar_id || ''}
|
||||
onChange={(event) => newEvent.value = { ...newEvent.value!, calendar_id: event.currentTarget.value }}
|
||||
>
|
||||
{calendars.map((calendar) => <option value={calendar.id}>{calendar.name}</option>)}
|
||||
</select>
|
||||
<span
|
||||
class={`w-5 h-5 block ${
|
||||
calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color
|
||||
} rounded-full`}
|
||||
title={calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color}
|
||||
>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_start_date'>Start date</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='datetime-local'
|
||||
name='event_start_date'
|
||||
id='event_start_date'
|
||||
value={newEvent.value?.start_date
|
||||
? new Date(newEvent.value.start_date).toISOString().substring(0, 16)
|
||||
: ''}
|
||||
onInput={(event) =>
|
||||
newEvent.value = { ...newEvent.value!, start_date: new Date(event.currentTarget.value) }}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_end_date'>End date</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='datetime-local'
|
||||
name='event_end_date'
|
||||
id='event_end_date'
|
||||
value={newEvent.value?.end_date ? new Date(newEvent.value.end_date).toISOString().substring(0, 16) : ''}
|
||||
onInput={(event) =>
|
||||
newEvent.value = { ...newEvent.value!, end_date: new Date(event.currentTarget.value) }}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_is_all_day'>All-day?</label>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='event_is_all_day'
|
||||
id='event_is_all_day'
|
||||
value='true'
|
||||
checked={newEvent.value?.is_all_day}
|
||||
onChange={(event) => newEvent.value = { ...newEvent.value!, is_all_day: event.currentTarget.checked }}
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newEvent.value!)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { getCalendarEventColor } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewDayProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'extra'>[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function CalendarViewDay(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewDayProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const dayFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||
if (!calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDayDate = new Date(startDate);
|
||||
const endDayDate = new Date(startDate);
|
||||
endDayDate.setHours(23);
|
||||
endDayDate.setMinutes(59);
|
||||
endDayDate.setSeconds(59);
|
||||
endDayDate.setMilliseconds(999);
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const hours: { date: Date; isCurrentHour: boolean }[] = Array.from({ length: 24 }).map((_, index) => {
|
||||
const hourNumber = index;
|
||||
|
||||
const date = new Date(startDate);
|
||||
date.setHours(hourNumber);
|
||||
|
||||
const shortIsoDate = date.toISOString().substring(0, 10);
|
||||
|
||||
const isCurrentHour = shortIsoDate === today && new Date().getHours() === hourNumber;
|
||||
|
||||
return {
|
||||
date,
|
||||
isCurrentHour,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='border-b border-slate-500 bg-slate-700 text-center text-base font-semibold text-white flex-none rounded-t-md'>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-t-md'>
|
||||
<span>{dayFormat.format(startDate)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class='flex bg-slate-500 text-sm text-white flex-auto rounded-b-md'>
|
||||
<section class='w-full rounded-b-md'>
|
||||
{allDayEvents.length > 0
|
||||
? (
|
||||
<section
|
||||
class={`relative bg-slate-700 min-h-16 px-3 py-2 text-slate-100 border-b border-b-slate-600`}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new all-day event'
|
||||
>
|
||||
All-day
|
||||
</time>
|
||||
<ol class='mt-2'>
|
||||
{allDayEvents.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(calendarEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{hours.map((hour, hourIndex) => {
|
||||
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||
|
||||
const startHourDate = new Date(shortIsoDate);
|
||||
startHourDate.setHours(hour.date.getHours());
|
||||
const endHourDate = new Date(shortIsoDate);
|
||||
endHourDate.setHours(hour.date.getHours());
|
||||
endHourDate.setMinutes(59);
|
||||
endHourDate.setSeconds(59);
|
||||
endHourDate.setMilliseconds(999);
|
||||
|
||||
const isLastHour = hourIndex === 23;
|
||||
|
||||
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||
if (calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
eventEndDate.setSeconds(eventEndDate.getSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||
|
||||
// Event starts and ends on this hour
|
||||
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this hour
|
||||
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this hour
|
||||
if (
|
||||
eventStartDate >= startHourDate && eventStartDate <= endHourDate && eventEndDate >= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this hour
|
||||
if (
|
||||
eventStartDate <= startHourDate && eventEndDate >= startHourDate && eventEndDate <= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} ${
|
||||
hourIndex <= 6 ? 'min-h-8' : 'min-h-16'
|
||||
} px-3 py-2 ${hour.isCurrentHour ? '' : 'text-slate-100'} ${
|
||||
isLastHour ? 'rounded-b-md' : ''
|
||||
} border-b border-b-slate-600`}
|
||||
>
|
||||
<time
|
||||
datetime={startHourDate.toISOString()}
|
||||
onClick={() => onClickAddEvent(startHourDate)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new event'
|
||||
>
|
||||
{hourFormat.format(startHourDate)}
|
||||
</time>
|
||||
{hourEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{hourEvents.map((hourEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(hourEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(hourEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(hourEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(hourEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{hourEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { getCalendarEventColor, getWeeksForMonth } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewWeekProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'extra'>[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function CalendarViewWeek(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
const weeks = getWeeksForMonth(new Date(startDate));
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='grid grid-cols-7 gap-px border-b border-slate-500 bg-slate-700 text-center text-xs font-semibold text-white flex-none rounded-t-md'>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-tl-md'>
|
||||
<span>Mon</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Tue</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Wed</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Thu</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Fri</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Sat</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-tr-md'>
|
||||
<span>Sun</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class='flex bg-slate-500 text-xs text-white flex-auto rounded-b-md'>
|
||||
<section class='w-full grid grid-cols-7 grid-rows-5 gap-px rounded-b-md'>
|
||||
{weeks.map((week, weekIndex) =>
|
||||
week.map((day, dayIndex) => {
|
||||
const shortIsoDate = day.date.toISOString().substring(0, 10);
|
||||
|
||||
const startDayDate = new Date(shortIsoDate);
|
||||
const endDayDate = new Date(shortIsoDate);
|
||||
endDayDate.setHours(23);
|
||||
endDayDate.setMinutes(59);
|
||||
endDayDate.setSeconds(59);
|
||||
endDayDate.setMilliseconds(999);
|
||||
|
||||
const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0;
|
||||
const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1;
|
||||
|
||||
const isToday = today === shortIsoDate;
|
||||
|
||||
const dayEvents = calendarEvents.filter((calendarEvent) => {
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${day.isSameMonth ? 'bg-slate-600' : 'bg-slate-700'} min-h-16 px-3 py-2 ${
|
||||
day.isSameMonth ? '' : 'text-slate-100'
|
||||
} ${isBottomLeftDay ? 'rounded-bl-md' : ''} ${isBottomRightDay ? 'rounded-br-md' : ''}`}
|
||||
>
|
||||
<time
|
||||
datetime={shortIsoDate}
|
||||
class={`cursor-pointer ${
|
||||
isToday ? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#51A4FB] font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickAddEvent(new Date(`${shortIsoDate}T09:00`))}
|
||||
title='Add a new event'
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</time>
|
||||
{dayEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{[...dayEvents].slice(0, 2).map((dayEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-1 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(dayEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(dayEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(dayEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(dayEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{dayEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{dayEvents.length > 2
|
||||
? (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/calendar/view=day&startDate=${shortIsoDate}`}
|
||||
class='flex bg-gray-700 px-2 py-1 rounded-md hover:no-underline hover:opacity-60'
|
||||
target='_blank'
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { getCalendarEventColor, getDaysForWeek } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewWeekProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'extra'>[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function CalendarViewWeek(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const weekDayFormat = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: '2-digit' });
|
||||
|
||||
const days = getDaysForWeek(new Date(startDate));
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='w-full grid gap-px grid-flow-col rounded-md text-white text-xs bg-slate-600 calendar-week-view-days'>
|
||||
{days.map((day, dayIndex) => {
|
||||
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||
if (!calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDayDate = new Date(day.date);
|
||||
const endDayDate = new Date(day.date);
|
||||
endDayDate.setHours(23);
|
||||
endDayDate.setMinutes(59);
|
||||
endDayDate.setSeconds(59);
|
||||
endDayDate.setMilliseconds(999);
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isFirstDay = dayIndex === 0;
|
||||
const isLastDay = dayIndex === 6;
|
||||
const isToday = new Date(day.date).toISOString().substring(0, 10) === today;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`flex justify-center ${isToday ? 'bg-[#51A4FB]' : 'bg-gray-900'} py-2 ${
|
||||
isFirstDay ? 'rounded-tl-md' : ''
|
||||
} ${isLastDay ? 'rounded-tr-md' : ''} text-center text-xs font-semibold text-white`}
|
||||
>
|
||||
<span>{weekDayFormat.format(day.date)}</span>
|
||||
</section>
|
||||
<section
|
||||
class={`relative bg-slate-700 min-h-8 px-3 py-2 text-slate-100`}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new all-day event'
|
||||
>
|
||||
All-day
|
||||
</time>
|
||||
{allDayEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{allDayEvents.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(calendarEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
{day.hours.map((hour, hourIndex) => {
|
||||
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||
|
||||
const startHourDate = new Date(shortIsoDate);
|
||||
startHourDate.setHours(hour.date.getHours());
|
||||
const endHourDate = new Date(shortIsoDate);
|
||||
endHourDate.setHours(hour.date.getHours());
|
||||
endHourDate.setMinutes(59);
|
||||
endHourDate.setSeconds(59);
|
||||
endHourDate.setMilliseconds(999);
|
||||
|
||||
const isLastHourOfFirstDay = hourIndex === 23 && dayIndex === 0;
|
||||
const isLastHourOfLastDay = hourIndex === 23 && dayIndex === 6;
|
||||
|
||||
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||
if (calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
eventEndDate.setSeconds(eventEndDate.getSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||
|
||||
// Event starts and ends on this hour
|
||||
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this hour
|
||||
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this hour
|
||||
if (
|
||||
eventStartDate >= startHourDate && eventStartDate <= endHourDate &&
|
||||
eventEndDate >= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this hour
|
||||
if (
|
||||
eventStartDate <= startHourDate && eventEndDate >= startHourDate &&
|
||||
eventEndDate <= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} min-h-8 px-3 py-2 ${
|
||||
hour.isCurrentHour ? '' : 'text-slate-100'
|
||||
} ${isLastHourOfFirstDay ? 'rounded-bl-md' : ''} ${isLastHourOfLastDay ? 'rounded-br-md' : ''}`}
|
||||
>
|
||||
<time
|
||||
datetime={startHourDate.toISOString()}
|
||||
onClick={() => onClickAddEvent(startHourDate)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new event'
|
||||
>
|
||||
{hourFormat.format(startHourDate)}
|
||||
</time>
|
||||
{hourEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{hourEvents.map((hourEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(hourEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(hourEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(hourEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(hourEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{hourEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar } from '/lib/types.ts';
|
||||
|
||||
interface ImportEventsModalProps {
|
||||
isOpen: boolean;
|
||||
calendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||
onClickImport: (calendarId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImportEventsModal(
|
||||
{ isOpen, calendars, onClickImport, onClose }: ImportEventsModalProps,
|
||||
) {
|
||||
const newCalendarId = useSignal<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
newCalendarId.value = null;
|
||||
} else {
|
||||
newCalendarId.value = calendars[0]!.id;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
newCalendarId.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 overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Import Events</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='event_calendar'>Calendar</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 !w-5/6'
|
||||
name='event_calendar'
|
||||
id='event_calendar'
|
||||
value={newCalendarId.value || ''}
|
||||
onChange={(event) => {
|
||||
newCalendarId.value = event.currentTarget.value;
|
||||
}}
|
||||
>
|
||||
{calendars.map((calendar) => <option value={calendar.id}>{calendar.name}</option>)}
|
||||
</select>
|
||||
<span
|
||||
class={`w-5 h-5 block ${
|
||||
calendars.find((calendar) => calendar.id === newCalendarId.value)?.color
|
||||
} rounded-full`}
|
||||
title={calendars.find((calendar) => calendar.id === newCalendarId.value)?.color}
|
||||
>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickImport(newCalendarId.value!)}
|
||||
>
|
||||
Choose File
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { baseUrl, capitalizeWord } from '/lib/utils/misc.ts';
|
||||
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
||||
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/calendar/get-events.tsx';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add-event.tsx';
|
||||
import {
|
||||
RequestBody as DeleteRequestBody,
|
||||
ResponseBody as DeleteResponseBody,
|
||||
} from '/routes/api/calendar/delete-event.tsx';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/calendar/import.tsx';
|
||||
import CalendarViewDay from './CalendarViewDay.tsx';
|
||||
import CalendarViewWeek from './CalendarViewWeek.tsx';
|
||||
import CalendarViewMonth from './CalendarViewMonth.tsx';
|
||||
import AddEventModal, { NewCalendarEvent } from './AddEventModal.tsx';
|
||||
import ViewEventModal from './ViewEventModal.tsx';
|
||||
import SearchEvents from './SearchEvents.tsx';
|
||||
import ImportEventsModal from './ImportEventsModal.tsx';
|
||||
|
||||
interface MainCalendarProps {
|
||||
initialCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible' | 'extra'>[];
|
||||
initialCalendarEvents: CalendarEvent[];
|
||||
view: 'day' | 'week' | 'month';
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export default function MainCalendar({ initialCalendars, initialCalendarEvents, view, startDate }: MainCalendarProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isExporting = useSignal<boolean>(false);
|
||||
const isImporting = useSignal<boolean>(false);
|
||||
const calendars = useSignal<Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible' | 'extra'>[]>(initialCalendars);
|
||||
const isViewOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
const isImportExportOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
const calendarEvents = useSignal<CalendarEvent[]>(initialCalendarEvents);
|
||||
const openEventModal = useSignal<
|
||||
{ isOpen: boolean; calendar?: typeof initialCalendars[number]; calendarEvent?: CalendarEvent }
|
||||
>({ isOpen: false });
|
||||
const newEventModal = useSignal<{ isOpen: boolean; initialStartDate?: Date; initiallyAllDay?: boolean }>({
|
||||
isOpen: false,
|
||||
});
|
||||
const openImportModal = useSignal<
|
||||
{ isOpen: boolean }
|
||||
>({ isOpen: false });
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' });
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const visibleCalendars = calendars.value.filter((calendar) => calendar.is_visible);
|
||||
|
||||
function onClickAddEvent(startDate = new Date(), isAllDay = false) {
|
||||
if (newEventModal.value.isOpen) {
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendars.value.length === 0) {
|
||||
alert('You need to create a calendar first!');
|
||||
return;
|
||||
}
|
||||
|
||||
newEventModal.value = {
|
||||
isOpen: true,
|
||||
initialStartDate: startDate,
|
||||
initiallyAllDay: isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickSaveNewEvent(newEvent: NewCalendarEvent) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = {
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.id),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarId: newEvent.calendar_id,
|
||||
title: newEvent.title,
|
||||
startDate: new Date(newEvent.start_date).toISOString(),
|
||||
endDate: new Date(newEvent.end_date).toISOString(),
|
||||
isAllDay: newEvent.is_all_day,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/add-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add event!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onCloseNewEvent() {
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleImportExportOptionsDropdown() {
|
||||
isImportExportOptionsDropdownOpen.value = !isImportExportOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
function toggleViewOptionsDropdown() {
|
||||
isViewOptionsDropdownOpen.value = !isViewOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
function onClickOpenEvent(calendarEvent: CalendarEvent) {
|
||||
if (openEventModal.value.isOpen) {
|
||||
openEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const calendar = calendars.value.find((calendar) => calendar.id === calendarEvent.calendar_id)!;
|
||||
|
||||
openEventModal.value = {
|
||||
isOpen: true,
|
||||
calendar,
|
||||
calendarEvent,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickDeleteEvent(calendarEventId: string) {
|
||||
if (confirm('Are you sure you want to delete this event?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.id),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarEventId,
|
||||
calendarId: calendarEvents.value.find((calendarEvent) => calendarEvent.id === calendarEventId)!.calendar_id,
|
||||
};
|
||||
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!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
|
||||
openEventModal.value = { isOpen: false };
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseOpenEvent() {
|
||||
openEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickChangeStartDate(changeTo: 'previous' | 'next' | 'today') {
|
||||
const previousDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const previousWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 7)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 7)).toISOString()
|
||||
.substring(0, 10);
|
||||
const previousMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() - 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() + 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
|
||||
if (changeTo === 'today') {
|
||||
if (today === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${today}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (changeTo === 'previous') {
|
||||
let newStartDate = previousMonth;
|
||||
|
||||
if (view === 'day') {
|
||||
newStartDate = previousDay;
|
||||
} else if (view === 'week') {
|
||||
newStartDate = previousWeek;
|
||||
}
|
||||
|
||||
if (newStartDate === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||
return;
|
||||
}
|
||||
|
||||
let newStartDate = nextMonth;
|
||||
|
||||
if (view === 'day') {
|
||||
newStartDate = nextDay;
|
||||
} else if (view === 'week') {
|
||||
newStartDate = nextWeek;
|
||||
}
|
||||
|
||||
if (newStartDate === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||
}
|
||||
|
||||
function onClickChangeView(newView: MainCalendarProps['view']) {
|
||||
if (view === newView) {
|
||||
isViewOptionsDropdownOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${newView}&startDate=${startDate}`;
|
||||
}
|
||||
|
||||
function onClickImportICS() {
|
||||
openImportModal.value = { isOpen: true };
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
function onClickChooseImportCalendar(calendarId: string) {
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = (event) => {
|
||||
const files = (event.target as HTMLInputElement)?.files!;
|
||||
const file = files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (fileRead) => {
|
||||
const importFileContents = fileRead.target?.result;
|
||||
|
||||
if (!importFileContents || isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.value = true;
|
||||
|
||||
openImportModal.value = { isOpen: false };
|
||||
|
||||
try {
|
||||
const partialCalendarEvents = parseVCalendarFromTextContents(importFileContents!.toString());
|
||||
|
||||
const requestBody: ImportRequestBody = {
|
||||
partialCalendarEvents,
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.id),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarId,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/calendar/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to import file!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isImporting.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickExportICS() {
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = ['calendar-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.ics']
|
||||
.join('');
|
||||
|
||||
try {
|
||||
const requestBody: GetRequestBody = { calendarIds: visibleCalendars.map((calendar) => calendar.id) };
|
||||
const response = await fetch(`/api/calendar/get-events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as GetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get contact!');
|
||||
}
|
||||
|
||||
const exportContents = formatCalendarEventsToVCalendar([...result.calendarEvents], calendars.value);
|
||||
|
||||
// Add content-type
|
||||
const vCardContent = ['data:text/calendar; charset=utf-8,', encodeURIComponent(exportContents)].join('');
|
||||
|
||||
// Download the file
|
||||
const data = vCardContent;
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', data);
|
||||
link.setAttribute('download', fileName);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isExporting.value = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<section class='flex flex-row items-center justify-start'>
|
||||
<a href='/calendars' class='mr-4 whitespace-nowrap'>Manage calendars</a>
|
||||
<SearchEvents calendars={visibleCalendars} onClickOpenEvent={onClickOpenEvent} />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='flex items-center justify-end'>
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
<time datetime={startDate}>{dateFormat.format(new Date(startDate))}</time>
|
||||
</h3>
|
||||
<section class='ml-2 relative flex items-center rounded-md bg-slate-700 shadow-sm md:items-stretch'>
|
||||
<button
|
||||
type='button'
|
||||
class='flex h-9 w-12 items-center justify-center rounded-l-md text-white hover:bg-slate-600 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('previous')}
|
||||
>
|
||||
<span class='sr-only'>Previous {view}</span>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='px-3.5 text-sm font-semibold text-white hover:bg-slate-600 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('today')}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='flex h-9 w-12 items-center justify-center rounded-r-md text-white hover:bg-slate-600 pl-1 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('next')}
|
||||
>
|
||||
<span class='sr-only'>Next {view}</span>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</section>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='view-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleViewOptionsDropdown()}
|
||||
>
|
||||
{capitalizeWord(view)}
|
||||
<svg class='-mr-1 h-5 w-5 text-white' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!isViewOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'day' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('day')}
|
||||
>
|
||||
Day
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'week' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('week')}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'month' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('month')}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='import-export-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleImportExportOptionsDropdown()}
|
||||
>
|
||||
ICS
|
||||
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!isImportExportOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='import-export-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickImportICS()}
|
||||
>
|
||||
Import ICS
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportICS()}
|
||||
>
|
||||
Export ICS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<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 event'
|
||||
onClick={() => onClickAddEvent()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new event'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{view === 'day'
|
||||
? (
|
||||
<CalendarViewDay
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{view === 'week'
|
||||
? (
|
||||
<CalendarViewWeek
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{view === 'month'
|
||||
? (
|
||||
<CalendarViewMonth
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<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}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>CalDAV URLs:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/principals/</code>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/calendars/</code>
|
||||
</section>
|
||||
|
||||
<AddEventModal
|
||||
isOpen={newEventModal.value.isOpen}
|
||||
initialStartDate={newEventModal.value.initialStartDate}
|
||||
initiallyAllDay={newEventModal.value.initiallyAllDay}
|
||||
calendars={calendars.value}
|
||||
onClickSave={onClickSaveNewEvent}
|
||||
onClose={onCloseNewEvent}
|
||||
/>
|
||||
|
||||
<ViewEventModal
|
||||
isOpen={openEventModal.value.isOpen}
|
||||
calendar={openEventModal.value.calendar!}
|
||||
calendarEvent={openEventModal.value.calendarEvent!}
|
||||
onClickDelete={onClickDeleteEvent}
|
||||
onClose={onCloseOpenEvent}
|
||||
/>
|
||||
|
||||
<ImportEventsModal
|
||||
isOpen={openImportModal.value.isOpen}
|
||||
calendars={calendars.value}
|
||||
onClickImport={onClickChooseImportCalendar}
|
||||
onClose={() => {
|
||||
openImportModal.value = { isOpen: false };
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx';
|
||||
interface SearchEventsProps {
|
||||
calendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEventsProps) {
|
||||
const isSearching = useSignal<boolean>(false);
|
||||
const areResultsVisible = useSignal<boolean>(false);
|
||||
const calendarEvents = useSignal<CalendarEvent[]>([]);
|
||||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const closeTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const calendarIds = calendars.map((calendar) => calendar.id);
|
||||
|
||||
function searchEvents(searchTerm: string) {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (searchTerm.trim().length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
areResultsVisible.value = false;
|
||||
|
||||
searchTimeout.value = setTimeout(async () => {
|
||||
isSearching.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RequestBody = { calendarIds, searchTerm };
|
||||
const response = await fetch(`/api/calendar/search-events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to search events!');
|
||||
}
|
||||
|
||||
calendarEvents.value = result.matchingCalendarEvents;
|
||||
|
||||
if (calendarEvents.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isSearching.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
if (calendarEvents.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
|
||||
closeTimeout.value = setTimeout(() => {
|
||||
areResultsVisible.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
class='input-field w-72 mr-2'
|
||||
type='search'
|
||||
name='search'
|
||||
placeholder='Search events...'
|
||||
onInput={(event) => searchEvents(event.currentTarget.value)}
|
||||
onFocus={() => onFocus()}
|
||||
onBlur={() => onBlur()}
|
||||
/>
|
||||
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{areResultsVisible.value
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2 text-xs'>
|
||||
<section
|
||||
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<section class='py-1'>
|
||||
<ol class='mt-2'>
|
||||
{calendarEvents.value.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 ${
|
||||
calendars.find((calendar) => calendar.id === calendarEvent.calendar_id)
|
||||
?.color || 'bg-gray-700'
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(calendarEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{dateFormat.format(new Date(calendarEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { convertRRuleToWords } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface ViewEventModalProps {
|
||||
isOpen: boolean;
|
||||
calendarEvent: CalendarEvent;
|
||||
calendar: Pick<Calendar, 'id' | 'name' | 'color'>;
|
||||
onClickDelete: (eventId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ViewEventModal(
|
||||
{ isOpen, calendarEvent, calendar, onClickDelete, onClose }: ViewEventModalProps,
|
||||
) {
|
||||
if (!calendarEvent || !calendar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? '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 overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>{calendarEvent.title}</h1>
|
||||
<header class='py-5 border-t border-b border-slate-500 font-semibold flex justify-between items-center'>
|
||||
<span>
|
||||
{calendarEvent.start_date ? allDayEventDateFormat.format(new Date(calendarEvent.start_date)) : ''}
|
||||
</span>
|
||||
{calendarEvent.is_all_day ? <span>All-day</span> : (
|
||||
<span>
|
||||
{calendarEvent.start_date ? hourFormat.format(new Date(calendarEvent.start_date)) : ''} -{' '}
|
||||
{calendarEvent.end_date ? hourFormat.format(new Date(calendarEvent.end_date)) : ''}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<section class='py-5 my-0 border-b border-slate-500 flex justify-between items-center'>
|
||||
<span>
|
||||
{calendar.name}
|
||||
</span>
|
||||
<span
|
||||
class={`w-5 h-5 ml-2 block ${calendar.color} rounded-full`}
|
||||
title={calendar.color}
|
||||
/>
|
||||
</section>
|
||||
{calendarEvent.extra.description
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<article class='overflow-auto max-w-full max-h-80 font-mono text-sm whitespace-pre-wrap'>
|
||||
{calendarEvent.extra.description}
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.extra.url
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<a href={calendarEvent.extra.url} target='_blank' rel='noopener noreferrer'>
|
||||
{calendarEvent.extra.url}
|
||||
</a>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.extra.location
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<a
|
||||
href={`https://maps.google.com/maps?q=${encodeURIComponent(calendarEvent.extra.location)}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{calendarEvent.extra.location}
|
||||
</a>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{Array.isArray(calendarEvent.extra.attendees) && calendarEvent.extra.attendees.length > 0
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
{calendarEvent.extra.attendees.map((attendee) => (
|
||||
<p class='my-1'>
|
||||
<a href={`mailto:${attendee.email}`} target='_blank' rel='noopener noreferrer'>
|
||||
{attendee.name || attendee.email}
|
||||
</a>{' '}
|
||||
- {attendee.status}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<p class='text-xs'>Repeats {convertRRuleToWords(calendarEvent.extra.recurring_rrule).toLowerCase()}.</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{Array.isArray(calendarEvent.extra.reminders) && calendarEvent.extra.reminders.length > 0
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
{calendarEvent.extra.reminders.map((reminder) => (
|
||||
<p class='my-1 text-xs'>
|
||||
{reminder.description || 'Reminder'} at {hourFormat.format(new Date(reminder.start_date))} via{' '}
|
||||
{reminder.type}.
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
<footer class='flex justify-between mt-2'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-red-600 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickDelete(calendarEvent.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<a
|
||||
href={`/calendar/${calendarEvent.id}`}
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
target='_blank'
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
components/files/CreateDirectoryModal.tsx
Normal file
60
components/files/CreateDirectoryModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
interface CreateDirectoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClickSave: (newDirectoryName: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CreateDirectoryModal(
|
||||
{ isOpen, onClickSave, onClose }: CreateDirectoryModalProps,
|
||||
) {
|
||||
const newDirectoryName = useSignal<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? '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 overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Create New Directory</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='directory_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='directory_name'
|
||||
id='directory_name'
|
||||
value={newDirectoryName.value}
|
||||
onInput={(event) => {
|
||||
newDirectoryName.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='Amazing'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newDirectoryName.value)}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
components/files/FilesBreadcrumb.tsx
Normal file
44
components/files/FilesBreadcrumb.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
interface FilesBreadcrumbProps {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) {
|
||||
if (path === '/') {
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
All files
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
const pathParts = path.slice(1, -1).split('/');
|
||||
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
<a href={`/files?path=/`}>All files</a>
|
||||
{pathParts.map((part, index) => {
|
||||
if (index === pathParts.length - 1) {
|
||||
return (
|
||||
<>
|
||||
<span class='ml-2 text-xs'>/</span>
|
||||
<span class='ml-2'>{part}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const fullPathForPart: string[] = [];
|
||||
|
||||
for (let pathPartIndex = 0; pathPartIndex <= index; ++pathPartIndex) {
|
||||
fullPathForPart.push(pathParts[pathPartIndex]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span class='ml-2 text-xs'>/</span>
|
||||
<a href={`/files?path=/${fullPathForPart.join('/')}/`} class='ml-2'>{part}</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
209
components/files/ListFiles.tsx
Normal file
209
components/files/ListFiles.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
|
||||
interface ListFilesProps {
|
||||
directories: Directory[];
|
||||
files: DirectoryFile[];
|
||||
onClickDeleteDirectory: (parentPath: string, name: string) => Promise<void>;
|
||||
onClickDeleteFile: (parentPath: string, name: string) => Promise<void>;
|
||||
onClickOpenRenameDirectory: (parentPath: string, name: string) => void;
|
||||
onClickOpenRenameFile: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveDirectory: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveFile: (parentPath: string, name: string) => void;
|
||||
}
|
||||
|
||||
export default function ListFiles(
|
||||
{
|
||||
directories,
|
||||
files,
|
||||
onClickDeleteDirectory,
|
||||
onClickDeleteFile,
|
||||
onClickOpenRenameDirectory,
|
||||
onClickOpenRenameFile,
|
||||
onClickOpenMoveDirectory,
|
||||
onClickOpenMoveFile,
|
||||
}: ListFilesProps,
|
||||
) {
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-slate-500 shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr class='border-b border-slate-600'>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-56'>Last update</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
{directories.map((directory) => {
|
||||
const fullPath = `${directory.parent_path}${directory.directory_name}/`;
|
||||
|
||||
return (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/files?path=${fullPath}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
>
|
||||
<img
|
||||
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Directory'
|
||||
title='Directory'
|
||||
/>
|
||||
{directory.directory_name}
|
||||
</a>
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{dateFormat.format(new Date(directory.updated_at))}
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
-
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
{fullPath === TRASH_PATH ? null : (
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Rename directory'
|
||||
title='Rename directory'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Move directory'
|
||||
title='Move directory'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
alt='Delete directory'
|
||||
title='Delete directory'
|
||||
/>
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{files.map((file) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src='/images/file.svg'
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='File'
|
||||
title='File'
|
||||
/>
|
||||
{file.file_name}
|
||||
</a>
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{dateFormat.format(new Date(file.updated_at))}
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{humanFileSize(file.size_in_bytes)}
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Rename file'
|
||||
title='Rename file'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Move file'
|
||||
title='Move file'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
alt='Delete file'
|
||||
title='Delete file'
|
||||
/>
|
||||
</span>
|
||||
</section>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{directories.length === 0 && files.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 files to show</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
522
components/files/MainFiles.tsx
Normal file
522
components/files/MainFiles.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx';
|
||||
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx';
|
||||
import {
|
||||
RequestBody as CreateDirectoryRequestBody,
|
||||
ResponseBody as CreateDirectoryResponseBody,
|
||||
} from '/routes/api/files/create-directory.tsx';
|
||||
import {
|
||||
RequestBody as RenameDirectoryRequestBody,
|
||||
ResponseBody as RenameDirectoryResponseBody,
|
||||
} from '/routes/api/files/rename-directory.tsx';
|
||||
import {
|
||||
RequestBody as MoveDirectoryRequestBody,
|
||||
ResponseBody as MoveDirectoryResponseBody,
|
||||
} from '/routes/api/files/move-directory.tsx';
|
||||
import {
|
||||
RequestBody as DeleteDirectoryRequestBody,
|
||||
ResponseBody as DeleteDirectoryResponseBody,
|
||||
} from '/routes/api/files/delete-directory.tsx';
|
||||
import SearchFiles from './SearchFiles.tsx';
|
||||
import ListFiles from './ListFiles.tsx';
|
||||
import FilesBreadcrumb from './FilesBreadcrumb.tsx';
|
||||
import CreateDirectoryModal from './CreateDirectoryModal.tsx';
|
||||
import RenameDirectoryOrFileModal from './RenameDirectoryOrFileModal.tsx';
|
||||
import MoveDirectoryOrFileModal from './MoveDirectoryOrFileModal.tsx';
|
||||
|
||||
interface MainFilesProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
export default function MainFiles({ initialDirectories, initialFiles, initialPath }: MainFilesProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isUploading = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isUpdating = useSignal<boolean>(false);
|
||||
const directories = useSignal<Directory[]>(initialDirectories);
|
||||
const files = useSignal<DirectoryFile[]>(initialFiles);
|
||||
const path = useSignal<string>(initialPath);
|
||||
const areNewOptionsOption = useSignal<boolean>(false);
|
||||
const isNewDirectoryModalOpen = useSignal<boolean>(false);
|
||||
const renameDirectoryOrFileModal = useSignal<
|
||||
{ isOpen: boolean; isDirectory: boolean; parentPath: string; name: string } | null
|
||||
>(null);
|
||||
const moveDirectoryOrFileModal = useSignal<
|
||||
{ isOpen: boolean; isDirectory: boolean; path: string; name: string } | null
|
||||
>(null);
|
||||
|
||||
function onClickUploadFile() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
const chosenFiles = (event.target as HTMLInputElement)?.files!;
|
||||
const chosenFile = chosenFiles[0];
|
||||
|
||||
if (!chosenFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading.value = true;
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
const requestBody = new FormData();
|
||||
requestBody.set('parent_path', path.value);
|
||||
requestBody.set('name', chosenFile.name);
|
||||
requestBody.set('contents', chosenFile);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/upload`, {
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
const result = await response.json() as UploadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to upload file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUploading.value = false;
|
||||
};
|
||||
}
|
||||
|
||||
function onClickCreateDirectory() {
|
||||
if (isNewDirectoryModalOpen.value) {
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isNewDirectoryModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function onClickSaveDirectory(newDirectoryName: string) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newDirectoryName) {
|
||||
return;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: CreateDirectoryRequestBody = {
|
||||
parentPath: path.value,
|
||||
name: newDirectoryName,
|
||||
};
|
||||
const response = await fetch(`/api/files/create-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as CreateDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to create directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onCloseCreateDirectory() {
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleNewOptionsDropdown() {
|
||||
areNewOptionsOption.value = !areNewOptionsOption.value;
|
||||
}
|
||||
|
||||
function onClickOpenRenameDirectory(parentPath: string, name: string) {
|
||||
renameDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: true,
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickOpenRenameFile(parentPath: string, name: string) {
|
||||
renameDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: false,
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickCloseRename() {
|
||||
renameDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveRenameDirectory(newName: string) {
|
||||
if (
|
||||
isUpdating.value || !renameDirectoryOrFileModal.value?.isOpen || !renameDirectoryOrFileModal.value?.isDirectory
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RenameDirectoryRequestBody = {
|
||||
parentPath: renameDirectoryOrFileModal.value.parentPath,
|
||||
oldName: renameDirectoryOrFileModal.value.name,
|
||||
newName,
|
||||
};
|
||||
const response = await fetch(`/api/files/rename-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as RenameDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to rename directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
renameDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveRenameFile(newName: string) {
|
||||
if (
|
||||
isUpdating.value || !renameDirectoryOrFileModal.value?.isOpen || renameDirectoryOrFileModal.value?.isDirectory
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RenameRequestBody = {
|
||||
parentPath: renameDirectoryOrFileModal.value.parentPath,
|
||||
oldName: renameDirectoryOrFileModal.value.name,
|
||||
newName,
|
||||
};
|
||||
const response = await fetch(`/api/files/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as RenameResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to rename file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
renameDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
function onClickOpenMoveDirectory(parentPath: string, name: string) {
|
||||
moveDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: true,
|
||||
path: parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickOpenMoveFile(parentPath: string, name: string) {
|
||||
moveDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: false,
|
||||
path: parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickCloseMove() {
|
||||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveMoveDirectory(newPath: string) {
|
||||
if (isUpdating.value || !moveDirectoryOrFileModal.value?.isOpen || !moveDirectoryOrFileModal.value?.isDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: MoveDirectoryRequestBody = {
|
||||
oldParentPath: moveDirectoryOrFileModal.value.path,
|
||||
newParentPath: newPath,
|
||||
name: moveDirectoryOrFileModal.value.name,
|
||||
};
|
||||
const response = await fetch(`/api/files/move-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as MoveDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to move directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveMoveFile(newPath: string) {
|
||||
if (isUpdating.value || !moveDirectoryOrFileModal.value?.isOpen || moveDirectoryOrFileModal.value?.isDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: MoveRequestBody = {
|
||||
oldParentPath: moveDirectoryOrFileModal.value.path,
|
||||
newParentPath: newPath,
|
||||
name: moveDirectoryOrFileModal.value.name,
|
||||
};
|
||||
const response = await fetch(`/api/files/move`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as MoveResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to move file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickDeleteDirectory(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this directory?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteDirectoryRequestBody = {
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
const response = await fetch(`/api/files/delete-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickDeleteFile(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this file?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
const response = await fetch(`/api/files/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<section class='flex flex-row items-center justify-start'>
|
||||
<SearchFiles />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='flex items-center justify-end'>
|
||||
<FilesBreadcrumb path={path.value} />
|
||||
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<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 event'
|
||||
id='new-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new file or directory'
|
||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='new-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickUploadFile()}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateDirectory()}
|
||||
>
|
||||
New Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<ListFiles
|
||||
directories={directories.value}
|
||||
files={files.value}
|
||||
onClickDeleteDirectory={onClickDeleteDirectory}
|
||||
onClickDeleteFile={onClickDeleteFile}
|
||||
onClickOpenRenameDirectory={onClickOpenRenameDirectory}
|
||||
onClickOpenRenameFile={onClickOpenRenameFile}
|
||||
onClickOpenMoveDirectory={onClickOpenMoveDirectory}
|
||||
onClickOpenMoveFile={onClickOpenMoveFile}
|
||||
/>
|
||||
|
||||
<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}
|
||||
{isAdding.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isUploading.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isUpdating.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Updating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isAdding.value && !isUploading.value && !isUpdating.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<CreateDirectoryModal
|
||||
isOpen={isNewDirectoryModalOpen.value}
|
||||
onClickSave={onClickSaveDirectory}
|
||||
onClose={onCloseCreateDirectory}
|
||||
/>
|
||||
|
||||
<RenameDirectoryOrFileModal
|
||||
isOpen={renameDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialName={renameDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={renameDirectoryOrFileModal.value?.isDirectory ? onClickSaveRenameDirectory : onClickSaveRenameFile}
|
||||
onClose={onClickCloseRename}
|
||||
/>
|
||||
|
||||
<MoveDirectoryOrFileModal
|
||||
isOpen={moveDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialPath={moveDirectoryOrFileModal.value?.path || ''}
|
||||
name={moveDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={moveDirectoryOrFileModal.value?.isDirectory ? onClickSaveMoveDirectory : onClickSaveMoveFile}
|
||||
onClose={onClickCloseMove}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
components/files/MoveDirectoryOrFileModal.tsx
Normal file
145
components/files/MoveDirectoryOrFileModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/routes/api/files/get-directories.tsx';
|
||||
import { Directory } from '/lib/types.ts';
|
||||
|
||||
interface MoveDirectoryOrFileModalProps {
|
||||
isOpen: boolean;
|
||||
initialPath: string;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
onClickSave: (newPath: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function MoveDirectoryOrFileModal(
|
||||
{ isOpen, initialPath, isDirectory, name, onClickSave, onClose }: MoveDirectoryOrFileModalProps,
|
||||
) {
|
||||
const newPath = useSignal<string>(initialPath);
|
||||
const isLoading = useSignal<boolean>(false);
|
||||
const directories = useSignal<Directory[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
newPath.value = initialPath;
|
||||
|
||||
fetchDirectories();
|
||||
}, [initialPath]);
|
||||
|
||||
async function fetchDirectories() {
|
||||
if (!initialPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RequestBody = {
|
||||
parentPath: newPath.value,
|
||||
directoryPathToExclude: isDirectory ? `${initialPath}${name}` : '',
|
||||
};
|
||||
const response = await fetch(`/api/files/get-directories`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get directories!');
|
||||
}
|
||||
|
||||
directories.value = [...result.directories];
|
||||
|
||||
isLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function onChooseNewDirectory(chosenPath: string) {
|
||||
newPath.value = chosenPath;
|
||||
|
||||
await fetchDirectories();
|
||||
}
|
||||
|
||||
const parentPath = newPath.value === '/'
|
||||
? null
|
||||
: `/${newPath.peek().split('/').filter(Boolean).slice(0, -1).join('/')}`;
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? '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 overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Move "{name}" into "{newPath.value}"</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<ol class='mt-2'>
|
||||
{parentPath
|
||||
? (
|
||||
<li class='mb-1'>
|
||||
<span
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer rounded-md`}
|
||||
onClick={() => onChooseNewDirectory(parentPath === '/' ? parentPath : `${parentPath}/`)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
..
|
||||
</p>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
{directories.value.map((directory) => (
|
||||
<li class='mb-1'>
|
||||
<span
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer rounded-md`}
|
||||
onClick={() => onChooseNewDirectory(`${directory.parent_path}${directory.directory_name}/`)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{directory.directory_name}
|
||||
</p>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isLoading.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Loading...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isLoading.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newPath.value)}
|
||||
>
|
||||
Move {isDirectory ? 'directory' : 'file'} here
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
components/files/RenameDirectoryOrFileModal.tsx
Normal file
67
components/files/RenameDirectoryOrFileModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
interface RenameDirectoryOrFileModalProps {
|
||||
isOpen: boolean;
|
||||
initialName: string;
|
||||
isDirectory: boolean;
|
||||
onClickSave: (newName: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RenameDirectoryOrFileModal(
|
||||
{ isOpen, initialName, isDirectory, onClickSave, onClose }: RenameDirectoryOrFileModalProps,
|
||||
) {
|
||||
const newName = useSignal<string>(initialName);
|
||||
|
||||
useEffect(() => {
|
||||
newName.value = initialName;
|
||||
}, [initialName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? '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 overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Rename {isDirectory ? 'Directory' : 'File'}</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='directory_or_file_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='directory_or_file_name'
|
||||
id='directory_or_file_name'
|
||||
value={newName.value}
|
||||
onInput={(event) => {
|
||||
newName.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='Amazing'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newName.value)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
components/files/SearchFiles.tsx
Normal file
165
components/files/SearchFiles.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
// import { RequestBody, ResponseBody } from '/routes/api/files/search.tsx';
|
||||
interface SearchFilesProps {}
|
||||
|
||||
export default function SearchFiles({}: SearchFilesProps) {
|
||||
const isSearching = useSignal<boolean>(false);
|
||||
const areResultsVisible = useSignal<boolean>(false);
|
||||
const matchingDirectories = useSignal<Directory[]>([]);
|
||||
const matchingFiles = useSignal<DirectoryFile[]>([]);
|
||||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const closeTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
function searchFiles(searchTerm: string) {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (searchTerm.trim().length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
areResultsVisible.value = false;
|
||||
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
isSearching.value = true;
|
||||
|
||||
// TODO: Build this
|
||||
// try {
|
||||
// const requestBody: RequestBody = { searchTerm };
|
||||
// const response = await fetch(`/api/files/search`, {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify(requestBody),
|
||||
// });
|
||||
// const result = await response.json() as ResponseBody;
|
||||
|
||||
// if (!result.success) {
|
||||
// throw new Error('Failed to search files!');
|
||||
// }
|
||||
|
||||
// matchingDirectories.value = result.matchingDirectories;
|
||||
// matchingFiles.value = result.matchingFiles;
|
||||
|
||||
// if (matchingDirectories.value.length > 0 || matchingFiles.value.length > 0) {
|
||||
// areResultsVisible.value = true;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error(error);
|
||||
// }
|
||||
|
||||
isSearching.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
if (matchingDirectories.value.length > 0 || matchingFiles.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
|
||||
closeTimeout.value = setTimeout(() => {
|
||||
areResultsVisible.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
class='input-field w-72 mr-2'
|
||||
type='search'
|
||||
name='search'
|
||||
placeholder='Search files...'
|
||||
onInput={(event) => searchFiles(event.currentTarget.value)}
|
||||
onFocus={() => onFocus()}
|
||||
onBlur={() => onBlur()}
|
||||
/>
|
||||
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{areResultsVisible.value
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2 text-xs'>
|
||||
<section
|
||||
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<section class='py-1'>
|
||||
<ol class='mt-2'>
|
||||
{matchingDirectories.value.map((directory) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/files?path=${directory.parent_path}${directory.directory_name}`}
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<time
|
||||
datetime={new Date(directory.updated_at).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{dateFormat.format(new Date(directory.updated_at))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{directory.directory_name}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{matchingFiles.value.map((file) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<time
|
||||
datetime={new Date(file.updated_at).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{dateFormat.format(new Date(file.updated_at))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{file.file_name}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { NewsFeed } from '/lib/types.ts';
|
||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||
import { crawlNewsFeed } from '/lib/data/news.ts';
|
||||
@@ -9,10 +8,6 @@ const db = new Database();
|
||||
export async function fetchNewArticles(forceFetch = false) {
|
||||
const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4));
|
||||
|
||||
const lock = new Locker(`feeds`);
|
||||
|
||||
await lock.acquire();
|
||||
|
||||
try {
|
||||
const feedsToCrawl = await db.query<NewsFeed>(
|
||||
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "last_crawled_at" IS NULL OR "last_crawled_at" <= $1`,
|
||||
@@ -27,6 +22,4 @@ export async function fetchNewArticles(forceFetch = false) {
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
lock.release();
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_contacts; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_contacts (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
revision text NOT NULL,
|
||||
first_name text NOT NULL,
|
||||
last_name text NOT NULL,
|
||||
extra jsonb NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.bewcloud_contacts OWNER TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_contacts bewcloud_contacts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_contacts
|
||||
ADD CONSTRAINT bewcloud_contacts_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_contacts bewcloud_contacts_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_contacts
|
||||
ADD CONSTRAINT bewcloud_contacts_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_contacts; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_contacts TO postgres;
|
||||
@@ -1,107 +0,0 @@
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_calendars; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_calendars (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
revision text NOT NULL,
|
||||
name text NOT NULL,
|
||||
color text NOT NULL,
|
||||
is_visible boolean NOT NULL,
|
||||
extra jsonb NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.bewcloud_calendars OWNER TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_calendars bewcloud_calendars_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_calendars
|
||||
ADD CONSTRAINT bewcloud_calendars_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_calendars bewcloud_calendars_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_calendars
|
||||
ADD CONSTRAINT bewcloud_calendars_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_calendars; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_calendars TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_calendar_events; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_calendar_events (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
calendar_id uuid DEFAULT gen_random_uuid(),
|
||||
revision text NOT NULL,
|
||||
title text NOT NULL,
|
||||
start_date timestamp with time zone NOT NULL,
|
||||
end_date timestamp with time zone NOT NULL,
|
||||
is_all_day boolean NOT NULL,
|
||||
status VARCHAR NOT NULL,
|
||||
extra jsonb NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.bewcloud_calendar_events OWNER TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_calendar_events bewcloud_calendar_events_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_calendar_events
|
||||
ADD CONSTRAINT bewcloud_calendar_events_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_calendar_events bewcloud_calendar_events_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_calendar_events
|
||||
ADD CONSTRAINT bewcloud_calendar_events_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_calendar_events bewcloud_calendar_events_calendar_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_calendar_events
|
||||
ADD CONSTRAINT bewcloud_calendar_events_calendar_id_fkey FOREIGN KEY (calendar_id) REFERENCES public.bewcloud_calendars(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_calendar_events; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_calendar_events TO postgres;
|
||||
92
fresh.gen.ts
92
fresh.gen.ts
@@ -2,124 +2,80 @@
|
||||
// This file SHOULD be checked into source version control.
|
||||
// This file is automatically updated during development when running `dev.ts`.
|
||||
|
||||
import * as $_well_known_caldav from './routes/.well-known/caldav.tsx';
|
||||
import * as $_well_known_carddav from './routes/.well-known/carddav.tsx';
|
||||
import * as $_404 from './routes/_404.tsx';
|
||||
import * as $_app from './routes/_app.tsx';
|
||||
import * as $_middleware from './routes/_middleware.tsx';
|
||||
import * as $api_calendar_add_event from './routes/api/calendar/add-event.tsx';
|
||||
import * as $api_calendar_add from './routes/api/calendar/add.tsx';
|
||||
import * as $api_calendar_delete_event from './routes/api/calendar/delete-event.tsx';
|
||||
import * as $api_calendar_delete from './routes/api/calendar/delete.tsx';
|
||||
import * as $api_calendar_get_events from './routes/api/calendar/get-events.tsx';
|
||||
import * as $api_calendar_import from './routes/api/calendar/import.tsx';
|
||||
import * as $api_calendar_search_events from './routes/api/calendar/search-events.tsx';
|
||||
import * as $api_calendar_update from './routes/api/calendar/update.tsx';
|
||||
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
||||
import * as $api_contacts_delete from './routes/api/contacts/delete.tsx';
|
||||
import * as $api_contacts_get from './routes/api/contacts/get.tsx';
|
||||
import * as $api_contacts_import from './routes/api/contacts/import.tsx';
|
||||
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
|
||||
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
|
||||
import * as $api_files_create_directory from './routes/api/files/create-directory.tsx';
|
||||
import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx';
|
||||
import * as $api_files_delete from './routes/api/files/delete.tsx';
|
||||
import * as $api_files_get_directories from './routes/api/files/get-directories.tsx';
|
||||
import * as $api_files_move_directory from './routes/api/files/move-directory.tsx';
|
||||
import * as $api_files_move from './routes/api/files/move.tsx';
|
||||
import * as $api_files_rename_directory from './routes/api/files/rename-directory.tsx';
|
||||
import * as $api_files_rename from './routes/api/files/rename.tsx';
|
||||
import * as $api_files_upload from './routes/api/files/upload.tsx';
|
||||
import * as $api_news_add_feed from './routes/api/news/add-feed.tsx';
|
||||
import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx';
|
||||
import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
|
||||
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
|
||||
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
||||
import * as $calendar from './routes/calendar.tsx';
|
||||
import * as $calendar_calendarEventId_ from './routes/calendar/[calendarEventId].tsx';
|
||||
import * as $calendars from './routes/calendars.tsx';
|
||||
import * as $contacts from './routes/contacts.tsx';
|
||||
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
|
||||
import * as $dashboard from './routes/dashboard.tsx';
|
||||
import * as $dav_addressbooks from './routes/dav/addressbooks.tsx';
|
||||
import * as $dav_addressbooks_contacts from './routes/dav/addressbooks/contacts.tsx';
|
||||
import * as $dav_addressbooks_contacts_contactId_vcf from './routes/dav/addressbooks/contacts/[contactId].vcf.tsx';
|
||||
import * as $dav_calendars from './routes/dav/calendars.tsx';
|
||||
import * as $dav_calendars_calendarId_ from './routes/dav/calendars/[calendarId].tsx';
|
||||
import * as $dav_calendars_calendarId_calendarEventId_ics from './routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx';
|
||||
import * as $dav_files from './routes/dav/files.tsx';
|
||||
import * as $dav_index from './routes/dav/index.tsx';
|
||||
import * as $dav_principals from './routes/dav/principals.tsx';
|
||||
import * as $files from './routes/files.tsx';
|
||||
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
|
||||
import * as $index from './routes/index.tsx';
|
||||
import * as $login from './routes/login.tsx';
|
||||
import * as $logout from './routes/logout.tsx';
|
||||
import * as $news from './routes/news.tsx';
|
||||
import * as $news_feeds from './routes/news/feeds.tsx';
|
||||
import * as $remote_php_davRoute_ from './routes/remote.php/[davRoute].tsx';
|
||||
import * as $settings from './routes/settings.tsx';
|
||||
import * as $signup from './routes/signup.tsx';
|
||||
import * as $Settings from './islands/Settings.tsx';
|
||||
import * as $calendar_CalendarWrapper from './islands/calendar/CalendarWrapper.tsx';
|
||||
import * as $calendar_Calendars from './islands/calendar/Calendars.tsx';
|
||||
import * as $calendar_ViewCalendarEvent from './islands/calendar/ViewCalendarEvent.tsx';
|
||||
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
|
||||
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
|
||||
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
||||
import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx';
|
||||
import * as $news_Articles from './islands/news/Articles.tsx';
|
||||
import * as $news_Feeds from './islands/news/Feeds.tsx';
|
||||
import { type Manifest } from '$fresh/server.ts';
|
||||
|
||||
const manifest = {
|
||||
routes: {
|
||||
'./routes/.well-known/caldav.tsx': $_well_known_caldav,
|
||||
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
|
||||
'./routes/_404.tsx': $_404,
|
||||
'./routes/_app.tsx': $_app,
|
||||
'./routes/_middleware.tsx': $_middleware,
|
||||
'./routes/api/calendar/add-event.tsx': $api_calendar_add_event,
|
||||
'./routes/api/calendar/add.tsx': $api_calendar_add,
|
||||
'./routes/api/calendar/delete-event.tsx': $api_calendar_delete_event,
|
||||
'./routes/api/calendar/delete.tsx': $api_calendar_delete,
|
||||
'./routes/api/calendar/get-events.tsx': $api_calendar_get_events,
|
||||
'./routes/api/calendar/import.tsx': $api_calendar_import,
|
||||
'./routes/api/calendar/search-events.tsx': $api_calendar_search_events,
|
||||
'./routes/api/calendar/update.tsx': $api_calendar_update,
|
||||
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
||||
'./routes/api/contacts/delete.tsx': $api_contacts_delete,
|
||||
'./routes/api/contacts/get.tsx': $api_contacts_get,
|
||||
'./routes/api/contacts/import.tsx': $api_contacts_import,
|
||||
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
||||
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
||||
'./routes/api/files/create-directory.tsx': $api_files_create_directory,
|
||||
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
|
||||
'./routes/api/files/delete.tsx': $api_files_delete,
|
||||
'./routes/api/files/get-directories.tsx': $api_files_get_directories,
|
||||
'./routes/api/files/move-directory.tsx': $api_files_move_directory,
|
||||
'./routes/api/files/move.tsx': $api_files_move,
|
||||
'./routes/api/files/rename-directory.tsx': $api_files_rename_directory,
|
||||
'./routes/api/files/rename.tsx': $api_files_rename,
|
||||
'./routes/api/files/upload.tsx': $api_files_upload,
|
||||
'./routes/api/news/add-feed.tsx': $api_news_add_feed,
|
||||
'./routes/api/news/delete-feed.tsx': $api_news_delete_feed,
|
||||
'./routes/api/news/import-feeds.tsx': $api_news_import_feeds,
|
||||
'./routes/api/news/mark-read.tsx': $api_news_mark_read,
|
||||
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
||||
'./routes/calendar.tsx': $calendar,
|
||||
'./routes/calendar/[calendarEventId].tsx': $calendar_calendarEventId_,
|
||||
'./routes/calendars.tsx': $calendars,
|
||||
'./routes/contacts.tsx': $contacts,
|
||||
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
|
||||
'./routes/dashboard.tsx': $dashboard,
|
||||
'./routes/dav/addressbooks.tsx': $dav_addressbooks,
|
||||
'./routes/dav/addressbooks/contacts.tsx': $dav_addressbooks_contacts,
|
||||
'./routes/dav/addressbooks/contacts/[contactId].vcf.tsx': $dav_addressbooks_contacts_contactId_vcf,
|
||||
'./routes/dav/calendars.tsx': $dav_calendars,
|
||||
'./routes/dav/calendars/[calendarId].tsx': $dav_calendars_calendarId_,
|
||||
'./routes/dav/calendars/[calendarId]/[calendarEventId].ics.tsx': $dav_calendars_calendarId_calendarEventId_ics,
|
||||
'./routes/dav/files.tsx': $dav_files,
|
||||
'./routes/dav/index.tsx': $dav_index,
|
||||
'./routes/dav/principals.tsx': $dav_principals,
|
||||
'./routes/files.tsx': $files,
|
||||
'./routes/files/open/[fileName].tsx': $files_open_fileName_,
|
||||
'./routes/index.tsx': $index,
|
||||
'./routes/login.tsx': $login,
|
||||
'./routes/logout.tsx': $logout,
|
||||
'./routes/news.tsx': $news,
|
||||
'./routes/news/feeds.tsx': $news_feeds,
|
||||
'./routes/remote.php/[davRoute].tsx': $remote_php_davRoute_,
|
||||
'./routes/settings.tsx': $settings,
|
||||
'./routes/signup.tsx': $signup,
|
||||
},
|
||||
islands: {
|
||||
'./islands/Settings.tsx': $Settings,
|
||||
'./islands/calendar/CalendarWrapper.tsx': $calendar_CalendarWrapper,
|
||||
'./islands/calendar/Calendars.tsx': $calendar_Calendars,
|
||||
'./islands/calendar/ViewCalendarEvent.tsx': $calendar_ViewCalendarEvent,
|
||||
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
|
||||
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
|
||||
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
||||
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
|
||||
'./islands/news/Articles.tsx': $news_Articles,
|
||||
'./islands/news/Feeds.tsx': $news_Feeds,
|
||||
},
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
"imports": {
|
||||
"/": "./",
|
||||
"./": "./",
|
||||
"xml/": "https://deno.land/x/xml@2.1.3/",
|
||||
"rrule-rust": "npm:rrule-rust@1.2.0",
|
||||
|
||||
"fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import MainCalendar from '/components/calendar/MainCalendar.tsx';
|
||||
|
||||
interface CalendarWrapperProps {
|
||||
initialCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible' | 'extra'>[];
|
||||
initialCalendarEvents: CalendarEvent[];
|
||||
view: 'day' | 'week' | 'month';
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
// 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 }: CalendarWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainCalendar
|
||||
initialCalendars={initialCalendars}
|
||||
initialCalendarEvents={initialCalendarEvents}
|
||||
view={view}
|
||||
startDate={startDate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar } from '/lib/types.ts';
|
||||
import { CALENDAR_COLOR_OPTIONS } 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?.id) {
|
||||
alert('A calendar is required to update one!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.name) {
|
||||
alert('A name is required to update the calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.color) {
|
||||
alert('A color is required to update the calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: UpdateRequestBody = { ...openCalendar.value };
|
||||
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.name}
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
<span
|
||||
class={`w-5 h-5 inline-block ${calendar.color} rounded-full cursor-pointer`}
|
||||
title={calendar.color}
|
||||
onClick={() => openCalendar.value = { ...calendar }}
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
{calendar.is_visible ? '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.id)}
|
||||
>
|
||||
<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 ? <> </> : 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?.name || ''}
|
||||
onInput={(event) => openCalendar.value = { ...openCalendar.value!, name: 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?.color || ''}
|
||||
onChange={(event) => openCalendar.value = { ...openCalendar.value!, color: event.currentTarget.value }}
|
||||
>
|
||||
{CALENDAR_COLOR_OPTIONS.map((color) => <option>{color}</option>)}
|
||||
</select>
|
||||
<span class={`w-5 h-5 block ${openCalendar.value?.color} rounded-full`} title={openCalendar.value?.color}>
|
||||
</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?.is_visible}
|
||||
onChange={(event) =>
|
||||
openCalendar.value = { ...openCalendar.value!, is_visible: event.currentTarget.checked }}
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSaveOpenCalendar()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.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[]) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
placeholder: 'Dentis',
|
||||
value: calendarEvent.title,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'calendar_id',
|
||||
label: 'Calendar',
|
||||
type: 'select',
|
||||
value: calendarEvent.calendar_id,
|
||||
options: calendars.map((calendar) => ({ label: calendar.name, value: calendar.id })),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'start_date',
|
||||
label: 'Start date',
|
||||
type: 'datetime-local',
|
||||
value: new Date(calendarEvent.start_date).toISOString().substring(0, 16),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'end_date',
|
||||
label: 'End date',
|
||||
type: 'datetime-local',
|
||||
value: new Date(calendarEvent.end_date).toISOString().substring(0, 16),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'is_all_day',
|
||||
label: 'All-day?',
|
||||
type: 'checkbox',
|
||||
placeholder: 'YYYYMMDD',
|
||||
value: 'true',
|
||||
required: false,
|
||||
checked: calendarEvent.is_all_day,
|
||||
},
|
||||
{
|
||||
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.extra.description,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
value: calendarEvent.extra.url,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
label: 'Location',
|
||||
type: 'text',
|
||||
placeholder: 'Birmingham, UK',
|
||||
value: calendarEvent.extra.location,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'transparency',
|
||||
label: 'Transparency',
|
||||
type: 'select',
|
||||
value: calendarEvent.extra.transparency,
|
||||
options: (['default', 'opaque', 'transparent'] as CalendarEvent['extra']['transparency'][]).map((
|
||||
transparency,
|
||||
) => ({
|
||||
label: capitalizeWord(transparency),
|
||||
value: transparency,
|
||||
})),
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
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() {
|
||||
if (confirm('Are you sure you want to delete this event?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
calendarIds: calendars.map((calendar) => calendar.id),
|
||||
calendarView: 'day',
|
||||
calendarStartDate: new Date().toISOString().substring(0, 10),
|
||||
calendarEventId: calendarEvent.value.id,
|
||||
calendarId: calendarEvent.value.calendar_id,
|
||||
};
|
||||
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).map((field) => generateFieldHtml(field, formData))}
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Update event</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<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 ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Contact } from '/lib/types.ts';
|
||||
import { baseUrl } from '/lib/utils/misc.ts';
|
||||
import { CONTACTS_PER_PAGE_COUNT, formatContactToVCard, parseVCardFromTextContents } from '/lib/utils/contacts.ts';
|
||||
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx';
|
||||
|
||||
interface ContactsProps {
|
||||
initialContacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
page: number;
|
||||
contactsCount: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export default function Contacts({ initialContacts, page, contactsCount, search }: ContactsProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isExporting = useSignal<boolean>(false);
|
||||
const isImporting = useSignal<boolean>(false);
|
||||
const contacts = useSignal<Pick<Contact, 'id' | 'first_name' | 'last_name'>[]>(initialContacts);
|
||||
const isOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
|
||||
async function onClickAddContact() {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstName = (prompt(`What's the **first name** for the new contact?`) || '').trim();
|
||||
|
||||
if (!firstName) {
|
||||
alert('A first name is required for a new contact!');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastName = (prompt(`What's the **last name** for the new contact?`) || '').trim();
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = { firstName, lastName, page };
|
||||
const response = await fetch(`/api/contacts/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function toggleOptionsDropdown() {
|
||||
isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
async function onClickDeleteContact(contactId: string) {
|
||||
if (confirm('Are you sure you want to delete this contact?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { contactId, page };
|
||||
const response = await fetch(`/api/contacts/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClickImportVCard() {
|
||||
isOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = (event) => {
|
||||
const files = (event.target as HTMLInputElement)?.files!;
|
||||
const file = files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (fileRead) => {
|
||||
const importFileContents = fileRead.target?.result;
|
||||
|
||||
if (!importFileContents || isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.value = true;
|
||||
|
||||
try {
|
||||
const partialContacts = parseVCardFromTextContents(importFileContents!.toString());
|
||||
|
||||
const requestBody: ImportRequestBody = { partialContacts, page };
|
||||
const response = await fetch(`/api/contacts/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to import contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isImporting.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickExportVCard() {
|
||||
isOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = ['contacts-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.vcf']
|
||||
.join('');
|
||||
|
||||
try {
|
||||
const requestBody: GetRequestBody = {};
|
||||
const response = await fetch(`/api/contacts/get`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as GetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get contact!');
|
||||
}
|
||||
|
||||
const exportContents = formatContactToVCard([...result.contacts]);
|
||||
|
||||
// Add content-type
|
||||
const vCardContent = ['data:text/vcard; charset=utf-8,', encodeURIComponent(exportContents)].join('');
|
||||
|
||||
// Download the file
|
||||
const data = vCardContent;
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', data);
|
||||
link.setAttribute('download', fileName);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isExporting.value = false;
|
||||
}
|
||||
|
||||
const pagesCount = Math.ceil(contactsCount / CONTACTS_PER_PAGE_COUNT);
|
||||
const pages = Array.from({ length: pagesCount }).map((_value, index) => index + 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<form method='GET' action='/contacts' class='m-0 p-0'>
|
||||
<input
|
||||
class='input-field w-60'
|
||||
type='search'
|
||||
name='search'
|
||||
value={search}
|
||||
placeholder='Search contacts...'
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
<section class='flex items-center'>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='import-export-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleOptionsDropdown()}
|
||||
>
|
||||
VCF
|
||||
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='import-export-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickImportVCard()}
|
||||
>
|
||||
Import vCard
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportVCard()}
|
||||
>
|
||||
Export vCard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<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 contact'
|
||||
onClick={() => onClickAddContact()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new contact'
|
||||
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-slate-500 shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr class='border-b border-slate-600'>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>First Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>Last Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
{contacts.value.map((contact) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal text-white'>
|
||||
<a href={`/contacts/${contact.id}`}>{contact.first_name}</a>
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{contact.last_name}
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteContact(contact.id)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
alt='Delete contact'
|
||||
title='Delete contact'
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{contacts.value.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={3}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No contacts 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}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{pagesCount > 0
|
||||
? (
|
||||
<section class='flex justify-end'>
|
||||
<nav class='isolate inline-flex -space-x-px rounded-md shadow-sm' aria-label='Pagination'>
|
||||
<a
|
||||
href={page > 1 ? `/contacts?search=${search}&page=${page - 1}` : 'javascript:void(0)'}
|
||||
class='relative inline-flex items-center rounded-l-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||
title='Previous'
|
||||
>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{pages.map((pageNumber) => {
|
||||
const isCurrent = pageNumber === page;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/contacts?search=${search}&page=${pageNumber}`}
|
||||
aria-current='page'
|
||||
class={`relative inline-flex items-center ${
|
||||
isCurrent ? 'bg-[#51A4FB] hover:bg-sky-400' : 'bg-slate-700 hover:bg-slate-600'
|
||||
} px-4 py-2 text-sm font-semibold text-white`}
|
||||
>
|
||||
{pageNumber}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<a
|
||||
href={page < pagesCount ? `/contacts?search=${search}&page=${page + 1}` : 'javascript:void(0)'}
|
||||
class='relative inline-flex items-center rounded-r-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||
title='Next'
|
||||
>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>CardDAV URLs:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/principals/</code>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/addressbooks/</code>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Contact } from '/lib/types.ts';
|
||||
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||
|
||||
interface ViewContactProps {
|
||||
initialContact: Contact;
|
||||
formData: Record<string, any>;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function formFields(contact: Contact) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'name_title',
|
||||
label: 'Honorary title/prefix',
|
||||
type: 'text',
|
||||
placeholder: 'Dr.',
|
||||
value: contact.extra.name_title,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'first_name',
|
||||
label: 'First name',
|
||||
type: 'text',
|
||||
placeholder: 'John',
|
||||
value: contact.first_name,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'middle_names',
|
||||
label: 'Middle name(s)',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
value: contact.extra.middle_names?.map((name) => (name || '').trim()).filter(Boolean).join(' '),
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'last_name',
|
||||
label: 'Last name',
|
||||
type: 'text',
|
||||
placeholder: 'Doe',
|
||||
value: contact.last_name,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'birthday',
|
||||
label: 'Birthday',
|
||||
type: 'text',
|
||||
placeholder: 'YYYYMMDD',
|
||||
value: contact.extra.birthday,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'nickname',
|
||||
label: 'Nickname',
|
||||
type: 'text',
|
||||
placeholder: 'Johnny',
|
||||
value: contact.extra.nickname,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
label: 'Company/Organization',
|
||||
type: 'text',
|
||||
placeholder: 'Acme Corporation',
|
||||
value: contact.extra.organization,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Job/Role',
|
||||
type: 'text',
|
||||
placeholder: '(Super) Genius',
|
||||
value: contact.extra.role,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'photo_url',
|
||||
label: 'Photo URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com/image.jpg',
|
||||
value: contact.extra.photo_url,
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Phones
|
||||
const phones = contact.extra.fields?.filter((field) => field.type === 'phone') || [];
|
||||
for (const [index, phone] of phones.entries()) {
|
||||
fields.push({
|
||||
name: 'phone_numbers',
|
||||
label: `Phone number #${index + 1}`,
|
||||
type: 'tel',
|
||||
placeholder: '+44 0000 111 2222',
|
||||
value: phone.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'phone_labels',
|
||||
label: `Phone label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: phone.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'phone_numbers',
|
||||
label: `Phone number #${phones.length + 1}`,
|
||||
type: 'tel',
|
||||
placeholder: '+44 0000 111 2222',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'phone_labels',
|
||||
label: `Phone label #${phones.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// Emails
|
||||
const emails = contact.extra.fields?.filter((field) => field.type === 'email') || [];
|
||||
for (const [index, email] of emails.entries()) {
|
||||
fields.push({
|
||||
name: 'email_addresses',
|
||||
label: `Email #${index + 1}`,
|
||||
type: 'email',
|
||||
placeholder: 'user@example.com',
|
||||
value: email.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'email_labels',
|
||||
label: `Email label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: email.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'email_addresses',
|
||||
label: `Email #${emails.length + 1}`,
|
||||
type: 'email',
|
||||
placeholder: 'user@example.com',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'email_labels',
|
||||
label: `Email label #${emails.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// URLs
|
||||
const urls = contact.extra.fields?.filter((field) => field.type === 'url') || [];
|
||||
for (const [index, url] of urls.entries()) {
|
||||
fields.push({
|
||||
name: 'url_addresses',
|
||||
label: `URL #${index + 1}`,
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
value: url.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'url_labels',
|
||||
label: `URL label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: url.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'url_addresses',
|
||||
label: `URL #${urls.length + 1}`,
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'url_labels',
|
||||
label: `URL label #${urls.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// Others
|
||||
const others = contact.extra.fields?.filter((field) => field.type === 'other') || [];
|
||||
for (const [index, other] of others.entries()) {
|
||||
fields.push({
|
||||
name: 'other_values',
|
||||
label: `Other contact #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '@acme',
|
||||
value: other.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'other_labels',
|
||||
label: `Other label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: other.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'other_values',
|
||||
label: `Other contact #${others.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '@acme',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'other_labels',
|
||||
label: `Other label #${others.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// Addresses
|
||||
const addresses = contact.extra.addresses || [];
|
||||
for (const [index, address] of addresses.entries()) {
|
||||
fields.push({
|
||||
name: 'address_line_1s',
|
||||
label: `Address line 1 #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '992 Tyburn Rd',
|
||||
value: address.line_1,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_line_2s',
|
||||
label: `Address line 2 #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Apt 2',
|
||||
value: address.line_2,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_cities',
|
||||
label: `Address city #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Birmingham',
|
||||
value: address.city,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_postal_codes',
|
||||
label: `Address postal code #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'B24 0TL',
|
||||
value: address.postal_code,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_states',
|
||||
label: `Address state #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'West Midlands',
|
||||
value: address.state,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_countries',
|
||||
label: `Address country #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'United Kingdom',
|
||||
value: address.country,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_labels',
|
||||
label: `Address label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: address.label,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'address_line_1s',
|
||||
label: `Address line 1 #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '992 Tyburn Rd',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_line_2s',
|
||||
label: `Address line 2 #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Apt 2',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_cities',
|
||||
label: `Address city #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Birmingham',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_postal_codes',
|
||||
label: `Address postal code #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'B24 0TL',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_states',
|
||||
label: `Address state #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'West Midlands',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_countries',
|
||||
label: `Address country #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'United Kingdom',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_labels',
|
||||
label: `Address label #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'notes',
|
||||
label: 'Notes',
|
||||
type: 'textarea',
|
||||
placeholder: 'Some notes...',
|
||||
value: contact.extra.notes,
|
||||
required: false,
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function ViewContact({ initialContact, formData: formDataObject, error, notice }: ViewContactProps) {
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const contact = useSignal<Contact>(initialContact);
|
||||
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
async function onClickDeleteContact() {
|
||||
if (confirm('Are you sure you want to delete this contact?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { contactId: contact.value.id, page: 1 };
|
||||
const response = await fetch(`/api/contacts/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete contact!');
|
||||
}
|
||||
|
||||
window.location.href = '/contacts';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<a href='/contacts' class='mr-2'>View contacts</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 contact'
|
||||
onClick={() => onClickDeleteContact()}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
alt='Delete contact'
|
||||
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(contact.peek()).map((field) => generateFieldHtml(field, formData))}
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Update contact</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<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 ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
islands/files/FilesWrapper.tsx
Normal file
21
islands/files/FilesWrapper.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import MainFiles from '/components/files/MainFiles.tsx';
|
||||
|
||||
interface FilesWrapperProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
// 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 FilesWrapper(
|
||||
{ initialDirectories, initialFiles, initialPath }: FilesWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainFiles
|
||||
initialDirectories={initialDirectories}
|
||||
initialFiles={initialFiles}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -13,3 +13,11 @@ export async function isSignupAllowed() {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getFilesRootPath() {
|
||||
const configRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH');
|
||||
|
||||
const filesRootPath = `${Deno.cwd()}/${configRootPath}`;
|
||||
|
||||
return filesRootPath;
|
||||
}
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
import { RRuleSet } from 'rrule-rust';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { Calendar, CalendarEvent, CalendarEventReminder } from '/lib/types.ts';
|
||||
import { getRandomItem } from '/lib/utils/misc.ts';
|
||||
import { CALENDAR_COLOR_OPTIONS, getVCalendarDate } from '/lib/utils/calendar.ts';
|
||||
import { getUserById } from './user.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getCalendars(userId: string): Promise<Calendar[]> {
|
||||
const calendars = await db.query<Calendar>(
|
||||
sql`SELECT * FROM "bewcloud_calendars" WHERE "user_id" = $1 ORDER BY "created_at" ASC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return calendars;
|
||||
}
|
||||
|
||||
export async function getCalendarEvents(
|
||||
userId: string,
|
||||
calendarIds: string[],
|
||||
dateRange?: { start: Date; end: Date },
|
||||
): Promise<CalendarEvent[]> {
|
||||
if (!dateRange) {
|
||||
const calendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) ORDER BY "start_date" ASC`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents;
|
||||
} else {
|
||||
// Fetch initial recurring events and calculate any necessary to create/show for the date range, if it's not in the past
|
||||
if (dateRange.end >= new Date()) {
|
||||
const lock = new Locker(`events-${userId}`);
|
||||
|
||||
await lock.acquire();
|
||||
|
||||
const initialRecurringCalendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "calendar_id" = ANY($2)
|
||||
AND "start_date" <= $3
|
||||
AND ("extra" ->> 'is_recurring')::boolean IS TRUE
|
||||
AND ("extra" ->> 'recurring_id')::uuid = "id"
|
||||
ORDER BY "start_date" ASC`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
dateRange.end,
|
||||
],
|
||||
);
|
||||
|
||||
// For each initial recurring event, check instance dates, check if those exist in calendarEvents. If not, create them.
|
||||
for (const initialRecurringCalendarEvent of initialRecurringCalendarEvents) {
|
||||
try {
|
||||
const oneMonthAgo = new Date(new Date().setUTCMonth(new Date().getUTCMonth() - 1));
|
||||
|
||||
let recurringInstanceStartDate = initialRecurringCalendarEvent.start_date;
|
||||
let lastSequence = initialRecurringCalendarEvent.extra.recurring_sequence!;
|
||||
|
||||
if (recurringInstanceStartDate <= oneMonthAgo) {
|
||||
// Fetch the latest recurring sample, so we don't have to calculate as many recurring dates, but still preserve the original date's properties for generating the recurring instances
|
||||
const latestRecurringInstance = (await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "calendar_id" = ANY($2)
|
||||
AND "start_date" <= $3
|
||||
AND ("extra" ->> 'is_recurring')::boolean IS TRUE
|
||||
AND ("extra" ->> 'recurring_id')::uuid = $4
|
||||
ORDER BY ("extra" ->> 'recurring_sequence')::number DESC
|
||||
LIMIT 1`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
dateRange.end,
|
||||
initialRecurringCalendarEvent.extra.recurring_id!,
|
||||
],
|
||||
))[0];
|
||||
|
||||
if (latestRecurringInstance) {
|
||||
recurringInstanceStartDate = latestRecurringInstance.start_date;
|
||||
lastSequence = latestRecurringInstance.extra.recurring_sequence!;
|
||||
}
|
||||
}
|
||||
|
||||
const rRuleSet = RRuleSet.parse(
|
||||
`DTSTART:${
|
||||
getVCalendarDate(recurringInstanceStartDate)
|
||||
}\n${initialRecurringCalendarEvent.extra.recurring_rrule}`,
|
||||
);
|
||||
|
||||
const maxRecurringDatesToGenerate = 30;
|
||||
|
||||
const timestamps = rRuleSet.all(maxRecurringDatesToGenerate);
|
||||
|
||||
const validDates = timestamps.map((timestamp) => new Date(timestamp)).filter((date) => date <= dateRange.end);
|
||||
|
||||
// For each date, check if an instance already exists. If not, create it and add it.
|
||||
for (const instanceDate of validDates) {
|
||||
instanceDate.setHours(recurringInstanceStartDate.getHours()); // NOTE: Something is making the hour shift when it shouldn't
|
||||
|
||||
const matchingRecurringInstance = (await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "calendar_id" = ANY($2)
|
||||
AND "start_date" = $3
|
||||
AND ("extra" ->> 'is_recurring')::boolean IS TRUE
|
||||
AND ("extra" ->> 'recurring_id')::uuid = $4
|
||||
ORDER BY "start_date" ASC
|
||||
LIMIT 1`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
instanceDate,
|
||||
initialRecurringCalendarEvent.extra.recurring_id!,
|
||||
],
|
||||
))[0];
|
||||
|
||||
if (!matchingRecurringInstance) {
|
||||
const oneHourLater = new Date(new Date(instanceDate).setHours(instanceDate.getHours() + 1));
|
||||
const newCalendarEvent = await createCalendarEvent(
|
||||
userId,
|
||||
initialRecurringCalendarEvent.calendar_id,
|
||||
initialRecurringCalendarEvent.title,
|
||||
instanceDate,
|
||||
oneHourLater,
|
||||
initialRecurringCalendarEvent.is_all_day,
|
||||
);
|
||||
|
||||
newCalendarEvent.extra = { ...newCalendarEvent.extra, ...initialRecurringCalendarEvent.extra };
|
||||
|
||||
newCalendarEvent.extra.recurring_sequence = ++lastSequence;
|
||||
|
||||
await updateCalendarEvent(newCalendarEvent);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error generating recurring instances: ${error}`);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
lock.release();
|
||||
}
|
||||
|
||||
const calendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events"
|
||||
WHERE "user_id" = $1
|
||||
AND "calendar_id" = ANY($2)
|
||||
AND (
|
||||
("start_date" >= $3 OR "end_date" <= $4)
|
||||
OR ("start_date" < $3 AND "end_date" > $4)
|
||||
)
|
||||
ORDER BY "start_date" ASC`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
dateRange.start,
|
||||
dateRange.end,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCalendarEvent(id: string, userId: string): Promise<CalendarEvent> {
|
||||
const calendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents[0];
|
||||
}
|
||||
|
||||
export async function getCalendar(id: string, userId: string) {
|
||||
const calendars = await db.query<Calendar>(
|
||||
sql`SELECT * FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return calendars[0];
|
||||
}
|
||||
|
||||
export async function createCalendar(userId: string, name: string, color?: string) {
|
||||
const extra: Calendar['extra'] = {
|
||||
default_transparency: 'opaque',
|
||||
};
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const newColor = color || getRandomItem(CALENDAR_COLOR_OPTIONS);
|
||||
|
||||
const newCalendar = (await db.query<Calendar>(
|
||||
sql`INSERT INTO "bewcloud_calendars" (
|
||||
"user_id",
|
||||
"revision",
|
||||
"name",
|
||||
"color",
|
||||
"is_visible",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
revision,
|
||||
name,
|
||||
newColor,
|
||||
true,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newCalendar;
|
||||
}
|
||||
|
||||
export async function updateCalendar(calendar: Calendar) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_calendars" SET
|
||||
"revision" = $3,
|
||||
"name" = $4,
|
||||
"color" = $5,
|
||||
"is_visible" = $6,
|
||||
"extra" = $7,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
calendar.id,
|
||||
calendar.revision,
|
||||
revision,
|
||||
calendar.name,
|
||||
calendar.color,
|
||||
calendar.is_visible,
|
||||
JSON.stringify(calendar.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteCalendar(id: string, userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_calendar_events" WHERE "calendar_id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_calendars" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function updateCalendarRevision(calendar: Calendar) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_calendars" SET
|
||||
"revision" = $3,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
calendar.id,
|
||||
calendar.revision,
|
||||
revision,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function createCalendarEvent(
|
||||
userId: string,
|
||||
calendarId: string,
|
||||
title: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
isAllDay = false,
|
||||
) {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const calendar = await getCalendar(calendarId, userId);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
const oneHourEarlier = new Date(new Date(startDate).setHours(new Date(startDate).getHours() - 1));
|
||||
const sameDayAtNine = new Date(new Date(startDate).setHours(9));
|
||||
|
||||
const newReminder: CalendarEventReminder = {
|
||||
start_date: isAllDay ? sameDayAtNine.toISOString() : oneHourEarlier.toISOString(),
|
||||
type: 'display',
|
||||
};
|
||||
|
||||
const extra: CalendarEvent['extra'] = {
|
||||
organizer_email: user.email,
|
||||
transparency: 'default',
|
||||
reminders: [newReminder],
|
||||
};
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const status: CalendarEvent['status'] = 'scheduled';
|
||||
|
||||
const newCalendarEvent = (await db.query<CalendarEvent>(
|
||||
sql`INSERT INTO "bewcloud_calendar_events" (
|
||||
"user_id",
|
||||
"calendar_id",
|
||||
"revision",
|
||||
"title",
|
||||
"start_date",
|
||||
"end_date",
|
||||
"is_all_day",
|
||||
"status",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
calendarId,
|
||||
revision,
|
||||
title,
|
||||
startDate,
|
||||
endDate,
|
||||
isAllDay,
|
||||
status,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
await updateCalendarRevision(calendar);
|
||||
|
||||
return newCalendarEvent;
|
||||
}
|
||||
|
||||
export async function updateCalendarEvent(calendarEvent: CalendarEvent, oldCalendarId?: string) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const user = await getUserById(calendarEvent.user_id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const calendar = await getCalendar(calendarEvent.calendar_id, user.id);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
const oldCalendar = oldCalendarId ? await getCalendar(oldCalendarId, user.id) : null;
|
||||
|
||||
const oldCalendarEvent = await getCalendarEvent(calendarEvent.id, user.id);
|
||||
|
||||
if (oldCalendarEvent.start_date !== calendarEvent.start_date) {
|
||||
const oneHourEarlier = new Date(
|
||||
new Date(calendarEvent.start_date).setHours(new Date(calendarEvent.start_date).getHours() - 1),
|
||||
);
|
||||
const sameDayAtNine = new Date(new Date(calendarEvent.start_date).setHours(9));
|
||||
|
||||
const newReminder: CalendarEventReminder = {
|
||||
start_date: calendarEvent.is_all_day ? sameDayAtNine.toISOString() : oneHourEarlier.toISOString(),
|
||||
type: 'display',
|
||||
};
|
||||
|
||||
if (!Array.isArray(calendarEvent.extra.reminders)) {
|
||||
calendarEvent.extra.reminders = [newReminder];
|
||||
} else {
|
||||
if (calendarEvent.extra.reminders.length === 0) {
|
||||
calendarEvent.extra.reminders.push(newReminder);
|
||||
} else {
|
||||
calendarEvent.extra.reminders[0] = { ...calendarEvent.extra.reminders[0], start_date: newReminder.start_date };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_calendar_events" SET
|
||||
"revision" = $3,
|
||||
"calendar_id" = $4,
|
||||
"title" = $5,
|
||||
"start_date" = $6,
|
||||
"end_date" = $7,
|
||||
"is_all_day" = $8,
|
||||
"status" = $9,
|
||||
"extra" = $10,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
calendarEvent.id,
|
||||
calendarEvent.revision,
|
||||
revision,
|
||||
calendarEvent.calendar_id,
|
||||
calendarEvent.title,
|
||||
calendarEvent.start_date,
|
||||
calendarEvent.end_date,
|
||||
calendarEvent.is_all_day,
|
||||
calendarEvent.status,
|
||||
JSON.stringify(calendarEvent.extra),
|
||||
],
|
||||
);
|
||||
|
||||
await updateCalendarRevision(calendar);
|
||||
|
||||
if (oldCalendar) {
|
||||
await updateCalendarRevision(oldCalendar);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCalendarEvent(id: string, calendarId: string, userId: string) {
|
||||
const calendar = await getCalendar(calendarId, userId);
|
||||
|
||||
if (!calendar) {
|
||||
throw new Error('Calendar not found');
|
||||
}
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_calendar_events" WHERE "id" = $1 AND "calendar_id" = $2 AND "user_id" = $3`,
|
||||
[
|
||||
id,
|
||||
calendarId,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await updateCalendarRevision(calendar);
|
||||
}
|
||||
|
||||
export async function searchCalendarEvents(
|
||||
searchTerm: string,
|
||||
userId: string,
|
||||
calendarIds: string[],
|
||||
): Promise<CalendarEvent[]> {
|
||||
const calendarEvents = await db.query<CalendarEvent>(
|
||||
sql`SELECT * FROM "bewcloud_calendar_events" WHERE "user_id" = $1 AND "calendar_id" = ANY($2) AND ("title" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "start_date" ASC`,
|
||||
[
|
||||
userId,
|
||||
calendarIds,
|
||||
`%${searchTerm.split(' ').join('%')}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return calendarEvents;
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { Contact } from '/lib/types.ts';
|
||||
import { CONTACTS_PER_PAGE_COUNT } from '/lib/utils/contacts.ts';
|
||||
import { updateUserContactRevision } from './user.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getContacts(userId: string, pageIndex: number) {
|
||||
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||
[
|
||||
userId,
|
||||
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function getContactsCount(userId: string) {
|
||||
const results = await db.query<{ count: number }>(
|
||||
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return Number(results[0]?.count || 0);
|
||||
}
|
||||
|
||||
export async function searchContacts(searchTerm: string, userId: string, pageIndex: number) {
|
||||
const contacts = await db.query<Pick<Contact, 'id' | 'first_name' | 'last_name'>>(
|
||||
sql`SELECT "id", "first_name", "last_name" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $3 OR "last_name" ILIKE $3 OR "extra"::text ILIKE $3) ORDER BY "first_name" ASC, "last_name" ASC LIMIT ${CONTACTS_PER_PAGE_COUNT} OFFSET $2`,
|
||||
[
|
||||
userId,
|
||||
pageIndex * CONTACTS_PER_PAGE_COUNT,
|
||||
`%${searchTerm.split(' ').join('%')}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function searchContactsCount(search: string, userId: string) {
|
||||
const results = await db.query<{ count: number }>(
|
||||
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_contacts" WHERE "user_id" = $1 AND ("first_name" ILIKE $2 OR "last_name" ILIKE $2 OR "extra"::text ILIKE $2)`,
|
||||
[
|
||||
userId,
|
||||
`%${search}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return Number(results[0]?.count || 0);
|
||||
}
|
||||
|
||||
export async function getAllContacts(userId: string) {
|
||||
const contacts = await db.query<Contact>(sql`SELECT * FROM "bewcloud_contacts" WHERE "user_id" = $1`, [
|
||||
userId,
|
||||
]);
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
export async function getContact(id: string, userId: string) {
|
||||
const contacts = await db.query<Contact>(
|
||||
sql`SELECT * FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return contacts[0];
|
||||
}
|
||||
|
||||
export async function createContact(userId: string, firstName: string, lastName: string) {
|
||||
const extra: Contact['extra'] = {};
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
const newContact = (await db.query<Contact>(
|
||||
sql`INSERT INTO "bewcloud_contacts" (
|
||||
"user_id",
|
||||
"revision",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
revision,
|
||||
firstName,
|
||||
lastName,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
await updateUserContactRevision(userId);
|
||||
|
||||
return newContact;
|
||||
}
|
||||
|
||||
export async function updateContact(contact: Contact) {
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_contacts" SET
|
||||
"revision" = $3,
|
||||
"first_name" = $4,
|
||||
"last_name" = $5,
|
||||
"extra" = $6,
|
||||
"updated_at" = now()
|
||||
WHERE "id" = $1 AND "revision" = $2`,
|
||||
[
|
||||
contact.id,
|
||||
contact.revision,
|
||||
revision,
|
||||
contact.first_name,
|
||||
contact.last_name,
|
||||
JSON.stringify(contact.extra),
|
||||
],
|
||||
);
|
||||
|
||||
await updateUserContactRevision(contact.user_id);
|
||||
}
|
||||
|
||||
export async function deleteContact(id: string, userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_contacts" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await updateUserContactRevision(userId);
|
||||
}
|
||||
261
lib/data/files.ts
Normal file
261
lib/data/files.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { join } from 'std/path/join.ts';
|
||||
|
||||
// import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { getFilesRootPath } from '/lib/config.ts';
|
||||
import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
|
||||
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
|
||||
// const db = new Database();
|
||||
|
||||
export async function getDirectories(userId: string, path: string): Promise<Directory[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
// const directoryShares = await db.query<FileShare>(sql`SELECT * FROM "bewcloud_file_shares"
|
||||
// WHERE "parent_path" = $2
|
||||
// AND "type" = 'directory'
|
||||
// AND (
|
||||
// "owner_user_id" = $1
|
||||
// OR ANY("user_ids_with_read_access") = $1
|
||||
// OR ANY("user_ids_with_write_access") = $1
|
||||
// )`, [
|
||||
// userId,
|
||||
// path,
|
||||
// ]);
|
||||
|
||||
const directoryShares: FileShare[] = [];
|
||||
|
||||
// TODO: Remove this mock test
|
||||
if (path === '/') {
|
||||
directoryShares.push({
|
||||
id: 'test-ing-123',
|
||||
owner_user_id: userId,
|
||||
parent_path: '/',
|
||||
name: 'Testing',
|
||||
type: 'directory',
|
||||
user_ids_with_read_access: [],
|
||||
user_ids_with_write_access: [],
|
||||
extra: {
|
||||
read_share_links: [],
|
||||
write_share_links: [],
|
||||
},
|
||||
updated_at: new Date('2024-04-01'),
|
||||
created_at: new Date('2024-03-31'),
|
||||
});
|
||||
}
|
||||
|
||||
const directories: Directory[] = [];
|
||||
|
||||
const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isDirectory);
|
||||
|
||||
for (const entry of directoryEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
const directory: Directory = {
|
||||
owner_user_id: userId,
|
||||
parent_path: path,
|
||||
directory_name: entry.name,
|
||||
has_write_access: true,
|
||||
file_share: directoryShares.find((share) =>
|
||||
share.owner_user_id === userId && share.parent_path === path && share.name === entry.name
|
||||
),
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
directories.push(directory);
|
||||
}
|
||||
|
||||
// TODO: Add directoryShares that aren't owned by this user
|
||||
|
||||
directories.sort(sortDirectoriesByName);
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
// const fileShares = await db.query<FileShare>(sql`SELECT * FROM "bewcloud_file_shares"
|
||||
// WHERE "parent_path" = $2
|
||||
// AND "type" = 'file'
|
||||
// AND (
|
||||
// "owner_user_id" = $1
|
||||
// OR ANY("user_ids_with_read_access") = $1
|
||||
// OR ANY("user_ids_with_write_access") = $1
|
||||
// )`, [
|
||||
// userId,
|
||||
// path,
|
||||
// ]);
|
||||
|
||||
const fileShares: FileShare[] = [];
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile);
|
||||
|
||||
for (const entry of fileEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
const file: DirectoryFile = {
|
||||
owner_user_id: userId,
|
||||
parent_path: path,
|
||||
file_name: entry.name,
|
||||
has_write_access: true,
|
||||
file_share: fileShares.find((share) =>
|
||||
share.owner_user_id === userId && share.parent_path === path && share.name === entry.name
|
||||
),
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
// TODO: Add fileShares that aren't owned by this user
|
||||
|
||||
files.sort(sortFilesByName);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
// Ensure the user directory exists
|
||||
if (path === '/') {
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if (error.toString().includes('NotFound')) {
|
||||
await Deno.mkdir(rootPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Deno.DirEntry[] = [];
|
||||
|
||||
for await (const dirEntry of Deno.readDir(rootPath)) {
|
||||
entries.push(dirEntry);
|
||||
}
|
||||
|
||||
entries.sort(sortEntriesByName);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function createDirectory(userId: string, path: string, name: string): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
await Deno.mkdir(join(rootPath, name), { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function renameDirectoryOrFile(
|
||||
userId: string,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<boolean> {
|
||||
const oldRootPath = join(getFilesRootPath(), userId, oldPath);
|
||||
const newRootPath = join(getFilesRootPath(), userId, newPath);
|
||||
|
||||
try {
|
||||
await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName));
|
||||
|
||||
// TODO: Update any matching file shares
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
if (path.startsWith(TRASH_PATH)) {
|
||||
await Deno.remove(join(rootPath, name), { recursive: true });
|
||||
} else {
|
||||
const trashPath = join(getFilesRootPath(), userId, TRASH_PATH);
|
||||
await Deno.rename(join(rootPath, name), join(trashPath, name));
|
||||
|
||||
// TODO: Delete any matching file shares
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
contents: string | ArrayBuffer,
|
||||
): Promise<boolean> {
|
||||
const rootPath = `${getFilesRootPath()}/${userId}${path}`;
|
||||
|
||||
try {
|
||||
if (typeof contents === 'string') {
|
||||
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true });
|
||||
} else {
|
||||
await Deno.writeFile(join(rootPath, name), new Uint8Array(contents), { append: false, createNew: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string }> {
|
||||
const rootPath = `${getFilesRootPath()}/${userId}${path}`;
|
||||
|
||||
try {
|
||||
const contents = await Deno.readFile(join(rootPath, name));
|
||||
|
||||
let contentType = 'application/octet-stream';
|
||||
|
||||
// NOTE: Detecting based on extension is not accurate, but installing a dependency like `npm:file-types` just for this seems unnecessary
|
||||
const extension = name.split('.').slice(-1).join('').toLowerCase();
|
||||
|
||||
if (extension === 'jpg' || extension === 'jpeg') {
|
||||
contentType = 'image/jpeg';
|
||||
} else if (extension === 'png') {
|
||||
contentType = 'image/png';
|
||||
} else if (extension === 'pdf') {
|
||||
contentType = 'application/pdf';
|
||||
} else if (extension === 'txt' || extension === 'md') {
|
||||
contentType = 'text/plain';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contents,
|
||||
contentType,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { NewsFeed, NewsFeedArticle } from '/lib/types.ts';
|
||||
import {
|
||||
findFeedInUrl,
|
||||
@@ -211,8 +212,11 @@ type JsonFeedArticle = JsonFeed['items'][number];
|
||||
const MAX_ARTICLES_CRAWLED_PER_RUN = 10;
|
||||
|
||||
export async function crawlNewsFeed(newsFeed: NewsFeed) {
|
||||
// TODO: Lock this per feedId, so no two processes run this at the same time
|
||||
const lock = new Locker(`feeds:${newsFeed.id}`);
|
||||
|
||||
await lock.acquire();
|
||||
|
||||
try {
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
const feedUrl = await findFeedInUrl(newsFeed.feed_url);
|
||||
|
||||
@@ -297,4 +301,9 @@ export async function crawlNewsFeed(newsFeed: NewsFeed) {
|
||||
newsFeed.last_crawled_at = new Date();
|
||||
|
||||
await updateNewsFeed(newsFeed);
|
||||
} catch (error) {
|
||||
lock.release();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,14 +283,3 @@ export async function validateVerificationCode(
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateUserContactRevision(id: string) {
|
||||
const user = await getUserById(id);
|
||||
|
||||
const revision = crypto.randomUUID();
|
||||
|
||||
user.extra.contacts_revision = revision;
|
||||
user.extra.contacts_updated_at = new Date().toISOString();
|
||||
|
||||
await updateUser(user);
|
||||
}
|
||||
|
||||
124
lib/types.ts
124
lib/types.ts
@@ -12,8 +12,6 @@ export interface User {
|
||||
is_email_verified: boolean;
|
||||
is_admin?: boolean;
|
||||
dav_hashed_password?: string;
|
||||
contacts_revision?: string;
|
||||
contacts_updated_at?: string;
|
||||
};
|
||||
created_at: Date;
|
||||
}
|
||||
@@ -87,109 +85,45 @@ export interface NewsFeedArticle {
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
// NOTE: I don't really organize contacts by groups or address books, so I don't think I'll need that complexity
|
||||
export interface Contact {
|
||||
export interface DirectoryOrFileShareLink {
|
||||
url: string;
|
||||
hashed_password: string;
|
||||
}
|
||||
|
||||
export interface FileShare {
|
||||
id: string;
|
||||
user_id: string;
|
||||
revision: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
extra: {
|
||||
name_title?: string;
|
||||
middle_names?: string[];
|
||||
organization?: string;
|
||||
role?: string;
|
||||
photo_url?: string;
|
||||
photo_mediatype?: string;
|
||||
addresses?: ContactAddress[];
|
||||
fields?: ContactField[];
|
||||
notes?: string;
|
||||
uid?: string;
|
||||
nickname?: string;
|
||||
birthday?: string;
|
||||
};
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface ContactAddress {
|
||||
label?: string;
|
||||
line_1?: string;
|
||||
line_2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
export type ContactFieldType = 'email' | 'phone' | 'url' | 'other';
|
||||
|
||||
export interface ContactField {
|
||||
owner_user_id: string;
|
||||
parent_path: string;
|
||||
name: string;
|
||||
value: string;
|
||||
type: ContactFieldType;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
id: string;
|
||||
user_id: string;
|
||||
revision: string;
|
||||
name: string;
|
||||
color: string;
|
||||
is_visible: boolean;
|
||||
type: 'directory' | 'file';
|
||||
user_ids_with_read_access: string[];
|
||||
user_ids_with_write_access: string[];
|
||||
extra: {
|
||||
shared_read_user_ids?: string[];
|
||||
shared_write_user_ids?: string[];
|
||||
default_transparency: 'opaque' | 'transparent';
|
||||
calendar_timezone?: string;
|
||||
read_share_links: DirectoryOrFileShareLink[];
|
||||
write_share_links: DirectoryOrFileShareLink[];
|
||||
};
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
user_id: string;
|
||||
calendar_id: string;
|
||||
revision: string;
|
||||
title: string;
|
||||
start_date: Date;
|
||||
end_date: Date;
|
||||
is_all_day: boolean;
|
||||
status: 'scheduled' | 'pending' | 'canceled';
|
||||
extra: {
|
||||
organizer_email: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
attendees?: CalendarEventAttendee[];
|
||||
transparency: 'default' | Calendar['extra']['default_transparency'];
|
||||
is_recurring?: boolean;
|
||||
recurring_id?: string;
|
||||
recurring_sequence?: number;
|
||||
recurring_rrule?: string;
|
||||
recurring_rdate?: string;
|
||||
recurring_exdate?: string;
|
||||
is_task?: boolean;
|
||||
task_due_date?: string;
|
||||
task_completed_at?: string;
|
||||
uid?: string;
|
||||
reminders?: CalendarEventReminder[];
|
||||
};
|
||||
export interface Directory {
|
||||
owner_user_id: string;
|
||||
parent_path: string;
|
||||
directory_name: string;
|
||||
has_write_access: boolean;
|
||||
file_share?: FileShare;
|
||||
size_in_bytes: number;
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CalendarEventAttendee {
|
||||
email: string;
|
||||
status: 'accepted' | 'rejected' | 'invited';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventReminder {
|
||||
uid?: string;
|
||||
start_date: string;
|
||||
type: 'email' | 'sound' | 'display';
|
||||
acknowledged_at?: string;
|
||||
description?: string;
|
||||
export interface DirectoryFile {
|
||||
owner_user_id: string;
|
||||
parent_path: string;
|
||||
file_name: string;
|
||||
has_write_access: boolean;
|
||||
file_share?: FileShare;
|
||||
size_in_bytes: number;
|
||||
updated_at: Date;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
@@ -1,716 +0,0 @@
|
||||
import { Calendar, CalendarEvent, CalendarEventAttendee, CalendarEventReminder } from '/lib/types.ts';
|
||||
|
||||
export const CALENDAR_COLOR_OPTIONS = [
|
||||
'bg-red-700',
|
||||
'bg-red-950',
|
||||
'bg-orange-700',
|
||||
'bg-orange-950',
|
||||
'bg-amber-700',
|
||||
'bg-yellow-800',
|
||||
'bg-lime-700',
|
||||
'bg-lime-950',
|
||||
'bg-green-700',
|
||||
'bg-emerald-800',
|
||||
'bg-teal-700',
|
||||
'bg-cyan-700',
|
||||
'bg-sky-800',
|
||||
'bg-blue-900',
|
||||
'bg-indigo-700',
|
||||
'bg-violet-700',
|
||||
'bg-purple-800',
|
||||
'bg-fuchsia-700',
|
||||
'bg-pink-800',
|
||||
'bg-rose-700',
|
||||
] as const;
|
||||
|
||||
const CALENDAR_COLOR_OPTIONS_HEX = [
|
||||
'#B51E1F',
|
||||
'#450A0A',
|
||||
'#BF4310',
|
||||
'#431407',
|
||||
'#B0550F',
|
||||
'#834F13',
|
||||
'#4D7D16',
|
||||
'#1A2E05',
|
||||
'#148041',
|
||||
'#066048',
|
||||
'#107873',
|
||||
'#0E7490',
|
||||
'#075985',
|
||||
'#1E3A89',
|
||||
'#423BCA',
|
||||
'#6A2BD9',
|
||||
'#6923A9',
|
||||
'#9D21B1',
|
||||
'#9C174D',
|
||||
'#BC133D',
|
||||
] as const;
|
||||
|
||||
// NOTE: This variable isn't really used, _but_ it allows for tailwind to include the classes without having to move this into the tailwind.config.ts file
|
||||
export const CALENDAR_BORDER_COLOR_OPTIONS = [
|
||||
'border-red-700',
|
||||
'border-red-950',
|
||||
'border-orange-700',
|
||||
'border-orange-950',
|
||||
'border-amber-700',
|
||||
'border-yellow-800',
|
||||
'border-lime-700',
|
||||
'border-lime-950',
|
||||
'border-green-700',
|
||||
'border-emerald-800',
|
||||
'border-teal-700',
|
||||
'border-cyan-700',
|
||||
'border-sky-800',
|
||||
'border-blue-900',
|
||||
'border-indigo-700',
|
||||
'border-violet-700',
|
||||
'border-purple-800',
|
||||
'border-fuchsia-700',
|
||||
'border-pink-800',
|
||||
'border-rose-700',
|
||||
] as const;
|
||||
|
||||
export function getColorAsHex(calendarColor: string) {
|
||||
const colorIndex = CALENDAR_COLOR_OPTIONS.findIndex((color) => color === calendarColor);
|
||||
|
||||
return CALENDAR_COLOR_OPTIONS_HEX[colorIndex];
|
||||
}
|
||||
|
||||
function getVCalendarAttendeeStatus(status: CalendarEventAttendee['status']) {
|
||||
if (status === 'accepted' || status === 'rejected') {
|
||||
return status.toUpperCase();
|
||||
}
|
||||
|
||||
return `NEEDS-ACTION`;
|
||||
}
|
||||
|
||||
function getAttendeeStatusFromVCalendar(
|
||||
status: 'NEEDS-ACTION' | 'ACCEPTED' | 'REJECTED',
|
||||
): CalendarEventAttendee['status'] {
|
||||
if (status === 'ACCEPTED' || status === 'REJECTED') {
|
||||
return status.toLowerCase() as CalendarEventAttendee['status'];
|
||||
}
|
||||
|
||||
return 'invited';
|
||||
}
|
||||
|
||||
export function getVCalendarDate(date: Date | string) {
|
||||
return new Date(date).toISOString().substring(0, 19).replaceAll('-', '').replaceAll(':', '');
|
||||
}
|
||||
|
||||
function getSafelyEscapedTextForVCalendar(text: string) {
|
||||
return text.replaceAll('\n', '\\n').replaceAll(',', '\\,');
|
||||
}
|
||||
|
||||
function getSafelyUnescapedTextFromVCalendar(text: string) {
|
||||
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
||||
}
|
||||
|
||||
export function formatCalendarEventsToVCalendar(
|
||||
calendarEvents: CalendarEvent[],
|
||||
calendars: Pick<Calendar, 'id' | 'color' | 'is_visible' | 'extra'>[],
|
||||
): string {
|
||||
const vCalendarText = calendarEvents.map((calendarEvent) =>
|
||||
`BEGIN:VEVENT
|
||||
DTSTAMP:${getVCalendarDate(calendarEvent.created_at)}
|
||||
DTSTART:${getVCalendarDate(calendarEvent.start_date)}
|
||||
DTEND:${getVCalendarDate(calendarEvent.end_date)}
|
||||
ORGANIZER;CN=:mailto:${calendarEvent.extra.organizer_email}
|
||||
SUMMARY:${getSafelyEscapedTextForVCalendar(calendarEvent.title)}
|
||||
TRANSP:${getCalendarEventTransparency(calendarEvent, calendars).toUpperCase()}
|
||||
${calendarEvent.extra.uid ? `UID:${calendarEvent.extra.uid}` : ''}
|
||||
${
|
||||
calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule
|
||||
? `RRULE:${calendarEvent.extra.recurring_rrule}`
|
||||
: ''
|
||||
}
|
||||
SEQUENCE:${calendarEvent.extra.recurring_sequence || 0}
|
||||
CREATED:${getVCalendarDate(calendarEvent.created_at)}
|
||||
LAST-MODIFIED:${getVCalendarDate(calendarEvent.updated_at)}
|
||||
${
|
||||
calendarEvent.extra.attendees?.map((attendee) =>
|
||||
`ATTENDEE;PARTSTAT=${getVCalendarAttendeeStatus(attendee.status)};CN=${
|
||||
getSafelyEscapedTextForVCalendar(attendee.name || '')
|
||||
}:mailto:${attendee.email}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
${
|
||||
calendarEvent.extra.reminders?.map((reminder) =>
|
||||
`BEGIN:VALARM
|
||||
ACTION:${reminder.type.toUpperCase()}
|
||||
${reminder.description ? `DESCRIPTION:${getSafelyEscapedTextForVCalendar(reminder.description)}` : ''}
|
||||
TRIGGER;VALUE=DATE-TIME:${getVCalendarDate(reminder.start_date)}
|
||||
${reminder.uid ? `UID:${reminder.uid}` : ''}
|
||||
${reminder.acknowledged_at ? `ACKNOWLEDGED:${getVCalendarDate(reminder.acknowledged_at)}` : ''}
|
||||
END:VALARM`
|
||||
).join('\n') || ''
|
||||
}
|
||||
END:VEVENT`
|
||||
).join('\n');
|
||||
|
||||
return `BEGIN:VCALENDAR\nVERSION:2.0\nCALSCALE:GREGORIAN\n${vCalendarText}\nEND:VCALENDAR`.split('\n').map((line) =>
|
||||
line.trim()
|
||||
).filter(
|
||||
Boolean,
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
type VCalendarVersion = '1.0' | '2.0';
|
||||
|
||||
export function parseVCalendarFromTextContents(text: string): Partial<CalendarEvent>[] {
|
||||
// Lines that start with a space should be moved to the line above them, as it's the same field/value to parse
|
||||
const lines = text.split('\n').reduce((previousLines, currentLine) => {
|
||||
if (currentLine.startsWith(' ')) {
|
||||
previousLines[previousLines.length - 1] = `${previousLines[previousLines.length - 1]}${
|
||||
currentLine.substring(1).replaceAll('\r', '')
|
||||
}`;
|
||||
} else {
|
||||
previousLines.push(currentLine.replaceAll('\r', ''));
|
||||
}
|
||||
|
||||
return previousLines;
|
||||
}, [] as string[]).map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const partialCalendarEvents: Partial<CalendarEvent>[] = [];
|
||||
|
||||
let partialCalendarEvent: Partial<CalendarEvent> = {};
|
||||
let partialCalendarReminder: Partial<CalendarEventReminder> = {};
|
||||
let vCalendarVersion: VCalendarVersion = '2.0';
|
||||
|
||||
// Loop through every line
|
||||
for (const line of lines) {
|
||||
// Start new vCard version
|
||||
if (line.startsWith('BEGIN:VCALENDAR')) {
|
||||
vCalendarVersion = '2.0';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start new event
|
||||
if (line.startsWith('BEGIN:VEVENT')) {
|
||||
partialCalendarEvent = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish event
|
||||
if (line.startsWith('END:VEVENT')) {
|
||||
partialCalendarEvents.push(partialCalendarEvent);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start new reminder
|
||||
if (line.startsWith('BEGIN:VALARM')) {
|
||||
partialCalendarReminder = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish reminder
|
||||
if (line.startsWith('END:VALARM')) {
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
reminders: [...(partialCalendarEvent.extra?.reminders || []), partialCalendarReminder as CalendarEventReminder],
|
||||
};
|
||||
|
||||
partialCalendarReminder = {};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select proper vCalendar version
|
||||
if (line.startsWith('VERSION:')) {
|
||||
if (line.startsWith('VERSION:1.0')) {
|
||||
vCalendarVersion = '1.0';
|
||||
} else if (line.startsWith('VERSION:2.0')) {
|
||||
vCalendarVersion = '2.0';
|
||||
} else {
|
||||
// Default to 2.0, log warning
|
||||
vCalendarVersion = '2.0';
|
||||
console.warn(`Invalid vCalendar version found: "${line}". Defaulting to 2.0 parser.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vCalendarVersion !== '1.0' && vCalendarVersion !== '2.0') {
|
||||
console.warn(`Invalid vCalendar version found: "${vCalendarVersion}". Defaulting to 2.0 parser.`);
|
||||
vCalendarVersion = '2.0';
|
||||
}
|
||||
|
||||
if (line.startsWith('UID:')) {
|
||||
const uid = line.replace('UID:', '').trim();
|
||||
|
||||
if (!uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(partialCalendarReminder).length > 0) {
|
||||
partialCalendarReminder.uid = uid;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
uid,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('DESCRIPTION:')) {
|
||||
const description = getSafelyUnescapedTextFromVCalendar(line.replace('DESCRIPTION:', '').trim());
|
||||
|
||||
if (!description) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(partialCalendarReminder).length > 0) {
|
||||
partialCalendarReminder.description = description;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
description,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('SUMMARY:')) {
|
||||
const title = getSafelyUnescapedTextFromVCalendar((line.split('SUMMARY:')[1] || '').trim());
|
||||
|
||||
partialCalendarEvent.title = title;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('DTSTART:') || line.startsWith('DTSTART;')) {
|
||||
const startDateInfo = line.split(':')[1] || '';
|
||||
const [dateInfo, hourInfo] = startDateInfo.split('T');
|
||||
|
||||
const year = dateInfo.substring(0, 4);
|
||||
const month = dateInfo.substring(4, 6);
|
||||
const day = dateInfo.substring(6, 8);
|
||||
|
||||
const hours = hourInfo.substring(0, 2);
|
||||
const minutes = hourInfo.substring(2, 4);
|
||||
const seconds = hourInfo.substring(4, 6);
|
||||
|
||||
const startDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
||||
|
||||
partialCalendarEvent.start_date = startDate;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('DTEND:') || line.startsWith('DTEND;')) {
|
||||
const endDateInfo = line.split(':')[1] || '';
|
||||
const [dateInfo, hourInfo] = endDateInfo.split('T');
|
||||
|
||||
const year = dateInfo.substring(0, 4);
|
||||
const month = dateInfo.substring(4, 6);
|
||||
const day = dateInfo.substring(6, 8);
|
||||
|
||||
const hours = hourInfo.substring(0, 2);
|
||||
const minutes = hourInfo.substring(2, 4);
|
||||
const seconds = hourInfo.substring(4, 6);
|
||||
|
||||
const endDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
||||
|
||||
partialCalendarEvent.end_date = endDate;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ORGANIZER;')) {
|
||||
const organizerInfo = line.split(':');
|
||||
const organizerEmail = organizerInfo.slice(-1)[0] || '';
|
||||
|
||||
if (!organizerEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
organizer_email: organizerEmail,
|
||||
};
|
||||
}
|
||||
|
||||
if (line.startsWith('TRANSP:')) {
|
||||
const transparency = (line.split('TRANSP:')[1] || 'default')
|
||||
.toLowerCase() as CalendarEvent['extra']['transparency'];
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
transparency,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ATTENDEE;')) {
|
||||
const attendeeInfo = line.split(':');
|
||||
const attendeeEmail = attendeeInfo.slice(-1)[0] || '';
|
||||
const attendeeStatusInfo = line.split('PARTSTAT=')[1] || '';
|
||||
const attendeeStatus = getAttendeeStatusFromVCalendar(
|
||||
(attendeeStatusInfo.split(';')[0] || 'NEEDS-ACTION') as 'ACCEPTED' | 'REJECTED' | 'NEEDS-ACTION',
|
||||
);
|
||||
const attendeeNameInfo = line.split('CN=')[1] || '';
|
||||
const attendeeName = getSafelyUnescapedTextFromVCalendar((attendeeNameInfo.split(';')[0] || '').trim());
|
||||
|
||||
if (!attendeeEmail) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const attendee: CalendarEventAttendee = {
|
||||
email: attendeeEmail,
|
||||
status: attendeeStatus,
|
||||
};
|
||||
|
||||
if (attendeeName) {
|
||||
attendee.name = attendeeName;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
attendees: [...(partialCalendarEvent.extra?.attendees || []), attendee],
|
||||
};
|
||||
}
|
||||
|
||||
if (line.startsWith('ACTION:')) {
|
||||
const reminderType =
|
||||
(line.replace('ACTION:', '').trim().toLowerCase() || 'display') as CalendarEventReminder['type'];
|
||||
|
||||
partialCalendarReminder.type = reminderType;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('TRIGGER:') || line.startsWith('TRIGGER;')) {
|
||||
const triggerInfo = line.split(':')[1] || '';
|
||||
let triggerDate = new Date(partialCalendarEvent.start_date || new Date());
|
||||
|
||||
if (line.includes('DATE-TIME')) {
|
||||
const [dateInfo, hourInfo] = triggerInfo.split('T');
|
||||
|
||||
const year = dateInfo.substring(0, 4);
|
||||
const month = dateInfo.substring(4, 6);
|
||||
const day = dateInfo.substring(6, 8);
|
||||
|
||||
const hours = hourInfo.substring(0, 2);
|
||||
const minutes = hourInfo.substring(2, 4);
|
||||
const seconds = hourInfo.substring(4, 6);
|
||||
|
||||
triggerDate = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000Z`);
|
||||
} else {
|
||||
const triggerHoursMatch = triggerInfo.match(/(\d+(?:H))/);
|
||||
const triggerMinutesMatch = triggerInfo.match(/(\d+(?:M))/);
|
||||
const triggerSecondsMatch = triggerInfo.match(/(\d+(?:S))/);
|
||||
|
||||
const isNegative = triggerInfo.startsWith('-');
|
||||
|
||||
if (triggerHoursMatch && triggerHoursMatch.length > 0) {
|
||||
const triggerHours = parseInt(triggerHoursMatch[0], 10);
|
||||
|
||||
if (isNegative) {
|
||||
triggerDate.setHours(triggerDate.getHours() - triggerHours);
|
||||
} else {
|
||||
triggerDate.setHours(triggerHours);
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerMinutesMatch && triggerMinutesMatch.length > 0) {
|
||||
const triggerMinutes = parseInt(triggerMinutesMatch[0], 10);
|
||||
|
||||
if (isNegative) {
|
||||
triggerDate.setMinutes(triggerDate.getMinutes() - triggerMinutes);
|
||||
} else {
|
||||
triggerDate.setMinutes(triggerMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
if (triggerSecondsMatch && triggerSecondsMatch.length > 0) {
|
||||
const triggerSeconds = parseInt(triggerSecondsMatch[0], 10);
|
||||
|
||||
if (isNegative) {
|
||||
triggerDate.setSeconds(triggerDate.getSeconds() - triggerSeconds);
|
||||
} else {
|
||||
triggerDate.setSeconds(triggerSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partialCalendarReminder.start_date = triggerDate.toISOString();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('RRULE:')) {
|
||||
const rRule = line.replace('RRULE:', '').trim();
|
||||
|
||||
if (!rRule) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
is_recurring: true,
|
||||
recurring_rrule: rRule,
|
||||
recurring_sequence: partialCalendarEvent.extra?.recurring_sequence || 0,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('SEQUENCE:')) {
|
||||
const sequence = line.replace('SEQUENCE:', '').trim();
|
||||
|
||||
if (!sequence || sequence === '0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
recurring_sequence: parseInt(sequence, 10),
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return partialCalendarEvents;
|
||||
}
|
||||
|
||||
// NOTE: Considers weeks starting Monday, not Sunday
|
||||
export function getWeeksForMonth(date: Date): { date: Date; isSameMonth: boolean }[][] {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const lastOfMonth = new Date(year, month + 1, 0);
|
||||
|
||||
const daysToShow = firstOfMonth.getDay() + (firstOfMonth.getDay() === 0 ? 6 : -1) + lastOfMonth.getDate();
|
||||
|
||||
const weekCount = Math.ceil(daysToShow / 7);
|
||||
|
||||
const weeks: { date: Date; isSameMonth: boolean }[][] = [];
|
||||
|
||||
const startingDate = new Date(firstOfMonth);
|
||||
startingDate.setDate(
|
||||
startingDate.getDate() - Math.abs(firstOfMonth.getDay() === 0 ? 6 : (firstOfMonth.getDay() - 1)),
|
||||
);
|
||||
|
||||
for (let weekIndex = 0; weeks.length < weekCount; ++weekIndex) {
|
||||
for (let dayIndex = 0; dayIndex < 7; ++dayIndex) {
|
||||
if (!Array.isArray(weeks[weekIndex])) {
|
||||
weeks[weekIndex] = [];
|
||||
}
|
||||
|
||||
const weekDayDate = new Date(startingDate);
|
||||
weekDayDate.setDate(weekDayDate.getDate() + (dayIndex + weekIndex * 7));
|
||||
|
||||
const isSameMonth = weekDayDate.getMonth() === month;
|
||||
|
||||
weeks[weekIndex].push({ date: weekDayDate, isSameMonth });
|
||||
}
|
||||
}
|
||||
|
||||
return weeks;
|
||||
}
|
||||
|
||||
// NOTE: Considers week starting Monday, not Sunday
|
||||
export function getDaysForWeek(
|
||||
date: Date,
|
||||
): { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] {
|
||||
const shortIsoDate = new Date().toISOString().substring(0, 10);
|
||||
const currentHour = new Date().getHours();
|
||||
|
||||
const days: { date: Date; isSameDay: boolean; hours: { date: Date; isCurrentHour: boolean }[] }[] = [];
|
||||
|
||||
const startingDate = new Date(date);
|
||||
startingDate.setDate(
|
||||
startingDate.getDate() - Math.abs(startingDate.getDay() === 0 ? 6 : (startingDate.getDay() - 1)),
|
||||
);
|
||||
|
||||
for (let dayIndex = 0; days.length < 7; ++dayIndex) {
|
||||
const dayDate = new Date(startingDate);
|
||||
dayDate.setDate(dayDate.getDate() + dayIndex);
|
||||
|
||||
const isSameDay = dayDate.toISOString().substring(0, 10) === shortIsoDate;
|
||||
|
||||
days[dayIndex] = {
|
||||
date: dayDate,
|
||||
isSameDay,
|
||||
hours: [],
|
||||
};
|
||||
|
||||
for (let hourIndex = 0; hourIndex < 24; ++hourIndex) {
|
||||
const dayHourDate = new Date(dayDate);
|
||||
dayHourDate.setHours(hourIndex);
|
||||
|
||||
const isCurrentHour = isSameDay && hourIndex === currentHour;
|
||||
|
||||
days[dayIndex].hours.push({ date: dayHourDate, isCurrentHour });
|
||||
}
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function getCalendarEventTransparency(
|
||||
calendarEvent: CalendarEvent,
|
||||
calendars: Pick<Calendar, 'id' | 'extra'>[],
|
||||
) {
|
||||
const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id);
|
||||
|
||||
const transparency = calendarEvent.extra.transparency === 'default'
|
||||
? (matchingCalendar?.extra.default_transparency || 'opaque')
|
||||
: calendarEvent.extra.transparency;
|
||||
|
||||
return transparency;
|
||||
}
|
||||
|
||||
export function getCalendarEventColor(
|
||||
calendarEvent: CalendarEvent,
|
||||
calendars: Pick<Calendar, 'id' | 'color' | 'extra'>[],
|
||||
) {
|
||||
const matchingCalendar = calendars.find((calendar) => calendar.id === calendarEvent.calendar_id);
|
||||
const opaqueColor = matchingCalendar?.color || 'bg-gray-700';
|
||||
const transparentColor = opaqueColor.replace('bg-', 'border border-');
|
||||
|
||||
const transparency = getCalendarEventTransparency(calendarEvent, calendars);
|
||||
|
||||
return transparency === 'opaque' ? opaqueColor : transparentColor;
|
||||
}
|
||||
|
||||
type RRuleFrequency = 'DAILY' | 'WEEKLY' | 'MONTHLY';
|
||||
type RRuleWeekDay = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU';
|
||||
type RRuleType = 'FREQ' | 'BYDAY' | 'BYMONTHDAY' | 'BYHOUR' | 'BYMINUTE' | 'COUNT' | 'INTERVAL';
|
||||
|
||||
const rRuleToFrequencyOrWeekDay = new Map<RRuleFrequency | RRuleWeekDay, string>([
|
||||
['DAILY', 'day'],
|
||||
['WEEKLY', 'week'],
|
||||
['MONTHLY', 'month'],
|
||||
['MO', 'Monday'],
|
||||
['TU', 'Tuesday'],
|
||||
['WE', 'Wednesday'],
|
||||
['TH', 'Thursday'],
|
||||
['FR', 'Friday'],
|
||||
['SA', 'Saturday'],
|
||||
['SU', 'Sunday'],
|
||||
]);
|
||||
|
||||
function convertRRuleDaysToWords(day: string | RRuleFrequency | RRuleWeekDay): string {
|
||||
if (day.includes(',')) {
|
||||
const days = day.split(',') as (typeof day)[];
|
||||
return days.map((individualDay) => rRuleToFrequencyOrWeekDay.get(individualDay as RRuleFrequency | RRuleWeekDay))
|
||||
.join(',');
|
||||
}
|
||||
|
||||
return rRuleToFrequencyOrWeekDay.get(day as RRuleFrequency | RRuleWeekDay)!;
|
||||
}
|
||||
|
||||
function getOrdinalSuffix(number: number) {
|
||||
const text = ['th', 'st', 'nd', 'rd'] as const;
|
||||
const value = number % 100;
|
||||
return `${number}${(text[(value - 20) % 10] || text[value] || text[0])}`;
|
||||
}
|
||||
|
||||
export function convertRRuleToWords(rRule: string): string {
|
||||
const rulePart = rRule.replace('RRULE:', '');
|
||||
|
||||
const rulePieces = rulePart.split(';');
|
||||
|
||||
const parsedRRule: Partial<Record<RRuleType, string>> = {};
|
||||
|
||||
rulePieces.forEach(function (rulePiece) {
|
||||
const keyAndValue = rulePiece.split('=') as [RRuleType, string];
|
||||
const [key, value] = keyAndValue;
|
||||
|
||||
parsedRRule[key] = value;
|
||||
});
|
||||
|
||||
const frequency = parsedRRule.FREQ;
|
||||
const byDay = parsedRRule.BYDAY;
|
||||
const byMonthDay = parsedRRule.BYMONTHDAY;
|
||||
const byHour = parsedRRule.BYHOUR;
|
||||
const byMinute = parsedRRule.BYMINUTE;
|
||||
const count = parsedRRule.COUNT;
|
||||
const interval = parsedRRule.INTERVAL;
|
||||
|
||||
const words: string[] = [];
|
||||
|
||||
if (frequency === 'DAILY') {
|
||||
if (byHour) {
|
||||
if (byMinute) {
|
||||
words.push(`Every day at ${byHour}:${byMinute}`);
|
||||
} else {
|
||||
words.push(`Every day at ${byHour}:00`);
|
||||
}
|
||||
} else {
|
||||
words.push(`Every day`);
|
||||
}
|
||||
|
||||
if (count) {
|
||||
if (count === '1') {
|
||||
words.push(`for 1 time`);
|
||||
} else {
|
||||
words.push(`for ${count} times`);
|
||||
}
|
||||
}
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
if (frequency === 'WEEKLY') {
|
||||
if (byDay) {
|
||||
if (interval && parseInt(interval, 10) > 1) {
|
||||
words.push(
|
||||
`Every ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on ${convertRRuleDaysToWords(byDay)}`,
|
||||
);
|
||||
} else {
|
||||
words.push(`Every ${rRuleToFrequencyOrWeekDay.get(frequency)} on ${convertRRuleDaysToWords(byDay)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (byMonthDay) {
|
||||
words.push(`the ${getOrdinalSuffix(parseInt(byMonthDay, 10))}`);
|
||||
}
|
||||
|
||||
if (count) {
|
||||
if (count === '1') {
|
||||
words.push(`for 1 time`);
|
||||
} else {
|
||||
words.push(`for ${count} times`);
|
||||
}
|
||||
}
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
// monthly
|
||||
if (frequency === 'MONTHLY' && byMonthDay) {
|
||||
if (interval && parseInt(interval, 10) > 1) {
|
||||
words.push(
|
||||
`Every ${interval} ${rRuleToFrequencyOrWeekDay.get(frequency)}s on the ${
|
||||
getOrdinalSuffix(parseInt(byMonthDay, 10))
|
||||
}`,
|
||||
);
|
||||
} else {
|
||||
words.push(
|
||||
`Every ${rRuleToFrequencyOrWeekDay.get(frequency)} on the ${getOrdinalSuffix(parseInt(byMonthDay, 10))}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (count) {
|
||||
if (count === '1') {
|
||||
words.push(` for 1 time`);
|
||||
} else {
|
||||
words.push(` for ${count} times`);
|
||||
}
|
||||
}
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
return words.join(' ');
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import { Contact, ContactAddress, ContactField } from '../types.ts';
|
||||
|
||||
export const CONTACTS_PER_PAGE_COUNT = 20;
|
||||
|
||||
function getSafelyEscapedTextForVCard(text: string) {
|
||||
return text.replaceAll('\n', '\\n').replaceAll(',', '\\,');
|
||||
}
|
||||
|
||||
function getSafelyUnescapedTextFromVCard(text: string) {
|
||||
return text.replaceAll('\\n', '\n').replaceAll('\\,', ',');
|
||||
}
|
||||
|
||||
export function formatContactToVCard(contacts: Contact[]): string {
|
||||
const vCardText = contacts.map((contact) =>
|
||||
`BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:${contact.last_name};${contact.first_name};${
|
||||
contact.extra.middle_names ? contact.extra.middle_names?.map((name) => name.trim()).filter(Boolean).join(' ') : ''
|
||||
};${contact.extra.name_title || ''};
|
||||
FN:${contact.extra.name_title ? `${contact.extra.name_title || ''} ` : ''}${contact.first_name} ${contact.last_name}
|
||||
${contact.extra.organization ? `ORG:${getSafelyEscapedTextForVCard(contact.extra.organization)}` : ''}
|
||||
${contact.extra.role ? `TITLE:${getSafelyEscapedTextForVCard(contact.extra.role)}` : ''}
|
||||
${contact.extra.birthday ? `BDAY:${contact.extra.birthday}` : ''}
|
||||
${contact.extra.nickname ? `NICKNAME:${getSafelyEscapedTextForVCard(contact.extra.nickname)}` : ''}
|
||||
${contact.extra.photo_url ? `PHOTO;MEDIATYPE=${contact.extra.photo_mediatype}:${contact.extra.photo_url}` : ''}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'phone').map((phone) =>
|
||||
`TEL;TYPE=${phone.name}:${phone.value}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
${
|
||||
contact.extra.addresses?.map((address) =>
|
||||
`ADR;TYPE=${address.label}:${getSafelyEscapedTextForVCard(address.line_2 || '')};${
|
||||
getSafelyEscapedTextForVCard(address.line_1 || '')
|
||||
};${getSafelyEscapedTextForVCard(address.city || '')};${getSafelyEscapedTextForVCard(address.state || '')};${
|
||||
getSafelyEscapedTextForVCard(address.postal_code || '')
|
||||
};${getSafelyEscapedTextForVCard(address.country || '')}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'email').map((email) =>
|
||||
`EMAIL;TYPE=${email.name}:${email.value}`
|
||||
).join('\n') || ''
|
||||
}
|
||||
REV:${new Date(contact.updated_at).toISOString()}
|
||||
${
|
||||
contact.extra.fields?.filter((field) => field.type === 'other').map((other) => `x-${other.name}:${other.value}`)
|
||||
.join('\n') || ''
|
||||
}
|
||||
${contact.extra.notes ? `NOTE:${getSafelyEscapedTextForVCard(contact.extra.notes.replaceAll('\r', ''))}` : ''}
|
||||
${contact.extra.uid ? `UID:${contact.extra.uid}` : ''}
|
||||
END:VCARD`
|
||||
).join('\n');
|
||||
|
||||
return vCardText.split('\n').map((line) => line.trim()).filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
type VCardVersion = '2.1' | '3.0' | '4.0';
|
||||
|
||||
export function parseVCardFromTextContents(text: string): Partial<Contact>[] {
|
||||
const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||
|
||||
const partialContacts: Partial<Contact>[] = [];
|
||||
|
||||
let partialContact: Partial<Contact> = {};
|
||||
let vCardVersion: VCardVersion = '2.1';
|
||||
|
||||
// Loop through every line
|
||||
for (const line of lines) {
|
||||
// Start new contact and vCard version
|
||||
if (line.startsWith('BEGIN:VCARD')) {
|
||||
partialContact = {};
|
||||
vCardVersion = '2.1';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finish contact
|
||||
if (line.startsWith('END:VCARD')) {
|
||||
partialContacts.push(partialContact);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Select proper vCard version
|
||||
if (line.startsWith('VERSION:')) {
|
||||
if (line.startsWith('VERSION:2.1')) {
|
||||
vCardVersion = '2.1';
|
||||
} else if (line.startsWith('VERSION:3.0')) {
|
||||
vCardVersion = '3.0';
|
||||
} else if (line.startsWith('VERSION:4.0')) {
|
||||
vCardVersion = '4.0';
|
||||
} else {
|
||||
// Default to 2.1, log warning
|
||||
vCardVersion = '2.1';
|
||||
console.warn(`Invalid vCard version found: "${line}". Defaulting to 2.1 parser.`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vCardVersion !== '2.1' && vCardVersion !== '3.0' && vCardVersion !== '4.0') {
|
||||
console.warn(`Invalid vCard version found: "${vCardVersion}". Defaulting to 2.1 parser.`);
|
||||
vCardVersion = '2.1';
|
||||
}
|
||||
|
||||
if (line.startsWith('UID:')) {
|
||||
const uid = line.replace('UID:', '');
|
||||
|
||||
if (!uid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
uid,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('N:')) {
|
||||
const names = line.split('N:')[1].split(';');
|
||||
|
||||
const lastName = names[0] || '';
|
||||
const firstName = names[1] || '';
|
||||
const middleNames = names.slice(2, -1).filter(Boolean);
|
||||
const title = names.slice(-1).join(' ') || '';
|
||||
|
||||
if (!firstName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.first_name = firstName;
|
||||
partialContact.last_name = lastName;
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
middle_names: middleNames,
|
||||
name_title: title,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('ORG:')) {
|
||||
const organization = ((line.split('ORG:')[1] || '').split(';').join(' ') || '').replaceAll('\\,', ',');
|
||||
|
||||
if (!organization) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
organization,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('BDAY:')) {
|
||||
const birthday = line.split('BDAY:')[1] || '';
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
birthday,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('NICKNAME:')) {
|
||||
const nickname = (line.split('NICKNAME:')[1] || '').split(';').join(' ') || '';
|
||||
|
||||
if (!nickname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
nickname,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('TITLE:')) {
|
||||
const role = line.split('TITLE:')[1] || '';
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
role,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('NOTE:')) {
|
||||
const notes = getSafelyUnescapedTextFromVCard(line.split('NOTE:')[1] || '');
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
notes,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('ADR;')) {
|
||||
const addressInfo = line.split('ADR;')[1] || '';
|
||||
const addressParts = (addressInfo.split(':')[1] || '').split(';');
|
||||
const country = getSafelyUnescapedTextFromVCard(addressParts.slice(-1, addressParts.length).join(' '));
|
||||
const postalCode = getSafelyUnescapedTextFromVCard(addressParts.slice(-2, addressParts.length - 1).join(' '));
|
||||
const state = getSafelyUnescapedTextFromVCard(addressParts.slice(-3, addressParts.length - 2).join(' '));
|
||||
const city = getSafelyUnescapedTextFromVCard(addressParts.slice(-4, addressParts.length - 3).join(' '));
|
||||
const line1 = getSafelyUnescapedTextFromVCard(addressParts.slice(-5, addressParts.length - 4).join(' '));
|
||||
const line2 = getSafelyUnescapedTextFromVCard(addressParts.slice(-6, addressParts.length - 5).join(' '));
|
||||
|
||||
const label = getSafelyUnescapedTextFromVCard(
|
||||
((addressInfo.split(':')[0] || '').split('TYPE=')[1] || 'home').replaceAll(';', ''),
|
||||
);
|
||||
|
||||
if (!country && !postalCode && !state && !city && !line2 && !line1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address: ContactAddress = {
|
||||
label,
|
||||
line_1: line1,
|
||||
line_2: line2,
|
||||
city,
|
||||
state,
|
||||
postal_code: postalCode,
|
||||
country,
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
addresses: [...(partialContact.extra?.addresses || []), address],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('PHOTO;')) {
|
||||
const photoInfo = line.split('PHOTO;')[1] || '';
|
||||
const photoUrl = photoInfo.split(':')[1];
|
||||
const photoMediaTypeInfo = photoInfo.split(':')[0];
|
||||
let photoMediaType = photoMediaTypeInfo.split('TYPE=')[1] || '';
|
||||
|
||||
if (!photoMediaType) {
|
||||
photoMediaType = 'image/jpeg';
|
||||
}
|
||||
|
||||
if (!photoMediaType.startsWith('image/')) {
|
||||
photoMediaType = `image/${photoMediaType.toLowerCase()}`;
|
||||
}
|
||||
|
||||
if (!photoUrl) {
|
||||
continue;
|
||||
}
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
photo_mediatype: photoMediaType,
|
||||
photo_url: photoUrl,
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('TEL;')) {
|
||||
const phoneInfo = line.split('TEL;')[1] || '';
|
||||
const phoneNumber = phoneInfo.split(':')[1] || '';
|
||||
const name = (phoneInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!phoneNumber) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name,
|
||||
value: phoneNumber,
|
||||
type: 'phone',
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
fields: [...(partialContact.extra?.fields || []), field],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('EMAIL;')) {
|
||||
const emailInfo = line.split('EMAIL;')[1] || '';
|
||||
const emailAddress = emailInfo.split(':')[1] || '';
|
||||
const name = (emailInfo.split(':')[0].split('TYPE=')[1] || 'home').replaceAll(';', '');
|
||||
|
||||
if (!emailAddress) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name,
|
||||
value: emailAddress,
|
||||
type: 'email',
|
||||
};
|
||||
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
fields: [...(partialContact.extra?.fields || []), field],
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return partialContacts;
|
||||
}
|
||||
57
lib/utils/files.ts
Normal file
57
lib/utils/files.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
|
||||
export const TRASH_PATH = `/.Trash/`;
|
||||
|
||||
export function humanFileSize(bytes: number) {
|
||||
if (Math.abs(bytes) < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
const units = ['KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
let unitIndex = -1;
|
||||
const roundedPower = 10 ** 2;
|
||||
|
||||
do {
|
||||
bytes /= 1024;
|
||||
++unitIndex;
|
||||
} while (Math.round(Math.abs(bytes) * roundedPower) / roundedPower >= 1024 && unitIndex < units.length - 1);
|
||||
|
||||
return `${bytes.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export function sortEntriesByName(entryA: Deno.DirEntry, entryB: Deno.DirEntry) {
|
||||
if (entryA.name > entryB.name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (entryA.name < entryB.name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortDirectoriesByName(directoryA: Directory, directoryB: Directory) {
|
||||
if (directoryA.directory_name > directoryB.directory_name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (directoryA.directory_name < directoryB.directory_name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortFilesByName(fileA: DirectoryFile, fileB: DirectoryFile) {
|
||||
if (fileA.file_name > fileB.file_name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (fileA.file_name < fileB.file_name) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
36
lib/utils/files_test.ts
Normal file
36
lib/utils/files_test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||
import { humanFileSize } from './files.ts';
|
||||
|
||||
Deno.test('that humanFileSize works', () => {
|
||||
const tests: { input: number; expected: string }[] = [
|
||||
{
|
||||
input: 1000,
|
||||
expected: '1000 B',
|
||||
},
|
||||
{
|
||||
input: 1024,
|
||||
expected: '1.00 KB',
|
||||
},
|
||||
{
|
||||
input: 10000,
|
||||
expected: '9.77 KB',
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
expected: '1 B',
|
||||
},
|
||||
{
|
||||
input: 1048576,
|
||||
expected: '1.00 MB',
|
||||
},
|
||||
{
|
||||
input: 1073741824,
|
||||
expected: '1.00 GB',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = humanFileSize(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
@@ -10,15 +10,9 @@ if (typeof Deno !== 'undefined') {
|
||||
|
||||
export const baseUrl = BASE_URL || 'http://localhost:8000';
|
||||
export const defaultTitle = 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud';
|
||||
export const defaultDescription = `Have your calendar, contacts, tasks, and files under your own control.`;
|
||||
export const defaultDescription = `Have your files under your own control.`;
|
||||
export const helpEmail = 'help@bewcloud.com';
|
||||
|
||||
export const DAV_RESPONSE_HEADER = '1, 2, 3, 4, addressbook, calendar-access';
|
||||
// Response headers from Nextcloud:
|
||||
// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, oc-resource-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||
// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, addressbook, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||
// 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, calendar-access, calendar-proxy, calendar-auto-schedule, calendar-availability, nc-calendar-trashbin, nc-calendar-webcal-cache, calendarserver-subscribed, oc-resource-sharing, oc-calendar-publishing, calendarserver-sharing, nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar
|
||||
|
||||
export function isRunningLocally(request: Request) {
|
||||
return request.url.includes('localhost');
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// Nextcloud/ownCloud mimicry
|
||||
export function handler(): Response {
|
||||
return new Response('Redirecting...', {
|
||||
status: 307,
|
||||
headers: { Location: '/dav' },
|
||||
});
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
// Nextcloud/ownCloud mimicry
|
||||
export function handler(): Response {
|
||||
return new Response('Redirecting...', {
|
||||
status: 307,
|
||||
headers: { Location: '/dav' },
|
||||
});
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { createCalendarEvent, getCalendar, getCalendarEvents } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const newCalendarEvent = await createCalendarEvent(
|
||||
context.state.user.id,
|
||||
requestBody.calendarId,
|
||||
requestBody.title,
|
||||
new Date(requestBody.startDate),
|
||||
new Date(requestBody.endDate),
|
||||
requestBody.isAllDay,
|
||||
);
|
||||
|
||||
if (!newCalendarEvent) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) };
|
||||
|
||||
if (requestBody.calendarView === 'day') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 1);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 1);
|
||||
} else if (requestBody.calendarView === 'week') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 7);
|
||||
} else {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||
}
|
||||
|
||||
const newCalendarEvents = await getCalendarEvents(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||
import { createCalendar, getCalendars } from '/lib/data/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) {
|
||||
const newCalendar = await createCalendar(context.state.user.id, requestBody.name);
|
||||
|
||||
if (!newCalendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const newCalendars = await getCalendars(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { deleteCalendarEvent, getCalendar, getCalendarEvent, getCalendarEvents } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendarEvent = await getCalendarEvent(
|
||||
requestBody.calendarEventId,
|
||||
context.state.user.id,
|
||||
);
|
||||
|
||||
if (!calendarEvent || requestBody.calendarId !== calendarEvent.calendar_id) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
await deleteCalendarEvent(requestBody.calendarEventId, requestBody.calendarId, context.state.user.id);
|
||||
|
||||
const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) };
|
||||
|
||||
if (requestBody.calendarView === 'day') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 1);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 1);
|
||||
} else if (requestBody.calendarView === 'week') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 7);
|
||||
} else {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||
}
|
||||
|
||||
const newCalendarEvents = await getCalendarEvents(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||
import { deleteCalendar, getCalendar, getCalendars } from '/lib/data/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 getCalendar(requestBody.calendarId, context.state.user.id);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await deleteCalendar(requestBody.calendarId, context.state.user.id);
|
||||
}
|
||||
|
||||
const newCalendars = await getCalendars(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { getCalendarEvents } from '/lib/data/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 getCalendarEvents(
|
||||
context.state.user.id,
|
||||
requestBody.calendarIds,
|
||||
);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, calendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||
import { createCalendarEvent, getCalendar, getCalendarEvents, updateCalendarEvent } from '/lib/data/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
calendarIds: string[];
|
||||
calendarView: 'day' | 'week' | 'month';
|
||||
calendarStartDate: string;
|
||||
partialCalendarEvents: Partial<CalendarEvent>[];
|
||||
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.partialCalendarEvents ||
|
||||
!requestBody.calendarView || !requestBody.calendarStartDate
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const calendar = await getCalendar(requestBody.calendarId, context.state.user.id);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
if (requestBody.partialCalendarEvents.length === 0) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await concurrentPromises(
|
||||
requestBody.partialCalendarEvents.map((partialCalendarEvent) => async () => {
|
||||
if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) {
|
||||
const calendarEvent = await createCalendarEvent(
|
||||
context.state.user!.id,
|
||||
requestBody.calendarId,
|
||||
partialCalendarEvent.title,
|
||||
new Date(partialCalendarEvent.start_date),
|
||||
new Date(partialCalendarEvent.end_date),
|
||||
partialCalendarEvent.is_all_day,
|
||||
);
|
||||
|
||||
const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {});
|
||||
|
||||
if (parsedExtra !== '{}') {
|
||||
calendarEvent.extra = partialCalendarEvent.extra!;
|
||||
|
||||
if (
|
||||
calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_sequence === 0 &&
|
||||
!calendarEvent.extra.recurring_id
|
||||
) {
|
||||
calendarEvent.extra.recurring_id = calendarEvent.id;
|
||||
}
|
||||
|
||||
await updateCalendarEvent(calendarEvent);
|
||||
}
|
||||
}
|
||||
}),
|
||||
5,
|
||||
);
|
||||
|
||||
const dateRange = { start: new Date(requestBody.calendarStartDate), end: new Date(requestBody.calendarStartDate) };
|
||||
|
||||
if (requestBody.calendarView === 'day') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 1);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 1);
|
||||
} else if (requestBody.calendarView === 'week') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 7);
|
||||
} else {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||
}
|
||||
|
||||
const newCalendarEvents = await getCalendarEvents(context.state.user.id, requestBody.calendarIds, dateRange);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { searchCalendarEvents } from '/lib/data/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 matchingCalendarEvents = await searchCalendarEvents(
|
||||
requestBody.searchTerm,
|
||||
context.state.user.id,
|
||||
requestBody.calendarIds,
|
||||
);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, matchingCalendarEvents };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||
import { getCalendar, getCalendars, updateCalendar } from '/lib/data/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
is_visible: 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 getCalendar(requestBody.id, context.state.user.id);
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
calendar.name = requestBody.name;
|
||||
calendar.color = requestBody.color;
|
||||
calendar.is_visible = requestBody.is_visible;
|
||||
|
||||
await updateCalendar(calendar);
|
||||
}
|
||||
|
||||
const newCalendars = await getCalendars(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, newCalendars };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { createContact, getContacts } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
}
|
||||
|
||||
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.firstName) {
|
||||
const contact = await createContact(context.state.user.id, requestBody.firstName, requestBody.lastName);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { deleteContact, getContact, getContacts } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
contactId: string;
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
}
|
||||
|
||||
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.contactId) {
|
||||
const contact = await getContact(requestBody.contactId, context.state.user.id);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await deleteContact(requestBody.contactId, context.state.user.id);
|
||||
}
|
||||
|
||||
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { getAllContacts } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Contact[];
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const contacts = await getAllContacts(context.state.user.id);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||
import { createContact, getContacts, updateContact } from '/lib/data/contacts.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
partialContacts: Partial<Contact>[];
|
||||
page: number;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
contacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
}
|
||||
|
||||
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.partialContacts) {
|
||||
if (requestBody.partialContacts.length === 0) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
await concurrentPromises(
|
||||
requestBody.partialContacts.map((partialContact) => async () => {
|
||||
if (partialContact.first_name) {
|
||||
const contact = await createContact(
|
||||
context.state.user!.id,
|
||||
partialContact.first_name,
|
||||
partialContact.last_name || '',
|
||||
);
|
||||
|
||||
const parsedExtra = JSON.stringify(partialContact.extra || {});
|
||||
|
||||
if (parsedExtra !== '{}') {
|
||||
contact.extra = partialContact.extra!;
|
||||
|
||||
await updateContact(contact);
|
||||
}
|
||||
}
|
||||
}),
|
||||
5,
|
||||
);
|
||||
}
|
||||
|
||||
const contacts = await getContacts(context.state.user.id, requestBody.page - 1);
|
||||
|
||||
const responseBody: ResponseBody = { success: true, contacts };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
47
routes/api/files/create-directory.tsx
Normal file
47
routes/api/files/create-directory.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Directory, FreshContextState } from '/lib/types.ts';
|
||||
import { createDirectory, getDirectories } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
parentPath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newDirectories: Directory[];
|
||||
}
|
||||
|
||||
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.parentPath || !requestBody.name?.trim() || !requestBody.parentPath.startsWith('/') ||
|
||||
requestBody.parentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to path and get the appropriate ownerUserId
|
||||
|
||||
const createdDirectory = await createDirectory(
|
||||
context.state.user.id,
|
||||
requestBody.parentPath,
|
||||
requestBody.name.trim(),
|
||||
);
|
||||
|
||||
const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: createdDirectory, newDirectories };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
47
routes/api/files/delete-directory.tsx
Normal file
47
routes/api/files/delete-directory.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Directory, FreshContextState } from '/lib/types.ts';
|
||||
import { deleteDirectoryOrFile, getDirectories } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
parentPath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newDirectories: Directory[];
|
||||
}
|
||||
|
||||
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.parentPath || !requestBody.name?.trim() || !requestBody.parentPath.startsWith('/') ||
|
||||
requestBody.parentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to path and get the appropriate ownerUserId
|
||||
|
||||
const deletedDirectory = await deleteDirectoryOrFile(
|
||||
context.state.user.id,
|
||||
requestBody.parentPath,
|
||||
requestBody.name.trim(),
|
||||
);
|
||||
|
||||
const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: deletedDirectory, newDirectories };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
47
routes/api/files/delete.tsx
Normal file
47
routes/api/files/delete.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { DirectoryFile, FreshContextState } from '/lib/types.ts';
|
||||
import { deleteDirectoryOrFile, getFiles } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
parentPath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newFiles: DirectoryFile[];
|
||||
}
|
||||
|
||||
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.parentPath || !requestBody.name?.trim() || !requestBody.parentPath.startsWith('/') ||
|
||||
requestBody.parentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to path/file and get the appropriate ownerUserId
|
||||
|
||||
const deletedFile = await deleteDirectoryOrFile(
|
||||
context.state.user.id,
|
||||
requestBody.parentPath,
|
||||
requestBody.name.trim(),
|
||||
);
|
||||
|
||||
const newFiles = await getFiles(context.state.user.id, requestBody.parentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: deletedFile, newFiles };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
47
routes/api/files/get-directories.tsx
Normal file
47
routes/api/files/get-directories.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Directory, FreshContextState } from '/lib/types.ts';
|
||||
import { getDirectories } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
parentPath: string;
|
||||
directoryPathToExclude?: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
directories: Directory[];
|
||||
}
|
||||
|
||||
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.parentPath || !requestBody.parentPath.startsWith('/') || requestBody.parentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const directories = await getDirectories(
|
||||
context.state.user.id,
|
||||
requestBody.parentPath,
|
||||
);
|
||||
|
||||
const filteredDirectories = requestBody.directoryPathToExclude
|
||||
? directories.filter((directory) =>
|
||||
`${directory.parent_path}${directory.directory_name}` !== requestBody.directoryPathToExclude
|
||||
)
|
||||
: directories;
|
||||
|
||||
const responseBody: ResponseBody = { success: true, directories: filteredDirectories };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
52
routes/api/files/move-directory.tsx
Normal file
52
routes/api/files/move-directory.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Directory, FreshContextState } from '/lib/types.ts';
|
||||
import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
oldParentPath: string;
|
||||
newParentPath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newDirectories: Directory[];
|
||||
}
|
||||
|
||||
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.oldParentPath || !requestBody.newParentPath || !requestBody.name?.trim() ||
|
||||
!requestBody.oldParentPath.startsWith('/') ||
|
||||
requestBody.oldParentPath.includes('../') || !requestBody.newParentPath.startsWith('/') ||
|
||||
requestBody.newParentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to old and new paths and get the appropriate ownerUserIds
|
||||
|
||||
const movedDirectory = await renameDirectoryOrFile(
|
||||
context.state.user.id,
|
||||
requestBody.oldParentPath,
|
||||
requestBody.newParentPath,
|
||||
requestBody.name.trim(),
|
||||
requestBody.name.trim(),
|
||||
);
|
||||
|
||||
const newDirectories = await getDirectories(context.state.user.id, requestBody.oldParentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: movedDirectory, newDirectories };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
52
routes/api/files/move.tsx
Normal file
52
routes/api/files/move.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { DirectoryFile, FreshContextState } from '/lib/types.ts';
|
||||
import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
oldParentPath: string;
|
||||
newParentPath: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newFiles: DirectoryFile[];
|
||||
}
|
||||
|
||||
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.oldParentPath || !requestBody.newParentPath || !requestBody.name?.trim() ||
|
||||
!requestBody.oldParentPath.startsWith('/') ||
|
||||
requestBody.oldParentPath.includes('../') || !requestBody.newParentPath.startsWith('/') ||
|
||||
requestBody.newParentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to old and new paths/files and get the appropriate ownerUserIds
|
||||
|
||||
const movedFile = await renameDirectoryOrFile(
|
||||
context.state.user.id,
|
||||
requestBody.oldParentPath,
|
||||
requestBody.newParentPath,
|
||||
requestBody.name.trim(),
|
||||
requestBody.name.trim(),
|
||||
);
|
||||
|
||||
const newFiles = await getFiles(context.state.user.id, requestBody.oldParentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: movedFile, newFiles };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
51
routes/api/files/rename-directory.tsx
Normal file
51
routes/api/files/rename-directory.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { Directory, FreshContextState } from '/lib/types.ts';
|
||||
import { getDirectories, renameDirectoryOrFile } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
parentPath: string;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newDirectories: Directory[];
|
||||
}
|
||||
|
||||
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.parentPath || !requestBody.oldName?.trim() || !requestBody.newName?.trim() ||
|
||||
!requestBody.parentPath.startsWith('/') ||
|
||||
requestBody.parentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to path and get the appropriate ownerUserId
|
||||
|
||||
const movedDirectory = await renameDirectoryOrFile(
|
||||
context.state.user.id,
|
||||
requestBody.parentPath,
|
||||
requestBody.parentPath,
|
||||
requestBody.oldName.trim(),
|
||||
requestBody.newName.trim(),
|
||||
);
|
||||
|
||||
const newDirectories = await getDirectories(context.state.user.id, requestBody.parentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: movedDirectory, newDirectories };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
51
routes/api/files/rename.tsx
Normal file
51
routes/api/files/rename.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { DirectoryFile, FreshContextState } from '/lib/types.ts';
|
||||
import { getFiles, renameDirectoryOrFile } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
parentPath: string;
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newFiles: DirectoryFile[];
|
||||
}
|
||||
|
||||
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.parentPath || !requestBody.oldName?.trim() || !requestBody.newName?.trim() ||
|
||||
!requestBody.parentPath.startsWith('/') ||
|
||||
requestBody.parentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to path/file and get the appropriate ownerUserId
|
||||
|
||||
const movedFile = await renameDirectoryOrFile(
|
||||
context.state.user.id,
|
||||
requestBody.parentPath,
|
||||
requestBody.parentPath,
|
||||
requestBody.oldName.trim(),
|
||||
requestBody.newName.trim(),
|
||||
);
|
||||
|
||||
const newFiles = await getFiles(context.state.user.id, requestBody.parentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: movedFile, newFiles };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
42
routes/api/files/upload.tsx
Normal file
42
routes/api/files/upload.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { DirectoryFile, FreshContextState } from '/lib/types.ts';
|
||||
import { createFile, getFiles } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
newFiles: DirectoryFile[];
|
||||
}
|
||||
|
||||
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().formData();
|
||||
|
||||
const parentPath = requestBody.get('parent_path') as string;
|
||||
const name = requestBody.get('name') as string;
|
||||
const contents = requestBody.get('contents') as File;
|
||||
|
||||
if (
|
||||
!parentPath || !name.trim() || !contents || !parentPath.startsWith('/') ||
|
||||
parentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: Verify user has write access to path and get the appropriate ownerUserId
|
||||
|
||||
const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), await contents.arrayBuffer());
|
||||
|
||||
const newFiles = await getFiles(context.state.user.id, parentPath);
|
||||
|
||||
const responseBody: ResponseBody = { success: createdFile, newFiles };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { getCalendarEvents, getCalendars } from '/lib/data/calendar.ts';
|
||||
import CalendarWrapper from '/islands/calendar/CalendarWrapper.tsx';
|
||||
|
||||
interface Data {
|
||||
userCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible' | 'extra'>[];
|
||||
userCalendarEvents: CalendarEvent[];
|
||||
view: 'day' | 'week' | 'month';
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const userCalendars = await getCalendars(context.state.user.id);
|
||||
const visibleCalendarIds = userCalendars.filter((calendar) => calendar.is_visible).map((calendar) => calendar.id);
|
||||
|
||||
const dateRange = { start: new Date(startDate), end: new Date(startDate) };
|
||||
|
||||
if (view === 'day') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 1);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 1);
|
||||
} else if (view === 'week') {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 7);
|
||||
} else {
|
||||
dateRange.start.setDate(dateRange.start.getDate() - 7);
|
||||
dateRange.end.setDate(dateRange.end.getDate() + 31);
|
||||
}
|
||||
|
||||
const userCalendarEvents = await getCalendarEvents(context.state.user.id, visibleCalendarIds, dateRange);
|
||||
|
||||
return await context.render({ userCalendars, userCalendarEvents, view, startDate });
|
||||
},
|
||||
};
|
||||
|
||||
export default function CalendarPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<CalendarWrapper
|
||||
initialCalendars={data.userCalendars}
|
||||
initialCalendarEvents={data.userCalendarEvents}
|
||||
view={data.view}
|
||||
startDate={data.startDate}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { convertFormDataToObject } from '/lib/utils/misc.ts';
|
||||
import { getCalendarEvent, getCalendars, updateCalendarEvent } from '/lib/data/calendar.ts';
|
||||
import { getFormDataField } from '/lib/form-utils.tsx';
|
||||
import ViewCalendarEvent, { formFields } from '/islands/calendar/ViewCalendarEvent.tsx';
|
||||
|
||||
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 { calendarEventId } = context.params;
|
||||
|
||||
const calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id);
|
||||
|
||||
if (!calendarEvent) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendars = await getCalendars(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 { calendarEventId } = context.params;
|
||||
|
||||
const calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id);
|
||||
|
||||
if (!calendarEvent) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendars = await getCalendars(context.state.user.id);
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
calendarEvent.title = getFormDataField(formData, 'title');
|
||||
calendarEvent.start_date = new Date(getFormDataField(formData, 'start_date'));
|
||||
calendarEvent.end_date = new Date(getFormDataField(formData, 'end_date'));
|
||||
calendarEvent.is_all_day = getFormDataField(formData, 'is_all_day') === 'true';
|
||||
calendarEvent.status = getFormDataField(formData, 'status') as CalendarEvent['status'];
|
||||
|
||||
calendarEvent.extra.description = getFormDataField(formData, 'description') || undefined;
|
||||
calendarEvent.extra.url = getFormDataField(formData, 'url') || undefined;
|
||||
calendarEvent.extra.location = getFormDataField(formData, 'location') || undefined;
|
||||
calendarEvent.extra.transparency =
|
||||
getFormDataField(formData, 'transparency') as CalendarEvent['extra']['transparency'] || 'default';
|
||||
|
||||
const newCalendarId = getFormDataField(formData, 'calendar_id');
|
||||
let oldCalendarId: string | undefined;
|
||||
|
||||
if (newCalendarId !== calendarEvent.calendar_id) {
|
||||
oldCalendarId = calendarEvent.calendar_id;
|
||||
}
|
||||
|
||||
calendarEvent.calendar_id = newCalendarId;
|
||||
|
||||
try {
|
||||
if (!calendarEvent.title) {
|
||||
throw new Error(`Title is required.`);
|
||||
}
|
||||
|
||||
formFields(calendarEvent, calendars).forEach((field) => {
|
||||
if (field.required) {
|
||||
const value = formData.get(field.name);
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${field.label} is required`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await updateCalendarEvent(calendarEvent, oldCalendarId);
|
||||
|
||||
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.toString(),
|
||||
formData: convertFormDataToObject(formData),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<ViewCalendarEvent
|
||||
initialCalendarEvent={data.calendarEvent}
|
||||
calendars={data.calendars}
|
||||
formData={data.formData}
|
||||
error={data.error}
|
||||
notice={data.notice}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||
import { getCalendars } from '/lib/data/calendar.ts';
|
||||
import Calendars from '/islands/calendar/Calendars.tsx';
|
||||
|
||||
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 userCalendars = await getCalendars(context.state.user.id);
|
||||
|
||||
return await context.render({ userCalendars });
|
||||
},
|
||||
};
|
||||
|
||||
export default function CalendarsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<Calendars initialCalendars={data.userCalendars || []} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { getContacts, getContactsCount, searchContacts, searchContactsCount } from '/lib/data/contacts.ts';
|
||||
import Contacts from '/islands/contacts/Contacts.tsx';
|
||||
|
||||
interface Data {
|
||||
userContacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
page: number;
|
||||
contactsCount: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
const search = searchParams.get('search') || undefined;
|
||||
|
||||
const userContacts = search
|
||||
? await searchContacts(search, context.state.user.id, page - 1)
|
||||
: await getContacts(context.state.user.id, page - 1);
|
||||
|
||||
const contactsCount = search
|
||||
? await searchContactsCount(search, context.state.user.id)
|
||||
: await getContactsCount(context.state.user.id);
|
||||
|
||||
return await context.render({ userContacts, page, contactsCount, search });
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<Contacts
|
||||
initialContacts={data?.userContacts || []}
|
||||
page={data?.page || 1}
|
||||
contactsCount={data?.contactsCount || 0}
|
||||
search={data?.search || ''}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Contact, ContactAddress, ContactField, FreshContextState } from '/lib/types.ts';
|
||||
import { convertFormDataToObject } from '/lib/utils/misc.ts';
|
||||
import { getContact, updateContact } from '/lib/data/contacts.ts';
|
||||
import { getFormDataField, getFormDataFieldArray } from '/lib/form-utils.tsx';
|
||||
import ViewContact, { formFields } from '/islands/contacts/ViewContact.tsx';
|
||||
|
||||
interface Data {
|
||||
contact: Contact;
|
||||
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 { contactId } = context.params;
|
||||
|
||||
const contact = await getContact(contactId, context.state.user.id);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
return await context.render({ contact, formData: {} });
|
||||
},
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const { contactId } = context.params;
|
||||
|
||||
const contact = await getContact(contactId, context.state.user.id);
|
||||
|
||||
if (!contact) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
contact.extra.name_title = getFormDataField(formData, 'name_title') || undefined;
|
||||
contact.first_name = getFormDataField(formData, 'first_name');
|
||||
contact.last_name = getFormDataField(formData, 'last_name');
|
||||
contact.extra.middle_names = getFormDataField(formData, 'middle_names').split(' ').map((name) =>
|
||||
(name || '').trim()
|
||||
).filter(Boolean);
|
||||
if (contact.extra.middle_names.length === 0) {
|
||||
contact.extra.middle_names = undefined;
|
||||
}
|
||||
contact.extra.birthday = getFormDataField(formData, 'birthday') || undefined;
|
||||
contact.extra.nickname = getFormDataField(formData, 'nickname') || undefined;
|
||||
contact.extra.organization = getFormDataField(formData, 'organization') || undefined;
|
||||
contact.extra.role = getFormDataField(formData, 'role') || undefined;
|
||||
contact.extra.photo_url = getFormDataField(formData, 'photo_url') || undefined;
|
||||
contact.extra.photo_mediatype = contact.extra.photo_url
|
||||
? `image/${contact.extra.photo_url.split('.').slice(-1, 1).join('').toLowerCase()}`
|
||||
: undefined;
|
||||
contact.extra.notes = getFormDataField(formData, 'notes') || undefined;
|
||||
|
||||
contact.extra.fields = [];
|
||||
|
||||
// Phones
|
||||
const phoneNumbers = getFormDataFieldArray(formData, 'phone_numbers');
|
||||
const phoneLabels = getFormDataFieldArray(formData, 'phone_labels');
|
||||
|
||||
for (const [index, phoneNumber] of phoneNumbers.entries()) {
|
||||
if (phoneNumber.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: phoneLabels[index] || 'Home',
|
||||
value: phoneNumber.trim(),
|
||||
type: 'phone',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
// Emails
|
||||
const emailAddresses = getFormDataFieldArray(formData, 'email_addresses');
|
||||
const emailLabels = getFormDataFieldArray(formData, 'email_labels');
|
||||
|
||||
for (const [index, emailAddress] of emailAddresses.entries()) {
|
||||
if (emailAddress.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: emailLabels[index] || 'Home',
|
||||
value: emailAddress.trim(),
|
||||
type: 'email',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
// URLs
|
||||
const urlAddresses = getFormDataFieldArray(formData, 'url_addresses');
|
||||
const urlLabels = getFormDataFieldArray(formData, 'url_labels');
|
||||
|
||||
for (const [index, urlAddress] of urlAddresses.entries()) {
|
||||
if (urlAddress.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: urlLabels[index] || 'Home',
|
||||
value: urlAddress.trim(),
|
||||
type: 'url',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
// Others
|
||||
const otherValues = getFormDataFieldArray(formData, 'other_values');
|
||||
const otherLabels = getFormDataFieldArray(formData, 'other_labels');
|
||||
|
||||
for (const [index, otherValue] of otherValues.entries()) {
|
||||
if (otherValue.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const field: ContactField = {
|
||||
name: otherLabels[index] || 'Home',
|
||||
value: otherValue.trim(),
|
||||
type: 'other',
|
||||
};
|
||||
|
||||
contact.extra.fields.push(field);
|
||||
}
|
||||
|
||||
contact.extra.addresses = [];
|
||||
|
||||
// Addresses
|
||||
const addressLine1s = getFormDataFieldArray(formData, 'address_line_1s');
|
||||
const addressLine2s = getFormDataFieldArray(formData, 'address_line_2s');
|
||||
const addressCities = getFormDataFieldArray(formData, 'address_cities');
|
||||
const addressPostalCodes = getFormDataFieldArray(formData, 'address_postal_codes');
|
||||
const addressStates = getFormDataFieldArray(formData, 'address_states');
|
||||
const addressCountries = getFormDataFieldArray(formData, 'address_countries');
|
||||
const addressLabels = getFormDataFieldArray(formData, 'address_labels');
|
||||
|
||||
for (const [index, addressLine1] of addressLine1s.entries()) {
|
||||
if (addressLine1.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const address: ContactAddress = {
|
||||
label: addressLabels[index] || 'Home',
|
||||
line_1: addressLine1.trim(),
|
||||
line_2: addressLine2s[index] || undefined,
|
||||
city: addressCities[index] || undefined,
|
||||
postal_code: addressPostalCodes[index] || undefined,
|
||||
state: addressStates[index] || undefined,
|
||||
country: addressCountries[index] || undefined,
|
||||
};
|
||||
|
||||
contact.extra.addresses.push(address);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!contact.first_name) {
|
||||
throw new Error(`First name is required.`);
|
||||
}
|
||||
|
||||
formFields(contact).forEach((field) => {
|
||||
if (field.required) {
|
||||
const value = formData.get(field.name);
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`${field.label} is required`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await updateContact(contact);
|
||||
|
||||
return await context.render({
|
||||
contact,
|
||||
notice: 'Contact updated successfully!',
|
||||
formData: convertFormDataToObject(formData),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return await context.render({ contact, error: error.toString(), formData: convertFormDataToObject(formData) });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default function ContactsPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<ViewContact initialContact={data.contact} formData={data.formData} error={data.error} notice={data.notice} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype'?: {
|
||||
'd:collection': {};
|
||||
'card:addressbook'?: {};
|
||||
};
|
||||
'd:displayname'?: string;
|
||||
'd:getetag'?: string;
|
||||
'd:current-user-principal'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:principal-URL'?: {};
|
||||
'card:addressbook-home-set'?: {};
|
||||
};
|
||||
'd:status': string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
{
|
||||
'd:href': '/dav/addressbooks/',
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} },
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
},
|
||||
{
|
||||
'd:href': '/dav/addressbooks/contacts/',
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
'card:addressbook': {},
|
||||
},
|
||||
'd:displayname': 'Contacts',
|
||||
'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`),
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': { 'd:principal-URL': {}, 'card:addressbook-home-set': {} },
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,243 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
import { parse } from 'xml/mod.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
||||
import { formatContactToVCard } from '/lib/utils/contacts.ts';
|
||||
import { getAllContacts } from '/lib/data/contacts.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype'?: {
|
||||
'd:collection'?: {};
|
||||
'card:addressbook'?: {};
|
||||
};
|
||||
'd:displayname'?: string | {};
|
||||
'card:address-data'?: string;
|
||||
'd:getlastmodified'?: string | {};
|
||||
'd:getetag'?: string | {};
|
||||
'd:getcontenttype'?: string | {};
|
||||
'd:getcontentlength'?: number | {};
|
||||
'd:creationdate'?: string | {};
|
||||
'card:addressbook-description'?: string | {};
|
||||
'cs:getctag'?: {};
|
||||
'd:current-user-privilege-set'?: {
|
||||
'd:privilege': {
|
||||
'd:write-properties'?: {};
|
||||
'd:write'?: {};
|
||||
'd:write-content'?: {};
|
||||
'd:unlock'?: {};
|
||||
'd:bind'?: {};
|
||||
'd:unbind'?: {};
|
||||
'd:write-acl'?: {};
|
||||
'd:read'?: {};
|
||||
'd:read-acl'?: {};
|
||||
'd:read-current-user-privilege-set'?: {};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
'd:status': string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
'xmlns:cs': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const contacts = await getAllContacts(context.state.user.id);
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
let includeVCard = false;
|
||||
let includeCollection = true;
|
||||
const includePrivileges = requestBody.includes('current-user-privilege-set');
|
||||
|
||||
const filterContactIds = new Set<string>();
|
||||
|
||||
try {
|
||||
const parsedDocument = parse(requestBody);
|
||||
|
||||
const multiGetRequest = (parsedDocument['addressbook-multiget'] || parsedDocument['r:addressbook-multiget'] ||
|
||||
parsedDocument['f:addressbook-multiget'] || parsedDocument['d:addressbook-multiget'] ||
|
||||
parsedDocument['r:addressbook-query'] ||
|
||||
parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
|
||||
includeVCard = Boolean(multiGetRequest);
|
||||
|
||||
const requestedHrefs: string[] = (multiGetRequest && (multiGetRequest['href'] || multiGetRequest['d:href'])) || [];
|
||||
|
||||
includeCollection = requestedHrefs.length === 0;
|
||||
|
||||
for (const requestedHref of requestedHrefs) {
|
||||
const userVCard = requestedHref.split('/').slice(-1).join('');
|
||||
const [userId] = userVCard.split('.vcf');
|
||||
|
||||
if (userId) {
|
||||
filterContactIds.add(userId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse XML`, error);
|
||||
}
|
||||
|
||||
const filteredContacts = filterContactIds.size > 0
|
||||
? contacts.filter((contact) => filterContactIds.has(contact.id))
|
||||
: contacts;
|
||||
|
||||
const parsedContacts = filteredContacts.map((contact) => {
|
||||
const parsedContact: DavResponse = {
|
||||
'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`,
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()),
|
||||
'd:getetag': escapeHtml(`"${contact.revision}"`),
|
||||
'd:getcontenttype': 'text/vcard; charset=utf-8',
|
||||
'd:resourcetype': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': {
|
||||
'd:displayname': {},
|
||||
'd:getcontentlength': {},
|
||||
'd:creationdate': {},
|
||||
'card:addressbook-description': {},
|
||||
'cs:getctag': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
};
|
||||
|
||||
if (includeVCard) {
|
||||
parsedContact['d:propstat'][0]['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact]));
|
||||
}
|
||||
|
||||
if (includePrivileges) {
|
||||
parsedContact['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||
'd:privilege': [
|
||||
{ 'd:write-properties': {} },
|
||||
{ 'd:write': {} },
|
||||
{ 'd:write-content': {} },
|
||||
{ 'd:unlock': {} },
|
||||
{ 'd:bind': {} },
|
||||
{ 'd:unbind': {} },
|
||||
{ 'd:write-acl': {} },
|
||||
{ 'd:read': {} },
|
||||
{ 'd:read-acl': {} },
|
||||
{ 'd:read-current-user-privilege-set': {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return parsedContact;
|
||||
});
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
...parsedContacts,
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||
},
|
||||
};
|
||||
|
||||
if (includeCollection) {
|
||||
const collectionResponse: DavResponse = {
|
||||
'd:href': '/dav/addressbooks/contacts/',
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
'card:addressbook': {},
|
||||
},
|
||||
'd:displayname': 'Contacts',
|
||||
'd:getetag': escapeHtml(`"${context.state.user.extra.contacts_revision || 'new'}"`),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': {
|
||||
'd:getlastmodified': {},
|
||||
'd:getcontenttype': {},
|
||||
'd:getcontentlength': {},
|
||||
'd:creationdate': {},
|
||||
'card:addressbook-description': {},
|
||||
'cs:getctag': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
};
|
||||
|
||||
if (includePrivileges) {
|
||||
collectionResponse['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||
'd:privilege': [
|
||||
{ 'd:write-properties': {} },
|
||||
{ 'd:write': {} },
|
||||
{ 'd:write-content': {} },
|
||||
{ 'd:unlock': {} },
|
||||
{ 'd:bind': {} },
|
||||
{ 'd:unbind': {} },
|
||||
{ 'd:write-acl': {} },
|
||||
{ 'd:read': {} },
|
||||
{ 'd:read-acl': {} },
|
||||
{ 'd:read-current-user-privilege-set': {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
responseBody['d:multistatus']['d:response'].unshift(collectionResponse);
|
||||
}
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
import { parse } from 'xml/mod.ts';
|
||||
|
||||
import { Contact, FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
||||
import { formatContactToVCard, parseVCardFromTextContents } from '/lib/utils/contacts.ts';
|
||||
import { createContact, deleteContact, getContact, updateContact } from '/lib/data/contacts.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection'?: {};
|
||||
'card:addressbook'?: {};
|
||||
};
|
||||
'card:address-data'?: string;
|
||||
'd:getlastmodified'?: string;
|
||||
'd:getetag'?: string;
|
||||
'd:getcontenttype'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' &&
|
||||
request.method !== 'PUT' && request.method !== 'DELETE'
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const { contactId } = context.params;
|
||||
|
||||
let contact: Contact | null = null;
|
||||
|
||||
try {
|
||||
contact = await getContact(contactId, context.state.user.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!contact) {
|
||||
if (request.method === 'PUT') {
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
const [partialContact] = parseVCardFromTextContents(requestBody);
|
||||
|
||||
if (partialContact.first_name) {
|
||||
const newContact = await createContact(
|
||||
context.state.user.id,
|
||||
partialContact.first_name,
|
||||
partialContact.last_name || '',
|
||||
);
|
||||
|
||||
// Use the sent id for the UID
|
||||
if (!partialContact.extra?.uid) {
|
||||
partialContact.extra = {
|
||||
...(partialContact.extra || {}),
|
||||
uid: contactId,
|
||||
};
|
||||
}
|
||||
|
||||
newContact.extra = partialContact.extra!;
|
||||
|
||||
await updateContact(newContact);
|
||||
|
||||
contact = await getContact(newContact.id, context.state.user.id);
|
||||
|
||||
return new Response('Created', { status: 201, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||
|
||||
// Don't update outdated data
|
||||
if (clientRevision && clientRevision !== `"${contact.revision}"`) {
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
|
||||
await deleteContact(contactId, context.state.user.id);
|
||||
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||
|
||||
// Don't update outdated data
|
||||
if (clientRevision && clientRevision !== `"${contact.revision}"`) {
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
const [partialContact] = parseVCardFromTextContents(requestBody);
|
||||
|
||||
contact = {
|
||||
...contact,
|
||||
...partialContact,
|
||||
};
|
||||
|
||||
await updateContact(contact);
|
||||
|
||||
contact = await getContact(contactId, context.state.user.id);
|
||||
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${contact.revision}"` } });
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
// Set a UID if there isn't one
|
||||
if (!contact.extra.uid) {
|
||||
contact.extra.uid = crypto.randomUUID();
|
||||
await updateContact(contact);
|
||||
|
||||
contact = await getContact(contactId, context.state.user.id);
|
||||
}
|
||||
|
||||
const response = new Response(formatContactToVCard([contact]), {
|
||||
status: 200,
|
||||
headers: { 'etag': `"${contact.revision}"` },
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
}
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
let includeVCard = false;
|
||||
|
||||
try {
|
||||
const parsedDocument = parse(requestBody);
|
||||
|
||||
const multiGetRequest = (parsedDocument['r:addressbook-multiget'] || parsedDocument['r:addressbook-query'] ||
|
||||
parsedDocument['card:addressbook-multiget'] || parsedDocument['card:addressbook-query']) as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
|
||||
includeVCard = Boolean(multiGetRequest);
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse XML`, error);
|
||||
}
|
||||
|
||||
const parsedContact: DavResponse = {
|
||||
'd:href': `/dav/addressbooks/contacts/${contact.id}.vcf`,
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:getlastmodified': buildRFC822Date(contact.updated_at.toISOString()),
|
||||
'd:getetag': escapeHtml(`"${contact.revision}"`),
|
||||
'd:getcontenttype': 'text/vcard; charset=utf-8',
|
||||
'd:resourcetype': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
};
|
||||
|
||||
if (includeVCard) {
|
||||
parsedContact['d:propstat']['d:prop']['card:address-data'] = escapeXml(formatContactToVCard([contact]));
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [parsedContact],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,196 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts';
|
||||
import { getColorAsHex } from '/lib/utils/calendar.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
import { getCalendars } from '/lib/data/calendar.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype'?: {
|
||||
'd:collection': {};
|
||||
'cal:calendar'?: {};
|
||||
};
|
||||
'd:displayname'?: string | {};
|
||||
'd:getetag'?: string | {};
|
||||
'cs:getctag'?: string | {};
|
||||
'd:current-user-principal'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:principal-URL'?: {};
|
||||
'd:current-user-privilege-set'?: {
|
||||
'd:privilege': {
|
||||
'd:write-properties'?: {};
|
||||
'd:write'?: {};
|
||||
'd:write-content'?: {};
|
||||
'd:unlock'?: {};
|
||||
'd:bind'?: {};
|
||||
'd:unbind'?: {};
|
||||
'd:write-acl'?: {};
|
||||
'd:read'?: {};
|
||||
'd:read-acl'?: {};
|
||||
'd:read-current-user-privilege-set'?: {};
|
||||
}[];
|
||||
};
|
||||
'ic:calendar-color'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:cal': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
'xmlns:cs': string;
|
||||
'xmlns:ic': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
{
|
||||
'd:href': '/dav/calendars/',
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': { 'd:principal-URL': {}, 'd:displayname': {}, 'cs:getctag': {} },
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||
'xmlns:ic': 'http://apple.com/ns/ical/',
|
||||
},
|
||||
};
|
||||
|
||||
const calendars = await getCalendars(context.state.user.id);
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
const includePrivileges = requestBody.includes('current-user-privilege-set');
|
||||
const includeColor = requestBody.includes('calendar-color');
|
||||
|
||||
if (includePrivileges) {
|
||||
responseBody['d:multistatus']['d:response'][0]['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||
'd:privilege': [
|
||||
{ 'd:write-properties': {} },
|
||||
{ 'd:write': {} },
|
||||
{ 'd:write-content': {} },
|
||||
{ 'd:unlock': {} },
|
||||
{ 'd:bind': {} },
|
||||
{ 'd:unbind': {} },
|
||||
{ 'd:write-acl': {} },
|
||||
{ 'd:read': {} },
|
||||
{ 'd:read-acl': {} },
|
||||
{ 'd:read-current-user-privilege-set': {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
for (const calendar of calendars) {
|
||||
const parsedCalendar: DavResponse = {
|
||||
'd:href': `/dav/calendars/${calendar.id}`,
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
'cal:calendar': {},
|
||||
},
|
||||
'd:displayname': calendar.name,
|
||||
'd:getetag': escapeHtml(`"${calendar.revision}"`),
|
||||
'cs:getctag': calendar.revision,
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': { 'd:principal-URL': {} },
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
};
|
||||
|
||||
if (includePrivileges) {
|
||||
parsedCalendar['d:propstat'][0]['d:prop']['d:current-user-privilege-set'] = {
|
||||
'd:privilege': [
|
||||
{ 'd:write-properties': {} },
|
||||
{ 'd:write': {} },
|
||||
{ 'd:write-content': {} },
|
||||
{ 'd:unlock': {} },
|
||||
{ 'd:bind': {} },
|
||||
{ 'd:unbind': {} },
|
||||
{ 'd:write-acl': {} },
|
||||
{ 'd:read': {} },
|
||||
{ 'd:read-acl': {} },
|
||||
{ 'd:read-current-user-privilege-set': {} },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (includeColor) {
|
||||
parsedCalendar['d:propstat'][0]['d:prop']['ic:calendar-color'] = getColorAsHex(calendar.color);
|
||||
}
|
||||
|
||||
responseBody['d:multistatus']['d:response'].push(parsedCalendar);
|
||||
}
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,234 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
import { parse } from 'xml/mod.ts';
|
||||
|
||||
import { Calendar, FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
||||
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
||||
import {
|
||||
createCalendar,
|
||||
createCalendarEvent,
|
||||
getCalendar,
|
||||
getCalendarEvent,
|
||||
getCalendarEvents,
|
||||
updateCalendarEvent,
|
||||
} from '/lib/data/calendar.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype'?: {
|
||||
'd:collection'?: {};
|
||||
'cal:calendar'?: {};
|
||||
};
|
||||
'd:displayname'?: string | {};
|
||||
'cal:calendar-data'?: string;
|
||||
'd:getlastmodified'?: string;
|
||||
'd:getetag'?: string;
|
||||
'cs:getctag'?: string;
|
||||
'd:getcontenttype'?: string;
|
||||
'd:getcontentlength'?: {};
|
||||
'd:creationdate'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:cal': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
'xmlns:cs': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' &&
|
||||
request.method !== 'PUT' && request.method !== 'MKCALENDAR'
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const { calendarId } = context.params;
|
||||
|
||||
let calendar: Calendar | null = null;
|
||||
|
||||
try {
|
||||
calendar = await getCalendar(calendarId, context.state.user.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!calendar && request.method === 'MKCALENDAR') {
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
try {
|
||||
const parsedDocument = parse(requestBody);
|
||||
|
||||
const makeCalendarRequest = (parsedDocument['c:mkcalendar'] || parsedDocument['cal:mkcalendar']) as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
|
||||
const name: string = makeCalendarRequest!['d:set']['d:prop']['d:displayname']!;
|
||||
|
||||
const calendar = await createCalendar(context.state.user.id, name);
|
||||
|
||||
return new Response('Created', { status: 201, headers: { 'etag': `"${calendar.revision}"` } });
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse XML`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
|
||||
|
||||
if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) {
|
||||
const newCalendarEvent = await createCalendarEvent(
|
||||
context.state.user.id,
|
||||
calendarId,
|
||||
partialCalendarEvent.title,
|
||||
new Date(partialCalendarEvent.start_date),
|
||||
new Date(partialCalendarEvent.end_date),
|
||||
partialCalendarEvent.is_all_day,
|
||||
);
|
||||
|
||||
const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {});
|
||||
|
||||
if (parsedExtra !== '{}') {
|
||||
newCalendarEvent.extra = partialCalendarEvent.extra!;
|
||||
|
||||
if (
|
||||
newCalendarEvent.extra.is_recurring && newCalendarEvent.extra.recurring_sequence === 0 &&
|
||||
!newCalendarEvent.extra.recurring_id
|
||||
) {
|
||||
newCalendarEvent.extra.recurring_id = newCalendarEvent.id;
|
||||
}
|
||||
|
||||
await updateCalendarEvent(newCalendarEvent);
|
||||
}
|
||||
|
||||
const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id);
|
||||
|
||||
return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
const calendarEvents = await getCalendarEvents(context.state.user.id, [calendar.id]);
|
||||
|
||||
if (request.method === 'GET') {
|
||||
const response = new Response(formatCalendarEventsToVCalendar(calendarEvents, [calendar]), {
|
||||
status: 200,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
}
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
const includeVCalendar = requestBody.includes('calendar-data');
|
||||
|
||||
const parsedCalendar: DavResponse = {
|
||||
'd:href': `/dav/calendars/${calendar.id}`,
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:displayname': calendar.name,
|
||||
'd:getlastmodified': buildRFC822Date(calendar.updated_at.toISOString()),
|
||||
'd:getetag': escapeHtml(`"${calendar.revision}"`),
|
||||
'cs:getctag': calendar.revision,
|
||||
'd:getcontenttype': 'text/calendar; charset=utf-8',
|
||||
'd:resourcetype': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}],
|
||||
};
|
||||
|
||||
const parsedCalendarEvents = calendarEvents.map((calendarEvent) => {
|
||||
const parsedCalendarEvent: DavResponse = {
|
||||
'd:href': `/dav/calendars/${calendar!.id}/${calendarEvent.id}.ics`,
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:getlastmodified': buildRFC822Date(calendarEvent.updated_at.toISOString()),
|
||||
'd:getetag': escapeHtml(`"${calendarEvent.revision}"`),
|
||||
'cs:getctag': calendarEvent.revision,
|
||||
'd:getcontenttype': 'text/calendar; charset=utf-8',
|
||||
'd:resourcetype': {},
|
||||
'd:creationdate': buildRFC822Date(calendarEvent.created_at.toISOString()),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': {
|
||||
'd:displayname': {},
|
||||
'd:getcontentlength': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
};
|
||||
|
||||
if (includeVCalendar) {
|
||||
parsedCalendarEvent['d:propstat'][0]['d:prop']['cal:calendar-data'] = escapeXml(
|
||||
formatCalendarEventsToVCalendar([calendarEvent], [calendar!]),
|
||||
);
|
||||
}
|
||||
|
||||
return parsedCalendarEvent;
|
||||
});
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [parsedCalendar, ...parsedCalendarEvents],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { Calendar, CalendarEvent, FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml, escapeXml } from '/lib/utils/misc.ts';
|
||||
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
||||
import {
|
||||
createCalendarEvent,
|
||||
deleteCalendarEvent,
|
||||
getCalendar,
|
||||
getCalendarEvent,
|
||||
updateCalendarEvent,
|
||||
} from '/lib/data/calendar.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype'?: {
|
||||
'd:collection'?: {};
|
||||
'cal:calendar'?: {};
|
||||
};
|
||||
'd:displayname'?: string | {};
|
||||
'cal:calendar-data'?: string;
|
||||
'd:getlastmodified'?: string;
|
||||
'd:getetag'?: string;
|
||||
'cs:getctag'?: string;
|
||||
'd:getcontenttype'?: string;
|
||||
'd:getcontentlength'?: {};
|
||||
'd:creationdate'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:cal': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
'xmlns:cs': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method !== 'PROPFIND' && request.method !== 'REPORT' && request.method !== 'GET' &&
|
||||
request.method !== 'PUT' && request.method !== 'DELETE'
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const { calendarId, calendarEventId } = context.params;
|
||||
|
||||
let calendar: Calendar | null = null;
|
||||
let calendarEvent: CalendarEvent | null = null;
|
||||
|
||||
try {
|
||||
calendar = await getCalendar(calendarId, context.state.user.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!calendar) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
if (!calendarEvent) {
|
||||
if (request.method === 'PUT') {
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
|
||||
|
||||
if (partialCalendarEvent.title && partialCalendarEvent.start_date && partialCalendarEvent.end_date) {
|
||||
const newCalendarEvent = await createCalendarEvent(
|
||||
context.state.user.id,
|
||||
calendarId,
|
||||
partialCalendarEvent.title,
|
||||
new Date(partialCalendarEvent.start_date),
|
||||
new Date(partialCalendarEvent.end_date),
|
||||
partialCalendarEvent.is_all_day,
|
||||
);
|
||||
|
||||
// Use the sent id for the UID
|
||||
if (!partialCalendarEvent.extra?.uid) {
|
||||
partialCalendarEvent.extra = {
|
||||
...(partialCalendarEvent.extra! || {}),
|
||||
uid: calendarEventId,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedExtra = JSON.stringify(partialCalendarEvent.extra || {});
|
||||
|
||||
if (parsedExtra !== '{}') {
|
||||
newCalendarEvent.extra = partialCalendarEvent.extra!;
|
||||
|
||||
if (
|
||||
newCalendarEvent.extra.is_recurring && newCalendarEvent.extra.recurring_sequence === 0 &&
|
||||
!newCalendarEvent.extra.recurring_id
|
||||
) {
|
||||
newCalendarEvent.extra.recurring_id = newCalendarEvent.id;
|
||||
}
|
||||
|
||||
await updateCalendarEvent(newCalendarEvent);
|
||||
}
|
||||
|
||||
const calendarEvent = await getCalendarEvent(newCalendarEvent.id, context.state.user.id);
|
||||
|
||||
return new Response('Created', { status: 201, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (request.method === 'DELETE') {
|
||||
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||
|
||||
// Don't update outdated data
|
||||
if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||
}
|
||||
|
||||
await deleteCalendarEvent(calendarEventId, calendarId, context.state.user.id);
|
||||
|
||||
return new Response(null, { status: 202 });
|
||||
}
|
||||
|
||||
if (request.method === 'PUT') {
|
||||
const clientRevision = request.headers.get('if-match') || request.headers.get('etag');
|
||||
|
||||
// Don't update outdated data
|
||||
if (clientRevision && clientRevision !== `"${calendarEvent.revision}"`) {
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().text();
|
||||
|
||||
const [partialCalendarEvent] = parseVCalendarFromTextContents(requestBody);
|
||||
|
||||
calendarEvent = {
|
||||
...calendarEvent,
|
||||
...partialCalendarEvent,
|
||||
};
|
||||
|
||||
await updateCalendarEvent(calendarEvent);
|
||||
|
||||
calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id);
|
||||
|
||||
return new Response(null, { status: 204, headers: { 'etag': `"${calendarEvent.revision}"` } });
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
// Set a UID if there isn't one
|
||||
if (!calendarEvent.extra.uid) {
|
||||
calendarEvent.extra.uid = crypto.randomUUID();
|
||||
await updateCalendarEvent(calendarEvent);
|
||||
|
||||
calendarEvent = await getCalendarEvent(calendarEventId, context.state.user.id);
|
||||
}
|
||||
|
||||
const response = new Response(formatCalendarEventsToVCalendar([calendarEvent], [calendar]), {
|
||||
status: 200,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
}
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
const includeVCalendar = requestBody.includes('calendar-data');
|
||||
|
||||
const parsedCalendarEvent: DavResponse = {
|
||||
'd:href': `/dav/calendars/${calendar!.id}/${calendarEvent.id}.ics`,
|
||||
'd:propstat': [{
|
||||
'd:prop': {
|
||||
'd:getlastmodified': buildRFC822Date(calendarEvent.updated_at.toISOString()),
|
||||
'd:getetag': escapeHtml(`"${calendarEvent.revision}"`),
|
||||
'cs:getctag': calendarEvent.revision,
|
||||
'd:getcontenttype': 'text/calendar; charset=utf-8',
|
||||
'd:resourcetype': {},
|
||||
'd:creationdate': buildRFC822Date(calendarEvent.created_at.toISOString()),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
}, {
|
||||
'd:prop': {
|
||||
'd:displayname': {},
|
||||
'd:getcontentlength': {},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 404 Not Found',
|
||||
}],
|
||||
};
|
||||
|
||||
if (includeVCalendar) {
|
||||
parsedCalendarEvent['d:propstat'][0]['d:prop']['cal:calendar-data'] = escapeXml(
|
||||
formatCalendarEventsToVCalendar([calendarEvent], [calendar!]),
|
||||
);
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [parsedCalendarEvent],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection'?: {};
|
||||
};
|
||||
'd:getlastmodified': string;
|
||||
'd:getetag': string;
|
||||
'd:getcontentlength'?: number;
|
||||
'd:getcontenttype'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string };
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: List directories and files in root
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
{
|
||||
'd:href': '/dav/files/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:getlastmodified': buildRFC822Date('2020-01-01'),
|
||||
'd:getetag': escapeHtml(`"fake"`),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { buildRFC822Date, convertObjectToDavXml, DAV_RESPONSE_HEADER, escapeHtml } from '/lib/utils/misc.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {};
|
||||
};
|
||||
'd:current-user-principal'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:getlastmodified'?: string;
|
||||
'd:getetag'?: string;
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': { 'xmlns:d': string; 'xmlns:s': string; 'xmlns:oc': string; 'xmlns:nc': string };
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [
|
||||
{
|
||||
'd:href': '/dav/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
{
|
||||
'd:href': '/dav/addressbooks/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
{
|
||||
'd:href': '/dav/files/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
'd:getlastmodified': buildRFC822Date('2020-01-01'),
|
||||
'd:getetag': escapeHtml(`"fake"`),
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Handler } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { convertObjectToDavXml, DAV_RESPONSE_HEADER } from '/lib/utils/misc.ts';
|
||||
import { createSessionCookie } from '/lib/auth.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
interface DavResponse {
|
||||
'd:href': string;
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection'?: {};
|
||||
'd:principal': {};
|
||||
};
|
||||
'd:displayname'?: string;
|
||||
'card:addressbook-home-set'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'cal:calendar-home-set'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:current-user-principal'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
'd:principal-URL'?: {
|
||||
'd:href': string;
|
||||
};
|
||||
};
|
||||
'd:status': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DavMultiStatusResponse {
|
||||
'd:multistatus': {
|
||||
'd:response': DavResponse[];
|
||||
};
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': string;
|
||||
'xmlns:s': string;
|
||||
'xmlns:cal': string;
|
||||
'xmlns:cs': string;
|
||||
'xmlns:card': string;
|
||||
'xmlns:oc': string;
|
||||
'xmlns:nc': string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ResponseBody extends DavMultiStatusResponse {}
|
||||
|
||||
export const handler: Handler<Data, FreshContextState> = async (request, context) => {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', {
|
||||
status: 401,
|
||||
headers: { 'www-authenticate': 'Basic realm="bewCloud", charset="UTF-8"' },
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === 'GET') {
|
||||
return new Response('This is the WebDAV interface. It can only be accessed by WebDAV clients.');
|
||||
}
|
||||
|
||||
if (request.method !== 'PROPFIND' && request.method !== 'REPORT') {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const responseBody: ResponseBody = {
|
||||
'd:multistatus': {
|
||||
'd:response': [],
|
||||
},
|
||||
'd:multistatus_attributes': {
|
||||
'xmlns:d': 'DAV:',
|
||||
'xmlns:s': 'http://sabredav.org/ns',
|
||||
'xmlns:cal': 'urn:ietf:params:xml:ns:caldav',
|
||||
'xmlns:cs': 'http://calendarserver.org/ns/',
|
||||
'xmlns:card': 'urn:ietf:params:xml:ns:carddav',
|
||||
'xmlns:oc': 'http://owncloud.org/ns',
|
||||
'xmlns:nc': 'http://nextcloud.org/ns',
|
||||
},
|
||||
};
|
||||
|
||||
if (request.method === 'PROPFIND') {
|
||||
const propResponse: DavResponse = {
|
||||
'd:href': '/dav/principals/',
|
||||
'd:propstat': {
|
||||
'd:prop': {
|
||||
'd:resourcetype': {
|
||||
'd:collection': {},
|
||||
'd:principal': {},
|
||||
},
|
||||
'd:current-user-principal': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
'd:principal-URL': {
|
||||
'd:href': '/dav/principals/',
|
||||
},
|
||||
},
|
||||
'd:status': 'HTTP/1.1 200 OK',
|
||||
},
|
||||
};
|
||||
|
||||
const requestBody = (await request.clone().text()).toLowerCase();
|
||||
|
||||
if (requestBody.includes('displayname')) {
|
||||
propResponse['d:propstat']['d:prop']['d:displayname'] = `${context.state.user.email}`;
|
||||
}
|
||||
|
||||
if (requestBody.includes('addressbook-home-set')) {
|
||||
propResponse['d:propstat']['d:prop']['card:addressbook-home-set'] = {
|
||||
'd:href': `/dav/addressbooks/`,
|
||||
};
|
||||
}
|
||||
|
||||
if (requestBody.includes('calendar-home-set')) {
|
||||
propResponse['d:propstat']['d:prop']['cal:calendar-home-set'] = {
|
||||
'd:href': `/dav/calendars/`,
|
||||
};
|
||||
}
|
||||
|
||||
responseBody['d:multistatus']['d:response'].push(propResponse);
|
||||
}
|
||||
|
||||
const response = new Response(convertObjectToDavXml(responseBody, true), {
|
||||
headers: {
|
||||
'content-type': 'application/xml; charset=utf-8',
|
||||
'dav': DAV_RESPONSE_HEADER,
|
||||
},
|
||||
status: 207,
|
||||
});
|
||||
|
||||
if (context.state.session) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return createSessionCookie(request, context.state.user, response, true);
|
||||
};
|
||||
51
routes/files.tsx
Normal file
51
routes/files.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
|
||||
import { getDirectories, getFiles } from '/lib/data/files.ts';
|
||||
import FilesWrapper from '/islands/files/FilesWrapper.tsx';
|
||||
|
||||
interface Data {
|
||||
userDirectories: Directory[];
|
||||
userFiles: DirectoryFile[];
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
let currentPath = searchParams.get('path') || '/';
|
||||
|
||||
// Send invalid paths back to root
|
||||
if (!currentPath.startsWith('/') || currentPath.includes('../')) {
|
||||
currentPath = '/';
|
||||
}
|
||||
|
||||
// Always append a trailing slash
|
||||
if (!currentPath.endsWith('/')) {
|
||||
currentPath = `${currentPath}/`;
|
||||
}
|
||||
|
||||
const userDirectories = await getDirectories(context.state.user.id, currentPath);
|
||||
|
||||
const userFiles = await getFiles(context.state.user.id, currentPath);
|
||||
|
||||
return await context.render({ userDirectories, userFiles, currentPath });
|
||||
},
|
||||
};
|
||||
|
||||
export default function FilesPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<FilesWrapper
|
||||
initialDirectories={data.userDirectories}
|
||||
initialFiles={data.userFiles}
|
||||
initialPath={data.currentPath}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
47
routes/files/open/[fileName].tsx
Normal file
47
routes/files/open/[fileName].tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { getFile } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const { fileName } = context.params;
|
||||
|
||||
if (!fileName) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
let currentPath = searchParams.get('path') || '/';
|
||||
|
||||
// Send invalid paths back to root
|
||||
if (!currentPath.startsWith('/') || currentPath.includes('../')) {
|
||||
currentPath = '/';
|
||||
}
|
||||
|
||||
// Always append a trailing slash
|
||||
if (!currentPath.endsWith('/')) {
|
||||
currentPath = `${currentPath}/`;
|
||||
}
|
||||
|
||||
// TODO: Verify user has read or write access to path/file and get the appropriate ownerUserId
|
||||
|
||||
const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName));
|
||||
|
||||
if (!fileResult.success) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(fileResult.contents!, {
|
||||
status: 200,
|
||||
headers: { 'cache-control': 'no-cache, no-store, must-revalidate', 'content-type': fileResult.contentType! },
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
// Nextcloud/ownCloud mimicry
|
||||
export function handler(): Response {
|
||||
return new Response('Redirecting...', {
|
||||
status: 307,
|
||||
headers: { Location: '/dav' },
|
||||
});
|
||||
}
|
||||
1
static/images/directory.svg
Normal file
1
static/images/directory.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M440 432H72a40 40 0 0 1-40-40V120a40 40 0 0 1 40-40h75.89a40 40 0 0 1 22.19 6.72l27.84 18.56a40 40 0 0 0 22.19 6.72H440a40 40 0 0 1 40 40v240a40 40 0 0 1-40 40ZM32 192h448"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
1
static/images/file.svg
Normal file
1
static/images/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="32" d="M416 221.25V416a48 48 0 0 1-48 48H144a48 48 0 0 1-48-48V96a48 48 0 0 1 48-48h98.75a32 32 0 0 1 22.62 9.37l141.26 141.26a32 32 0 0 1 9.37 22.62Z"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M256 56v120a32 32 0 0 0 32 32h120"/></svg>
|
||||
|
After Width: | Height: | Size: 466 B |
1
static/images/move.svg
Normal file
1
static/images/move.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="m268 112l144 144l-144 144m124-144H100"/></svg>
|
||||
|
After Width: | Height: | Size: 239 B |
1
static/images/rename.svg
Normal file
1
static/images/rename.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m32 415.5l120-320l120 320m-42-112H74m252-64c12.19-28.69 41-48 74-48h0c46 0 80 32 80 80v144"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M320 358.5c0 36 26.86 58 60 58c54 0 100-27 100-106v-15c-20 0-58 1-92 5c-32.77 3.86-68 19-68 58Z"/></svg>
|
||||
|
After Width: | Height: | Size: 498 B |
1
static/images/share.svg
Normal file
1
static/images/share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M336 192h40a40 40 0 0 1 40 40v192a40 40 0 0 1-40 40H136a40 40 0 0 1-40-40V232a40 40 0 0 1 40-40h40m160-64l-80-80l-80 80m80 193V48"/></svg>
|
||||
|
After Width: | Height: | Size: 331 B |
1
static/images/trash.svg
Normal file
1
static/images/trash.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="m112 112l20 320c.95 18.49 14.4 32 32 32h184c17.67 0 30.87-13.51 32-32l20-320"/><path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-miterlimit="10" stroke-width="32" d="M80 112h352"/><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M192 112V72h0a23.93 23.93 0 0 1 24-24h80a23.93 23.93 0 0 1 24 24h0v40m-64 64v224m-72-224l8 224m136-224l-8 224"/></svg>
|
||||
|
After Width: | Height: | Size: 627 B |
@@ -73,8 +73,3 @@ img.red {
|
||||
img.gray {
|
||||
filter: invert(30%) sepia(46%) saturate(356%) hue-rotate(174deg) brightness(90%) contrast(82%);
|
||||
}
|
||||
|
||||
.calendar-week-view-days {
|
||||
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
grid-template-rows: 0.5fr 1fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 0.5fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user