mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 15:50:11 -08:00
feat: folders
This commit is contained in:
@@ -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[]
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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' />
|
||||
|
||||
104
src/components/pages/folders/FavoriteFiles.tsx
Normal file
104
src/components/pages/folders/FavoriteFiles.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
85
src/components/pages/folders/FolderCard.tsx
Normal file
85
src/components/pages/folders/FolderCard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
src/components/pages/folders/ViewFilesModal.tsx
Normal file
45
src/components/pages/folders/ViewFilesModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/pages/folders/actions.tsx
Normal file
96
src/components/pages/folders/actions.tsx
Normal 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');
|
||||
}
|
||||
105
src/components/pages/folders/index.tsx
Normal file
105
src/components/pages/folders/index.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
src/components/pages/folders/views/FolderGridView.tsx
Normal file
51
src/components/pages/folders/views/FolderGridView.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
src/components/pages/folders/views/FolderTableView.tsx
Normal file
145
src/components/pages/folders/views/FolderTableView.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
25
src/lib/db/models/folder.ts
Normal file
25
src/lib/db/models/folder.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => ({
|
||||
|
||||
178
src/pages/api/user/folders/[id].ts
Normal file
178
src/pages/api/user/folders/[id].ts
Normal 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);
|
||||
91
src/pages/api/user/folders/index.ts
Normal file
91
src/pages/api/user/folders/index.ts
Normal 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);
|
||||
19
src/pages/dashboard/folders.tsx
Normal file
19
src/pages/dashboard/folders.tsx
Normal 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
80
src/pages/folder/[id].tsx
Normal 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),
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user