From 635ca90de0a7d767ff488c66e105ddf33688f283 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Sat, 27 Apr 2024 08:12:44 +0100 Subject: [PATCH] Add Photos UI --- README.md | 4 +- components/files/FilesBreadcrumb.tsx | 37 +++-- components/files/ListFiles.tsx | 232 +++++++++++++++------------ components/files/ListPhotos.tsx | 226 ++++++++++++++++++++++++++ components/photos/ListPhotos.tsx | 66 ++++++++ components/photos/MainPhotos.tsx | 224 ++++++++++++++++++++++++++ fresh.gen.ts | 4 + islands/notes/Note.tsx | 2 +- islands/photos/PhotosWrapper.tsx | 21 +++ lib/utils/photos.ts | 3 + routes/notes.tsx | 2 +- routes/photos.tsx | 58 +++++++ 12 files changed, 757 insertions(+), 122 deletions(-) create mode 100644 components/files/ListPhotos.tsx create mode 100644 components/photos/ListPhotos.tsx create mode 100644 components/photos/MainPhotos.tsx create mode 100644 islands/photos/PhotosWrapper.tsx create mode 100644 lib/utils/photos.ts create mode 100644 routes/photos.tsx diff --git a/README.md b/README.md index 4548b1a..a3ed4db 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,9 @@ Just push to the `main` branch. - [x] WebDav Server - [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] Add photo auto-uplod support for mobile client +- [x] Add photo auto-upload support for mobile client - [x] Notes UI -- [ ] Photos UI +- [x] Photos UI - [ ] Add notes view support for mobile app - [ ] Add notes edit support for mobile app - [ ] Respect `CONFIG_ENABLED_APPS` in `.env` for enabling apps diff --git a/components/files/FilesBreadcrumb.tsx b/components/files/FilesBreadcrumb.tsx index 324d7d8..79c9b98 100644 --- a/components/files/FilesBreadcrumb.tsx +++ b/components/files/FilesBreadcrumb.tsx @@ -1,37 +1,44 @@ interface FilesBreadcrumbProps { path: string; isShowingNotes?: boolean; + isShowingPhotos?: boolean; } -export default function FilesBreadcrumb({ path, isShowingNotes }: FilesBreadcrumbProps) { - const routePath = isShowingNotes ? 'notes' : 'files'; +export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos }: FilesBreadcrumbProps) { + let routePath = 'files'; + let rootPath = '/'; - if (!isShowingNotes && path === '/') { - return ( -

- All files -

- ); + if (isShowingNotes) { + routePath = 'notes'; + rootPath = '/Notes/'; + } else if (isShowingPhotos) { + routePath = 'photos'; + rootPath = '/Photos/'; } - if (isShowingNotes && path === '/Notes/') { + const itemPluralLabel = routePath; + + if (path === rootPath) { return (

- All notes + All {itemPluralLabel}

); } const pathParts = path.slice(1, -1).split('/'); - if (isShowingNotes) { - pathParts.shift(); - } - return (

- {isShowingNotes ? All notes : All files} + {!isShowingNotes && !isShowingPhotos ? All files : null} + {isShowingNotes ? All notes : null} + {isShowingPhotos ? All photos : null} {pathParts.map((part, index) => { + // Ignore the first directory in special ones + if (index === 0 && (isShowingNotes || isShowingPhotos)) { + return null; + } + if (index === pathParts.length - 1) { return ( <> diff --git a/components/files/ListFiles.tsx b/components/files/ListFiles.tsx index e37b572..45199b2 100644 --- a/components/files/ListFiles.tsx +++ b/components/files/ListFiles.tsx @@ -8,9 +8,10 @@ interface ListFilesProps { onClickOpenRenameFile?: (parentPath: string, name: string) => void; onClickOpenMoveDirectory?: (parentPath: string, name: string) => void; onClickOpenMoveFile?: (parentPath: string, name: string) => void; - onClickDeleteDirectory: (parentPath: string, name: string) => Promise; - onClickDeleteFile: (parentPath: string, name: string) => Promise; + onClickDeleteDirectory?: (parentPath: string, name: string) => Promise; + onClickDeleteFile?: (parentPath: string, name: string) => Promise; isShowingNotes?: boolean; + isShowingPhotos?: boolean; } export default function ListFiles( @@ -24,6 +25,7 @@ export default function ListFiles( onClickDeleteDirectory, onClickDeleteFile, isShowingNotes, + isShowingPhotos, }: ListFilesProps, ) { const dateFormat = new Intl.DateTimeFormat('en-GB', { @@ -35,9 +37,23 @@ export default function ListFiles( minute: '2-digit', }); - const routePath = isShowingNotes ? 'notes' : 'files'; - const itemSingleLabel = isShowingNotes ? 'note' : 'file'; - const itemPluralLabel = routePath; + let routePath = 'files'; + let itemSingleLabel = 'file'; + 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 (
@@ -46,8 +62,10 @@ export default function ListFiles( Name Last update - {isShowingNotes ? null : Size} - + {isShowingNotes || isShowingPhotos + ? null + : Size} + {isShowingPhotos ? null : } @@ -75,59 +93,63 @@ export default function ListFiles( {dateFormat.format(new Date(directory.updated_at))} - {isShowingNotes ? null : ( + {isShowingNotes || isShowingPhotos ? null : ( - )} - - {(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' || - typeof onClickOpenMoveDirectory === 'undefined') - ? null - : ( -
- - - -
- )} - + {isShowingPhotos ? null : ( + + {(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' || + typeof onClickOpenMoveDirectory === 'undefined') + ? null + : ( +
+ + + {typeof onClickDeleteDirectory === 'undefined' ? null : ( + + )} +
+ )} + + )} ); })} @@ -159,53 +181,57 @@ export default function ListFiles( {humanFileSize(file.size_in_bytes)} )} - -
- {typeof onClickOpenRenameFile === 'undefined' ? null : ( - - )} - {typeof onClickOpenMoveFile === 'undefined' ? null : ( - - )} - -
- + {isShowingPhotos ? null : ( + +
+ {typeof onClickOpenRenameFile === 'undefined' ? null : ( + + )} + {typeof onClickOpenMoveFile === 'undefined' ? null : ( + + )} + {typeof onClickDeleteFile === 'undefined' ? null : ( + + )} +
+ + )} ))} {directories.length === 0 && files.length === 0 diff --git a/components/files/ListPhotos.tsx b/components/files/ListPhotos.tsx new file mode 100644 index 0000000..e37b572 --- /dev/null +++ b/components/files/ListPhotos.tsx @@ -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; + onClickDeleteFile: (parentPath: string, name: string) => Promise; + 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 ( +
+ + + + + + {isShowingNotes ? null : } + + + + + {directories.map((directory) => { + const fullPath = `${directory.parent_path}${directory.directory_name}/`; + + return ( + + + + {isShowingNotes ? null : ( + + )} + + + ); + })} + {files.map((file) => ( + + + + {isShowingNotes ? null : ( + + )} + + + ))} + {directories.length === 0 && files.length === 0 + ? ( + + + + ) + : null} + +
NameLast updateSize
+ + Directory + {directory.directory_name} + + + {dateFormat.format(new Date(directory.updated_at))} + + - + + {(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' || + typeof onClickOpenMoveDirectory === 'undefined') + ? null + : ( +
+ + + +
+ )} +
+ + File + {file.file_name} + + + {dateFormat.format(new Date(file.updated_at))} + + {humanFileSize(file.size_in_bytes)} + +
+ {typeof onClickOpenRenameFile === 'undefined' ? null : ( + + )} + {typeof onClickOpenMoveFile === 'undefined' ? null : ( + + )} + +
+
+
+
No {itemPluralLabel} to show
+
+
+
+ ); +} diff --git a/components/photos/ListPhotos.tsx b/components/photos/ListPhotos.tsx new file mode 100644 index 0000000..d740229 --- /dev/null +++ b/components/photos/ListPhotos.tsx @@ -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 ( +
+ {files.length === 0 + ? ( +
+
No photos to show
+
+ ) + : ( +
+ {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 ( + + ); + })} +
+ )} +
+ ); +} diff --git a/components/photos/MainPhotos.tsx b/components/photos/MainPhotos.tsx new file mode 100644 index 0000000..5a92c71 --- /dev/null +++ b/components/photos/MainPhotos.tsx @@ -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(false); + const isUploading = useSignal(false); + const directories = useSignal(initialDirectories); + const files = useSignal(initialFiles); + const path = useSignal(initialPath); + const areNewOptionsOption = useSignal(false); + const isNewDirectoryModalOpen = useSignal(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 ( + <> +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + + + + + {isAdding.value + ? ( + <> + Creating... + + ) + : null} + {isUploading.value + ? ( + <> + Uploading... + + ) + : null} + {!isAdding.value && !isUploading.value ? <>  : null} + +
+ + + + ); +} diff --git a/fresh.gen.ts b/fresh.gen.ts index 17f91ee..1ce91a6 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -35,6 +35,7 @@ import * as $news from './routes/news.tsx'; import * as $news_feeds from './routes/news/feeds.tsx'; import * as $notes from './routes/notes.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 $signup from './routes/signup.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 $notes_Note from './islands/notes/Note.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'; const manifest = { @@ -82,6 +84,7 @@ const manifest = { './routes/news/feeds.tsx': $news_feeds, './routes/notes.tsx': $notes, './routes/notes/open/[fileName].tsx': $notes_open_fileName_, + './routes/photos.tsx': $photos, './routes/settings.tsx': $settings, './routes/signup.tsx': $signup, }, @@ -94,6 +97,7 @@ const manifest = { './islands/news/Feeds.tsx': $news_Feeds, './islands/notes/Note.tsx': $notes_Note, './islands/notes/NotesWrapper.tsx': $notes_NotesWrapper, + './islands/photos/PhotosWrapper.tsx': $photos_PhotosWrapper, }, baseUrl: import.meta.url, } satisfies Manifest; diff --git a/islands/notes/Note.tsx b/islands/notes/Note.tsx index 78c6683..b4e7f96 100644 --- a/islands/notes/Note.tsx +++ b/islands/notes/Note.tsx @@ -84,7 +84,7 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) { diff --git a/islands/photos/PhotosWrapper.tsx b/islands/photos/PhotosWrapper.tsx new file mode 100644 index 0000000..61c85a4 --- /dev/null +++ b/islands/photos/PhotosWrapper.tsx @@ -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 ( + + ); +} diff --git a/lib/utils/photos.ts b/lib/utils/photos.ts new file mode 100644 index 0000000..d29b1c0 --- /dev/null +++ b/lib/utils/photos.ts @@ -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; diff --git a/routes/notes.tsx b/routes/notes.tsx index ab6b14e..13f19c3 100644 --- a/routes/notes.tsx +++ b/routes/notes.tsx @@ -40,7 +40,7 @@ export const handler: Handlers = { }, }; -export default function FilesPage({ data }: PageProps) { +export default function NotesPage({ data }: PageProps) { return (
= { + 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) { + return ( +
+ +
+ ); +}