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.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 (
-
+
{
- 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">) => (
+
+);
+Pagination.displayName = "Pagination";
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationContent.displayName = "PaginationContent";
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+));
+PaginationItem.displayName = "PaginationItem";
+
+type PaginationLinkProps = {
+ isActive?: boolean;
+} & Pick
&
+ React.ComponentProps<"a">;
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+);
+PaginationLink.displayName = "PaginationLink";
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+);
+PaginationPrevious.displayName = "PaginationPrevious";
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+);
+PaginationNext.displayName = "PaginationNext";
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+);
+PaginationEllipsis.displayName = "PaginationEllipsis";
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+};
diff --git a/apps/web/src/lib/queries/useChannelQuery.ts b/apps/web/src/lib/queries/useChannelQuery.ts
new file mode 100644
index 00000000..82fb347b
--- /dev/null
+++ b/apps/web/src/lib/queries/useChannelQuery.ts
@@ -0,0 +1,38 @@
+import {
+ useSuspenseQuery,
+ type UseSuspenseQueryOptions,
+} from "@tanstack/react-query";
+import { type Project } from "../validators/project";
+import type { HTTPError } from "ky";
+import { type ZodError } from "zod";
+import ky from "ky";
+import type { youtube_v3 } from "@googleapis/youtube";
+
+type UseProjectsQueryOptions = Omit<
+ UseSuspenseQueryOptions<
+ youtube_v3.Schema$Channel,
+ HTTPError | ZodError,
+ youtube_v3.Schema$Channel,
+ ["channel", Project["channelId"]]
+ >,
+ "queryKey" | "queryFn"
+>;
+
+export function useChannelSuspenseQuery(
+ channelId: Project["channelId"],
+ options: UseProjectsQueryOptions = {},
+) {
+ return useSuspenseQuery({
+ queryKey: ["channel", channelId],
+ queryFn: async () => {
+ const res = await ky
+ .get(`/api/youtube/channel/${channelId}`)
+ .json();
+
+ return res;
+
+ return res;
+ },
+ ...options,
+ });
+}
diff --git a/apps/web/src/lib/queries/useProjectsQuery.ts b/apps/web/src/lib/queries/useProjectsQuery.ts
new file mode 100644
index 00000000..07e14976
--- /dev/null
+++ b/apps/web/src/lib/queries/useProjectsQuery.ts
@@ -0,0 +1,49 @@
+import {
+ keepPreviousData,
+ useQuery,
+ type UseQueryOptions,
+} from "@tanstack/react-query";
+import { type Project } from "../validators/project";
+import type { HTTPError } from "ky";
+import { type ZodError } from "zod";
+import ky from "ky";
+import type { Organization } from "../validators/organization";
+import { env } from "~/env";
+import type { GetProjectsResponse } from "~/server/actions/organization";
+
+type UseProjectsQueryOptions = Omit<
+ UseQueryOptions<
+ GetProjectsResponse,
+ HTTPError | ZodError,
+ GetProjectsResponse,
+ ["projects", string, number, string]
+ >,
+ "queryKey" | "queryFn"
+>;
+
+export function useProjectsPaginatedQuery(
+ organizationName: Organization["name"],
+ page: number,
+ query: Project["title"],
+ options: UseProjectsQueryOptions = {},
+) {
+ return useQuery({
+ queryKey: ["projects", organizationName, +page!, query],
+ queryFn: async () => {
+ const searchParams = new URLSearchParams([
+ [`page`, page.toString()],
+ [`q`, query],
+ ]);
+
+ const res = await ky
+ .get(
+ `${env.NEXT_PUBLIC_API_URL}/api/organizations/${organizationName}/projects?${searchParams}`,
+ )
+ .json();
+
+ return res;
+ },
+ placeholderData: keepPreviousData,
+ ...options,
+ });
+}
diff --git a/apps/web/src/lib/queries/useVideoQuery.ts b/apps/web/src/lib/queries/useVideoQuery.ts
new file mode 100644
index 00000000..b0e4ef43
--- /dev/null
+++ b/apps/web/src/lib/queries/useVideoQuery.ts
@@ -0,0 +1,40 @@
+import {
+ useSuspenseQuery,
+ type UseSuspenseQueryOptions,
+} from "@tanstack/react-query";
+import { type Project } from "../validators/project";
+import type { HTTPError } from "ky";
+import ky from "ky";
+import { env } from "~/env";
+import type { youtube_v3 } from "@googleapis/youtube";
+import type { Organization } from "../validators/organization";
+
+type UseProjectsQueryOptions = Omit<
+ UseSuspenseQueryOptions<
+ youtube_v3.Schema$Video,
+ HTTPError,
+ youtube_v3.Schema$Video,
+ ["video", Project["videoId"]]
+ >,
+ "queryKey" | "queryFn"
+>;
+
+export function useVideoSuspenseQuery(
+ videoId: Project["videoId"],
+ organizationName: Organization["name"],
+ options: UseProjectsQueryOptions = {},
+) {
+ return useSuspenseQuery({
+ queryKey: ["video", videoId],
+ queryFn: async () => {
+ const res = await ky
+ .get(
+ `${env.NEXT_PUBLIC_API_URL}/api/organizations/${organizationName}/projects/video/${videoId}`,
+ )
+ .json();
+
+ return res;
+ },
+ ...options,
+ });
+}
diff --git a/apps/web/src/server/actions/organization.ts b/apps/web/src/server/actions/organization.ts
index afd0ea63..d23067a2 100644
--- a/apps/web/src/server/actions/organization.ts
+++ b/apps/web/src/server/actions/organization.ts
@@ -1,6 +1,6 @@
"use server";
-import { and, eq, or } from "drizzle-orm";
+import { and, count, eq, ilike, or } from "drizzle-orm";
import type { Result } from "~/lib/utils";
import type {
@@ -72,13 +72,35 @@ export async function getOwnOrganizationByName(
: [null, "Not found"];
}
-export async function getOrganizationProjects(id: Organization["id"]) {
- const projects = await db
- .select()
- .from(projectsTable)
- .where(eq(projectsTable.organizationId, id));
-
- return projects;
+export type GetProjectsResponse = Awaited<
+ ReturnType
+>;
+
+export async function getOrganizationProjects(
+ id: Organization["id"],
+ page: number,
+ query: string,
+) {
+ const [projects, projectCount] = await Promise.all([
+ db
+ .select()
+ .from(projectsTable)
+ .where(
+ and(
+ eq(projectsTable.organizationId, id),
+ ilike(projectsTable.title, `%${query}%`),
+ ),
+ )
+ .limit(12)
+ .offset((page - 1) * 12),
+ db.select({ count: count() }).from(projectsTable),
+ ]);
+
+ return {
+ projects,
+ total: projectCount[0]!.count,
+ hasNextPage: projectCount[0]!.count > page * 12,
+ };
}
export async function getOrganizations(): Promise<
diff --git a/apps/web/src/server/api/utils/project.ts b/apps/web/src/server/api/utils/project.ts
index d21fdf65..ed65aead 100644
--- a/apps/web/src/server/api/utils/project.ts
+++ b/apps/web/src/server/api/utils/project.ts
@@ -14,8 +14,7 @@ export async function getYoutubeCategories(): Promise<
const categories: youtube_v3.Schema$VideoCategoryListResponse =
(await youtubeClient.videoCategories
.list({
- // @ts-expect-error Types from google libraries are awful
- part: "snippet",
+ part: ["snippet"],
regionCode: "PL",
key: env.YOUTUBE_DATA_API_KEY,
})
@@ -30,14 +29,11 @@ export async function getYoutubeCategories(): Promise<
export async function getYoutubeSupportedLanguages(): Promise<
Result
> {
- const youtubeClient = youtube("v3");
-
try {
const languages: youtube_v3.Schema$I18nLanguageListResponse =
(await youtubeClient.i18nLanguages
.list({
- // @ts-expect-error Types from google libraries are awful
- part: "snippet",
+ part: ["snippet"],
key: env.YOUTUBE_DATA_API_KEY,
})
.then((res) => res.data)) as any;
@@ -60,8 +56,6 @@ export async function getOwnerChannels(
const ownerAccount = (await getOwnerAccount(owner.id))!;
// Create a new OAuth2Client for authorization
- const youtubeClient = youtube("v3");
-
const oauth2Client = new OAuth2Client({
clientSecret: env.AUTH_GOOGLE_SECRET,
clientId: env.AUTH_GOOGLE_ID,
@@ -75,8 +69,7 @@ export async function getOwnerChannels(
try {
const channels = await youtubeClient.channels
.list({
- // @ts-expect-error Types from google libraries are awful
- part: "snippet",
+ part: ["snippet"],
auth: oauth2Client,
mine: true,
key: env.YOUTUBE_DATA_API_KEY,
@@ -90,3 +83,66 @@ export async function getOwnerChannels(
return [null, (e as Error).message];
}
}
+
+export async function getChannel(
+ channelId: string,
+): Promise> {
+ try {
+ const channel = await youtubeClient.channels
+ .list({
+ part: ["snippet"],
+ id: [channelId],
+ key: env.YOUTUBE_DATA_API_KEY,
+ })
+ .then((res) => res.data);
+
+ // @ts-expect-error Types from google libraries are awful
+ return [channel.items[0], null];
+ } catch (e) {
+ console.error(e);
+ return [null, (e as Error).message];
+ }
+}
+
+export async function getVideo(
+ organizationName: Organization["name"],
+ videoId: string,
+): Promise> {
+ // Get owner of the organization
+ const owner = await getOwnerId(organizationName);
+ if (!owner) {
+ return [null, "NOT_FOUND"];
+ }
+
+ const ownerAccount = (await getOwnerAccount(owner.id))!;
+
+ // Create a new OAuth2Client for authorization
+ const oauth2Client = new OAuth2Client({
+ clientSecret: env.AUTH_GOOGLE_SECRET,
+ clientId: env.AUTH_GOOGLE_ID,
+
+ credentials: {
+ access_token: ownerAccount?.access_token,
+ refresh_token: ownerAccount?.refresh_token,
+ },
+ });
+
+ try {
+ const channel = await youtubeClient.videos
+ // @ts-expect-error Types from google libraries are awful
+ .list({
+ part: ["snippet"],
+ id: [videoId],
+ key: env.YOUTUBE_DATA_API_KEY,
+ auth: oauth2Client,
+ mine: true,
+ })
+ .then((res) => res.data);
+
+ // @ts-expect-error Types from google libraries are awful
+ return [channel.items[0], null];
+ } catch (e) {
+ console.error(e);
+ return [null, (e as Error).message];
+ }
+}
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 0ed432d0..1c8b6472 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -76,7 +76,10 @@ const config: Config = {
},
},
},
- plugins: [require("tailwindcss-animate")],
+ plugins: [
+ require("tailwindcss-animate"),
+ require("@tailwindcss/container-queries"),
+ ],
};
export default config;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ba3eaba3..c870b6f9 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -86,6 +86,9 @@ importers:
'@t3-oss/env-nextjs':
specifier: ^0.10.1
version: 0.10.1(typescript@5.5.4)(zod@3.23.8)
+ '@tailwindcss/container-queries':
+ specifier: ^0.1.1
+ version: 0.1.1(tailwindcss@3.4.10)
'@tanstack/react-query':
specifier: ^5.51.23
version: 5.51.23(react@18.3.1)
@@ -95,6 +98,9 @@ importers:
'@total-typescript/ts-reset':
specifier: ^0.5.1
version: 0.5.1
+ '@uidotdev/usehooks':
+ specifier: ^2.4.1
+ version: 2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@vercel/postgres':
specifier: ^0.9.0
version: 0.9.0
@@ -1789,6 +1795,11 @@ packages:
typescript:
optional: true
+ '@tailwindcss/container-queries@0.1.1':
+ resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
+ peerDependencies:
+ tailwindcss: '>=3.2.0'
+
'@tanstack/query-core@5.51.21':
resolution: {integrity: sha512-POQxm42IUp6n89kKWF4IZi18v3fxQWFRolvBA6phNVmA8psdfB1MvDnGacCJdS+EOX12w/CyHM62z//rHmYmvw==}
@@ -2031,6 +2042,13 @@ packages:
resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==}
engines: {node: ^18.18.0 || >=20.0.0}
+ '@uidotdev/usehooks@2.4.1':
+ resolution: {integrity: sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ react: '>=18.0.0'
+ react-dom: '>=18.0.0'
+
'@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
@@ -6036,6 +6054,10 @@ snapshots:
optionalDependencies:
typescript: 5.5.4
+ '@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.10)':
+ dependencies:
+ tailwindcss: 3.4.10
+
'@tanstack/query-core@5.51.21': {}
'@tanstack/query-devtools@5.51.16': {}
@@ -6355,6 +6377,11 @@ snapshots:
'@typescript-eslint/types': 7.18.0
eslint-visitor-keys: 3.4.3
+ '@uidotdev/usehooks@2.4.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
'@ungap/structured-clone@1.2.0': {}
'@vercel/postgres@0.9.0':