diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 66ee0f52..d5198284 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -17,7 +17,13 @@ const config = { protocol: "https", hostname: "yt3.ggpht.com", port: "", - pathname: "/ytc/**", + pathname: "/**", + }, + { + protocol: "https", + hostname: "i.ytimg.com", + port: "", + pathname: "/vi/**", }, ], }, diff --git a/apps/web/package.json b/apps/web/package.json index 4d3d8704..33b7726e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,9 +36,11 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", "@t3-oss/env-nextjs": "^0.10.1", + "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^5.51.23", "@tanstack/react-query-devtools": "^5.51.23", "@total-typescript/ts-reset": "^0.5.1", + "@uidotdev/usehooks": "^2.4.1", "@vercel/postgres": "^0.9.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/apps/web/src/app/api/organizations/[name]/projects/route.ts b/apps/web/src/app/api/organizations/[name]/projects/route.ts index 128fcf53..779652de 100644 --- a/apps/web/src/app/api/organizations/[name]/projects/route.ts +++ b/apps/web/src/app/api/organizations/[name]/projects/route.ts @@ -6,7 +6,7 @@ import { } from "~/server/actions/organization"; export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: { name: string } }, ) { const [organization, err] = await getOwnOrganizationByName(params.name); @@ -22,7 +22,23 @@ export async function GET( ); } - const projects = await getOrganizationProjects(organization.id); + const page = req.nextUrl.searchParams.get("page"); + if (page === null) { + return NextResponse.json( + { message: "Page query param is required." }, + { status: 400 }, + ); + } + + const query = req.nextUrl.searchParams.get("q"); + if (query === null) { + return NextResponse.json( + { message: "Q query param is required." }, + { status: 400 }, + ); + } + + const projectsResponse = await getOrganizationProjects(organization.id, +page, query); - return NextResponse.json(projects); + return NextResponse.json(projectsResponse); } diff --git a/apps/web/src/app/api/organizations/[name]/projects/video/[id]/route.ts b/apps/web/src/app/api/organizations/[name]/projects/video/[id]/route.ts new file mode 100644 index 00000000..2f32c74c --- /dev/null +++ b/apps/web/src/app/api/organizations/[name]/projects/video/[id]/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isUserInOrganization } from "~/server/api/utils/organizations"; +import { getVideo } from "~/server/api/utils/project"; +import { auth } from "~/server/auth"; + +export async function GET( + req: NextRequest, + { params }: { params: { id: string; name: string } }, +) { + const session = await auth(); + + const isUserAuthorized = + session?.user && (await isUserInOrganization(session.user.id, params.name)); + + if (!isUserAuthorized) { + return NextResponse.json({ message: "UNAUTHORIZED" }, { status: 401 }); + } + + const [video, err] = await getVideo(params.name, params.id); + + if (err !== null) { + return NextResponse.json( + { message: "Something went wrong on our end", cause: err }, + { status: 500 }, + ); + } + + return NextResponse.json(video); +} diff --git a/apps/web/src/app/api/youtube/channel/[id]/route.ts b/apps/web/src/app/api/youtube/channel/[id]/route.ts new file mode 100644 index 00000000..9ba25bc2 --- /dev/null +++ b/apps/web/src/app/api/youtube/channel/[id]/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getChannel } from "~/server/api/utils/project"; +import { auth } from "~/server/auth"; + +export async function GET( + req: NextRequest, + { params }: { params: { id: string } }, +) { + const session = auth(); + + if (!session) { + return NextResponse.json({ message: "UNAUTHORIZED" }, { status: 401 }); + } + + const [categories, err] = await getChannel(params.id); + + if (err !== null) { + return NextResponse.json( + { message: "Something went wrong on our end", cause: err }, + { status: 500 }, + ); + } + + return NextResponse.json(categories); +} diff --git a/apps/web/src/app/dashboard/[name]/overview/page.tsx b/apps/web/src/app/dashboard/[name]/overview/page.tsx index 195c41ad..f3fc6acf 100644 --- a/apps/web/src/app/dashboard/[name]/overview/page.tsx +++ b/apps/web/src/app/dashboard/[name]/overview/page.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; import { Suspense } from "react"; import ProjectGrid from "~/components/dashboard/project-grid"; +import ProjectsSkeleton from "~/components/dashboard/project-grid-skeleton"; +import ProjectPagination from "~/components/dashboard/project-pagination"; import SearchNavigation from "~/components/dashboard/search-navigation"; import { getOwnOrganizations } from "~/server/actions/organization"; @@ -23,12 +25,16 @@ export default async function DashboardOverviewPage({ const organization = organizations.find((org) => name === org.name); return organization ? ( -
+
- Loading...
}> - - + + +
+ }> + + +
) : ( redirect("/404") diff --git a/apps/web/src/components/create-project/project-form.tsx b/apps/web/src/components/create-project/project-form.tsx index d91f6720..995413ed 100644 --- a/apps/web/src/components/create-project/project-form.tsx +++ b/apps/web/src/components/create-project/project-form.tsx @@ -53,7 +53,7 @@ const defaultValues: InsertProject = { defaultLanguage: "none", tags: "", embeddable: true, - privacyStatus: "private", + privacyStatus: "unlisted", publicStatsViewable: true, selfDeclaredMadeForKids: false, notifySubscribers: true, diff --git a/apps/web/src/components/dashboard/project-card.tsx b/apps/web/src/components/dashboard/project-card.tsx index f5fb4251..e3281b3f 100644 --- a/apps/web/src/components/dashboard/project-card.tsx +++ b/apps/web/src/components/dashboard/project-card.tsx @@ -1,27 +1,53 @@ -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; +"use client"; + +import Image from "next/image"; +import Link from "next/link"; import type { Project } from "~/lib/validators/project"; +import { useVideoSuspenseQuery } from "~/lib/queries/useVideoQuery"; +import { useParams } from "next/navigation"; +import { useChannelSuspenseQuery } from "~/lib/queries/useChannelQuery"; interface ProjectCardProps { project: Project; } export default function VideoCard({ project }: ProjectCardProps) { + const { name } = useParams() as { name: string }; + + const { data: video } = useVideoSuspenseQuery(project.videoId!, name); + const { data: channel } = useChannelSuspenseQuery(project.channelId); + return ( - - - - {project.title} - +
+ +
+ project thumbnail +
+ +
+ channel thumbnail - - {project.description} - - - +
+

{project.title}

+

{channel.snippet?.title}

+
+
+ +
); } diff --git a/apps/web/src/components/dashboard/project-grid-skeleton.tsx b/apps/web/src/components/dashboard/project-grid-skeleton.tsx new file mode 100644 index 00000000..f85505ac --- /dev/null +++ b/apps/web/src/components/dashboard/project-grid-skeleton.tsx @@ -0,0 +1,33 @@ +import { Skeleton } from "../ui/skeleton"; + +export default function ProjectsSkeleton() { + return ( + <> + {(() => { + const projects = []; + for (let i = 0; i < 12; i++) { + projects.push( +
+ + +
+ + +
+ + + +
+
+
, + ); + } + + return projects; + })()} + + ); +} diff --git a/apps/web/src/components/dashboard/project-grid.tsx b/apps/web/src/components/dashboard/project-grid.tsx index 76b1695e..cd9cbe5b 100644 --- a/apps/web/src/components/dashboard/project-grid.tsx +++ b/apps/web/src/components/dashboard/project-grid.tsx @@ -1,20 +1,16 @@ "use client"; import Link from "next/link"; -import ProjectSmallCard from "./project-small-card"; import ProjectCard from "./project-card"; import type { Organization } from "~/lib/validators/organization"; -import { useSuspenseQuery } from "@tanstack/react-query"; -import { z } from "zod"; -import { projectSchema } from "~/lib/validators/project"; -import { env } from "~/env"; import Image from "next/image"; -import { useSearchParams } from "next/navigation"; -import ky from "ky"; +import { usePathname, useSearchParams, useRouter } from "next/navigation"; +import { useProjectsPaginatedQuery } from "~/lib/queries/useProjectsQuery"; +import ProjectsSkeleton from "./project-grid-skeleton"; function EmptyProjectsInfo() { return ( -
+
suprised pikachu { - const res = await ky - .get( - `${env.NEXT_PUBLIC_API_URL}/api/organizations/${organization.name}/projects`, - ) - .json(); - - const data = z.array(projectSchema).safeParse(res); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); - if (data.error) { - throw data.error; - } + const page = searchParams.get("page"); + if (page === null || isNaN(parseInt(page))) { + const params = new URLSearchParams(searchParams); + params.set("page", "1"); - return data.data; - }, - }); + router.push(pathname + "?" + params.toString()); + } - const searchParams = useSearchParams(); + const query = searchParams.get("q"); + if (query === null) { + const params = new URLSearchParams(searchParams); + params.set("q", ""); - const listDisplayType = searchParams.get("listType"); + router.push(pathname + "?" + params.toString()); + } - return projects.length === 0 ? ( - - ) : listDisplayType === null || searchParams.get("listType") === "list" ? ( -
- {projects.map((project) => ( - - ))} -
- ) : ( -
- {projects.map((project) => ( - - ))} -
+ const { data } = useProjectsPaginatedQuery( + organization.name, + page ? +page : 0, + query ?? "", ); + + if (!data?.projects) { + return ; + } + + if (data.projects?.length === 0) { + return ; + } + + return data.projects.map((project) => ( + + )); } diff --git a/apps/web/src/components/dashboard/project-pagination.tsx b/apps/web/src/components/dashboard/project-pagination.tsx new file mode 100644 index 00000000..0d47f7da --- /dev/null +++ b/apps/web/src/components/dashboard/project-pagination.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "../ui/pagination"; +import type { Organization } from "~/lib/validators/organization"; +import { useProjectsPaginatedQuery } from "~/lib/queries/useProjectsQuery"; + +interface ProjectPaginationProps { + organizationName: Organization["name"]; +} + +export default function ProjectPagination({ + organizationName, +}: ProjectPaginationProps) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const page = searchParams.get("page"); + if (page === null || isNaN(parseInt(page))) { + const params = new URLSearchParams(searchParams); + params.set("page", "1"); + + router.push(pathname + "?" + params.toString()); + } + + const query = searchParams.get("q"); + + const { data } = useProjectsPaginatedQuery(organizationName, +page!, query!); + + function changePage(newPage: number) { + const params = new URLSearchParams(searchParams); + params.set("page", newPage.toString()); + + router.push(pathname + "?" + params.toString()); + } + + return ( + data && + data.projects.length !== 0 && + data.total > 12 && ( + + + + { + if (+page! > 1) changePage(+page! - 1); + }} + /> + + +
+ + { + changePage(1); + }} + > + 1 + + + + {+page! > 4 && ( + + + + )} + + {(() => { + const pages = []; + let leftBoundry = +page! - 2 > 1 ? +page! - 2 : 2; + let rightBoundry = + +page! + 2 < Math.ceil(data.total / 12) + ? +page! + 2 + : Math.ceil(data.total / 12); + + if (rightBoundry - leftBoundry < 4) { + if (leftBoundry === 2) { + rightBoundry = Math.min(7, Math.ceil(data.total / 12)); + } else { + leftBoundry = Math.max(2, Math.ceil(data.total / 12) - 6); + } + } + + for (let i = leftBoundry; i <= rightBoundry; i++) { + if (i > 1 && i < Math.ceil(data.total / 12)) { + pages.push( + + { + changePage(i); + }} + > + {i} + + , + ); + } + } + return pages; + })()} + + {+page! < Math.floor(data.total / 12) - 1 && ( + + + + )} + + + { + changePage(Math.ceil(data.total / 12)); + }} + > + {Math.ceil(data.total / 12)} + + +
+ + + { + if (data.hasNextPage) changePage(+page! + 1); + }} + /> + +
+
+ ) + ); +} diff --git a/apps/web/src/components/dashboard/project-small-card.tsx b/apps/web/src/components/dashboard/project-small-card.tsx deleted file mode 100644 index 200b9ca2..00000000 --- a/apps/web/src/components/dashboard/project-small-card.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { - Card, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import type { Project } from "~/lib/validators/project"; - -interface VideoCardProps { - project: Project; -} - -export default function ProjectSmallCard({ project }: VideoCardProps) { - return ( - -
- - - {project.title} - - - {project.description} - -
-
- ); -} diff --git a/apps/web/src/components/dashboard/search-navigation.tsx b/apps/web/src/components/dashboard/search-navigation.tsx index 5db15062..9f9bfcac 100644 --- a/apps/web/src/components/dashboard/search-navigation.tsx +++ b/apps/web/src/components/dashboard/search-navigation.tsx @@ -1,53 +1,49 @@ "use client"; -import { SearchIcon, StretchHorizontal } from "lucide-react"; +import { SearchIcon } from "lucide-react"; import Link from "next/link"; import { Input } from "~/components/ui/input"; -import { Toggle } from "~/components/ui/toggle"; import { Button } from "~/components/ui/button"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState, type ChangeEvent } from "react"; +import { useDebounce } from "@uidotdev/usehooks"; interface ProjectGridProps { orgName: string; } export default function SearchNavigation({ orgName }: ProjectGridProps) { - const searchParams = useSearchParams(); - const pathname = usePathname(); const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const [query, setQuery] = useState(searchParams.get("q") ?? ""); + const debouncedQuery = useDebounce(query, 300); - const listDisplayType = searchParams.get("listType"); + useEffect(() => { + const params = new URLSearchParams(searchParams); + params.set("q", debouncedQuery); + + router.push(pathname + "?" + params.toString()); + }, [debouncedQuery]); + + function onChange(event: ChangeEvent) { + setQuery(event.target.value); + } return ( -
+
- -
- { - const params = new URLSearchParams(searchParams); - - switch (listDisplayType) { - case "list": - params.set("listType", "grid"); - break; - case "grid": - default: - params.set("listType", "list"); - break; - } - - router.replace(`${pathname}?${params.toString()}`); - }} - className="mr-2" - > - - + +
diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx new file mode 100644 index 00000000..8a4162a7 --- /dev/null +++ b/apps/web/src/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react"; +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { type ButtonProps, buttonVariants } from "~/components/ui/button"; + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +