Files CRUD.

Remove Contacts and Calendar + CardDav and CalDav.
This commit is contained in:
Bruno Bernardino
2024-04-03 14:02:04 +01:00
parent c4788761d2
commit 4e5fdd569a
89 changed files with 2302 additions and 8001 deletions

View File

@@ -14,4 +14,5 @@ PASSWORD_SALT="fake"
BREVO_API_KEY="fake" BREVO_API_KEY="fake"
CONFIG_ALLOW_SIGNUPS="false" 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
View File

@@ -9,3 +9,6 @@ db/
# Env var # Env var
.env .env
# Files
data-files/

View File

@@ -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/). 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! ## Self-host it!
Check the [Development section below](#development). 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 ## Requirements
@@ -55,14 +60,20 @@ Just push to the `main` branch.
- [x] Dashboard with URLs and Notes - [x] Dashboard with URLs and Notes
- [x] News - [x] News
- [x] Contacts / CardDav - [ ] Files UI
- [ ] Calendar / CalDav - [ ] Notes UI
- [ ] Tasks / CalDav - [ ] Photos UI
- [ ] Files / WebDav - [ ] Desktop app for selective file sync (WebDav or potentially just `rclone` or `rsync`)
- [ ] Notes / WebDav
- [ ] Photos / WebDav
- [ ] Desktop app for selective file sync (or potentially just `rclone`)
- [ ] Mobile app for offline file sync - [ ] Mobile app for offline file sync
- [ ] Add notes support for mobile app - [ ] Add notes support for mobile app
- [ ] Add photos/sync support for mobile client - [ ] Add photos/sync support for mobile client
- [ ] Address `TODO:`s in code - [ ] 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).

View File

@@ -31,18 +31,6 @@ export default function Header({ route, user }: Data) {
url: '/news', url: '/news',
label: 'News', label: 'News',
}, },
{
url: '/contacts',
label: 'Contacts',
},
{
url: '/calendar',
label: 'Calendar',
},
{
url: '/tasks',
label: 'Tasks',
},
{ {
url: '/files', url: '/files',
label: 'Files', label: 'Files',
@@ -70,10 +58,6 @@ export default function Header({ route, user }: Data) {
pageLabel = 'Settings'; pageLabel = 'Settings';
} }
if (route.startsWith('/calendars')) {
pageLabel = 'Calendars';
}
return ( return (
<> <>
<Head> <Head>

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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 ? <>&nbsp;</> : 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 };
}}
/>
</>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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 ? <>&nbsp;</> : 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}
/>
</>
);
}

View 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 ? <>&nbsp;</> : 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>
</>
);
}

View 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>
</>
);
}

View 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}
</>
);
}

View File

@@ -1,5 +1,4 @@
import Database, { sql } from '/lib/interfaces/database.ts'; import Database, { sql } from '/lib/interfaces/database.ts';
import Locker from '/lib/interfaces/locker.ts';
import { NewsFeed } from '/lib/types.ts'; import { NewsFeed } from '/lib/types.ts';
import { concurrentPromises } from '/lib/utils/misc.ts'; import { concurrentPromises } from '/lib/utils/misc.ts';
import { crawlNewsFeed } from '/lib/data/news.ts'; import { crawlNewsFeed } from '/lib/data/news.ts';
@@ -9,10 +8,6 @@ const db = new Database();
export async function fetchNewArticles(forceFetch = false) { export async function fetchNewArticles(forceFetch = false) {
const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4)); const fourHoursAgo = forceFetch ? new Date() : new Date(new Date().setUTCHours(new Date().getUTCHours() - 4));
const lock = new Locker(`feeds`);
await lock.acquire();
try { try {
const feedsToCrawl = await db.query<NewsFeed>( const feedsToCrawl = await db.query<NewsFeed>(
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "last_crawled_at" IS NULL OR "last_crawled_at" <= $1`, 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) { } catch (error) {
console.log(error); console.log(error);
} }
lock.release();
} }

View File

@@ -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;

View File

@@ -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;

View File

@@ -2,124 +2,80 @@
// This file SHOULD be checked into source version control. // This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`. // 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 $_404 from './routes/_404.tsx';
import * as $_app from './routes/_app.tsx'; import * as $_app from './routes/_app.tsx';
import * as $_middleware from './routes/_middleware.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_links from './routes/api/dashboard/save-links.tsx';
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.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_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_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_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_mark_read from './routes/api/news/mark-read.tsx';
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.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 $dashboard from './routes/dashboard.tsx';
import * as $dav_addressbooks from './routes/dav/addressbooks.tsx'; import * as $files from './routes/files.tsx';
import * as $dav_addressbooks_contacts from './routes/dav/addressbooks/contacts.tsx'; import * as $files_open_fileName_ from './routes/files/open/[fileName].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 $index from './routes/index.tsx'; import * as $index from './routes/index.tsx';
import * as $login from './routes/login.tsx'; import * as $login from './routes/login.tsx';
import * as $logout from './routes/logout.tsx'; import * as $logout from './routes/logout.tsx';
import * as $news from './routes/news.tsx'; import * as $news from './routes/news.tsx';
import * as $news_feeds from './routes/news/feeds.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 $settings from './routes/settings.tsx';
import * as $signup from './routes/signup.tsx'; import * as $signup from './routes/signup.tsx';
import * as $Settings from './islands/Settings.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_Links from './islands/dashboard/Links.tsx';
import * as $dashboard_Notes from './islands/dashboard/Notes.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_Articles from './islands/news/Articles.tsx';
import * as $news_Feeds from './islands/news/Feeds.tsx'; import * as $news_Feeds from './islands/news/Feeds.tsx';
import { type Manifest } from '$fresh/server.ts'; import { type Manifest } from '$fresh/server.ts';
const manifest = { const manifest = {
routes: { routes: {
'./routes/.well-known/caldav.tsx': $_well_known_caldav,
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
'./routes/_404.tsx': $_404, './routes/_404.tsx': $_404,
'./routes/_app.tsx': $_app, './routes/_app.tsx': $_app,
'./routes/_middleware.tsx': $_middleware, './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-links.tsx': $api_dashboard_save_links,
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes, './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/add-feed.tsx': $api_news_add_feed,
'./routes/api/news/delete-feed.tsx': $api_news_delete_feed, './routes/api/news/delete-feed.tsx': $api_news_delete_feed,
'./routes/api/news/import-feeds.tsx': $api_news_import_feeds, './routes/api/news/import-feeds.tsx': $api_news_import_feeds,
'./routes/api/news/mark-read.tsx': $api_news_mark_read, './routes/api/news/mark-read.tsx': $api_news_mark_read,
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles, './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/dashboard.tsx': $dashboard,
'./routes/dav/addressbooks.tsx': $dav_addressbooks, './routes/files.tsx': $files,
'./routes/dav/addressbooks/contacts.tsx': $dav_addressbooks_contacts, './routes/files/open/[fileName].tsx': $files_open_fileName_,
'./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/index.tsx': $index, './routes/index.tsx': $index,
'./routes/login.tsx': $login, './routes/login.tsx': $login,
'./routes/logout.tsx': $logout, './routes/logout.tsx': $logout,
'./routes/news.tsx': $news, './routes/news.tsx': $news,
'./routes/news/feeds.tsx': $news_feeds, './routes/news/feeds.tsx': $news_feeds,
'./routes/remote.php/[davRoute].tsx': $remote_php_davRoute_,
'./routes/settings.tsx': $settings, './routes/settings.tsx': $settings,
'./routes/signup.tsx': $signup, './routes/signup.tsx': $signup,
}, },
islands: { islands: {
'./islands/Settings.tsx': $Settings, './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/Links.tsx': $dashboard_Links,
'./islands/dashboard/Notes.tsx': $dashboard_Notes, './islands/dashboard/Notes.tsx': $dashboard_Notes,
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
'./islands/news/Articles.tsx': $news_Articles, './islands/news/Articles.tsx': $news_Articles,
'./islands/news/Feeds.tsx': $news_Feeds, './islands/news/Feeds.tsx': $news_Feeds,
}, },

View File

@@ -2,8 +2,6 @@
"imports": { "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/",
"$fresh/": "https://deno.land/x/fresh@1.6.8/", "$fresh/": "https://deno.land/x/fresh@1.6.8/",

View File

@@ -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}
/>
);
}

View File

@@ -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 ? <>&nbsp;</> : null}
</span>
</section>
<section
class={`fixed ${
openCalendar.value ? 'block' : 'hidden'
} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
>
</section>
<section
class={`fixed ${
openCalendar.value ? 'block' : 'hidden'
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
>
<h1 class='text-2xl font-semibold my-5'>Edit Calendar</h1>
<section class='py-5 my-2 border-y border-slate-500'>
<fieldset class='block mb-2'>
<label class='text-slate-300 block pb-1' for='calendar_name'>Name</label>
<input
class='input-field'
type='text'
name='calendar_name'
id='calendar_name'
value={openCalendar.value?.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>
</>
);
}

View File

@@ -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 ? <>&nbsp;</> : null}
</span>
</section>
</>
);
}

View File

@@ -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 ? <>&nbsp;</> : 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>
</>
);
}

View File

@@ -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 ? <>&nbsp;</> : null}
</span>
</section>
</>
);
}

View 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}
/>
);
}

View File

@@ -13,3 +13,11 @@ export async function isSignupAllowed() {
return false; return false;
} }
export function getFilesRootPath() {
const configRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH');
const filesRootPath = `${Deno.cwd()}/${configRootPath}`;
return filesRootPath;
}

View File

@@ -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;
}

View File

@@ -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
View 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,
};
}
}

View File

