From 3c07a5229c301f4229d3f3aef198e4e0d544144e Mon Sep 17 00:00:00 2001 From: izakdvlpr Date: Wed, 29 Jul 2026 10:45:55 -0300 Subject: [PATCH 1/2] feat: add game screenshots --- src/components/pages/user/screenshots-tab.tsx | 145 +++++--- src/components/shared/cards/screenshot.tsx | 52 ++- src/components/shared/modals/game.tsx | 343 +++++++++++------- src/hooks/game.ts | 121 +++++- src/lib/api.ts | 30 +- src/lib/i18n/locales/en-US/feed.json | 5 + src/lib/i18n/locales/en-US/user.json | 2 + src/routes/game/$slug.tsx | 49 ++- src/routes/user/$username/index.tsx | 6 +- 9 files changed, 543 insertions(+), 210 deletions(-) diff --git a/src/components/pages/user/screenshots-tab.tsx b/src/components/pages/user/screenshots-tab.tsx index badada9..2056c7c 100644 --- a/src/components/pages/user/screenshots-tab.tsx +++ b/src/components/pages/user/screenshots-tab.tsx @@ -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(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(); + + 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 ; + } + + if (groups.length === 0) { + return ( + + + + + + {t("user:noScreenshots")} + {t("user:noScreenshotsDescription")} + + + ); + } + + return ( +
+ + {groups.map((group) => ( + + ))} + + +
+ + {screenshotsQuery.isFetchingNextPage && } +
+ ); +} + +function ScreenshotsSkeleton({ length = 8 }: { length?: number }) { return ( - {gamesWithScreenshots.map((f) => ( - + {Array.from({ length }).map((_, index) => ( +
+ + +
))}
); diff --git a/src/components/shared/cards/screenshot.tsx b/src/components/shared/cards/screenshot.tsx index 4454a7d..cb9c00a 100644 --- a/src/components/shared/cards/screenshot.tsx +++ b/src/components/shared/cards/screenshot.tsx @@ -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([]); + + const isRevealed = (image: ScreenshotImage) => !image.isSpoiler || revealed.includes(image.id); + return ( - + !open && setRevealed([])}>
@@ -43,14 +58,31 @@ export function ScreenshotItem({ title, imageURL, images }: ScreenshotProps) { > {images.map((image, index) => ( - - {`${title} + +
+ {`${title} + {!isRevealed(image) && ( + + )} +
+ {image.description && ( +

{image.description}

+ )}
))}
diff --git a/src/components/shared/modals/game.tsx b/src/components/shared/modals/game.tsx index 0d14bbf..4cf0395 100644 --- a/src/components/shared/modals/game.tsx +++ b/src/components/shared/modals/game.tsx @@ -9,7 +9,14 @@ import { Controller, useForm } from "react-hook-form"; import { Trans, useTranslation } from "react-i18next"; import { toast } from "sonner"; import z from "zod"; -import { useUploadImage } from "@/hooks/game.ts"; +import { + useCreateGameScreenshot, + useDeleteGameScreenshot, + useGameScreenshots, + useUpdateGameScreenshot, + useUploadImage, + validateScreenshotFile, +} from "@/hooks/game.ts"; import { type ApiTypes, api, apiEndpoints } from "@/lib/api.ts"; import { useSession } from "@/lib/auth.ts"; import { Button } from "../../ui/button"; @@ -50,6 +57,8 @@ const COMPLETION_OPTIONS = ["mainStory", "mainStoryPlusExtras", "100%", "endless const SUMMARY_MAX_LENGTH = 500; const REVIEW_NOTES_MAX_LENGTH = 1000; const PROGRESS_NOTES_MAX_LENGTH = 1000; +// Mirrors the `@MaxLength(255)` on CreateGameScreenshotDto.description. +const SCREENSHOT_DESCRIPTION_MAX_LENGTH = 255; function createProgressSchema(t: TFunction) { return z.object({ @@ -103,11 +112,9 @@ interface GameReviewData { recommended: boolean | null; } -interface PendingScreenshot { - file: File; +interface UploadingScreenshot { + tempId: string; previewUrl: string; - isSpoiler: boolean; - description: string; } interface GameModalProps { @@ -161,11 +168,21 @@ export function GameModal({ gameId, onClose }: GameModalProps) { const [newListInput, setNewListInput] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); - const [pendingScreenshots, setPendingScreenshots] = useState([]); + const [uploadingScreenshots, setUploadingScreenshots] = useState([]); const fileInputRef = useRef(null); const uploadImageMutation = useUploadImage(); + const createScreenshotMutation = useCreateGameScreenshot(); + const updateScreenshotMutation = useUpdateGameScreenshot(); + const deleteScreenshotMutation = useDeleteGameScreenshot(); + + const screenshotsQuery = useGameScreenshots({ userId, gameId }); + + const screenshots = useMemo( + () => screenshotsQuery.data?.pages.flatMap((page) => page.items) ?? [], + [screenshotsQuery.data], + ); const progressQuery = useQuery({ queryKey: ["gameProgress", gameId, userId], @@ -244,6 +261,18 @@ export function GameModal({ gameId, onClose }: GameModalProps) { }); }, [reviewQuery.data, reviewForm.reset]); + const uploadingScreenshotsRef = useRef([]); + uploadingScreenshotsRef.current = uploadingScreenshots; + + useEffect( + () => () => { + for (const screenshot of uploadingScreenshotsRef.current) { + URL.revokeObjectURL(screenshot.previewUrl); + } + }, + [], + ); + const invalidateProgress = () => { queryClient.invalidateQueries({ queryKey: ["gameProgress", gameId, userId] }); queryClient.invalidateQueries({ queryKey: ["game"] }); @@ -304,7 +333,7 @@ export function GameModal({ gameId, onClose }: GameModalProps) { queryClient.invalidateQueries({ queryKey: ["gameProgress", gameId, userId] }); queryClient.invalidateQueries({ queryKey: ["gameReview", gameId, userId] }); queryClient.invalidateQueries({ queryKey: ["gameReviews", gameId] }); - queryClient.invalidateQueries({ queryKey: ["gameScreenshots", gameId] }); + queryClient.invalidateQueries({ queryKey: ["game-screenshots"] }); queryClient.invalidateQueries({ queryKey: ["game"] }); setConfirmDeleteOpen(false); onClose?.(); @@ -341,15 +370,38 @@ export function GameModal({ gameId, onClose }: GameModalProps) { setNewListInput(null); }; + const uploadScreenshot = async (file: File) => { + const tempId = crypto.randomUUID(); + const previewUrl = URL.createObjectURL(file); + + setUploadingScreenshots((prev) => [...prev, { tempId, previewUrl }]); + + try { + const url = await uploadImageMutation.mutateAsync(file); + + await createScreenshotMutation.mutateAsync({ gameId: gameId as string, url }); + } catch { + toast.error(t("feed:screenshotUploadFailed")); + } finally { + setUploadingScreenshots((prev) => prev.filter((screenshot) => screenshot.tempId !== tempId)); + URL.revokeObjectURL(previewUrl); + } + }; + const handleFileSelect = (files: FileList | null) => { - if (!files) return; - const newScreenshots: PendingScreenshot[] = Array.from(files).map((file) => ({ - file, - previewUrl: URL.createObjectURL(file), - isSpoiler: false, - description: "", - })); - setPendingScreenshots((prev) => [...prev, ...newScreenshots]); + if (!files || !gameId) return; + + for (const file of Array.from(files)) { + const error = validateScreenshotFile(file); + + if (error) { + toast.error(t(error, { name: file.name })); + continue; + } + + // Each file uploads on its own so a single failure doesn't drop the others. + void uploadScreenshot(file); + } }; const handleDrop = (event: DragEvent) => { @@ -357,29 +409,6 @@ export function GameModal({ gameId, onClose }: GameModalProps) { handleFileSelect(event.dataTransfer.files); }; - const handleRemoveScreenshot = (index: number) => { - setPendingScreenshots((prev) => { - URL.revokeObjectURL(prev[index].previewUrl); - return prev.filter((_, i) => i !== index); - }); - }; - - const uploadScreenshots = async (gameReviewId: string) => { - await Promise.all( - pendingScreenshots.map(async (screenshot) => { - const imageUrl = await uploadImageMutation.mutateAsync(screenshot.file); - await api.post(apiEndpoints.gameReviewScreenshot, { - gameReviewId, - isSpoiler: screenshot.isSpoiler, - url: imageUrl, - description: screenshot.description || undefined, - }); - }), - ); - setPendingScreenshots([]); - queryClient.invalidateQueries({ queryKey: ["gameScreenshots", gameId] }); - }; - const handleSave = async () => { const progress = progressForm.getValues(); @@ -398,11 +427,7 @@ export function GameModal({ gameId, onClose }: GameModalProps) { if ((progress.status === "played" || progress.status === "dropped") && Number(review.overall) > 0) { if (!(await reviewForm.trigger())) return; - const reviewId = await saveReviewMutation.mutateAsync(review); - - if (reviewId && pendingScreenshots.length > 0) { - await uploadScreenshots(reviewId); - } + await saveReviewMutation.mutateAsync(review); } onClose?.(); @@ -411,7 +436,7 @@ export function GameModal({ gameId, onClose }: GameModalProps) { } }; - const isSaving = saveProgressMutation.isPending || saveReviewMutation.isPending || uploadImageMutation.isPending; + const isSaving = saveProgressMutation.isPending || saveReviewMutation.isPending; return (
@@ -581,94 +606,88 @@ export function GameModal({ gameId, onClose }: GameModalProps) {
- {/** biome-ignore lint/a11y/useSemanticElements: false */} -
e.preventDefault()} - onDrop={handleDrop} - onClick={() => fileInputRef.current?.click()} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - > - -

- , - }} +

+

+ + {t("common:screenshots")} +

+ + {/** biome-ignore lint/a11y/useSemanticElements: false */} +
e.preventDefault()} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + > + +

+ , + }} + /> +

+ handleFileSelect(e.target.files)} /> -

- handleFileSelect(e.target.files)} - /> -
+
- {pendingScreenshots.length > 0 && ( -
- {pendingScreenshots.map((screenshot, index) => ( -
- Screenshot preview -
- - setPendingScreenshots((prev) => - prev.map((s, i) => (i === index ? { ...s, description: e.target.value } : s)), - ) - } + {(screenshots.length > 0 || uploadingScreenshots.length > 0) && ( +
+ {uploadingScreenshots.map((screenshot) => ( +
+ - - - setPendingScreenshots((prev) => - prev.map((s, i) => (i === index ? { ...s, isSpoiler: !!checked } : s)), - ) - } - /> - - {t("feed:spoiler")} - - + + + {t("feed:screenshotUploading")} +
- -
- ))} -
- )} + ))} + + {screenshots.map((screenshot) => ( + + updateScreenshotMutation.mutate({ screenshotId: screenshot.id, description }) + } + onSpoilerChange={(isSpoiler) => + updateScreenshotMutation.mutate({ screenshotId: screenshot.id, isSpoiler }) + } + onRemove={() => deleteScreenshotMutation.mutate(screenshot.id)} + isRemoving={ + deleteScreenshotMutation.isPending && deleteScreenshotMutation.variables === screenshot.id + } + /> + ))} +
+ )} +
@@ -942,3 +961,71 @@ export function GameModal({ gameId, onClose }: GameModalProps) {
); } + +interface ScreenshotRowProps { + screenshot: ApiTypes.GameScreenshot; + onDescriptionChange: (description: string) => void; + onSpoilerChange: (isSpoiler: boolean) => void; + onRemove: () => void; + isRemoving: boolean; +} + +/** + * Keeps the description in local state so typing never fights the refetch that + * follows each PATCH; the change is only committed on blur. + */ +function ScreenshotRow({ screenshot, onDescriptionChange, onSpoilerChange, onRemove, isRemoving }: ScreenshotRowProps) { + const { t } = useTranslation(); + + const [description, setDescription] = useState(screenshot.description ?? ""); + + const handleBlur = () => { + if (description === (screenshot.description ?? "")) return; + + onDescriptionChange(description); + }; + + return ( +
+ {screenshot.description +
+ setDescription(e.target.value)} + onBlur={handleBlur} + /> + + onSpoilerChange(!!checked)} + /> + + {t("feed:spoiler")} + + +
+ +
+ ); +} diff --git a/src/hooks/game.ts b/src/hooks/game.ts index fec43b4..c026220 100644 --- a/src/hooks/game.ts +++ b/src/hooks/game.ts @@ -1,5 +1,11 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; -import { api, apiEndpoints } from "@/lib/api.ts"; +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type ApiTypes, api, apiEndpoints } from "@/lib/api.ts"; + +const SCREENSHOTS_PER_PAGE = 20; + +const SCREENSHOT_MAX_SIZE = 1024 * 1024 * 5; + +const SCREENSHOT_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp)$/i; interface GameProgress { hoursPlayed: number; @@ -10,13 +16,6 @@ interface GameProgress { finishDate: string | null; } -interface GameReviewScreenshot { - gameReviewId: string; - isSpoiler: boolean; - url: string; - description?: string; -} - interface GameReviewPayload { gameId: string; userId: string; @@ -29,7 +28,24 @@ interface GameReviewPayload { platform?: string; recommended?: boolean; summary?: string; - screenshots?: GameReviewScreenshot[]; +} + +interface GameScreenshotsFilters { + userId?: string; + gameId?: string; +} + +interface CreateGameScreenshotPayload { + gameId: string; + url: string; + description?: string; + isSpoiler?: boolean; +} + +interface UpdateGameScreenshotPayload { + screenshotId: string; + description?: string; + isSpoiler?: boolean; } export function useGameProgress(userId: string | undefined, gameId: string | undefined) { @@ -55,15 +71,6 @@ export function useGameReview() { }); } -export function useGameScreenshot() { - return useMutation({ - mutationFn: async (payload: GameReviewScreenshot) => { - const { data } = await api.post(apiEndpoints.gameReviewScreenshot, payload); - return data; - }, - }); -} - export function useUploadImage() { return useMutation({ mutationFn: async (file: File) => { @@ -74,3 +81,79 @@ export function useUploadImage() { }, }); } + +/** + * Mirrors the multer `imageConfig` used by the API so the user gets feedback + * before the file leaves the browser. Returns an i18n key, or null when valid. + */ +export function validateScreenshotFile(file: File): string | null { + if (!SCREENSHOT_EXTENSIONS.test(file.name)) return "feed:screenshotTypeInvalid"; + if (file.size > SCREENSHOT_MAX_SIZE) return "feed:screenshotTooLarge"; + return null; +} + +export function gameScreenshotsQueryKey({ userId, gameId }: GameScreenshotsFilters) { + return ["game-screenshots", userId ?? null, gameId ?? null]; +} + +export function useGameScreenshots({ userId, gameId }: GameScreenshotsFilters) { + return useInfiniteQuery({ + queryKey: gameScreenshotsQueryKey({ userId, gameId }), + queryFn: ({ pageParam }) => + api + .get(`${apiEndpoints.gameScreenshot}/`, { + params: { + ...(userId && { userId }), + ...(gameId && { gameId }), + page: pageParam, + itemsPerPage: SCREENSHOTS_PER_PAGE, + }, + }) + .then(({ data }) => data.screenshots), + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.inPage < lastPage.pages ? lastPage.inPage + 1 : undefined), + enabled: !!userId || !!gameId, + }); +} + +function useInvalidateGameScreenshots() { + const queryClient = useQueryClient(); + + return () => queryClient.invalidateQueries({ queryKey: ["game-screenshots"] }); +} + +export function useCreateGameScreenshot() { + const invalidate = useInvalidateGameScreenshots(); + + return useMutation({ + mutationFn: async (payload: CreateGameScreenshotPayload) => { + const { data } = await api.post(apiEndpoints.gameScreenshot, payload); + return data.screenshot; + }, + onSuccess: invalidate, + }); +} + +export function useUpdateGameScreenshot() { + const invalidate = useInvalidateGameScreenshots(); + + return useMutation({ + mutationFn: async ({ screenshotId, ...payload }: UpdateGameScreenshotPayload) => { + const { data } = await api.patch( + apiEndpoints.gameScreenshotById(screenshotId), + payload, + ); + return data.screenshot; + }, + onSuccess: invalidate, + }); +} + +export function useDeleteGameScreenshot() { + const invalidate = useInvalidateGameScreenshots(); + + return useMutation({ + mutationFn: (screenshotId: string) => api.delete(apiEndpoints.gameScreenshotById(screenshotId)), + onSuccess: invalidate, + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 290de06..3968a90 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -298,6 +298,7 @@ export namespace ApiTypes { lists: number; favorites: number; reviews: number; + screenshots: number; }; latestReviewType: ReviewContentType | null; latestProgressType: ReviewContentType | null; @@ -693,6 +694,32 @@ export namespace ApiTypes { mangaReviews?: PaginatedResponse; } + export interface GameScreenshotGame { + id: string; + igdbId: number; + name: string; + coverUrl: string | null; + } + + export interface GameScreenshot { + id: string; + url: string; + description: string | null; + isSpoiler: boolean; + userId: string; + gameId: string; + createdAt: string; + game: GameScreenshotGame; + } + + export interface GetGameScreenshotsResponse { + screenshots: PaginatedResponse; + } + + export interface GameScreenshotResponse { + screenshot: GameScreenshot; + } + export type ActivityType = | "AccountCreated" | "ListCreated" @@ -862,7 +889,8 @@ export const apiEndpoints = { getGameComing: "/game/top?filter=coming", getGameAnticipated: "/game/top?filter=antecipated", getGameRecentlyReleased: "/game/top?filter=recentlyReleased", - gameReviewScreenshot: "/game/review/screenshot", + gameScreenshot: "/game/screenshot", + gameScreenshotById: (screenshotId: string) => `/game/screenshot/${screenshotId}`, gameReview: "/game/review", gameProgress: "/game/progress", getGameProgress: (userId: string, gameId: string) => `/game/progress?userId=${userId}&gameId=${gameId}`, diff --git a/src/lib/i18n/locales/en-US/feed.json b/src/lib/i18n/locales/en-US/feed.json index b5a4994..b0a9b58 100644 --- a/src/lib/i18n/locales/en-US/feed.json +++ b/src/lib/i18n/locales/en-US/feed.json @@ -94,6 +94,11 @@ "endless": "Endless" }, "uploadScreenshot": "Drag and drop or choose screenshots to upload", + "screenshotDescription": "Add a description", + "screenshotUploading": "Uploading...", + "screenshotUploadFailed": "Couldn't upload the screenshot. Try again.", + "screenshotTooLarge": "{{name}} is larger than 5 MB.", + "screenshotTypeInvalid": "{{name}} is not a supported image (jpg, jpeg, png, gif, webp).", "addNegative": "Add a Negative Point", "addPositive": "Add a Positive Point", "pros": "Pros", diff --git a/src/lib/i18n/locales/en-US/user.json b/src/lib/i18n/locales/en-US/user.json index b36b299..0aed84e 100644 --- a/src/lib/i18n/locales/en-US/user.json +++ b/src/lib/i18n/locales/en-US/user.json @@ -45,6 +45,8 @@ "lists": "Lists", "noProgress": "No progress yet.", "noProgressDescription": "Titles this user is tracking will show up here.", + "noScreenshots": "No screenshots yet.", + "noScreenshotsDescription": "Screenshots this user uploads will show up here.", "copyProfileUsernameSuccess": "Username copied to clipboard!", "sort": { "placeholder": "Sort by:", diff --git a/src/routes/game/$slug.tsx b/src/routes/game/$slug.tsx index fe2de17..5655f86 100644 --- a/src/routes/game/$slug.tsx +++ b/src/routes/game/$slug.tsx @@ -30,6 +30,7 @@ import { ImageZoom } from "@/components/ui/image-zoom"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { gameScreenshotsQueryKey } from "@/hooks/game"; import { useToggleReviewReaction } from "@/hooks/review"; import { type ApiTypes, api, apiEndpoints } from "@/lib/api.ts"; import { useSession } from "@/lib/auth.ts"; @@ -209,9 +210,13 @@ function GameDetailsRoute() { enabled: !!game?.id, }, { - queryKey: ["gameScreenshots", game?.id], + queryKey: gameScreenshotsQueryKey({ gameId: game?.id }), queryFn: () => - api.get(`${apiEndpoints.gameReviewScreenshot}/?gameId=${game?.id}`).then(({ data }) => data.screenshots), + api + .get(`${apiEndpoints.gameScreenshot}/`, { + params: { gameId: game?.id }, + }) + .then(({ data }) => data.screenshots), enabled: !!game?.id, }, ], @@ -516,7 +521,7 @@ function GameDetailsRoute() { {!screenshotsQuery.isLoading && !screenshotsQuery.isError && ( - {t("common:screenshots")} ({screenshots?.length ?? 0}) + {t("common:screenshots")} ({screenshots?.total ?? 0}) )} @@ -738,10 +743,8 @@ function GameDetailsRoute() { {!screenshotsQuery.isLoading && !screenshotsQuery.isError && (
- {screenshots?.map((screenshot: { checksum: string; imageId: string }) => ( - - - + {screenshots?.items.map((screenshot) => ( + ))}
@@ -804,3 +807,35 @@ function GameDetailsRoute() { ); } + +function UserScreenshot({ screenshot }: { screenshot: ApiTypes.GameScreenshot }) { + const [revealed, setRevealed] = useState(!screenshot.isSpoiler); + + if (!revealed) { + return ( + + ); + } + + return ( +
+ + {screenshot.description + + {screenshot.description && ( +
{screenshot.description}
+ )} +
+ ); +} diff --git a/src/routes/user/$username/index.tsx b/src/routes/user/$username/index.tsx index 0ca3f8c..123261d 100644 --- a/src/routes/user/$username/index.tsx +++ b/src/routes/user/$username/index.tsx @@ -121,7 +121,9 @@ function UserDetailsRoute() { {t("user:favorites")} ({userQuery.data.counts.favorites}) - {t("common:screenshots")} (0) + + {t("common:screenshots")} ({userQuery.data.counts.screenshots}) + @@ -167,7 +169,7 @@ function UserDetailsRoute() { - + From 3b02b04f2b04862dff17987446f44cf4af1d7fe7 Mon Sep 17 00:00:00 2001 From: izakdvlpr Date: Wed, 29 Jul 2026 10:57:35 -0300 Subject: [PATCH 2/2] chore(biome): format code --- src/providers/tanstack-devtools-provider/index.ts | 2 +- src/routes/__root.tsx | 4 ++-- src/routes/_authenticated/settings.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/providers/tanstack-devtools-provider/index.ts b/src/providers/tanstack-devtools-provider/index.ts index 7f0440b..1a8f850 100644 --- a/src/providers/tanstack-devtools-provider/index.ts +++ b/src/providers/tanstack-devtools-provider/index.ts @@ -1 +1 @@ -export * from './provider' \ No newline at end of file +export * from "./provider"; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index b6fb825..d87adcd 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -106,10 +106,10 @@ function RootDocument({ children }: Readonly<{ children: ReactNode }>) { data-allow-localhost="false" /> - + {children} - + diff --git a/src/routes/_authenticated/settings.tsx b/src/routes/_authenticated/settings.tsx index e0b8cdd..5a41c6c 100644 --- a/src/routes/_authenticated/settings.tsx +++ b/src/routes/_authenticated/settings.tsx @@ -115,7 +115,7 @@ function SettingsRoute() { if (variables.language !== i18n.language) { setLanguageCookie(variables.language); - + window.localStorage.setItem(LANGUAGE_TOKEN, variables.language); await i18n.changeLanguage(variables.language);