mirror of
https://github.com/diced/zipline.git
synced 2025-12-06 04:41:12 -08:00
Compare commits
4 Commits
v3.7.2
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adb984b2db | ||
|
|
3be9f1521e | ||
|
|
5d971a9fef | ||
|
|
2c86abbf4e |
26
prisma/migrations/20230401212405_file_tags/migration.sql
Normal file
26
prisma/migrations/20230401212405_file_tags/migration.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tag" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"color" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "Tag_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_FileToTag" (
|
||||
"A" INTEGER NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "_FileToTag_AB_unique" ON "_FileToTag"("A", "B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_FileToTag_B_index" ON "_FileToTag"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "File"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_FileToTag" ADD CONSTRAINT "_FileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -60,8 +60,18 @@ model File {
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
userId Int?
|
||||
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
|
||||
folderId Int?
|
||||
|
||||
tags Tag[]
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
color String
|
||||
|
||||
files File[]
|
||||
}
|
||||
|
||||
model InvisibleFile {
|
||||
|
||||
@@ -3,11 +3,14 @@ import {
|
||||
Group,
|
||||
LoadingOverlay,
|
||||
Modal,
|
||||
MultiSelect,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Tooltip,
|
||||
Text,
|
||||
Accordion,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard, useMediaQuery } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
@@ -24,21 +27,27 @@ import {
|
||||
IconFolderCancel,
|
||||
IconFolderMinus,
|
||||
IconFolderPlus,
|
||||
IconFolders,
|
||||
IconHash,
|
||||
IconInfoCircle,
|
||||
IconPhoto,
|
||||
IconPhotoCancel,
|
||||
IconPhotoMinus,
|
||||
IconPhotoStar,
|
||||
IconPlus,
|
||||
IconTags,
|
||||
} from '@tabler/icons-react';
|
||||
import useFetch, { ApiError } from 'hooks/useFetch';
|
||||
import { useFileDelete, useFileFavorite, UserFilesResponse } from 'lib/queries/files';
|
||||
import { useFolders } from 'lib/queries/folders';
|
||||
import { bytesToHuman } from 'lib/utils/bytes';
|
||||
import { relativeTime } from 'lib/utils/client';
|
||||
import { colorHash, relativeTime } from 'lib/utils/client';
|
||||
import { useState } from 'react';
|
||||
import { FileMeta } from '.';
|
||||
import Type from '../Type';
|
||||
import Tag from 'components/File/tag/Tag';
|
||||
import Item from 'components/File/tag/Item';
|
||||
import { useDeleteFileTags, useFileTags, useTags, useUpdateFileTags } from 'lib/queries/tags';
|
||||
|
||||
export default function FileModal({
|
||||
open,
|
||||
@@ -62,9 +71,14 @@ export default function FileModal({
|
||||
const deleteFile = useFileDelete();
|
||||
const favoriteFile = useFileFavorite();
|
||||
const folders = useFolders();
|
||||
const tags = useFileTags(file.id);
|
||||
const updateTags = useUpdateFileTags(file.id);
|
||||
const removeTags = useDeleteFileTags(file.id);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const allTags = useTags();
|
||||
|
||||
const [overrideRender, setOverrideRender] = useState(false);
|
||||
const clipboard = useClipboard();
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFile.mutate(file.id, {
|
||||
@@ -209,12 +223,50 @@ export default function FileModal({
|
||||
return { value: t, label: t };
|
||||
};
|
||||
|
||||
const handleTagsSave = () => {
|
||||
console.log('should save');
|
||||
};
|
||||
|
||||
const handleAddTags = (t: string[]) => {
|
||||
// filter out existing tags from t
|
||||
t = t.filter((tag) => !tags.data.find((t) => t.id === tag));
|
||||
|
||||
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
|
||||
|
||||
if (!fullTag) return;
|
||||
|
||||
updateTags.mutate([...tags.data, fullTag], {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Added tag',
|
||||
message: fullTag.name,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveTags = (t: string[]) => {
|
||||
const fullTag = allTags.data.find((tag) => tag.id === t[0]);
|
||||
|
||||
removeTags.mutate(t, {
|
||||
onSuccess: () =>
|
||||
showNotification({
|
||||
title: 'Removed tag',
|
||||
message: fullTag.name,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={<Title>{file.name}</Title>}
|
||||
size='auto'
|
||||
size='lg'
|
||||
fullScreen={useMediaQuery('(max-width: 600px)')}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
@@ -224,8 +276,6 @@ export default function FileModal({
|
||||
src={`/r/${encodeURI(file.name)}?compress=${compress}`}
|
||||
alt={file.name}
|
||||
popup
|
||||
sx={{ minHeight: 200 }}
|
||||
style={{ minHeight: 200 }}
|
||||
disableMediaPreview={false}
|
||||
overrideRender={overrideRender}
|
||||
setOverrideRender={setOverrideRender}
|
||||
@@ -269,6 +319,98 @@ export default function FileModal({
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
{!reducedActions ? (
|
||||
<Accordion
|
||||
variant='contained'
|
||||
mb='sm'
|
||||
styles={(t) => ({
|
||||
content: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
|
||||
control: { backgroundColor: t.colorScheme === 'dark' ? t.colors.dark[7] : t.colors.gray[0] },
|
||||
})}
|
||||
>
|
||||
<Accordion.Item value='tags'>
|
||||
<Accordion.Control icon={<IconTags size='1rem' />}>Tags</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<MultiSelect
|
||||
value={tags.data?.map((t) => t.id) ?? []}
|
||||
data={allTags.data?.map((t) => ({ value: t.id, label: t.name, color: t.color })) ?? []}
|
||||
placeholder={allTags.data?.length ? 'Add tags' : 'Add tags (optional)'}
|
||||
icon={<IconTags size='1rem' />}
|
||||
valueComponent={Tag}
|
||||
itemComponent={Item}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(t) => (
|
||||
<Group>
|
||||
<IconPlus size='1rem' />
|
||||
<Text ml='sm' display='flex'>
|
||||
Create tag{' '}
|
||||
<Text ml={4} color={colorHash(t)}>
|
||||
"{t}"
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
// onChange={(t) => (t.length === 1 ? handleRemoveTags(t) : handleAddTags(t))}
|
||||
onChange={(t) => console.log(t)}
|
||||
onCreate={(t) => {
|
||||
const item = { value: t, label: t, color: colorHash(t) };
|
||||
// setLabelTags([...labelTags, item]);
|
||||
return item;
|
||||
}}
|
||||
onBlur={handleTagsSave}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value='folders'>
|
||||
<Accordion.Control icon={<IconFolders size='1rem' />}>Folders</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{inFolder && !folders.isLoading ? (
|
||||
<Group>
|
||||
<Tooltip
|
||||
label={`Remove from folder "${
|
||||
folders.data.find((f) => f.id === file.folderId)?.name ?? ''
|
||||
}"`}
|
||||
>
|
||||
<ActionIcon
|
||||
color='red'
|
||||
variant='filled'
|
||||
onClick={removeFromFolder}
|
||||
loading={folders.isLoading}
|
||||
>
|
||||
<IconFolderMinus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Text display='flex' align='center'>
|
||||
Currently in folder "{folders.data.find((f) => f.id === file.folderId)?.name ?? ''}
|
||||
"
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Tooltip label='Add to folder'>
|
||||
<Select
|
||||
icon={<IconFolderPlus size='1rem' />}
|
||||
onChange={addToFolder}
|
||||
placeholder='Add to folder'
|
||||
data={[
|
||||
...(folders.data ? folders.data : []).map((folder) => ({
|
||||
value: String(folder.id),
|
||||
label: `${folder.id}: ${folder.name}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
) : null}
|
||||
|
||||
<Group position='apart' my='md'>
|
||||
<Group position='left'>
|
||||
{exifEnabled && !reducedActions && (
|
||||
@@ -282,32 +424,6 @@ export default function FileModal({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{reducedActions ? null : inFolder && !folders.isLoading ? (
|
||||
<Tooltip
|
||||
label={`Remove from folder "${folders.data.find((f) => f.id === file.folderId)?.name ?? ''}"`}
|
||||
>
|
||||
<ActionIcon color='red' variant='filled' onClick={removeFromFolder} loading={folders.isLoading}>
|
||||
<IconFolderMinus size='1rem' />
|
||||
</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}`,
|
||||
})),
|
||||
]}
|
||||
searchable
|
||||
creatable
|
||||
getCreateLabel={(query) => `Create folder "${query}"`}
|
||||
onCreate={createFolder}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
<Group position='right'>
|
||||
{reducedActions ? null : (
|
||||
|
||||
17
src/components/File/tag/Item.tsx
Normal file
17
src/components/File/tag/Item.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ComponentPropsWithoutRef, forwardRef } from 'react';
|
||||
import { Group, Text } from '@mantine/core';
|
||||
|
||||
interface ItemProps extends ComponentPropsWithoutRef<'div'> {
|
||||
color: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const Item = forwardRef<HTMLDivElement, ItemProps>(({ color, label, ...others }: ItemProps, ref) => (
|
||||
<div ref={ref} {...others}>
|
||||
<Group noWrap>
|
||||
<Text color={color}>{label}</Text>
|
||||
</Group>
|
||||
</div>
|
||||
));
|
||||
|
||||
export default Item;
|
||||
26
src/components/File/tag/Tag.tsx
Normal file
26
src/components/File/tag/Tag.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Box, CloseButton, MultiSelectValueProps, rem } from '@mantine/core';
|
||||
|
||||
export default function Tag({
|
||||
label,
|
||||
onRemove,
|
||||
color,
|
||||
...others
|
||||
}: MultiSelectValueProps & { color: string }) {
|
||||
return (
|
||||
<div {...others}>
|
||||
<Box
|
||||
sx={(theme) => ({
|
||||
display: 'flex',
|
||||
cursor: 'default',
|
||||
alignItems: 'center',
|
||||
backgroundColor: color,
|
||||
paddingLeft: theme.spacing.xs,
|
||||
borderRadius: theme.radius.sm,
|
||||
})}
|
||||
>
|
||||
<Box sx={{ lineHeight: 1, fontSize: rem(12) }}>{label}</Box>
|
||||
<CloseButton onMouseDown={onRemove} variant='transparent' size={22} iconSize={14} tabIndex={-1} />
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
src/components/pages/Files/TagsModal.tsx
Normal file
197
src/components/pages/Files/TagsModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
ColorInput,
|
||||
Group,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDeleteTags, useTags } from 'lib/queries/tags';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconRefresh, IconTag, IconTags, IconTagsOff } from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { colorHash } from 'utils/client';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { useModals } from '@mantine/modals';
|
||||
import MutedText from 'components/MutedText';
|
||||
|
||||
export function TagCard({ tags, tag }) {
|
||||
const deleteTags = useDeleteTags();
|
||||
const modals = useModals();
|
||||
|
||||
const deleteTag = () => {
|
||||
modals.openConfirmModal({
|
||||
zIndex: 1000,
|
||||
size: 'auto',
|
||||
title: (
|
||||
<Title>
|
||||
Delete tag <b style={{ color: tag.color }}>{tag.name}</b>?
|
||||
</Title>
|
||||
),
|
||||
children: `This will remove the tag from ${tag.files.length} file${tag.files.length === 1 ? '' : 's'}`,
|
||||
labels: {
|
||||
confirm: 'Delete',
|
||||
cancel: 'Cancel',
|
||||
},
|
||||
onCancel() {
|
||||
modals.closeAll();
|
||||
},
|
||||
onConfirm() {
|
||||
deleteTags.mutate([tag.id], {
|
||||
onSuccess: () => {
|
||||
showNotification({
|
||||
title: 'Tag deleted',
|
||||
message: `Tag ${tag.name} was deleted`,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
modals.closeAll();
|
||||
tags.refetch();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius='sm'
|
||||
sx={(t) => ({
|
||||
backgroundColor: tag.color,
|
||||
'&:hover': {
|
||||
backgroundColor: t.fn.darken(tag.color, 0.1),
|
||||
},
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
px='xs'
|
||||
onClick={deleteTag}
|
||||
>
|
||||
<Group position='apart'>
|
||||
<Text>
|
||||
{tag.name} ({tag.files.length})
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function CreateTagModal({ tags, open, onClose }) {
|
||||
const [color, setColor] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
const [colorError, setColorError] = useState('');
|
||||
const [nameError, setNameError] = useState('');
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setNameError('');
|
||||
setColorError('');
|
||||
|
||||
const n = name.trim();
|
||||
const c = color.trim();
|
||||
|
||||
if (n.length === 0 && c.length === 0) {
|
||||
setNameError('Name is required');
|
||||
setColorError('Color is required');
|
||||
return;
|
||||
} else if (n.length === 0) {
|
||||
setNameError('Name is required');
|
||||
setColorError('');
|
||||
return;
|
||||
} else if (c.length === 0) {
|
||||
setNameError('');
|
||||
setColorError('Color is required');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await useFetch('/api/user/tags', 'POST', {
|
||||
tags: [
|
||||
{
|
||||
name: n,
|
||||
color: c,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!data.error) {
|
||||
showNotification({
|
||||
title: 'Tag created',
|
||||
message: (
|
||||
<>
|
||||
Tag <b style={{ color: color }}>{name}</b> was created
|
||||
</>
|
||||
),
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
tags.refetch();
|
||||
onClose();
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Error creating tag',
|
||||
message: data.error,
|
||||
color: 'red',
|
||||
icon: <IconTagsOff size='1rem' />,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={<Title>Create Tag</Title>} size='xs' opened={open} onClose={onClose} zIndex={300}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<TextInput
|
||||
icon={<IconTag size='1rem' />}
|
||||
label='Name'
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
error={nameError}
|
||||
/>
|
||||
<ColorInput
|
||||
dropdownZIndex={301}
|
||||
label='Color'
|
||||
value={color}
|
||||
onChange={setColor}
|
||||
error={colorError}
|
||||
rightSection={
|
||||
<Tooltip label='Generate color from name'>
|
||||
<ActionIcon variant='subtle' onClick={() => setColor(colorHash(name))} color='primary'>
|
||||
<IconRefresh size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button type='submit' fullWidth variant='outline' my='sm'>
|
||||
Create Tag
|
||||
</Button>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagsModal({ open, onClose }) {
|
||||
const tags = useTags();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateTagModal tags={tags} open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||
<Modal title={<Title>Tags</Title>} size='auto' opened={open} onClose={onClose}>
|
||||
<MutedText size='sm'>Click on a tag to delete it.</MutedText>
|
||||
<Stack>
|
||||
{tags.isSuccess && tags.data.map((tag) => <TagCard key={tag.id} tags={tags} tag={tag} />)}
|
||||
</Stack>
|
||||
|
||||
<Button mt='xl' variant='outline' onClick={() => setCreateOpen(true)} fullWidth compact>
|
||||
Create Tag
|
||||
</Button>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Accordion, ActionIcon, Box, Group, Pagination, SimpleGrid, Title, Tooltip } from '@mantine/core';
|
||||
import { IconFileUpload, IconPhotoUp } from '@tabler/icons-react';
|
||||
import { IconFileUpload, IconPhotoUp, IconTags } from '@tabler/icons-react';
|
||||
import File from 'components/File';
|
||||
import useFetch from 'hooks/useFetch';
|
||||
import { usePaginatedFiles } from 'lib/queries/files';
|
||||
@@ -7,13 +7,15 @@ import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import FilePagation from './FilePagation';
|
||||
import PendingFilesModal from './PendingFilesModal';
|
||||
import TagsModal from 'components/pages/Files/TagsModal';
|
||||
|
||||
export default function Files({ disableMediaPreview, exifEnabled, queryPage, compress }) {
|
||||
const [favoritePage, setFavoritePage] = useState(1);
|
||||
const [favoriteNumPages, setFavoriteNumPages] = useState(0);
|
||||
const favoritePages = usePaginatedFiles(favoritePage, 'media', true);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pendingOpen, setPendingOpen] = useState(false);
|
||||
const [tagsOpen, setTagsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -24,7 +26,8 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
|
||||
|
||||
return (
|
||||
<>
|
||||
<PendingFilesModal open={open} onClose={() => setOpen(false)} />
|
||||
<PendingFilesModal open={pendingOpen} onClose={() => setPendingOpen(false)} />
|
||||
<TagsModal open={tagsOpen} onClose={() => setTagsOpen(false)} />
|
||||
|
||||
<Group mb='md'>
|
||||
<Title>Files</Title>
|
||||
@@ -33,10 +36,15 @@ export default function Files({ disableMediaPreview, exifEnabled, queryPage, com
|
||||
</ActionIcon>
|
||||
|
||||
<Tooltip label='View pending uploads'>
|
||||
<ActionIcon onClick={() => setOpen(true)} variant='filled' color='primary'>
|
||||
<ActionIcon onClick={() => setPendingOpen(true)} variant='filled' color='primary'>
|
||||
<IconPhotoUp size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label='View tags'>
|
||||
<ActionIcon onClick={() => setTagsOpen(true)} variant='filled' color='primary'>
|
||||
<IconTags size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
{favoritePages.isSuccess && favoritePages.data.length ? (
|
||||
<Accordion
|
||||
|
||||
168
src/lib/queries/tags.ts
Normal file
168
src/lib/queries/tags.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import queryClient from 'lib/queries/client';
|
||||
|
||||
export type UserTagsResponse = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
files: {
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TagsRequest = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
export const useTags = () => {
|
||||
return useQuery<UserTagsResponse[]>(['tags'], async () => {
|
||||
return fetch('/api/user/tags')
|
||||
.then((res) => res.json() as Promise<UserTagsResponse[]>)
|
||||
.then((data) => data);
|
||||
});
|
||||
};
|
||||
|
||||
export const useFileTags = (id: string) => {
|
||||
return useQuery<UserTagsResponse[]>(['tags', id], async () => {
|
||||
return fetch(`/api/user/file/${id}/tags`)
|
||||
.then((res) => res.json() as Promise<UserTagsResponse[]>)
|
||||
.then((data) => data);
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFileTags = (id: string) => {
|
||||
return useMutation(
|
||||
(tags: TagsRequest[]) =>
|
||||
fetch(`/api/user/file/${id}/tags`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tags }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['tags', id]);
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeleteFileTags = (id: string) => {
|
||||
return useMutation(
|
||||
(tags: string[]) =>
|
||||
fetch(`/api/user/file/${id}/tags`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ tags }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['tags', id]);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const useDeleteTags = () => {
|
||||
return useMutation(
|
||||
(tags: string[]) =>
|
||||
fetch('/api/user/tags', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ tags }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}).then((res) => res.json()),
|
||||
{
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries(['tags']);
|
||||
queryClient.refetchQueries(['files']);
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// export const usePaginatedFiles = (page?: number, filter = 'media', favorite = null) => {
|
||||
// const queryBuilder = new URLSearchParams({
|
||||
// page: Number(page || '1').toString(),
|
||||
// filter,
|
||||
// ...(favorite !== null && { favorite: favorite.toString() }),
|
||||
// });
|
||||
// const queryString = queryBuilder.toString();
|
||||
//
|
||||
// return useQuery<UserFilesResponse[]>(['files', queryString], async () => {
|
||||
// return fetch('/api/user/paged?' + queryString)
|
||||
// .then((res) => res.json() as Promise<UserFilesResponse[]>)
|
||||
// .then((data) =>
|
||||
// data.map((x) => ({
|
||||
// ...x,
|
||||
// createdAt: new Date(x.createdAt),
|
||||
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
|
||||
// }))
|
||||
// );
|
||||
// });
|
||||
// };
|
||||
//
|
||||
// export const useRecent = (filter?: string) => {
|
||||
// return useQuery<UserFilesResponse[]>(['recent', filter], async () => {
|
||||
// return fetch(`/api/user/recent?filter=${encodeURIComponent(filter)}`)
|
||||
// .then((res) => res.json())
|
||||
// .then((data) =>
|
||||
// data.map((x) => ({
|
||||
// ...x,
|
||||
// createdAt: new Date(x.createdAt),
|
||||
// expiresAt: x.expiresAt ? new Date(x.expiresAt) : null,
|
||||
// }))
|
||||
// );
|
||||
// });
|
||||
// };
|
||||
//
|
||||
// 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 invalidateFiles() {
|
||||
// return queryClient.invalidateQueries(['files', 'recent', 'stats']);
|
||||
// }
|
||||
16
src/lib/utils/db.ts
Normal file
16
src/lib/utils/db.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function exclude<T, Key extends keyof T>(obj: T, keys: Key[]): Omit<T, Key> {
|
||||
for (const key of keys) {
|
||||
delete obj[key];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
export function pick<T, Key extends keyof T>(obj: T, keys: Key[]): Pick<T, Key> {
|
||||
const newObj: unknown = {};
|
||||
|
||||
for (const key of keys) {
|
||||
newObj[key] = obj[key];
|
||||
}
|
||||
|
||||
return newObj as Pick<T, Key>;
|
||||
}
|
||||
43
src/pages/api/user/file/[id]/index.ts
Normal file
43
src/pages/api/user/file/[id]/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
import { pick } from 'utils/db';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
let { id } = req.query as { id: string | number };
|
||||
|
||||
if (!id) return res.badRequest('no id');
|
||||
|
||||
id = Number(id);
|
||||
|
||||
if (isNaN(id)) return res.badRequest('invalid id');
|
||||
|
||||
const file = await prisma.file.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
include: {
|
||||
tags: true,
|
||||
invisible: true,
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) return res.notFound('file not found or not owned by user');
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
return res.badRequest('file deletions must be done at `DELETE /api/user/files`');
|
||||
} else {
|
||||
// @ts-ignore
|
||||
if (file.password) file.password = true;
|
||||
|
||||
if (req.query.pick) {
|
||||
const picks = (req.query.pick as string).split(',') as (keyof typeof file)[];
|
||||
|
||||
return res.json(pick(file, picks));
|
||||
}
|
||||
|
||||
return res.json(file);
|
||||
}
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'DELETE'],
|
||||
user: true,
|
||||
});
|
||||
104
src/pages/api/user/file/[id]/tags.ts
Normal file
104
src/pages/api/user/file/[id]/tags.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
let { id } = req.query as { id: string | number };
|
||||
|
||||
if (!id) return res.badRequest('no id');
|
||||
|
||||
id = Number(id);
|
||||
|
||||
if (isNaN(id)) return res.badRequest('invalid id');
|
||||
|
||||
const file = await prisma.file.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
select: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!file) return res.notFound('file not found or not owned by user');
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const { tags } = req.body as {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
if (!tags) return res.badRequest('no tags');
|
||||
if (!tags.length) return res.badRequest('no tags');
|
||||
|
||||
const nFile = await prisma.file.update({
|
||||
where: { id },
|
||||
data: {
|
||||
tags: {
|
||||
disconnect: tags.map((tag) => ({ id: tag })),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(nFile.tags);
|
||||
} else if (req.method === 'PATCH') {
|
||||
const { tags } = req.body as {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
if (!tags) return res.badRequest('no tags');
|
||||
if (!tags.length) return res.badRequest('no tags');
|
||||
|
||||
const nFile = await prisma.file.update({
|
||||
where: { id },
|
||||
data: {
|
||||
tags: {
|
||||
connect: tags.map((tag) => ({ id: tag })),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(nFile.tags);
|
||||
} else if (req.method === 'POST') {
|
||||
const { tags } = req.body as {
|
||||
tags: {
|
||||
name?: string;
|
||||
color?: string;
|
||||
id?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
if (!tags) return res.badRequest('no tags');
|
||||
if (!tags.length) return res.badRequest('no tags');
|
||||
|
||||
// if the tag has an id, it means it already exists, so we just connect it
|
||||
// if it doesn't have an id, we create it and then connect it
|
||||
const nFile = await prisma.file.update({
|
||||
where: { id },
|
||||
data: {
|
||||
tags: {
|
||||
connectOrCreate: tags.map((tag) => ({
|
||||
where: { id: tag.id ?? '' },
|
||||
create: {
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(nFile.tags);
|
||||
}
|
||||
|
||||
return res.json(file.tags);
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
||||
user: true,
|
||||
});
|
||||
66
src/pages/api/user/tags.ts
Normal file
66
src/pages/api/user/tags.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import prisma from 'lib/prisma';
|
||||
import { NextApiReq, NextApiRes, UserExtended, withZipline } from 'middleware/withZipline';
|
||||
|
||||
async function handler(req: NextApiReq, res: NextApiRes, user: UserExtended) {
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
files: {
|
||||
every: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const { tags: tagIds } = req.body as {
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
if (!tagIds) return res.badRequest('no tags');
|
||||
if (!tagIds.length) return res.badRequest('no tags');
|
||||
|
||||
const nTags = await prisma.tag.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: tagIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json(nTags);
|
||||
} else if (req.method === 'POST') {
|
||||
const { tags } = req.body as {
|
||||
tags: {
|
||||
name: string;
|
||||
color: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
if (!tags) return res.badRequest('no tags');
|
||||
if (!tags.length) return res.badRequest('no tags');
|
||||
|
||||
const nTags = await prisma.tag.createMany({
|
||||
data: tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
color: tag.color,
|
||||
})),
|
||||
});
|
||||
|
||||
return res.json(nTags);
|
||||
}
|
||||
|
||||
return res.json(tags);
|
||||
}
|
||||
|
||||
export default withZipline(handler, {
|
||||
methods: ['GET', 'POST', 'DELETE'],
|
||||
user: true,
|
||||
});
|
||||
Reference in New Issue
Block a user