@@ -1,6 +1,7 @@
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts'; import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
import Database, { sql } from '/lib/interfaces/database.ts'; import Database, { sql } from '/lib/interfaces/database.ts';
import Locker from '/lib/interfaces/locker.ts';
import { NewsFeed, NewsFeedArticle } from '/lib/types.ts'; import { NewsFeed, NewsFeedArticle } from '/lib/types.ts';
import { import {
findFeedInUrl, findFeedInUrl,
@@ -211,8 +212,11 @@ type JsonFeedArticle = JsonFeed['items'][number];
const MAX_ARTICLES_CRAWLED_PER_RUN = 10; const MAX_ARTICLES_CRAWLED_PER_RUN = 10;
export async function crawlNewsFeed(newsFeed: NewsFeed) { 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) { if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
const feedUrl = await findFeedInUrl(newsFeed.feed_url); const feedUrl = await findFeedInUrl(newsFeed.feed_url);
@@ -297,4 +301,9 @@ export async function crawlNewsFeed(newsFeed: NewsFeed) {
newsFeed.last_crawled_at = new Date(); newsFeed.last_crawled_at = new Date();
await updateNewsFeed(newsFeed); await updateNewsFeed(newsFeed);
} catch (error) {
lock.release();
throw error;
}
} }

View File

@@ -283,14 +283,3 @@ export async function validateVerificationCode(
throw new Error('Not Found'); 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);
}

View File

@@ -12,8 +12,6 @@ export interface User {
is_email_verified: boolean; is_email_verified: boolean;
is_admin?: boolean; is_admin?: boolean;
dav_hashed_password?: string; dav_hashed_password?: string;
contacts_revision?: string;
contacts_updated_at?: string;
}; };
created_at: Date; created_at: Date;
} }
@@ -87,109 +85,45 @@ export interface NewsFeedArticle {
created_at: Date; 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 DirectoryOrFileShareLink {
export interface Contact { url: string;
hashed_password: string;
}
export interface FileShare {
id: string; id: string;
user_id: string; owner_user_id: string;
revision: string; parent_path: 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 {
name: string; name: string;
value: string; type: 'directory' | 'file';
type: ContactFieldType; user_ids_with_read_access: string[];
} user_ids_with_write_access: string[];
export interface Calendar {
id: string;
user_id: string;
revision: string;
name: string;
color: string;
is_visible: boolean;
extra: { extra: {
shared_read_user_ids?: string[]; read_share_links: DirectoryOrFileShareLink[];
shared_write_user_ids?: string[]; write_share_links: DirectoryOrFileShareLink[];
default_transparency: 'opaque' | 'transparent';
calendar_timezone?: string;
}; };
updated_at: Date; updated_at: Date;
created_at: Date; created_at: Date;
} }
export interface CalendarEvent { export interface Directory {
id: string; owner_user_id: string;
user_id: string; parent_path: string;
calendar_id: string; directory_name: string;
revision: string; has_write_access: boolean;
title: string; file_share?: FileShare;
start_date: Date; size_in_bytes: number;
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[];
};
updated_at: Date; updated_at: Date;
created_at: Date; created_at: Date;
} }
export interface CalendarEventAttendee { export interface DirectoryFile {
email: string; owner_user_id: string;
status: 'accepted' | 'rejected' | 'invited'; parent_path: string;
name?: string; file_name: string;
} has_write_access: boolean;
file_share?: FileShare;
export interface CalendarEventReminder { size_in_bytes: number;
uid?: string; updated_at: Date;
start_date: string; created_at: Date;
type: 'email' | 'sound' | 'display';
acknowledged_at?: string;
description?: string;
} }

View File

@@ -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(' ');
}

View File

@@ -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
View 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
View 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);
}
});

View File

@@ -10,15 +10,9 @@ if (typeof Deno !== 'undefined') {
export const baseUrl = BASE_URL || 'http://localhost:8000'; export const baseUrl = BASE_URL || 'http://localhost:8000';
export const defaultTitle = 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud'; 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 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) { export function isRunningLocally(request: Request) {
return request.url.includes('localhost'); return request.url.includes('localhost');
} }

View File

@@ -1,7 +0,0 @@
// Nextcloud/ownCloud mimicry
export function handler(): Response {
return new Response('Redirecting...', {
status: 307,
headers: { Location: '/dav' },
});
}

View File

@@ -1,7 +0,0 @@
// Nextcloud/ownCloud mimicry
export function handler(): Response {
return new Response('Redirecting...', {
status: 307,
headers: { Location: '/dav' },
});
}

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View File

@@ -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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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
View 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));
},
};

View 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));
},
};

View 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));
},
};

View 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));
},
};

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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
View 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>
);
}

View 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! },
});
},
};

View File

@@ -1,7 +0,0 @@
// Nextcloud/ownCloud mimicry
export function handler(): Response {
return new Response('Redirecting...', {
status: 307,
headers: { Location: '/dav' },
});
}

View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -73,8 +73,3 @@ img.red {
img.gray { img.gray {
filter: invert(30%) sepia(46%) saturate(356%) hue-rotate(174deg) brightness(90%) contrast(82%); 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;
}