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