Merge pull request #58 from bewcloud/feature/upload-directories-web

Upload Directories via Web
This commit is contained in:
Bruno Bernardino
2025-05-13 16:09:43 +01:00
committed by GitHub
19 changed files with 74 additions and 15 deletions

View File

@@ -5,3 +5,6 @@
docker-compose* docker-compose*
Dockerfile Dockerfile
render.yaml 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 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: denoland/setup-deno@v1 - uses: denoland/setup-deno@v2
with: with:
deno-version-file: .dvmrc deno-version-file: .dvmrc
- run: | - run: |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
const fileInput = document.createElement('input'); const fileInput = document.createElement('input');
fileInput.type = 'file'; fileInput.type = 'file';
fileInput.multiple = true; 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.click();
fileInput.onchange = async (event) => { fileInput.onchange = async (event) => {
@@ -78,10 +83,19 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
areNewOptionsOpen.value = false; areNewOptionsOpen.value = false;
const requestBody = new FormData(); const requestBody = new FormData();
requestBody.set('path_in_view', path.value);
requestBody.set('parent_path', path.value); requestBody.set('parent_path', path.value);
requestBody.set('name', chosenFile.name); requestBody.set('name', chosenFile.name);
requestBody.set('contents', chosenFile); 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 { try {
const response = await fetch(`/api/files/upload`, { const response = await fetch(`/api/files/upload`, {
method: 'POST', method: 'POST',
@@ -94,6 +108,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
} }
files.value = [...result.newFiles]; files.value = [...result.newFiles];
directories.value = [...result.newDirectories];
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
@@ -509,6 +524,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
<button <button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickBulkDelete()} onClick={() => onClickBulkDelete()}
type='button'
> >
Delete {bulkItemsCount} item{bulkItemsCount === 1 ? '' : 's'} Delete {bulkItemsCount} item{bulkItemsCount === 1 ? '' : 's'}
</button> </button>
@@ -557,12 +573,14 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
<button <button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickUploadFile()} onClick={() => onClickUploadFile()}
type='button'
> >
Upload File Upload Files
</button> </button>
<button <button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()} onClick={() => onClickCreateDirectory()}
type='button'
> >
New Directory New Directory
</button> </button>

View File

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

View File

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

View File

@@ -44,12 +44,14 @@ export default function CreateNoteModal(
<button <button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md' class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClickSave(newNoteName.value)} onClick={() => onClickSave(newNoteName.value)}
type='button'
> >
Create Create
</button> </button>
<button <button
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md' class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
onClick={() => onClose()} onClick={() => onClose()}
type='button'
> >
Close Close
</button> </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 flex-row items-center justify-between mb-4'>
<section class='flex items-center justify-end w-full'> <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'> <section class='relative inline-block text-left ml-2'>
<div> <div>
@@ -241,12 +241,14 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
<button <button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateNote()} onClick={() => onClickCreateNote()}
type='button'
> >
New Note New Note
</button> </button>
<button <button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()} onClick={() => onClickCreateDirectory()}
type='button'
> >
New Directory New Directory
</button> </button>
@@ -262,7 +264,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
files={files.value} files={files.value}
onClickDeleteDirectory={onClickDeleteDirectory} onClickDeleteDirectory={onClickDeleteDirectory}
onClickDeleteFile={onClickDeleteFile} onClickDeleteFile={onClickDeleteFile}
isShowingNotes={true} isShowingNotes
/> />
<span <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 flex-row items-center justify-between mb-4'>
<section class='flex items-center justify-end w-full'> <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'> <section class='relative inline-block text-left ml-2'>
<div> <div>
@@ -167,12 +167,14 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
<button <button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickUploadFile()} onClick={() => onClickUploadFile()}
type='button'
> >
Upload Photo Upload Photo
</button> </button>
<button <button
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`} class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
onClick={() => onClickCreateDirectory()} onClick={() => onClickCreateDirectory()}
type='button'
> >
New Directory New Directory
</button> </button>
@@ -186,7 +188,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
<ListFiles <ListFiles
directories={directories.value} directories={directories.value}
files={[]} files={[]}
isShowingPhotos={true} isShowingPhotos
/> />
<ListPhotos <ListPhotos

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
return ( return (
<section class='flex flex-col'> <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'> <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'> <h3 class='text-base text-white font-semibold'>
<span class='mr-2 text-xs'>/</span> <span class='mr-2 text-xs'>/</span>
{decodeURIComponent(fileName)} {decodeURIComponent(fileName)}

View File

@@ -192,6 +192,15 @@ export async function createFile(
const rootPath = join(getFilesRootPath(), userId, path); const rootPath = join(getFilesRootPath(), userId, path);
try { 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') { if (typeof contents === 'string') {
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true }); await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true });
} else { } else {

View File

@@ -1,13 +1,14 @@
import { Handlers } from 'fresh/server.ts'; import { Handlers } from 'fresh/server.ts';
import { DirectoryFile, FreshContextState } from '/lib/types.ts'; import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
import { createFile, getFiles } from '/lib/data/files.ts'; import { createFile, getDirectories, getFiles } from '/lib/data/files.ts';
interface Data {} interface Data {}
export interface ResponseBody { export interface ResponseBody {
success: boolean; success: boolean;
newFiles: DirectoryFile[]; newFiles: DirectoryFile[];
newDirectories: Directory[];
} }
export const handler: Handlers<Data, FreshContextState> = { export const handler: Handlers<Data, FreshContextState> = {
@@ -18,13 +19,14 @@ export const handler: Handlers<Data, FreshContextState> = {
const requestBody = await request.clone().formData(); const requestBody = await request.clone().formData();
const pathInView = requestBody.get('path_in_view') as string;
const parentPath = requestBody.get('parent_path') as string; const parentPath = requestBody.get('parent_path') as string;
const name = requestBody.get('name') as string; const name = requestBody.get('name') as string;
const contents = requestBody.get('contents') as File | string; const contents = requestBody.get('contents') as File | string;
if ( if (
!parentPath || !name.trim() || !contents || !parentPath.startsWith('/') || !parentPath || !pathInView || !name.trim() || !contents || !parentPath.startsWith('/') ||
parentPath.includes('../') parentPath.includes('../') || !pathInView.startsWith('/') || pathInView.includes('../')
) { ) {
return new Response('Bad Request', { status: 400 }); 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 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)); return new Response(JSON.stringify(responseBody));
}, },