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];
-}