Add basic Notes UI
This commit is contained in:
27
README.md
27
README.md
@@ -21,16 +21,16 @@ Alternatively, check the [Development section below](#development).
|
||||
> [!IMPORTANT]
|
||||
> Even with signups disabled (`CONFIG_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin.
|
||||
|
||||
## Requirements
|
||||
|
||||
This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work.
|
||||
|
||||
For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed.
|
||||
|
||||
Don't forget to set up your `.env` file based on `.env.sample`.
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Don't forget to set up your `.env` file based on `.env.sample`.
|
||||
- This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work.
|
||||
- For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed.
|
||||
|
||||
### Commands
|
||||
|
||||
```sh
|
||||
$ docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally
|
||||
$ make migrate-db # runs any missing database migrations
|
||||
@@ -39,7 +39,7 @@ $ make format # formats the code
|
||||
$ make test # runs tests
|
||||
```
|
||||
|
||||
## Other less-used commands
|
||||
### Other less-used commands
|
||||
|
||||
```sh
|
||||
$ make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";`
|
||||
@@ -69,10 +69,11 @@ Just push to the `main` branch.
|
||||
- [x] [Desktop app for selective file sync](https://github.com/bewcloud/bewcloud-desktop/releases) (`rclone` via WebDav)
|
||||
- [x] [Mobile app for offline file view](https://github.com/bewcloud/bewcloud-mobile/releases) (API + WebDav client)
|
||||
- [x] Add photo auto-uplod support for mobile client
|
||||
- [x] Notes UI
|
||||
- [ ] Photos UI
|
||||
- [ ] Add notes view support for mobile app
|
||||
- [ ] Add notes edit support for mobile app
|
||||
- [ ] Notes UI
|
||||
- [ ] Photos UI
|
||||
- [ ] Respect `CONFIG_ENABLED_APPS` in `.env` for enabling apps
|
||||
|
||||
## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement?
|
||||
|
||||
@@ -88,3 +89,7 @@ You can simply `ln -s /<absolute-path-to-data-files>/<owner-user-id>/<directory-
|
||||
|
||||
> [!NOTE]
|
||||
> If you're running the app with docker, the symlink needs to point to the container's directory, usually starting with `/app` if you didn't change the `Dockerfile`, otherwise the container will fail to load the linked directory.
|
||||
|
||||
## How does it look?
|
||||
|
||||
[Check the website](https://bewcloud.com) for screenshots or [the YouTube channel](https://www.youtube.com/@bewCloud) for 1-minute demos.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
interface FilesBreadcrumbProps {
|
||||
path: string;
|
||||
isShowingNotes?: boolean;
|
||||
}
|
||||
|
||||
export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) {
|
||||
if (path === '/') {
|
||||
export default function FilesBreadcrumb({ path, isShowingNotes }: FilesBreadcrumbProps) {
|
||||
const routePath = isShowingNotes ? 'notes' : 'files';
|
||||
|
||||
if (!isShowingNotes && path === '/') {
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
All files
|
||||
@@ -11,17 +14,29 @@ export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (isShowingNotes && path === '/Notes/') {
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
All notes
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
const pathParts = path.slice(1, -1).split('/');
|
||||
|
||||
if (isShowingNotes) {
|
||||
pathParts.shift();
|
||||
}
|
||||
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
<a href={`/files?path=/`}>All files</a>
|
||||
{isShowingNotes ? <a href={`/notes?path=/Notes/`}>All notes</a> : <a href={`/files?path=/`}>All files</a>}
|
||||
{pathParts.map((part, index) => {
|
||||
if (index === pathParts.length - 1) {
|
||||
return (
|
||||
<>
|
||||
<span class='ml-2 text-xs'>/</span>
|
||||
<span class='ml-2'>{part}</span>
|
||||
<span class='ml-2'>{decodeURIComponent(part)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +50,7 @@ export default function FilesBreadcrumb({ path }: FilesBreadcrumbProps) {
|
||||
return (
|
||||
<>
|
||||
<span class='ml-2 text-xs'>/</span>
|
||||
<a href={`/files?path=/${fullPathForPart.join('/')}/`} class='ml-2'>{part}</a>
|
||||
<a href={`/${routePath}?path=/${fullPathForPart.join('/')}/`} class='ml-2'>{decodeURIComponent(part)}</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,12 +4,13 @@ import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
interface ListFilesProps {
|
||||
directories: Directory[];
|
||||
files: DirectoryFile[];
|
||||
onClickOpenRenameDirectory: (parentPath: string, name: string) => void;
|
||||
onClickOpenRenameFile: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveDirectory: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveFile: (parentPath: string, name: string) => void;
|
||||
onClickOpenRenameDirectory?: (parentPath: string, name: string) => void;
|
||||
onClickOpenRenameFile?: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveDirectory?: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveFile?: (parentPath: string, name: string) => void;
|
||||
onClickDeleteDirectory: (parentPath: string, name: string) => Promise<void>;
|
||||
onClickDeleteFile: (parentPath: string, name: string) => Promise<void>;
|
||||
isShowingNotes?: boolean;
|
||||
}
|
||||
|
||||
export default function ListFiles(
|
||||
@@ -22,6 +23,7 @@ export default function ListFiles(
|
||||
onClickOpenMoveFile,
|
||||
onClickDeleteDirectory,
|
||||
onClickDeleteFile,
|
||||
isShowingNotes,
|
||||
}: ListFilesProps,
|
||||
) {
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
@@ -33,6 +35,10 @@ export default function ListFiles(
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const routePath = isShowingNotes ? 'notes' : 'files';
|
||||
const itemSingleLabel = isShowingNotes ? 'note' : 'file';
|
||||
const itemPluralLabel = routePath;
|
||||
|
||||
return (
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-slate-500 shadow-sm rounded-md'>
|
||||
@@ -40,7 +46,7 @@ export default function ListFiles(
|
||||
<tr class='border-b border-slate-600'>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-56'>Last update</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>
|
||||
{isShowingNotes ? null : <th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>}
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -52,7 +58,7 @@ export default function ListFiles(
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/files?path=${fullPath}`}
|
||||
href={`/${routePath}?path=${fullPath}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
>
|
||||
<img
|
||||
@@ -69,11 +75,16 @@ export default function ListFiles(
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{dateFormat.format(new Date(directory.updated_at))}
|
||||
</td>
|
||||
{isShowingNotes ? null : (
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
-
|
||||
</td>
|
||||
)}
|
||||
<td class='px-6 py-4'>
|
||||
{fullPath === TRASH_PATH ? null : (
|
||||
{(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' ||
|
||||
typeof onClickOpenMoveDirectory === 'undefined')
|
||||
? null
|
||||
: (
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
@@ -124,7 +135,7 @@ export default function ListFiles(
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
href={`/${routePath}/open/${file.file_name}?path=${file.parent_path}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
@@ -143,11 +154,14 @@ export default function ListFiles(
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{dateFormat.format(new Date(file.updated_at))}
|
||||
</td>
|
||||
{isShowingNotes ? null : (
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{humanFileSize(file.size_in_bytes)}
|
||||
</td>
|
||||
)}
|
||||
<td class='px-6 py-4'>
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
{typeof onClickOpenRenameFile === 'undefined' ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
||||
@@ -157,10 +171,12 @@ export default function ListFiles(
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Rename file'
|
||||
title='Rename file'
|
||||
alt={`Rename ${itemSingleLabel}`}
|
||||
title={`Rename ${itemSingleLabel}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{typeof onClickOpenMoveFile === 'undefined' ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
||||
@@ -170,10 +186,11 @@ export default function ListFiles(
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Move file'
|
||||
title='Move file'
|
||||
alt={`Move ${itemSingleLabel}`}
|
||||
title={`Move ${itemSingleLabel}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
||||
@@ -183,8 +200,8 @@ export default function ListFiles(
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
alt='Delete file'
|
||||
title='Delete file'
|
||||
alt={`Delete ${itemSingleLabel}`}
|
||||
title={`Delete ${itemSingleLabel}`}
|
||||
/>
|
||||
</span>
|
||||
</section>
|
||||
@@ -196,7 +213,7 @@ export default function ListFiles(
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No files to show</div>
|
||||
<div class='font-medium text-slate-400'>No {itemPluralLabel} to show</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -409,7 +409,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new event'
|
||||
title='Add new file or directory'
|
||||
id='new-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
|
||||
60
components/notes/CreateNoteModal.tsx
Normal file
60
components/notes/CreateNoteModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
interface CreateNoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClickSave: (newNoteName: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CreateNoteModal(
|
||||
{ isOpen, onClickSave, onClose }: CreateNoteModalProps,
|
||||
) {
|
||||
const newNoteName = useSignal<string>('');
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Create New Note</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='directory_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='directory_name'
|
||||
id='directory_name'
|
||||
value={newNoteName.value}
|
||||
onInput={(event) => {
|
||||
newNoteName.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='Amazing'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newNoteName.value)}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
302
components/notes/MainNotes.tsx
Normal file
302
components/notes/MainNotes.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx';
|
||||
import {
|
||||
RequestBody as CreateDirectoryRequestBody,
|
||||
ResponseBody as CreateDirectoryResponseBody,
|
||||
} from '/routes/api/files/create-directory.tsx';
|
||||
import {
|
||||
RequestBody as DeleteDirectoryRequestBody,
|
||||
ResponseBody as DeleteDirectoryResponseBody,
|
||||
} from '/routes/api/files/delete-directory.tsx';
|
||||
import ListFiles from '/components/files/ListFiles.tsx';
|
||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
|
||||
import CreateNoteModal from './CreateNoteModal.tsx';
|
||||
|
||||
interface MainNotesProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
export default function MainNotes({ initialDirectories, initialFiles, initialPath }: MainNotesProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const directories = useSignal<Directory[]>(initialDirectories);
|
||||
const files = useSignal<DirectoryFile[]>(initialFiles);
|
||||
const path = useSignal<string>(initialPath);
|
||||
const areNewOptionsOption = useSignal<boolean>(false);
|
||||
const isNewNoteModalOpen = useSignal<boolean>(false);
|
||||
const isNewDirectoryModalOpen = useSignal<boolean>(false);
|
||||
|
||||
function onClickCreateNote() {
|
||||
if (isNewNoteModalOpen.value) {
|
||||
isNewNoteModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isNewNoteModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function onClickSaveNote(newNoteName: string) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newNoteName) {
|
||||
return;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
isAdding.value = true;
|
||||
|
||||
const requestBody = new FormData();
|
||||
requestBody.set('parent_path', path.value);
|
||||
requestBody.set('name', `${newNoteName}.md`);
|
||||
requestBody.set('contents', `# ${newNoteName}\n\nStart your new note!\n`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/upload`, {
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
const result = await response.json() as UploadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to create note!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
|
||||
isNewNoteModalOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onCloseCreateNote() {
|
||||
isNewNoteModalOpen.value = false;
|
||||
}
|
||||
|
||||
function onClickCreateDirectory() {
|
||||
if (isNewDirectoryModalOpen.value) {
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isNewDirectoryModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function onClickSaveDirectory(newDirectoryName: string) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newDirectoryName) {
|
||||
return;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: CreateDirectoryRequestBody = {
|
||||
parentPath: path.value,
|
||||
name: newDirectoryName,
|
||||
};
|
||||
const response = await fetch(`/api/files/create-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as CreateDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to create directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onCloseCreateDirectory() {
|
||||
isNewDirectoryModalOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleNewOptionsDropdown() {
|
||||
areNewOptionsOption.value = !areNewOptionsOption.value;
|
||||
}
|
||||
|
||||
async function onClickDeleteDirectory(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this directory?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteDirectoryRequestBody = {
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
const response = await fetch(`/api/files/delete-directory`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete directory!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickDeleteFile(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this note?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
parentPath,
|
||||
name,
|
||||
};
|
||||
const response = await fetch(`/api/files/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete note!');
|
||||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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} />
|
||||
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new note or directory'
|
||||
id='new-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new note or directory'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='new-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateNote()}
|
||||
>
|
||||
New Note
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateDirectory()}
|
||||
>
|
||||
New Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<ListFiles
|
||||
directories={directories.value}
|
||||
files={files.value}
|
||||
onClickDeleteDirectory={onClickDeleteDirectory}
|
||||
onClickDeleteFile={onClickDeleteFile}
|
||||
isShowingNotes={true}
|
||||
/>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isAdding.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isAdding.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<CreateDirectoryModal
|
||||
isOpen={isNewDirectoryModalOpen.value}
|
||||
onClickSave={onClickSaveDirectory}
|
||||
onClose={onCloseCreateDirectory}
|
||||
/>
|
||||
|
||||
<CreateNoteModal
|
||||
isOpen={isNewNoteModalOpen.value}
|
||||
onClickSave={onClickSaveNote}
|
||||
onClose={onCloseCreateNote}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
fresh.gen.ts
10
fresh.gen.ts
@@ -23,6 +23,7 @@ import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx';
|
||||
import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
|
||||
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
|
||||
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
||||
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
||||
import * as $dashboard from './routes/dashboard.tsx';
|
||||
import * as $dav from './routes/dav.tsx';
|
||||
import * as $files from './routes/files.tsx';
|
||||
@@ -32,6 +33,8 @@ import * as $login from './routes/login.tsx';
|
||||
import * as $logout from './routes/logout.tsx';
|
||||
import * as $news from './routes/news.tsx';
|
||||
import * as $news_feeds from './routes/news/feeds.tsx';
|
||||
import * as $notes from './routes/notes.tsx';
|
||||
import * as $notes_open_fileName_ from './routes/notes/open/[fileName].tsx';
|
||||
import * as $settings from './routes/settings.tsx';
|
||||
import * as $signup from './routes/signup.tsx';
|
||||
import * as $Settings from './islands/Settings.tsx';
|
||||
@@ -40,6 +43,8 @@ import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
||||
import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx';
|
||||
import * as $news_Articles from './islands/news/Articles.tsx';
|
||||
import * as $news_Feeds from './islands/news/Feeds.tsx';
|
||||
import * as $notes_Note from './islands/notes/Note.tsx';
|
||||
import * as $notes_NotesWrapper from './islands/notes/NotesWrapper.tsx';
|
||||
import { type Manifest } from '$fresh/server.ts';
|
||||
|
||||
const manifest = {
|
||||
@@ -65,6 +70,7 @@ const manifest = {
|
||||
'./routes/api/news/import-feeds.tsx': $api_news_import_feeds,
|
||||
'./routes/api/news/mark-read.tsx': $api_news_mark_read,
|
||||
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
||||
'./routes/api/notes/save.tsx': $api_notes_save,
|
||||
'./routes/dashboard.tsx': $dashboard,
|
||||
'./routes/dav.tsx': $dav,
|
||||
'./routes/files.tsx': $files,
|
||||
@@ -74,6 +80,8 @@ const manifest = {
|
||||
'./routes/logout.tsx': $logout,
|
||||
'./routes/news.tsx': $news,
|
||||
'./routes/news/feeds.tsx': $news_feeds,
|
||||
'./routes/notes.tsx': $notes,
|
||||
'./routes/notes/open/[fileName].tsx': $notes_open_fileName_,
|
||||
'./routes/settings.tsx': $settings,
|
||||
'./routes/signup.tsx': $signup,
|
||||
},
|
||||
@@ -84,6 +92,8 @@ const manifest = {
|
||||
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
|
||||
'./islands/news/Articles.tsx': $news_Articles,
|
||||
'./islands/news/Feeds.tsx': $news_Feeds,
|
||||
'./islands/notes/Note.tsx': $notes_Note,
|
||||
'./islands/notes/NotesWrapper.tsx': $notes_NotesWrapper,
|
||||
},
|
||||
baseUrl: import.meta.url,
|
||||
} satisfies Manifest;
|
||||
|
||||
109
islands/notes/Note.tsx
Normal file
109
islands/notes/Note.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useSignal, useSignalEffect } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/routes/api/notes/save.tsx';
|
||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||
|
||||
interface NoteProps {
|
||||
fileName: string;
|
||||
currentPath: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
export default function Note({ fileName, currentPath, contents }: NoteProps) {
|
||||
const saveTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const hasSavedTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const isSaving = useSignal<boolean>(false);
|
||||
const hasSaved = useSignal<boolean>(false);
|
||||
|
||||
function saveNote(newNotes: string) {
|
||||
if (saveTimeout.value) {
|
||||
clearTimeout(saveTimeout.value);
|
||||
}
|
||||
|
||||
saveTimeout.value = setTimeout(async () => {
|
||||
hasSaved.value = false;
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RequestBody = { fileName, currentPath, contents: newNotes };
|
||||
const response = await fetch(`/api/notes/save`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to save note!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isSaving.value = false;
|
||||
hasSaved.value = true;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useSignalEffect(() => {
|
||||
if (hasSaved.value && !hasSavedTimeout.value) {
|
||||
hasSavedTimeout.value = setTimeout(() => {
|
||||
hasSaved.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeout.value) {
|
||||
clearTimeout(saveTimeout.value);
|
||||
}
|
||||
|
||||
if (hasSavedTimeout.value) {
|
||||
clearTimeout(hasSavedTimeout.value);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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} />
|
||||
<h3 class='text-base text-white font-semibold'>
|
||||
<span class='mr-2 text-xs'>/</span>
|
||||
{decodeURIComponent(fileName)}
|
||||
</h3>
|
||||
</section>
|
||||
|
||||
<textarea
|
||||
class='my-2 input-field text-sm font-mono mx-auto max-w-7xl'
|
||||
onInput={(event) => saveNote(event.currentTarget.value)}
|
||||
rows={20}
|
||||
>
|
||||
{contents}
|
||||
</textarea>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 ${
|
||||
hasSaved.value ? 'text-green-600' : 'text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{hasSaved.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isSaving.value && !hasSaved.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
islands/notes/NotesWrapper.tsx
Normal file
21
islands/notes/NotesWrapper.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import MainNotes from '/components/notes/MainNotes.tsx';
|
||||
|
||||
interface NotesWrapperProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||
export default function NotesWrapper(
|
||||
{ initialDirectories, initialFiles, initialPath }: NotesWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainNotes
|
||||
initialDirectories={initialDirectories}
|
||||
initialFiles={initialFiles}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,17 @@ async function getPathEntries(userId: string, path: string): Promise<Deno.DirEnt
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the Notes or Photos directories exist, if being requested
|
||||
if (path === '/Notes/' || path === '/Photos/') {
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if (error.toString().includes('NotFound')) {
|
||||
await Deno.mkdir(rootPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Deno.DirEntry[] = [];
|
||||
|
||||
for await (const dirEntry of Deno.readDir(rootPath)) {
|
||||
@@ -159,6 +170,24 @@ export async function createFile(
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
contents: string,
|
||||
): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
|
||||
const parentPath = requestBody.get('parent_path') as string;
|
||||
const name = requestBody.get('name') as string;
|
||||
const contents = requestBody.get('contents') as File;
|
||||
const contents = requestBody.get('contents') as File | string;
|
||||
|
||||
if (
|
||||
!parentPath || !name.trim() || !contents || !parentPath.startsWith('/') ||
|
||||
@@ -29,7 +29,9 @@ export const handler: Handlers<Data, FreshContextState> = {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), await contents.arrayBuffer());
|
||||
const fileContents = typeof contents === 'string' ? contents : await contents.arrayBuffer();
|
||||
|
||||
const createdFile = await createFile(context.state.user.id, parentPath, name.trim(), fileContents);
|
||||
|
||||
const newFiles = await getFiles(context.state.user.id, parentPath);
|
||||
|
||||
|
||||
66
routes/api/notes/save.tsx
Normal file
66
routes/api/notes/save.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Handlers } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { getFile, updateFile } from '/lib/data/files.ts';
|
||||
|
||||
interface Data {}
|
||||
|
||||
export interface RequestBody {
|
||||
fileName: string;
|
||||
currentPath: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
export interface ResponseBody {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async POST(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const requestBody = await request.clone().json() as RequestBody;
|
||||
|
||||
if (
|
||||
!requestBody.currentPath || !requestBody.fileName || !requestBody.currentPath.startsWith('/Notes/') ||
|
||||
requestBody.currentPath.includes('../') || !requestBody.currentPath.endsWith('/')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
if (
|
||||
!requestBody.currentPath || !requestBody.currentPath.startsWith('/Notes/') ||
|
||||
requestBody.currentPath.includes('../')
|
||||
) {
|
||||
return new Response('Bad Request', { status: 400 });
|
||||
}
|
||||
|
||||
// Don't allow non-markdown files here
|
||||
if (!requestBody.fileName.endsWith('.md')) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const fileResult = await getFile(
|
||||
context.state.user.id,
|
||||
requestBody.currentPath,
|
||||
decodeURIComponent(requestBody.fileName),
|
||||
);
|
||||
|
||||
if (!fileResult.success) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const updatedFile = await updateFile(
|
||||
context.state.user.id,
|
||||
requestBody.currentPath,
|
||||
decodeURIComponent(requestBody.fileName),
|
||||
requestBody.contents || '',
|
||||
);
|
||||
|
||||
const responseBody: ResponseBody = { success: updatedFile };
|
||||
|
||||
return new Response(JSON.stringify(responseBody));
|
||||
},
|
||||
};
|
||||
53
routes/notes.tsx
Normal file
53
routes/notes.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { Directory, DirectoryFile, FreshContextState } from '/lib/types.ts';
|
||||
import { getDirectories, getFiles } from '/lib/data/files.ts';
|
||||
import NotesWrapper from '/islands/notes/NotesWrapper.tsx';
|
||||
|
||||
interface Data {
|
||||
userDirectories: Directory[];
|
||||
userNotes: DirectoryFile[];
|
||||
currentPath: string;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
let currentPath = searchParams.get('path') || '/Notes/';
|
||||
|
||||
// Send invalid paths back to Notes root
|
||||
if (!currentPath.startsWith('/Notes/') || currentPath.includes('../')) {
|
||||
currentPath = '/Notes/';
|
||||
}
|
||||
|
||||
// Always append a trailing slash
|
||||
if (!currentPath.endsWith('/')) {
|
||||
currentPath = `${currentPath}/`;
|
||||
}
|
||||
|
||||
const userDirectories = await getDirectories(context.state.user.id, currentPath);
|
||||
|
||||
const userFiles = await getFiles(context.state.user.id, currentPath);
|
||||
|
||||
const userNotes = userFiles.filter((file) => file.file_name.endsWith('.md'));
|
||||
|
||||
return await context.render({ userDirectories, userNotes, currentPath });
|
||||
},
|
||||
};
|
||||
|
||||
export default function FilesPage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return (
|
||||
<main>
|
||||
<NotesWrapper
|
||||
initialDirectories={data.userDirectories}
|
||||
initialFiles={data.userNotes}
|
||||
initialPath={data.currentPath}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
56
routes/notes/open/[fileName].tsx
Normal file
56
routes/notes/open/[fileName].tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Handlers, PageProps } from 'fresh/server.ts';
|
||||
|
||||
import { FreshContextState } from '/lib/types.ts';
|
||||
import { getFile } from '/lib/data/files.ts';
|
||||
import Note from '/islands/notes/Note.tsx';
|
||||
|
||||
interface Data {
|
||||
fileName: string;
|
||||
currentPath: string;
|
||||
contents: string;
|
||||
}
|
||||
|
||||
export const handler: Handlers<Data, FreshContextState> = {
|
||||
async GET(request, context) {
|
||||
if (!context.state.user) {
|
||||
return new Response('Redirect', { status: 303, headers: { 'Location': `/login` } });
|
||||
}
|
||||
|
||||
const { fileName } = context.params;
|
||||
|
||||
if (!fileName) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
|
||||
let currentPath = searchParams.get('path') || '/';
|
||||
|
||||
// Send invalid paths back to notes root
|
||||
if (!currentPath.startsWith('/Notes/') || currentPath.includes('../')) {
|
||||
currentPath = '/Notes/';
|
||||
}
|
||||
|
||||
// Always append a trailing slash
|
||||
if (!currentPath.endsWith('/')) {
|
||||
currentPath = `${currentPath}/`;
|
||||
}
|
||||
|
||||
// Don't allow non-markdown files here
|
||||
if (!fileName.endsWith('.md')) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
const fileResult = await getFile(context.state.user.id, currentPath, decodeURIComponent(fileName));
|
||||
|
||||
if (!fileResult.success) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
return await context.render({ fileName, currentPath, contents: new TextDecoder().decode(fileResult.contents!) });
|
||||
},
|
||||
};
|
||||
|
||||
export default function OpenNotePage({ data }: PageProps<Data, FreshContextState>) {
|
||||
return <Note fileName={data.fileName} currentPath={data.currentPath} contents={data.contents} />;
|
||||
}
|
||||
Reference in New Issue
Block a user