Skip to content

Commit 7241298

Browse files
✨ feat: add filters to uploads page
Adds a filter to the uploads page that allows users to filter uploads by the author. - Added Filter component to uploads page - Updated fetchUploads query to support authorId filtering - Updated page component to pass projectUsers and currentUserId to Uploads component
1 parent f6d49ca commit 7241298

3 files changed

Lines changed: 278 additions & 14 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"use client";
2+
import * as React from "react";
3+
import { CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons";
4+
5+
import { cn } from "@/lib/utils";
6+
import { Badge } from "@/components/ui/badge";
7+
import { Button } from "@/components/ui/button";
8+
import {
9+
Command,
10+
CommandEmpty,
11+
CommandGroup,
12+
CommandInput,
13+
CommandItem,
14+
CommandList,
15+
CommandSeparator,
16+
} from "@/components/ui/command";
17+
import {
18+
Popover,
19+
PopoverContent,
20+
PopoverTrigger,
21+
} from "@/components/ui/popover";
22+
import { Separator } from "@/components/ui/separator";
23+
24+
interface FilterProps<TData, TValue> {
25+
title?: string;
26+
options: {
27+
label: string;
28+
value: string;
29+
icon?: React.ComponentType<{ className?: string }>;
30+
}[];
31+
onChange: (value: string[]) => void;
32+
defaultValue: string[] | undefined;
33+
}
34+
35+
export function Filter<TData, TValue>({
36+
title,
37+
options,
38+
onChange,
39+
defaultValue = [],
40+
}: FilterProps<TData, TValue>) {
41+
const [selectedValues, setSelectedValues] = React.useState(new Set(defaultValue));
42+
43+
return (
44+
<Popover>
45+
<PopoverTrigger asChild>
46+
<Button variant="outline" size="sm" className="h-8 border-dashed">
47+
<PlusCircledIcon className="mr-2 h-4 w-4" />
48+
{title}
49+
{selectedValues?.size > 0 && (
50+
<>
51+
<Separator orientation="vertical" className="mx-2 h-4" />
52+
<Badge
53+
variant="secondary"
54+
className="rounded-sm px-1 font-normal lg:hidden"
55+
>
56+
{selectedValues.size}
57+
</Badge>
58+
<div className="hidden space-x-1 lg:flex">
59+
{selectedValues.size > 2 ? (
60+
<Badge
61+
variant="secondary"
62+
className="rounded-sm px-1 font-normal"
63+
>
64+
{selectedValues.size} selected
65+
</Badge>
66+
) : (
67+
options
68+
.filter((option) => selectedValues.has(option.value))
69+
.map((option) => (
70+
<Badge
71+
variant="secondary"
72+
key={option.value}
73+
className="rounded-sm px-1 font-normal"
74+
>
75+
{option.label}
76+
</Badge>
77+
))
78+
)}
79+
</div>
80+
</>
81+
)}
82+
</Button>
83+
</PopoverTrigger>
84+
<PopoverContent className="w-[200px] p-0" align="start">
85+
<Command>
86+
<CommandInput placeholder={title} />
87+
<CommandList>
88+
<CommandEmpty>No results found.</CommandEmpty>
89+
<CommandGroup>
90+
{options.map((option) => {
91+
const isSelected = selectedValues.has(option.value);
92+
return (
93+
<CommandItem
94+
key={option.value}
95+
onSelect={() => {
96+
if (isSelected) {
97+
selectedValues.delete(option.value);
98+
} else {
99+
selectedValues.add(option.value);
100+
}
101+
const filterValues = Array.from(selectedValues);
102+
setSelectedValues(new Set(filterValues));
103+
onChange(filterValues as string[]);
104+
}}
105+
>
106+
<div
107+
className={cn(
108+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
109+
isSelected
110+
? "bg-primary text-primary-foreground"
111+
: "opacity-50 [&_svg]:invisible"
112+
)}
113+
>
114+
<CheckIcon className={cn("h-4 w-4")} />
115+
</div>
116+
{option.icon && (
117+
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
118+
)}
119+
<span>{option.label}</span>
120+
</CommandItem>
121+
);
122+
})}
123+
</CommandGroup>
124+
{selectedValues.size > 0 && (
125+
<>
126+
<CommandSeparator />
127+
<CommandGroup>
128+
<CommandItem
129+
onSelect={() => {
130+
setSelectedValues(new Set());
131+
onChange([]);
132+
}}
133+
className="justify-center text-center"
134+
>
135+
Clear filters
136+
</CommandItem>
137+
</CommandGroup>
138+
</>
139+
)}
140+
</CommandList>
141+
</Command>
142+
</PopoverContent>
143+
</Popover>
144+
);
145+
}

apps/web/app/(dashboard)/app/Uploads.tsx

Lines changed: 92 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import { ArrowDownIcon } from "lucide-react";
77
import Link from "next/link";
88
import React, { useRef, useState } from "react";
99
import { AiOutlineDownload, AiOutlineVideoCameraAdd } from "react-icons/ai";
10+
import { Filter } from "./Filter";
11+
import { Role } from "@prisma/client";
12+
import { toast } from "sonner";
13+
import { queryClient } from "@/components/TanQueryProvider";
1014

1115
interface Upload {
1216
id: string;
@@ -25,10 +29,65 @@ interface Upload {
2529
deviceId: string;
2630
}
2731

28-
const fetchUploads = async ({ pageParam = { offset: 0, limit: 10 } }) => {
32+
interface Filters {
33+
authorIds: string[];
34+
}
35+
interface UploadsProps {
36+
projectId: string;
37+
projectUsers: {
38+
id: string;
39+
name: string;
40+
role: Role;
41+
}[];
42+
currentUserId: string;
43+
isUserOwner: boolean;
44+
}
45+
46+
interface FetchUploadsType {
47+
pageParam: { offset: number; limit: number };
48+
projectId: string;
49+
authorIds?: string[] | null;
50+
}
51+
52+
const fetchUploads = async ({
53+
pageParam = { offset: 0, limit: 10 },
54+
projectId,
55+
authorIds,
56+
}: FetchUploadsType) => {
2957
const { offset, limit } = pageParam;
30-
const res = await fetch(`/api/uploads?offset=${offset}&limit=${limit}`);
31-
if (!res.ok) throw new Error("Network response was not ok");
58+
// Prepare the request body
59+
const requestBody = {
60+
offset,
61+
limit,
62+
projectId,
63+
authorIds, // Ensure this matches the expected API parameter for author IDs
64+
};
65+
const res = await fetch(`/api/uploads/search`, {
66+
method: "POST",
67+
headers: {
68+
"Content-Type": "application/json",
69+
},
70+
body: JSON.stringify(requestBody),
71+
});
72+
if (!res.ok) {
73+
const error = await res.json();
74+
const errorMessage = error.error ?? error.message ?? "Could not filter.";
75+
console.log({ error, errorMessage });
76+
77+
if (res.status === 403) {
78+
// If a 403 error is encountered, cancel future refetches for this query
79+
queryClient.cancelQueries({
80+
queryKey: ["uploads", projectId, authorIds],
81+
});
82+
// Set a specific state in your query data to indicate a 403 error was encountered. Allows your UI to react accordingly
83+
queryClient.setQueryData(["uploads", projectId, authorIds], {
84+
error: "Forbidden",
85+
});
86+
}
87+
88+
toast.error(`${errorMessage}`);
89+
throw new Error("Network response was not ok");
90+
}
3291
const data = (await res.json()) as { uploads: Upload[]; total: number };
3392
const hasMore = offset + limit < data.total;
3493
return {
@@ -37,13 +96,21 @@ const fetchUploads = async ({ pageParam = { offset: 0, limit: 10 } }) => {
3796
};
3897
};
3998

40-
export default function Uploads() {
99+
export default function Uploads({
100+
projectId,
101+
projectUsers,
102+
currentUserId,
103+
isUserOwner,
104+
}: UploadsProps) {
105+
const [filters, setFilters] = useState<Filters>({ authorIds: [] });
106+
41107
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching } =
42108
useInfiniteQuery({
43-
queryKey: ["uploads"],
44-
queryFn: fetchUploads,
109+
queryKey: ["uploads", projectId, filters.authorIds],
110+
queryFn: ({ pageParam }) =>
111+
fetchUploads({ pageParam, projectId, authorIds: filters.authorIds }),
45112
getNextPageParam: (lastPage) => lastPage.nextPage,
46-
initialPageParam: undefined,
113+
initialPageParam: { offset: 0, limit: 10 },
47114
});
48115

49116
const observer = useRef<IntersectionObserver>();
@@ -65,14 +132,28 @@ export default function Uploads() {
65132
const allUploads = data?.pages.flatMap((page) => page.uploads) || [];
66133

67134
return (
68-
<main className="flex-1 p-4 rounded overflow-hidden">
135+
<main className="flex-1 p-2 rounded overflow-hidden">
136+
{isUserOwner && (
137+
<div className="flex flex-1 items-center space-x-2 pb-2">
138+
<Filter
139+
title="Created By"
140+
options={projectUsers.map((user) => ({
141+
label: user.name,
142+
value: user.id,
143+
}))}
144+
onChange={(value: string[]) => setFilters({ authorIds: value })}
145+
defaultValue={[currentUserId]}
146+
/>
147+
</div>
148+
)}
149+
69150
{!isFetchingNextPage && !isFetching && allUploads.length === 0 && (
70151
<NoUploads />
71152
)}
72153
<div className="grid grid-cols-3 gap-4">
73-
{allUploads.map((upload) => (
74-
upload && <Upload upload={upload} key={upload.id} />
75-
))}
154+
{allUploads.map(
155+
(upload) => upload && <Upload upload={upload} key={upload.id} />
156+
)}
76157
{(isFetchingNextPage || isFetching) && <UploadSkeleton quantity={10} />}
77158
</div>
78159

apps/web/app/(dashboard)/app/page.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { authOptions } from "@/app/api/auth/[...nextauth]/AuthOptions";
2-
import { PrismaClient } from "@prisma/client";
2+
import { Role } from "@prisma/client";
33
import { getServerSession } from "next-auth";
44
import { formatDistanceToNow } from "date-fns";
55
import Link from "next/link";
@@ -58,6 +58,39 @@ export default async function Dashboard() {
5858
return;
5959
};
6060

61+
const isUserOwner = !!(await prisma.projectUsers.findFirst({
62+
where: {
63+
projectId: currentProjectId,
64+
userId,
65+
role: Role.owner,
66+
},
67+
})) ?? false;
68+
69+
const projectUsers = isUserOwner
70+
? await prisma.projectUsers
71+
.findMany({
72+
where: {
73+
projectId: currentProjectId,
74+
},
75+
include: {
76+
user: {
77+
select: {
78+
name: true,
79+
},
80+
},
81+
},
82+
})
83+
.then((users) => {
84+
return users.map((user) => {
85+
return {
86+
id: user.userId,
87+
name: user.user?.name || "",
88+
role: user.role,
89+
};
90+
});
91+
})
92+
: [];
93+
6194
return (
6295
<section className="relative">
6396
<div className="relative max-w-6xl mx-auto px-4 sm:px-6">
@@ -74,11 +107,16 @@ export default async function Dashboard() {
74107
className="flex flex-col rounded overflow-hidden bg-slate-800 bg-opacity-60 "
75108
>
76109
<header className="rounded overflow-hidden">
77-
<div className="max-w-7xl mx-auto flex items-center justify-between p-4">
110+
<div className="max-w-7xl mx-auto flex items-center justify-between px-4 pt-4">
78111
<h1 className="text-md font-semibold">Video Library</h1>
79112
</div>
80113
</header>
81-
<Uploads />
114+
<Uploads
115+
projectId={currentProjectId}
116+
projectUsers={projectUsers}
117+
currentUserId={userId}
118+
isUserOwner={isUserOwner}
119+
/>
82120
</div>
83121
</div>
84122
</div>

0 commit comments

Comments
 (0)