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