diff --git a/client/api/api.ts b/client/api/api.ts index 3bcd31c7..f00c6c06 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -288,6 +288,22 @@ async function getRewindStats(args: timeframe): Promise { return handleJson(r); } +async function getTrackFeedback(trackId: number): Promise { + return apiFetch(`/apis/web/v1/track/${trackId}/feedback`); +} + +async function setTrackFeedback( + trackId: number, + score: number, +): Promise { + const r = await fetch(`/apis/web/v1/track/${trackId}/feedback`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ score }), + }); + return handleJson(r); +} + export { getTopAlbums, search, @@ -315,6 +331,8 @@ export { getExport, submitListen, getRewindStats, + getTrackFeedback, + setTrackFeedback, }; type ImageList = { xs: string; @@ -441,6 +459,9 @@ type NowPlaying = { currently_playing: boolean; track: Track; }; +type TrackFeedback = { + score: number; +}; type RewindStats = { title: string; top_artists: Ranked[]; @@ -481,4 +502,5 @@ export type { Stats, RewindStats, ImageList, + TrackFeedback, }; diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx index ca8db0fe..62fdd33c 100644 --- a/client/app/routes/MediaItems/MediaLayout.tsx +++ b/client/app/routes/MediaItems/MediaLayout.tsx @@ -1,8 +1,13 @@ import React, { useEffect, useState } from "react"; import { average } from "color.js"; -import { type ImageList, type SearchResponse } from "api/api"; +import { + getTrackFeedback, + setTrackFeedback, + type ImageList, + type SearchResponse, +} from "api/api"; import ImageDropHandler from "~/components/ImageDropHandler"; -import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react"; +import { Edit, Heart, ImageIcon, Merge, Plus, Trash } from "lucide-react"; import { useAppContext } from "~/providers/AppProvider"; import MergeModal from "~/components/modals/MergeModal"; import ImageReplaceModal from "~/components/modals/ImageReplaceModal"; @@ -13,6 +18,7 @@ import MbzIcon from "~/components/icons/MbzIcon"; import { timeListenedString } from "~/utils/utils"; import { Link } from "react-router"; import useWindowWidth from "~/hooks/useWindowWidth"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; export type MergeFunc = ( from: number, @@ -48,7 +54,10 @@ export default function MediaLayout(props: Props) { const [imageModalOpen, setImageModalOpen] = useState(false); const [renameModalOpen, setRenameModalOpen] = useState(false); const [addListenModalOpen, setAddListenModalOpen] = useState(false); + const [feedbackPending, setFeedbackPending] = useState(false); const { user } = useAppContext(); + const queryClient = useQueryClient(); + const feedbackQueryKey = ["track-feedback", props.id] as const; useEffect(() => { average(props.img.xs, { amount: 1 }).then((color) => { @@ -56,6 +65,28 @@ export default function MediaLayout(props: Props) { }); }, [props.img]); + const { data: feedback } = useQuery({ + queryKey: feedbackQueryKey, + queryFn: () => getTrackFeedback(props.id), + enabled: !!user && props.type === "Track", + }); + const loved = feedback?.score === 1; + + const toggleLove = async () => { + if (feedbackPending || props.type !== "Track") return; + const previous = feedback ?? { score: 0 }; + const next = { score: loved ? 0 : 1 }; + queryClient.setQueryData(feedbackQueryKey, next); + setFeedbackPending(true); + try { + await setTrackFeedback(props.id, next.score); + } catch { + queryClient.setQueryData(feedbackQueryKey, previous); + } finally { + setFeedbackPending(false); + } + }; + const replaceImageCallback = () => { window.location.reload(); }; @@ -152,6 +183,18 @@ export default function MediaLayout(props: Props) { <> {props.type === "Track" && ( <> +