mirror of
https://github.com/diced/zipline.git
synced 2025-12-12 15:50:11 -08:00
feat: tags & file meta editing backend
This commit is contained in:
@@ -35,6 +35,7 @@ model User {
|
||||
folders Folder[]
|
||||
limits UserLimit[]
|
||||
invites Invite[]
|
||||
tags Tag[]
|
||||
oauthProviders OAuthProvider[]
|
||||
IncompleteFile IncompleteFile[]
|
||||
}
|
||||
@@ -46,8 +47,8 @@ model UserPasskey {
|
||||
|
||||
lastUsed DateTime?
|
||||
|
||||
name String
|
||||
reg Json
|
||||
name String
|
||||
reg Json
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
userId String
|
||||
@@ -137,17 +138,16 @@ model File {
|
||||
folderId String?
|
||||
|
||||
thumbnail Thumbnail?
|
||||
|
||||
}
|
||||
|
||||
model Thumbnail {
|
||||
model Thumbnail {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
path String
|
||||
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
file File @relation(fields: [fileId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
fileId String
|
||||
|
||||
@@unique([fileId])
|
||||
@@ -197,7 +197,9 @@ model Tag {
|
||||
name String @unique
|
||||
color String
|
||||
|
||||
files File[]
|
||||
files File[]
|
||||
User User? @relation(fields: [userId], references: [id])
|
||||
userId String?
|
||||
}
|
||||
|
||||
model Url {
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import TagPill from '@/components/pages/files/tags/TagPill';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { File } from '@/lib/db/models/file';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import {
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Modal,
|
||||
Pill,
|
||||
PillsInput,
|
||||
SimpleGrid,
|
||||
Title,
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
} from '@mantine/core';
|
||||
import { useClipboard } from '@mantine/hooks';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import {
|
||||
Icon,
|
||||
IconBombFilled,
|
||||
@@ -24,9 +36,14 @@ import {
|
||||
IconRefresh,
|
||||
IconStar,
|
||||
IconStarFilled,
|
||||
IconTags,
|
||||
IconTagsOff,
|
||||
IconTextRecognition,
|
||||
IconTrashFilled,
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import DashboardFileType from '../DashboardFileType';
|
||||
import {
|
||||
addToFolder,
|
||||
@@ -35,16 +52,11 @@ import {
|
||||
deleteFile,
|
||||
downloadFile,
|
||||
favoriteFile,
|
||||
mutateFiles,
|
||||
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';
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { useSettingsStore } from '@/lib/store/settings';
|
||||
import { useState } from 'react';
|
||||
|
||||
function ActionButton({
|
||||
Icon,
|
||||
@@ -84,7 +96,7 @@ export default function FileModal({
|
||||
'/api/user/folders?noincl=true',
|
||||
);
|
||||
|
||||
const combobox = useCombobox();
|
||||
const folderCombobox = useCombobox();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const handleAdd = async (value: string) => {
|
||||
@@ -95,6 +107,66 @@ export default function FileModal({
|
||||
}
|
||||
};
|
||||
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
|
||||
|
||||
const tagsCombobox = useCombobox();
|
||||
const [value, setValue] = useState(file?.tags?.map((x) => x.id) ?? []);
|
||||
const handleValueSelect = (val: string) => {
|
||||
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
|
||||
};
|
||||
|
||||
const handleValueRemove = (val: string) => {
|
||||
setValue((current) => current.filter((v) => v !== val));
|
||||
};
|
||||
|
||||
const handleTagsUpdate = async () => {
|
||||
if (value.length === file?.tags?.length && value.every((v) => file?.tags?.map((x) => x.id).includes(v))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Response['/api/user/files/[id]']>(
|
||||
`/api/user/files/${file!.id}`,
|
||||
'PATCH',
|
||||
{
|
||||
tags: value,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Failed to save tags',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
icon: <IconTagsOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Saved tags',
|
||||
message: `Saved ${data!.tags!.length} tags for file ${data!.name}`,
|
||||
color: 'green',
|
||||
icon: <IconTags size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutateFiles();
|
||||
};
|
||||
|
||||
const triggerSave = async () => {
|
||||
tagsCombobox.closeDropdown();
|
||||
|
||||
handleTagsUpdate();
|
||||
};
|
||||
|
||||
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
setValue(file.tags?.map((x) => x.id) ?? []);
|
||||
} else {
|
||||
setValue([]);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={open}
|
||||
@@ -133,8 +205,66 @@ export default function FileModal({
|
||||
<FileStat Icon={IconBombFilled} title='Deletes at' value={file.deletesAt.toLocaleString()} />
|
||||
)}
|
||||
<FileStat Icon={IconEyeFilled} title='Views' value={file.views} />
|
||||
{file.originalName && (
|
||||
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
<>
|
||||
<Title order={4} mt='lg' mb='xs'>
|
||||
Tags
|
||||
</Title>
|
||||
<Combobox store={tagsCombobox} onOptionSubmit={handleValueSelect} withinPortal={false}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput
|
||||
onBlur={() => triggerSave()}
|
||||
pointer
|
||||
onClick={() => tagsCombobox.toggleDropdown()}
|
||||
>
|
||||
<Pill.Group>
|
||||
{values.length > 0 ? (
|
||||
values
|
||||
) : (
|
||||
<Input.Placeholder>Pick one or more tags</Input.Placeholder>
|
||||
)}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type='hidden'
|
||||
onBlur={() => tagsCombobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
handleValueRemove(value[value.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{tags?.map((tag) => (
|
||||
<Combobox.Option value={tag.id} key={tag.id} active={value.includes(tag.id)}>
|
||||
<Group gap='sm'>
|
||||
<Checkbox
|
||||
checked={value.includes(tag.id)}
|
||||
onChange={() => {}}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
<TagPill tag={tag} />
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
</>
|
||||
|
||||
<Group justify='space-between' mt='lg'>
|
||||
<Group>
|
||||
{!reduce &&
|
||||
@@ -149,7 +279,7 @@ export default function FileModal({
|
||||
/>
|
||||
) : (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
store={folderCombobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(value) => handleAdd(value)}
|
||||
>
|
||||
@@ -158,14 +288,14 @@ export default function FileModal({
|
||||
rightSection={<Combobox.Chevron />}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
folderCombobox.openDropdown();
|
||||
folderCombobox.updateSelectedOptionIndex();
|
||||
setSearch(event.currentTarget.value);
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onClick={() => folderCombobox.openDropdown()}
|
||||
onFocus={() => folderCombobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
folderCombobox.closeDropdown();
|
||||
setSearch(search || '');
|
||||
}}
|
||||
placeholder='Add to folder...'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Group, Title } from '@mantine/core';
|
||||
import FavoriteFiles from './views/FavoriteFiles';
|
||||
import FileTable from './views/FileTable';
|
||||
import Files from './views/Files';
|
||||
import TagsButton from './tags/TagsButton';
|
||||
|
||||
export default function DashbaordFiles() {
|
||||
const view = useViewStore((state) => state.files);
|
||||
@@ -13,6 +14,8 @@ export default function DashbaordFiles() {
|
||||
<Group>
|
||||
<Title>Files</Title>
|
||||
|
||||
<TagsButton />
|
||||
|
||||
<GridTableSwitcher type='files' />
|
||||
</Group>
|
||||
|
||||
|
||||
96
src/components/pages/files/tags/CreateTagModal.tsx
Normal file
96
src/components/pages/files/tags/CreateTagModal.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { colorHash } from '@/lib/theme/color';
|
||||
import { ActionIcon, Button, ColorInput, Modal, Stack, Text, TextInput, Title, Tooltip } from '@mantine/core';
|
||||
import { hasLength, useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconTag, IconTagOff, IconTextRecognition } from '@tabler/icons-react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function CreateTagModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const form = useForm<{
|
||||
name: string;
|
||||
color: string;
|
||||
}>({
|
||||
initialValues: {
|
||||
name: '',
|
||||
color: '',
|
||||
},
|
||||
validate: {
|
||||
name: hasLength({ min: 1 }, 'Name is required'),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
const color = values.color.trim() === '' ? colorHash(values.name) : values.color.trim();
|
||||
|
||||
if (!color.startsWith('#')) {
|
||||
form.setFieldError('color', 'Color must start with #');
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/user/tags'], Tag>>(
|
||||
'/api/user/tags',
|
||||
'POST',
|
||||
{
|
||||
name: values.name,
|
||||
color,
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Failed to create tag',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
icon: <IconTagOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Created tag',
|
||||
message: `Created tag ${data!.name}`,
|
||||
color: data!.color,
|
||||
icon: <IconTag size='1rem' />,
|
||||
});
|
||||
|
||||
onClose();
|
||||
form.reset();
|
||||
mutate('/api/user/tags');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={open} onClose={onClose} title={<Title>Create new tag</Title>} zIndex={3000}>
|
||||
<Text size='sm' c='dimmed'>
|
||||
Create a new tag that can be applied to files
|
||||
</Text>
|
||||
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap='sm'>
|
||||
<TextInput label='Name' placeholder='Enter a name...' {...form.getInputProps('name')} />
|
||||
|
||||
<ColorInput
|
||||
label='Color'
|
||||
rightSection={
|
||||
<Tooltip label='Choose a color based on the name' zIndex={3001}>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
color='white'
|
||||
onClick={() => form.setFieldValue('color', colorHash(form.values.name))}
|
||||
>
|
||||
<IconTextRecognition size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
popoverProps={{ zIndex: 3001 }}
|
||||
{...form.getInputProps('color')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm'>
|
||||
Create tag
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
109
src/components/pages/files/tags/EditTagModal.tsx
Normal file
109
src/components/pages/files/tags/EditTagModal.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { colorHash } from '@/lib/theme/color';
|
||||
import { ActionIcon, Button, ColorInput, Modal, Stack, TextInput, Title, Tooltip } from '@mantine/core';
|
||||
import { hasLength, useForm } from '@mantine/form';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import { IconTag, IconTagOff, IconTextRecognition } from '@tabler/icons-react';
|
||||
import { useEffect } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
export default function EditTagModal({
|
||||
open,
|
||||
onClose,
|
||||
tag,
|
||||
}: {
|
||||
tag: Tag | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const form = useForm<{
|
||||
name: string;
|
||||
color: string;
|
||||
}>({
|
||||
initialValues: {
|
||||
name: tag?.name || '',
|
||||
color: tag?.color || '',
|
||||
},
|
||||
validate: {
|
||||
name: hasLength({ min: 1 }, 'Name is required'),
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: typeof form.values) => {
|
||||
const color = values.color.trim() === '' ? colorHash(values.name) : values.color.trim();
|
||||
|
||||
if (!color.startsWith('#')) {
|
||||
form.setFieldError('color', 'Color must start with #');
|
||||
}
|
||||
|
||||
const { data, error } = await fetchApi<Extract<Response['/api/user/tags'], Tag>>(
|
||||
`/api/user/tags/${tag!.id}`,
|
||||
'PATCH',
|
||||
{
|
||||
...(values.name !== tag!.name && { name: values.name }),
|
||||
...(color !== tag!.color && { color }),
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Failed to edit tag',
|
||||
message: error.message,
|
||||
color: 'red',
|
||||
icon: <IconTagOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Edited tag',
|
||||
message: `Edited tag ${data!.name}`,
|
||||
color: data!.color,
|
||||
icon: <IconTag size='1rem' />,
|
||||
});
|
||||
|
||||
onClose();
|
||||
form.reset();
|
||||
mutate('/api/user/tags');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (tag) {
|
||||
form.setFieldValue('name', tag.name);
|
||||
form.setFieldValue('color', tag.color);
|
||||
form.resetDirty();
|
||||
}
|
||||
}, [tag]);
|
||||
|
||||
return (
|
||||
<Modal opened={open} onClose={onClose} title={<Title>Edit tag</Title>} zIndex={3000}>
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap='sm'>
|
||||
<TextInput label='Name' placeholder='Enter a name...' {...form.getInputProps('name')} />
|
||||
|
||||
<ColorInput
|
||||
label='Color'
|
||||
rightSection={
|
||||
<Tooltip label='Choose a color based on the name' zIndex={3001}>
|
||||
<ActionIcon
|
||||
variant='transparent'
|
||||
color='white'
|
||||
onClick={() => form.setFieldValue('color', colorHash(form.values.name))}
|
||||
>
|
||||
<IconTextRecognition size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
}
|
||||
popoverProps={{ zIndex: 3001 }}
|
||||
{...form.getInputProps('color')}
|
||||
/>
|
||||
|
||||
<Button type='submit' variant='outline' radius='sm' disabled={!form.isDirty}>
|
||||
Edit tag
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
19
src/components/pages/files/tags/TagPill.tsx
Normal file
19
src/components/pages/files/tags/TagPill.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { Pill, isLightColor } from '@mantine/core';
|
||||
|
||||
export default function TagPill({
|
||||
tag,
|
||||
...other
|
||||
}: {
|
||||
tag: Tag | null;
|
||||
withRemoveButton?: boolean;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
if (!tag) return null;
|
||||
|
||||
return (
|
||||
<Pill bg={tag.color || undefined} c={isLightColor(tag.color) ? 'black' : 'white'} {...other}>
|
||||
{tag.name}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
105
src/components/pages/files/tags/TagsButton.tsx
Normal file
105
src/components/pages/files/tags/TagsButton.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
import { ActionIcon, Group, Modal, Stack, Text, Title, Tooltip } from '@mantine/core';
|
||||
import { IconPencil, IconPlus, IconTagOff, IconTags, IconTrashFilled } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import TagPill from './TagPill';
|
||||
import { fetchApi } from '@/lib/fetchApi';
|
||||
import { showNotification } from '@mantine/notifications';
|
||||
import CreateTagModal from './CreateTagModal';
|
||||
import EditTagModal from './EditTagModal';
|
||||
|
||||
export default function TagsButton() {
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(router.query.tags !== undefined);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
|
||||
|
||||
const { data: tags, mutate } = useSWR<Extract<Tag[], Response['/api/user/tags']>>('/api/user/tags');
|
||||
|
||||
const handleDelete = async (tag: Tag) => {
|
||||
const { error } = await fetchApi<Response['/api/user/tags/[id]']>(`/api/user/tags/${tag.id}`, 'DELETE');
|
||||
|
||||
if (error) {
|
||||
showNotification({
|
||||
title: 'Error',
|
||||
message: `Failed to delete tag: ${error.message}`,
|
||||
color: 'red',
|
||||
icon: <IconTagOff size='1rem' />,
|
||||
});
|
||||
} else {
|
||||
showNotification({
|
||||
title: 'Deleted tag',
|
||||
message: `Deleted tag ${tag.name}`,
|
||||
color: 'green',
|
||||
icon: <IconTrashFilled size='1rem' />,
|
||||
});
|
||||
}
|
||||
|
||||
mutate();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
router.push({ query: { ...router.query, tags: 'true' } }, undefined, { shallow: true });
|
||||
} else {
|
||||
delete router.query.tags;
|
||||
router.push({ query: router.query }, undefined, { shallow: true });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateTagModal open={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||
<EditTagModal open={!!selectedTag} onClose={() => setSelectedTag(null)} tag={selectedTag} />
|
||||
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title={
|
||||
<Group>
|
||||
<Title>Tags</Title>
|
||||
<ActionIcon variant='outline' onClick={() => setCreateModalOpen(true)}>
|
||||
<IconPlus size='1rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap='xs'>
|
||||
{tags
|
||||
?.sort((a, b) => b.files!.length - a.files!.length)
|
||||
.map((tag) => (
|
||||
<Group justify='space-between' key={tag.id}>
|
||||
<Group>
|
||||
<TagPill tag={tag} />
|
||||
|
||||
<Text size='sm' c='dimmed'>
|
||||
{tag.files!.length} files
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<ActionIcon variant='outline' onClick={() => setSelectedTag(tag)}>
|
||||
<IconPencil size='1rem' />
|
||||
</ActionIcon>
|
||||
|
||||
<ActionIcon variant='outline' color='red' onClick={() => handleDelete(tag)}>
|
||||
<IconTrashFilled size='1rem' />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Tooltip label='View tags'>
|
||||
<ActionIcon variant='outline' onClick={() => setOpen(true)}>
|
||||
<IconTags size='1rem' />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,17 @@ import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Combobox,
|
||||
Flex,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Paper,
|
||||
Pill,
|
||||
PillsInput,
|
||||
ScrollArea,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
@@ -29,9 +35,11 @@ import { useApiPagination } from '../useApiPagination';
|
||||
import useSWR from 'swr';
|
||||
import { Response } from '@/lib/api/response';
|
||||
import { Folder } from '@/lib/db/models/folder';
|
||||
import TagPill from '../tags/TagPill';
|
||||
import { Tag } from '@/lib/db/models/tag';
|
||||
|
||||
type ReducerQuery = {
|
||||
state: { name: string; originalName: string; type: string };
|
||||
state: { name: string; originalName: string; type: string; tags: string };
|
||||
action: { field: string; query: string };
|
||||
};
|
||||
|
||||
@@ -86,6 +94,88 @@ function SearchFilter({
|
||||
);
|
||||
}
|
||||
|
||||
function TagsFilter({
|
||||
setSearchField,
|
||||
setSearchQuery,
|
||||
searchQuery,
|
||||
}: {
|
||||
searchQuery: {
|
||||
name: string;
|
||||
originalName: string;
|
||||
type: string;
|
||||
tags: string;
|
||||
};
|
||||
setSearchField: (...args: any) => void;
|
||||
setSearchQuery: (...args: any) => void;
|
||||
}) {
|
||||
const combobox = useCombobox();
|
||||
const { data: tags } = useSWR<Extract<Response['/api/user/tags'], Tag[]>>('/api/user/tags');
|
||||
|
||||
const [value, setValue] = useState(searchQuery.tags.split(','));
|
||||
const handleValueSelect = (val: string) => {
|
||||
setValue((current) => (current.includes(val) ? current.filter((v) => v !== val) : [...current, val]));
|
||||
};
|
||||
|
||||
const handleValueRemove = (val: string) => {
|
||||
setValue((current) => current.filter((v) => v !== val));
|
||||
};
|
||||
|
||||
const values = value.map((tag) => <TagPill key={tag} tag={tags?.find((t) => t.id === tag) || null} />);
|
||||
|
||||
const triggerSave = () => {
|
||||
setSearchField('tags');
|
||||
|
||||
setSearchQuery({
|
||||
field: 'tags',
|
||||
query: value.join(','),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox store={combobox} onOptionSubmit={handleValueSelect} withinPortal={false}>
|
||||
<Combobox.DropdownTarget>
|
||||
<PillsInput onBlur={() => triggerSave()} pointer onClick={() => combobox.toggleDropdown()} w={200}>
|
||||
<Pill.Group>
|
||||
{values.length > 0 ? values : <Input.Placeholder>Pick one or more tags</Input.Placeholder>}
|
||||
|
||||
<Combobox.EventsTarget>
|
||||
<PillsInput.Field
|
||||
type='hidden'
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault();
|
||||
handleValueRemove(value[value.length - 1]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Combobox.EventsTarget>
|
||||
</Pill.Group>
|
||||
</PillsInput>
|
||||
</Combobox.DropdownTarget>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{tags?.map((tag) => (
|
||||
<Combobox.Option value={tag.id} key={tag.id} active={value.includes(tag.id)}>
|
||||
<Group gap='sm'>
|
||||
<Checkbox
|
||||
checked={value.includes(tag.id)}
|
||||
onChange={() => {}}
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
<TagPill tag={tag} />
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileTable({ id }: { id?: string }) {
|
||||
const router = useRouter();
|
||||
const clipboard = useClipboard();
|
||||
@@ -104,7 +194,7 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
const [order, setOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type'>('name');
|
||||
const [searchField, setSearchField] = useState<'name' | 'originalName' | 'type' | 'tags'>('name');
|
||||
const [searchQuery, setSearchQuery] = useReducer(
|
||||
(state: ReducerQuery['state'], action: ReducerQuery['action']) => {
|
||||
return {
|
||||
@@ -112,7 +202,7 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
[action.field]: action.query,
|
||||
};
|
||||
},
|
||||
{ name: '', originalName: '', type: '' },
|
||||
{ name: '', originalName: '', type: '', tags: '' },
|
||||
);
|
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
@@ -172,7 +262,7 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const field of ['name', 'originalName', 'type'] as const) {
|
||||
for (const field of ['name', 'originalName', 'type', 'tags'] as const) {
|
||||
if (field !== searchField) {
|
||||
setSearchQuery({
|
||||
field,
|
||||
@@ -298,17 +388,26 @@ export default function FileTable({ id }: { id?: string }) {
|
||||
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'originalName',
|
||||
sortable: true,
|
||||
accessor: 'tags',
|
||||
sortable: false,
|
||||
width: 200,
|
||||
render: (file) => (
|
||||
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
|
||||
<Flex gap='sm'>
|
||||
{file.tags!.map((tag) => (
|
||||
<TagPill tag={tag} key={tag.id} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollArea>
|
||||
),
|
||||
filter: (
|
||||
<SearchFilter
|
||||
<TagsFilter
|
||||
setSearchField={setSearchField}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
field='originalName'
|
||||
/>
|
||||
),
|
||||
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '',
|
||||
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
|
||||
},
|
||||
{
|
||||
accessor: 'type',
|
||||
|
||||
@@ -20,6 +20,8 @@ import { ApiUserMfaPasskeyResponse } from '@/pages/api/user/mfa/passkey';
|
||||
import { ApiUserMfaTotpResponse } from '@/pages/api/user/mfa/totp';
|
||||
import { ApiUserRecentResponse } from '@/pages/api/user/recent';
|
||||
import { ApiUserStatsResponse } from '@/pages/api/user/stats';
|
||||
import { ApiUserTagsResponse } from '@/pages/api/user/tags';
|
||||
import { ApiUserTagsIdResponse } from '@/pages/api/user/tags/[id]';
|
||||
import { ApiUserTokenResponse } from '@/pages/api/user/token';
|
||||
import { ApiUserUrlsResponse } from '@/pages/api/user/urls';
|
||||
import { ApiUserUrlsIdResponse } from '@/pages/api/user/urls/[id]';
|
||||
@@ -45,6 +47,8 @@ export type Response = {
|
||||
'/api/user/files': ApiUserFilesResponse;
|
||||
'/api/user/urls/[id]': ApiUserUrlsIdResponse;
|
||||
'/api/user/urls': ApiUserUrlsResponse;
|
||||
'/api/user/tags/[id]': ApiUserTagsIdResponse;
|
||||
'/api/user/tags': ApiUserTagsResponse;
|
||||
'/api/user': ApiUserResponse;
|
||||
'/api/user/stats': ApiUserStatsResponse;
|
||||
'/api/user/recent': ApiUserRecentResponse;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { config } from '@/lib/config';
|
||||
import { formatRootUrl } from '@/lib/url';
|
||||
import { Tag, tagSelectNoFiles } from './tag';
|
||||
|
||||
export type File = {
|
||||
createdAt: Date;
|
||||
@@ -19,6 +20,8 @@ export type File = {
|
||||
path: string;
|
||||
} | null;
|
||||
|
||||
tags?: Tag[];
|
||||
|
||||
url?: string;
|
||||
similarity?: number;
|
||||
};
|
||||
@@ -40,6 +43,9 @@ export const fileSelect = {
|
||||
path: true,
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
select: tagSelectNoFiles,
|
||||
},
|
||||
};
|
||||
|
||||
export function cleanFile(file: File) {
|
||||
|
||||
31
src/lib/db/models/tag.ts
Normal file
31
src/lib/db/models/tag.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type Tag = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
name: string;
|
||||
color: string;
|
||||
files?: {
|
||||
id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const tagSelect = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
color: true,
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const tagSelectNoFiles = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
color: true,
|
||||
};
|
||||
@@ -9,3 +9,18 @@ export function darken(color: string, alpha: number, theme: MantineTheme): strin
|
||||
alpha,
|
||||
);
|
||||
}
|
||||
|
||||
export function colorHash(str: string) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
let color = '#';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += ('00' + value.toString(16)).substr(-2);
|
||||
}
|
||||
|
||||
return color;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { bytes } from '@/lib/bytes';
|
||||
import { hashPassword } from '@/lib/crypto';
|
||||
import { datasource } from '@/lib/datasource';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, fileSelect } from '@/lib/db/models/file';
|
||||
@@ -12,6 +13,11 @@ export type ApiUserFilesIdResponse = File;
|
||||
|
||||
type Body = {
|
||||
favorite?: boolean;
|
||||
maxViews?: number;
|
||||
password?: string | null;
|
||||
originalName?: string;
|
||||
type?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
type Query = {
|
||||
@@ -30,12 +36,48 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
|
||||
if (!file) return res.notFound();
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
if (req.body.maxViews !== undefined && req.body.maxViews < 0)
|
||||
return res.badRequest('maxViews must be >= 0');
|
||||
|
||||
let password: string | null | undefined = undefined;
|
||||
if (req.body.password !== undefined) {
|
||||
if (req.body.password === null) {
|
||||
password = null;
|
||||
} else if (typeof req.body.password === 'string') {
|
||||
password = await hashPassword(req.body.password);
|
||||
} else {
|
||||
return res.badRequest('password must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
if (req.body.tags !== undefined) {
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
id: {
|
||||
in: req.body.tags,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (tags.length !== req.body.tags.length) return res.badRequest('invalid tag somewhere');
|
||||
}
|
||||
|
||||
const newFile = await prisma.file.update({
|
||||
where: {
|
||||
id: req.query.id,
|
||||
},
|
||||
data: {
|
||||
...(req.body.favorite !== undefined && { favorite: req.body.favorite }),
|
||||
...(req.body.maxViews !== undefined && { maxViews: req.body.maxViews }),
|
||||
...(req.body.originalName !== undefined && { originalName: req.body.originalName }),
|
||||
...(req.body.type !== undefined && { type: req.body.type }),
|
||||
...(password !== undefined && { password }),
|
||||
...(req.body.tags !== undefined && {
|
||||
tags: {
|
||||
set: req.body.tags.map((tag) => ({ id: tag })),
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: fileSelect,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
|
||||
import { log } from '@/lib/logger';
|
||||
import { combine } from '@/lib/middleware/combine';
|
||||
import { method } from '@/lib/middleware/method';
|
||||
import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
|
||||
@@ -12,9 +11,8 @@ import { z } from 'zod';
|
||||
export type ApiUserFilesResponse = {
|
||||
page: File[];
|
||||
search?: {
|
||||
field: 'name' | 'originalName' | 'type';
|
||||
query: string;
|
||||
threshold: number;
|
||||
field: 'name' | 'originalName' | 'type' | 'tags';
|
||||
query: string | string[];
|
||||
};
|
||||
total?: number;
|
||||
pages?: number;
|
||||
@@ -27,13 +25,12 @@ type Query = {
|
||||
favorite?: 'true' | 'false';
|
||||
sortBy: keyof Prisma.FileOrderByWithAggregationInput;
|
||||
order: 'asc' | 'desc';
|
||||
searchField?: 'name' | 'originalName' | 'type';
|
||||
searchField?: 'name' | 'originalName' | 'type' | 'tags';
|
||||
searchQuery?: string;
|
||||
searchThreshold?: string;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
const validateSearchField = z.enum(['name', 'originalName', 'type']).default('name');
|
||||
const validateSearchField = z.enum(['name', 'originalName', 'type', 'tags']).default('name');
|
||||
|
||||
const validateSortBy = z
|
||||
.enum([
|
||||
@@ -51,9 +48,6 @@ const validateSortBy = z
|
||||
.default('createdAt');
|
||||
|
||||
const validateOrder = z.enum(['asc', 'desc']).default('desc');
|
||||
const validateThreshold = z.number().default(0.1);
|
||||
|
||||
const logger = log('api').c('user').c('files');
|
||||
|
||||
export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserFilesResponse>) {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -85,16 +79,39 @@ export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUs
|
||||
const searchField = validateSearchField.safeParse(req.query.searchField || 'name');
|
||||
if (!searchField.success) return res.badRequest('Invalid searchField value');
|
||||
|
||||
const searchThreshold = validateThreshold.safeParse(Number(req.query.searchThreshold) || 0.1);
|
||||
if (!searchThreshold.success) return res.badRequest('Invalid searchThreshold value');
|
||||
|
||||
if (searchQuery) {
|
||||
const extension: { extname: string }[] =
|
||||
await prisma.$queryRaw`SELECT extname FROM pg_extension WHERE extname = 'pg_trgm';`;
|
||||
if (extension.length === 0) {
|
||||
logger.debug('pg_trgm extension not found, installing...');
|
||||
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm;`;
|
||||
logger.debug('pg_trgm extension installed');
|
||||
let tagFiles: string[] = [];
|
||||
|
||||
if (searchField.data === 'tags') {
|
||||
const parsedTags = searchQuery
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag);
|
||||
|
||||
const foundTags = await prisma.tag.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: {
|
||||
in: searchQuery
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
files: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (foundTags.length !== parsedTags.length) return res.badRequest('invalid tag somewhere');
|
||||
|
||||
tagFiles = foundTags
|
||||
.map((tag) => tag.files.map((file) => file.id))
|
||||
.reduce((a, b) => a.filter((c) => b.includes(c)));
|
||||
}
|
||||
|
||||
const similarityResult = await prisma.file.findMany({
|
||||
@@ -120,20 +137,38 @@ export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUs
|
||||
filter !== 'all' && {
|
||||
favorite: true,
|
||||
}),
|
||||
[searchField.data]: {
|
||||
contains: searchQuery,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
...(searchField.data === 'tags'
|
||||
? {
|
||||
id: {
|
||||
in: tagFiles,
|
||||
},
|
||||
}
|
||||
: {
|
||||
[searchField.data]: {
|
||||
contains: searchQuery,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: fileSelect,
|
||||
orderBy: {
|
||||
[sortBy.data]: order.data,
|
||||
},
|
||||
skip: (Number(page) - 1) * perpage,
|
||||
take: perpage,
|
||||
});
|
||||
|
||||
return res.ok({
|
||||
page: cleanFiles(similarityResult),
|
||||
search: {
|
||||
field: searchField.data,
|
||||
query: searchQuery,
|
||||
threshold: searchThreshold.data,
|
||||
query:
|
||||
searchField.data === 'tags'
|
||||
? searchQuery
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag)
|
||||
: searchQuery,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
72
src/pages/api/user/tags/[id].ts
Normal file
72
src/pages/api/user/tags/[id].ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Tag, tagSelect } from '@/lib/db/models/tag';
|
||||
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 ApiUserTagsIdResponse = Tag;
|
||||
|
||||
type Body = {
|
||||
name?: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
type Query = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiUserTagsIdResponse>) {
|
||||
const { id } = req.query;
|
||||
|
||||
const tag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
id,
|
||||
},
|
||||
select: tagSelect,
|
||||
});
|
||||
if (!tag) return res.notFound();
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
const tag = await prisma.tag.delete({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: tagSelect,
|
||||
});
|
||||
|
||||
return res.ok(tag);
|
||||
}
|
||||
|
||||
if (req.method === 'PATCH') {
|
||||
const { name, color } = req.body;
|
||||
|
||||
if (name) {
|
||||
const existing = await prisma.tag.findFirst({
|
||||
where: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) return res.badRequest('tag name already exists');
|
||||
}
|
||||
|
||||
const tag = await prisma.tag.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(color && { color }),
|
||||
},
|
||||
select: tagSelect,
|
||||
});
|
||||
|
||||
return res.ok(tag);
|
||||
}
|
||||
|
||||
return res.ok(tag);
|
||||
}
|
||||
|
||||
export default combine([method(['GET', 'DELETE', 'PATCH']), ziplineAuth()], handler);
|
||||
41
src/pages/api/user/tags/index.ts
Normal file
41
src/pages/api/user/tags/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { Tag, tagSelect } from '@/lib/db/models/tag';
|
||||
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 ApiUserTagsResponse = Tag | Tag[];
|
||||
|
||||
type Body = {
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export async function handler(req: NextApiReq<Body>, res: NextApiRes<ApiUserTagsResponse>) {
|
||||
if (req.method === 'POST') {
|
||||
const { name, color } = req.body;
|
||||
|
||||
const tag = await prisma.tag.create({
|
||||
data: {
|
||||
name,
|
||||
color,
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: tagSelect,
|
||||
});
|
||||
|
||||
return res.ok(tag);
|
||||
}
|
||||
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: {
|
||||
userId: req.user.id,
|
||||
},
|
||||
select: tagSelect,
|
||||
});
|
||||
|
||||
return res.ok(tags);
|
||||
}
|
||||
|
||||
export default combine([method(['GET', 'POST']), ziplineAuth()], handler);
|
||||
Reference in New Issue
Block a user