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[]
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,7 +138,6 @@ model File {
folderId String?
thumbnail Thumbnail?
}
model Thumbnail {
@@ -147,7 +147,7 @@ model Thumbnail {
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 {

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 { 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...'

View File

@@ -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>

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,
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',

View File

@@ -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;

View File

@@ -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
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,
);
}
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 { 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,
});

View File

@@ -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,
},
});
}

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