diff --git a/package.json b/package.json index 70b3f184..bbe8e7dc 100755 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "dependencies": { "@aws-sdk/client-s3": "3.726.1", "@aws-sdk/lib-storage": "3.726.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fastify/cookie": "^11.0.2", "@fastify/cors": "^11.1.0", "@fastify/multipart": "^9.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 682f1b38..e0d9e853 100755 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@aws-sdk/lib-storage': specifier: 3.726.1 version: 3.726.1(@aws-sdk/client-s3@3.726.1) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.1.1) '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -579,6 +588,28 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/runtime@1.4.5': resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} @@ -5656,6 +5687,31 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dnd-kit/accessibility@3.1.1(react@19.1.1)': + dependencies: + react: 19.1.1 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.1.1) + '@dnd-kit/utilities': 3.2.2(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@dnd-kit/utilities': 3.2.2(react@19.1.1) + react: 19.1.1 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.1.1)': + dependencies: + react: 19.1.1 + tslib: 2.8.1 + '@emnapi/runtime@1.4.5': dependencies: tslib: 2.8.1 diff --git a/src/components/pages/files/TableEditModal.tsx b/src/components/pages/files/TableEditModal.tsx new file mode 100644 index 00000000..797d1fcc --- /dev/null +++ b/src/components/pages/files/TableEditModal.tsx @@ -0,0 +1,99 @@ +import { FieldSettings, useFileTableSettingsStore } from '@/lib/store/fileTableSettings'; +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Button, Checkbox, Group, Modal, Paper, Text } from '@mantine/core'; +import { IconGripVertical } from '@tabler/icons-react'; +import { useShallow } from 'zustand/shallow'; + +export const NAMES = { + name: 'Name', + originalName: 'Original Name', + tags: 'Tags', + type: 'Type', + size: 'Size', + createdAt: 'Created At', + favorite: 'Favorite', + views: 'Views', +}; + +function SortableTableField({ item }: { item: FieldSettings }) { + const setVisible = useFileTableSettingsStore((state) => state.setVisible); + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: item.field, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + cursor: 'grab', + width: '100%', + }; + + return ( + + + + + setVisible(item.field, !item.visible)} /> + + {NAMES[item.field]} + + + ); +} + +export default function TableEditModal({ opened, onCLose }: { opened: boolean; onCLose: () => void }) { + const [fields, setIndex, reset] = useFileTableSettingsStore( + useShallow((state) => [state.fields, state.setIndex, state.reset]), + ); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (active.id !== over?.id) { + const newIndex = fields.findIndex((item) => item.field === over?.id); + + setIndex(active.id as FieldSettings['field'], newIndex); + } + }; + + return ( + + + Select and drag fields below to make them appear/disappear/reorder in the file table view. + + + + item.field)} strategy={verticalListSortingStrategy}> + {fields.map((item, index) => ( +
+ +
+ ))} +
+
+ + +
+ ); +} diff --git a/src/components/pages/files/views/FileTable.tsx b/src/components/pages/files/views/FileTable.tsx index 8a5b4a8e..bf13e573 100755 --- a/src/components/pages/files/views/FileTable.tsx +++ b/src/components/pages/files/views/FileTable.tsx @@ -5,6 +5,8 @@ import { bytes } from '@/lib/bytes'; import { type File } from '@/lib/db/models/file'; import { Folder } from '@/lib/db/models/folder'; import { Tag } from '@/lib/db/models/tag'; +import { useQueryState } from '@/lib/hooks/useQueryState'; +import { useFileTableSettingsStore } from '@/lib/store/fileTableSettings'; import { useSettingsStore } from '@/lib/store/settings'; import { ActionIcon, @@ -34,16 +36,17 @@ import { IconFile, IconGridPatternFilled, IconStar, + IconTableOptions, IconTrashFilled, } from '@tabler/icons-react'; import { DataTable } from 'mantine-datatable'; import { lazy, useEffect, useReducer, useState } from 'react'; import { Link } from 'react-router-dom'; import useSWR from 'swr'; +import TableEditModal, { NAMES } from '../TableEditModal'; import { bulkDelete, bulkFavorite } from '../bulk'; import TagPill from '../tags/TagPill'; import { useApiPagination } from '../useApiPagination'; -import { useQueryState } from '@/lib/hooks/useQueryState'; const FileModal = lazy(() => import('@/components/file/DashboardFile/FileModal')); @@ -54,13 +57,6 @@ type ReducerQuery = { const PER_PAGE_OPTIONS = [10, 20, 50]; -const NAMES = { - name: 'Name', - originalName: 'Original name', - type: 'Type', - id: 'ID', -}; - function SearchFilter({ setSearchField, searchQuery, @@ -88,8 +84,8 @@ function SearchFilter({ return ( state.settings.warnDeletion); + const [tableEditOpen, setTableEditOpen] = useState(false); + + const fields = useFileTableSettingsStore((state) => state.fields); + const { data: folders } = useSWR>( '/api/user/folders?noincl=true', ); @@ -264,6 +264,100 @@ export default function FileTable({ id }: { id?: string }) { }), }); + const FIELDS = [ + { + accessor: 'name', + sortable: true, + filter: ( + + ), + filtering: searchField === 'name' && searchQuery.name.trim() !== '', + }, + { + accessor: 'originalName', + sortable: true, + filter: ( + + ), + filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '', + }, + { + accessor: 'tags', + sortable: false, + width: 200, + render: (file: File) => ( + e.stopPropagation()}> + + {file.tags!.map((tag) => ( + + ))} + + + ), + filter: ( + + ), + filtering: searchField === 'tags' && searchQuery.tags.trim() !== '', + }, + { + accessor: 'type', + sortable: true, + filter: ( + + ), + filtering: searchField === 'type' && searchQuery.type.trim() !== '', + }, + { accessor: 'size', sortable: true, render: (file: File) => bytes(file.size) }, + { + accessor: 'createdAt', + sortable: true, + render: (file: File) => , + }, + { + accessor: 'favorite', + sortable: true, + render: (file: File) => (file.favorite ? Yes : 'No'), + }, + { + accessor: 'views', + sortable: true, + render: (file: File) => file.views, + }, + { + accessor: 'id', + hidden: searchField !== 'id' || searchQuery.id.trim() === '', + filtering: searchField === 'id' && searchQuery.id.trim() !== '', + }, + ]; + + const visibleFields = fields.filter((f) => f.visible).map((f) => f.field); + const columns = FIELDS.filter((f) => visibleFields.includes(f.accessor as any)); + columns.sort((a, b) => { + const aIndex = fields.findIndex((f) => f.field === a.accessor); + const bIndex = fields.findIndex((f) => f.field === b.accessor); + + return aIndex - bIndex; + }); + useEffect(() => { if (data && selectedFile) { const file = data.page.find((x) => x.id === selectedFile.id); @@ -295,19 +389,32 @@ export default function FileTable({ id }: { id?: string }) { file={selectedFile} /> + setTableEditOpen(false)} /> + - - { - setIdSearchOpen((open) => !open); - }} - // lol if it works it works :shrug: - style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }} - > - - - + + + setTableEditOpen((open) => !open)} + style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }} + > + + + + + { + setIdSearchOpen((open) => !open); + }} + // lol if it works it works :shrug: + style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }} + > + + + + 0}> @@ -417,75 +524,7 @@ export default function FileTable({ id }: { id?: string }) { minHeight={200} records={data?.page ?? []} columns={[ - { - accessor: 'name', - sortable: true, - filter: ( - - ), - filtering: searchField === 'name' && searchQuery.name.trim() !== '', - }, - { - accessor: 'tags', - sortable: false, - width: 200, - render: (file) => ( - e.stopPropagation()}> - - {file.tags!.map((tag) => ( - - ))} - - - ), - filter: ( - - ), - filtering: searchField === 'tags' && searchQuery.tags.trim() !== '', - }, - { - accessor: 'type', - sortable: true, - filter: ( - - ), - filtering: searchField === 'type' && searchQuery.type.trim() !== '', - }, - { accessor: 'size', sortable: true, render: (file) => bytes(file.size) }, - { - accessor: 'createdAt', - sortable: true, - render: (file) => , - }, - { - accessor: 'favorite', - sortable: true, - render: (file) => (file.favorite ? Yes : 'No'), - }, - { - accessor: 'views', - sortable: true, - render: (file) => file.views, - }, - { - accessor: 'id', - hidden: searchField !== 'id' || searchQuery.id.trim() === '', - filtering: searchField === 'id' && searchQuery.id.trim() !== '', - }, + ...columns, { accessor: 'actions', textAlign: 'right', diff --git a/src/lib/store/fileTableSettings.ts b/src/lib/store/fileTableSettings.ts new file mode 100755 index 00000000..2021810c --- /dev/null +++ b/src/lib/store/fileTableSettings.ts @@ -0,0 +1,58 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +const FIELDS = ['name', 'originalName', 'tags', 'type', 'size', 'createdAt', 'favorite', 'views'] as const; + +export const defaultFields: FieldSettings[] = [ + { field: 'name', visible: true }, + { field: 'originalName', visible: false }, + { field: 'tags', visible: true }, + { field: 'type', visible: true }, + { field: 'size', visible: true }, + { field: 'createdAt', visible: true }, + { field: 'favorite', visible: true }, + { field: 'views', visible: true }, +]; + +export type FieldSettings = { + field: (typeof FIELDS)[number]; + visible: boolean; +}; + +export type FileTableSettings = { + fields: FieldSettings[]; + + setVisible: (field: FieldSettings['field'], visible: boolean) => void; + setIndex: (field: FieldSettings['field'], index: number) => void; + reset: () => void; +}; + +export const useFileTableSettingsStore = create()( + persist( + (set) => ({ + fields: defaultFields, + + setVisible: (field, visible) => + set((state) => ({ + fields: state.fields.map((f) => (f.field === field ? { ...f, visible } : f)), + })), + + setIndex: (field, index) => + set((state) => { + const currentIndex = state.fields.findIndex((f) => f.field === field); + if (currentIndex === -1 || index < 0 || index >= state.fields.length) return state; + + const newFields = [...state.fields]; + const [movedField] = newFields.splice(currentIndex, 1); + newFields.splice(index, 0, movedField); + + return { fields: newFields }; + }), + + reset: () => set({ fields: defaultFields }), + }), + { + name: 'zipline-file-table-settings', + }, + ), +); diff --git a/src/server/routes/api/upload/partial.ts b/src/server/routes/api/upload/partial.ts index 52a09956..910fea95 100644 --- a/src/server/routes/api/upload/partial.ts +++ b/src/server/routes/api/upload/partial.ts @@ -7,13 +7,13 @@ import { guess } from '@/lib/mimes'; import { randomCharacters } from '@/lib/random'; import { formatFileName } from '@/lib/uploader/formatFileName'; import { UploadHeaders, UploadOptions, parseHeaders } from '@/lib/uploader/parseHeaders'; +import { Prisma } from '@/prisma/client'; import { userMiddleware } from '@/server/middleware/user'; import fastifyPlugin from 'fastify-plugin'; import { readdir, rename, rm } from 'fs/promises'; import { join } from 'path'; import { Worker } from 'worker_threads'; import { ApiUploadResponse, getExtension } from '.'; -import { Prisma } from '@/prisma/client'; const logger = log('api').c('upload').c('partial');