Files CRUD.
Remove Contacts and Calendar + CardDav and CalDav.
This commit is contained in:
@@ -31,18 +31,6 @@ export default function Header({ route, user }: Data) {
|
||||
url: '/news',
|
||||
label: 'News',
|
||||
},
|
||||
{
|
||||
url: '/contacts',
|
||||
label: 'Contacts',
|
||||
},
|
||||
{
|
||||
url: '/calendar',
|
||||
label: 'Calendar',
|
||||
},
|
||||
{
|
||||
url: '/tasks',
|
||||
label: 'Tasks',
|
||||
},
|
||||
{
|
||||
url: '/files',
|
||||
label: 'Files',
|
||||
@@ -70,10 +58,6 @@ export default function Header({ route, user }: Data) {
|
||||
pageLabel = 'Settings';
|
||||
}
|
||||
|
||||
if (route.startsWith('/calendars')) {
|
||||
pageLabel = 'Calendars';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
|
||||
export type NewCalendarEvent = Pick<
|
||||
CalendarEvent,
|
||||
'id' | 'calendar_id' | 'title' | 'start_date' | 'end_date' | 'is_all_day'
|
||||
>;
|
||||
|
||||
interface AddEventModalProps {
|
||||
isOpen: boolean;
|
||||
initialStartDate?: Date;
|
||||
initiallyAllDay?: boolean;
|
||||
calendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||
onClickSave: (newEvent: NewCalendarEvent) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddEventModal(
|
||||
{ isOpen, initialStartDate, initiallyAllDay, calendars, onClickSave, onClose }: AddEventModalProps,
|
||||
) {
|
||||
const newEvent = useSignal<NewCalendarEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
newEvent.value = null;
|
||||
} else {
|
||||
const startDate = new Date(initialStartDate || new Date());
|
||||
|
||||
startDate.setMinutes(0);
|
||||
startDate.setSeconds(0);
|
||||
startDate.setMilliseconds(0);
|
||||
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setHours(startDate.getHours() + 1);
|
||||
|
||||
if (initiallyAllDay) {
|
||||
startDate.setHours(9);
|
||||
endDate.setHours(18);
|
||||
}
|
||||
|
||||
newEvent.value = {
|
||||
id: 'new',
|
||||
title: '',
|
||||
calendar_id: calendars[0]!.id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
is_all_day: initiallyAllDay || false,
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
newEvent.value ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>New Event</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_title'>Title</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='event_title'
|
||||
id='event_title'
|
||||
value={newEvent.value?.title || ''}
|
||||
onInput={(event) => newEvent.value = { ...newEvent.value!, title: event.currentTarget.value }}
|
||||
placeholder='Dentist'
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 !w-5/6'
|
||||
name='event_calendar'
|
||||
id='event_calendar'
|
||||
value={newEvent.value?.calendar_id || ''}
|
||||
onChange={(event) => newEvent.value = { ...newEvent.value!, calendar_id: event.currentTarget.value }}
|
||||
>
|
||||
{calendars.map((calendar) => <option value={calendar.id}>{calendar.name}</option>)}
|
||||
</select>
|
||||
<span
|
||||
class={`w-5 h-5 block ${
|
||||
calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color
|
||||
} rounded-full`}
|
||||
title={calendars.find((calendar) => calendar.id === newEvent.value?.calendar_id)?.color}
|
||||
>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_start_date'>Start date</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='datetime-local'
|
||||
name='event_start_date'
|
||||
id='event_start_date'
|
||||
value={newEvent.value?.start_date
|
||||
? new Date(newEvent.value.start_date).toISOString().substring(0, 16)
|
||||
: ''}
|
||||
onInput={(event) =>
|
||||
newEvent.value = { ...newEvent.value!, start_date: new Date(event.currentTarget.value) }}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_end_date'>End date</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='datetime-local'
|
||||
name='event_end_date'
|
||||
id='event_end_date'
|
||||
value={newEvent.value?.end_date ? new Date(newEvent.value.end_date).toISOString().substring(0, 16) : ''}
|
||||
onInput={(event) =>
|
||||
newEvent.value = { ...newEvent.value!, end_date: new Date(event.currentTarget.value) }}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_is_all_day'>All-day?</label>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='event_is_all_day'
|
||||
id='event_is_all_day'
|
||||
value='true'
|
||||
checked={newEvent.value?.is_all_day}
|
||||
onChange={(event) => newEvent.value = { ...newEvent.value!, is_all_day: event.currentTarget.checked }}
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newEvent.value!)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { getCalendarEventColor } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewDayProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'extra'>[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function CalendarViewDay(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewDayProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const dayFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||
if (!calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDayDate = new Date(startDate);
|
||||
const endDayDate = new Date(startDate);
|
||||
endDayDate.setHours(23);
|
||||
endDayDate.setMinutes(59);
|
||||
endDayDate.setSeconds(59);
|
||||
endDayDate.setMilliseconds(999);
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const hours: { date: Date; isCurrentHour: boolean }[] = Array.from({ length: 24 }).map((_, index) => {
|
||||
const hourNumber = index;
|
||||
|
||||
const date = new Date(startDate);
|
||||
date.setHours(hourNumber);
|
||||
|
||||
const shortIsoDate = date.toISOString().substring(0, 10);
|
||||
|
||||
const isCurrentHour = shortIsoDate === today && new Date().getHours() === hourNumber;
|
||||
|
||||
return {
|
||||
date,
|
||||
isCurrentHour,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='border-b border-slate-500 bg-slate-700 text-center text-base font-semibold text-white flex-none rounded-t-md'>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-t-md'>
|
||||
<span>{dayFormat.format(startDate)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class='flex bg-slate-500 text-sm text-white flex-auto rounded-b-md'>
|
||||
<section class='w-full rounded-b-md'>
|
||||
{allDayEvents.length > 0
|
||||
? (
|
||||
<section
|
||||
class={`relative bg-slate-700 min-h-16 px-3 py-2 text-slate-100 border-b border-b-slate-600`}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new all-day event'
|
||||
>
|
||||
All-day
|
||||
</time>
|
||||
<ol class='mt-2'>
|
||||
{allDayEvents.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(calendarEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{hours.map((hour, hourIndex) => {
|
||||
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||
|
||||
const startHourDate = new Date(shortIsoDate);
|
||||
startHourDate.setHours(hour.date.getHours());
|
||||
const endHourDate = new Date(shortIsoDate);
|
||||
endHourDate.setHours(hour.date.getHours());
|
||||
endHourDate.setMinutes(59);
|
||||
endHourDate.setSeconds(59);
|
||||
endHourDate.setMilliseconds(999);
|
||||
|
||||
const isLastHour = hourIndex === 23;
|
||||
|
||||
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||
if (calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
eventEndDate.setSeconds(eventEndDate.getSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||
|
||||
// Event starts and ends on this hour
|
||||
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this hour
|
||||
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this hour
|
||||
if (
|
||||
eventStartDate >= startHourDate && eventStartDate <= endHourDate && eventEndDate >= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this hour
|
||||
if (
|
||||
eventStartDate <= startHourDate && eventEndDate >= startHourDate && eventEndDate <= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} ${
|
||||
hourIndex <= 6 ? 'min-h-8' : 'min-h-16'
|
||||
} px-3 py-2 ${hour.isCurrentHour ? '' : 'text-slate-100'} ${
|
||||
isLastHour ? 'rounded-b-md' : ''
|
||||
} border-b border-b-slate-600`}
|
||||
>
|
||||
<time
|
||||
datetime={startHourDate.toISOString()}
|
||||
onClick={() => onClickAddEvent(startHourDate)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new event'
|
||||
>
|
||||
{hourFormat.format(startHourDate)}
|
||||
</time>
|
||||
{hourEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{hourEvents.map((hourEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(hourEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(hourEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(hourEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(hourEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{hourEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { getCalendarEventColor, getWeeksForMonth } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewWeekProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'extra'>[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function CalendarViewWeek(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
const weeks = getWeeksForMonth(new Date(startDate));
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='grid grid-cols-7 gap-px border-b border-slate-500 bg-slate-700 text-center text-xs font-semibold text-white flex-none rounded-t-md'>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-tl-md'>
|
||||
<span>Mon</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Tue</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Wed</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Thu</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Fri</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Sat</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-tr-md'>
|
||||
<span>Sun</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class='flex bg-slate-500 text-xs text-white flex-auto rounded-b-md'>
|
||||
<section class='w-full grid grid-cols-7 grid-rows-5 gap-px rounded-b-md'>
|
||||
{weeks.map((week, weekIndex) =>
|
||||
week.map((day, dayIndex) => {
|
||||
const shortIsoDate = day.date.toISOString().substring(0, 10);
|
||||
|
||||
const startDayDate = new Date(shortIsoDate);
|
||||
const endDayDate = new Date(shortIsoDate);
|
||||
endDayDate.setHours(23);
|
||||
endDayDate.setMinutes(59);
|
||||
endDayDate.setSeconds(59);
|
||||
endDayDate.setMilliseconds(999);
|
||||
|
||||
const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0;
|
||||
const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1;
|
||||
|
||||
const isToday = today === shortIsoDate;
|
||||
|
||||
const dayEvents = calendarEvents.filter((calendarEvent) => {
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${day.isSameMonth ? 'bg-slate-600' : 'bg-slate-700'} min-h-16 px-3 py-2 ${
|
||||
day.isSameMonth ? '' : 'text-slate-100'
|
||||
} ${isBottomLeftDay ? 'rounded-bl-md' : ''} ${isBottomRightDay ? 'rounded-br-md' : ''}`}
|
||||
>
|
||||
<time
|
||||
datetime={shortIsoDate}
|
||||
class={`cursor-pointer ${
|
||||
isToday ? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#51A4FB] font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickAddEvent(new Date(`${shortIsoDate}T09:00`))}
|
||||
title='Add a new event'
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</time>
|
||||
{dayEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{[...dayEvents].slice(0, 2).map((dayEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-1 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(dayEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(dayEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(dayEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(dayEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{dayEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{dayEvents.length > 2
|
||||
? (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/calendar/view=day&startDate=${shortIsoDate}`}
|
||||
class='flex bg-gray-700 px-2 py-1 rounded-md hover:no-underline hover:opacity-60'
|
||||
target='_blank'
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,216 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { getCalendarEventColor, getDaysForWeek } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewWeekProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'extra'>[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function CalendarViewWeek(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent }: CalendarViewWeekProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
const weekDayFormat = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: '2-digit' });
|
||||
|
||||
const days = getDaysForWeek(new Date(startDate));
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='w-full grid gap-px grid-flow-col rounded-md text-white text-xs bg-slate-600 calendar-week-view-days'>
|
||||
{days.map((day, dayIndex) => {
|
||||
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||
if (!calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDayDate = new Date(day.date);
|
||||
const endDayDate = new Date(day.date);
|
||||
endDayDate.setHours(23);
|
||||
endDayDate.setMinutes(59);
|
||||
endDayDate.setSeconds(59);
|
||||
endDayDate.setMilliseconds(999);
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isFirstDay = dayIndex === 0;
|
||||
const isLastDay = dayIndex === 6;
|
||||
const isToday = new Date(day.date).toISOString().substring(0, 10) === today;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`flex justify-center ${isToday ? 'bg-[#51A4FB]' : 'bg-gray-900'} py-2 ${
|
||||
isFirstDay ? 'rounded-tl-md' : ''
|
||||
} ${isLastDay ? 'rounded-tr-md' : ''} text-center text-xs font-semibold text-white`}
|
||||
>
|
||||
<span>{weekDayFormat.format(day.date)}</span>
|
||||
</section>
|
||||
<section
|
||||
class={`relative bg-slate-700 min-h-8 px-3 py-2 text-slate-100`}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new all-day event'
|
||||
>
|
||||
All-day
|
||||
</time>
|
||||
{allDayEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{allDayEvents.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(calendarEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
{day.hours.map((hour, hourIndex) => {
|
||||
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||
|
||||
const startHourDate = new Date(shortIsoDate);
|
||||
startHourDate.setHours(hour.date.getHours());
|
||||
const endHourDate = new Date(shortIsoDate);
|
||||
endHourDate.setHours(hour.date.getHours());
|
||||
endHourDate.setMinutes(59);
|
||||
endHourDate.setSeconds(59);
|
||||
endHourDate.setMilliseconds(999);
|
||||
|
||||
const isLastHourOfFirstDay = hourIndex === 23 && dayIndex === 0;
|
||||
const isLastHourOfLastDay = hourIndex === 23 && dayIndex === 6;
|
||||
|
||||
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||
if (calendarEvent.is_all_day) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.start_date);
|
||||
const eventEndDate = new Date(calendarEvent.end_date);
|
||||
eventEndDate.setSeconds(eventEndDate.getSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||
|
||||
// Event starts and ends on this hour
|
||||
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this hour
|
||||
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this hour
|
||||
if (
|
||||
eventStartDate >= startHourDate && eventStartDate <= endHourDate &&
|
||||
eventEndDate >= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this hour
|
||||
if (
|
||||
eventStartDate <= startHourDate && eventEndDate >= startHourDate &&
|
||||
eventEndDate <= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} min-h-8 px-3 py-2 ${
|
||||
hour.isCurrentHour ? '' : 'text-slate-100'
|
||||
} ${isLastHourOfFirstDay ? 'rounded-bl-md' : ''} ${isLastHourOfLastDay ? 'rounded-br-md' : ''}`}
|
||||
>
|
||||
<time
|
||||
datetime={startHourDate.toISOString()}
|
||||
onClick={() => onClickAddEvent(startHourDate)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new event'
|
||||
>
|
||||
{hourFormat.format(startHourDate)}
|
||||
</time>
|
||||
{hourEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{hourEvents.map((hourEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60 ${
|
||||
getCalendarEventColor(hourEvent, visibleCalendars)
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(hourEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(hourEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(hourEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{hourEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar } from '/lib/types.ts';
|
||||
|
||||
interface ImportEventsModalProps {
|
||||
isOpen: boolean;
|
||||
calendars: Pick<Calendar, 'id' | 'name' | 'color'>[];
|
||||
onClickImport: (calendarId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImportEventsModal(
|
||||
{ isOpen, calendars, onClickImport, onClose }: ImportEventsModalProps,
|
||||
) {
|
||||
const newCalendarId = useSignal<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
newCalendarId.value = null;
|
||||
} else {
|
||||
newCalendarId.value = calendars[0]!.id;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
newCalendarId.value ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Import Events</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 !w-5/6'
|
||||
name='event_calendar'
|
||||
id='event_calendar'
|
||||
value={newCalendarId.value || ''}
|
||||
onChange={(event) => {
|
||||
newCalendarId.value = event.currentTarget.value;
|
||||
}}
|
||||
>
|
||||
{calendars.map((calendar) => <option value={calendar.id}>{calendar.name}</option>)}
|
||||
</select>
|
||||
<span
|
||||
class={`w-5 h-5 block ${
|
||||
calendars.find((calendar) => calendar.id === newCalendarId.value)?.color
|
||||
} rounded-full`}
|
||||
title={calendars.find((calendar) => calendar.id === newCalendarId.value)?.color}
|
||||
>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickImport(newCalendarId.value!)}
|
||||
>
|
||||
Choose File
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { baseUrl, capitalizeWord } from '/lib/utils/misc.ts';
|
||||
import { formatCalendarEventsToVCalendar, parseVCalendarFromTextContents } from '/lib/utils/calendar.ts';
|
||||
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/calendar/get-events.tsx';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add-event.tsx';
|
||||
import {
|
||||
RequestBody as DeleteRequestBody,
|
||||
ResponseBody as DeleteResponseBody,
|
||||
} from '/routes/api/calendar/delete-event.tsx';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/calendar/import.tsx';
|
||||
import CalendarViewDay from './CalendarViewDay.tsx';
|
||||
import CalendarViewWeek from './CalendarViewWeek.tsx';
|
||||
import CalendarViewMonth from './CalendarViewMonth.tsx';
|
||||
import AddEventModal, { NewCalendarEvent } from './AddEventModal.tsx';
|
||||
import ViewEventModal from './ViewEventModal.tsx';
|
||||
import SearchEvents from './SearchEvents.tsx';
|
||||
import ImportEventsModal from './ImportEventsModal.tsx';
|
||||
|
||||
interface MainCalendarProps {
|
||||
initialCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible' | 'extra'>[];
|
||||
initialCalendarEvents: CalendarEvent[];
|
||||
view: 'day' | 'week' | 'month';
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
export default function MainCalendar({ initialCalendars, initialCalendarEvents, view, startDate }: MainCalendarProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isExporting = useSignal<boolean>(false);
|
||||
const isImporting = useSignal<boolean>(false);
|
||||
const calendars = useSignal<Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible' | 'extra'>[]>(initialCalendars);
|
||||
const isViewOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
const isImportExportOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
const calendarEvents = useSignal<CalendarEvent[]>(initialCalendarEvents);
|
||||
const openEventModal = useSignal<
|
||||
{ isOpen: boolean; calendar?: typeof initialCalendars[number]; calendarEvent?: CalendarEvent }
|
||||
>({ isOpen: false });
|
||||
const newEventModal = useSignal<{ isOpen: boolean; initialStartDate?: Date; initiallyAllDay?: boolean }>({
|
||||
isOpen: false,
|
||||
});
|
||||
const openImportModal = useSignal<
|
||||
{ isOpen: boolean }
|
||||
>({ isOpen: false });
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long' });
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const visibleCalendars = calendars.value.filter((calendar) => calendar.is_visible);
|
||||
|
||||
function onClickAddEvent(startDate = new Date(), isAllDay = false) {
|
||||
if (newEventModal.value.isOpen) {
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendars.value.length === 0) {
|
||||
alert('You need to create a calendar first!');
|
||||
return;
|
||||
}
|
||||
|
||||
newEventModal.value = {
|
||||
isOpen: true,
|
||||
initialStartDate: startDate,
|
||||
initiallyAllDay: isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickSaveNewEvent(newEvent: NewCalendarEvent) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = {
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.id),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarId: newEvent.calendar_id,
|
||||
title: newEvent.title,
|
||||
startDate: new Date(newEvent.start_date).toISOString(),
|
||||
endDate: new Date(newEvent.end_date).toISOString(),
|
||||
isAllDay: newEvent.is_all_day,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/add-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add event!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onCloseNewEvent() {
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleImportExportOptionsDropdown() {
|
||||
isImportExportOptionsDropdownOpen.value = !isImportExportOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
function toggleViewOptionsDropdown() {
|
||||
isViewOptionsDropdownOpen.value = !isViewOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
function onClickOpenEvent(calendarEvent: CalendarEvent) {
|
||||
if (openEventModal.value.isOpen) {
|
||||
openEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const calendar = calendars.value.find((calendar) => calendar.id === calendarEvent.calendar_id)!;
|
||||
|
||||
openEventModal.value = {
|
||||
isOpen: true,
|
||||
calendar,
|
||||
calendarEvent,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickDeleteEvent(calendarEventId: string) {
|
||||
if (confirm('Are you sure you want to delete this event?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.id),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarEventId,
|
||||
calendarId: calendarEvents.value.find((calendarEvent) => calendarEvent.id === calendarEventId)!.calendar_id,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/delete-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete event!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
|
||||
openEventModal.value = { isOpen: false };
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseOpenEvent() {
|
||||
openEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickChangeStartDate(changeTo: 'previous' | 'next' | 'today') {
|
||||
const previousDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const previousWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 7)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 7)).toISOString()
|
||||
.substring(0, 10);
|
||||
const previousMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() - 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() + 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
|
||||
if (changeTo === 'today') {
|
||||
if (today === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${today}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (changeTo === 'previous') {
|
||||
let newStartDate = previousMonth;
|
||||
|
||||
if (view === 'day') {
|
||||
newStartDate = previousDay;
|
||||
} else if (view === 'week') {
|
||||
newStartDate = previousWeek;
|
||||
}
|
||||
|
||||
if (newStartDate === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||
return;
|
||||
}
|
||||
|
||||
let newStartDate = nextMonth;
|
||||
|
||||
if (view === 'day') {
|
||||
newStartDate = nextDay;
|
||||
} else if (view === 'week') {
|
||||
newStartDate = nextWeek;
|
||||
}
|
||||
|
||||
if (newStartDate === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||
}
|
||||
|
||||
function onClickChangeView(newView: MainCalendarProps['view']) {
|
||||
if (view === newView) {
|
||||
isViewOptionsDropdownOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${newView}&startDate=${startDate}`;
|
||||
}
|
||||
|
||||
function onClickImportICS() {
|
||||
openImportModal.value = { isOpen: true };
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
function onClickChooseImportCalendar(calendarId: string) {
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = (event) => {
|
||||
const files = (event.target as HTMLInputElement)?.files!;
|
||||
const file = files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (fileRead) => {
|
||||
const importFileContents = fileRead.target?.result;
|
||||
|
||||
if (!importFileContents || isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.value = true;
|
||||
|
||||
openImportModal.value = { isOpen: false };
|
||||
|
||||
try {
|
||||
const partialCalendarEvents = parseVCalendarFromTextContents(importFileContents!.toString());
|
||||
|
||||
const requestBody: ImportRequestBody = {
|
||||
partialCalendarEvents,
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.id),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarId,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/calendar/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to import file!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isImporting.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickExportICS() {
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = ['calendar-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.ics']
|
||||
.join('');
|
||||
|
||||
try {
|
||||
const requestBody: GetRequestBody = { calendarIds: visibleCalendars.map((calendar) => calendar.id) };
|
||||
const response = await fetch(`/api/calendar/get-events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as GetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get contact!');
|
||||
}
|
||||
|
||||
const exportContents = formatCalendarEventsToVCalendar([...result.calendarEvents], calendars.value);
|
||||
|
||||
// Add content-type
|
||||
const vCardContent = ['data:text/calendar; charset=utf-8,', encodeURIComponent(exportContents)].join('');
|
||||
|
||||
// Download the file
|
||||
const data = vCardContent;
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', data);
|
||||
link.setAttribute('download', fileName);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isExporting.value = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<section class='flex flex-row items-center justify-start'>
|
||||
<a href='/calendars' class='mr-4 whitespace-nowrap'>Manage calendars</a>
|
||||
<SearchEvents calendars={visibleCalendars} onClickOpenEvent={onClickOpenEvent} />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='flex items-center justify-end'>
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
<time datetime={startDate}>{dateFormat.format(new Date(startDate))}</time>
|
||||
</h3>
|
||||
<section class='ml-2 relative flex items-center rounded-md bg-slate-700 shadow-sm md:items-stretch'>
|
||||
<button
|
||||
type='button'
|
||||
class='flex h-9 w-12 items-center justify-center rounded-l-md text-white hover:bg-slate-600 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('previous')}
|
||||
>
|
||||
<span class='sr-only'>Previous {view}</span>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='px-3.5 text-sm font-semibold text-white hover:bg-slate-600 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('today')}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='flex h-9 w-12 items-center justify-center rounded-r-md text-white hover:bg-slate-600 pl-1 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('next')}
|
||||
>
|
||||
<span class='sr-only'>Next {view}</span>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</section>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='view-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleViewOptionsDropdown()}
|
||||
>
|
||||
{capitalizeWord(view)}
|
||||
<svg class='-mr-1 h-5 w-5 text-white' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!isViewOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'day' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('day')}
|
||||
>
|
||||
Day
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'week' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('week')}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'month' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('month')}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='import-export-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleImportExportOptionsDropdown()}
|
||||
>
|
||||
ICS
|
||||
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!isImportExportOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='import-export-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickImportICS()}
|
||||
>
|
||||
Import ICS
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportICS()}
|
||||
>
|
||||
Export ICS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new event'
|
||||
onClick={() => onClickAddEvent()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new event'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{view === 'day'
|
||||
? (
|
||||
<CalendarViewDay
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{view === 'week'
|
||||
? (
|
||||
<CalendarViewWeek
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{view === 'month'
|
||||
? (
|
||||
<CalendarViewMonth
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>CalDAV URLs:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/principals/</code>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/calendars/</code>
|
||||
</section>
|
||||
|
||||
<AddEventModal
|
||||
isOpen={newEventModal.value.isOpen}
|
||||
initialStartDate={newEventModal.value.initialStartDate}
|
||||
initiallyAllDay={newEventModal.value.initiallyAllDay}
|
||||
calendars={calendars.value}
|
||||
onClickSave={onClickSaveNewEvent}
|
||||
onClose={onCloseNewEvent}
|
||||
/>
|
||||
|
||||
<ViewEventModal
|
||||
isOpen={openEventModal.value.isOpen}
|
||||
calendar={openEventModal.value.calendar!}
|
||||
calendarEvent={openEventModal.value.calendarEvent!}
|
||||
onClickDelete={onClickDeleteEvent}
|
||||
onClose={onCloseOpenEvent}
|
||||
/>
|
||||
|
||||
<ImportEventsModal
|
||||
isOpen={openImportModal.value.isOpen}
|
||||
calendars={calendars.value}
|
||||
onClickImport={onClickChooseImportCalendar}
|
||||
onClose={() => {
|
||||
openImportModal.value = { isOpen: false };
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { RequestBody, ResponseBody } from '/routes/api/calendar/search-events.tsx';
|
||||
interface SearchEventsProps {
|
||||
calendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible'>[];
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEventsProps) {
|
||||
const isSearching = useSignal<boolean>(false);
|
||||
const areResultsVisible = useSignal<boolean>(false);
|
||||
const calendarEvents = useSignal<CalendarEvent[]>([]);
|
||||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const closeTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const calendarIds = calendars.map((calendar) => calendar.id);
|
||||
|
||||
function searchEvents(searchTerm: string) {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (searchTerm.trim().length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
areResultsVisible.value = false;
|
||||
|
||||
searchTimeout.value = setTimeout(async () => {
|
||||
isSearching.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RequestBody = { calendarIds, searchTerm };
|
||||
const response = await fetch(`/api/calendar/search-events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to search events!');
|
||||
}
|
||||
|
||||
calendarEvents.value = result.matchingCalendarEvents;
|
||||
|
||||
if (calendarEvents.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isSearching.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
if (calendarEvents.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
|
||||
closeTimeout.value = setTimeout(() => {
|
||||
areResultsVisible.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
class='input-field w-72 mr-2'
|
||||
type='search'
|
||||
name='search'
|
||||
placeholder='Search events...'
|
||||
onInput={(event) => searchEvents(event.currentTarget.value)}
|
||||
onFocus={() => onFocus()}
|
||||
onBlur={() => onBlur()}
|
||||
/>
|
||||
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{areResultsVisible.value
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2 text-xs'>
|
||||
<section
|
||||
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<section class='py-1'>
|
||||
<ol class='mt-2'>
|
||||
{calendarEvents.value.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 ${
|
||||
calendars.find((calendar) => calendar.id === calendarEvent.calendar_id)
|
||||
?.color || 'bg-gray-700'
|
||||
}`}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(calendarEvent.start_date).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{dateFormat.format(new Date(calendarEvent.start_date))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { convertRRuleToWords } from '/lib/utils/calendar.ts';
|
||||
|
||||
interface ViewEventModalProps {
|
||||
isOpen: boolean;
|
||||
calendarEvent: CalendarEvent;
|
||||
calendar: Pick<Calendar, 'id' | 'name' | 'color'>;
|
||||
onClickDelete: (eventId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ViewEventModal(
|
||||
{ isOpen, calendarEvent, calendar, onClickDelete, onClose }: ViewEventModalProps,
|
||||
) {
|
||||
if (!calendarEvent || !calendar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', { hour12: false, hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>{calendarEvent.title}</h1>
|
||||
<header class='py-5 border-t border-b border-slate-500 font-semibold flex justify-between items-center'>
|
||||
<span>
|
||||
{calendarEvent.start_date ? allDayEventDateFormat.format(new Date(calendarEvent.start_date)) : ''}
|
||||
</span>
|
||||
{calendarEvent.is_all_day ? <span>All-day</span> : (
|
||||
<span>
|
||||
{calendarEvent.start_date ? hourFormat.format(new Date(calendarEvent.start_date)) : ''} -{' '}
|
||||
{calendarEvent.end_date ? hourFormat.format(new Date(calendarEvent.end_date)) : ''}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<section class='py-5 my-0 border-b border-slate-500 flex justify-between items-center'>
|
||||
<span>
|
||||
{calendar.name}
|
||||
</span>
|
||||
<span
|
||||
class={`w-5 h-5 ml-2 block ${calendar.color} rounded-full`}
|
||||
title={calendar.color}
|
||||
/>
|
||||
</section>
|
||||
{calendarEvent.extra.description
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<article class='overflow-auto max-w-full max-h-80 font-mono text-sm whitespace-pre-wrap'>
|
||||
{calendarEvent.extra.description}
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.extra.url
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<a href={calendarEvent.extra.url} target='_blank' rel='noopener noreferrer'>
|
||||
{calendarEvent.extra.url}
|
||||
</a>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.extra.location
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<a
|
||||
href={`https://maps.google.com/maps?q=${encodeURIComponent(calendarEvent.extra.location)}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{calendarEvent.extra.location}
|
||||
</a>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{Array.isArray(calendarEvent.extra.attendees) && calendarEvent.extra.attendees.length > 0
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
{calendarEvent.extra.attendees.map((attendee) => (
|
||||
<p class='my-1'>
|
||||
<a href={`mailto:${attendee.email}`} target='_blank' rel='noopener noreferrer'>
|
||||
{attendee.name || attendee.email}
|
||||
</a>{' '}
|
||||
- {attendee.status}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.extra.is_recurring && calendarEvent.extra.recurring_rrule
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<p class='text-xs'>Repeats {convertRRuleToWords(calendarEvent.extra.recurring_rrule).toLowerCase()}.</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{Array.isArray(calendarEvent.extra.reminders) && calendarEvent.extra.reminders.length > 0
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
{calendarEvent.extra.reminders.map((reminder) => (
|
||||
<p class='my-1 text-xs'>
|
||||
{reminder.description || 'Reminder'} at {hourFormat.format(new Date(reminder.start_date))} via{' '}
|
||||
{reminder.type}.
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
<footer class='flex justify-between mt-2'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-red-600 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickDelete(calendarEvent.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<a
|
||||
href={`/calendar/${calendarEvent.id}`}
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
target='_blank'
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
60
components/files/CreateDirectoryModal.tsx
Normal file
60
components/files/CreateDirectoryModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
interface CreateDirectoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClickSave: (newDirectoryName: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CreateDirectoryModal(
|
||||
{ isOpen, onClickSave, onClose }: CreateDirectoryModalProps,
|
||||
) {
|
||||
const newDirectoryName = useSignal<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Create New Directory</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='directory_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='directory_name'
|
||||
id='directory_name'
|
||||
value={newDirectoryName.value}
|
||||
onInput={(event) => {
|
||||
newDirectoryName.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='Amazing'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newDirectoryName.value)}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
components/files/FilesBreadcrumb.tsx
Normal file
44
components/files/FilesBreadcrumb.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
interface FilesBreadcrumbProps {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) {
|
||||
if (path === '/') {
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
All files
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
const pathParts = path.slice(1, -1).split('/');
|
||||
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
<a href={`/files?path=/`}>All files</a>
|
||||
{pathParts.map((part, index) => {
|
||||
if (index === pathParts.length - 1) {
|
||||
return (
|
||||
<>
|
||||
<span class='ml-2 text-xs'>/</span>
|
||||
<span class='ml-2'>{part}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const fullPathForPart: string[] = [];
|
||||
|
||||
for (let pathPartIndex = 0; pathPartIndex <= index; ++pathPartIndex) {
|
||||
fullPathForPart.push(pathParts[pathPartIndex]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span class='ml-2 text-xs'>/</span>
|
||||
<a href={`/files?path=/${fullPathForPart.join('/')}/`} class='ml-2'>{part}</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
209
components/files/ListFiles.tsx
Normal file
209
components/files/ListFiles.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
|
||||
interface ListFilesProps {
|
||||
directories: Directory[];
|
||||
files: DirectoryFile[];
|
||||
onClickDeleteDirectory: (parentPath: string, name: string) => Promise<void>;
|
||||
onClickDeleteFile: (parentPath: string, name: string) => Promise<void>;
|
||||
onClickOpenRenameDirectory: (parentPath: string, name: string) => void;
|
||||
onClickOpenRenameFile: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveDirectory: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveFile: (parentPath: string, name: string) => void;
|
||||
}
|
||||
|
||||
export default function ListFiles(
|
||||
{
|
||||
directories,
|
||||
files,
|
||||
onClickDeleteDirectory,
|
||||
onClickDeleteFile,
|
||||
onClickOpenRenameDirectory,
|
||||
onClickOpenRenameFile,
|
||||
onClickOpenMoveDirectory,
|
||||
onClickOpenMoveFile,
|
||||
}: ListFilesProps,
|
||||
) {
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
return (
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-slate-500 shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr class='border-b border-slate-600'>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-56'>Last update</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
{directories.map((directory) => {
|
||||
const fullPath = `${directory.parent_path}${directory.directory_name}/`;
|
||||
|
||||
return (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/files?path=${fullPath}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
>
|
||||
<img
|
||||
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Directory'
|
||||
title='Directory'
|
||||
/>
|
||||
{directory.directory_name}
|
||||
</a>
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{dateFormat.format(new Date(directory.updated_at))}
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
-
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
{fullPath === TRASH_PATH ? null : (
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Rename directory'
|
||||
title='Rename directory'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Move directory'
|
||||
title='Move directory'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
alt='Delete directory'
|
||||
title='Delete directory'
|
||||
/>
|
||||
</span>
|
||||
</section>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{files.map((file) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src='/images/file.svg'
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='File'
|
||||
title='File'
|
||||
/>
|
||||
{file.file_name}
|
||||
</a>
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{dateFormat.format(new Date(file.updated_at))}
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{humanFileSize(file.size_in_bytes)}
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Rename file'
|
||||
title='Rename file'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Move file'
|
||||
title='Move file'
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
alt='Delete file'
|
||||
title='Delete file'
|
||||
/>
|
||||
</span>
|
||||
</section>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{directories.length === 0 && files.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No files to show</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
522
components/files/MainFiles.tsx
Normal file
522
components/files/MainFiles.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx';
|
||||
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx';
|
||||
import {
|
||||
RequestBody as CreateDirectoryRequestBody,
|
||||
ResponseBody as CreateDirectoryResponseBody,
|
||||
} from '/routes/api/files/create-directory.tsx';
|
||||
import {
|
||||
RequestBody as RenameDirectoryRequestBody,
|
||||
ResponseBody as RenameDirectoryResponseBody,
|
||||
} from '/routes/api/files/rename-directory.tsx';
|
||||
import {
|
||||
RequestBody as MoveDirectoryRequestBody,
|
||||
ResponseBody as MoveDirectoryResponseBody,
|
||||
} from '/routes/api/files/move-directory.tsx';
|
||||
import {
|
||||
RequestBody as DeleteDirectoryRequestBody,
|
||||
ResponseBody as DeleteDirectoryResponseBody,
|
||||
} from '/routes/api/files/delete-directory.tsx';
|
||||
import SearchFiles from './SearchFiles.tsx';
|
||||
import ListFiles from './ListFiles.tsx';
|
||||
import FilesBreadcrumb from './FilesBreadcrumb.tsx';
|
||||
import CreateDirectoryModal from './CreateDirectoryModal.tsx';
|
||||
import RenameDirectoryOrFileModal from './RenameDirectoryOrFileModal.tsx';
|
||||
import MoveDirectoryOrFileModal from './MoveDirectoryOrFileModal.tsx';
|
||||
|
||||
interface MainFilesProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
export default function MainFiles({ initialDirectories, initialFiles, initialPath }: MainFilesProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isUploading = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isUpdating = useSignal<boolean>(false);
|
||||
const directories = useSignal<Directory[]>(initialDirectories);
|
||||
const files = useSignal<DirectoryFile[]>(initialFiles);
|
||||
const path = useSignal<string>(initialPath);
|
||||
const areNewOptionsOption = useSignal<boolean>(false);
|
||||
const isNewDirectoryModalOpen = useSignal<boolean>(false);
|
||||
const renameDirectoryOrFileModal = useSignal<
|
||||
{ isOpen: boolean; isDirectory: boolean; parentPath: string; name: string } | null
|
||||
>(null);
|
||||
const moveDirectoryOrFileModal = useSignal<
|
||||
{ isOpen: boolean; isDirectory: boolean; path: string; name: string } | null
|
||||
>(null);
|
||||
|
||||
function onClickUploadFile() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
const chosenFiles = (event.target as HTMLInputElement)?.files!;
|
||||
const chosenFile = chosenFiles[0];
|
||||
|
||||
if (!chosenFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUploading.value = true;
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
const requestBody = new FormData();
|
||||
requestBody.set('parent_path', path.value);
|
||||
requestBody.set('name', chosenFile.name);
|
||||
requestBody.set('contents', chosenFile);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/upload`, {
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
const result = await response.json() as UploadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to upload file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUploading.value = false;
|
||||
};
|
||||
}
|
||||
|
||||
function onClickCreateDirectory() {
|
||||
if (isNewDirectoryModalOpen.value) {
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isNewDirectoryModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function onClickSaveDirectory(newDirectoryName: string) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newDirectoryName) {
|
||||
return;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: CreateDirectoryRequestBody = {
|
||||
parentPath: path.value,
|
||||
name: newDirectoryName,
|
||||
};
|
||||
const response = await fetch(`/api/files/create-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as CreateDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to create directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onCloseCreateDirectory() {
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleNewOptionsDropdown() {
|
||||
areNewOptionsOption.value = !areNewOptionsOption.value;
|
||||
}
|
||||
|
||||
function onClickOpenRenameDirectory(parentPath: string, name: string) {
|
||||
renameDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: true,
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickOpenRenameFile(parentPath: string, name: string) {
|
||||
renameDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: false,
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickCloseRename() {
|
||||
renameDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveRenameDirectory(newName: string) {
|
||||
if (
|
||||
isUpdating.value || !renameDirectoryOrFileModal.value?.isOpen || !renameDirectoryOrFileModal.value?.isDirectory
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RenameDirectoryRequestBody = {
|
||||
parentPath: renameDirectoryOrFileModal.value.parentPath,
|
||||
oldName: renameDirectoryOrFileModal.value.name,
|
||||
newName,
|
||||
};
|
||||
const response = await fetch(`/api/files/rename-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as RenameDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to rename directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
renameDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveRenameFile(newName: string) {
|
||||
if (
|
||||
isUpdating.value || !renameDirectoryOrFileModal.value?.isOpen || renameDirectoryOrFileModal.value?.isDirectory
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RenameRequestBody = {
|
||||
parentPath: renameDirectoryOrFileModal.value.parentPath,
|
||||
oldName: renameDirectoryOrFileModal.value.name,
|
||||
newName,
|
||||
};
|
||||
const response = await fetch(`/api/files/rename`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as RenameResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to rename file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
renameDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
function onClickOpenMoveDirectory(parentPath: string, name: string) {
|
||||
moveDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: true,
|
||||
path: parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickOpenMoveFile(parentPath: string, name: string) {
|
||||
moveDirectoryOrFileModal.value = {
|
||||
isOpen: true,
|
||||
isDirectory: false,
|
||||
path: parentPath,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickCloseMove() {
|
||||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveMoveDirectory(newPath: string) {
|
||||
if (isUpdating.value || !moveDirectoryOrFileModal.value?.isOpen || !moveDirectoryOrFileModal.value?.isDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: MoveDirectoryRequestBody = {
|
||||
oldParentPath: moveDirectoryOrFileModal.value.path,
|
||||
newParentPath: newPath,
|
||||
name: moveDirectoryOrFileModal.value.name,
|
||||
};
|
||||
const response = await fetch(`/api/files/move-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as MoveDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to move directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickSaveMoveFile(newPath: string) {
|
||||
if (isUpdating.value || !moveDirectoryOrFileModal.value?.isOpen || moveDirectoryOrFileModal.value?.isDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: MoveRequestBody = {
|
||||
oldParentPath: moveDirectoryOrFileModal.value.path,
|
||||
newParentPath: newPath,
|
||||
name: moveDirectoryOrFileModal.value.name,
|
||||
};
|
||||
const response = await fetch(`/api/files/move`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as MoveResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to move file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickDeleteDirectory(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this directory?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteDirectoryRequestBody = {
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
const response = await fetch(`/api/files/delete-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickDeleteFile(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this file?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
const response = await fetch(`/api/files/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete file!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<section class='flex flex-row items-center justify-start'>
|
||||
<SearchFiles />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='flex items-center justify-end'>
|
||||
<FilesBreadcrumb path={path.value} />
|
||||
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new event'
|
||||
id='new-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new file or directory'
|
||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='new-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickUploadFile()}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateDirectory()}
|
||||
>
|
||||
New Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<ListFiles
|
||||
directories={directories.value}
|
||||
files={files.value}
|
||||
onClickDeleteDirectory={onClickDeleteDirectory}
|
||||
onClickDeleteFile={onClickDeleteFile}
|
||||
onClickOpenRenameDirectory={onClickOpenRenameDirectory}
|
||||
onClickOpenRenameFile={onClickOpenRenameFile}
|
||||
onClickOpenMoveDirectory={onClickOpenMoveDirectory}
|
||||
onClickOpenMoveFile={onClickOpenMoveFile}
|
||||
/>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isAdding.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isUploading.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isUpdating.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Updating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isAdding.value && !isUploading.value && !isUpdating.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<CreateDirectoryModal
|
||||
isOpen={isNewDirectoryModalOpen.value}
|
||||
onClickSave={onClickSaveDirectory}
|
||||
onClose={onCloseCreateDirectory}
|
||||
/>
|
||||
|
||||
<RenameDirectoryOrFileModal
|
||||
isOpen={renameDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialName={renameDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={renameDirectoryOrFileModal.value?.isDirectory ? onClickSaveRenameDirectory : onClickSaveRenameFile}
|
||||
onClose={onClickCloseRename}
|
||||
/>
|
||||
|
||||
<MoveDirectoryOrFileModal
|
||||
isOpen={moveDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialPath={moveDirectoryOrFileModal.value?.path || ''}
|
||||
name={moveDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={moveDirectoryOrFileModal.value?.isDirectory ? onClickSaveMoveDirectory : onClickSaveMoveFile}
|
||||
onClose={onClickCloseMove}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
components/files/MoveDirectoryOrFileModal.tsx
Normal file
145
components/files/MoveDirectoryOrFileModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/routes/api/files/get-directories.tsx';
|
||||
import { Directory } from '/lib/types.ts';
|
||||
|
||||
interface MoveDirectoryOrFileModalProps {
|
||||
isOpen: boolean;
|
||||
initialPath: string;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
onClickSave: (newPath: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function MoveDirectoryOrFileModal(
|
||||
{ isOpen, initialPath, isDirectory, name, onClickSave, onClose }: MoveDirectoryOrFileModalProps,
|
||||
) {
|
||||
const newPath = useSignal<string>(initialPath);
|
||||
const isLoading = useSignal<boolean>(false);
|
||||
const directories = useSignal<Directory[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
newPath.value = initialPath;
|
||||
|
||||
fetchDirectories();
|
||||
}, [initialPath]);
|
||||
|
||||
async function fetchDirectories() {
|
||||
if (!initialPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RequestBody = {
|
||||
parentPath: newPath.value,
|
||||
directoryPathToExclude: isDirectory ? `${initialPath}${name}` : '',
|
||||
};
|
||||
const response = await fetch(`/api/files/get-directories`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get directories!');
|
||||
}
|
||||
|
||||
directories.value = [...result.directories];
|
||||
|
||||
isLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function onChooseNewDirectory(chosenPath: string) {
|
||||
newPath.value = chosenPath;
|
||||
|
||||
await fetchDirectories();
|
||||
}
|
||||
|
||||
const parentPath = newPath.value === '/'
|
||||
? null
|
||||
: `/${newPath.peek().split('/').filter(Boolean).slice(0, -1).join('/')}`;
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Move "{name}" into "{newPath.value}"</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<ol class='mt-2'>
|
||||
{parentPath
|
||||
? (
|
||||
<li class='mb-1'>
|
||||
<span
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer rounded-md`}
|
||||
onClick={() => onChooseNewDirectory(parentPath === '/' ? parentPath : `${parentPath}/`)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
..
|
||||
</p>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
{directories.value.map((directory) => (
|
||||
<li class='mb-1'>
|
||||
<span
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer rounded-md`}
|
||||
onClick={() => onChooseNewDirectory(`${directory.parent_path}${directory.directory_name}/`)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{directory.directory_name}
|
||||
</p>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isLoading.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Loading...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isLoading.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newPath.value)}
|
||||
>
|
||||
Move {isDirectory ? 'directory' : 'file'} here
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
67
components/files/RenameDirectoryOrFileModal.tsx
Normal file
67
components/files/RenameDirectoryOrFileModal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
interface RenameDirectoryOrFileModalProps {
|
||||
isOpen: boolean;
|
||||
initialName: string;
|
||||
isDirectory: boolean;
|
||||
onClickSave: (newName: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RenameDirectoryOrFileModal(
|
||||
{ isOpen, initialName, isDirectory, onClickSave, onClose }: RenameDirectoryOrFileModalProps,
|
||||
) {
|
||||
const newName = useSignal<string>(initialName);
|
||||
|
||||
useEffect(() => {
|
||||
newName.value = initialName;
|
||||
}, [initialName]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Rename {isDirectory ? 'Directory' : 'File'}</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='directory_or_file_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='directory_or_file_name'
|
||||
id='directory_or_file_name'
|
||||
value={newName.value}
|
||||
onInput={(event) => {
|
||||
newName.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='Amazing'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newName.value)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
165
components/files/SearchFiles.tsx
Normal file
165
components/files/SearchFiles.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
// import { RequestBody, ResponseBody } from '/routes/api/files/search.tsx';
|
||||
interface SearchFilesProps {}
|
||||
|
||||
export default function SearchFiles({}: SearchFilesProps) {
|
||||
const isSearching = useSignal<boolean>(false);
|
||||
const areResultsVisible = useSignal<boolean>(false);
|
||||
const matchingDirectories = useSignal<Directory[]>([]);
|
||||
const matchingFiles = useSignal<DirectoryFile[]>([]);
|
||||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const closeTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
function searchFiles(searchTerm: string) {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (searchTerm.trim().length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
areResultsVisible.value = false;
|
||||
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
isSearching.value = true;
|
||||
|
||||
// TODO: Build this
|
||||
// try {
|
||||
// const requestBody: RequestBody = { searchTerm };
|
||||
// const response = await fetch(`/api/files/search`, {
|
||||
// method: 'POST',
|
||||
// body: JSON.stringify(requestBody),
|
||||
// });
|
||||
// const result = await response.json() as ResponseBody;
|
||||
|
||||
// if (!result.success) {
|
||||
// throw new Error('Failed to search files!');
|
||||
// }
|
||||
|
||||
// matchingDirectories.value = result.matchingDirectories;
|
||||
// matchingFiles.value = result.matchingFiles;
|
||||
|
||||
// if (matchingDirectories.value.length > 0 || matchingFiles.value.length > 0) {
|
||||
// areResultsVisible.value = true;
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error(error);
|
||||
// }
|
||||
|
||||
isSearching.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
if (matchingDirectories.value.length > 0 || matchingFiles.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
|
||||
closeTimeout.value = setTimeout(() => {
|
||||
areResultsVisible.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
class='input-field w-72 mr-2'
|
||||
type='search'
|
||||
name='search'
|
||||
placeholder='Search files...'
|
||||
onInput={(event) => searchFiles(event.currentTarget.value)}
|
||||
onFocus={() => onFocus()}
|
||||
onBlur={() => onBlur()}
|
||||
/>
|
||||
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{areResultsVisible.value
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2 text-xs'>
|
||||
<section
|
||||
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<section class='py-1'>
|
||||
<ol class='mt-2'>
|
||||
{matchingDirectories.value.map((directory) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/files?path=${directory.parent_path}${directory.directory_name}`}
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<time
|
||||
datetime={new Date(directory.updated_at).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{dateFormat.format(new Date(directory.updated_at))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{directory.directory_name}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{matchingFiles.value.map((file) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<time
|
||||
datetime={new Date(file.updated_at).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{dateFormat.format(new Date(file.updated_at))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{file.file_name}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user