diff --git a/bun.lock b/bun.lock index 7da5571..63a8913 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "trackverse", diff --git a/index.html b/index.html index dd4ed34..3590eb7 100644 --- a/index.html +++ b/index.html @@ -9,7 +9,7 @@ - +
diff --git a/src/components/pages/details/details-page-layout.tsx b/src/components/pages/details/details-page-layout.tsx index 5c535f6..b5af5a5 100644 --- a/src/components/pages/details/details-page-layout.tsx +++ b/src/components/pages/details/details-page-layout.tsx @@ -8,8 +8,8 @@ interface DetailsPageLayoutProps { export function DetailsPageLayout({ sidebar, children }: DetailsPageLayoutProps) { return ( -
-
+
+
{sidebar} diff --git a/src/components/pages/feed/activity-item.tsx b/src/components/pages/feed/activity-item.tsx index 257d9b6..b93dd0e 100644 --- a/src/components/pages/feed/activity-item.tsx +++ b/src/components/pages/feed/activity-item.tsx @@ -114,13 +114,18 @@ export function ActivityItem({ profile, item, onReact, isReacting = false }: fee onClick={() => onReact?.(emoji, currentReaction)} disabled={isReacting || !currentUserId} aria-pressed={active} - className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-sm transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ${ + className={`inline-flex items-center gap-1 rounded-full border px-2 py-1 text-sm transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed ${ active ? "border-primary bg-primary/10 text-primary" : "border-border/50 hover:border-primary/50 hover:bg-muted" }`} > - {emoji} + + {emoji} + {count > 0 && {count}} ); diff --git a/src/components/ui/image-zoom.tsx b/src/components/ui/image-zoom.tsx index 3bf8e9d..b228682 100644 --- a/src/components/ui/image-zoom.tsx +++ b/src/components/ui/image-zoom.tsx @@ -30,11 +30,11 @@ export const ImageZoom = ({ className, backdropClassName, ...props }: ImageZoomP classDialog={cn( "[&::backdrop]:hidden", "[&[open]]:fixed [&[open]]:m-0 [&[open]]:h-dvh [&[open]]:max-h-none [&[open]]:w-dvw [&[open]]:max-w-none [&[open]]:overflow-hidden [&[open]]:border-0 [&[open]]:bg-transparent [&[open]]:p-0", - "[&_[data-rmiz-modal-overlay]]:absolute [&_[data-rmiz-modal-overlay]]:inset-0 [&_[data-rmiz-modal-overlay]]:transition-all", + "[&_[data-rmiz-modal-overlay]]:absolute [&_[data-rmiz-modal-overlay]]:inset-0 [&_[data-rmiz-modal-overlay]]:transition-all [&_[data-rmiz-modal-overlay]]:duration-300 [&_[data-rmiz-modal-overlay]]:ease-out", '[&_[data-rmiz-modal-overlay="hidden"]]:bg-transparent', '**:data-[rmiz-modal-overlay="visible"]:bg-transparent! [&_[data-rmiz-modal-overlay="visible"]]:backdrop-blur-md', "[&_[data-rmiz-modal-content]]:relative [&_[data-rmiz-modal-content]]:size-full", - "[&_[data-rmiz-modal-img]]:absolute [&_[data-rmiz-modal-img]]:origin-top-left [&_[data-rmiz-modal-img]]:cursor-zoom-out [&_[data-rmiz-modal-img]]:transition-transform", + "[&_[data-rmiz-modal-img]]:absolute [&_[data-rmiz-modal-img]]:origin-top-left [&_[data-rmiz-modal-img]]:cursor-zoom-out [&_[data-rmiz-modal-img]]:transition-transform [&_[data-rmiz-modal-img]]:duration-300 [&_[data-rmiz-modal-img]]:ease-out", "motion-reduce:[&_[data-rmiz-modal-img]]:transition-none motion-reduce:[&_[data-rmiz-modal-overlay]]:transition-none", backdropClassName, )} diff --git a/src/routes/anime/$slug.tsx b/src/routes/anime/$slug.tsx index 5353b1c..4023950 100644 --- a/src/routes/anime/$slug.tsx +++ b/src/routes/anime/$slug.tsx @@ -22,6 +22,11 @@ import { ErrorComponent } from "@/components/shared/error.tsx"; import { LoadingDetails } from "@/components/shared/loadings/details.tsx"; import { EpisodicContentModal } from "@/components/shared/modals/episodic-content"; import { RefreshData } from "@/components/shared/modals/refresh-data"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { type ApiTypes, api, apiEndpoints } from "@/lib/api.ts"; import { useSession } from "@/lib/auth.ts"; @@ -54,6 +59,7 @@ function AnimeDetailsRoute() { const { slug } = Route.useParams(); const { anime: loaderAnime } = Route.useLoaderData(); const { t } = useTranslation(); + const [moreOpen, setMoreOpen] = useState(false); const [mySeason, _setMySeason] = useState({ totalEpisodes: 12, watchedEpisodes: [1, 2, 3, 4, 5], @@ -109,6 +115,82 @@ function AnimeDetailsRoute() { const session = useSession(); const isAuthenticated = !!session?.data?.session; + const userId = session?.data?.user?.id; + const [newListName, setNewListName] = useState(""); + + const listsQuery = useQuery>({ + queryKey: ["animeContainingLists", anime?.id], + queryFn: () => + api + .get(apiEndpoints.getListsContainingItem, { + params: { type: "Anime", animeId: anime?.id, itemsPerPage: 50 }, + }) + .then(({ data }) => data.lists), + enabled: !!anime?.id, + }); + + const userListsQuery = useQuery({ + queryKey: ["animeLists", userId], + queryFn: () => + api + .get(apiEndpoints.getListsByUserId(userId as string), { + params: { type: "Anime", itemsPerPage: 50 }, + }) + .then(({ data }) => data.lists.items), + enabled: isAuthenticated && !!userId, + }); + + const listStatusQuery = useQuery({ + queryKey: ["animeListStatus", anime?.id, userId], + queryFn: () => + api + .get(apiEndpoints.getListStatus, { + params: { type: "Anime", animeId: anime?.id }, + }) + .then(({ data }) => data.listIds), + enabled: isAuthenticated && !!userId && !!anime?.id, + }); + + const toggleListMutation = useMutation({ + mutationFn: ({ listId, isMember }: { listId: string; isMember: boolean }) => { + const body = { type: "Anime", listId, userId, animeId: anime?.id }; + return isMember + ? api.delete(apiEndpoints.listItem(listId), { data: body }) + : api.post(apiEndpoints.listItem(listId), body); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["animeListStatus", anime?.id, userId] }); + queryClient.invalidateQueries({ queryKey: ["animeContainingLists", anime?.id] }); + }, + onError: () => toast.error(t("api:INTERNAL_SERVER_ERROR")), + }); + + const createAndAddListMutation = useMutation({ + mutationFn: async (name: string) => { + await api.post(apiEndpoints.list, { name, userId, type: "Anime" }); + const freshLists = await api + .get(apiEndpoints.getListsByUserId(userId as string), { + params: { type: "Anime", itemsPerPage: 50 }, + }) + .then(({ data }) => data.lists.items); + const newList = [...freshLists].reverse().find((l) => l.name === name); + if (!newList) throw new Error("List not found after creation"); + await api.post(apiEndpoints.listItem(newList.id), { + type: "Anime", + listId: newList.id, + userId, + animeId: anime?.id, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["animeLists", userId] }); + queryClient.invalidateQueries({ queryKey: ["animeListStatus", anime?.id, userId] }); + queryClient.invalidateQueries({ queryKey: ["animeContainingLists", anime?.id] }); + setNewListName(""); + }, + onError: () => toast.error(t("api:INTERNAL_SERVER_ERROR")), + }); + if (isLoading) return ; if (isError || !anime) return ; @@ -160,6 +242,8 @@ function AnimeDetailsRoute() { subtitle={anime.season && anime.year ? `${anime.season} ${anime.year}` : undefined} description={anime.synopsis} triggerLabel={t("library:moreOptions")} + open={moreOpen} + onOpenChange={setMoreOpen} > @@ -264,253 +348,337 @@ function AnimeDetailsRoute() { ); return ( - -

- {anime.title} -

- -
- {!reviewsQuery.isLoading && !reviewsQuery.isError && reviews?.total >= 1 && ( -
-
- - - - - + <> + +

+ {anime.title} +

+ +
+ {!reviewsQuery.isLoading && !reviewsQuery.isError && reviews?.total >= 1 && ( +
+
+ + + + + +
+ {rating} + + ({reviews?.total ?? 0} {t("library:reviews")}) +
- {rating} - - ({reviews.total} {t("library:reviews")}) - -
- )} -
+ )} +
- -
- - {t("library:info")} - {t("library:relations")} - {!episodesQuery.isLoading && !episodesQuery.isError && episodes?.length > 0 && ( - {t("library:episode_other")} - )} - {t("library:cast")} - {t("library:characters")} - {!reviewsQuery.isLoading && !reviewsQuery.isError && reviews?.total >= 1 && ( - - {t("library:reviews")} ({reviews.total}) + +
+ + {t("library:info")} + {!episodesQuery.isLoading && !episodesQuery.isError && episodes?.length > 0 && ( + {t("library:episode_other")} + )} + {t("library:cast")} + {t("library:characters")} + + {t("library:lists")} ({listsQuery.data?.total ?? 0}) - )} - {t("library:lists")} (30) - -
- -
-

{t("library:genres")}

- getGenreLabel(t, g)} /> +
+ +
+

{anime.synopsis}

+

{t("library:genres")}

+ getGenreLabel(t, g)} /> +
-
-

{t("library:synopsis")}

-

{anime.synopsis}

-
+
+

{t("library:animeCharacteristics")}

+ + {anime.type && ( + } + description={anime.type} + /> + )} + {anime.source && ( + } + description={anime.source} + /> + )} + {anime.numberOfEpisodes && ( + } + description={anime.numberOfEpisodes} + /> + )} + {anime.broadcast.string && ( + } + description={anime.broadcast.string} + /> + )} + {anime.rating && ( + } + description={anime.rating} + /> + )} + {anime.duration && ( + } + description={anime.duration} + /> + )} + {anime.studios.length >= 1 && ( + } + description={anime.studios.map((st: { name: string; malId: number }, index: number) => ( + + {st.name} + {index < anime.studios.length - 1 && ", "} + + ))} + /> + )} + {anime.producers.length >= 1 && ( + } + description={anime.producers.map((pd: { name: string; malId: number }, index: number) => ( + + {pd.name} + {index < anime.producers.length - 1 && ", "} + + ))} + /> + )} + +
-
-

{t("library:animeCharacteristics")}

- - {anime.type && ( - } - description={anime.type} - /> - )} - {anime.source && ( - } - description={anime.source} - /> - )} - {anime.numberOfEpisodes && ( - } - description={anime.numberOfEpisodes} - /> - )} - {anime.broadcast.string && ( - } - description={anime.broadcast.string} - /> - )} - {anime.rating && ( - } - description={anime.rating} - /> - )} - {anime.duration && ( - } - description={anime.duration} - /> - )} - {anime.studios.length >= 1 && ( - } - description={anime.studios.map((st: { name: string; malId: number }, index: number) => ( - - {st.name} - {index < anime.studios.length - 1 && ", "} - - ))} - /> - )} - {anime.producers.length >= 1 && ( - } - description={anime.producers.map((pd: { name: string; malId: number }, index: number) => ( - - {pd.name} - {index < anime.producers.length - 1 && ", "} - - ))} - /> - )} - -
+ {anime.relations?.nodes?.length > 0 || anime.relations?.edges?.length > 0 ? ( +
+

{t("library:relations")}

+ +
+ ) : null} - {isAuthenticated && } - -
-

{t("library:communityStatistics")}

- -
+ {isAuthenticated && } + +
+

{t("library:communityStatistics")}

+ +
- {anime.trailer.embedUrl && ( -