From 164a9dc56fe1993b801736f1fa7bcc1c41da086c Mon Sep 17 00:00:00 2001 From: Gabe Farrell Date: Wed, 19 Nov 2025 14:34:19 -0500 Subject: [PATCH] fix: useQuery instead of useEffect for now playing --- client/api/api.ts | 610 +++++++++++++++------------- client/app/components/LastPlays.tsx | 271 ++++++------ 2 files changed, 475 insertions(+), 406 deletions(-) diff --git a/client/api/api.ts b/client/api/api.ts index afca716..c7e0b96 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -1,353 +1,419 @@ interface getItemsArgs { - limit: number, - period: string, - page: number, - artist_id?: number, - album_id?: number, - track_id?: number + limit: number; + period: string; + page: number; + artist_id?: number; + album_id?: number; + track_id?: number; } interface getActivityArgs { - step: string - range: number - month: number - year: number - artist_id: number - album_id: number - track_id: number + step: string; + range: number; + month: number; + year: number; + artist_id: number; + album_id: number; + track_id: number; } -function getLastListens(args: getItemsArgs): Promise> { - return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise>) +function getLastListens( + args: getItemsArgs +): Promise> { + return fetch( + `/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}` + ).then((r) => r.json() as Promise>); } function getTopTracks(args: getItemsArgs): Promise> { - if (args.artist_id) { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise>) - } else if (args.album_id) { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise>) - } else { - return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise>) - } + if (args.artist_id) { + return fetch( + `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}` + ).then((r) => r.json() as Promise>); + } else if (args.album_id) { + return fetch( + `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}` + ).then((r) => r.json() as Promise>); + } else { + return fetch( + `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}` + ).then((r) => r.json() as Promise>); + } } function getTopAlbums(args: getItemsArgs): Promise> { - const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` - if (args.artist_id) { - return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise>) - } else { - return fetch(baseUri).then(r => r.json() as Promise>) - } + const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`; + if (args.artist_id) { + return fetch(baseUri + `&artist_id=${args.artist_id}`).then( + (r) => r.json() as Promise> + ); + } else { + return fetch(baseUri).then( + (r) => r.json() as Promise> + ); + } } function getTopArtists(args: getItemsArgs): Promise> { - const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` - return fetch(baseUri).then(r => r.json() as Promise>) + const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`; + return fetch(baseUri).then( + (r) => r.json() as Promise> + ); } function getActivity(args: getActivityArgs): Promise { - return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise) + return fetch( + `/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}` + ).then((r) => r.json() as Promise); } function getStats(period: string): Promise { - return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise) + return fetch(`/apis/web/v1/stats?period=${period}`).then( + (r) => r.json() as Promise + ); } function search(q: string): Promise { - q = encodeURIComponent(q) - return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise) + q = encodeURIComponent(q); + return fetch(`/apis/web/v1/search?q=${q}`).then( + (r) => r.json() as Promise + ); } function imageUrl(id: string, size: string) { - if (!id) { - id = 'default' - } - return `/images/${size}/${id}` + if (!id) { + id = "default"; + } + return `/images/${size}/${id}`; } function replaceImage(form: FormData): Promise { - return fetch(`/apis/web/v1/replace-image`, { - method: "POST", - body: form, - }) + return fetch(`/apis/web/v1/replace-image`, { + method: "POST", + body: form, + }); } function mergeTracks(from: number, to: number): Promise { - return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { - method: "POST", - }) -} -function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise { - return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { - method: "POST", - }) -} -function mergeArtists(from: number, to: number, replaceImage: boolean): Promise { - return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { - method: "POST", - }) -} -function login(username: string, password: string, remember: boolean): Promise { - const form = new URLSearchParams - form.append('username', username) - form.append('password', password) - form.append('remember_me', String(remember)) - return fetch(`/apis/web/v1/login`, { - method: "POST", - body: form, - }) + return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { + method: "POST", + }); +} +function mergeAlbums( + from: number, + to: number, + replaceImage: boolean +): Promise { + return fetch( + `/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, + { + method: "POST", + } + ); +} +function mergeArtists( + from: number, + to: number, + replaceImage: boolean +): Promise { + return fetch( + `/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, + { + method: "POST", + } + ); +} +function login( + username: string, + password: string, + remember: boolean +): Promise { + const form = new URLSearchParams(); + form.append("username", username); + form.append("password", password); + form.append("remember_me", String(remember)); + return fetch(`/apis/web/v1/login`, { + method: "POST", + body: form, + }); } function logout(): Promise { - return fetch(`/apis/web/v1/logout`, { - method: "POST", - }) + return fetch(`/apis/web/v1/logout`, { + method: "POST", + }); } function getCfg(): Promise { - return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise) - + return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise); } function submitListen(id: string, ts: Date): Promise { - const form = new URLSearchParams - form.append("track_id", id) - const ms = new Date(ts).getTime() - const unix= Math.floor(ms / 1000); - form.append("unix", unix.toString()) - return fetch(`/apis/web/v1/listen`, { - method: "POST", - body: form, - }) + const form = new URLSearchParams(); + form.append("track_id", id); + const ms = new Date(ts).getTime(); + const unix = Math.floor(ms / 1000); + form.append("unix", unix.toString()); + return fetch(`/apis/web/v1/listen`, { + method: "POST", + body: form, + }); } function getApiKeys(): Promise { - return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise) + return fetch(`/apis/web/v1/user/apikeys`).then( + (r) => r.json() as Promise + ); } const createApiKey = async (label: string): Promise => { - const form = new URLSearchParams - form.append('label', label) - const r = await fetch(`/apis/web/v1/user/apikeys`, { - method: "POST", - body: form, - }); - if (!r.ok) { - let errorMessage = `error: ${r.status}`; - try { - const errorData: ApiError = await r.json(); - if (errorData && typeof errorData.error === 'string') { - errorMessage = errorData.error; - } - } catch (e) { - console.error("unexpected api error:", e); - } - throw new Error(errorMessage); + const form = new URLSearchParams(); + form.append("label", label); + const r = await fetch(`/apis/web/v1/user/apikeys`, { + method: "POST", + body: form, + }); + if (!r.ok) { + let errorMessage = `error: ${r.status}`; + try { + const errorData: ApiError = await r.json(); + if (errorData && typeof errorData.error === "string") { + errorMessage = errorData.error; + } + } catch (e) { + console.error("unexpected api error:", e); } - const data: ApiKey = await r.json(); - return data; + throw new Error(errorMessage); + } + const data: ApiKey = await r.json(); + return data; }; function deleteApiKey(id: number): Promise { - return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { - method: "DELETE" - }) + return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { + method: "DELETE", + }); } function updateApiKeyLabel(id: number, label: string): Promise { - const form = new URLSearchParams - form.append('id', String(id)) - form.append('label', label) - return fetch(`/apis/web/v1/user/apikeys`, { - method: "PATCH", - body: form, - }) + const form = new URLSearchParams(); + form.append("id", String(id)); + form.append("label", label); + return fetch(`/apis/web/v1/user/apikeys`, { + method: "PATCH", + body: form, + }); } function deleteItem(itemType: string, id: number): Promise { - return fetch(`/apis/web/v1/${itemType}?id=${id}`, { - method: "DELETE" - }) + return fetch(`/apis/web/v1/${itemType}?id=${id}`, { + method: "DELETE", + }); } function updateUser(username: string, password: string) { - const form = new URLSearchParams - form.append('username', username) - form.append('password', password) - return fetch(`/apis/web/v1/user`, { - method: "PATCH", - body: form, - }) + const form = new URLSearchParams(); + form.append("username", username); + form.append("password", password); + return fetch(`/apis/web/v1/user`, { + method: "PATCH", + body: form, + }); } function getAliases(type: string, id: number): Promise { - return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise) -} -function createAlias(type: string, id: number, alias: string): Promise { - const form = new URLSearchParams - form.append(`${type}_id`, String(id)) - form.append('alias', alias) - return fetch(`/apis/web/v1/aliases`, { - method: 'POST', - body: form, - }) -} -function deleteAlias(type: string, id: number, alias: string): Promise { - const form = new URLSearchParams - form.append(`${type}_id`, String(id)) - form.append('alias', alias) - return fetch(`/apis/web/v1/aliases/delete`, { - method: "POST", - body: form, - }) -} -function setPrimaryAlias(type: string, id: number, alias: string): Promise { - const form = new URLSearchParams - form.append(`${type}_id`, String(id)) - form.append('alias', alias) - return fetch(`/apis/web/v1/aliases/primary`, { - method: "POST", - body: form, - }) + return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then( + (r) => r.json() as Promise + ); +} +function createAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases`, { + method: "POST", + body: form, + }); +} +function deleteAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases/delete`, { + method: "POST", + body: form, + }); +} +function setPrimaryAlias( + type: string, + id: number, + alias: string +): Promise { + const form = new URLSearchParams(); + form.append(`${type}_id`, String(id)); + form.append("alias", alias); + return fetch(`/apis/web/v1/aliases/primary`, { + method: "POST", + body: form, + }); } function getAlbum(id: number): Promise { - return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise) + return fetch(`/apis/web/v1/album?id=${id}`).then( + (r) => r.json() as Promise + ); } function deleteListen(listen: Listen): Promise { - const ms = new Date(listen.time).getTime() - const unix= Math.floor(ms / 1000); - return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { - method: "DELETE" - }) + const ms = new Date(listen.time).getTime(); + const unix = Math.floor(ms / 1000); + return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { + method: "DELETE", + }); } -function getExport() { +function getExport() {} + +function getNowPlaying(): Promise { + return fetch("/apis/web/v1/now-playing").then((r) => r.json()); } export { - getLastListens, - getTopTracks, - getTopAlbums, - getTopArtists, - getActivity, - getStats, - search, - replaceImage, - mergeTracks, - mergeAlbums, - mergeArtists, - imageUrl, - login, - logout, - getCfg, - deleteItem, - updateUser, - getAliases, - createAlias, - deleteAlias, - setPrimaryAlias, - getApiKeys, - createApiKey, - deleteApiKey, - updateApiKeyLabel, - deleteListen, - getAlbum, - getExport, - submitListen, -} + getLastListens, + getTopTracks, + getTopAlbums, + getTopArtists, + getActivity, + getStats, + search, + replaceImage, + mergeTracks, + mergeAlbums, + mergeArtists, + imageUrl, + login, + logout, + getCfg, + deleteItem, + updateUser, + getAliases, + createAlias, + deleteAlias, + setPrimaryAlias, + getApiKeys, + createApiKey, + deleteApiKey, + updateApiKeyLabel, + deleteListen, + getAlbum, + getExport, + submitListen, + getNowPlaying, +}; type Track = { - id: number - title: string - artists: SimpleArtists[] - listen_count: number - image: string - album_id: number - musicbrainz_id: string - time_listened: number - first_listen: number -} + id: number; + title: string; + artists: SimpleArtists[]; + listen_count: number; + image: string; + album_id: number; + musicbrainz_id: string; + time_listened: number; + first_listen: number; +}; type Artist = { - id: number - name: string - image: string, - aliases: string[] - listen_count: number - musicbrainz_id: string - time_listened: number - first_listen: number - is_primary: boolean -} + id: number; + name: string; + image: string; + aliases: string[]; + listen_count: number; + musicbrainz_id: string; + time_listened: number; + first_listen: number; + is_primary: boolean; +}; type Album = { - id: number, - title: string - image: string - listen_count: number - is_various_artists: boolean - artists: SimpleArtists[] - musicbrainz_id: string - time_listened: number - first_listen: number -} + id: number; + title: string; + image: string; + listen_count: number; + is_various_artists: boolean; + artists: SimpleArtists[]; + musicbrainz_id: string; + time_listened: number; + first_listen: number; +}; type Alias = { - id: number - alias: string - source: string - is_primary: boolean -} + id: number; + alias: string; + source: string; + is_primary: boolean; +}; type Listen = { - time: string, - track: Track, -} + time: string; + track: Track; +}; type PaginatedResponse = { - items: T[], - total_record_count: number, - has_next_page: boolean, - current_page: number, - items_per_page: number, -} + items: T[]; + total_record_count: number; + has_next_page: boolean; + current_page: number; + items_per_page: number; +}; type ListenActivityItem = { - start_time: Date, - listens: number -} + start_time: Date; + listens: number; +}; type SimpleArtists = { - name: string - id: number -} + name: string; + id: number; +}; type Stats = { - listen_count: number - track_count: number - album_count: number - artist_count: number - minutes_listened: number -} + listen_count: number; + track_count: number; + album_count: number; + artist_count: number; + minutes_listened: number; +}; type SearchResponse = { - albums: Album[] - artists: Artist[] - tracks: Track[] -} + albums: Album[]; + artists: Artist[]; + tracks: Track[]; +}; type User = { - id: number - username: string - role: 'user' | 'admin' -} + id: number; + username: string; + role: "user" | "admin"; +}; type ApiKey = { - id: number - key: string - label: string - created_at: Date -} + id: number; + key: string; + label: string; + created_at: Date; +}; type ApiError = { - error: string -} + error: string; +}; type Config = { - default_theme: string -} + default_theme: string; +}; +type NowPlaying = { + currently_playing: boolean; + track: Track; +}; export type { - getItemsArgs, - getActivityArgs, - Track, - Artist, - Album, - Listen, - SearchResponse, - PaginatedResponse, - ListenActivityItem, - User, - Alias, - ApiKey, - ApiError, - Config -} + getItemsArgs, + getActivityArgs, + Track, + Artist, + Album, + Listen, + SearchResponse, + PaginatedResponse, + ListenActivityItem, + User, + Alias, + ApiKey, + ApiError, + Config, + NowPlaying, +}; diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index aff08c8..c6687b4 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -1,147 +1,150 @@ -import { useEffect, useState } from "react" -import { useQuery } from "@tanstack/react-query" -import { timeSince } from "~/utils/utils" -import ArtistLinks from "./ArtistLinks" -import { deleteListen, getLastListens, type getItemsArgs, type Listen, type Track } from "api/api" -import { Link } from "react-router" -import { useAppContext } from "~/providers/AppProvider" +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { timeSince } from "~/utils/utils"; +import ArtistLinks from "./ArtistLinks"; +import { + deleteListen, + getLastListens, + getNowPlaying, + type getItemsArgs, + type Listen, + type Track, +} from "api/api"; +import { Link } from "react-router"; +import { useAppContext } from "~/providers/AppProvider"; interface Props { - limit: number - artistId?: Number - albumId?: Number - trackId?: number - hideArtists?: boolean - showNowPlaying?: boolean + limit: number; + artistId?: Number; + albumId?: Number; + trackId?: number; + hideArtists?: boolean; + showNowPlaying?: boolean; } export default function LastPlays(props: Props) { - const { user } = useAppContext() - const { isPending, isError, data, error } = useQuery({ - queryKey: ['last-listens', { - limit: props.limit, - period: 'all_time', - artist_id: props.artistId, - album_id: props.albumId, - track_id: props.trackId - }], - queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), - }) + const { user } = useAppContext(); + const { isPending, isError, data, error } = useQuery({ + queryKey: [ + "last-listens", + { + limit: props.limit, + period: "all_time", + artist_id: props.artistId, + album_id: props.albumId, + track_id: props.trackId, + }, + ], + queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), + }); + const { data: npData } = useQuery({ + queryKey: ["now-playing"], + queryFn: () => getNowPlaying(), + }); + const [items, setItems] = useState(null); - const [items, setItems] = useState(null) - const [nowPlaying, setNowPlaying] = useState(undefined) - - useEffect(() => { - fetch('/apis/web/v1/now-playing') - .then(r => r.json()) - .then(r => { - console.log(r) - if (r.currently_playing) { - setNowPlaying(r.track) - } - }) - }, []) - - const handleDelete = async (listen: Listen) => { - if (!data) return - try { - const res = await deleteListen(listen) - if (res.ok || (res.status >= 200 && res.status < 300)) { - setItems((prev) => (prev ?? data.items).filter((i) => i.time !== listen.time)) - } else { - console.error("Failed to delete listen:", res.status) - } - } catch (err) { - console.error("Error deleting listen:", err) - } + const handleDelete = async (listen: Listen) => { + if (!data) return; + try { + const res = await deleteListen(listen); + if (res.ok || (res.status >= 200 && res.status < 300)) { + setItems((prev) => + (prev ?? data.items).filter((i) => i.time !== listen.time) + ); + } else { + console.error("Failed to delete listen:", res.status); + } + } catch (err) { + console.error("Error deleting listen:", err); } + }; - if (isPending) { - return ( -
-

Last Played

-

Loading...

-
- ) - } - if (isError) { - return

Error: {error.message}

- } + if (isPending) { + return ( +
+

Last Played

+

Loading...

+
+ ); + } + if (isError) { + return

Error: {error.message}

; + } - const listens = items ?? data.items + const listens = items ?? data.items; - let params = '' - params += props.artistId ? `&artist_id=${props.artistId}` : '' - params += props.albumId ? `&album_id=${props.albumId}` : '' - params += props.trackId ? `&track_id=${props.trackId}` : '' + let params = ""; + params += props.artistId ? `&artist_id=${props.artistId}` : ""; + params += props.albumId ? `&album_id=${props.albumId}` : ""; + params += props.trackId ? `&track_id=${props.trackId}` : ""; - return ( -
-

- Last Played -

- - - {props.showNowPlaying && nowPlaying && - - - - - - } - {listens.map((item) => ( - - - - - - ))} - -
- - Now Playing - - {props.hideArtists ? null : ( - <> - –{' '} - - )} - - {nowPlaying.title} - -
- - - {timeSince(new Date(item.time))} - - {props.hideArtists ? null : ( - <> - –{' '} - - )} - - {item.track.title} - -
-
- ) + return ( +
+

+ Last Played +

+ + + {props.showNowPlaying && npData && npData.currently_playing && ( + + + + + + )} + {listens.map((item) => ( + + + + + + ))} + +
+ Now Playing + + {props.hideArtists ? null : ( + <> + –{" "} + + )} + + {npData.track.title} + +
+ + + {timeSince(new Date(item.time))} + + {props.hideArtists ? null : ( + <> + –{" "} + + )} + + {item.track.title} + +
+
+ ); }