feat(v3.7.0-rc3): folders for files
This commit is contained in:
parent
06e84b41aa
commit
fb5f50d5bd
24 changed files with 907 additions and 67 deletions
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "zipline",
|
||||
"version": "3.7.0-rc2",
|
||||
"version": "3.7.0-rc3",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm-run-all build:server dev:run",
|
||||
|
|
19
prisma/migrations/20230128183334_folders/migration.sql
Normal file
19
prisma/migrations/20230128183334_folders/migration.sql
Normal file
|
@ -0,0 +1,19 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "File" ADD COLUMN "folderId" INTEGER;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Folder" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "Folder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Folder" ADD CONSTRAINT "Folder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "File" ADD CONSTRAINT "File_folderId_fkey" FOREIGN KEY ("folderId") REFERENCES "Folder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
@ -21,9 +21,22 @@ model User {
|
|||
totpSecret String?
|
||||
domains String[]
|
||||
oauth OAuth[]
|
||||
files File[]
|
||||
files File[]
|
||||
urls Url[]
|
||||
Invite Invite[]
|
||||
Folder Folder[]
|
||||
}
|
||||
|
||||
model Folder {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
|
||||
files File[]
|
||||
}
|
||||
|
||||
enum FileNameFormat {
|
||||
|
@ -34,30 +47,33 @@ enum FileNameFormat {
|
|||
}
|
||||
|
||||
model File {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
originalName String?
|
||||
mimetype String @default("image/png")
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
password String?
|
||||
invisible InvisibleFile?
|
||||
format FileNameFormat @default(RANDOM)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
mimetype String @default("image/png")
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
maxViews Int?
|
||||
views Int @default(0)
|
||||
favorite Boolean @default(false)
|
||||
embed Boolean @default(false)
|
||||
password String?
|
||||
invisible InvisibleFile?
|
||||
format FileNameFormat @default(RANDOM)
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
}
|
||||
|
||||
model InvisibleFile {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
// imageId Int @unique
|
||||
fileId Int @unique
|
||||
// image File @relation(fields: [imageId], references: [id], onDelete: Cascade)
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
fileId Int @unique
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Url {
|
||||
|
@ -68,30 +84,33 @@ model Url {
|
|||
maxViews Int?
|
||||
views Int @default(0)
|
||||
invisible InvisibleUrl?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
}
|
||||
|
||||
model InvisibleUrl {
|
||||
id Int @id @default(autoincrement())
|
||||
invis String @unique
|
||||
|
||||
urlId String @unique
|
||||
url Url @relation(fields: [urlId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Stats {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
data Json
|
||||
data Json
|
||||
}
|
||||
|
||||
model Invite {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
used Boolean @default(false)
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime?
|
||||
used Boolean @default(false)
|
||||
|
||||
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
|
||||
createdById Int
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
LoadingOverlay,
|
||||
Modal,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
|
@ -13,8 +14,11 @@ import {
|
|||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { invalidateFiles, useFileDelete, useFileFavorite } from 'lib/queries/files';
|
||||
import { invalidateFolders, useFolders } from 'lib/queries/folders';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CalendarIcon,
|
||||
|
@ -30,10 +34,17 @@ import {
|
|||
ImageIcon,
|
||||
StarIcon,
|
||||
InfoIcon,
|
||||
FolderMinusIcon,
|
||||
FolderPlusIcon,
|
||||
} from './icons';
|
||||
import MutedText from './MutedText';
|
||||
import Type from './Type';
|
||||
|
||||
const CREATE_FOLDER_OPTION = {
|
||||
label: 'Create new folder',
|
||||
value: 'create',
|
||||
};
|
||||
|
||||
export function FileMeta({ Icon, title, subtitle, ...other }) {
|
||||
return other.tooltip ? (
|
||||
<Group>
|
||||
|
@ -56,12 +67,15 @@ export function FileMeta({ Icon, title, subtitle, ...other }) {
|
|||
);
|
||||
}
|
||||
|
||||
export default function File({ image, disableMediaPreview, exifEnabled }) {
|
||||
export default function File({ image, disableMediaPreview, exifEnabled, refreshImages }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const clipboard = useClipboard();
|
||||
const router = useRouter();
|
||||
|
||||
const folders = useFolders();
|
||||
|
||||
const loading = deleteFile.isLoading || favoriteFile.isLoading;
|
||||
|
||||
|
@ -125,6 +139,65 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
|
|||
);
|
||||
};
|
||||
|
||||
const inFolder = image.folderId;
|
||||
|
||||
const refresh = () => {
|
||||
refreshImages();
|
||||
folders.refetch();
|
||||
};
|
||||
|
||||
const removeFromFolder = async () => {
|
||||
const res = await useFetch('/api/user/folders/' + image.folderId, 'DELETE', {
|
||||
file: Number(image.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Removed from folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderMinusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to remove from folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addToFolder = async (t) => {
|
||||
if (t === CREATE_FOLDER_OPTION.value) {
|
||||
router.push('/dashboard/folders?create=' + image.id);
|
||||
} else {
|
||||
const res = await useFetch('/api/user/folders/' + t, 'POST', {
|
||||
file: Number(image.id),
|
||||
});
|
||||
|
||||
refresh();
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Added to folder',
|
||||
message: res.name,
|
||||
color: 'green',
|
||||
icon: <FolderPlusIcon />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to add to folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <CrossIcon />,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>{image.name}</Title>} size='xl'>
|
||||
|
@ -182,9 +255,6 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
|
|||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && (
|
||||
// <Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
|
||||
// <Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
|
||||
// </Link>
|
||||
<Tooltip label='View Metadata'>
|
||||
<ActionIcon
|
||||
color='blue'
|
||||
|
@ -195,6 +265,36 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
|
|||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${
|
||||
folders.data.find((f) => f.id === image.folderId)?.name ?? ''
|
||||
}"`}
|
||||
>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
variant='filled'
|
||||
onClick={removeFromFolder}
|
||||
loading={folders.isLoading}
|
||||
>
|
||||
<FolderMinusIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
CREATE_FOLDER_OPTION,
|
||||
]}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
<Tooltip label='Delete file'>
|
||||
|
@ -235,17 +335,6 @@ export default function File({ image, disableMediaPreview, exifEnabled }) {
|
|||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{/* {exifEnabled && (
|
||||
<Link href={`/dashboard/metadata/${image.id}`} target='_blank' rel='noopener noreferrer'>
|
||||
<Button leftIcon={<ExternalLinkIcon />}>View Metadata</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button onClick={handleCopy}>Copy URL</Button>
|
||||
<Button onClick={handleDelete}>Delete</Button>
|
||||
<Button onClick={handleFavorite}>{image.favorite ? 'Unfavorite' : 'Favorite'}</Button>
|
||||
<Link href={image.url} target='_blank'>
|
||||
<Button rightIcon={<ExternalLinkIcon />}>Open</Button>
|
||||
</Link> */}
|
||||
</Group>
|
||||
</Modal>
|
||||
<Card sx={{ maxWidth: '100%', height: '100%' }} shadow='md'>
|
||||
|
|
|
@ -38,6 +38,7 @@ import {
|
|||
DiscordIcon,
|
||||
ExternalLinkIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
GitHubIcon,
|
||||
GoogleIcon,
|
||||
HomeIcon,
|
||||
|
@ -71,6 +72,11 @@ const items: NavbarItems[] = [
|
|||
text: 'Files',
|
||||
link: '/dashboard/files',
|
||||
},
|
||||
{
|
||||
icon: <FolderIcon size={18} />,
|
||||
text: 'Folders',
|
||||
link: '/dashboard/folders',
|
||||
},
|
||||
{
|
||||
icon: <ActivityIcon size={18} />,
|
||||
text: 'Stats',
|
||||
|
|
5
src/components/icons/FolderIcon.tsx
Normal file
5
src/components/icons/FolderIcon.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { Folder } from 'react-feather';
|
||||
|
||||
export default function FolderIcon({ ...props }) {
|
||||
return <Folder size={15} {...props} />;
|
||||
}
|
5
src/components/icons/FolderMinusIcon.tsx
Normal file
5
src/components/icons/FolderMinusIcon.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { FolderMinus } from 'react-feather';
|
||||
|
||||
export default function FolderMinusIcon({ ...props }) {
|
||||
return <FolderMinus size={15} {...props} />;
|
||||
}
|
5
src/components/icons/FolderPlusIcon.tsx
Normal file
5
src/components/icons/FolderPlusIcon.tsx
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { FolderPlus } from 'react-feather';
|
||||
|
||||
export default function FolderPlusIcon({ ...props }) {
|
||||
return <FolderPlus size={15} {...props} />;
|
||||
}
|
|
@ -35,6 +35,9 @@ import RefreshIcon from './RefreshIcon';
|
|||
import KeyIcon from './KeyIcon';
|
||||
import DatabaseIcon from './DatabaseIcon';
|
||||
import InfoIcon from './InfoIcon';
|
||||
import FolderIcon from './FolderIcon';
|
||||
import FolderMinusIcon from './FolderMinusIcon';
|
||||
import FolderPlusIcon from './FolderPlusIcon';
|
||||
|
||||
export {
|
||||
ActivityIcon,
|
||||
|
@ -74,4 +77,7 @@ export {
|
|||
KeyIcon,
|
||||
DatabaseIcon,
|
||||
InfoIcon,
|
||||
FolderIcon,
|
||||
FolderMinusIcon,
|
||||
FolderPlusIcon,
|
||||
};
|
||||
|
|
|
@ -24,6 +24,7 @@ export default function RecentFiles({ disableMediaPreview, exifEnabled }) {
|
|||
image={image}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={recent.refetch}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
@ -67,7 +67,12 @@ export default function FilePagation({ disableMediaPreview, exifEnabled, queryPa
|
|||
? pages.data.length
|
||||
? pages.data.map((image) => (
|
||||
<div key={image.id}>
|
||||
<File image={image} disableMediaPreview={disableMediaPreview} exifEnabled={exifEnabled} />
|
||||
<File
|
||||
image={image}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={pages.refetch}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null
|
||||
|
|
|
@ -19,12 +19,6 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
|||
})();
|
||||
});
|
||||
|
||||
const updatePages = async (favorite) => {
|
||||
if (favorite) {
|
||||
favoritePages.refetch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group mb='md'>
|
||||
|
@ -55,6 +49,7 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage }) {
|
|||
image={image}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={favoritePages.refetch}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
|
66
src/components/pages/Folders/CreateFolderModal.tsx
Normal file
66
src/components/pages/Folders/CreateFolderModal.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import { Button, Group, Modal, TextInput, Title } from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CrossIcon, FolderIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function CreateFolderModal({ open, setOpen, updateFolders, createWithFile }) {
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values) => {
|
||||
const res = await useFetch('/api/user/folders', 'POST', {
|
||||
name: values.name,
|
||||
add: createWithFile ? [createWithFile] : undefined,
|
||||
});
|
||||
|
||||
if (res.error) {
|
||||
showNotification({
|
||||
title: 'Failed to create folder',
|
||||
message: res.error,
|
||||
icon: <CrossIcon />,
|
||||
color: 'red',
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Created folder ' + res.name,
|
||||
message: createWithFile ? 'Added file to folder' : undefined,
|
||||
icon: <FolderIcon />,
|
||||
color: 'green',
|
||||
});
|
||||
|
||||
if (createWithFile) {
|
||||
router.push('/dashboard/folders');
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
updateFolders();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={open} onClose={() => setOpen(false)} title={<Title>Create Folder</Title>}>
|
||||
<form onSubmit={form.onSubmit((v) => onSubmit(v))}>
|
||||
<TextInput label='Folder Name' placeholder='Folder Name' {...form.getInputProps('name')} />
|
||||
|
||||
{createWithFile && (
|
||||
<MutedText size='sm'>
|
||||
Creating this folder will add file with an ID of <b>{createWithFile}</b> to it automatically.
|
||||
</MutedText>
|
||||
)}
|
||||
|
||||
<Group position='right' mt='md'>
|
||||
<Button onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button type='submit'>Create</Button>
|
||||
</Group>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
46
src/components/pages/Folders/ViewFolderFilesModal.tsx
Normal file
46
src/components/pages/Folders/ViewFolderFilesModal.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { Center, Modal, SimpleGrid, Stack, Text, Title } from '@mantine/core';
|
||||
import File from 'components/File';
|
||||
import MutedText from 'components/MutedText';
|
||||
import { useFolder } from 'lib/queries/folders';
|
||||
|
||||
export default function ViewFolderFilesModal({ open, setOpen, folderId, disableMediaPreview, exifEnabled }) {
|
||||
if (!folderId) return null;
|
||||
|
||||
const folder = useFolder(folderId, true);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>View {folder.data?.name}'s files</Title>}
|
||||
size='xl'
|
||||
>
|
||||
{folder.isSuccess ? (
|
||||
<>
|
||||
{folder.data.files.length ? (
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{folder.data.files.map((file) => (
|
||||
<File
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
key={file.id}
|
||||
image={file}
|
||||
exifEnabled={exifEnabled}
|
||||
refreshImages={folder.refetch}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Center>
|
||||
<Stack>
|
||||
<Text align='center'>No files in this folder</Text>
|
||||
<MutedText size='sm'>
|
||||
Add files to {folder.data.name} by clicking a file in the Files tab and selecting a folder
|
||||
</MutedText>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
146
src/components/pages/Folders/index.tsx
Normal file
146
src/components/pages/Folders/index.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
import { ActionIcon, Avatar, Card, Group, SimpleGrid, Skeleton, Stack, Title, Tooltip } from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { CopyIcon, DeleteIcon, FileIcon, PlusIcon } from 'components/icons';
|
||||
import MutedText from 'components/MutedText';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import CreateFolderModal from './CreateFolderModal';
|
||||
import ViewFolderFilesModal from './ViewFolderFilesModal';
|
||||
|
||||
export default function Folders({ disableMediaPreview, exifEnabled }) {
|
||||
const folders = useFolders();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createWithFile, setCreateWithFile] = useState(null);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
const [activeFolderId, setActiveFolderId] = useState(null);
|
||||
|
||||
const modals = useModals();
|
||||
const clipboard = useClipboard();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.create) {
|
||||
setCreateOpen(true);
|
||||
setCreateWithFile(router.query.create);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deleteFolder = (folder) => {
|
||||
modals.openConfirmModal({
|
||||
title: <Title>Delete folder {folder.name}?</Title>,
|
||||
children:
|
||||
'Are you sure you want to delete this folder? All files within the folder will still exist, but will no longer be in a folder.',
|
||||
labels: {
|
||||
confirm: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const res = await useFetch(`/api/user/folders/${folder.id}`, 'DELETE', {
|
||||
deleteFolder: true,
|
||||
});
|
||||
|
||||
if (!res.error) {
|
||||
showNotification({
|
||||
title: 'Deleted folder',
|
||||
message: `Deleted folder ${folder.name}`,
|
||||
color: 'green',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
folders.refetch();
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Failed to delete folder',
|
||||
message: res.error,
|
||||
color: 'red',
|
||||
icon: <DeleteIcon />,
|
||||
});
|
||||
folders.refetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateFolderModal
|
||||
open={createOpen}
|
||||
setOpen={setCreateOpen}
|
||||
createWithFile={createWithFile}
|
||||
updateFolders={folders.refetch}
|
||||
/>
|
||||
<ViewFolderFilesModal
|
||||
open={viewOpen}
|
||||
setOpen={setViewOpen}
|
||||
folderId={activeFolderId}
|
||||
disableMediaPreview={disableMediaPreview}
|
||||
exifEnabled={exifEnabled}
|
||||
/>
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>Folders</Title>
|
||||
<ActionIcon onClick={() => setCreateOpen(!createOpen)} component='a' variant='filled' color='primary'>
|
||||
<PlusIcon />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={3} spacing='lg' breakpoints={[{ maxWidth: 'sm', cols: 1, spacing: 'sm' }]}>
|
||||
{folders.isSuccess
|
||||
? folders.data.length
|
||||
? folders.data.map((folder) => (
|
||||
<Card key={folder.id}>
|
||||
<Group position='apart'>
|
||||
<Group position='left'>
|
||||
<Avatar size='lg' color='primary'>
|
||||
{folder.id}
|
||||
</Avatar>
|
||||
<Stack spacing={0}>
|
||||
<Title>{folder.name}</Title>
|
||||
<MutedText size='sm'>ID: {folder.id}</MutedText>
|
||||
<Tooltip label={new Date(folder.createdAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Created {relativeTime(new Date(folder.createdAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip label={new Date(folder.updatedAt).toLocaleString()}>
|
||||
<div>
|
||||
<MutedText size='sm'>
|
||||
Last updated {relativeTime(new Date(folder.updatedAt))}
|
||||
</MutedText>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Stack>
|
||||
<ActionIcon
|
||||
aria-label='view files'
|
||||
onClick={() => {
|
||||
setViewOpen(!viewOpen);
|
||||
setActiveFolderId(folder.id);
|
||||
}}
|
||||
>
|
||||
<FileIcon />
|
||||
</ActionIcon>
|
||||
<ActionIcon aria-label='delete' onClick={() => deleteFolder(folder)}>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
: null
|
||||
: [1, 2, 3, 4].map((x) => (
|
||||
<div key={x}>
|
||||
<Skeleton width='100%' height={220} sx={{ borderRadius: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
85
src/lib/queries/folders.ts
Normal file
85
src/lib/queries/folders.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import queryClient from './client';
|
||||
import { UserFilesResponse } from './files';
|
||||
|
||||
export type UserFoldersResponse = {
|
||||
id: number;
|
||||
name: string;
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
files?: UserFilesResponse[];
|
||||
};
|
||||
|
||||
export const useFolders = (query: { [key: string]: string } = {}) => {
|
||||
const queryBuilder = new URLSearchParams(query);
|
||||
const queryString = queryBuilder.toString();
|
||||
|
||||
return useQuery<UserFoldersResponse[]>(['folders', queryString], async () => {
|
||||
return fetch('/api/user/folders?' + queryString)
|
||||
.then((res) => res.json() as Promise<UserFoldersResponse[]>)
|
||||
.then((data) =>
|
||||
data.map((x) => ({
|
||||
...x,
|
||||
createdAt: new Date(x.createdAt).toLocaleString(),
|
||||
updatedAt: new Date(x.updatedAt).toLocaleString(),
|
||||
}))
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFolder = (id: string, withFiles: boolean = false) => {
|
||||
return useQuery<UserFoldersResponse>(['folder', id], async () => {
|
||||
return fetch('/api/user/folders/' + id + (withFiles ? '?files=true' : ''))
|
||||
.then((res) => res.json() as Promise<UserFoldersResponse>)
|
||||
.then((data) => ({
|
||||
...data,
|
||||
createdAt: new Date(data.createdAt).toLocaleString(),
|
||||
updatedAt: new Date(data.updatedAt).toLocaleString(),
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
// export function useFileDelete() {
|
||||
// // '/api/user/files', 'DELETE', { id: image.id }
|
||||
// return useMutation(
|
||||
// async (id: string) => {
|
||||
// return fetch('/api/user/files', {
|
||||
// method: 'DELETE',
|
||||
// body: JSON.stringify({ id }),
|
||||
// headers: {
|
||||
// 'content-type': 'application/json',
|
||||
// },
|
||||
// }).then((res) => res.json());
|
||||
// },
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// queryClient.refetchQueries(['files']);
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
// export function useFileFavorite() {
|
||||
// // /api/user/files', 'PATCH', { id: image.id, favorite: !image.favorite }
|
||||
// return useMutation(
|
||||
// async (data: { id: string; favorite: boolean }) => {
|
||||
// return fetch('/api/user/files', {
|
||||
// method: 'PATCH',
|
||||
// body: JSON.stringify(data),
|
||||
// headers: {
|
||||
// 'content-type': 'application/json',
|
||||
// },
|
||||
// }).then((res) => res.json());
|
||||
// },
|
||||
// {
|
||||
// onSuccess: () => {
|
||||
// queryClient.refetchQueries(['files']);
|
||||
// },
|
||||
// }
|
||||
// );
|
||||
// }
|
||||
|
||||
export function invalidateFolders() {
|
||||
return queryClient.invalidateQueries(['folders']);
|
||||
}
|
3
src/lib/utils/urls.ts
Normal file
3
src/lib/utils/urls.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function formatRootUrl(route: string, src: string) {
|
||||
return `${route === '/' ? '' : route}/${src}`;
|
||||
}
|
|
@ -2,6 +2,7 @@ import config from 'lib/config';
|
|||
import datasource from 'lib/datasource';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
const logger = Logger.get('files');
|
||||
|
@ -89,7 +90,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
|||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
(files[i] as unknown as { url: string }).url = `${config.uploader.route}/${files[i].name}`;
|
||||
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
|
||||
}
|
||||
|
||||
if (req.query.filter && req.query.filter === 'media')
|
||||
|
|
213
src/pages/api/user/folders/[id].ts
Normal file
213
src/pages/api/user/folders/[id].ts
Normal file
|
@ -0,0 +1,213 @@
|
|||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
const logger = Logger.get('folders');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
const { id } = req.query as { id: string };
|
||||
const idParsed = Number(id);
|
||||
|
||||
if (isNaN(idParsed)) return res.badRequest('id must be a number');
|
||||
|
||||
const folder = await prisma.folder.findUnique({
|
||||
where: {
|
||||
id: idParsed,
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!folder) return res.notFound('folder not found');
|
||||
|
||||
if (folder.userId !== user.id) return res.forbidden('you do not have permission to access this folder');
|
||||
|
||||
if (req.method === 'POST') {
|
||||
const { file: fileId }: { file: string } = req.body;
|
||||
if (!fileId) return res.badRequest('file is required');
|
||||
const fileIdParsed = Number(fileId);
|
||||
|
||||
if (isNaN(fileIdParsed)) return res.badRequest('file must be a number');
|
||||
|
||||
const file = await prisma.file.findUnique({
|
||||
where: {
|
||||
id: fileIdParsed,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) return res.notFound('file not found');
|
||||
|
||||
if (file.userId !== user.id) return res.forbidden('you do not have permission to access this file');
|
||||
|
||||
const fileInFolder = await prisma.file.findFirst({
|
||||
where: {
|
||||
id: fileIdParsed,
|
||||
folder: {
|
||||
id: idParsed,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (fileInFolder) return res.badRequest('file is already in folder');
|
||||
|
||||
const folder = await prisma.folder.update({
|
||||
where: {
|
||||
id: idParsed,
|
||||
},
|
||||
data: {
|
||||
files: {
|
||||
connect: {
|
||||
id: fileIdParsed,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`added file ${fileIdParsed} to folder ${idParsed}`);
|
||||
|
||||
logger.info(
|
||||
`Added file "${file.name}" to folder "${folder.name}" for user ${user.username} (${user.id})`
|
||||
);
|
||||
|
||||
if (req.query.files) {
|
||||
for (let i = 0; i !== folder.files.length; ++i) {
|
||||
const file = folder.files[i];
|
||||
delete file.password;
|
||||
|
||||
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
|
||||
config.uploader.route,
|
||||
folder.files[i].name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(folder);
|
||||
} else if (req.method === 'DELETE') {
|
||||
const deletingFolder = !!req.body.deleteFolder;
|
||||
|
||||
if (deletingFolder) {
|
||||
const folder = await prisma.folder.delete({
|
||||
where: {
|
||||
id: idParsed,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`deleted folder ${idParsed}`);
|
||||
|
||||
logger.info(`Deleted folder "${folder.name}" for user ${user.username} (${user.id})`);
|
||||
|
||||
return res.json(folder);
|
||||
} else {
|
||||
const { file: fileId }: { file: string } = req.body;
|
||||
|
||||
if (!fileId) return res.badRequest('file is required');
|
||||
|
||||
const fileIdParsed = Number(fileId);
|
||||
|
||||
if (isNaN(fileIdParsed)) return res.badRequest('file must be a number');
|
||||
|
||||
const file = await prisma.file.findUnique({
|
||||
where: {
|
||||
id: fileIdParsed,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) return res.notFound('file not found');
|
||||
|
||||
if (file.userId !== user.id) return res.forbidden('you do not have permission to access this file');
|
||||
|
||||
const fileInFolder = await prisma.file.findFirst({
|
||||
where: {
|
||||
id: fileIdParsed,
|
||||
folder: {
|
||||
id: idParsed,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!fileInFolder) return res.badRequest('file is not in folder');
|
||||
|
||||
const folder = await prisma.folder.update({
|
||||
where: {
|
||||
id: idParsed,
|
||||
},
|
||||
data: {
|
||||
files: {
|
||||
disconnect: {
|
||||
id: fileIdParsed,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`removed file ${fileIdParsed} from folder ${idParsed}`);
|
||||
|
||||
logger.info(
|
||||
`Removed file "${file.name}" from folder "${folder.name}" for user ${user.username} (${user.id})`
|
||||
);
|
||||
|
||||
if (req.query.files) {
|
||||
for (let i = 0; i !== folder.files.length; ++i) {
|
||||
const file = folder.files[i];
|
||||
delete file.password;
|
||||
|
||||
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
|
||||
config.uploader.route,
|
||||
folder.files[i].name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(folder);
|
||||
}
|
||||
} else {
|
||||
if (req.query.files) {
|
||||
for (let i = 0; i !== folder.files.length; ++i) {
|
||||
const file = folder.files[i];
|
||||
delete file.password;
|
||||
|
||||
(folder.files[i] as unknown as { url: string }).url = formatRootUrl(
|
||||
config.uploader.route,
|
||||
folder.files[i].name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(folder);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'POST', 'DELETE'],
|
||||
user: true,
|
||||
});
|
97
src/pages/api/user/folders/index.ts
Normal file
97
src/pages/api/user/folders/index.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
const logger = Logger.get('folders');
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
if (req.method === 'POST') {
|
||||
const { name, add }: { name: string; add: string[] } = req.body;
|
||||
if (!name) return res.badRequest('name is required');
|
||||
if (name.trim() === '') return res.badRequest('name cannot be empty');
|
||||
|
||||
if (add) {
|
||||
// add contains a list of file IDs to add to the folder
|
||||
const files = await prisma.file.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: add.map((id) => Number(id)),
|
||||
},
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (files.length !== add.length)
|
||||
return res.badRequest(
|
||||
`files ${add.filter((id) => !files.find((file) => file.id === Number(id))).join(', ')} not found`
|
||||
);
|
||||
|
||||
const folder = await prisma.folder.create({
|
||||
data: {
|
||||
name,
|
||||
userId: user.id,
|
||||
files: {
|
||||
connect: files.map((file) => ({ id: file.id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`created folder ${JSON.stringify(folder)}`);
|
||||
|
||||
logger.info(`Created folder "${folder.name}" for user ${user.username} (${user.id})`);
|
||||
|
||||
return res.json(folder);
|
||||
}
|
||||
|
||||
const folder = await prisma.folder.create({
|
||||
data: {
|
||||
name,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(`created folder ${JSON.stringify(folder)}`);
|
||||
|
||||
logger.info(`Created folder "${folder.name}" for user ${user.username} (${user.id})`);
|
||||
|
||||
return res.json(folder);
|
||||
} else {
|
||||
const folders = await prisma.folder.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
files: !!req.query.files,
|
||||
id: true,
|
||||
name: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (req.query.files) {
|
||||
for (let i = 0; i !== folders.length; ++i) {
|
||||
const folder = folders[i];
|
||||
for (let j = 0; j !== folders[i].files.length; ++j) {
|
||||
const file = folder.files[j];
|
||||
delete file.password;
|
||||
|
||||
(folder.files[j] as unknown as { url: string }).url = formatRootUrl(
|
||||
config.uploader.route,
|
||||
folder.files[j].name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(folders);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'POST'],
|
||||
user: true,
|
||||
});
|
|
@ -1,5 +1,6 @@
|
|||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
const pageCount = 16;
|
||||
|
@ -56,6 +57,7 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
|||
expiresAt: Date;
|
||||
maxViews: number;
|
||||
views: number;
|
||||
folderId: number;
|
||||
}[] = await prisma.file.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
|
@ -70,15 +72,14 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
|||
favorite: true,
|
||||
views: true,
|
||||
maxViews: true,
|
||||
folderId: true,
|
||||
},
|
||||
skip: page ? (Number(page) - 1) * pageCount : undefined,
|
||||
take: page ? pageCount : undefined,
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
(files[i] as unknown as { url: string }).url = `${
|
||||
config.uploader.route === '/' ? '/' : `${config.uploader.route}/`
|
||||
}${files[i].name}`;
|
||||
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
|
||||
}
|
||||
|
||||
return res.json(files);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import config from 'lib/config';
|
||||
import prisma from 'lib/prisma';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
@ -23,13 +24,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
|||
id: true,
|
||||
views: true,
|
||||
maxViews: true,
|
||||
folderId: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (let i = 0; i !== files.length; ++i) {
|
||||
(files[i] as unknown as { url: string }).url = `${
|
||||
config.uploader.route === '/' ? '/' : `${config.uploader.route}/`
|
||||
}${files[i].name}`;
|
||||
(files[i] as unknown as { url: string }).url = formatRootUrl(config.uploader.route, files[i].name);
|
||||
}
|
||||
|
||||
if (req.query.filter && req.query.filter === 'media')
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import config from 'lib/config';
|
||||
import Logger from 'lib/logger';
|
||||
import prisma from 'lib/prisma';
|
||||
import { formatRootUrl } from 'lib/utils/urls';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
|
@ -34,11 +35,12 @@ async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
|||
},
|
||||
});
|
||||
|
||||
urls.map(
|
||||
(url) =>
|
||||
// @ts-ignore
|
||||
(url.url = `${config.urls.route === '/' ? '/' : `${config.urls.route}/`}${url.vanity ?? url.id}`)
|
||||
);
|
||||
for (let i = 0; i !== urls.length; ++i) {
|
||||
(urls[i] as unknown as { url: string }).url = formatRootUrl(
|
||||
config.urls.route,
|
||||
urls[i].vanity ?? urls[i].id
|
||||
);
|
||||
}
|
||||
return res.json(urls);
|
||||
}
|
||||
}
|
||||
|
|
25
src/pages/dashboard/folders.tsx
Normal file
25
src/pages/dashboard/folders.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { LoadingOverlay } from '@mantine/core';
|
||||
import Layout from 'components/Layout';
|
||||
import Folders from 'components/pages/Folders';
|
||||
import useLogin from 'hooks/useLogin';
|
||||
import Head from 'next/head';
|
||||
export { getServerSideProps } from 'middleware/getServerSideProps';
|
||||
|
||||
export default function FilesPage(props) {
|
||||
const { loading } = useLogin();
|
||||
|
||||
if (loading) return <LoadingOverlay visible={loading} />;
|
||||
|
||||
const title = `${props.title} - Folders`;
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
|
||||
<Layout props={props}>
|
||||
<Folders disableMediaPreview={props.disable_media_preview} exifEnabled={props.exif_enabled} />
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue