From 3d102e487650f17de889a9ff191b52f27f1ebe5a Mon Sep 17 00:00:00 2001 From: bernard-ng Date: Wed, 3 Dec 2025 18:29:04 +0200 Subject: [PATCH] feat: categories carousel --- apps/api/src/trpc/routers/_app.ts | 2 + apps/api/src/trpc/routers/categories.ts | 7 + .../(app)/(sidebar)/articles/page.tsx | 1 + .../(app)/(sidebar)/sources/[id]/page.tsx | 1 + .../src/components/articles-feed.tsx | 9 + .../src/components/categories-carousel.tsx | 93 +++++++ bun.lock | 7 + packages/db/src/queries/articles.ts | 11 +- packages/db/src/queries/categories.ts | 10 + packages/db/src/queries/index.ts | 1 + packages/ui/package.json | 1 + packages/ui/src/components/carousel.tsx | 229 ++++++++++++++++++ 12 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/trpc/routers/categories.ts create mode 100644 apps/dashboard/src/components/categories-carousel.tsx create mode 100644 packages/db/src/queries/categories.ts create mode 100644 packages/ui/src/components/carousel.tsx diff --git a/apps/api/src/trpc/routers/_app.ts b/apps/api/src/trpc/routers/_app.ts index 39ec5b1..fe828c7 100644 --- a/apps/api/src/trpc/routers/_app.ts +++ b/apps/api/src/trpc/routers/_app.ts @@ -3,12 +3,14 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { createTRPCRouter } from "#api/trpc/init"; import { articlesRouter } from "#api/trpc/routers/articles"; import { authRouter } from "#api/trpc/routers/auth"; +import { categoriesRouter } from "#api/trpc/routers/categories"; import { reportsRouter } from "#api/trpc/routers/reports"; import { sourcesRouter } from "#api/trpc/routers/sources"; export const appRouter = createTRPCRouter({ articles: articlesRouter, auth: authRouter, + categories: categoriesRouter, reports: reportsRouter, sources: sourcesRouter, }); diff --git a/apps/api/src/trpc/routers/categories.ts b/apps/api/src/trpc/routers/categories.ts new file mode 100644 index 0000000..76d47b3 --- /dev/null +++ b/apps/api/src/trpc/routers/categories.ts @@ -0,0 +1,7 @@ +import { getCategories } from "@basango/db/queries"; + +import { createTRPCRouter, protectedProcedure } from "#api/trpc/init"; + +export const categoriesRouter = createTRPCRouter({ + list: protectedProcedure.query(async ({ ctx }) => getCategories(ctx.db)), +}); diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/articles/page.tsx b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/articles/page.tsx index a606868..af78b78 100644 --- a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/articles/page.tsx +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/articles/page.tsx @@ -9,6 +9,7 @@ export const metadata: Metadata = { }; export default function Page() { + prefetch(trpc.categories.list.queryOptions()); prefetch(trpc.articles.list.infiniteQueryOptions({ limit: 12 })); return ( diff --git a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/[id]/page.tsx b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/[id]/page.tsx index 8f02d6d..116647e 100644 --- a/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/[id]/page.tsx +++ b/apps/dashboard/src/app/[locale]/(app)/(sidebar)/sources/[id]/page.tsx @@ -20,6 +20,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }> trpc.sources.getById.queryOptions({ id }), trpc.sources.getCategoryShares.queryOptions({ id, limit: 10 }), trpc.sources.getPublications.queryOptions({ id }), + trpc.categories.list.queryOptions(), trpc.articles.list.infiniteQueryOptions({ limit: 12, sourceId: id }), ]); diff --git a/apps/dashboard/src/components/articles-feed.tsx b/apps/dashboard/src/components/articles-feed.tsx index e12239e..3308fa1 100644 --- a/apps/dashboard/src/components/articles-feed.tsx +++ b/apps/dashboard/src/components/articles-feed.tsx @@ -9,6 +9,7 @@ import * as React from "react"; import { useTRPC } from "#dashboard/trpc/client"; import { ArticleCard, ArticleCardSkeleton } from "./article-card"; +import { CategoriesCarousel } from "./categories-carousel"; type ArticlesTableProps = { sourceId?: string; @@ -18,10 +19,16 @@ const PLACEHOLDER_COUNT = 8; export function ArticlesFeed({ sourceId }: ArticlesTableProps) { const trpc = useTRPC(); + const [selectedCategory, setSelectedCategory] = React.useState(null); + + const handleCategorySelect = React.useCallback((categoryId: string | null) => { + setSelectedCategory((current) => (current === categoryId ? null : categoryId)); + }, []); const query = useInfiniteQuery( trpc.articles.list.infiniteQueryOptions( { + category: selectedCategory ?? undefined, limit: 12, sourceId, }, @@ -41,6 +48,8 @@ export function ArticlesFeed({ sourceId }: ArticlesTableProps) { return (
+ + {query.isError && ( Unable to load articles diff --git a/apps/dashboard/src/components/categories-carousel.tsx b/apps/dashboard/src/components/categories-carousel.tsx new file mode 100644 index 0000000..b655211 --- /dev/null +++ b/apps/dashboard/src/components/categories-carousel.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from "@basango/ui/components/carousel"; +import { Skeleton } from "@basango/ui/components/skeleton"; +import { cn } from "@basango/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { useTRPC } from "#dashboard/trpc/client"; + +type Props = { + onSelect: (categoryId: string | null) => void; + selectedCategory: string | null; +}; + +const PLACEHOLDER_COUNT = 10; + +export function CategoriesCarousel({ onSelect, selectedCategory }: Props) { + const trpc = useTRPC(); + const { data, isLoading } = useQuery(trpc.categories.list.queryOptions()); + const categories = data ?? []; + const showSkeletons = isLoading && categories.length === 0; + + return ( +
+ + + + onSelect(null)}> + All + + + {showSkeletons + ? Array.from({ length: PLACEHOLDER_COUNT }).map((_, index) => ( + + + + )) + : categories.map((category) => ( + + onSelect(category.id)} + > + {category.name} + + + ))} + + + + +
+ ); +} + +type CategoryPillProps = { + active?: boolean; + children: React.ReactNode; + onClick: () => void; +}; + +function CategoryPill({ active, children, onClick }: CategoryPillProps) { + return ( + + ); +} diff --git a/bun.lock b/bun.lock index 0a16672..1acff7f 100644 --- a/bun.lock +++ b/bun.lock @@ -221,6 +221,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "catalog:", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.554.0", "next-themes": "^0.4.6", "react": "catalog:", @@ -1430,6 +1431,12 @@ "electron-to-chromium": ["electron-to-chromium@1.5.249", "", {}, "sha512-5vcfL3BBe++qZ5kuFhD/p8WOM1N9m3nwvJPULJx+4xf2usSlZFJ0qoNYO2fOX4hi3ocuDcmDobtA+5SFr4OmBg=="], + "embla-carousel": ["embla-carousel@8.6.0", "", {}, "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA=="], + + "embla-carousel-react": ["embla-carousel-react@8.6.0", "", { "dependencies": { "embla-carousel": "8.6.0", "embla-carousel-reactive-utils": "8.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA=="], + + "embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], diff --git a/packages/db/src/queries/articles.ts b/packages/db/src/queries/articles.ts index 8a3fe92..6f1af10 100644 --- a/packages/db/src/queries/articles.ts +++ b/packages/db/src/queries/articles.ts @@ -11,7 +11,7 @@ import { } from "@basango/domain/models"; import { md5 } from "@basango/encryption"; import type { SQL } from "drizzle-orm"; -import { count, desc, eq, getTableColumns, or, sql } from "drizzle-orm"; +import { count, desc, eq, getTableColumns, sql } from "drizzle-orm"; import * as uuid from "uuid"; import { Database } from "#db/client"; @@ -105,14 +105,7 @@ function buildFilters(params: GetArticlesParams, pagination: PaginationState) { } if (params.category) { - const categoryFilter = or( - eq(categories.slug, params.category), - eq(articles.categoryId, params.category), - ); - - if (categoryFilter) { - filters.push(categoryFilter); - } + filters.push(eq(articles.categoryId, params.category)); } if (params.search?.trim()) { diff --git a/packages/db/src/queries/categories.ts b/packages/db/src/queries/categories.ts new file mode 100644 index 0000000..f758794 --- /dev/null +++ b/packages/db/src/queries/categories.ts @@ -0,0 +1,10 @@ +import { asc, desc } from "drizzle-orm"; + +import { Database } from "#db/client"; +import { categories } from "#db/schema"; + +export async function getCategories(db: Database) { + return db.query.categories.findMany({ + orderBy: [desc(categories.weight), asc(categories.name)], + }); +} diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index 88863b2..2586556 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -1,4 +1,5 @@ export * from "./articles"; +export * from "./categories"; export * from "./reports"; export * from "./sources"; export * from "./users"; diff --git a/packages/ui/package.json b/packages/ui/package.json index 401d253..b27719b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "catalog:", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.554.0", "next-themes": "^0.4.6", "react": "catalog:", diff --git a/packages/ui/src/components/carousel.tsx b/packages/ui/src/components/carousel.tsx new file mode 100644 index 0000000..cc89afe --- /dev/null +++ b/packages/ui/src/components/carousel.tsx @@ -0,0 +1,229 @@ +"use client"; + +import { Button } from "@basango/ui/components/button"; +import { cn } from "@basango/ui/lib/utils"; +import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import * as React from "react"; + +type CarouselApi = UseEmblaCarouselType[1]; +type UseCarouselParameters = Parameters; +type CarouselOptions = UseCarouselParameters[0]; +type CarouselPlugin = UseCarouselParameters[1]; + +type CarouselProps = { + opts?: CarouselOptions; + plugins?: CarouselPlugin; + orientation?: "horizontal" | "vertical"; + setApi?: (api: CarouselApi) => void; +}; + +type CarouselContextProps = { + carouselRef: ReturnType[0]; + api: ReturnType[1]; + scrollPrev: () => void; + scrollNext: () => void; + canScrollPrev: boolean; + canScrollNext: boolean; +} & CarouselProps; + +const CarouselContext = React.createContext(null); + +function useCarousel() { + const context = React.useContext(CarouselContext); + + if (!context) { + throw new Error("useCarousel must be used within a "); + } + + return context; +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins, + ); + const [canScrollPrev, setCanScrollPrev] = React.useState(false); + const [canScrollNext, setCanScrollNext] = React.useState(false); + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return; + setCanScrollPrev(api.canScrollPrev()); + setCanScrollNext(api.canScrollNext()); + }, []); + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev(); + }, [api]); + + const scrollNext = React.useCallback(() => { + api?.scrollNext(); + }, [api]); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault(); + scrollPrev(); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + scrollNext(); + } + }, + [scrollPrev, scrollNext], + ); + + React.useEffect(() => { + if (!api || !setApi) return; + setApi(api); + }, [api, setApi]); + + React.useEffect(() => { + if (!api) return; + onSelect(api); + api.on("reInit", onSelect); + api.on("select", onSelect); + + return () => { + api?.off("select", onSelect); + }; + }, [api, onSelect]); + + return ( + +
+ {children} +
+
+ ); +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel(); + + return ( +
+
+
+ ); +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel(); + + return ( +
+ ); +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel(); + + return ( + + ); +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +};