diff --git a/apps/cms/src/app/(main)/[workspace]/(dashboard)/media/page-client.tsx b/apps/cms/src/app/(main)/[workspace]/(dashboard)/media/page-client.tsx index b474b4ac..762209d6 100644 --- a/apps/cms/src/app/(main)/[workspace]/(dashboard)/media/page-client.tsx +++ b/apps/cms/src/app/(main)/[workspace]/(dashboard)/media/page-client.tsx @@ -2,7 +2,6 @@ import { toast } from "@marble/ui/components/sonner"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; -import { parseAsStringLiteral, useQueryState } from "nuqs"; import { useEffect, useState } from "react"; import { WorkspacePageWrapper } from "@/components/layout/wrapper"; import { MediaControls } from "@/components/media/media-controls"; @@ -10,27 +9,16 @@ import { MediaGallery } from "@/components/media/media-gallery"; import PageLoader from "@/components/shared/page-loader"; import { useMediaActions } from "@/hooks/use-media-actions"; import { useWorkspaceId } from "@/hooks/use-workspace-id"; -import { MEDIA_FILTER_TYPES, MEDIA_LIMIT, MEDIA_SORTS } from "@/lib/constants"; +import { MEDIA_FILTER_TYPES, MEDIA_SORTS } from "@/lib/constants"; import { uploadFile } from "@/lib/media/upload"; import { QUERY_KEYS } from "@/lib/queries/keys"; -import type { - MediaFilterType, - MediaListResponse, - MediaQueryKey, - MediaSort, -} from "@/types/media"; +import { getMediaApiUrl, useMediaPageFilters } from "@/lib/search-params"; +import type { MediaListResponse, MediaQueryKey } from "@/types/media"; import { toMediaType } from "@/utils/media"; function PageClient() { const workspaceId = useWorkspaceId(); - const [type, setType] = useQueryState( - "type", - parseAsStringLiteral(MEDIA_FILTER_TYPES).withDefault("all") - ); - const [sort, setSort] = useQueryState( - "sort", - parseAsStringLiteral(MEDIA_SORTS).withDefault("createdAt_desc") - ); + const [{ type, sort }] = useMediaPageFilters(); const normalizedType = toMediaType(type); const [selectedItems, setSelectedItems] = useState>(new Set()); const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); @@ -52,18 +40,13 @@ function PageClient() { ], queryFn: async ({ pageParam }: { pageParam?: string }) => { try { - const params = new URLSearchParams(); - params.set("limit", String(MEDIA_LIMIT)); - params.set("sort", sort); - - if (normalizedType) { - params.set("type", normalizedType); - } - if (pageParam) { - params.set("cursor", pageParam); - } + const url = getMediaApiUrl("/api/media", { + sort, + type: normalizedType, + cursor: pageParam, + }); - const res = await fetch(`/api/media?${params}`); + const res = await fetch(url); if (!res.ok) { throw new Error( `Failed to fetch media: ${res.status} ${res.statusText}` @@ -109,17 +92,13 @@ function PageClient() { { type: normalizedType, sort: sortOption }, ], queryFn: async ({ pageParam }: { pageParam?: string }) => { - const params = new URLSearchParams(); - params.set("limit", String(MEDIA_LIMIT)); - params.set("sort", sortOption); - if (normalizedType) { - params.set("type", normalizedType); - } - if (pageParam) { - params.set("cursor", pageParam); - } + const url = getMediaApiUrl("/api/media", { + sort: sortOption, + type: normalizedType, + cursor: pageParam, + }); - const res = await fetch(`/api/media?${params}`); + const res = await fetch(url); if (!res.ok) { throw new Error( `Failed to prefetch media: ${res.status} ${res.statusText}` @@ -234,10 +213,6 @@ function PageClient() { onSelectAll={handleSelectAll} onUpload={handleFileUpload} selectedItems={selectedItems} - setSort={setSort} - setType={setType} - sort={sort} - type={type} /> )} void; - sort: MediaSort; - setSort: (value: MediaSort) => void; onUpload: (files: FileList) => void; isUploading: boolean; selectedItems: Set; @@ -42,13 +35,14 @@ export function MediaControls({ onBulkDelete: () => void; mediaLength: number; }) { + const [{ type, sort }, setSearchParams] = useMediaPageFilters(); return (
{ if (isMediaSort(val)) { - setSort(val); + setSearchParams({ sort: val }); } }} value={sort} diff --git a/apps/cms/src/lib/constants.ts b/apps/cms/src/lib/constants.ts index 00e8bda2..7bed26a9 100644 --- a/apps/cms/src/lib/constants.ts +++ b/apps/cms/src/lib/constants.ts @@ -190,12 +190,12 @@ export const PLATFORM_DOMAINS = { bluesky: ["bsky.app"], } as const; -export const MEDIA_SORTS = [ - "createdAt_desc", - "createdAt_asc", - "name_asc", - "name_desc", -]; +export const MEDIA_SORT_BY = ["createdAt", "name"] as const; +export const SORT_DIRECTIONS = ["asc", "desc"] as const; + +export const MEDIA_SORTS = MEDIA_SORT_BY.flatMap((field) => + SORT_DIRECTIONS.map((direction) => `${field}_${direction}` as const) +); export const MEDIA_TYPES = ["image", "video", "audio", "document"] as const; diff --git a/apps/cms/src/lib/search-params.ts b/apps/cms/src/lib/search-params.ts new file mode 100644 index 00000000..24d2b34c --- /dev/null +++ b/apps/cms/src/lib/search-params.ts @@ -0,0 +1,60 @@ +import { useQueryStates } from "nuqs"; +import { + createLoader, + createParser, + createSerializer, + type inferParserType, + type Options, + parseAsInteger, + parseAsString, + parseAsStringLiteral, +} from "nuqs/server"; +import { + MEDIA_FILTER_TYPES, + MEDIA_LIMIT, + MEDIA_SORT_BY, + MEDIA_TYPES, + SORT_DIRECTIONS, +} from "./constants"; + +function parseAsSort(fields: readonly Field[]) { + const parseAsField = parseAsStringLiteral(fields); + const parseAsDirection = parseAsStringLiteral(SORT_DIRECTIONS); + return createParser({ + parse(query) { + const [field = "", direction = ""] = query.split("_"); + const parsedField = parseAsField.parse(field); + const parsedDirection = parseAsDirection.parse(direction); + if (!parsedField || !parsedDirection) { + return null; + } + return `${parsedField}_${parsedDirection}` as const; + }, + serialize: String, + }); +} + +const sortParser = parseAsSort(MEDIA_SORT_BY).withDefault("createdAt_desc"); + +// Page level search params +const mediaPageSearchParams = { + sort: sortParser, + type: parseAsStringLiteral(MEDIA_FILTER_TYPES).withDefault("all"), +}; + +export const useMediaPageFilters = (options: Options = {}) => + useQueryStates(mediaPageSearchParams, options); + +// React Query API endpoint level search params +const mediaApiSearchParams = { + sort: sortParser, + type: parseAsStringLiteral(MEDIA_TYPES), + cursor: parseAsString, + limit: parseAsInteger.withDefault(MEDIA_LIMIT), +}; + +export const loadMediaApiFilters = createLoader(mediaApiSearchParams); + +export const getMediaApiUrl = createSerializer(mediaApiSearchParams, { + clearOnDefault: false, +}); diff --git a/apps/cms/src/lib/validations/upload.ts b/apps/cms/src/lib/validations/upload.ts index c5bd32b0..5378b254 100644 --- a/apps/cms/src/lib/validations/upload.ts +++ b/apps/cms/src/lib/validations/upload.ts @@ -7,7 +7,6 @@ import { MAX_AVATAR_FILE_SIZE, MAX_LOGO_FILE_SIZE, MAX_MEDIA_FILE_SIZE, - MEDIA_LIMIT, } from "@/lib/constants"; import type { UploadType } from "@/types/media"; @@ -139,15 +138,6 @@ export const completeSchema = z.union([ completeMediaSchema, ]); -export const GetSchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).default(MEDIA_LIMIT), - cursor: z.string().min(1).optional(), - type: z.enum(["image", "video", "audio", "document"]).optional(), - sort: z - .enum(["createdAt_desc", "createdAt_asc", "name_asc", "name_desc"]) - .default("createdAt_desc"), -}); - export const DeleteSchema = z .object({ mediaId: z.string().optional(), diff --git a/apps/cms/src/utils/media.ts b/apps/cms/src/utils/media.ts index e7043ce1..fbe5a303 100644 --- a/apps/cms/src/utils/media.ts +++ b/apps/cms/src/utils/media.ts @@ -1,4 +1,5 @@ -import type { MediaFilterType, MediaType } from "@/types/media"; +import type { MEDIA_SORT_BY, SORT_DIRECTIONS } from "@/lib/constants"; +import type { MediaFilterType, MediaSort, MediaType } from "@/types/media"; export function getMediaType(mimeType: string): MediaType { if (mimeType.startsWith("image/")) { @@ -31,13 +32,21 @@ export function getEmptyStateMessage(type?: MediaType, hasAnyMedia?: boolean) { } } -export function isMediaSort(value: string) { +export function isMediaSort(value: string): value is MediaSort { // defer to constants list at call sites where needed; this keeps util generic return ["createdAt_desc", "createdAt_asc", "name_asc", "name_desc"].includes( value ); } +export function splitMediaSort(sort: MediaSort) { + const [field, direction] = sort.split("_") as [ + (typeof MEDIA_SORT_BY)[number], + (typeof SORT_DIRECTIONS)[number], + ]; + return { field, direction }; +} + export function isMediaFilterType( value: MediaFilterType ): value is MediaFilterType {