diff --git a/src/components/file/DashboardFile/FileViewer.tsx b/src/components/file/DashboardFile/FileViewer.tsx index c4535dec..bdabb700 100644 --- a/src/components/file/DashboardFile/FileViewer.tsx +++ b/src/components/file/DashboardFile/FileViewer.tsx @@ -1,20 +1,36 @@ +import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions'; +import TagPill from '@/components/pages/files/tags/TagPill'; +import { Response } from '@/lib/api/response'; import { bytes } from '@/lib/bytes'; +import { useFolders } from '@/lib/client/hooks/useFolders'; import { useFileNavStore } from '@/lib/client/store/fileNav'; import { useSettingsStore } from '@/lib/client/store/settings'; import { File } from '@/lib/db/models/file'; +import { Tag } from '@/lib/db/models/tag'; +import { fetchApi } from '@/lib/fetchApi'; +import { buildFolderHierarchy } from '@/lib/folderHierarchy'; import { ActionIcon, ActionIconProps, Box, + Button, + Checkbox, + Combobox, Drawer, Group, + Input, + InputBase, Paper, + Pill, + PillsInput, Stack, Text, Title, Tooltip, + useCombobox, } from '@mantine/core'; import { useClipboard } from '@mantine/hooks'; +import { showNotification } from '@mantine/notifications'; import { Icon, IconBombFilled, @@ -27,22 +43,36 @@ import { IconExternalLink, IconEyeFilled, IconFileInfo, + IconFolderMinus, IconInfoCircle, IconPencil, IconRefresh, IconStar, IconStarFilled, + IconTags, + IconTagsOff, IconTextRecognition, IconTrashFilled, IconUpload, IconUserQuestion, IconX, } from '@tabler/icons-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import useSWR, { mutate } from 'swr'; import { useShallow } from 'zustand/shallow'; import DashboardFileType from '../DashboardFileType'; -import { copyFile, deleteFile, downloadFile, favoriteFile, viewFile } from '../actions'; +import { + addToFolder, + copyFile, + createFolderAndAdd, + deleteFile, + downloadFile, + favoriteFile, + mutateFiles, + removeFromFolder, + viewFile, +} from '../actions'; import EditFileDetailsModal from './EditFileDetailsModal'; import FileStat from './FileStat'; @@ -79,7 +109,7 @@ export default function FileViewer({ setOpen, file, reduce, - user: _user, + user, sequenced, }: { open: boolean; @@ -93,6 +123,81 @@ export default function FileViewer({ const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion); const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons); + const { data: folders } = useFolders(user); + + const folderOptions = useMemo(() => { + if (!folders) return []; + return buildFolderHierarchy(folders); + }, [folders]); + + const folderCombobox = useCombobox(); + const [search, setSearch] = useState(''); + + const handleAdd = async (value: string) => { + if (value === '$create') { + await createFolderAndAdd(file!, search.trim()); + } else { + await addToFolder(file!, value); + } + }; + + const { data: tags } = useSWR>( + user ? `/api/users/${user}/tags` : '/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( + `/api/user/files/${file!.id}`, + 'PATCH', + { + tags: value, + }, + ); + + if (error) { + showNotification({ + title: 'Failed to save tags', + message: error.error, + color: 'red', + icon: , + }); + } else { + showNotification({ + title: 'Saved tags', + message: `Saved ${data!.tags!.length} tags for file ${data!.name}`, + color: 'green', + icon: , + }); + } + + mutateFiles(); + mutate('/api/user/tags'); + }; + + const triggerSave = async () => { + tagsCombobox.closeDropdown(); + + handleTagsUpdate(); + }; + + const values = value.map((id) => t.id === id) || null} />); + const [editFileOpen, setEditFileOpen] = useState(false); const [infoOpen, setInfoOpen] = useState(false); const [scrollParent, setScrollParent] = useState(null); @@ -221,6 +326,139 @@ export default function FileViewer({ )} {file.anonymous && } + {!reduce && ( + <> + + + Tags + + + + triggerSave()} + pointer + onClick={() => tagsCombobox.openDropdown()} + > + + {values.length > 0 ? ( + values + ) : ( + Pick one or more tags + )} + + + tagsCombobox.openDropdown()} + onBlur={() => tagsCombobox.closeDropdown()} + onKeyDown={(event) => { + if ( + event.key === 'Backspace' && + value.length > 0 && + event.currentTarget.value === '' + ) { + event.preventDefault(); + handleValueRemove(value[value.length - 1]); + } + }} + /> + + + + + + + + {tags?.length ? ( + tags.map((tag) => ( + + + {}} + aria-hidden + tabIndex={-1} + style={{ pointerEvents: 'none' }} + /> + + + + )) + ) : ( + No tags found, create one outside of this menu. + )} + + + + + + + Folder + + {file.folderId ? ( + + ) : ( + handleAdd(v)}> + + } + value={search} + onChange={(event) => { + folderCombobox.openDropdown(); + folderCombobox.updateSelectedOptionIndex(); + setSearch(event.currentTarget.value); + }} + onClick={() => { + folderCombobox.openDropdown(); + setSearch(''); + }} + onFocus={() => { + folderCombobox.openDropdown(); + setSearch(''); + }} + onBlur={() => { + folderCombobox.closeDropdown(); + setSearch(''); + }} + placeholder='Add to folder...' + rightSectionPointerEvents='none' + /> + + + + {folders?.length === 0 && ( + + You have no folders. Start typing to create a new folder for this file. + + )} + + f.name === search) && + search.trim().length > 0 ? ( + + + Create folder "{search}" + + ) : null + } + /> + + + )} + + + )} )}