From 3f5422f8eb61f0805687145fa891a0dfd7f55528 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Fri, 26 Apr 2024 14:31:25 +0100 Subject: [PATCH] Add basic Notes UI --- README.md | 27 ++- components/files/FilesBreadcrumb.tsx | 25 ++- components/files/ListFiles.tsx | 187 +++++++++-------- components/files/MainFiles.tsx | 2 +- components/notes/CreateNoteModal.tsx | 60 ++++++ components/notes/MainNotes.tsx | 302 +++++++++++++++++++++++++++ fresh.gen.ts | 10 + islands/notes/Note.tsx | 109 ++++++++++ islands/notes/NotesWrapper.tsx | 21 ++ lib/data/files.ts | 29 +++ routes/api/files/upload.tsx | 6 +- routes/api/notes/save.tsx | 66 ++++++ routes/notes.tsx | 53 +++++ routes/notes/open/[fileName].tsx | 56 +++++ 14 files changed, 849 insertions(+), 104 deletions(-) create mode 100644 components/notes/CreateNoteModal.tsx create mode 100644 components/notes/MainNotes.tsx create mode 100644 islands/notes/Note.tsx create mode 100644 islands/notes/NotesWrapper.tsx create mode 100644 routes/api/notes/save.tsx create mode 100644 routes/notes.tsx create mode 100644 routes/notes/open/[fileName].tsx diff --git a/README.md b/README.md index 67a6ebc..4548b1a 100644 --- a/README.md +++ b/README.md @@ -21,16 +21,16 @@ Alternatively, check the [Development section below](#development). > [!IMPORTANT] > Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin. -## Requirements - -This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work. - -For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed. - -Don't forget to set up your `.env` file based on `.env.sample`. - ## Development +### Requirements + +- Don't forget to set up your `.env` file based on `.env.sample`. +- This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work. +- For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed. + +### Commands + ```sh $ docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally $ make migrate-db # runs any missing database migrations @@ -39,7 +39,7 @@ $ make format # formats the code $ make test # runs tests ``` -## Other less-used commands +### Other less-used commands ```sh $ make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";` @@ -69,10 +69,11 @@ Just push to the `main` branch. - [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] Notes UI +- [ ] Photos UI - [ ] Add notes view support for mobile app - [ ] Add notes edit support for mobile app -- [ ] Notes UI -- [ ] Photos UI +- [ ] Respect `CONFIG_ENABLED_APPS` in `.env` for enabling apps ## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement? @@ -88,3 +89,7 @@ You can simply `ln -s /// [!NOTE] > If you're running the app with docker, the symlink needs to point to the container's directory, usually starting with `/app` if you didn't change the `Dockerfile`, otherwise the container will fail to load the linked directory. + +## How does it look? + +[Check the website](https://bewcloud.com) for screenshots or [the YouTube channel](https://www.youtube.com/@bewCloud) for 1-minute demos. diff --git a/components/files/FilesBreadcrumb.tsx b/components/files/FilesBreadcrumb.tsx index 43a3084..324d7d8 100644 --- a/components/files/FilesBreadcrumb.tsx +++ b/components/files/FilesBreadcrumb.tsx @@ -1,9 +1,12 @@ interface FilesBreadcrumbProps { path: string; + isShowingNotes?: boolean; } -export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) { - if (path === '/') { +export default function FilesBreadcrumb({ path, isShowingNotes }: FilesBreadcrumbProps) { + const routePath = isShowingNotes ? 'notes' : 'files'; + + if (!isShowingNotes && path === '/') { return (

All files @@ -11,17 +14,29 @@ export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) { ); } + if (isShowingNotes && path === '/Notes/') { + return ( +

+ All notes +

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

- All files + {isShowingNotes ? All notes : All files} {pathParts.map((part, index) => { if (index === pathParts.length - 1) { return ( <> / - {part} + {decodeURIComponent(part)} ); } @@ -35,7 +50,7 @@ export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) { return ( <> / - {part} + {decodeURIComponent(part)} ); })} diff --git a/components/files/ListFiles.tsx b/components/files/ListFiles.tsx index b4f9162..e37b572 100644 --- a/components/files/ListFiles.tsx +++ b/components/files/ListFiles.tsx @@ -4,12 +4,13 @@ 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; + 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( @@ -22,6 +23,7 @@ export default function ListFiles( onClickOpenMoveFile, onClickDeleteDirectory, onClickDeleteFile, + isShowingNotes, }: ListFilesProps, ) { const dateFormat = new Intl.DateTimeFormat('en-GB', { @@ -33,6 +35,10 @@ export default function ListFiles( minute: '2-digit', }); + const routePath = isShowingNotes ? 'notes' : 'files'; + const itemSingleLabel = isShowingNotes ? 'note' : 'file'; + const itemPluralLabel = routePath; + return (
@@ -40,7 +46,7 @@ export default function ListFiles( - + {isShowingNotes ? null : } @@ -52,7 +58,7 @@ export default function ListFiles( - + {isShowingNotes ? null : ( + + )} ); @@ -124,7 +135,7 @@ export default function ListFiles( - + {isShowingNotes ? null : ( + + )} diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx index 69c7793..ce59696 100644 --- a/components/files/MainFiles.tsx +++ b/components/files/MainFiles.tsx @@ -409,7 +409,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat + + + + + ); +} diff --git a/components/notes/MainNotes.tsx b/components/notes/MainNotes.tsx new file mode 100644 index 0000000..4b353ee --- /dev/null +++ b/components/notes/MainNotes.tsx @@ -0,0 +1,302 @@ +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 DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx'; +import { + RequestBody as CreateDirectoryRequestBody, + ResponseBody as CreateDirectoryResponseBody, +} from '/routes/api/files/create-directory.tsx'; +import { + RequestBody as DeleteDirectoryRequestBody, + ResponseBody as DeleteDirectoryResponseBody, +} from '/routes/api/files/delete-directory.tsx'; +import ListFiles from '/components/files/ListFiles.tsx'; +import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx'; +import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx'; +import CreateNoteModal from './CreateNoteModal.tsx'; + +interface MainNotesProps { + initialDirectories: Directory[]; + initialFiles: DirectoryFile[]; + initialPath: string; +} + +export default function MainNotes({ initialDirectories, initialFiles, initialPath }: MainNotesProps) { + const isAdding = useSignal(false); + const isDeleting = useSignal(false); + const directories = useSignal(initialDirectories); + const files = useSignal(initialFiles); + const path = useSignal(initialPath); + const areNewOptionsOption = useSignal(false); + const isNewNoteModalOpen = useSignal(false); + const isNewDirectoryModalOpen = useSignal(false); + + function onClickCreateNote() { + if (isNewNoteModalOpen.value) { + isNewNoteModalOpen.value = false; + return; + } + + isNewNoteModalOpen.value = true; + } + + async function onClickSaveNote(newNoteName: string) { + if (isAdding.value) { + return; + } + + if (!newNoteName) { + return; + } + + areNewOptionsOption.value = false; + isAdding.value = true; + + const requestBody = new FormData(); + requestBody.set('parent_path', path.value); + requestBody.set('name', `${newNoteName}.md`); + requestBody.set('contents', `# ${newNoteName}\n\nStart your new note!\n`); + + 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 create note!'); + } + + files.value = [...result.newFiles]; + + isNewNoteModalOpen.value = false; + } catch (error) { + console.error(error); + } + + isAdding.value = false; + } + + function onCloseCreateNote() { + isNewNoteModalOpen.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; + } + + async function onClickDeleteDirectory(parentPath: string, name: string) { + if (confirm('Are you sure you want to delete this directory?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteDirectoryRequestBody = { + parentPath, + name, + }; + const response = await fetch(`/api/files/delete-directory`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteDirectoryResponseBody; + + if (!result.success) { + throw new Error('Failed to delete directory!'); + } + + directories.value = [...result.newDirectories]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + async function onClickDeleteFile(parentPath: string, name: string) { + if (confirm('Are you sure you want to delete this note?')) { + if (isDeleting.value) { + return; + } + + isDeleting.value = true; + + try { + const requestBody: DeleteRequestBody = { + parentPath, + name, + }; + const response = await fetch(`/api/files/delete`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as DeleteResponseBody; + + if (!result.success) { + throw new Error('Failed to delete note!'); + } + + files.value = [...result.newFiles]; + } catch (error) { + console.error(error); + } + + isDeleting.value = false; + } + } + + return ( + <> +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + + + {isDeleting.value + ? ( + <> + Deleting... + + ) + : null} + {isAdding.value + ? ( + <> + Creating... + + ) + : null} + {!isDeleting.value && !isAdding.value ? <>  : null} + +
+ + + + + + ); +} diff --git a/fresh.gen.ts b/fresh.gen.ts index 3af51c0..17f91ee 100644 --- a/fresh.gen.ts +++ b/fresh.gen.ts @@ -23,6 +23,7 @@ import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx'; import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx'; import * as $api_news_mark_read from './routes/api/news/mark-read.tsx'; import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx'; +import * as $api_notes_save from './routes/api/notes/save.tsx'; import * as $dashboard from './routes/dashboard.tsx'; import * as $dav from './routes/dav.tsx'; import * as $files from './routes/files.tsx'; @@ -32,6 +33,8 @@ import * as $login from './routes/login.tsx'; import * as $logout from './routes/logout.tsx'; 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 $settings from './routes/settings.tsx'; import * as $signup from './routes/signup.tsx'; import * as $Settings from './islands/Settings.tsx'; @@ -40,6 +43,8 @@ import * as $dashboard_Notes from './islands/dashboard/Notes.tsx'; import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx'; import * as $news_Articles from './islands/news/Articles.tsx'; import * as $news_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 { type Manifest } from '$fresh/server.ts'; const manifest = { @@ -65,6 +70,7 @@ const manifest = { './routes/api/news/import-feeds.tsx': $api_news_import_feeds, './routes/api/news/mark-read.tsx': $api_news_mark_read, './routes/api/news/refresh-articles.tsx': $api_news_refresh_articles, + './routes/api/notes/save.tsx': $api_notes_save, './routes/dashboard.tsx': $dashboard, './routes/dav.tsx': $dav, './routes/files.tsx': $files, @@ -74,6 +80,8 @@ const manifest = { './routes/logout.tsx': $logout, './routes/news.tsx': $news, './routes/news/feeds.tsx': $news_feeds, + './routes/notes.tsx': $notes, + './routes/notes/open/[fileName].tsx': $notes_open_fileName_, './routes/settings.tsx': $settings, './routes/signup.tsx': $signup, }, @@ -84,6 +92,8 @@ const manifest = { './islands/files/FilesWrapper.tsx': $files_FilesWrapper, './islands/news/Articles.tsx': $news_Articles, './islands/news/Feeds.tsx': $news_Feeds, + './islands/notes/Note.tsx': $notes_Note, + './islands/notes/NotesWrapper.tsx': $notes_NotesWrapper, }, baseUrl: import.meta.url, } satisfies Manifest; diff --git a/islands/notes/Note.tsx b/islands/notes/Note.tsx new file mode 100644 index 0000000..78c6683 --- /dev/null +++ b/islands/notes/Note.tsx @@ -0,0 +1,109 @@ +import { useSignal, useSignalEffect } from '@preact/signals'; +import { useEffect } from 'preact/hooks'; + +import { RequestBody, ResponseBody } from '/routes/api/notes/save.tsx'; +import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx'; + +interface NoteProps { + fileName: string; + currentPath: string; + contents: string; +} + +export default function Note({ fileName, currentPath, contents }: NoteProps) { + const saveTimeout = useSignal>(0); + const hasSavedTimeout = useSignal>(0); + const isSaving = useSignal(false); + const hasSaved = useSignal(false); + + function saveNote(newNotes: string) { + if (saveTimeout.value) { + clearTimeout(saveTimeout.value); + } + + saveTimeout.value = setTimeout(async () => { + hasSaved.value = false; + isSaving.value = true; + + try { + const requestBody: RequestBody = { fileName, currentPath, contents: newNotes }; + const response = await fetch(`/api/notes/save`, { + method: 'POST', + body: JSON.stringify(requestBody), + }); + const result = await response.json() as ResponseBody; + + if (!result.success) { + throw new Error('Failed to save note!'); + } + } catch (error) { + console.error(error); + } + + isSaving.value = false; + hasSaved.value = true; + }, 1000); + } + + useSignalEffect(() => { + if (hasSaved.value && !hasSavedTimeout.value) { + hasSavedTimeout.value = setTimeout(() => { + hasSaved.value = false; + }, 3000); + } + }); + + useEffect(() => { + return () => { + if (saveTimeout.value) { + clearTimeout(saveTimeout.value); + } + + if (hasSavedTimeout.value) { + clearTimeout(hasSavedTimeout.value); + } + }; + }, []); + + return ( +
+
+ +

+ / + {decodeURIComponent(fileName)} +

+
+ + + + + {isSaving.value + ? ( + <> + Saving... + + ) + : null} + {hasSaved.value + ? ( + <> + Saved! + + ) + : null} + {!isSaving.value && !hasSaved.value ? <>  : null} + +
+ ); +} diff --git a/islands/notes/NotesWrapper.tsx b/islands/notes/NotesWrapper.tsx new file mode 100644 index 0000000..dc4b56c --- /dev/null +++ b/islands/notes/NotesWrapper.tsx @@ -0,0 +1,21 @@ +import { Directory, DirectoryFile } from '/lib/types.ts'; +import MainNotes from '/components/notes/MainNotes.tsx'; + +interface NotesWrapperProps { + 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 NotesWrapper( + { initialDirectories, initialFiles, initialPath }: NotesWrapperProps, +) { + return ( + + ); +} diff --git a/lib/data/files.ts b/lib/data/files.ts index 63c2b4a..742609b 100644 --- a/lib/data/files.ts +++ b/lib/data/files.ts @@ -75,6 +75,17 @@ async function getPathEntries(userId: string, path: string): Promise { + const rootPath = join(getFilesRootPath(), userId, path); + + try { + await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false }); + } catch (error) { + console.error(error); + return false; + } + + return true; +} + export async function getFile( userId: string, path: string, diff --git a/routes/api/files/upload.tsx b/routes/api/files/upload.tsx index fc0486b..7e81c5c 100644 --- a/routes/api/files/upload.tsx +++ b/routes/api/files/upload.tsx @@ -20,7 +20,7 @@ export const handler: Handlers = { const parentPath = requestBody.get('parent_path') as string; const name = requestBody.get('name') as string; - const contents = requestBody.get('contents') as File; + const contents = requestBody.get('contents') as File | string; if ( !parentPath || !name.trim() || !contents || !parentPath.startsWith('/') || @@ -29,7 +29,9 @@ export const handler: Handlers = { return new Response('Bad Request', { status: 400 }); } - const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), await contents.arrayBuffer()); + const fileContents = typeof contents === 'string' ? contents : await contents.arrayBuffer(); + + const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), fileContents); const newFiles = await getFiles(context.state.user.id, parentPath); diff --git a/routes/api/notes/save.tsx b/routes/api/notes/save.tsx new file mode 100644 index 0000000..7626cae --- /dev/null +++ b/routes/api/notes/save.tsx @@ -0,0 +1,66 @@ +import { Handlers } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { getFile, updateFile } from '/lib/data/files.ts'; + +interface Data {} + +export interface RequestBody { + fileName: string; + currentPath: string; + contents: string; +} + +export interface ResponseBody { + success: boolean; +} + +export const handler: Handlers = { + async POST(request, context) { + if (!context.state.user) { + return new Response('Unauthorized', { status: 401 }); + } + + const requestBody = await request.clone().json() as RequestBody; + + if ( + !requestBody.currentPath || !requestBody.fileName || !requestBody.currentPath.startsWith('/Notes/') || + requestBody.currentPath.includes('../') || !requestBody.currentPath.endsWith('/') + ) { + return new Response('Bad Request', { status: 400 }); + } + + if ( + !requestBody.currentPath || !requestBody.currentPath.startsWith('/Notes/') || + requestBody.currentPath.includes('../') + ) { + return new Response('Bad Request', { status: 400 }); + } + + // Don't allow non-markdown files here + if (!requestBody.fileName.endsWith('.md')) { + return new Response('Not Found', { status: 404 }); + } + + const fileResult = await getFile( + context.state.user.id, + requestBody.currentPath, + decodeURIComponent(requestBody.fileName), + ); + + if (!fileResult.success) { + return new Response('Not Found', { status: 404 }); + } + + const updatedFile = await updateFile( + context.state.user.id, + requestBody.currentPath, + decodeURIComponent(requestBody.fileName), + requestBody.contents || '', + ); + + const responseBody: ResponseBody = { success: updatedFile }; + + return new Response(JSON.stringify(responseBody)); + }, +}; diff --git a/routes/notes.tsx b/routes/notes.tsx new file mode 100644 index 0000000..ab6b14e --- /dev/null +++ b/routes/notes.tsx @@ -0,0 +1,53 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; +import { getDirectories, getFiles } from '/lib/data/files.ts'; +import NotesWrapper from '/islands/notes/NotesWrapper.tsx'; + +interface Data { + userDirectories: Directory[]; + userNotes: DirectoryFile[]; + currentPath: string; +} + +export const handler: Handlers = { + 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') || '/Notes/'; + + // Send invalid paths back to Notes root + if (!currentPath.startsWith('/Notes/') || currentPath.includes('../')) { + currentPath = '/Notes/'; + } + + // 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 userNotes = userFiles.filter((file) => file.file_name.endsWith('.md')); + + return await context.render({ userDirectories, userNotes, currentPath }); + }, +}; + +export default function FilesPage({ data }: PageProps) { + return ( +
+ +
+ ); +} diff --git a/routes/notes/open/[fileName].tsx b/routes/notes/open/[fileName].tsx new file mode 100644 index 0000000..12ef522 --- /dev/null +++ b/routes/notes/open/[fileName].tsx @@ -0,0 +1,56 @@ +import { Handlers, PageProps } from 'fresh/server.ts'; + +import { FreshContextState } from '/lib/types.ts'; +import { getFile } from '/lib/data/files.ts'; +import Note from '/islands/notes/Note.tsx'; + +interface Data { + fileName: string; + currentPath: string; + contents: string; +} + +export const handler: Handlers = { + async GET(request, context) { + if (!context.state.user) { + return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } }); + } + + const { fileName } = context.params; + + if (!fileName) { + return new Response('Not Found', { status: 404 }); + } + + const searchParams = new URL(request.url).searchParams; + + let currentPath = searchParams.get('path') || '/'; + + // Send invalid paths back to notes root + if (!currentPath.startsWith('/Notes/') || currentPath.includes('../')) { + currentPath = '/Notes/'; + } + + // Always append a trailing slash + if (!currentPath.endsWith('/')) { + currentPath = `${currentPath}/`; + } + + // Don't allow non-markdown files here + if (!fileName.endsWith('.md')) { + return new Response('Not Found', { status: 404 }); + } + + const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName)); + + if (!fileResult.success) { + return new Response('Not Found', { status: 404 }); + } + + return await context.render({ fileName, currentPath, contents: new TextDecoder().decode(fileResult.contents!) }); + }, +}; + +export default function OpenNotePage({ data }: PageProps) { + return ; +}
Name Last updateSizeSize
{dateFormat.format(new Date(directory.updated_at))} - - - + - + - {fullPath === TRASH_PATH ? null : ( -
- - - -
- )} + {(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' || + typeof onClickOpenMoveDirectory === 'undefined') + ? null + : ( +
+ + + +
+ )}
{dateFormat.format(new Date(file.updated_at))} - {humanFileSize(file.size_in_bytes)} - + {humanFileSize(file.size_in_bytes)} +
- - + {typeof onClickOpenRenameFile === 'undefined' ? null : ( + + )} + {typeof onClickOpenMoveFile === 'undefined' ? null : ( + + )}
@@ -196,7 +213,7 @@ export default function ListFiles(
-
No files to show
+
No {itemPluralLabel} to show