From 5fca354c58cf0e1b3db0babff7434ab4dc8247fd Mon Sep 17 00:00:00 2001 From: Izan Gil <66965250+SrIzan10@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:38:53 +0100 Subject: [PATCH] feat(ui): new index page --- apps/web/package.json | 1 + apps/web/src/app/(ui)/(public)/page.tsx | 86 +++--- .../components/app/StreamGrid/StreamGrid.tsx | 218 +++++++++++++++ apps/web/src/components/ui/carousel.tsx | 262 ++++++++++++++++++ dev/mediamtx.yml | 1 - pnpm-lock.yaml | 47 +++- 6 files changed, 550 insertions(+), 65 deletions(-) create mode 100644 apps/web/src/components/app/StreamGrid/StreamGrid.tsx create mode 100644 apps/web/src/components/ui/carousel.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 6b826d1..46d9480 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,6 +48,7 @@ "clsx": "^2.1.0", "cmdk": "1.0.0", "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", "hls-video-element": "^1.5.0", "hls.js": "^1.6.15", "lucia": "^3.2.2", diff --git a/apps/web/src/app/(ui)/(public)/page.tsx b/apps/web/src/app/(ui)/(public)/page.tsx index ea60d17..4828f6c 100644 --- a/apps/web/src/app/(ui)/(public)/page.tsx +++ b/apps/web/src/app/(ui)/(public)/page.tsx @@ -1,10 +1,8 @@ import LandingPage from '@/components/app/LandingPage/LandingPage'; -import { Card, CardContent } from '@/components/ui/card'; +import StreamGrid from '@/components/app/StreamGrid/StreamGrid'; import ConfusedDino from '@/components/ui/confuseddino'; import { validateRequest } from '@/lib/auth/validate'; import { prisma } from '@hctv/db'; -import { Avatar, AvatarImage, AvatarFallback } from '@radix-ui/react-avatar'; -import Image from 'next/image'; import Link from 'next/link'; import { redirect } from 'next/navigation'; @@ -13,67 +11,45 @@ export default async function Home() { if (user && !user?.hasOnboarded) { redirect('/onboarding'); } - const streams = await prisma.streamInfo.findMany({ - where: { - isLive: true, - }, - include: { - channel: true, - }, - }); + + const [liveStreams, offlineStreams] = await Promise.all([ + prisma.streamInfo.findMany({ + where: { isLive: true }, + include: { channel: true }, + }), + prisma.streamInfo.findMany({ + where: { isLive: false }, + include: { channel: true }, + }), + ]); + if (!user) { return ; } - if (!streams.length) { + + if (!liveStreams.length && !offlineStreams.length) { return ( -
-

No streams found!

-

...maybe start one?

- +
+ +
+

Nothing live right now

+

+ Nobody's streaming yet — why not be the first? +

+
+ + Start streaming +
); } return ( -
-
- {streams.map((stream) => ( - - - -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {stream.title} -
- LIVE -
-
- {stream.viewers} viewers -
-
-
-
- - - {stream.channel.name} - -
-

{stream.title}

-

{stream.category}

-
-
-
-
-
- - ))} -
+
+
); } diff --git a/apps/web/src/components/app/StreamGrid/StreamGrid.tsx b/apps/web/src/components/app/StreamGrid/StreamGrid.tsx new file mode 100644 index 0000000..1506607 --- /dev/null +++ b/apps/web/src/components/app/StreamGrid/StreamGrid.tsx @@ -0,0 +1,218 @@ +'use client'; + +import Link from 'next/link'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import ConfusedDino from '@/components/ui/confuseddino'; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; +import type { Channel, StreamInfo } from '@hctv/db'; + +type StreamWithChannel = StreamInfo & { channel: Channel }; + +interface StreamGridProps { + liveStreams: StreamWithChannel[]; + offlineStreams: StreamWithChannel[]; +} + +export default function StreamGrid({ liveStreams, offlineStreams }: StreamGridProps) { + const sorted = [...liveStreams].sort((a, b) => b.viewers - a.viewers); + const [featured, ...rest] = sorted; + + return ( +
+ {!featured && ( +
+ +
+

Nobody's live right now

+

Why not be the first?

+
+ + Start streaming + +
+ )} + + {featured && ( +
+ + +
+
+ {featured.title} +
+
+ + {featured.category && ( + + {featured.category} + + )} +
+
+ +
+
+
+ + + + {featured.channel.name.slice(0, 2).toUpperCase()} + + +
+

{featured.title}

+

{featured.channel.name}

+
+
+
+ +
+ )} + + {rest.length > 0 && ( +
+ +
+ {rest.map((stream) => ( + + ))} +
+
+ )} + + {offlineStreams.length > 0 && ( +
+ +
+ + + {offlineStreams.map((stream) => ( + + + + ))} + + + + +
+
+ )} +
+ ); +} + +function StreamCard({ stream }: { stream: StreamWithChannel }) { + return ( + +
+
+ {stream.title} +
+
+ +
+
+ +
+
+
+ + + + {stream.channel.name.slice(0, 2).toUpperCase()} + + +
+

{stream.title}

+

{stream.channel.name}

+ {stream.category && ( + + {stream.category} + + )} +
+
+
+ + ); +} + +function OfflineCard({ stream }: { stream: StreamWithChannel }) { + return ( + +
+
+ + + + {stream.channel.name.slice(0, 2).toUpperCase()} + + + +
+

{stream.channel.name}

+
+ + ); +} + +function LiveBadge({ small }: { small?: boolean }) { + return ( + + + Live + + ); +} + +function ViewerCount({ count, small }: { count: number; small?: boolean }) { + return ( + + + {count.toLocaleString()} + + ); +} + +function SectionHeading({ label, count }: { label: string; count?: number }) { + return ( +
+

{label}

+ {count !== undefined && ( + + {count} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx new file mode 100644 index 0000000..ec505d0 --- /dev/null +++ b/apps/web/src/components/ui/carousel.tsx @@ -0,0 +1,262 @@ +"use client" + +import * as React from "react" +import useEmblaCarousel, { + type UseEmblaCarouselType, +} from "embla-carousel-react" +import { ArrowLeft, ArrowRight } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +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 +} + +const Carousel = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & CarouselProps +>( + ( + { + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props + }, + ref + ) => { + 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} +
+
+ ) + } +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) +}) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + HTMLButtonElement, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/dev/mediamtx.yml b/dev/mediamtx.yml index f9df4ac..e2f22de 100644 --- a/dev/mediamtx.yml +++ b/dev/mediamtx.yml @@ -6,7 +6,6 @@ srt: yes srtAddress: :8890 hls: yes -hlsAddress: :8891 authMethod: http authHTTPAddress: http://host.docker.internal:3000/api/mediamtx/publish diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a32810..5c2a840 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + embla-carousel-react: + specifier: ^8.6.0 + version: 8.6.0(react@19.2.3) hls-video-element: specifier: ^1.5.0 version: 1.5.10 @@ -4595,6 +4598,19 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + embla-carousel-react@8.6.0: + resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + embla-carousel-reactive-utils@8.6.0: + resolution: {integrity: sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==} + peerDependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: + resolution: {integrity: sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -5143,11 +5159,12 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@13.24.0: resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} @@ -13053,6 +13070,18 @@ snapshots: electron-to-chromium@1.5.267: {} + embla-carousel-react@8.6.0(react@19.2.3): + dependencies: + embla-carousel: 8.6.0 + embla-carousel-reactive-utils: 8.6.0(embla-carousel@8.6.0) + react: 19.2.3 + + embla-carousel-reactive-utils@8.6.0(embla-carousel@8.6.0): + dependencies: + embla-carousel: 8.6.0 + + embla-carousel@8.6.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -13284,8 +13313,8 @@ snapshots: '@typescript-eslint/parser': 8.51.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -13304,7 +13333,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -13315,22 +13344,22 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.51.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -13341,7 +13370,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3