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*
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));
},