mirror of
https://github.com/diced/zipline.git
synced 2026-04-28 10:43:06 -07:00
fix: add tags/folder editing to new viewer
This commit is contained in:
@@ -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 { bytes } from '@/lib/bytes';
|
||||||
|
import { useFolders } from '@/lib/client/hooks/useFolders';
|
||||||
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
import { useFileNavStore } from '@/lib/client/store/fileNav';
|
||||||
import { useSettingsStore } from '@/lib/client/store/settings';
|
import { useSettingsStore } from '@/lib/client/store/settings';
|
||||||
import { File } from '@/lib/db/models/file';
|
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 {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
ActionIconProps,
|
ActionIconProps,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Combobox,
|
||||||
Drawer,
|
Drawer,
|
||||||
Group,
|
Group,
|
||||||
|
Input,
|
||||||
|
InputBase,
|
||||||
Paper,
|
Paper,
|
||||||
|
Pill,
|
||||||
|
PillsInput,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
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,
|
||||||
@@ -27,22 +43,36 @@ import {
|
|||||||
IconExternalLink,
|
IconExternalLink,
|
||||||
IconEyeFilled,
|
IconEyeFilled,
|
||||||
IconFileInfo,
|
IconFileInfo,
|
||||||
|
IconFolderMinus,
|
||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
|
IconTags,
|
||||||
|
IconTagsOff,
|
||||||
IconTextRecognition,
|
IconTextRecognition,
|
||||||
IconTrashFilled,
|
IconTrashFilled,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
IconUserQuestion,
|
IconUserQuestion,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} 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 { useShallow } from 'zustand/shallow';
|
||||||
|
|
||||||
import DashboardFileType from '../DashboardFileType';
|
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 EditFileDetailsModal from './EditFileDetailsModal';
|
||||||
import FileStat from './FileStat';
|
import FileStat from './FileStat';
|
||||||
|
|
||||||
@@ -79,7 +109,7 @@ export default function FileViewer({
|
|||||||
setOpen,
|
setOpen,
|
||||||
file,
|
file,
|
||||||
reduce,
|
reduce,
|
||||||
user: _user,
|
user,
|
||||||
sequenced,
|
sequenced,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -93,6 +123,81 @@ export default function FileViewer({
|
|||||||
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
|
||||||
const fileNavButtons = useSettingsStore((state) => state.settings.fileNavButtons);
|
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<Extract<Response['/api/user/tags'], Tag[]>>(
|
||||||
|
user ? `/api/users/${user}/tags` : '/api/user/tags',
|
||||||
|
);
|
||||||
|
|
||||||
|
const tagsCombobox = useCombobox();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string[]>(() => 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.error,
|
||||||
|
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();
|
||||||
|
mutate('/api/user/tags');
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerSave = async () => {
|
||||||
|
tagsCombobox.closeDropdown();
|
||||||
|
|
||||||
|
handleTagsUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const values = value.map((id) => <TagPill key={id} tag={tags?.find((t) => t.id === id) || null} />);
|
||||||
|
|
||||||
const [editFileOpen, setEditFileOpen] = useState(false);
|
const [editFileOpen, setEditFileOpen] = useState(false);
|
||||||
const [infoOpen, setInfoOpen] = useState(false);
|
const [infoOpen, setInfoOpen] = useState(false);
|
||||||
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
const [scrollParent, setScrollParent] = useState<HTMLDivElement | null>(null);
|
||||||
@@ -221,6 +326,139 @@ export default function FileViewer({
|
|||||||
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
|
<FileStat Icon={IconTextRecognition} title='Original Name' value={file.originalName} />
|
||||||
)}
|
)}
|
||||||
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
|
{file.anonymous && <FileStat Icon={IconUserQuestion} title='Anonymous' value='Yes' />}
|
||||||
|
{!reduce && (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Title order={4} mb='xs'>
|
||||||
|
Tags
|
||||||
|
</Title>
|
||||||
|
<Combobox zIndex={90000} store={tagsCombobox} onOptionSubmit={handleValueSelect}>
|
||||||
|
<Combobox.DropdownTarget>
|
||||||
|
<PillsInput
|
||||||
|
onBlur={() => triggerSave()}
|
||||||
|
pointer
|
||||||
|
onClick={() => tagsCombobox.openDropdown()}
|
||||||
|
>
|
||||||
|
<Pill.Group>
|
||||||
|
{values.length > 0 ? (
|
||||||
|
values
|
||||||
|
) : (
|
||||||
|
<Input.Placeholder>Pick one or more tags</Input.Placeholder>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Combobox.EventsTarget>
|
||||||
|
<PillsInput.Field
|
||||||
|
type='hidden'
|
||||||
|
onFocus={() => tagsCombobox.openDropdown()}
|
||||||
|
onBlur={() => tagsCombobox.closeDropdown()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (
|
||||||
|
event.key === 'Backspace' &&
|
||||||
|
value.length > 0 &&
|
||||||
|
event.currentTarget.value === ''
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleValueRemove(value[value.length - 1]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Combobox.EventsTarget>
|
||||||
|
</Pill.Group>
|
||||||
|
</PillsInput>
|
||||||
|
</Combobox.DropdownTarget>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
<Combobox.Options>
|
||||||
|
{tags?.length ? (
|
||||||
|
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.Empty>No tags found, create one outside of this menu.</Combobox.Empty>
|
||||||
|
)}
|
||||||
|
</Combobox.Options>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Title order={4} mb='xs'>
|
||||||
|
Folder
|
||||||
|
</Title>
|
||||||
|
{file.folderId ? (
|
||||||
|
<Button
|
||||||
|
color='red'
|
||||||
|
leftSection={<IconFolderMinus size='1rem' />}
|
||||||
|
onClick={() => removeFromFolder(file)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Remove from folder "
|
||||||
|
{folders?.find((f: { id: string }) => f.id === file.folderId)?.name ?? ''}
|
||||||
|
"
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Combobox zIndex={90000} store={folderCombobox} onOptionSubmit={(v) => handleAdd(v)}>
|
||||||
|
<Combobox.Target>
|
||||||
|
<InputBase
|
||||||
|
rightSection={<Combobox.Chevron />}
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown>
|
||||||
|
{folders?.length === 0 && (
|
||||||
|
<Combobox.Empty>
|
||||||
|
You have no folders. Start typing to create a new folder for this file.
|
||||||
|
</Combobox.Empty>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FolderComboboxOptions
|
||||||
|
folderOptions={folderOptions}
|
||||||
|
searchValue={search}
|
||||||
|
additionalOptions={
|
||||||
|
!folders?.some((f: { name: string }) => f.name === search) &&
|
||||||
|
search.trim().length > 0 ? (
|
||||||
|
<Combobox.Option value='$create'>
|
||||||
|
+ Create folder "{search}"
|
||||||
|
</Combobox.Option>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|||||||
Reference in New Issue
Block a user