From b8866cdb39ece25aa2aaaf14f3a2e5b5171120f1 Mon Sep 17 00:00:00 2001 From: Bruno Bernardino Date: Tue, 13 May 2025 16:07:27 +0100 Subject: [PATCH] Upload Directories via Web This implements the option to choose directories when uploading files via the Web UI (The most important part of #52). When you choose a directory, its file and sub-directory structure will be maintained. Tested with the latest Safari, Firefox, and Chrome. Additionally, the Deno version was updated, which required some accessibility improvements as well. --- .dockerignore | 3 +++ .dvmrc | 2 +- .github/workflows/test.yml | 2 +- Dockerfile | 2 +- components/expenses/BudgetModal.tsx | 3 +++ components/expenses/ExpenseModal.tsx | 3 +++ components/expenses/MainExpenses.tsx | 4 ++++ components/files/CreateDirectoryModal.tsx | 2 ++ components/files/MainFiles.tsx | 20 ++++++++++++++++++- components/files/MoveDirectoryOrFileModal.tsx | 2 ++ .../files/RenameDirectoryOrFileModal.tsx | 2 ++ components/notes/CreateNoteModal.tsx | 2 ++ components/notes/MainNotes.tsx | 6 ++++-- components/photos/MainPhotos.tsx | 6 ++++-- islands/news/Articles.tsx | 2 ++ islands/news/Feeds.tsx | 2 ++ islands/notes/Note.tsx | 2 +- lib/data/files.ts | 9 +++++++++ routes/api/files/upload.tsx | 15 ++++++++------ 19 files changed, 74 insertions(+), 15 deletions(-) diff --git a/.dockerignore b/.dockerignore index 6c92c32..da80e51 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,6 @@ docker-compose* Dockerfile render.yaml +LICENSE +README.md +.env.sample diff --git a/.dvmrc b/.dvmrc index 63a1a1c..2bf1c1c 100644 --- a/.dvmrc +++ b/.dvmrc @@ -1 +1 @@ -2.1.9 +2.3.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 96f8e15..2b3625c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: denoland/setup-deno@v1 + - uses: denoland/setup-deno@v2 with: deno-version-file: .dvmrc - run: | diff --git a/Dockerfile b/Dockerfile index 7a48b57..63191f9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM denoland/deno:ubuntu-2.1.9 +FROM denoland/deno:ubuntu-2.3.1 EXPOSE 8000 diff --git a/components/expenses/BudgetModal.tsx b/components/expenses/BudgetModal.tsx index 632c378..12da81d 100644 --- a/components/expenses/BudgetModal.tsx +++ b/components/expenses/BudgetModal.tsx @@ -104,6 +104,7 @@ export default function BudgetModal( @@ -112,6 +113,7 @@ export default function BudgetModal( @@ -123,6 +125,7 @@ export default function BudgetModal( newBudgetMonth.value.substring(0, 7), formatInputToNumber(newBudgetValue.value), )} + type='button' > {budget ? 'Update' : 'Create'} diff --git a/components/expenses/ExpenseModal.tsx b/components/expenses/ExpenseModal.tsx index 4c275b6..b2fdc74 100644 --- a/components/expenses/ExpenseModal.tsx +++ b/components/expenses/ExpenseModal.tsx @@ -237,6 +237,7 @@ export default function ExpenseModal( @@ -245,6 +246,7 @@ export default function ExpenseModal( @@ -259,6 +261,7 @@ export default function ExpenseModal( newExpenseIsRecurring.value, ); }} + type='button' > {expense ? 'Update' : 'Create'} diff --git a/components/expenses/MainExpenses.tsx b/components/expenses/MainExpenses.tsx index fced55d..44c4f5f 100644 --- a/components/expenses/MainExpenses.tsx +++ b/components/expenses/MainExpenses.tsx @@ -729,12 +729,14 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM @@ -746,12 +748,14 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM diff --git a/components/files/CreateDirectoryModal.tsx b/components/files/CreateDirectoryModal.tsx index 8fc36ce..6d0eeee 100644 --- a/components/files/CreateDirectoryModal.tsx +++ b/components/files/CreateDirectoryModal.tsx @@ -44,12 +44,14 @@ export default function CreateDirectoryModal( diff --git a/components/files/MainFiles.tsx b/components/files/MainFiles.tsx index bbd275f..a6270aa 100644 --- a/components/files/MainFiles.tsx +++ b/components/files/MainFiles.tsx @@ -61,6 +61,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.multiple = true; + fileInput.webkitdirectory = true; + // @ts-expect-error - mozdirectory is not typed + fileInput.mozdirectory = true; + // @ts-expect-error - directory is not typed + fileInput.directory = true; fileInput.click(); fileInput.onchange = async (event) => { @@ -78,10 +83,19 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat areNewOptionsOpen.value = false; const requestBody = new FormData(); + requestBody.set('path_in_view', path.value); requestBody.set('parent_path', path.value); requestBody.set('name', chosenFile.name); requestBody.set('contents', chosenFile); + // Keep directory structure if the file comes from a chosen directory + if (chosenFile.webkitRelativePath) { + const directoryPath = chosenFile.webkitRelativePath.replace(chosenFile.name, ''); + + // We don't need to worry about path joining here, the API will handle it (and make sure it's secure) + requestBody.set('parent_path', `${path.value}${directoryPath}`); + } + try { const response = await fetch(`/api/files/upload`, { method: 'POST', @@ -94,6 +108,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat } files.value = [...result.newFiles]; + directories.value = [...result.newDirectories]; } catch (error) { console.error(error); } @@ -509,6 +524,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat @@ -557,12 +573,14 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat diff --git a/components/files/MoveDirectoryOrFileModal.tsx b/components/files/MoveDirectoryOrFileModal.tsx index 4987e76..c7f6469 100644 --- a/components/files/MoveDirectoryOrFileModal.tsx +++ b/components/files/MoveDirectoryOrFileModal.tsx @@ -129,12 +129,14 @@ export default function MoveDirectoryOrFileModal( diff --git a/components/files/RenameDirectoryOrFileModal.tsx b/components/files/RenameDirectoryOrFileModal.tsx index 6a65096..e9282a3 100644 --- a/components/files/RenameDirectoryOrFileModal.tsx +++ b/components/files/RenameDirectoryOrFileModal.tsx @@ -51,12 +51,14 @@ export default function RenameDirectoryOrFileModal( diff --git a/components/notes/CreateNoteModal.tsx b/components/notes/CreateNoteModal.tsx index 7fd3a22..c1e4139 100644 --- a/components/notes/CreateNoteModal.tsx +++ b/components/notes/CreateNoteModal.tsx @@ -44,12 +44,14 @@ export default function CreateNoteModal( diff --git a/components/notes/MainNotes.tsx b/components/notes/MainNotes.tsx index 4b353ee..7904efa 100644 --- a/components/notes/MainNotes.tsx +++ b/components/notes/MainNotes.tsx @@ -205,7 +205,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat <>
- +
@@ -241,12 +241,14 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat @@ -262,7 +264,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat files={files.value} onClickDeleteDirectory={onClickDeleteDirectory} onClickDeleteFile={onClickDeleteFile} - isShowingNotes={true} + isShowingNotes />
- +
@@ -167,12 +167,14 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa @@ -186,7 +188,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa setNewFilter({ status: 'unread' })} + type='button' > Show only unread @@ -183,6 +184,7 @@ export default function Articles({ initialArticles }: ArticlesProps) { filter.value.status === 'all' ? 'font-semibold' : '' }`} onClick={() => setNewFilter({ status: 'all' })} + type='button' > Show all diff --git a/islands/news/Feeds.tsx b/islands/news/Feeds.tsx index 39e4efd..2c57eee 100644 --- a/islands/news/Feeds.tsx +++ b/islands/news/Feeds.tsx @@ -252,12 +252,14 @@ export default function Feeds({ initialFeeds }: FeedsProps) { diff --git a/islands/notes/Note.tsx b/islands/notes/Note.tsx index b4e7f96..3d5fce8 100644 --- a/islands/notes/Note.tsx +++ b/islands/notes/Note.tsx @@ -68,7 +68,7 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) { return (
- +

/ {decodeURIComponent(fileName)} diff --git a/lib/data/files.ts b/lib/data/files.ts index d68ef54..12c8977 100644 --- a/lib/data/files.ts +++ b/lib/data/files.ts @@ -192,6 +192,15 @@ export async function createFile( const rootPath = join(getFilesRootPath(), userId, path); try { + // Ensure the directory exist, if being requested + try { + await Deno.stat(rootPath); + } catch (error) { + if ((error as Error).toString().includes('NotFound')) { + await Deno.mkdir(rootPath, { recursive: true }); + } + } + if (typeof contents === 'string') { await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true }); } else { diff --git a/routes/api/files/upload.tsx b/routes/api/files/upload.tsx index 7e81c5c..b953cb4 100644 --- a/routes/api/files/upload.tsx +++ b/routes/api/files/upload.tsx @@ -1,13 +1,14 @@ import { Handlers } from 'fresh/server.ts'; -import { DirectoryFile, FreshContextState } from '/lib/types.ts'; -import { createFile, getFiles } from '/lib/data/files.ts'; +import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts'; +import { createFile, getDirectories, getFiles } from '/lib/data/files.ts'; interface Data {} export interface ResponseBody { success: boolean; newFiles: DirectoryFile[]; + newDirectories: Directory[]; } export const handler: Handlers = { @@ -18,13 +19,14 @@ export const handler: Handlers = { const requestBody = await request.clone().formData(); + const pathInView = requestBody.get('path_in_view') as string; const parentPath = requestBody.get('parent_path') as string; const name = requestBody.get('name') as string; const contents = requestBody.get('contents') as File | string; if ( - !parentPath || !name.trim() || !contents || !parentPath.startsWith('/') || - parentPath.includes('../') + !parentPath || !pathInView || !name.trim() || !contents || !parentPath.startsWith('/') || + parentPath.includes('../') || !pathInView.startsWith('/') || pathInView.includes('../') ) { return new Response('Bad Request', { status: 400 }); } @@ -33,9 +35,10 @@ export const handler: Handlers = { const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), fileContents); - const newFiles = await getFiles(context.state.user.id, parentPath); + const newFiles = await getFiles(context.state.user.id, pathInView); + const newDirectories = await getDirectories(context.state.user.id, pathInView); - const responseBody: ResponseBody = { success: createdFile, newFiles }; + const responseBody: ResponseBody = { success: createdFile, newFiles, newDirectories }; return new Response(JSON.stringify(responseBody)); },