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.
This commit is contained in:
Bruno Bernardino
2025-05-13 16:07:27 +01:00
parent 1e1d3657a2
commit b8866cdb39
19 changed files with 74 additions and 15 deletions

View File

@@ -5,3 +5,6 @@
docker-compose*
Dockerfile
render.yaml
LICENSE
README.md
.env.sample

2
.dvmrc
View File

@@ -1 +1 @@
2.1.9
2.3.1

View File

@@ -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: |

View File

@@ -1,4 +1,4 @@
FROM denoland/deno:ubuntu-2.1.9
FROM denoland/deno:ubuntu-2.3.1
EXPOSE 8000

View File

@@ -104,6 +104,7 @@ export default function BudgetModal(
<button
class='px-5 py-2 bg-red-600 text-white cursor-pointer rounded-md mr-2 opacity-30 hover:opacity-100'
onClick={() => onClickDelete()}
type='button'
>
Delete
</button>
@@ -112,6 +113,7 @@ export default function BudgetModal(
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
onClick={() => onClose()}
type='button'
>
{budget ? 'Cancel' : 'Close'}
</button>
@@ -123,6 +125,7 @@ export default function BudgetModal(
newBudgetMonth.value.substring(0, 7),
formatInputToNumber(newBudgetValue.value),
)}
type='button'
>
{budget ? 'Update' : 'Create'}
</button>

View File

@@ -237,6 +237,7 @@ export default function ExpenseModal(
<button
class='px-5 py-2 bg-red-600 text-white cursor-pointer rounded-md mr-2 opacity-30 hover:opacity-100'
onClick={() => onClickDelete()}
type='button'
>
Delete
</button>
@@ -245,6 +246,7 @@ export default function ExpenseModal(
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
onClick={() => onClose()}
type='button'
>
{expense ? 'Cancel' : 'Close'}
</button>
@@ -259,6 +261,7 @@ export default function ExpenseModal(
newExpenseIsRecurring.value,
);
}}
type='button'
>
{expense ? 'Update' : 'Create'}
</button>

View File

@@ -729,12 +729,14 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateExpense()}
type='button'
>
New Expense
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateBudget()}
type='button'
>
New Budget
</button>
@@ -746,12 +748,14 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickImportFile()}
type='button'
>
Import
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickExportFile()}
type='button'
>
Export
</button>

View File

@@ -44,12 +44,14 @@ export default function CreateDirectoryModal(
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClickSave(newDirectoryName.value)}
type='button'
>
Create
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClose()}
type='button'
>
Close
</button>

View File

@@ -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
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickBulkDelete()}
type='button'
>
Delete {bulkItemsCount} item{bulkItemsCount === 1 ? '' : 's'}
</button>
@@ -557,12 +573,14 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickUploadFile()}
type='button'
>
Upload File
Upload Files
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()}
type='button'
>
New Directory
</button>

View File

@@ -129,12 +129,14 @@ export default function MoveDirectoryOrFileModal(
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClickSave(newPath.value)}
type='button'
>
Move {isDirectory ? 'directory' : 'file'} here
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClose()}
type='button'
>
Close
</button>

View File

@@ -51,12 +51,14 @@ export default function RenameDirectoryOrFileModal(
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClickSave(newName.value)}
type='button'
>
Save
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClose()}
type='button'
>
Close
</button>

View File

@@ -44,12 +44,14 @@ export default function CreateNoteModal(
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClickSave(newNoteName.value)}
type='button'
>
Create
</button>
<button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClose()}
type='button'
>
Close
</button>

View File

@@ -205,7 +205,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
<>
<section class='flex flex-row items-center justify-between mb-4'>
<section class='flex items-center justify-end w-full'>
<FilesBreadcrumb path={path.value} isShowingNotes={true} />
<FilesBreadcrumb path={path.value} isShowingNotes />
<section class='relative inline-block text-left ml-2'>
<div>
@@ -241,12 +241,14 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateNote()}
type='button'
>
New Note
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()}
type='button'
>
New Directory
</button>
@@ -262,7 +264,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
files={files.value}
onClickDeleteDirectory={onClickDeleteDirectory}
onClickDeleteFile={onClickDeleteFile}
isShowingNotes={true}
isShowingNotes
/>
<span

View File

@@ -131,7 +131,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
<>
<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} />
<FilesBreadcrumb path={path.value} isShowingPhotos />
<section class='relative inline-block text-left ml-2'>
<div>
@@ -167,12 +167,14 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickUploadFile()}
type='button'
>
Upload Photo
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()}
type='button'
>
New Directory
</button>
@@ -186,7 +188,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
<ListFiles
directories={directories.value}
files={[]}
isShowingPhotos={true}
isShowingPhotos
/>
<ListPhotos

View File

@@ -175,6 +175,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
filter.value.status === 'unread' ? 'font-semibold' : ''
}`}
onClick={() => setNewFilter({ status: 'unread' })}
type='button'
>
Show only unread
</button>
@@ -183,6 +184,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
filter.value.status === 'all' ? 'font-semibold' : ''
}`}
onClick={() => setNewFilter({ status: 'all' })}
type='button'
>
Show all
</button>

View File

@@ -252,12 +252,14 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickImportOpml()}
type='button'
>
Import OPML
</button>
<button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickExportOpml()}
type='button'
>
Export OPML
</button>

View File

@@ -68,7 +68,7 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
return (
<section class='flex flex-col'>
<section class='mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 w-full flex flex-row items-center justify-start'>
<FilesBreadcrumb path={currentPath} isShowingNotes={true} />
<FilesBreadcrumb path={currentPath} isShowingNotes />
<h3 class='text-base text-white font-semibold'>
<span class='mr-2 text-xs'>/</span>
{decodeURIComponent(fileName)}

View File

@@ -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 {

View File

@@ -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<Data, FreshContextState> = {
@@ -18,13 +19,14 @@ export const handler: Handlers<Data, FreshContextState> = {
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<Data, FreshContextState> = {
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));
},