feat: folders

This commit is contained in:
diced
2023-07-22 22:12:22 -07:00
parent c85f0b9ae0
commit 60a5c478fe
21 changed files with 1235 additions and 41 deletions

View File

@@ -47,8 +47,8 @@ model OAuthProvider {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String
provider OAuthProviderType
userId String
provider OAuthProviderType
username String
accessToken String
@@ -125,7 +125,8 @@ model Folder {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String
name String
public Boolean @default(false)
files File[]

View File

@@ -30,6 +30,7 @@ import {
IconFileText,
IconFileUpload,
IconFiles,
IconFolder,
IconHome,
IconLink,
IconLogout,
@@ -68,6 +69,12 @@ const navLinks: NavLinks[] = [
active: (path: string) => path === '/dashboard/files',
href: '/dashboard/files',
},
{
label: 'Folders',
icon: <IconFolder size='1rem' />,
active: (path: string) => path === '/dashboard/folders',
href: '/dashboard/folders',
},
{
label: 'Upload',
icon: <IconUpload size='1rem' />,
@@ -198,7 +205,7 @@ export default function Layout({ children, config }: { children: React.ReactNode
navbarOffsetBreakpoint='sm'
asideOffsetBreakpoint='sm'
navbar={
<Navbar hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 200 }}>
<Navbar hiddenBreakpoint='sm' hidden={!opened} width={{ sm: 200, lg: 230 }}>
{navLinks
.filter((link) => !link.if || link.if(user as Response['/api/user']['user']))
.map((link) => {

View File

@@ -1,5 +1,5 @@
import { File } from '@/lib/db/models/file';
import { ActionIcon, Group, Modal, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
import { ActionIcon, Group, Modal, Select, SimpleGrid, Text, Title, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
@@ -11,16 +11,29 @@ import {
IconExternalLink,
IconEyeFilled,
IconFileInfo,
IconFolderMinus,
IconRefresh,
IconStar,
IconStarFilled,
IconTrashFilled,
IconUpload
IconUpload,
} from '@tabler/icons-react';
import bytes from 'bytes';
import DashboardFileType from '../DashboardFileType';
import { copyFile, deleteFile, downloadFile, favoriteFile, viewFile } from '../actions';
import {
addToFolder,
copyFile,
createFolderAndAdd,
deleteFile,
downloadFile,
favoriteFile,
removeFromFolder,
viewFile,
} from '../actions';
import FileStat from './FileStat';
import useSWR from 'swr';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
function ActionButton({
Icon,
@@ -46,13 +59,19 @@ export default function FileModal({
open,
setOpen,
file,
reduce,
}: {
open: boolean;
setOpen: (open: boolean) => void;
file?: File | null;
reduce?: boolean;
}) {
const clipboard = useClipboard();
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true'
);
return (
<Modal
opened={open}
@@ -89,7 +108,7 @@ export default function FileModal({
<FileStat Icon={IconDeviceSdCard} title='Size' value={bytes(file.size, { unitSeparator: ' ' })} />
<FileStat Icon={IconUpload} title='Created at' value={file.createdAt.toLocaleString()} />
<FileStat Icon={IconRefresh} title='Updated at' value={file.updatedAt.toLocaleString()} />
{file.deletesAt && (
{file.deletesAt && !reduce && (
<FileStat Icon={IconBombFilled} title='Deletes at' value={file.deletesAt.toLocaleString()} />
)}
<FileStat Icon={IconEyeFilled} title='Views' value={file.views} />
@@ -97,18 +116,47 @@ export default function FileModal({
<Group position='apart'>
<Group position='left'>
<Text size='sm' color='dimmed'>
{file.id}
</Text>
{!reduce &&
(file.folderId ? (
<ActionButton
Icon={IconFolderMinus}
onClick={() => removeFromFolder(file)}
tooltip={`Remove from folder "${
folders?.find((f) => f.id === file.folderId)?.name ?? ''
}"`}
color='red'
/>
) : (
<Select
data={folders?.map((f) => ({ value: f.id, label: f.name })) ?? []}
placeholder='Add to a folder...'
searchable
creatable
getCreateLabel={(value) => `Create folder "${value}"`}
onCreate={(query) => createFolderAndAdd(file, query)}
onChange={(value) => addToFolder(file, value)}
size='xs'
/>
))}
</Group>
<Group position='right'>
<ActionButton
Icon={IconTrashFilled}
onClick={() => deleteFile(file, notifications, setOpen)}
tooltip='Delete file'
color='red'
/>
{!reduce && (
<>
<ActionButton
Icon={IconTrashFilled}
onClick={() => deleteFile(file, setOpen)}
tooltip='Delete file'
color='red'
/>
<ActionButton
Icon={file.favorite ? IconStarFilled : IconStar}
onClick={() => favoriteFile(file)}
tooltip={file.favorite ? 'Unfavorite file' : 'Favorite file'}
color={file.favorite ? 'yellow' : 'gray'}
/>
</>
)}
<ActionButton
Icon={IconExternalLink}
onClick={() => viewFile(file)}
@@ -116,15 +164,9 @@ export default function FileModal({
/>
<ActionButton
Icon={IconCopy}
onClick={() => copyFile(file, clipboard, notifications)}
onClick={() => copyFile(file, clipboard)}
tooltip='Copy file link'
/>
<ActionButton
Icon={file.favorite ? IconStarFilled : IconStar}
onClick={() => favoriteFile(file, notifications)}
tooltip={file.favorite ? 'Unfavorite file' : 'Favorite file'}
color={file.favorite ? 'yellow' : 'gray'}
/>
<ActionButton Icon={IconDownload} onClick={() => downloadFile(file)} tooltip='Download file' />
</Group>
</Group>

View File

@@ -4,12 +4,12 @@ import { useState } from 'react';
import DashboardFileType from '../DashboardFileType';
import FileModal from './FileModal';
export default function DashboardFile({ file }: { file: File }) {
export default function DashboardFile({ file, reduce }: { file: File; reduce?: boolean }) {
const [open, setOpen] = useState(false);
return (
<>
<FileModal open={open} setOpen={setOpen} file={file} />
<FileModal open={open} setOpen={setOpen} file={file} reduce={reduce} />
<Card
shadow='md'

View File

@@ -1,10 +1,20 @@
import { Response } from '@/lib/api/response';
import type { File } from '@/lib/db/models/file';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { Anchor } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { notifications as notifs } from '@mantine/notifications';
import { IconCopy, IconStar, IconStarFilled, IconTrashFilled, IconTrashXFilled } from '@tabler/icons-react';
import { notifications, notifications as notifs } from '@mantine/notifications';
import {
IconCopy,
IconFolderMinus,
IconFolderOff,
IconFolderPlus,
IconStar,
IconStarFilled,
IconTrashFilled,
IconTrashXFilled,
} from '@tabler/icons-react';
import Link from 'next/link';
import { mutate } from 'swr';
@@ -16,11 +26,7 @@ export function downloadFile(file: File) {
window.open(`/raw/${file.name}?download=true`, '_blank');
}
export function copyFile(
file: File,
clipboard: ReturnType<typeof useClipboard>,
notifications: typeof notifs
) {
export function copyFile(file: File, clipboard: ReturnType<typeof useClipboard>) {
const domain = `${window.location.protocol}//${window.location.host}`;
const url = file.url ? `${domain}${file.url}` : `${domain}/view/${file.name}`;
@@ -39,7 +45,7 @@ export function copyFile(
});
}
export async function deleteFile(file: File, notifications: typeof notifs, setOpen: (open: boolean) => void) {
export async function deleteFile(file: File, setOpen: (open: boolean) => void) {
const { error } = await fetchApi(`/api/user/files/${file.id}`, 'DELETE');
if (error) {
@@ -63,7 +69,7 @@ export async function deleteFile(file: File, notifications: typeof notifs, setOp
mutateFiles();
}
export async function favoriteFile(file: File, notifications: typeof notifs) {
export async function favoriteFile(file: File) {
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
`/api/user/files/${file.id}`,
'PATCH',
@@ -91,7 +97,101 @@ export async function favoriteFile(file: File, notifications: typeof notifs) {
mutateFiles();
}
export function createFolderAndAdd(file: File, folderName: string | null) {
fetchApi<Extract<Response['/api/user/folders'], Folder>>(`/api/user/folders`, 'POST', {
name: folderName,
files: [file.id],
}).then(({ data, error }) => {
if (error) {
notifications.show({
title: 'Error while creating folder',
message: error.message,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'Folder created',
message: `${data!.name} has been created with ${file.name}`,
color: 'green',
icon: <IconFolderPlus size='1rem' />,
});
}
});
mutateFolders();
mutateFiles();
return undefined;
}
export async function removeFromFolder(file: File) {
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
`/api/user/folders/${file.folderId}`,
'DELETE',
{
delete: 'file',
id: file.id,
}
);
if (error) {
notifications.show({
title: 'Error while removing from folder',
message: error.message,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'File removed from folder',
message: `${file.name} has been removed from ${data!.name}`,
color: 'green',
icon: <IconFolderMinus size='1rem' />,
});
}
mutateFolders();
mutateFiles();
}
export async function addToFolder(file: File, folderId: string | null) {
if (!folderId) return;
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folderId}`,
'POST',
{
id: file.id,
}
);
if (error) {
notifications.show({
title: 'Error while adding to folder',
message: error.message,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'File added to folder',
message: `${file.name} has been added to ${data!.name}`,
color: 'green',
icon: <IconFolderPlus size='1rem' />,
});
}
mutateFolders();
mutateFiles();
}
export function mutateFiles() {
mutate('/api/user/recent');
mutate((key) => (key as Record<any, any>)?.key === '/api/user/files'); // paged files
}
export function mutateFolders() {
mutate('/api/user/folders');
mutate('/api/user/folders?noincl=true');
}

View File

@@ -50,6 +50,16 @@ export default function FileTable({ id }: { id?: string }) {
);
}, [page]);
useEffect(() => {
if (data && selectedFile) {
const file = data.page.find((x) => x.id === selectedFile.id);
if (file) {
setSelectedFile(file);
}
}
}, [data]);
return (
<>
<FileModal
@@ -63,9 +73,7 @@ export default function FileTable({ id }: { id?: string }) {
<Box my='sm'>
{selectedFiles.length > 0 && (
<Paper withBorder p='sm' my='sm'>
<Title order={3}>
Operations
</Title>
<Title order={3}>Operations</Title>
<Text size='sm' color='dimmed' mb='xs'>
Selections are saved across page changes
@@ -158,7 +166,7 @@ export default function FileTable({ id }: { id?: string }) {
color='gray'
onClick={(e) => {
e.stopPropagation();
copyFile(file, clipboard, notifications);
copyFile(file, clipboard);
}}
>
<IconCopy size='1rem' />
@@ -171,7 +179,7 @@ export default function FileTable({ id }: { id?: string }) {
color='red'
onClick={(e) => {
e.stopPropagation();
deleteFile(file, notifications, () => {});
deleteFile(file, () => {});
}}
>
<IconTrashFilled size='1rem' />

View File

@@ -0,0 +1,104 @@
import { useConfig } from '@/components/ConfigProvider';
import DashboardFile from '@/components/file/DashboardFile';
import {
Accordion,
Button,
Center,
Group,
LoadingOverlay,
Pagination,
Paper,
SimpleGrid,
Stack,
Title,
} from '@mantine/core';
import { IconFileUpload, IconFilesOff } from '@tabler/icons-react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useApiPagination } from '../useApiPagination';
export default function FavoriteFiles() {
const router = useRouter();
const [page, setPage] = useState<number>(
router.query.favoritePage ? parseInt(router.query.favoritePage as string) : 1
);
const { data, isLoading } = useApiPagination({
page,
favorite: true,
filter: 'dashboard',
});
useEffect(() => {
router.replace(
{
query: {
...router.query,
favoritePage: page,
},
},
undefined,
{ shallow: true }
);
}, [page]);
if (!isLoading && data?.page.length === 0) return null;
return (
<>
<Accordion variant='separated'>
<Accordion.Item value='favorite'>
<Accordion.Control>
Favorite Files
<Accordion.Panel>
<SimpleGrid
my='sm'
cols={data?.page.length ?? 0 > 0 ? 3 : 1}
spacing='md'
breakpoints={[
{ maxWidth: 'sm', cols: 1 },
{ maxWidth: 'md', cols: 2 },
]}
pos='relative'
>
{isLoading ? (
<Paper withBorder h={200}>
<LoadingOverlay visible />
</Paper>
) : data?.page.length ?? 0 > 0 ? (
data?.page.map((file) => <DashboardFile key={file.id} file={file} />)
) : (
<Paper withBorder p='sm'>
<Center>
<Stack>
<Group>
<IconFilesOff size='2rem' />
<Title order={2}>No files found</Title>
</Group>
<Button
variant='outline'
color='gray'
compact
leftIcon={<IconFileUpload size='1rem' />}
component={Link}
href='/dashboard/upload/file'
>
Upload a file
</Button>
</Stack>
</Center>
</Paper>
)}
</SimpleGrid>
<Center>
<Pagination my='sm' value={page} onChange={setPage} total={data?.pages ?? 1} />
</Center>
</Accordion.Panel>
</Accordion.Control>
</Accordion.Item>
</Accordion>
</>
);
}

View File

@@ -0,0 +1,85 @@
import { useConfig } from '@/components/ConfigProvider';
import RelativeDate from '@/components/RelativeDate';
import { Url } from '@/lib/db/models/url';
import { formatRootUrl } from '@/lib/url';
import { ActionIcon, Anchor, Card, Group, Menu, Stack, Text } from '@mantine/core';
import { IconCopy, IconDots, IconFiles, IconLock, IconLockOpen, IconTrashFilled } from '@tabler/icons-react';
import { useState } from 'react';
import { useClipboard } from '@mantine/hooks';
import { Folder } from '@/lib/db/models/folder';
import ViewFilesModal from './ViewFilesModal';
import { copyFolderUrl, deleteFolder, editFolderVisibility } from './actions';
export default function FolderCard({ folder }: { folder: Folder }) {
const config = useConfig();
const clipboard = useClipboard();
const [open, setOpen] = useState(false);
return (
<>
<ViewFilesModal opened={open} onClose={() => setOpen(false)} folder={folder} />
<Card withBorder shadow='sm' radius='sm'>
<Card.Section withBorder inheritPadding py='xs'>
<Group position='apart'>
<Text weight={400}>{folder.name}</Text>
<Menu withinPortal position='bottom-end' shadow='sm'>
<Group spacing={2}>
<Menu.Target>
<ActionIcon>
<IconDots size='1rem' />
</ActionIcon>
</Menu.Target>
</Group>
<Menu.Dropdown>
<Menu.Item icon={<IconFiles size='1rem' />} onClick={() => setOpen(true)}>
View Files
</Menu.Item>
<Menu.Item
icon={folder.public ? <IconLock size='1rem' /> : <IconLockOpen size='1rem' />}
onClick={() => editFolderVisibility(folder, !folder.public)}
>
{folder.public ? 'Make Private' : 'Make Public'}
</Menu.Item>
<Menu.Item
icon={<IconCopy size='1rem' />}
disabled={!folder.public}
onClick={() => copyFolderUrl(folder, clipboard)}
>
Copy URL
</Menu.Item>
<Menu.Item
icon={<IconTrashFilled size='1rem' />}
color='red'
onClick={() => deleteFolder(folder)}
>
Delete
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Card.Section>
<Card.Section inheritPadding py='xs'>
<Stack spacing={1}>
<Text size='xs' color='dimmed'>
<b>Created:</b> <RelativeDate date={folder.createdAt} />
</Text>
<Text size='xs' color='dimmed'>
<b>Updated:</b> <RelativeDate date={folder.updatedAt} />
</Text>
<Text size='xs' color='dimmed'>
<b>Public:</b> {folder.public ? 'Yes' : 'No'}
</Text>
<Text size='xs' color='dimmed'>
<b>Files:</b> {folder.files.length}
</Text>
</Stack>
</Card.Section>
</Card>
</>
);
}

View File

@@ -0,0 +1,45 @@
import DashboardFile from '@/components/file/DashboardFile';
import { Folder } from '@/lib/db/models/folder';
import { Modal, Paper, SimpleGrid, Title } from '@mantine/core';
export default function ViewFilesModal({
folder,
opened,
onClose,
}: {
folder: Folder | null;
opened: boolean;
onClose: () => void;
}) {
return (
<Modal
size='auto'
zIndex={100}
centered
title={<Title>{folder?.name}</Title>}
opened={opened}
onClose={onClose}
>
{folder?.files?.length === 0 ? (
<Paper p='lg' withBorder>
No files found
</Paper>
) : (
<SimpleGrid
my='sm'
spacing='md'
cols={3}
breakpoints={[
{ maxWidth: 'sm', cols: 1 },
{ maxWidth: 'md', cols: 2 },
]}
pos='relative'
>
{folder?.files.map((file) => (
<DashboardFile file={file} key={file.id} />
))}
</SimpleGrid>
)}
</Modal>
);
}

View File

@@ -0,0 +1,96 @@
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { Anchor, Title } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
import { IconCheck, IconCopy, IconFolderOff } from '@tabler/icons-react';
import Link from 'next/link';
import { mutate } from 'swr';
export async function deleteFolder(folder: Folder) {
modals.openConfirmModal({
centered: true,
title: <Title>Delete {folder.name}?</Title>,
children: `Are you sure you want to delete ${folder.name}? This action cannot be undone.`,
labels: {
cancel: 'Cancel',
confirm: 'Delete',
},
confirmProps: { color: 'red' },
onConfirm: () => handleDeleteFolder(folder),
onCancel: modals.closeAll,
});
}
export function copyFolderUrl(folder: Folder, clipboard: ReturnType<typeof useClipboard>) {
clipboard.copy(`${window.location.protocol}//${window.location.host}/folder/${folder.id}`);
notifications.show({
title: 'Copied link',
message: (
<Anchor component={Link} href={`/folder/${folder.id}`}>
{`${window.location.protocol}//${window.location.host}/folder/${folder.id}`}
</Anchor>
),
color: 'green',
icon: <IconCopy size='1rem' />,
});
}
export async function editFolderVisibility(folder: Folder, isPublic: boolean) {
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'PATCH',
{
isPublic,
}
);
if (error) {
notifications.show({
title: 'Failed to edit folder visibility',
message: error.message,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'Folder visibility edited',
message: `${data?.name} is now ${isPublic ? 'public' : 'private'}`,
color: 'green',
icon: <IconCheck size='1rem' />,
});
}
mutate('/api/user/folders');
}
async function handleDeleteFolder(folder: Folder) {
const { data, error } = await fetchApi<Response['/api/user/folders/[id]']>(
`/api/user/folders/${folder.id}`,
'DELETE',
{
delete: 'folder',
}
);
if (error) {
notifications.show({
title: 'Failed to delete folder',
message: error.message,
color: 'red',
icon: <IconFolderOff size='1rem' />,
});
} else {
notifications.show({
title: 'Folder deleted',
message: `${data?.name} has been deleted`,
color: 'green',
icon: <IconCheck size='1rem' />,
});
}
mutate('/api/user/folders');
}

View File

@@ -0,0 +1,105 @@
import GridTableSwitcher from '@/components/GridTableSwitcher';
import { useViewStore } from '@/lib/store/view';
import {
ActionIcon,
Button,
Group,
Modal,
NumberInput,
Stack,
Switch,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { IconFolderPlus, IconLink, IconPlus } from '@tabler/icons-react';
import { useState } from 'react';
import FolderGridView from './views/FolderGridView';
import FolderTableView from './views/FolderTableView';
import { hasLength, useForm } from '@mantine/form';
import { Folder } from '@/lib/db/models/folder';
import { fetchApi } from '@/lib/fetchApi';
import { Response } from '@/lib/api/response';
import { mutate } from 'swr';
import { notifications } from '@mantine/notifications';
export default function DashboardFolders() {
const clipboard = useClipboard();
const view = useViewStore((state) => state.folders);
const [open, setOpen] = useState(false);
const form = useForm({
initialValues: {
name: '',
isPublic: false,
},
validate: {
name: hasLength({ min: 1 }, 'Name is required'),
},
});
const onSubmit = async (values: typeof form.values) => {
const { error } = await fetchApi<Extract<Response['/api/user/folders'], Folder>>(
`/api/user/folders`,
'POST',
{
name: values.name,
isPublic: values.isPublic,
}
);
if (error) {
notifications.show({
message: error.message,
color: 'red',
});
} else {
mutate('/api/user/folders');
setOpen(false);
form.reset();
}
};
return (
<>
<Modal centered opened={open} onClose={() => setOpen(false)} title={<Title>Create a folder</Title>}>
<form onSubmit={form.onSubmit(onSubmit)}>
<Stack spacing='sm'>
<TextInput label='Name' placeholder='Enter a name...' {...form.getInputProps('name')} />
<Switch
label='Public'
description='Public folders are visible to everyone'
{...form.getInputProps('isPublic', { type: 'checkbox' })}
/>
<Button
type='submit'
variant='outline'
color='gray'
radius='sm'
leftIcon={<IconFolderPlus size='1rem' />}
>
Create
</Button>
</Stack>
</form>
</Modal>
<Group>
<Title>Folders</Title>
<Tooltip label='Create a new folder'>
<ActionIcon variant='outline' color='gray' onClick={() => setOpen(true)}>
<IconPlus size='1rem' />
</ActionIcon>
</Tooltip>
<GridTableSwitcher type='folders' />
</Group>
{view === 'grid' ? <FolderGridView /> : <FolderTableView />}
</>
);
}

View File

@@ -0,0 +1,51 @@
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import useSWR from 'swr';
import FolderCard from '../FolderCard';
import { Paper, LoadingOverlay, SimpleGrid, Center, Stack, Group, Title, Text } from '@mantine/core';
import { IconLink } from '@tabler/icons-react';
import urls from '../../urls';
export default function FolderGridView() {
const { data: folders, isLoading } =
useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
return (
<>
{isLoading ? (
<Paper withBorder h={200}>
<LoadingOverlay visible />
</Paper>
) : folders?.length ?? 0 !== 0 ? (
<SimpleGrid
my='sm'
spacing='md'
cols={4}
breakpoints={[
{ maxWidth: 'sm', cols: 1 },
{ maxWidth: 'md', cols: 2 },
]}
pos='relative'
>
{folders?.map((folder) => (
<FolderCard key={folder.id} folder={folder} />
))}
</SimpleGrid>
) : (
<Paper withBorder p='sm' my='sm'>
<Center>
<Stack>
<Group>
<IconLink size='2rem' />
<Title order={2}>No Folders found</Title>
</Group>
<Text size='sm' color='dimmed'>
Create a folder to see it here
</Text>
</Stack>
</Center>
</Paper>
)}
</>
);
}

View File

@@ -0,0 +1,145 @@
import { useConfig } from '@/components/ConfigProvider';
import RelativeDate from '@/components/RelativeDate';
import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder';
import { ActionIcon, Box, Group, Tooltip } from '@mantine/core';
import { useClipboard } from '@mantine/hooks';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { copyFolderUrl, deleteFolder, editFolderVisibility } from '../actions';
import { IconCopy, IconFiles, IconLock, IconLockOpen, IconTrashFilled } from '@tabler/icons-react';
import ViewFilesModal from '../ViewFilesModal';
export default function FolderTableView() {
const config = useConfig();
const clipboard = useClipboard();
const { data, isLoading } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>('/api/user/folders');
const [sortStatus, setSortStatus] = useState<DataTableSortStatus>({
columnAccessor: 'createdAt',
direction: 'desc',
});
const [sorted, setSorted] = useState<Folder[]>(data ?? []);
const [selectedFolder, setSelectedFolder] = useState<Folder | null>(null);
useEffect(() => {
if (data) {
const sorted = data.sort((a, b) => {
const cl = sortStatus.columnAccessor as keyof Folder;
return sortStatus.direction === 'asc' ? (a[cl]! > b[cl]! ? 1 : -1) : a[cl]! < b[cl]! ? 1 : -1;
});
setSorted(sorted);
}
}, [sortStatus]);
useEffect(() => {
if (data) {
setSorted(data);
}
}, [data]);
return (
<>
<ViewFilesModal
opened={!!selectedFolder}
onClose={() => setSelectedFolder(null)}
folder={selectedFolder}
/>
<Box my='sm'>
<DataTable
borderRadius='sm'
withBorder
minHeight={200}
records={sorted ?? []}
columns={[
{
accessor: 'name',
sortable: true,
},
{
accessor: 'public',
sortable: true,
render: (folder) => (folder.public ? 'Yes' : 'No'),
},
{
accessor: 'createdAt',
title: 'Created',
sortable: true,
render: (folder) => <RelativeDate date={folder.createdAt} />,
},
{
accessor: 'updatedAt',
title: 'Last update at',
sortable: true,
render: (folder) => <RelativeDate date={folder.updatedAt} />,
},
{
accessor: 'actions',
width: 170,
render: (folder) => (
<Group spacing='sm'>
<Tooltip label='View files'>
<ActionIcon
variant='outline'
color='gray'
onClick={(e) => {
e.stopPropagation();
setSelectedFolder(folder);
}}
>
<IconFiles size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Copy folder link'>
<ActionIcon
variant='outline'
color='gray'
onClick={(e) => {
e.stopPropagation();
copyFolderUrl(folder, clipboard);
}}
>
<IconCopy size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label={folder.public ? 'Make private' : 'Make public'}>
<ActionIcon
variant='outline'
color={folder.public ? 'blue' : 'gray'}
onClick={(e) => {
e.stopPropagation();
editFolderVisibility(folder, !folder.public);
}}
>
{folder.public ? <IconLockOpen size='1rem' /> : <IconLock size='1rem' />}
</ActionIcon>
</Tooltip>
<Tooltip label='Delete Folder'>
<ActionIcon
variant='outline'
color='red'
onClick={(e) => {
e.stopPropagation();
deleteFolder(folder);
}}
>
<IconTrashFilled size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
),
},
]}
fetching={isLoading}
sortStatus={sortStatus}
onSortStatusChange={(s) => setSortStatus(s)}
/>
</Box>
</>
);
}

View File

@@ -9,6 +9,8 @@ import { ApiUserFilesResponse } from '@/pages/api/user/files';
import { ApiUserFilesIdResponse } from '@/pages/api/user/files/[id]';
import { ApiUserFilesIdPasswordResponse } from '@/pages/api/user/files/[id]/password';
import { ApiUserFilesTransactionResponse } from '@/pages/api/user/files/transaction';
import { ApiUserFoldersResponse } from '@/pages/api/user/folders';
import { ApiUserFoldersIdResponse } from '@/pages/api/user/folders/[id]';
import { ApiUserRecentResponse } from '@/pages/api/user/recent';
import { ApiUserStatsResponse } from '@/pages/api/user/stats';
import { ApiUserTokenResponse } from '@/pages/api/user/token';
@@ -21,6 +23,8 @@ export type Response = {
'/api/auth/oauth': ApiAuthOauthResponse;
'/api/auth/login': ApiLoginResponse;
'/api/auth/logout': ApiLogoutResponse;
'/api/user/folders/[id]': ApiUserFoldersIdResponse;
'/api/user/folders': ApiUserFoldersResponse;
'/api/user/files/[id]/password': ApiUserFilesIdPasswordResponse;
'/api/user/files/[id]': ApiUserFilesIdResponse;
'/api/user/files/transaction': ApiUserFilesTransactionResponse;

View File

@@ -13,6 +13,7 @@ export type File = {
type: string;
views: number;
password?: string | boolean | null;
folderId: string | null;
url?: string;
};
@@ -28,6 +29,7 @@ export const fileSelect = {
size: true,
type: true,
views: true,
folderId: true,
};
export function cleanFile(file: File) {
@@ -38,11 +40,15 @@ export function cleanFile(file: File) {
return file;
}
export function cleanFiles(files: File[]) {
export function cleanFiles(files: File[], stringifyDates = false) {
for (let i = 0; i !== files.length; ++i) {
const file = files[i];
if (file.password) file.password = true;
(file as any).createdAt = stringifyDates ? file.createdAt.toISOString() : file.createdAt;
(file as any).updatedAt = stringifyDates ? file.updatedAt.toISOString() : file.updatedAt;
(file as any).deletesAt = stringifyDates ? file.deletesAt?.toISOString() || null : file.deletesAt;
file.url = formatRootUrl(config.files.route, file.name);
}

View File

@@ -0,0 +1,25 @@
import type { Folder as PrismaFolder } from '@prisma/client';
import { File, cleanFiles } from './file';
export type Folder = PrismaFolder & {
files?: File[];
};
export function cleanFolder(folder: Folder, stringifyDates = false) {
if (folder.files) cleanFiles(folder.files, stringifyDates);
(folder as any).createdAt = stringifyDates ? folder.createdAt.toISOString() : folder.createdAt;
(folder as any).updatedAt = stringifyDates ? folder.updatedAt.toISOString() : folder.updatedAt;
return folder;
}
export function cleanFolders(folders: Folder[]) {
for (let i = 0; i !== folders.length; ++i) {
const folder = folders[i];
if (folder.files) cleanFiles(folder.files);
}
return folders;
}

View File

@@ -8,6 +8,7 @@ export type ViewStore = {
urls: ViewType;
users: ViewType;
invites: ViewType;
folders: ViewType;
setView: (type: Exclude<keyof ViewStore, 'setView'>, value: ViewType) => void;
};
@@ -19,6 +20,7 @@ export const useViewStore = create<ViewStore>()(
urls: 'table',
users: 'table',
invites: 'table',
folders: 'table',
setView: (type, value) =>
set((state) => ({

View File

@@ -0,0 +1,178 @@
import { prisma } from '@/lib/db';
import { fileSelect } from '@/lib/db/models/file';
import { Folder, cleanFolder } from '@/lib/db/models/folder';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFoldersIdResponse = Folder;
type Query = {
id: string;
};
type Body = {
id?: string;
isPublic?: boolean;
delete?: 'file' | 'folder';
};
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserFoldersIdResponse>) {
const { id } = req.query;
const folder = await prisma.folder.findUnique({
where: {
id,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
if (!folder) return res.notFound('Folder not found');
if (req.user.id !== folder.userId) return res.forbidden('You do not own this folder');
if (req.method === 'POST') {
const { id } = req.body;
if (!id) return res.badRequest('File id is required');
const file = await prisma.file.findUnique({
where: {
id,
},
});
if (!file) return res.notFound('File not found');
if (file.userId !== req.user.id) return res.forbidden('You do not own this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id,
Folder: {
id: folder.id,
},
},
});
if (fileInFolder) return res.badRequest('File already in folder');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
files: {
connect: {
id,
},
},
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
} else if (req.method === 'PATCH') {
const { isPublic } = req.body;
if (isPublic === undefined) return res.badRequest('isPublic is required');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
public: isPublic,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
} else if (req.method === 'DELETE') {
const { delete: del } = req.body;
if (del === 'folder') {
const nFolder = await prisma.folder.delete({
where: {
id: folder.id,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
} else if (del === 'file') {
const { id } = req.body;
if (!id) return res.badRequest('File id is required');
const file = await prisma.file.findUnique({
where: {
id,
},
});
if (!file) return res.notFound('File not found');
if (file.userId !== req.user.id) return res.forbidden('You do not own this file');
const fileInFolder = await prisma.file.findFirst({
where: {
id,
Folder: {
id: folder.id,
},
},
});
if (!fileInFolder) return res.badRequest('File not in folder');
const nFolder = await prisma.folder.update({
where: {
id: folder.id,
},
data: {
files: {
disconnect: {
id,
},
},
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
return res.ok(cleanFolder(nFolder));
}
return res.badRequest('Invalid delete type');
}
return res.ok(cleanFolder(folder));
}
export default combine([method(['GET', 'POST', 'PATCH', 'DELETE']), ziplineAuth()], handler);

View File

@@ -0,0 +1,91 @@
import { prisma } from '@/lib/db';
import { cleanFiles, fileSelect } from '@/lib/db/models/file';
import { Folder, cleanFolder, cleanFolders } from '@/lib/db/models/folder';
import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
import { NextApiReq, NextApiRes } from '@/lib/response';
export type ApiUserFoldersResponse = Folder | Folder[];
type Body = {
files?: string[];
name?: string;
isPublic?: boolean;
};
type Query = {
noincl?: boolean;
};
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserFoldersResponse>) {
const { noincl } = req.query;
if (req.method === 'POST') {
let { name, isPublic, files } = req.body;
if (!name) return res.badRequest('Name is required');
if (files) {
const filesAdd = await prisma.file.findMany({
where: {
id: {
in: files,
},
},
select: {
id: true,
},
});
if (!filesAdd.length) return res.badRequest('No files found, with given request');
files = filesAdd.map((f) => f.id);
}
const folder = await prisma.folder.create({
data: {
name,
userId: req.user.id,
...(files?.length && {
files: {
connect: files!.map((f) => ({ id: f })),
},
}),
public: isPublic ?? false,
},
...(!noincl && {
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
}),
});
return res.ok(cleanFolder(folder));
}
const folders = await prisma.folder.findMany({
where: {
userId: req.user.id,
},
...(!noincl && {
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
}),
});
return res.ok(cleanFolders(folders));
}
export default combine([method(['GET', 'POST']), ziplineAuth()], handler);

View File

@@ -0,0 +1,19 @@
import Layout from '@/components/Layout';
import DashboardFolders from '@/components/pages/folders';
import useLogin from '@/lib/hooks/useLogin';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import { LoadingOverlay } from '@mantine/core';
import { InferGetServerSidePropsType } from 'next';
export default function DashboardIndex({ config }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const { loading } = useLogin();
if (loading) return <LoadingOverlay visible />;
return (
<Layout config={config}>
<DashboardFolders />
</Layout>
);
}
export const getServerSideProps = withSafeConfig();

80
src/pages/folder/[id].tsx Normal file
View File

@@ -0,0 +1,80 @@
import DashboardFile from '@/components/file/DashboardFile';
import DashboardFileType from '@/components/file/DashboardFileType';
import { isCode } from '@/lib/code';
import { config as zConfig } from '@/lib/config';
import { SafeConfig, safeConfig } from '@/lib/config/safe';
import { verifyPassword } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { fileSelect, type File } from '@/lib/db/models/file';
import { Folder, cleanFolder } from '@/lib/db/models/folder';
import { User, userSelect } from '@/lib/db/models/user';
import { fetchApi } from '@/lib/fetchApi';
import { withSafeConfig } from '@/lib/middleware/next/withSafeConfig';
import { parseString } from '@/lib/parser';
import { formatRootUrl } from '@/lib/url';
import {
Button,
Center,
Collapse,
Container,
Group,
Modal,
Paper,
PasswordInput,
SimpleGrid,
Text,
Title,
TypographyStylesProvider,
} from '@mantine/core';
import { IconFileDownload } from '@tabler/icons-react';
import { sanitize } from 'isomorphic-dompurify';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
export default function ViewFolder({ folder }: InferGetServerSidePropsType<typeof getServerSideProps>) {
if (!folder) return null;
return (
<>
<Container>
<Title order={1}>{folder.name}</Title>
<SimpleGrid my='sm' cols={3} spacing='md' breakpoints={[{ maxWidth: 'sm', cols: 1 }, { maxWidth: 'md', cols: 2 }]}>
{folder.files?.map((file) => (
<DashboardFile key={file.id} file={file} reduce />
))}
</SimpleGrid>
</Container>
</>
)
}
export const getServerSideProps = withSafeConfig<{
folder?: Folder;
}>(async (ctx) => {
const { id } = ctx.query;
if (!id) return { notFound: true };
const folder = await prisma.folder.findUnique({
where: {
id: id as string,
},
include: {
files: {
select: {
...fileSelect,
password: true,
},
},
},
});
if (!folder) return { notFound: true };
if (!folder.public) return { notFound: true };
return {
folder: cleanFolder(folder, true),
};
});