Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 102 additions & 43 deletions src/components/pages/user/screenshots-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,109 @@
import { Icon } from "@iconify/react";
import { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Grid } from "@/components/layouts/grid";
import { ScreenshotItem } from "@/components/shared/cards/screenshot";

export function UserScreenshotsTab() {
const gamesWithScreenshots = [
{
id: "a1",
title: "Romeo is a Dead Man",
image: "https://images.igdb.com/igdb/image/upload/t_original/coakmt.webp",
images: [
"https://images.igdb.com/igdb/image/upload/t_720p/sc5rik.webp",
"https://images.igdb.com/igdb/image/upload/t_720p/sc5ril.webp",
],
},
{
id: "a2",
title: "Soul Hackers 2",
image: "https://images.igdb.com/igdb/image/upload/t_original/co4hzh.webp",
images: [
"https://images.igdb.com/igdb/image/upload/t_720p/sc5rik.webp",
"https://images.igdb.com/igdb/image/upload/t_720p/sc5ril.webp",
],
},
{
id: "a3",
title: "Grand Theft Auto VI",
image: "https://images.igdb.com/igdb/image/upload/t_original/co9rwo.webp",
images: [
"https://images.igdb.com/igdb/image/upload/t_720p/sc5rik.webp",
"https://images.igdb.com/igdb/image/upload/t_720p/sc5ril.webp",
],
},
{
id: "a4",
title: "Call of Duty: Black Ops 7",
image: "https://images.igdb.com/igdb/image/upload/t_original/co9xwv.webp",
images: [
"https://images.igdb.com/igdb/image/upload/t_720p/sc5rik.webp",
"https://images.igdb.com/igdb/image/upload/t_720p/sc5ril.webp",
],
},
];
import { type ScreenshotImage, ScreenshotItem } from "@/components/shared/cards/screenshot";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { useGameScreenshots } from "@/hooks/game";
import type { ApiTypes } from "@/lib/api";

interface GameScreenshotGroup {
game: ApiTypes.GameScreenshotGame;
images: ScreenshotImage[];
}

export function UserScreenshotsTab({ userId }: { userId: string }) {
const { t } = useTranslation();

const screenshotsQuery = useGameScreenshots({ userId });

const sentinelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const el = sentinelRef.current;
if (!el) return;

const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && screenshotsQuery.hasNextPage && !screenshotsQuery.isFetchingNextPage) {
screenshotsQuery.fetchNextPage();
}
},
{ rootMargin: "200px" },
);

observer.observe(el);
return () => observer.disconnect();
}, [screenshotsQuery.hasNextPage, screenshotsQuery.isFetchingNextPage, screenshotsQuery.fetchNextPage]);

// Groups by game keeping the order the screenshots came in (newest first).
const groups = useMemo(() => {
const screenshots = screenshotsQuery.data?.pages.flatMap((page) => page.items) ?? [];
const byGame = new Map<string, GameScreenshotGroup>();

for (const screenshot of screenshots) {
const group = byGame.get(screenshot.game.id) ?? { game: screenshot.game, images: [] };

group.images.push({
id: screenshot.id,
url: screenshot.url,
description: screenshot.description,
isSpoiler: screenshot.isSpoiler,
});

byGame.set(screenshot.game.id, group);
}

return [...byGame.values()];
}, [screenshotsQuery.data]);

if (screenshotsQuery.isLoading) {
return <ScreenshotsSkeleton />;
}

if (groups.length === 0) {
return (
<Empty className="border-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<Icon icon="lucide:images" />
</EmptyMedia>
<EmptyTitle>{t("user:noScreenshots")}</EmptyTitle>
<EmptyDescription>{t("user:noScreenshotsDescription")}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

return (
<div className="flex flex-col gap-4">
<Grid minColSize={"128px"} className="flex w-full flex-col rounded-2xl py-4 px-2 gap-6">
{groups.map((group) => (
<ScreenshotItem
key={group.game.id}
title={group.game.name}
imageURL={group.game.coverUrl ?? ""}
images={group.images}
/>
))}
</Grid>

<div ref={sentinelRef} />

{screenshotsQuery.isFetchingNextPage && <ScreenshotsSkeleton length={4} />}
</div>
);
}

function ScreenshotsSkeleton({ length = 8 }: { length?: number }) {
return (
<Grid minColSize={"128px"} className="flex w-full flex-col rounded-2xl py-4 px-2 gap-6">
{gamesWithScreenshots.map((f) => (
<ScreenshotItem key={f.id} title={f.title} imageURL={f.image} images={f.images} />
{Array.from({ length }).map((_, index) => (
<div key={index} className="flex flex-col gap-2">
<Skeleton className="aspect-3/4 w-full rounded-xl" />
<Skeleton className="h-4 w-3/4" />
</div>
))}
</Grid>
);
Expand Down
52 changes: 42 additions & 10 deletions src/components/shared/cards/screenshot.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,32 @@
import { Icon } from "@iconify/react";
import { Image } from "@unpic/react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel";
import { Dialog, DialogContent, DialogTrigger } from "../../ui/dialog";

export interface ScreenshotImage {
id: string;
url: string;
description?: string | null;
isSpoiler: boolean;
}

interface ScreenshotProps {
title: string;
imageURL: string;
images: string[];
images: ScreenshotImage[];
}

export function ScreenshotItem({ title, imageURL, images }: ScreenshotProps) {
const { t } = useTranslation();

const [revealed, setRevealed] = useState<string[]>([]);

const isRevealed = (image: ScreenshotImage) => !image.isSpoiler || revealed.includes(image.id);

return (
<Dialog>
<Dialog onOpenChange={(open) => !open && setRevealed([])}>
<DialogTrigger asChild>
<div className={"cursor-pointer"}>
<div className="relative rounded-xl border border-border overflow-hidden aspect-3/4 group">
Expand Down Expand Up @@ -43,14 +58,31 @@ export function ScreenshotItem({ title, imageURL, images }: ScreenshotProps) {
>
<CarouselContent>
{images.map((image, index) => (
<CarouselItem key={image}>
<Image
src={image}
layout="fullWidth"
aspectRatio={16 / 9}
className="w-full aspect-video object-contain"
alt={`${title} – screenshot ${index + 1} of ${images.length}`}
/>
<CarouselItem key={image.id}>
<div className="relative">
<Image
src={image.url}
layout="fullWidth"
aspectRatio={16 / 9}
className={`w-full aspect-video object-contain transition-all duration-300 ${
isRevealed(image) ? "" : "blur-xl"
}`}
alt={`${title} – screenshot ${index + 1} of ${images.length}`}
/>
{!isRevealed(image) && (
<button
type="button"
className="absolute inset-0 flex items-center justify-center gap-2 text-sm font-medium text-white bg-black/40"
onClick={() => setRevealed((prev) => [...prev, image.id])}
>
<Icon icon={"lucide:eye-off"} className="size-4" />
{t("comments:spoilerReveal")}
</button>
)}
</div>
{image.description && (
<p className="text-sm text-muted-foreground text-center mt-3 px-4">{image.description}</p>
)}
</CarouselItem>
))}
</CarouselContent>
Expand Down
Loading