feat: tags & file meta editing backend

This commit is contained in:
diced
2023-12-04 23:02:05 -08:00
parent 72e36374fb
commit a2e235d2ac
16 changed files with 862 additions and 53 deletions

View File

@@ -35,6 +35,7 @@ model User {
folders Folder[] folders Folder[]
limits UserLimit[] limits UserLimit[]
invites Invite[] invites Invite[]
tags Tag[]
oauthProviders OAuthProvider[] oauthProviders OAuthProvider[]
IncompleteFile IncompleteFile[] IncompleteFile IncompleteFile[]
} }
@@ -46,8 +47,8 @@ model UserPasskey {
lastUsed DateTime? lastUsed DateTime?
name String name String
reg Json reg Json
User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) User User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
userId String userId String
@@ -137,17 +138,16 @@ model File {
folderId String? folderId String?
thumbnail Thumbnail? thumbnail Thumbnail?
} }
model Thumbnail { model Thumbnail {
id String @id @default(cuid()) id String @id @default(cuid())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
path String 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 fileId String
@@unique([fileId]) @@unique([fileId])
@@ -197,7 +197,9 @@ model Tag {
name String @unique name String @unique
color String color String
files File[] files File[]
User User? @relation(fields: [userId], references: [id])
userId String?
} }
model Url { model Url {

View File

@@ -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 { 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 { import {
ActionIcon, ActionIcon,
Checkbox,
Combobox, Combobox,
Group, Group,
Input,
InputBase, InputBase,
Modal, Modal,
Pill,
PillsInput,
SimpleGrid, SimpleGrid,
Title, Title,
Tooltip, Tooltip,
useCombobox, useCombobox,
} from '@mantine/core'; } from '@mantine/core';
import { useClipboard } from '@mantine/hooks'; import { useClipboard } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { import {
Icon, Icon,
IconBombFilled, IconBombFilled,
@@ -24,9 +36,14 @@ import {
IconRefresh, IconRefresh,
IconStar, IconStar,
IconStarFilled, IconStarFilled,
IconTags,
IconTagsOff,
IconTextRecognition,
IconTrashFilled, IconTrashFilled,
IconUpload, IconUpload,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import DashboardFileType from '../DashboardFileType'; import DashboardFileType from '../DashboardFileType';
import { import {
addToFolder, addToFolder,
@@ -35,16 +52,11 @@ import {
deleteFile, deleteFile,
downloadFile, downloadFile,
favoriteFile, favoriteFile,
mutateFiles,
removeFromFolder, removeFromFolder,
viewFile, viewFile,
} from '../actions'; } from '../actions';
import FileStat from './FileStat'; 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({ function ActionButton({
Icon, Icon,
@@ -84,7 +96,7 @@ export default function FileModal({
'/api/user/folders?noincl=true', '/api/user/folders?noincl=true',
); );
const combobox = useCombobox(); const folderCombobox = useCombobox();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const handleAdd = async (value: string) => { 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 ( return (
<Modal <Modal
opened={open} opened={open}
@@ -133,8 +205,66 @@ export default function FileModal({
<FileStat Icon={IconBombFilled} title='Deletes at' value={file.deletesAt.toLocaleString()} /> <FileStat Icon={IconBombFilled} title='Deletes at' value={file.deletesAt.toLocaleString()} />
)} )}
<FileStat Icon={IconEyeFilled} title='Views' value={file.views} /> <FileStat Icon={IconEyeFilled} title='Views' value={file.views} />
{file.originalName && (
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
)}
</SimpleGrid> </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 justify='space-between' mt='lg'>
<Group> <Group>
{!reduce && {!reduce &&
@@ -149,7 +279,7 @@ export default function FileModal({
/> />
) : ( ) : (
<Combobox <Combobox
store={combobox} store={folderCombobox}
withinPortal={false} withinPortal={false}
onOptionSubmit={(value) => handleAdd(value)} onOptionSubmit={(value) => handleAdd(value)}
> >
@@ -158,14 +288,14 @@ export default function FileModal({
rightSection={<Combobox.Chevron />} rightSection={<Combobox.Chevron />}
value={search} value={search}
onChange={(event) => { onChange={(event) => {
combobox.openDropdown(); folderCombobox.openDropdown();
combobox.updateSelectedOptionIndex(); folderCombobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value); setSearch(event.currentTarget.value);
}} }}
onClick={() => combobox.openDropdown()} onClick={() => folderCombobox.openDropdown()}
onFocus={() => combobox.openDropdown()} onFocus={() => folderCombobox.openDropdown()}
onBlur={() => { onBlur={() => {
combobox.closeDropdown(); folderCombobox.closeDropdown();
setSearch(search || ''); setSearch(search || '');
}} }}
placeholder='Add to folder...' placeholder='Add to folder...'

View File

@@ -4,6 +4,7 @@ import { Group, Title } from '@mantine/core';
import FavoriteFiles from './views/FavoriteFiles'; import FavoriteFiles from './views/FavoriteFiles';
import FileTable from './views/FileTable'; import FileTable from './views/FileTable';
import Files from './views/Files'; import Files from './views/Files';
import TagsButton from './tags/TagsButton';
export default function DashbaordFiles() { export default function DashbaordFiles() {
const view = useViewStore((state) => state.files); const view = useViewStore((state) => state.files);
@@ -13,6 +14,8 @@ export default function DashbaordFiles() {
<Group> <Group>
<Title>Files</Title> <Title>Files</Title>
<TagsButton />
<GridTableSwitcher type='files' /> <GridTableSwitcher type='files' />
</Group> </Group>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View File

@@ -8,11 +8,17 @@ import {
ActionIcon, ActionIcon,
Box, Box,
Button, Button,
Checkbox,
Collapse, Collapse,
Combobox, Combobox,
Flex,
Group, Group,
Input,
InputBase, InputBase,
Paper, Paper,
Pill,
PillsInput,
ScrollArea,
Text, Text,
TextInput, TextInput,
Tooltip, Tooltip,
@@ -29,9 +35,11 @@ import { useApiPagination } from '../useApiPagination';
import useSWR from 'swr'; import useSWR from 'swr';
import { Response } from '@/lib/api/response'; import { Response } from '@/lib/api/response';
import { Folder } from '@/lib/db/models/folder'; import { Folder } from '@/lib/db/models/folder';
import TagPill from '../tags/TagPill';
import { Tag } from '@/lib/db/models/tag';
type ReducerQuery = { type ReducerQuery = {
state: { name: string; originalName: string; type: string }; state: { name: string; originalName: string; type: string; tags: string };
action: { field: string; query: 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 }) { export default function FileTable({ id }: { id?: string }) {
const router = useRouter(); const router = useRouter();
const clipboard = useClipboard(); const clipboard = useClipboard();
@@ -104,7 +194,7 @@ export default function FileTable({ id }: { id?: string }) {
const [order, setOrder] = useState<'asc' | 'desc'>('desc'); const [order, setOrder] = useState<'asc' | 'desc'>('desc');
const [selectedFile, setSelectedFile] = useState<File | null>(null); 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( const [searchQuery, setSearchQuery] = useReducer(
(state: ReducerQuery['state'], action: ReducerQuery['action']) => { (state: ReducerQuery['state'], action: ReducerQuery['action']) => {
return { return {
@@ -112,7 +202,7 @@ export default function FileTable({ id }: { id?: string }) {
[action.field]: action.query, [action.field]: action.query,
}; };
}, },
{ name: '', originalName: '', type: '' }, { name: '', originalName: '', type: '', tags: '' },
); );
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
@@ -172,7 +262,7 @@ export default function FileTable({ id }: { id?: string }) {
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {
for (const field of ['name', 'originalName', 'type'] as const) { for (const field of ['name', 'originalName', 'type', 'tags'] as const) {
if (field !== searchField) { if (field !== searchField) {
setSearchQuery({ setSearchQuery({
field, field,
@@ -298,17 +388,26 @@ export default function FileTable({ id }: { id?: string }) {
filtering: searchField === 'name' && searchQuery.name.trim() !== '', filtering: searchField === 'name' && searchQuery.name.trim() !== '',
}, },
{ {
accessor: 'originalName', accessor: 'tags',
sortable: true, 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: ( filter: (
<SearchFilter <TagsFilter
setSearchField={setSearchField} setSearchField={setSearchField}
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} setSearchQuery={setSearchQuery}
field='originalName'
/> />
), ),
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '', filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
}, },
{ {
accessor: 'type', accessor: 'type',

View File

@@ -20,6 +20,8 @@ import { ApiUserMfaPasskeyResponse } from '@/pages/api/user/mfa/passkey';
import { ApiUserMfaTotpResponse } from '@/pages/api/user/mfa/totp'; import { ApiUserMfaTotpResponse } from '@/pages/api/user/mfa/totp';
import { ApiUserRecentResponse } from '@/pages/api/user/recent'; import { ApiUserRecentResponse } from '@/pages/api/user/recent';
import { ApiUserStatsResponse } from '@/pages/api/user/stats'; 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 { ApiUserTokenResponse } from '@/pages/api/user/token';
import { ApiUserUrlsResponse } from '@/pages/api/user/urls'; import { ApiUserUrlsResponse } from '@/pages/api/user/urls';
import { ApiUserUrlsIdResponse } from '@/pages/api/user/urls/[id]'; import { ApiUserUrlsIdResponse } from '@/pages/api/user/urls/[id]';
@@ -45,6 +47,8 @@ export type Response = {
'/api/user/files': ApiUserFilesResponse; '/api/user/files': ApiUserFilesResponse;
'/api/user/urls/[id]': ApiUserUrlsIdResponse; '/api/user/urls/[id]': ApiUserUrlsIdResponse;
'/api/user/urls': ApiUserUrlsResponse; '/api/user/urls': ApiUserUrlsResponse;
'/api/user/tags/[id]': ApiUserTagsIdResponse;
'/api/user/tags': ApiUserTagsResponse;
'/api/user': ApiUserResponse; '/api/user': ApiUserResponse;
'/api/user/stats': ApiUserStatsResponse; '/api/user/stats': ApiUserStatsResponse;
'/api/user/recent': ApiUserRecentResponse; '/api/user/recent': ApiUserRecentResponse;

View File

@@ -1,5 +1,6 @@
import { config } from '@/lib/config'; import { config } from '@/lib/config';
import { formatRootUrl } from '@/lib/url'; import { formatRootUrl } from '@/lib/url';
import { Tag, tagSelectNoFiles } from './tag';
export type File = { export type File = {
createdAt: Date; createdAt: Date;
@@ -19,6 +20,8 @@ export type File = {
path: string; path: string;
} | null; } | null;
tags?: Tag[];
url?: string; url?: string;
similarity?: number; similarity?: number;
}; };
@@ -40,6 +43,9 @@ export const fileSelect = {
path: true, path: true,
}, },
}, },
tags: {
select: tagSelectNoFiles,
},
}; };
export function cleanFile(file: File) { export function cleanFile(file: File) {

31
src/lib/db/models/tag.ts Normal file
View 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,
};

View File

@@ -9,3 +9,18 @@ export function darken(color: string, alpha: number, theme: MantineTheme): strin
alpha, 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;
}

View File

@@ -1,4 +1,5 @@
import { bytes } from '@/lib/bytes'; import { bytes } from '@/lib/bytes';
import { hashPassword } from '@/lib/crypto';
import { datasource } from '@/lib/datasource'; import { datasource } from '@/lib/datasource';
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { File, fileSelect } from '@/lib/db/models/file'; import { File, fileSelect } from '@/lib/db/models/file';
@@ -12,6 +13,11 @@ export type ApiUserFilesIdResponse = File;
type Body = { type Body = {
favorite?: boolean; favorite?: boolean;
maxViews?: number;
password?: string | null;
originalName?: string;
type?: string;
tags?: string[];
}; };
type Query = { type Query = {
@@ -30,12 +36,48 @@ export async function handler(req: NextApiReq<Body, Query>, res: NextApiRes<ApiU
if (!file) return res.notFound(); if (!file) return res.notFound();
if (req.method === 'PATCH') { 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({ const newFile = await prisma.file.update({
where: { where: {
id: req.query.id, id: req.query.id,
}, },
data: { data: {
...(req.body.favorite !== undefined && { favorite: req.body.favorite }), ...(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, select: fileSelect,
}); });

View File

@@ -1,6 +1,5 @@
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { File, cleanFiles, fileSelect } from '@/lib/db/models/file'; import { File, cleanFiles, fileSelect } from '@/lib/db/models/file';
import { log } from '@/lib/logger';
import { combine } from '@/lib/middleware/combine'; import { combine } from '@/lib/middleware/combine';
import { method } from '@/lib/middleware/method'; import { method } from '@/lib/middleware/method';
import { ziplineAuth } from '@/lib/middleware/ziplineAuth'; import { ziplineAuth } from '@/lib/middleware/ziplineAuth';
@@ -12,9 +11,8 @@ import { z } from 'zod';
export type ApiUserFilesResponse = { export type ApiUserFilesResponse = {
page: File[]; page: File[];
search?: { search?: {
field: 'name' | 'originalName' | 'type'; field: 'name' | 'originalName' | 'type' | 'tags';
query: string; query: string | string[];
threshold: number;
}; };
total?: number; total?: number;
pages?: number; pages?: number;
@@ -27,13 +25,12 @@ type Query = {
favorite?: 'true' | 'false'; favorite?: 'true' | 'false';
sortBy: keyof Prisma.FileOrderByWithAggregationInput; sortBy: keyof Prisma.FileOrderByWithAggregationInput;
order: 'asc' | 'desc'; order: 'asc' | 'desc';
searchField?: 'name' | 'originalName' | 'type'; searchField?: 'name' | 'originalName' | 'type' | 'tags';
searchQuery?: string; searchQuery?: string;
searchThreshold?: string;
id?: 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 const validateSortBy = z
.enum([ .enum([
@@ -51,9 +48,6 @@ const validateSortBy = z
.default('createdAt'); .default('createdAt');
const validateOrder = z.enum(['asc', 'desc']).default('desc'); 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>) { export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUserFilesResponse>) {
const user = await prisma.user.findUnique({ 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'); const searchField = validateSearchField.safeParse(req.query.searchField || 'name');
if (!searchField.success) return res.badRequest('Invalid searchField value'); 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) { if (searchQuery) {
const extension: { extname: string }[] = let tagFiles: string[] = [];
await prisma.$queryRaw`SELECT extname FROM pg_extension WHERE extname = 'pg_trgm';`;
if (extension.length === 0) { if (searchField.data === 'tags') {
logger.debug('pg_trgm extension not found, installing...'); const parsedTags = searchQuery
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS pg_trgm;`; .split(',')
logger.debug('pg_trgm extension installed'); .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({ const similarityResult = await prisma.file.findMany({
@@ -120,20 +137,38 @@ export async function handler(req: NextApiReq<any, Query>, res: NextApiRes<ApiUs
filter !== 'all' && { filter !== 'all' && {
favorite: true, favorite: true,
}), }),
[searchField.data]: { ...(searchField.data === 'tags'
contains: searchQuery, ? {
mode: 'insensitive', id: {
}, in: tagFiles,
},
}
: {
[searchField.data]: {
contains: searchQuery,
mode: 'insensitive',
},
}),
}, },
select: fileSelect, select: fileSelect,
orderBy: {
[sortBy.data]: order.data,
},
skip: (Number(page) - 1) * perpage,
take: perpage,
}); });
return res.ok({ return res.ok({
page: cleanFiles(similarityResult), page: cleanFiles(similarityResult),
search: { search: {
field: searchField.data, field: searchField.data,
query: searchQuery, query:
threshold: searchThreshold.data, searchField.data === 'tags'
? searchQuery
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag)
: searchQuery,
}, },
}); });
} }

View 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);

View 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);