Merge pull request #58 from bewcloud/feature/upload-directories-web
Upload Directories via Web
This commit is contained in:
@@ -5,3 +5,6 @@
|
||||
docker-compose*
|
||||
Dockerfile
|
||||
render.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
.env.sample
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM denoland/deno:ubuntu-2.1.9
|
||||
FROM denoland/deno:ubuntu-2.3.1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user