Add Photos UI

This commit is contained in:
Bruno Bernardino
2024-04-27 08:12:44 +01:00
parent 3f5422f8eb
commit 635ca90de0
12 changed files with 757 additions and 122 deletions

View File

@@ -68,9 +68,9 @@ Just push to the `main` branch.
- [x] WebDav Server - [x] WebDav Server
- [x] [Desktop app for selective file sync](https://github.com/bewcloud/bewcloud-desktop/releases) (`rclone` via WebDav) - [x] [Desktop app for selective file sync](https://github.com/bewcloud/bewcloud-desktop/releases) (`rclone` via WebDav)
- [x] [Mobile app for offline file view](https://github.com/bewcloud/bewcloud-mobile/releases) (API + WebDav client) - [x] [Mobile app for offline file view](https://github.com/bewcloud/bewcloud-mobile/releases) (API + WebDav client)
- [x] Add photo auto-uplod support for mobile client - [x] Add photo auto-upload support for mobile client
- [x] Notes UI - [x] Notes UI
- [ ] Photos UI - [x] Photos UI
- [ ] Add notes view support for mobile app - [ ] Add notes view support for mobile app
- [ ] Add notes edit support for mobile app - [ ] Add notes edit support for mobile app
- [ ] Respect `CONFIG_ENABLED_APPS` in `.env` for enabling apps - [ ] Respect `CONFIG_ENABLED_APPS` in `.env` for enabling apps

View File

@@ -1,37 +1,44 @@
interface FilesBreadcrumbProps { interface FilesBreadcrumbProps {
path: string; path: string;
isShowingNotes?: boolean; isShowingNotes?: boolean;
isShowingPhotos?: boolean;
} }
export default function FilesBreadcrumb({ path, isShowingNotes }: FilesBreadcrumbProps) { export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos }: FilesBreadcrumbProps) {
const routePath = isShowingNotes ? 'notes' : 'files'; let routePath = 'files';
let rootPath = '/';
if (!isShowingNotes && path === '/') { if (isShowingNotes) {
return ( routePath = 'notes';
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'> rootPath = '/Notes/';
All files } else if (isShowingPhotos) {
</h3> routePath = 'photos';
); rootPath = '/Photos/';
} }
if (isShowingNotes && path === '/Notes/') { const itemPluralLabel = routePath;
if (path === rootPath) {
return ( return (
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'> <h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
All notes All {itemPluralLabel}
</h3> </h3>
); );
} }
const pathParts = path.slice(1, -1).split('/'); const pathParts = path.slice(1, -1).split('/');
if (isShowingNotes) {
pathParts.shift();
}
return ( return (
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'> <h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
{isShowingNotes ? <a href={`/notes?path=/Notes/`}>All notes</a> : <a href={`/files?path=/`}>All files</a>} {!isShowingNotes && !isShowingPhotos ? <a href={`/files?path=/`}>All files</a> : null}
{isShowingNotes ? <a href={`/notes?path=/Notes/`}>All notes</a> : null}
{isShowingPhotos ? <a href={`/photos?path=/Photos/`}>All photos</a> : null}
{pathParts.map((part, index) => { {pathParts.map((part, index) => {
// Ignore the first directory in special ones
if (index === 0 && (isShowingNotes || isShowingPhotos)) {
return null;
}
if (index === pathParts.length - 1) { if (index === pathParts.length - 1) {
return ( return (
<> <>

View File

@@ -8,9 +8,10 @@ interface ListFilesProps {
onClickOpenRenameFile?: (parentPath: string, name: string) => void; onClickOpenRenameFile?: (parentPath: string, name: string) => void;
onClickOpenMoveDirectory?: (parentPath: string, name: string) => void; onClickOpenMoveDirectory?: (parentPath: string, name: string) => void;
onClickOpenMoveFile?: (parentPath: string, name: string) => void; onClickOpenMoveFile?: (parentPath: string, name: string) => void;
onClickDeleteDirectory: (parentPath: string, name: string) => Promise<void>; onClickDeleteDirectory?: (parentPath: string, name: string) => Promise<void>;
onClickDeleteFile: (parentPath: string, name: string) => Promise<void>; onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>;
isShowingNotes?: boolean; isShowingNotes?: boolean;
isShowingPhotos?: boolean;
} }
export default function ListFiles( export default function ListFiles(
@@ -24,6 +25,7 @@ export default function ListFiles(
onClickDeleteDirectory, onClickDeleteDirectory,
onClickDeleteFile, onClickDeleteFile,
isShowingNotes, isShowingNotes,
isShowingPhotos,
}: ListFilesProps, }: ListFilesProps,
) { ) {
const dateFormat = new Intl.DateTimeFormat('en-GB', { const dateFormat = new Intl.DateTimeFormat('en-GB', {
@@ -35,9 +37,23 @@ export default function ListFiles(
minute: '2-digit', minute: '2-digit',
}); });
const routePath = isShowingNotes ? 'notes' : 'files'; let routePath = 'files';
const itemSingleLabel = isShowingNotes ? 'note' : 'file'; let itemSingleLabel = 'file';
const itemPluralLabel = routePath; let itemPluralLabel = 'files';
if (isShowingNotes) {
routePath = 'notes';
itemSingleLabel = 'note';
itemPluralLabel = 'notes';
} else if (isShowingPhotos) {
routePath = 'photos';
itemSingleLabel = 'photo';
itemPluralLabel = 'photos';
}
if (isShowingPhotos && directories.length === 0) {
return null;
}
return ( return (
<section class='mx-auto max-w-7xl my-8'> <section class='mx-auto max-w-7xl my-8'>
@@ -46,8 +62,10 @@ export default function ListFiles(
<tr class='border-b border-slate-600'> <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'>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-56'>Last update</th>
{isShowingNotes ? null : <th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>} {isShowingNotes || isShowingPhotos
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th> ? null
: <th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>}
{isShowingPhotos ? null : <th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>}
</tr> </tr>
</thead> </thead>
<tbody class='divide-y divide-slate-600 border-t border-slate-600'> <tbody class='divide-y divide-slate-600 border-t border-slate-600'>
@@ -75,59 +93,63 @@ export default function ListFiles(
<td class='px-6 py-4 text-slate-200'> <td class='px-6 py-4 text-slate-200'>
{dateFormat.format(new Date(directory.updated_at))} {dateFormat.format(new Date(directory.updated_at))}
</td> </td>
{isShowingNotes ? null : ( {isShowingNotes || isShowingPhotos ? null : (
<td class='px-6 py-4 text-slate-200'> <td class='px-6 py-4 text-slate-200'>
- -
</td> </td>
)} )}
<td class='px-6 py-4'> {isShowingPhotos ? null : (
{(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' || <td class='px-6 py-4'>
typeof onClickOpenMoveDirectory === 'undefined') {(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' ||
? null typeof onClickOpenMoveDirectory === 'undefined')
: ( ? null
<section class='flex items-center justify-end w-20'> : (
<span <section class='flex items-center justify-end w-20'>
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' <span
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)} 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' <img
class='white drop-shadow-md' src='/images/rename.svg'
width={18} class='white drop-shadow-md'
height={18} width={18}
alt='Rename directory' height={18}
title='Rename directory' alt='Rename directory'
/> title='Rename directory'
</span> />
<span </span>
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' <span
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)} 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' <img
class='white drop-shadow-md' src='/images/move.svg'
width={18} class='white drop-shadow-md'
height={18} width={18}
alt='Move directory' height={18}
title='Move directory' alt='Move directory'
/> title='Move directory'
</span> />
<span </span>
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100' {typeof onClickDeleteDirectory === 'undefined' ? null : (
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)} <span
> class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
<img onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
src='/images/delete.svg' >
class='red drop-shadow-md' <img
width={20} src='/images/delete.svg'
height={20} class='red drop-shadow-md'
alt='Delete directory' width={20}
title='Delete directory' height={20}
/> alt='Delete directory'
</span> title='Delete directory'
</section> />
)} </span>
</td> )}
</section>
)}
</td>
)}
</tr> </tr>
); );
})} })}
@@ -159,53 +181,57 @@ export default function ListFiles(
{humanFileSize(file.size_in_bytes)} {humanFileSize(file.size_in_bytes)}
</td> </td>
)} )}
<td class='px-6 py-4'> {isShowingPhotos ? null : (
<section class='flex items-center justify-end w-20'> <td class='px-6 py-4'>
{typeof onClickOpenRenameFile === 'undefined' ? null : ( <section class='flex items-center justify-end w-20'>
<span {typeof onClickOpenRenameFile === 'undefined' ? null : (
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' <span
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)} 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' <img
class='white drop-shadow-md' src='/images/rename.svg'
width={18} class='white drop-shadow-md'
height={18} width={18}
alt={`Rename ${itemSingleLabel}`} height={18}
title={`Rename ${itemSingleLabel}`} alt={`Rename ${itemSingleLabel}`}
/> title={`Rename ${itemSingleLabel}`}
</span> />
)} </span>
{typeof onClickOpenMoveFile === 'undefined' ? null : ( )}
<span {typeof onClickOpenMoveFile === 'undefined' ? null : (
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2' <span
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)} 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' <img
class='white drop-shadow-md' src='/images/move.svg'
width={18} class='white drop-shadow-md'
height={18} width={18}
alt={`Move ${itemSingleLabel}`} height={18}
title={`Move ${itemSingleLabel}`} alt={`Move ${itemSingleLabel}`}
/> title={`Move ${itemSingleLabel}`}
</span> />
)} </span>
<span )}
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100' {typeof onClickDeleteFile === 'undefined' ? null : (
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)} <span
> class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
<img onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
src='/images/delete.svg' >
class='red drop-shadow-md' <img
width={20} src='/images/delete.svg'
height={20} class='red drop-shadow-md'
alt={`Delete ${itemSingleLabel}`} width={20}
title={`Delete ${itemSingleLabel}`} height={20}
/> alt={`Delete ${itemSingleLabel}`}
</span> title={`Delete ${itemSingleLabel}`}
</section> />
</td> </span>
)}
</section>
</td>
)}
</tr> </tr>
))} ))}
{directories.length === 0 && files.length === 0 {directories.length === 0 && files.length === 0

View File

@@ -0,0 +1,226 @@
import { Directory, DirectoryFile } from '/lib/types.ts';
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
interface ListFilesProps {
directories: Directory[];
files: DirectoryFile[];
onClickOpenRenameDirectory?: (parentPath: string, name: string) => void;
onClickOpenRenameFile?: (parentPath: string, name: string) => void;
onClickOpenMoveDirectory?: (parentPath: string, name: string) => void;
onClickOpenMoveFile?: (parentPath: string, name: string) => void;
onClickDeleteDirectory: (parentPath: string, name: string) => Promise<void>;
onClickDeleteFile: (parentPath: string, name: string) => Promise<void>;
isShowingNotes?: boolean;
}
export default function ListFiles(
{
directories,
files,
onClickOpenRenameDirectory,
onClickOpenRenameFile,
onClickOpenMoveDirectory,
onClickOpenMoveFile,
onClickDeleteDirectory,
onClickDeleteFile,
isShowingNotes,
}: ListFilesProps,
) {
const dateFormat = new Intl.DateTimeFormat('en-GB', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour12: false,
hour: '2-digit',
minute: '2-digit',
});
const routePath = isShowingNotes ? 'notes' : 'files';
const itemSingleLabel = isShowingNotes ? 'note' : 'file';
const itemPluralLabel = routePath;
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>
{isShowingNotes ? null : <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={`/${routePath}?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>
{isShowingNotes ? null : (
<td class='px-6 py-4 text-slate-200'>
-
</td>
)}
<td class='px-6 py-4'>
{(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' ||
typeof onClickOpenMoveDirectory === 'undefined')
? 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={`/${routePath}/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>
{isShowingNotes ? null : (
<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'>
{typeof onClickOpenRenameFile === 'undefined' ? null : (
<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 ${itemSingleLabel}`}
title={`Rename ${itemSingleLabel}`}
/>
</span>
)}
{typeof onClickOpenMoveFile === 'undefined' ? null : (
<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 ${itemSingleLabel}`}
title={`Move ${itemSingleLabel}`}
/>
</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 ${itemSingleLabel}`}
title={`Delete ${itemSingleLabel}`}
/>
</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 {itemPluralLabel} to show</div>
</div>
</td>
</tr>
)
: null}
</tbody>
</table>
</section>
);
}

View File

@@ -0,0 +1,66 @@
import { DirectoryFile } from '/lib/types.ts';
import { PHOTO_IMAGE_EXTENSIONS, PHOTO_VIDEO_EXTENSIONS } from '/lib/utils/photos.ts';
interface ListPhotosProps {
files: DirectoryFile[];
}
export default function ListPhotos(
{
files,
}: ListPhotosProps,
) {
return (
<section class='mx-auto max-w-7xl my-8'>
{files.length === 0
? (
<article class='px-6 py-4 font-normal text-center w-full'>
<div class='font-medium text-slate-400 text-md'>No photos to show</div>
</article>
)
: (
<section class='w-full grid grid-cols-2 md:grid-cols-3 gap-4'>
{files.map((file) => {
const lowercaseFileName = file.file_name.toLowerCase();
const extensionName = lowercaseFileName.split('.').pop() || '';
const isImage = PHOTO_IMAGE_EXTENSIONS.some((extension) => extension === extensionName);
const isVideo = PHOTO_VIDEO_EXTENSIONS.some((extension) => extension === extensionName);
return (
<article class='hover:opacity-70'>
<a
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
class='flex items-center'
target='_blank'
rel='noopener noreferrer'
>
{isVideo
? (
<video class='h-auto max-w-full rounded-md' title={file.file_name}>
<source
src={`/files/open/${file.file_name}?path=${file.parent_path}`}
type={`video/${extensionName}`}
/>
</video>
)
: null}
{isImage
? (
<img
src={`/files/open/${file.file_name}?path=${file.parent_path}`}
class='h-auto max-w-full rounded-md'
alt={file.file_name}
title={file.file_name}
/>
)
: null}
</a>
</article>
);
})}
</section>
)}
</section>
);
}

View File

@@ -0,0 +1,224 @@
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 CreateDirectoryRequestBody,
ResponseBody as CreateDirectoryResponseBody,
} from '/routes/api/files/create-directory.tsx';
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
import ListFiles from '/components/files/ListFiles.tsx';
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
import ListPhotos from '/components/photos/ListPhotos.tsx';
interface MainPhotosProps {
initialDirectories: Directory[];
initialFiles: DirectoryFile[];
initialPath: string;
}
export default function MainPhotos({ initialDirectories, initialFiles, initialPath }: MainPhotosProps) {
const isAdding = useSignal<boolean>(false);
const isUploading = 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);
function onClickUploadFile() {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.multiple = true;
fileInput.accept = 'image/*,video/*';
fileInput.click();
fileInput.onchange = async (event) => {
const chosenFilesList = (event.target as HTMLInputElement)?.files!;
const chosenFiles = Array.from(chosenFilesList);
isUploading.value = true;
for (const chosenFile of chosenFiles) {
if (!chosenFile) {
continue;
}
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 photo!');
}
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;
}
return (
<>
<section class='flex flex-row items-center justify-between mb-4'>
<section class='flex items-center justify-end w-full'>
<FilesBreadcrumb path={path.value} isShowingPhotos={true} />
<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 file or directory'
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 Photo
</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={[]}
isShowingPhotos={true}
/>
<ListPhotos
files={files.value}
/>
<span
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
>
{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}
{!isAdding.value && !isUploading.value ? <>&nbsp;</> : null}
</span>
</section>
<CreateDirectoryModal
isOpen={isNewDirectoryModalOpen.value}
onClickSave={onClickSaveDirectory}
onClose={onCloseCreateDirectory}
/>
</>
);
}

View File

@@ -35,6 +35,7 @@ import * as $news from './routes/news.tsx';
import * as $news_feeds from './routes/news/feeds.tsx'; import * as $news_feeds from './routes/news/feeds.tsx';
import * as $notes from './routes/notes.tsx'; import * as $notes from './routes/notes.tsx';
import * as $notes_open_fileName_ from './routes/notes/open/[fileName].tsx'; import * as $notes_open_fileName_ from './routes/notes/open/[fileName].tsx';
import * as $photos from './routes/photos.tsx';
import * as $settings from './routes/settings.tsx'; import * as $settings from './routes/settings.tsx';
import * as $signup from './routes/signup.tsx'; import * as $signup from './routes/signup.tsx';
import * as $Settings from './islands/Settings.tsx'; import * as $Settings from './islands/Settings.tsx';
@@ -45,6 +46,7 @@ import * as $news_Articles from './islands/news/Articles.tsx';
import * as $news_Feeds from './islands/news/Feeds.tsx'; import * as $news_Feeds from './islands/news/Feeds.tsx';
import * as $notes_Note from './islands/notes/Note.tsx'; import * as $notes_Note from './islands/notes/Note.tsx';
import * as $notes_NotesWrapper from './islands/notes/NotesWrapper.tsx'; import * as $notes_NotesWrapper from './islands/notes/NotesWrapper.tsx';
import * as $photos_PhotosWrapper from './islands/photos/PhotosWrapper.tsx';
import { type Manifest } from '$fresh/server.ts'; import { type Manifest } from '$fresh/server.ts';
const manifest = { const manifest = {
@@ -82,6 +84,7 @@ const manifest = {
'./routes/news/feeds.tsx': $news_feeds, './routes/news/feeds.tsx': $news_feeds,
'./routes/notes.tsx': $notes, './routes/notes.tsx': $notes,
'./routes/notes/open/[fileName].tsx': $notes_open_fileName_, './routes/notes/open/[fileName].tsx': $notes_open_fileName_,
'./routes/photos.tsx': $photos,
'./routes/settings.tsx': $settings, './routes/settings.tsx': $settings,
'./routes/signup.tsx': $signup, './routes/signup.tsx': $signup,
}, },
@@ -94,6 +97,7 @@ const manifest = {
'./islands/news/Feeds.tsx': $news_Feeds, './islands/news/Feeds.tsx': $news_Feeds,
'./islands/notes/Note.tsx': $notes_Note, './islands/notes/Note.tsx': $notes_Note,
'./islands/notes/NotesWrapper.tsx': $notes_NotesWrapper, './islands/notes/NotesWrapper.tsx': $notes_NotesWrapper,
'./islands/photos/PhotosWrapper.tsx': $photos_PhotosWrapper,
}, },
baseUrl: import.meta.url, baseUrl: import.meta.url,
} satisfies Manifest; } satisfies Manifest;

View File

@@ -84,7 +84,7 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
</textarea> </textarea>
<span <span
class={`flex justify-end items-center text-sm mt-1 mx-2 ${ class={`flex justify-end items-center text-sm mt-1 mx-auto max-w-7xl ${
hasSaved.value ? 'text-green-600' : 'text-slate-100' hasSaved.value ? 'text-green-600' : 'text-slate-100'
}`} }`}
> >

View File

@@ -0,0 +1,21 @@
import { Directory, DirectoryFile } from '/lib/types.ts';
import MainPhotos from '/components/photos/MainPhotos.tsx';
interface PhotosWrapperProps {
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 PhotosWrapper(
{ initialDirectories, initialFiles, initialPath }: PhotosWrapperProps,
) {
return (
<MainPhotos
initialDirectories={initialDirectories}
initialFiles={initialFiles}
initialPath={initialPath}
/>
);
}

3
lib/utils/photos.ts Normal file
View File

@@ -0,0 +1,3 @@
export const PHOTO_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'svg', 'bmp', 'gif', 'tiff', 'webp', 'heic'] as const;
export const PHOTO_VIDEO_EXTENSIONS = ['heif', 'webm', 'mp4', 'mov'] as const;
export const PHOTO_EXTENSIONS = [...PHOTO_IMAGE_EXTENSIONS, ...PHOTO_VIDEO_EXTENSIONS] as const;

View File

@@ -40,7 +40,7 @@ export const handler: Handlers<Data, FreshContextState> = {
}, },
}; };
export default function FilesPage({ data }: PageProps<Data, FreshContextState>) { export default function NotesPage({ data }: PageProps<Data, FreshContextState>) {
return ( return (
<main> <main>
<NotesWrapper <NotesWrapper

58
routes/photos.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { Handlers, PageProps } from 'fresh/server.ts';
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
import { getDirectories, getFiles } from '/lib/data/files.ts';
import { PHOTO_EXTENSIONS } from '/lib/utils/photos.ts';
import PhotosWrapper from '/islands/photos/PhotosWrapper.tsx';
interface Data {
userDirectories: Directory[];
userPhotos: DirectoryFile[];
currentPath: string;
}
export const handler: Handlers<Data, FreshContextState> = {
async GET(request, context) {
if (!context.state.user) {
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
}
const searchParams = new URL(request.url).searchParams;
let currentPath = searchParams.get('path') || '/Photos/';
// Send invalid paths back to Photos root
if (!currentPath.startsWith('/Photos/') || currentPath.includes('../')) {
currentPath = '/Photos/';
}
// Always append a trailing slash
if (!currentPath.endsWith('/')) {
currentPath = `${currentPath}/`;
}
const userDirectories = await getDirectories(context.state.user.id, currentPath);
const userFiles = await getFiles(context.state.user.id, currentPath);
const userPhotos = userFiles.filter((file) => {
const lowercaseFileName = file.file_name.toLowerCase();
return PHOTO_EXTENSIONS.some((extension) => lowercaseFileName.endsWith(extension));
});
return await context.render({ userDirectories, userPhotos, currentPath });
},
};
export default function PhotosPage({ data }: PageProps<Data, FreshContextState>) {
return (
<main>
<PhotosWrapper
initialDirectories={data.userDirectories}
initialFiles={data.userPhotos}
initialPath={data.currentPath}
/>
</main>
);
}