Compare commits

...

2 Commits

Author SHA1 Message Date
diced
69dfad201b feat: reorder/disable/enable table fields in file table 2025-10-12 21:43:50 -07:00
diced
ee1681497e feat: allow any env to be read from a file 2025-10-12 21:43:34 -07:00
7 changed files with 359 additions and 93 deletions

View File

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

56
pnpm-lock.yaml generated
View File

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

View File

@@ -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 (
<Paper withBorder p='xs' ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Group gap='xs'>
<IconGripVertical size='1rem' />
<Checkbox checked={item.visible} onChange={() => setVisible(item.field, !item.visible)} />
<Text>{NAMES[item.field]}</Text>
</Group>
</Paper>
);
}
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 (
<Modal opened={opened} onClose={onCLose} title='Table Options' centered>
<Text mb='md' size='sm' c='dimmed'>
Select and drag fields below to make them appear/disappear/reorder in the file table view.
</Text>
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={fields.map((item) => item.field)} strategy={verticalListSortingStrategy}>
{fields.map((item, index) => (
<div
key={index}
style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}
>
<SortableTableField item={item} />
</div>
))}
</SortableContext>
</DndContext>
<Button fullWidth color='red' onClick={() => reset()} variant='light' mt='md'>
Reset to Default
</Button>
</Modal>
);
}

View File

@@ -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 (
<TextInput
label={NAMES[field]}
placeholder={`Search by ${NAMES[field].toLowerCase()}`}
label={NAMES[field as keyof typeof NAMES]}
placeholder={`Search by ${NAMES[field as keyof typeof NAMES].toLowerCase()}`}
value={searchQuery[field]}
onChange={onChange}
size='sm'
@@ -183,6 +179,10 @@ export default function FileTable({ id }: { id?: string }) {
const clipboard = useClipboard();
const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion);
const [tableEditOpen, setTableEditOpen] = useState(false);
const fields = useFileTableSettingsStore((state) => state.fields);
const { data: folders } = useSWR<Extract<Response['/api/user/folders'], Folder[]>>(
'/api/user/folders?noincl=true',
);
@@ -264,6 +264,100 @@ export default function FileTable({ id }: { id?: string }) {
}),
});
const FIELDS = [
{
accessor: 'name',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='name'
/>
),
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
},
{
accessor: 'originalName',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='originalName'
/>
),
filtering: searchField === 'originalName' && searchQuery.originalName.trim() !== '',
},
{
accessor: 'tags',
sortable: false,
width: 200,
render: (file: File) => (
<ScrollArea w={180} onClick={(e) => e.stopPropagation()}>
<Flex gap='sm'>
{file.tags!.map((tag) => (
<TagPill tag={tag} key={tag.id} />
))}
</Flex>
</ScrollArea>
),
filter: (
<TagsFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
),
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
},
{
accessor: 'type',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='type'
/>
),
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
},
{ accessor: 'size', sortable: true, render: (file: File) => bytes(file.size) },
{
accessor: 'createdAt',
sortable: true,
render: (file: File) => <RelativeDate date={file.createdAt} />,
},
{
accessor: 'favorite',
sortable: true,
render: (file: File) => (file.favorite ? <Text c='yellow'>Yes</Text> : '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}
/>
<TableEditModal opened={tableEditOpen} onCLose={() => setTableEditOpen(false)} />
<Box>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
// lol if it works it works :shrug:
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
<Group>
<Tooltip label='Table Options'>
<ActionIcon
variant='outline'
onClick={() => setTableEditOpen((open) => !open)}
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
>
<IconTableOptions size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Search by ID'>
<ActionIcon
variant='outline'
onClick={() => {
setIdSearchOpen((open) => !open);
}}
// lol if it works it works :shrug:
style={{ position: 'relative', top: '-36.4px', left: '221px', margin: 0 }}
>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
</Group>
<Collapse in={selectedFiles.length > 0}>
<Paper withBorder p='sm' my='sm'>
@@ -417,75 +524,7 @@ export default function FileTable({ id }: { id?: string }) {
minHeight={200}
records={data?.page ?? []}
columns={[
{
accessor: 'name',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='name'
/>
),
filtering: searchField === 'name' && searchQuery.name.trim() !== '',
},
{
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: (
<TagsFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
),
filtering: searchField === 'tags' && searchQuery.tags.trim() !== '',
},
{
accessor: 'type',
sortable: true,
filter: (
<SearchFilter
setSearchField={setSearchField}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
field='type'
/>
),
filtering: searchField === 'type' && searchQuery.type.trim() !== '',
},
{ accessor: 'size', sortable: true, render: (file) => bytes(file.size) },
{
accessor: 'createdAt',
sortable: true,
render: (file) => <RelativeDate date={file.createdAt} />,
},
{
accessor: 'favorite',
sortable: true,
render: (file) => (file.favorite ? <Text c='yellow'>Yes</Text> : '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',

View File

@@ -1,5 +1,6 @@
import { log } from '@/lib/logger';
import { parse } from './transform';
import { readFileSync } from 'node:fs';
export type EnvType = 'string' | 'string[]' | 'number' | 'boolean' | 'byte' | 'ms' | 'json';
export function env(property: string, env: string | string[], type: EnvType, isDb: boolean = false) {
@@ -178,7 +179,17 @@ export function readEnv(): EnvResult {
env.variable = env.variable.find((v) => process.env[v] !== undefined) || 'DATABASE_URL';
}
const value = process.env[env.variable];
let value = process.env[env.variable];
const valueFileName = process.env[`${env.variable}_FILE`];
if (valueFileName) {
try {
value = readFileSync(valueFileName, 'utf-8').trim();
logger.debug('Using env value from file', { variable: env.variable, file: valueFileName });
} catch (e) {
logger.error(`Failed to read env value from file for ${env.variable}. Skipping...`).error(e as Error);
continue;
}
}
if (value === undefined) continue;

View File

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

View File

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