refactor: nuqs

This commit is contained in:
diced
2026-05-15 23:03:11 -07:00
parent 3fc3dcd1ed
commit a8c65c19b4
13 changed files with 95 additions and 123 deletions
+1
View File
@@ -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",
+37
View File
@@ -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
+5 -1
View File
@@ -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}
>
<Notifications position='top-center' zIndex={10000000} />
<Outlet />
<NuqsAdapter>
<Outlet />
</NuqsAdapter>
</ModalsProvider>
</ThemeProvider>
</SWRConfig>
+3 -3
View File
@@ -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<Response['/api/server/folder/[id]']>(
{
@@ -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<IncompleteFileStatus, ReactNode> = {
PENDING: (
@@ -38,7 +37,7 @@ export default function PendingFilesModal({
setModals,
}: {
modals: DashboardFilesModals;
setModals: UpdateFn<DashboardFilesModals>;
setModals: DashboardFilesModalsUpdate;
}) {
const { data: incompleteFiles, mutate } = useSWR<
Extract<IncompleteFile[], Response['/api/user/files/incomplete']>
@@ -72,7 +71,7 @@ export default function PendingFilesModal({
};
return (
<Modal opened={modals.pending} onClose={() => setModals('pending', false)} title='Pending Files'>
<Modal opened={modals.pending} onClose={() => setModals({ pending: false })} title='Pending Files'>
<Stack gap='xs'>
{incompleteFiles?.map((incompleteFile) => (
<Card key={incompleteFile.id} withBorder>
+21 -45
View File
@@ -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<typeof useModals>[1];
export default function DashboardFiles() {
const view = useViewStore((state) => state.files);
const [searchParams, setSearchParams] = useSearchParams();
const modalKeys: Array<keyof DashboardFilesModals> = ['table', 'idSearch', 'tags', 'pending'];
const modalQS = (key: keyof DashboardFilesModals) => searchParams.get(key) === 'true';
const [modals, setModalState] = useObjectState<DashboardFilesModals>({
table: modalQS('table'),
idSearch: modalQS('idSearch'),
tags: modalQS('tags'),
pending: modalQS('pending'),
});
const updateModalQuery = (updates: Partial<DashboardFilesModals>) => {
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<DashboardFilesModals> = (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() {
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item leftSection={<IconTags size='1rem' />} onClick={() => setModals('tags', !modals.tags)}>
<Menu.Item
leftSection={<IconTags size='1rem' />}
onClick={() => setModals({ tags: !modals.tags })}
>
Manage Tags
</Menu.Item>
<Menu.Item
leftSection={<IconFileDots size='1rem' />}
onClick={() => setModals('pending', !modals.pending)}
onClick={() => setModals({ pending: !modals.pending })}
>
View Pending Files
</Menu.Item>
@@ -106,13 +82,13 @@ export default function DashboardFiles() {
<Menu.Label>Table Options</Menu.Label>
<Menu.Item
leftSection={<IconGridPatternFilled size='1rem' />}
onClick={() => setModals('idSearch', !modals.idSearch)}
onClick={() => setModals({ idSearch: !modals.idSearch })}
>
Search by ID
</Menu.Item>
<Menu.Item
leftSection={<IconTableOptions size='1rem' />}
onClick={() => setModals('table', !modals.table)}
onClick={() => setModals({ table: !modals.table })}
>
Table Options
</Menu.Item>
@@ -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<DashboardFilesModals>;
setModals: DashboardFilesModalsUpdate;
}) {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [selectedTag, setSelectedTag] = useState<Tag | null>(null);
@@ -55,7 +54,7 @@ export default function TagsModals({
<Modal
opened={modals.tags}
onClose={() => setModals('tags', false)}
onClose={() => setModals({ tags: false })}
title={
<Group>
<Title>Tags</Title>
@@ -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,
@@ -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,
@@ -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<DashboardFilesModals>;
setModals?: UpdateFn<DashboardFilesModals>;
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 && (
<TableEditModal opened={!!modals.table} onClose={() => setModals('table', false)} />
<TableEditModal opened={!!modals.table} onClose={() => setModals({ table: false })} />
)}
<Box>
@@ -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,
+5 -9
View File
@@ -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<typeof loader>();
const view = useViewStore((state) => state.files);
const [modals, setModals] = useObjectState<Partial<DashboardFilesModals>>({
table: false,
idSearch: false,
});
const [modals, setModals] = useModals();
if (!data) return;
@@ -34,13 +30,13 @@ export default function ViewUserFiles() {
</Tooltip>
<Tooltip label='Table Options'>
<ActionIcon variant='outline' onClick={() => setModals('table', !modals.table)}>
<ActionIcon variant='outline' onClick={() => setModals({ table: !modals.table })}>
<IconTableOptions size='1rem' />
</ActionIcon>
</Tooltip>
<Tooltip label='Search by ID'>
<ActionIcon variant='outline' onClick={() => setModals('idSearch', !modals.idSearch)}>
<ActionIcon variant='outline' onClick={() => setModals({ idSearch: !modals.idSearch })}>
<IconGridPatternFilled size='1rem' />
</ActionIcon>
</Tooltip>
-37
View File
@@ -1,37 +0,0 @@
import { useSearchParams } from 'react-router-dom';
function parseValue<T>(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<T>(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];
}