diff --git a/src/components/pages/user/overview-tab/index.tsx b/src/components/pages/user/overview-tab/index.tsx index 36d10cd..21e4775 100644 --- a/src/components/pages/user/overview-tab/index.tsx +++ b/src/components/pages/user/overview-tab/index.tsx @@ -10,14 +10,15 @@ import { StatisticsCard } from "./statistics-card"; interface UserOverviewTabProps { user: ApiTypes.User; onSeeFavorites?: () => void; + onSeeProgress?: (contentType: ApiTypes.ReviewContentType) => void; } -export function UserOverviewTab({ user, onSeeFavorites }: UserOverviewTabProps) { +export function UserOverviewTab({ user, onSeeFavorites, onSeeProgress }: UserOverviewTabProps) { const session = useSession(); return (
- +
diff --git a/src/components/pages/user/overview-tab/statistics-card.tsx b/src/components/pages/user/overview-tab/statistics-card.tsx index 60e6a9e..24088d8 100644 --- a/src/components/pages/user/overview-tab/statistics-card.tsx +++ b/src/components/pages/user/overview-tab/statistics-card.tsx @@ -5,12 +5,14 @@ import type { ApiTypes } from "@/lib/api"; interface StatisticsCardProps { user: ApiTypes.User; + onSeeProgress?: (contentType: ApiTypes.ReviewContentType) => void; } type MediaKey = keyof ApiTypes.User["progressStats"]; interface MediaConfig { key: MediaKey; + contentType: ApiTypes.ReviewContentType; icon: string; label: string; gradient: string; @@ -21,6 +23,7 @@ interface MediaConfig { const MEDIA_CONFIG: MediaConfig[] = [ { key: "anime", + contentType: "anime", icon: "lucide:mountain", label: "common:types.anime_other", gradient: "from-purple-500/20", @@ -29,6 +32,7 @@ const MEDIA_CONFIG: MediaConfig[] = [ }, { key: "manga", + contentType: "manga", icon: "lucide:library-big", label: "common:types.manga_other", gradient: "from-rose-500/20", @@ -37,6 +41,7 @@ const MEDIA_CONFIG: MediaConfig[] = [ }, { key: "tvShow", + contentType: "tv", icon: "lucide:tv-minimal-play", label: "common:types.tv_other", gradient: "from-blue-500/20", @@ -45,6 +50,7 @@ const MEDIA_CONFIG: MediaConfig[] = [ }, { key: "movie", + contentType: "movie", icon: "lucide:clapperboard", label: "common:types.movie_other", gradient: "from-amber-500/20", @@ -53,6 +59,7 @@ const MEDIA_CONFIG: MediaConfig[] = [ }, { key: "game", + contentType: "game", icon: "lucide:gamepad-2", label: "common:types.game_other", gradient: "from-emerald-500/20", @@ -61,6 +68,7 @@ const MEDIA_CONFIG: MediaConfig[] = [ }, { key: "book", + contentType: "book", icon: "lucide:book", label: "common:types.book_other", gradient: "from-indigo-500/20", @@ -76,6 +84,7 @@ function MediaTile({ border, iconColor, total, + onClick, }: { icon: string; label: string; @@ -83,11 +92,17 @@ function MediaTile({ border: string; iconColor: string; total: number; + onClick?: () => void; }) { const { t } = useTranslation(); return ( -
+
-
+ ); } -export function StatisticsCard({ user }: StatisticsCardProps) { +export function StatisticsCard({ user, onSeeProgress }: StatisticsCardProps) { const { t } = useTranslation(); return ( @@ -115,7 +130,7 @@ export function StatisticsCard({ user }: StatisticsCardProps) {
- {MEDIA_CONFIG.map(({ key, icon, label, gradient, border, iconColor }) => ( + {MEDIA_CONFIG.map(({ key, contentType, icon, label, gradient, border, iconColor }) => ( onSeeProgress(contentType) : undefined} /> ))}
diff --git a/src/components/pages/user/progress-tab.tsx b/src/components/pages/user/progress-tab.tsx new file mode 100644 index 0000000..3913177 --- /dev/null +++ b/src/components/pages/user/progress-tab.tsx @@ -0,0 +1,124 @@ +import { Icon } from "@iconify/react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { FavoriteCard, type FavoriteItem } from "@/components/pages/user/overview-tab/favorite-card"; +import { Button } from "@/components/ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { PROGRESS_CONTENT, progressStatusSections, progressToItem, useUserProgress } from "@/hooks/progress"; +import type { ApiTypes } from "@/lib/api"; + +const CONTENT_TYPES: { type: ApiTypes.ReviewContentType; labelKey: string }[] = [ + { type: "game", labelKey: "common:types.game_other" }, + { type: "tv", labelKey: "common:types.tv_other" }, + { type: "anime", labelKey: "common:types.anime_other" }, + { type: "movie", labelKey: "common:types.movie_other" }, + { type: "manga", labelKey: "common:types.manga_other" }, + { type: "book", labelKey: "common:types.book_other" }, +]; + +export function UserProgressTab({ + userId, + progressStats, + contentType, + onContentTypeChange, +}: { + userId: string; + progressStats: ApiTypes.User["progressStats"]; + contentType: ApiTypes.ReviewContentType; + onContentTypeChange: (contentType: ApiTypes.ReviewContentType) => void; +}) { + const { t } = useTranslation(); + + const progressQuery = useUserProgress(contentType, userId); + + const rows = useMemo(() => progressQuery.data?.pages.flatMap((page) => page.items) ?? [], [progressQuery.data]); + + const sections = useMemo(() => { + const typeStats = progressStats[PROGRESS_CONTENT[contentType].statsKey] as unknown as Record< + string, + { count: number } + >; + + return progressStatusSections(contentType) + .map((section) => ({ + ...section, + count: typeStats[section.statsKey]?.count ?? 0, + items: rows + .filter((row) => row.status === section.status) + .map((row) => progressToItem(contentType, row)) + .filter((item): item is FavoriteItem => item !== null), + })) + .filter((section) => section.items.length > 0); + }, [rows, contentType, progressStats]); + + return ( +
+
+ +
+ + {progressQuery.isLoading ? ( +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+ ) : sections.length === 0 ? ( + + + + + + {t("user:noProgress")} + {t("user:noProgressDescription")} + + + ) : ( + sections.map((section) => ( +
+

+ {t(section.labelKey)} ({section.count}) +

+ +
+ {section.items.map((item) => ( + + ))} +
+
+ )) + )} + + {progressQuery.hasNextPage && ( +
+ +
+ )} +
+ ); +} + +function ProgressSkeleton() { + return ; +} diff --git a/src/hooks/progress.ts b/src/hooks/progress.ts new file mode 100644 index 0000000..5ae981f --- /dev/null +++ b/src/hooks/progress.ts @@ -0,0 +1,173 @@ +import { useInfiniteQuery } from "@tanstack/react-query"; +import type { FavoriteItem } from "@/components/pages/user/overview-tab/favorite-card"; +import { type ApiTypes, api, apiEndpoints } from "@/lib/api.ts"; + +const ITEMS_PER_PAGE = 18; + +type ProgressStatsKey = keyof ApiTypes.User["progressStats"]; + +interface ProgressContentConfig { + endpoint: string; + responseKey: keyof ApiTypes.GetProgressResponse; + /** Key into `progressStats` (note: content type "tv" maps to stats key "tvShow"). */ + statsKey: ProgressStatsKey; + /** Active-progress status for this content type. */ + activeStatus: ApiTypes.ProgressStatus; + /** `progressStats` sub-key + `feed:lists.*` i18n key for the active status. */ + activeStatsKey: "watching" | "playing" | "reading"; +} + +export const PROGRESS_CONTENT: Record = { + anime: { + endpoint: apiEndpoints.animeProgress, + responseKey: "animeProgresses", + statsKey: "anime", + activeStatus: "Watching", + activeStatsKey: "watching", + }, + manga: { + endpoint: apiEndpoints.mangaProgress, + responseKey: "mangaProgresses", + statsKey: "manga", + activeStatus: "Reading", + activeStatsKey: "reading", + }, + tv: { + endpoint: apiEndpoints.tvShowProgress, + responseKey: "tvShowProgresses", + statsKey: "tvShow", + activeStatus: "Watching", + activeStatsKey: "watching", + }, + movie: { + endpoint: apiEndpoints.movieProgress, + responseKey: "movieProgresses", + statsKey: "movie", + activeStatus: "Watching", + activeStatsKey: "watching", + }, + game: { + endpoint: apiEndpoints.gameProgress, + responseKey: "gameProgresses", + statsKey: "game", + activeStatus: "Playing", + activeStatsKey: "playing", + }, + book: { + endpoint: apiEndpoints.bookProgress, + responseKey: "bookProgresses", + statsKey: "book", + activeStatus: "Reading", + activeStatsKey: "reading", + }, +}; + +export interface ProgressStatusSection { + /** `ProgressStatus` value used to bucket the loaded rows. */ + status: ApiTypes.ProgressStatus; + /** Key into `progressStats[type]` for the accurate total count. */ + statsKey: string; + /** `feed:lists.*` i18n key for the section heading. */ + labelKey: string; +} + +/** Ordered status sections for a content type: active first, then paused/dropped/completed/planning. */ +export function progressStatusSections(contentType: ApiTypes.ReviewContentType): ProgressStatusSection[] { + const { activeStatus, activeStatsKey } = PROGRESS_CONTENT[contentType]; + + return [ + { status: activeStatus, statsKey: activeStatsKey, labelKey: `feed:lists.${activeStatsKey}` }, + { status: "Paused", statsKey: "paused", labelKey: "feed:lists.paused" }, + { status: "Dropped", statsKey: "dropped", labelKey: "feed:lists.dropped" }, + { status: "Completed", statsKey: "completed", labelKey: "feed:lists.completed" }, + { status: "Planning", statsKey: "planning", labelKey: "feed:lists.planning" }, + ]; +} + +/** Maps a progress row to a `FavoriteItem` so it can render with `FavoriteCard`. */ +export function progressToItem(contentType: ApiTypes.ReviewContentType, row: ApiTypes.Progress): FavoriteItem | null { + switch (contentType) { + case "anime": + if (!row.anime) return null; + return { + id: row.id, + title: row.anime.title, + image: row.anime.imageUrl ?? "", + contentType: "anime", + slug: String(row.anime.malId), + mediaId: row.anime.id, + }; + case "manga": + if (!row.manga) return null; + return { + id: row.id, + title: row.manga.title, + image: row.manga.imageUrl ?? "", + contentType: "manga", + slug: String(row.manga.malId), + mediaId: row.manga.id, + }; + case "tv": + if (!row.tvShow) return null; + return { + id: row.id, + title: row.tvShow.name, + image: row.tvShow.backdropUrl ?? "", + contentType: "tv", + slug: String(row.tvShow.tmdbId), + mediaId: row.tvShow.id, + }; + case "movie": + if (!row.movie) return null; + return { + id: row.id, + title: row.movie.title, + image: row.movie.backdropUrl ?? "", + contentType: "movie", + slug: String(row.movie.tmdbId), + mediaId: row.movie.id, + }; + case "game": + if (!row.game) return null; + return { + id: row.id, + title: row.game.name, + image: row.game.coverUrl ?? "", + contentType: "game", + slug: String(row.game.igdbId), + mediaId: row.game.id, + }; + case "book": + if (!row.book) return null; + return { + id: row.id, + title: row.book.title, + image: row.book.imageUrl ?? "", + contentType: "book", + slug: String(row.book.hardcoverId), + mediaId: row.book.id, + }; + default: + return null; + } +} + +export function userProgressQueryKey(contentType: ApiTypes.ReviewContentType, userId: string) { + return ["user-progress", contentType, userId]; +} + +export function useUserProgress(contentType: ApiTypes.ReviewContentType, userId: string) { + const config = PROGRESS_CONTENT[contentType]; + + return useInfiniteQuery({ + queryKey: userProgressQueryKey(contentType, userId), + queryFn: ({ pageParam }) => + api + .get(`${config.endpoint}/`, { + params: { userId, page: pageParam, itemsPerPage: ITEMS_PER_PAGE }, + }) + .then(({ data }) => data[config.responseKey] as ApiTypes.PaginatedResponse), + initialPageParam: 1, + getNextPageParam: (lastPage) => (lastPage.inPage < lastPage.pages ? lastPage.inPage + 1 : undefined), + }); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 150dd55..4eb3fa7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -299,6 +299,7 @@ export namespace ApiTypes { reviews: number; }; latestReviewType: ReviewContentType | null; + latestProgressType: ReviewContentType | null; } export interface GetUserByUsernameResponse { @@ -498,6 +499,39 @@ export namespace ApiTypes { favorited: boolean; } + /** User progress statuses (mirrors the backend `ProgressStatus` enum). */ + export type ProgressStatus = + | "NotWatched" + | "NotRead" + | "NotPlayed" + | "Watching" + | "Playing" + | "Reading" + | "Completed" + | "Paused" + | "Dropped" + | "Planning"; + + export interface Progress { + id: string; + status: ProgressStatus; + anime: { id: string; malId: number; title: string; imageUrl: string | null } | null; + manga: { id: string; malId: number; title: string; imageUrl: string | null } | null; + tvShow: { id: string; tmdbId: number; name: string; backdropUrl: string | null } | null; + movie: { id: string; tmdbId: number; title: string; backdropUrl: string | null } | null; + game: { id: string; igdbId: number; name: string; coverUrl: string | null } | null; + book: { id: string; hardcoverId: number; title: string; imageUrl: string | null } | null; + } + + export interface GetProgressResponse { + animeProgresses?: PaginatedResponse; + mangaProgresses?: PaginatedResponse; + tvShowProgresses?: PaginatedResponse; + movieProgresses?: PaginatedResponse; + gameProgresses?: PaginatedResponse; + bookProgresses?: PaginatedResponse; + } + export interface FavoriteRequest { type: FavoriteType; position?: number; @@ -837,11 +871,13 @@ export const apiEndpoints = { getAnimeComingSoon: "/anime/top?filter=upcoming", getAnimeTop: "/anime/top?filter=favorite", animeReview: "/anime/review", + animeProgress: "/anime/progress", refreshAnimeData: "/anime/refresh", getBookDetails: (id: string) => `/book/detail/${id}`, getBookTrending: "/book/top?filter=trending", getBookComingSoon: "/book/top?filter=comingSoon", bookReview: "/book/review", + bookProgress: "/book/progress", refreshBookData: "/book/refresh", getMovieDetails: (id: string) => `/movie/detail/${id}`, getMovieAiring: "/movie/top?filter=airing", @@ -874,6 +910,7 @@ export const apiEndpoints = { getMangaFavorite: "/manga/top?filter=favorite", getMangaRecommendations: "/manga/top?filter=bypopularity", mangaReview: "/manga/review", + mangaProgress: "/manga/progress", refreshMangaData: "/manga/refresh", list: "/list", getListsByUserId: (userId: string) => `/list/user/${userId}`, diff --git a/src/lib/i18n/locales/en-US/user.json b/src/lib/i18n/locales/en-US/user.json index f53db92..e530daa 100644 --- a/src/lib/i18n/locales/en-US/user.json +++ b/src/lib/i18n/locales/en-US/user.json @@ -45,6 +45,9 @@ "activityLastYear": "{{value}} activities in the last year", "activityNoCount": "No", "lists": "Lists", + "progress": "Progress", + "noProgress": "No progress yet.", + "noProgressDescription": "Titles this user is tracking will show up here.", "copyProfileUsernameSuccess": "Username copied to clipboard!", "sort": { "placeholder": "Sort by:", diff --git a/src/routes/user/$username/index.tsx b/src/routes/user/$username/index.tsx index 8537cda..86d9a90 100644 --- a/src/routes/user/$username/index.tsx +++ b/src/routes/user/$username/index.tsx @@ -3,12 +3,14 @@ import { createFileRoute, notFound, redirect } from "@tanstack/react-router"; import { Image } from "@unpic/react"; import axios from "axios"; import { useQueryState } from "nuqs"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { UserActivityTab } from "@/components/pages/user/activity-tab"; import { UserFavoritesTab } from "@/components/pages/user/favorites-tab"; import { UserFollowersTab, UserFollowingTab } from "@/components/pages/user/follows-tab"; import { UserListsTab } from "@/components/pages/user/lists-tab"; import { UserOverviewTab } from "@/components/pages/user/overview-tab"; +import { UserProgressTab } from "@/components/pages/user/progress-tab"; import { UserReviewsTab } from "@/components/pages/user/reviews-tab"; import { UserScreenshotsTab } from "@/components/pages/user/screenshots-tab"; import { UserProfileHeader } from "@/components/pages/user/user-profile-header"; @@ -60,6 +62,7 @@ function UserDetailsRoute() { const { t } = useTranslation(); const session = useSession(); const [activeTab, setActiveTab] = useQueryState("tab", { defaultValue: "overview" }); + const [progressType, setProgressType] = useState(loaderUser.latestProgressType ?? "game"); const userQuery = useQuery({ queryKey: ["user", username], @@ -67,6 +70,9 @@ function UserDetailsRoute() { initialData: loaderUser, }); + const { progressStats } = userQuery.data; + const totalProgress = Object.values(progressStats).reduce((sum, stats) => sum + stats.total, 0); + return (
@@ -97,6 +103,9 @@ function UserDetailsRoute() { {t("user:overview")} {t("user:activity")} + + {t("user:progress")} ({totalProgress}) + {t("user:lists")} ({userQuery.data.counts.lists}) @@ -110,13 +119,29 @@ function UserDetailsRoute() { - setActiveTab("favorites")} /> + setActiveTab("favorites")} + onSeeProgress={(nextContentType) => { + setProgressType(nextContentType); + setActiveTab("progress"); + }} + /> + + + +