Files CRUD.
Remove Contacts and Calendar + CardDav and CalDav.
This commit is contained in:
@@ -1,23 +0,0 @@
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import MainCalendar from '/components/calendar/MainCalendar.tsx';
|
||||
|
||||
interface CalendarWrapperProps {
|
||||
initialCalendars: Pick<Calendar, 'id' | 'name' | 'color' | 'is_visible' | 'extra'>[];
|
||||
initialCalendarEvents: CalendarEvent[];
|
||||
view: 'day' | 'week' | 'month';
|
||||
startDate: string;
|
||||
}
|
||||
|
||||
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||
export default function CalendarWrapper(
|
||||
{ initialCalendars, initialCalendarEvents, view, startDate }: CalendarWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainCalendar
|
||||
initialCalendars={initialCalendars}
|
||||
initialCalendarEvents={initialCalendarEvents}
|
||||
view={view}
|
||||
startDate={startDate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar } from '/lib/types.ts';
|
||||
import { CALENDAR_COLOR_OPTIONS } from '/lib/utils/calendar.ts';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/calendar/add.tsx';
|
||||
import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/routes/api/calendar/update.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/calendar/delete.tsx';
|
||||
|
||||
interface CalendarsProps {
|
||||
initialCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isSaving = useSignal<boolean>(false);
|
||||
const calendars = useSignal<Calendar[]>(initialCalendars);
|
||||
const openCalendar = useSignal<Calendar | null>(null);
|
||||
|
||||
async function onClickAddCalendar() {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = (prompt(`What's the **name** for the new calendar?`) || '').trim();
|
||||
|
||||
if (!name) {
|
||||
alert('A name is required for a new calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = { name };
|
||||
const response = await fetch(`/api/calendar/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add calendar!');
|
||||
}
|
||||
|
||||
calendars.value = [...result.newCalendars];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
async function onClickDeleteCalendar(calendarId: string) {
|
||||
if (confirm('Are you sure you want to delete this calendar and all its events?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { calendarId };
|
||||
const response = await fetch(`/api/calendar/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete calendar!');
|
||||
}
|
||||
|
||||
calendars.value = [...result.newCalendars];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickSaveOpenCalendar() {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.id) {
|
||||
alert('A calendar is required to update one!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.name) {
|
||||
alert('A name is required to update the calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.color) {
|
||||
alert('A color is required to update the calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: UpdateRequestBody = { ...openCalendar.value };
|
||||
const response = await fetch(`/api/calendar/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as UpdateResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to update calendar!');
|
||||
}
|
||||
|
||||
calendars.value = [...result.newCalendars];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isSaving.value = false;
|
||||
openCalendar.value = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<a href='/calendar' class='mr-2'>View calendar</a>
|
||||
<section class='flex items-center'>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new calendar'
|
||||
onClick={() => onClickAddCalendar()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new calendar'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-white shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope='col' class='px-6 py-4 font-medium'>Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium'>Color</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium'>Visible?</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
{calendars.value.map((calendar) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4 font-medium'>
|
||||
{calendar.name}
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
<span
|
||||
class={`w-5 h-5 inline-block ${calendar.color} rounded-full cursor-pointer`}
|
||||
title={calendar.color}
|
||||
onClick={() => openCalendar.value = { ...calendar }}
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
{calendar.is_visible ? 'Yes' : 'No'}
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteCalendar(calendar.id)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
alt='Delete calendar'
|
||||
title='Delete calendar'
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{calendars.value.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No calendars to show</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isSaving.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
openCalendar.value ? 'block' : 'hidden'
|
||||
} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
openCalendar.value ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Edit Calendar</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='calendar_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='calendar_name'
|
||||
id='calendar_name'
|
||||
value={openCalendar.value?.name || ''}
|
||||
onInput={(event) => openCalendar.value = { ...openCalendar.value!, name: event.currentTarget.value }}
|
||||
placeholder='Personal'
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='calendar_color'>Color</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 !w-5/6'
|
||||
name='calendar_color'
|
||||
id='calendar_color'
|
||||
value={openCalendar.value?.color || ''}
|
||||
onChange={(event) => openCalendar.value = { ...openCalendar.value!, color: event.currentTarget.value }}
|
||||
>
|
||||
{CALENDAR_COLOR_OPTIONS.map((color) => <option>{color}</option>)}
|
||||
</select>
|
||||
<span class={`w-5 h-5 block ${openCalendar.value?.color} rounded-full`} title={openCalendar.value?.color}>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='calendar_is_visible'>Visible?</label>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='calendar_is_visible'
|
||||
id='calendar_is_visible'
|
||||
value='true'
|
||||
checked={openCalendar.value?.is_visible}
|
||||
onChange={(event) =>
|
||||
openCalendar.value = { ...openCalendar.value!, is_visible: event.currentTarget.checked }}
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSaveOpenCalendar()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => openCalendar.value = null}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/types.ts';
|
||||
import { capitalizeWord, convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||
import {
|
||||
RequestBody as DeleteRequestBody,
|
||||
ResponseBody as DeleteResponseBody,
|
||||
} from '/routes/api/calendar/delete-event.tsx';
|
||||
|
||||
interface ViewCalendarEventProps {
|
||||
initialCalendarEvent: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
formData: Record<string, any>;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function formFields(calendarEvent: CalendarEvent, calendars: Calendar[]) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
placeholder: 'Dentis',
|
||||
value: calendarEvent.title,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'calendar_id',
|
||||
label: 'Calendar',
|
||||
type: 'select',
|
||||
value: calendarEvent.calendar_id,
|
||||
options: calendars.map((calendar) => ({ label: calendar.name, value: calendar.id })),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'start_date',
|
||||
label: 'Start date',
|
||||
type: 'datetime-local',
|
||||
value: new Date(calendarEvent.start_date).toISOString().substring(0, 16),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'end_date',
|
||||
label: 'End date',
|
||||
type: 'datetime-local',
|
||||
value: new Date(calendarEvent.end_date).toISOString().substring(0, 16),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'is_all_day',
|
||||
label: 'All-day?',
|
||||
type: 'checkbox',
|
||||
placeholder: 'YYYYMMDD',
|
||||
value: 'true',
|
||||
required: false,
|
||||
checked: calendarEvent.is_all_day,
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
value: calendarEvent.status,
|
||||
options: (['scheduled', 'pending', 'canceled'] as CalendarEvent['status'][]).map((status) => ({
|
||||
label: capitalizeWord(status),
|
||||
value: status,
|
||||
})),
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
placeholder: 'Just a regular check-up.',
|
||||
value: calendarEvent.extra.description,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
value: calendarEvent.extra.url,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'location',
|
||||
label: 'Location',
|
||||
type: 'text',
|
||||
placeholder: 'Birmingham, UK',
|
||||
value: calendarEvent.extra.location,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'transparency',
|
||||
label: 'Transparency',
|
||||
type: 'select',
|
||||
value: calendarEvent.extra.transparency,
|
||||
options: (['default', 'opaque', 'transparent'] as CalendarEvent['extra']['transparency'][]).map((
|
||||
transparency,
|
||||
) => ({
|
||||
label: capitalizeWord(transparency),
|
||||
value: transparency,
|
||||
})),
|
||||
required: true,
|
||||
},
|
||||
];
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function viewCalendarEvent(
|
||||
{ initialCalendarEvent, calendars, formData: formDataObject, error, notice }: ViewCalendarEventProps,
|
||||
) {
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const calendarEvent = useSignal<CalendarEvent>(initialCalendarEvent);
|
||||
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
async function onClickDeleteEvent() {
|
||||
if (confirm('Are you sure you want to delete this event?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
calendarIds: calendars.map((calendar) => calendar.id),
|
||||
calendarView: 'day',
|
||||
calendarStartDate: new Date().toISOString().substring(0, 10),
|
||||
calendarEventId: calendarEvent.value.id,
|
||||
calendarId: calendarEvent.value.calendar_id,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/delete-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete event!');
|
||||
}
|
||||
|
||||
window.location.href = '/calendar';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<a href='/calendar' class='mr-2'>View calendar</a>
|
||||
<section class='flex items-center'>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 ml-2'
|
||||
type='button'
|
||||
title='Delete event'
|
||||
onClick={() => onClickDeleteEvent()}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
alt='Delete event'
|
||||
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>Failed to update!</h3>
|
||||
<p>{error}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{notice
|
||||
? (
|
||||
<section class='notification-success'>
|
||||
<h3>Success!</h3>
|
||||
<p>{notice}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(calendarEvent.peek(), calendars).map((field) => generateFieldHtml(field, formData))}
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Update event</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Contact } from '/lib/types.ts';
|
||||
import { baseUrl } from '/lib/utils/misc.ts';
|
||||
import { CONTACTS_PER_PAGE_COUNT, formatContactToVCard, parseVCardFromTextContents } from '/lib/utils/contacts.ts';
|
||||
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx';
|
||||
|
||||
interface ContactsProps {
|
||||
initialContacts: Pick<Contact, 'id' | 'first_name' | 'last_name'>[];
|
||||
page: number;
|
||||
contactsCount: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export default function Contacts({ initialContacts, page, contactsCount, search }: ContactsProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isExporting = useSignal<boolean>(false);
|
||||
const isImporting = useSignal<boolean>(false);
|
||||
const contacts = useSignal<Pick<Contact, 'id' | 'first_name' | 'last_name'>[]>(initialContacts);
|
||||
const isOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
|
||||
async function onClickAddContact() {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstName = (prompt(`What's the **first name** for the new contact?`) || '').trim();
|
||||
|
||||
if (!firstName) {
|
||||
alert('A first name is required for a new contact!');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastName = (prompt(`What's the **last name** for the new contact?`) || '').trim();
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = { firstName, lastName, page };
|
||||
const response = await fetch(`/api/contacts/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function toggleOptionsDropdown() {
|
||||
isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
async function onClickDeleteContact(contactId: string) {
|
||||
if (confirm('Are you sure you want to delete this contact?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { contactId, page };
|
||||
const response = await fetch(`/api/contacts/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClickImportVCard() {
|
||||
isOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = (event) => {
|
||||
const files = (event.target as HTMLInputElement)?.files!;
|
||||
const file = files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (fileRead) => {
|
||||
const importFileContents = fileRead.target?.result;
|
||||
|
||||
if (!importFileContents || isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.value = true;
|
||||
|
||||
try {
|
||||
const partialContacts = parseVCardFromTextContents(importFileContents!.toString());
|
||||
|
||||
const requestBody: ImportRequestBody = { partialContacts, page };
|
||||
const response = await fetch(`/api/contacts/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to import contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isImporting.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickExportVCard() {
|
||||
isOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = ['contacts-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.vcf']
|
||||
.join('');
|
||||
|
||||
try {
|
||||
const requestBody: GetRequestBody = {};
|
||||
const response = await fetch(`/api/contacts/get`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as GetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get contact!');
|
||||
}
|
||||
|
||||
const exportContents = formatContactToVCard([...result.contacts]);
|
||||
|
||||
// Add content-type
|
||||
const vCardContent = ['data:text/vcard; charset=utf-8,', encodeURIComponent(exportContents)].join('');
|
||||
|
||||
// Download the file
|
||||
const data = vCardContent;
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', data);
|
||||
link.setAttribute('download', fileName);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isExporting.value = false;
|
||||
}
|
||||
|
||||
const pagesCount = Math.ceil(contactsCount / CONTACTS_PER_PAGE_COUNT);
|
||||
const pages = Array.from({ length: pagesCount }).map((_value, index) => index + 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<form method='GET' action='/contacts' class='m-0 p-0'>
|
||||
<input
|
||||
class='input-field w-60'
|
||||
type='search'
|
||||
name='search'
|
||||
value={search}
|
||||
placeholder='Search contacts...'
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
<section class='flex items-center'>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='import-export-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleOptionsDropdown()}
|
||||
>
|
||||
VCF
|
||||
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='import-export-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickImportVCard()}
|
||||
>
|
||||
Import vCard
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportVCard()}
|
||||
>
|
||||
Export vCard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new contact'
|
||||
onClick={() => onClickAddContact()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new contact'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-slate-500 shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr class='border-b border-slate-600'>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>First Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>Last Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
{contacts.value.map((contact) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal text-white'>
|
||||
<a href={`/contacts/${contact.id}`}>{contact.first_name}</a>
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{contact.last_name}
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteContact(contact.id)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
alt='Delete contact'
|
||||
title='Delete contact'
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{contacts.value.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={3}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No contacts to show</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
{pagesCount > 0
|
||||
? (
|
||||
<section class='flex justify-end'>
|
||||
<nav class='isolate inline-flex -space-x-px rounded-md shadow-sm' aria-label='Pagination'>
|
||||
<a
|
||||
href={page > 1 ? `/contacts?search=${search}&page=${page - 1}` : 'javascript:void(0)'}
|
||||
class='relative inline-flex items-center rounded-l-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||
title='Previous'
|
||||
>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{pages.map((pageNumber) => {
|
||||
const isCurrent = pageNumber === page;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/contacts?search=${search}&page=${pageNumber}`}
|
||||
aria-current='page'
|
||||
class={`relative inline-flex items-center ${
|
||||
isCurrent ? 'bg-[#51A4FB] hover:bg-sky-400' : 'bg-slate-700 hover:bg-slate-600'
|
||||
} px-4 py-2 text-sm font-semibold text-white`}
|
||||
>
|
||||
{pageNumber}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<a
|
||||
href={page < pagesCount ? `/contacts?search=${search}&page=${page + 1}` : 'javascript:void(0)'}
|
||||
class='relative inline-flex items-center rounded-r-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||
title='Next'
|
||||
>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>CardDAV URLs:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/principals/</code>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav/addressbooks/</code>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,471 +0,0 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Contact } from '/lib/types.ts';
|
||||
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||
|
||||
interface ViewContactProps {
|
||||
initialContact: Contact;
|
||||
formData: Record<string, any>;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function formFields(contact: Contact) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'name_title',
|
||||
label: 'Honorary title/prefix',
|
||||
type: 'text',
|
||||
placeholder: 'Dr.',
|
||||
value: contact.extra.name_title,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'first_name',
|
||||
label: 'First name',
|
||||
type: 'text',
|
||||
placeholder: 'John',
|
||||
value: contact.first_name,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'middle_names',
|
||||
label: 'Middle name(s)',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
value: contact.extra.middle_names?.map((name) => (name || '').trim()).filter(Boolean).join(' '),
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'last_name',
|
||||
label: 'Last name',
|
||||
type: 'text',
|
||||
placeholder: 'Doe',
|
||||
value: contact.last_name,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'birthday',
|
||||
label: 'Birthday',
|
||||
type: 'text',
|
||||
placeholder: 'YYYYMMDD',
|
||||
value: contact.extra.birthday,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'nickname',
|
||||
label: 'Nickname',
|
||||
type: 'text',
|
||||
placeholder: 'Johnny',
|
||||
value: contact.extra.nickname,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'organization',
|
||||
label: 'Company/Organization',
|
||||
type: 'text',
|
||||
placeholder: 'Acme Corporation',
|
||||
value: contact.extra.organization,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
label: 'Job/Role',
|
||||
type: 'text',
|
||||
placeholder: '(Super) Genius',
|
||||
value: contact.extra.role,
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'photo_url',
|
||||
label: 'Photo URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com/image.jpg',
|
||||
value: contact.extra.photo_url,
|
||||
required: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Phones
|
||||
const phones = contact.extra.fields?.filter((field) => field.type === 'phone') || [];
|
||||
for (const [index, phone] of phones.entries()) {
|
||||
fields.push({
|
||||
name: 'phone_numbers',
|
||||
label: `Phone number #${index + 1}`,
|
||||
type: 'tel',
|
||||
placeholder: '+44 0000 111 2222',
|
||||
value: phone.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'phone_labels',
|
||||
label: `Phone label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: phone.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'phone_numbers',
|
||||
label: `Phone number #${phones.length + 1}`,
|
||||
type: 'tel',
|
||||
placeholder: '+44 0000 111 2222',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'phone_labels',
|
||||
label: `Phone label #${phones.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// Emails
|
||||
const emails = contact.extra.fields?.filter((field) => field.type === 'email') || [];
|
||||
for (const [index, email] of emails.entries()) {
|
||||
fields.push({
|
||||
name: 'email_addresses',
|
||||
label: `Email #${index + 1}`,
|
||||
type: 'email',
|
||||
placeholder: 'user@example.com',
|
||||
value: email.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'email_labels',
|
||||
label: `Email label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: email.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'email_addresses',
|
||||
label: `Email #${emails.length + 1}`,
|
||||
type: 'email',
|
||||
placeholder: 'user@example.com',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'email_labels',
|
||||
label: `Email label #${emails.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// URLs
|
||||
const urls = contact.extra.fields?.filter((field) => field.type === 'url') || [];
|
||||
for (const [index, url] of urls.entries()) {
|
||||
fields.push({
|
||||
name: 'url_addresses',
|
||||
label: `URL #${index + 1}`,
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
value: url.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'url_labels',
|
||||
label: `URL label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: url.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'url_addresses',
|
||||
label: `URL #${urls.length + 1}`,
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'url_labels',
|
||||
label: `URL label #${urls.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// Others
|
||||
const others = contact.extra.fields?.filter((field) => field.type === 'other') || [];
|
||||
for (const [index, other] of others.entries()) {
|
||||
fields.push({
|
||||
name: 'other_values',
|
||||
label: `Other contact #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '@acme',
|
||||
value: other.value,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'other_labels',
|
||||
label: `Other label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: other.name,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'other_values',
|
||||
label: `Other contact #${others.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '@acme',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'other_labels',
|
||||
label: `Other label #${others.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
// Addresses
|
||||
const addresses = contact.extra.addresses || [];
|
||||
for (const [index, address] of addresses.entries()) {
|
||||
fields.push({
|
||||
name: 'address_line_1s',
|
||||
label: `Address line 1 #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '992 Tyburn Rd',
|
||||
value: address.line_1,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_line_2s',
|
||||
label: `Address line 2 #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Apt 2',
|
||||
value: address.line_2,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_cities',
|
||||
label: `Address city #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Birmingham',
|
||||
value: address.city,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_postal_codes',
|
||||
label: `Address postal code #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'B24 0TL',
|
||||
value: address.postal_code,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_states',
|
||||
label: `Address state #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'West Midlands',
|
||||
value: address.state,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_countries',
|
||||
label: `Address country #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'United Kingdom',
|
||||
value: address.country,
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'address_labels',
|
||||
label: `Address label #${index + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: address.label,
|
||||
required: false,
|
||||
});
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: 'address_line_1s',
|
||||
label: `Address line 1 #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: '992 Tyburn Rd',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_line_2s',
|
||||
label: `Address line 2 #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Apt 2',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_cities',
|
||||
label: `Address city #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Birmingham',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_postal_codes',
|
||||
label: `Address postal code #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'B24 0TL',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_states',
|
||||
label: `Address state #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'West Midlands',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_countries',
|
||||
label: `Address country #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'United Kingdom',
|
||||
value: '',
|
||||
required: false,
|
||||
}, {
|
||||
name: 'address_labels',
|
||||
label: `Address label #${addresses.length + 1}`,
|
||||
type: 'text',
|
||||
placeholder: 'Home, Work, etc.',
|
||||
value: '',
|
||||
required: false,
|
||||
});
|
||||
|
||||
fields.push({
|
||||
name: 'notes',
|
||||
label: 'Notes',
|
||||
type: 'textarea',
|
||||
placeholder: 'Some notes...',
|
||||
value: contact.extra.notes,
|
||||
required: false,
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function ViewContact({ initialContact, formData: formDataObject, error, notice }: ViewContactProps) {
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const contact = useSignal<Contact>(initialContact);
|
||||
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
async function onClickDeleteContact() {
|
||||
if (confirm('Are you sure you want to delete this contact?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { contactId: contact.value.id, page: 1 };
|
||||
const response = await fetch(`/api/contacts/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete contact!');
|
||||
}
|
||||
|
||||
window.location.href = '/contacts';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<a href='/contacts' class='mr-2'>View contacts</a>
|
||||
<section class='flex items-center'>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 ml-2'
|
||||
type='button'
|
||||
title='Delete contact'
|
||||
onClick={() => onClickDeleteContact()}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
alt='Delete contact'
|
||||
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>Failed to update!</h3>
|
||||
<p>{error}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{notice
|
||||
? (
|
||||
<section class='notification-success'>
|
||||
<h3>Success!</h3>
|
||||
<p>{notice}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(contact.peek()).map((field) => generateFieldHtml(field, formData))}
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Update contact</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
islands/files/FilesWrapper.tsx
Normal file
21
islands/files/FilesWrapper.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import MainFiles from '/components/files/MainFiles.tsx';
|
||||
|
||||
interface FilesWrapperProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||
export default function FilesWrapper(
|
||||
{ initialDirectories, initialFiles, initialPath }: FilesWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainFiles
|
||||
initialDirectories={initialDirectories}
|
||||
initialFiles={initialFiles}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user