Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,23 @@

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";
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<MediaFilterType>(
"type",
parseAsStringLiteral(MEDIA_FILTER_TYPES).withDefault("all")
);
const [sort, setSort] = useQueryState<MediaSort>(
"sort",
parseAsStringLiteral(MEDIA_SORTS).withDefault("createdAt_desc")
);
const [{ type, sort }] = useMediaPageFilters();
const normalizedType = toMediaType(type);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
Expand All @@ -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}`
Expand Down Expand Up @@ -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}`
Expand Down Expand Up @@ -234,10 +213,6 @@ function PageClient() {
onSelectAll={handleSelectAll}
onUpload={handleFileUpload}
selectedItems={selectedItems}
setSort={setSort}
setType={setType}
sort={sort}
type={type}
/>
)}
<MediaGallery
Expand Down
23 changes: 9 additions & 14 deletions apps/cms/src/app/api/media/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
import { db } from "@marble/db";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@/lib/auth/session";
import { R2_BUCKET_NAME, r2 } from "@/lib/r2";
import { DeleteSchema, GetSchema } from "@/lib/validations/upload";
import { loadMediaApiFilters } from "@/lib/search-params";
import { DeleteSchema } from "@/lib/validations/upload";
import { getWebhooks, WebhookClient } from "@/lib/webhooks/webhook-client";
import { splitMediaSort } from "@/utils/media";

export async function GET(request: Request) {
const sessionData = await getServerSession();
Expand All @@ -22,20 +25,12 @@ export async function GET(request: Request) {
);
}

const { searchParams } = new URL(request.url);
const parsed = GetSchema.safeParse(Object.fromEntries(searchParams));

if (!parsed.success) {
return NextResponse.json({ error: parsed.error.message }, { status: 400 });
const filters = loadMediaApiFilters(request, { strict: true });
if (!z.number().int().min(1).max(100).safeParse(filters.limit).success) {
return NextResponse.json({ error: "Invalid limit" }, { status: 400 });
}

const { limit, cursor, type, sort } = parsed.data;

// Break sort option into field + direction (e.g. "createdAt_desc")
const [field, direction] = sort.split("_") as [
"createdAt" | "name",
"asc" | "desc",
];
const { field, direction } = splitMediaSort(filters.sort);
const { limit, cursor, type } = filters;
const orderBy = [{ [field]: direction }, { id: direction }];

try {
Expand Down
16 changes: 5 additions & 11 deletions apps/cms/src/components/media/media-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,12 @@ import {
TooltipTrigger,
} from "@marble/ui/components/tooltip";
import { PlusIcon, TrashIcon, XIcon } from "@phosphor-icons/react";
import type { MediaFilterType, MediaSort } from "@/types/media";
import { useMediaPageFilters } from "@/lib/search-params";
import type { MediaFilterType } from "@/types/media";
import { isMediaFilterType, isMediaSort } from "@/utils/media";
import { FileUploadInput } from "./file-upload-input";

export function MediaControls({
type,
setType,
sort,
setSort,
onUpload,
isUploading,
selectedItems,
Expand All @@ -30,10 +27,6 @@ export function MediaControls({
onBulkDelete,
mediaLength,
}: {
type: MediaFilterType;
setType: (value: MediaFilterType) => void;
sort: MediaSort;
setSort: (value: MediaSort) => void;
onUpload: (files: FileList) => void;
isUploading: boolean;
selectedItems: Set<string>;
Expand All @@ -42,13 +35,14 @@ export function MediaControls({
onBulkDelete: () => void;
mediaLength: number;
}) {
const [{ type, sort }, setSearchParams] = useMediaPageFilters();
return (
<section className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex flex-wrap items-center gap-1 sm:gap-4">
<Select
onValueChange={(val: MediaFilterType) => {
if (isMediaFilterType(val)) {
setType(val);
setSearchParams({ type: val });
}
}}
value={type}
Expand All @@ -65,7 +59,7 @@ export function MediaControls({
<Select
onValueChange={(val: string) => {
if (isMediaSort(val)) {
setSort(val);
setSearchParams({ sort: val });
}
}}
value={sort}
Expand Down
12 changes: 6 additions & 6 deletions apps/cms/src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
60 changes: 60 additions & 0 deletions apps/cms/src/lib/search-params.ts
Original file line number Diff line number Diff line change
@@ -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<const Field extends string>(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,
});
10 changes: 0 additions & 10 deletions apps/cms/src/lib/validations/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(),
Expand Down
13 changes: 11 additions & 2 deletions apps/cms/src/utils/media.ts
Original file line number Diff line number Diff line change
@@ -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/")) {
Expand Down Expand Up @@ -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 {
Expand Down