diff --git a/package.json b/package.json index fe7b5ad7..7619334c 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "marked-react": "^4.0.0", "ms": "^2.1.3", "multer": "2.1.1", + "nuqs": "^2.8.9", "otplib": "^13.4.0", "prisma": "6.13.0", "qrcode": "^1.5.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eafc2a12..118adaf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: multer: specifier: 2.1.1 version: 2.1.1 + nuqs: + specifier: ^2.8.9 + version: 2.8.9(react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) otplib: specifier: ^13.4.0 version: 13.4.0 @@ -1735,6 +1738,9 @@ packages: peerDependencies: solid-js: ^1.6.12 + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3499,6 +3505,27 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nuqs@2.8.9: + resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==} + peerDependencies: + '@remix-run/react': '>=2' + '@tanstack/react-router': ^1 + next: '>=14.2.0' + react: '>=18.2.0 || ^19.0.0-0' + react-router: ^5 || ^6 || ^7 + react-router-dom: ^5 || ^6 || ^7 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@tanstack/react-router': + optional: true + next: + optional: true + react-router: + optional: true + react-router-dom: + optional: true + nypm@0.6.1: resolution: {integrity: sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==} engines: {node: ^14.16.0 || >=16.10.0} @@ -6361,6 +6388,8 @@ snapshots: dependencies: solid-js: 1.9.12 + '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} '@tabler/icons-react@3.44.0(react@19.2.6)': @@ -8265,6 +8294,14 @@ snapshots: normalize-path@3.0.0: {} + nuqs@2.8.9(react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6): + dependencies: + '@standard-schema/spec': 1.0.0 + react: 19.2.6 + optionalDependencies: + react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-router-dom: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + nypm@0.6.1: dependencies: citty: 0.1.6 diff --git a/src/client/Root.tsx b/src/client/Root.tsx index 5cc27371..0c6b582d 100644 --- a/src/client/Root.tsx +++ b/src/client/Root.tsx @@ -6,6 +6,7 @@ import ThemeProvider from '@/components/ThemeProvider'; import { type ZiplineTheme } from '@/lib/theme'; import { type Config } from '@/lib/config/validate'; import { Button, Text } from '@mantine/core'; +import { NuqsAdapter } from 'nuqs/adapters/react-router/v7'; const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => ( <> @@ -61,7 +62,10 @@ export default function Root({ modals={contextModals} > - + + + + diff --git a/src/client/pages/folder/[id]/index.tsx b/src/client/pages/folder/[id]/index.tsx index f40c678d..c50eeed0 100644 --- a/src/client/pages/folder/[id]/index.tsx +++ b/src/client/pages/folder/[id]/index.tsx @@ -1,6 +1,5 @@ import { useApiPagination } from '@/components/pages/files/useApiPagination'; import { type Response } from '@/lib/api/response'; -import { useQueryState } from '@/lib/client/hooks/useQueryState'; import { useTitle } from '@/lib/client/hooks/useTitle'; import { useFileNavStore } from '@/lib/client/store/fileNav'; import { Folder } from '@/lib/db/models/folder'; @@ -24,6 +23,7 @@ import { IconFolder, IconUpload } from '@tabler/icons-react'; import { lazy, Suspense, useEffect, useMemo } from 'react'; import { Link, Params, useLoaderData, useNavigate, useSearchParams } from 'react-router-dom'; import { useShallow } from 'zustand/shallow'; +import { useQueryState, parseAsInteger } from 'nuqs'; const DashboardFile = lazy(() => import('@/components/file/DashboardFile')); const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal')); @@ -78,8 +78,8 @@ export function Component() { const navigate = useNavigate(); const [, setSearchParams] = useSearchParams(); - const [page, setPage] = useQueryState('page', 1); - const [perpage] = useQueryState('perpage', 15); + const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); + const [perpage] = useQueryState('perpage', parseAsInteger.withDefault(15)); const { data, isLoading } = useApiPagination( { diff --git a/src/components/pages/files/PendingFilesModal.tsx b/src/components/pages/files/PendingFilesModal.tsx index f0b8a02a..aa264970 100644 --- a/src/components/pages/files/PendingFilesModal.tsx +++ b/src/components/pages/files/PendingFilesModal.tsx @@ -1,14 +1,13 @@ import { Response } from '@/lib/api/response'; import { IncompleteFile } from '@/lib/db/models/incompleteFile'; import { fetchApi } from '@/lib/fetchApi'; -import { UpdateFn } from '@/lib/client/hooks/useObjectState'; import { IncompleteFileStatus } from '@/prisma/client'; import { Badge, Button, Card, Group, Modal, Paper, Stack, Text } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { IconFileDots, IconTrashFilled } from '@tabler/icons-react'; import { ReactNode } from 'react'; import useSWR from 'swr'; -import { DashboardFilesModals } from '.'; +import { DashboardFilesModals, DashboardFilesModalsUpdate } from '.'; const badgeMap: Record = { PENDING: ( @@ -38,7 +37,7 @@ export default function PendingFilesModal({ setModals, }: { modals: DashboardFilesModals; - setModals: UpdateFn; + setModals: DashboardFilesModalsUpdate; }) { const { data: incompleteFiles, mutate } = useSWR< Extract @@ -72,7 +71,7 @@ export default function PendingFilesModal({ }; return ( - setModals('pending', false)} title='Pending Files'> + setModals({ pending: false })} title='Pending Files'> {incompleteFiles?.map((incompleteFile) => ( diff --git a/src/components/pages/files/index.tsx b/src/components/pages/files/index.tsx index 9fcb76b0..5c2289fd 100644 --- a/src/components/pages/files/index.tsx +++ b/src/components/pages/files/index.tsx @@ -1,5 +1,4 @@ import GridTableSwitcher from '@/components/GridTableSwitcher'; -import useObjectState, { type UpdateFn } from '@/lib/client/hooks/useObjectState'; import { useViewStore } from '@/lib/client/store/view'; import { ActionIcon, Group, Menu, Title, Tooltip } from '@mantine/core'; import { @@ -10,7 +9,8 @@ import { IconTableOptions, IconTags, } from '@tabler/icons-react'; -import { Link, useSearchParams } from 'react-router-dom'; +import { parseAsBoolean, useQueryStates } from 'nuqs'; +import { Link } from 'react-router-dom'; import PendingFilesModal from './PendingFilesModal'; import TagsModal from './tags/TagsModal'; import FavoriteFiles from './views/FavoriteFiles'; @@ -24,48 +24,21 @@ export type DashboardFilesModals = { pending: boolean; }; +export function useModals() { + return useQueryStates({ + table: parseAsBoolean.withDefault(false), + idSearch: parseAsBoolean.withDefault(false), + tags: parseAsBoolean.withDefault(false), + pending: parseAsBoolean.withDefault(false), + }); +} + +export type DashboardFilesModalsUpdate = ReturnType[1]; + export default function DashboardFiles() { const view = useViewStore((state) => state.files); - const [searchParams, setSearchParams] = useSearchParams(); - const modalKeys: Array = ['table', 'idSearch', 'tags', 'pending']; - const modalQS = (key: keyof DashboardFilesModals) => searchParams.get(key) === 'true'; - - const [modals, setModalState] = useObjectState({ - table: modalQS('table'), - idSearch: modalQS('idSearch'), - tags: modalQS('tags'), - pending: modalQS('pending'), - }); - - const updateModalQuery = (updates: Partial) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - - for (const key of modalKeys) { - if (!(key in updates)) continue; - - if (updates[key]) next.set(key, 'true'); - else next.delete(key); - } - - return next; - }, - { replace: true }, - ); - }; - - const setModals: UpdateFn = (keyOrObj: any, value?: any) => { - if (typeof keyOrObj === 'object' && value === undefined) { - setModalState(keyOrObj); - updateModalQuery(keyOrObj); - return; - } - - setModalState(keyOrObj, value); - updateModalQuery({ [keyOrObj]: value }); - }; + const [modals, setModals] = useModals(); return ( <> @@ -92,12 +65,15 @@ export default function DashboardFiles() { - } onClick={() => setModals('tags', !modals.tags)}> + } + onClick={() => setModals({ tags: !modals.tags })} + > Manage Tags } - onClick={() => setModals('pending', !modals.pending)} + onClick={() => setModals({ pending: !modals.pending })} > View Pending Files @@ -106,13 +82,13 @@ export default function DashboardFiles() { Table Options } - onClick={() => setModals('idSearch', !modals.idSearch)} + onClick={() => setModals({ idSearch: !modals.idSearch })} > Search by ID } - onClick={() => setModals('table', !modals.table)} + onClick={() => setModals({ table: !modals.table })} > Table Options diff --git a/src/components/pages/files/tags/TagsModal.tsx b/src/components/pages/files/tags/TagsModal.tsx index e6523e7b..0466b3f2 100644 --- a/src/components/pages/files/tags/TagsModal.tsx +++ b/src/components/pages/files/tags/TagsModal.tsx @@ -2,13 +2,12 @@ import { mutateFiles } from '@/components/file/actions'; import { Response } from '@/lib/api/response'; import { Tag } from '@/lib/db/models/tag'; import { fetchApi } from '@/lib/fetchApi'; -import { UpdateFn } from '@/lib/client/hooks/useObjectState'; import { ActionIcon, Group, Modal, Paper, Stack, Text, Title, Tooltip } from '@mantine/core'; import { showNotification } from '@mantine/notifications'; import { IconPencil, IconPlus, IconTagOff, IconTrashFilled } from '@tabler/icons-react'; import { useState } from 'react'; import useSWR from 'swr'; -import { DashboardFilesModals } from '..'; +import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..'; import CreateTagModal from './CreateTagModal'; import EditTagModal from './EditTagModal'; import TagPill from './TagPill'; @@ -18,7 +17,7 @@ export default function TagsModals({ setModals, }: { modals: DashboardFilesModals; - setModals: UpdateFn; + setModals: DashboardFilesModalsUpdate; }) { const [createModalOpen, setCreateModalOpen] = useState(false); const [selectedTag, setSelectedTag] = useState(null); @@ -55,7 +54,7 @@ export default function TagsModals({ setModals('tags', false)} + onClose={() => setModals({ tags: false })} title={ Tags diff --git a/src/components/pages/files/views/FavoriteFiles.tsx b/src/components/pages/files/views/FavoriteFiles.tsx index 63182cc2..3dda468a 100644 --- a/src/components/pages/files/views/FavoriteFiles.tsx +++ b/src/components/pages/files/views/FavoriteFiles.tsx @@ -1,4 +1,3 @@ -import { useQueryState } from '@/lib/client/hooks/useQueryState'; import { Accordion, Button, @@ -16,11 +15,12 @@ import { IconFileUpload, IconFilesOff } from '@tabler/icons-react'; import { Link } from 'react-router-dom'; import { useApiPagination } from '../useApiPagination'; import { lazy, Suspense } from 'react'; +import { parseAsInteger, useQueryState } from 'nuqs'; const DashboardFile = lazy(() => import('@/components/file/DashboardFile')); export default function FavoriteFiles() { - const [page, setPage] = useQueryState('fpage', 1); + const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1)); const { data, isLoading } = useApiPagination({ page, diff --git a/src/components/pages/files/views/FilesGridView.tsx b/src/components/pages/files/views/FilesGridView.tsx index 51f3b1ae..eee5ee46 100644 --- a/src/components/pages/files/views/FilesGridView.tsx +++ b/src/components/pages/files/views/FilesGridView.tsx @@ -1,4 +1,4 @@ -import { useQueryState } from '@/lib/client/hooks/useQueryState'; +import DashboardFile from '@/components/file/DashboardFile'; import { useFileNavStore } from '@/lib/client/store/fileNav'; import { Button, @@ -14,20 +14,19 @@ import { Title, } from '@mantine/core'; import { IconFilesOff, IconFileUpload } from '@tabler/icons-react'; -import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; +import { parseAsInteger, useQueryState } from 'nuqs'; +import { lazy, Suspense, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useShallow } from 'zustand/shallow'; - -import DashboardFile from '@/components/file/DashboardFile'; import { useApiPagination } from '../useApiPagination'; const DashboardFileModal = lazy(() => import('@/components/file/DashboardFile/DashboardFileModal')); -const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45]; +const PER_PAGE_OPTIONS = [9, 12, 15, 30, 45, 60]; export default function Files({ id, folderId }: { id?: string; folderId?: string }) { - const [page, setPage] = useQueryState('page', 1); - const [perpage, setPerpage] = useState(15); + const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); + const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(15)); const { data, isLoading } = useApiPagination({ page, diff --git a/src/components/pages/files/views/FilesTableView.tsx b/src/components/pages/files/views/FilesTableView.tsx index 50f7d8fd..d58d9330 100644 --- a/src/components/pages/files/views/FilesTableView.tsx +++ b/src/components/pages/files/views/FilesTableView.tsx @@ -4,7 +4,6 @@ import FolderComboboxOptions from '@/components/folders/FolderComboboxOptions'; import { Response } from '@/lib/api/response'; import { bytes } from '@/lib/bytes'; import { useFolders } from '@/lib/client/hooks/useFolders'; -import { useQueryState } from '@/lib/client/hooks/useQueryState'; import { useFileNavStore } from '@/lib/client/store/fileNav'; import { NAMES, useFileTableSettingsStore } from '@/lib/client/store/fileTableSettings'; import { useSettingsStore } from '@/lib/client/store/settings'; @@ -41,13 +40,12 @@ import { IconTrashFilled, } from '@tabler/icons-react'; import { DataTable } from 'mantine-datatable'; +import { parseAsInteger, useQueryState } from 'nuqs'; import { lazy, useEffect, useMemo, useReducer, useState } from 'react'; import { Link } from 'react-router-dom'; import useSWR from 'swr'; import { useShallow } from 'zustand/shallow'; - -import { UpdateFn } from '@/lib/client/hooks/useObjectState'; -import { DashboardFilesModals } from '..'; +import { DashboardFilesModals, DashboardFilesModalsUpdate } from '..'; import TableEditModal from '../TableEditModal'; import { bulkDelete, bulkFavorite } from '../bulk'; import TagPill from '../tags/TagPill'; @@ -60,7 +58,7 @@ type ReducerQuery = { action: { field: string; query: string }; }; -const PER_PAGE_OPTIONS = [10, 20, 50]; +const PER_PAGE_OPTIONS = [10, 20, 50, 70, 100]; function SearchFilter({ setSearchField, @@ -189,7 +187,7 @@ export default function FileTable({ id?: string; folderId?: string; modals?: Partial; - setModals?: UpdateFn; + setModals?: DashboardFilesModalsUpdate; }) { const clipboard = useClipboard(); const warnDeletion = useSettingsStore((state) => state.settings.warnDeletion); @@ -203,8 +201,8 @@ export default function FileTable({ return buildFolderHierarchy(folders); }, [folders]); - const [page, setPage] = useQueryState('page', 1); - const [perpage, setPerpage] = useState(20); + const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1)); + const [perpage, setPerpage] = useQueryState('perpage', parseAsInteger.withDefault(20)); const [sort, setSort] = useState< | 'id' | 'createdAt' @@ -394,7 +392,7 @@ export default function FileTable({ /> {modals && setModals && ( - setModals('table', false)} /> + setModals({ table: false })} /> )} diff --git a/src/components/pages/folders/FavoriteFiles.tsx b/src/components/pages/folders/FavoriteFiles.tsx index fbce7fcd..1d043ef4 100644 --- a/src/components/pages/folders/FavoriteFiles.tsx +++ b/src/components/pages/folders/FavoriteFiles.tsx @@ -1,4 +1,3 @@ -import { useQueryState } from '@/lib/client/hooks/useQueryState'; import { Accordion, Button, @@ -16,11 +15,12 @@ import { IconFileUpload, IconFilesOff } from '@tabler/icons-react'; import { Link } from 'react-router-dom'; import { useApiPagination } from '../files/useApiPagination'; import { lazy, Suspense } from 'react'; +import { parseAsInteger, useQueryState } from 'nuqs'; const DashboardFile = lazy(() => import('@/components/file/DashboardFile')); export default function FavoriteFiles() { - const [page, setPage] = useQueryState('fpage', 1); + const [page, setPage] = useQueryState('fpage', parseAsInteger.withDefault(1)); const { data, isLoading } = useApiPagination({ page, favorite: true, diff --git a/src/components/pages/users/ViewUserFiles.tsx b/src/components/pages/users/ViewUserFiles.tsx index cc1ff2cd..f4343926 100644 --- a/src/components/pages/users/ViewUserFiles.tsx +++ b/src/components/pages/users/ViewUserFiles.tsx @@ -1,22 +1,18 @@ import { type loader } from '@/client/pages/dashboard/admin/users/[id]/files'; import GridTableSwitcher from '@/components/GridTableSwitcher'; -import useObjectState from '@/lib/client/hooks/useObjectState'; import { useViewStore } from '@/lib/client/store/view'; import { ActionIcon, Group, Title, Tooltip } from '@mantine/core'; import { IconArrowBackUp, IconGridPatternFilled, IconTableOptions } from '@tabler/icons-react'; import { Link, useLoaderData } from 'react-router-dom'; -import { DashboardFilesModals } from '../files'; -import FilesTableView from '../files/views/FilesTableView'; +import { useModals } from '../files'; import FilesGridView from '../files/views/FilesGridView'; +import FilesTableView from '../files/views/FilesTableView'; export default function ViewUserFiles() { const data = useLoaderData(); const view = useViewStore((state) => state.files); - const [modals, setModals] = useObjectState>({ - table: false, - idSearch: false, - }); + const [modals, setModals] = useModals(); if (!data) return; @@ -34,13 +30,13 @@ export default function ViewUserFiles() { - setModals('table', !modals.table)}> + setModals({ table: !modals.table })}> - setModals('idSearch', !modals.idSearch)}> + setModals({ idSearch: !modals.idSearch })}> diff --git a/src/lib/client/hooks/useQueryState.ts b/src/lib/client/hooks/useQueryState.ts deleted file mode 100644 index ce07090a..00000000 --- a/src/lib/client/hooks/useQueryState.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useSearchParams } from 'react-router-dom'; - -function parseValue(value: string | null, defaultValue: T): T { - if (value === null) return defaultValue; - - if (typeof defaultValue === 'number') { - const parsed = Number(value); - return isNaN(parsed) ? defaultValue : (parsed as T); - } - - if (typeof defaultValue === 'boolean') { - return (value === 'true') as T; - } - - return value as T; -} - -export function useQueryState(key: string, defaultValue: T): [T, (value: T | null) => void] { - const [searchParams, setSearchParams] = useSearchParams(); - - const rawValue = searchParams.get(key); - const value: T = parseValue(rawValue, defaultValue); - - const setValue = (newValue: T | null) => { - setSearchParams((prev) => { - const next = new URLSearchParams(prev); - if (newValue === null) { - next.delete(key); - } else { - next.set(key, String(newValue)); - } - return next; - }); - }; - - return [value, setValue]; -}