fix: useQuery instead of useEffect for now playing

feat/custom-artist-sep
Gabe Farrell 3 weeks ago
parent d0c4d078d5
commit 164a9dc56f

@ -1,353 +1,419 @@
interface getItemsArgs { interface getItemsArgs {
limit: number, limit: number;
period: string, period: string;
page: number, page: number;
artist_id?: number, artist_id?: number;
album_id?: number, album_id?: number;
track_id?: number track_id?: number;
} }
interface getActivityArgs { interface getActivityArgs {
step: string step: string;
range: number range: number;
month: number month: number;
year: number year: number;
artist_id: number artist_id: number;
album_id: number album_id: number;
track_id: number track_id: number;
} }
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> { function getLastListens(
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<PaginatedResponse<Listen>>) args: getItemsArgs
): Promise<PaginatedResponse<Listen>> {
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<PaginatedResponse<Listen>>);
} }
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> { function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
if (args.artist_id) { 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<PaginatedResponse<Track>>) return fetch(
} else if (args.album_id) { `/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`
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<PaginatedResponse<Track>>) ).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
} else { } else if (args.album_id) {
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>) 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<PaginatedResponse<Track>>);
} else {
return fetch(
`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`
).then((r) => r.json() as Promise<PaginatedResponse<Track>>);
}
} }
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> { function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}` const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`;
if (args.artist_id) { if (args.artist_id) {
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>) return fetch(baseUri + `&artist_id=${args.artist_id}`).then(
} else { (r) => r.json() as Promise<PaginatedResponse<Album>>
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>) );
} } else {
return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Album>>
);
}
} }
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> { function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}` 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<PaginatedResponse<Artist>>) return fetch(baseUri).then(
(r) => r.json() as Promise<PaginatedResponse<Artist>>
);
} }
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> { function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
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<ListenActivityItem[]>) 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<ListenActivityItem[]>);
} }
function getStats(period: string): Promise<Stats> { function getStats(period: string): Promise<Stats> {
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>) return fetch(`/apis/web/v1/stats?period=${period}`).then(
(r) => r.json() as Promise<Stats>
);
} }
function search(q: string): Promise<SearchResponse> { function search(q: string): Promise<SearchResponse> {
q = encodeURIComponent(q) q = encodeURIComponent(q);
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>) return fetch(`/apis/web/v1/search?q=${q}`).then(
(r) => r.json() as Promise<SearchResponse>
);
} }
function imageUrl(id: string, size: string) { function imageUrl(id: string, size: string) {
if (!id) { if (!id) {
id = 'default' id = "default";
} }
return `/images/${size}/${id}` return `/images/${size}/${id}`;
} }
function replaceImage(form: FormData): Promise<Response> { function replaceImage(form: FormData): Promise<Response> {
return fetch(`/apis/web/v1/replace-image`, { return fetch(`/apis/web/v1/replace-image`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function mergeTracks(from: number, to: number): Promise<Response> { function mergeTracks(from: number, to: number): Promise<Response> {
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, { return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
method: "POST", method: "POST",
}) });
} }
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> { function mergeAlbums(
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { from: number,
method: "POST", to: number,
}) replaceImage: boolean
} ): Promise<Response> {
function mergeArtists(from: number, to: number, replaceImage: boolean): Promise<Response> { return fetch(
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, { `/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`,
method: "POST", {
}) method: "POST",
} }
function login(username: string, password: string, remember: boolean): Promise<Response> { );
const form = new URLSearchParams }
form.append('username', username) function mergeArtists(
form.append('password', password) from: number,
form.append('remember_me', String(remember)) to: number,
return fetch(`/apis/web/v1/login`, { replaceImage: boolean
method: "POST", ): Promise<Response> {
body: form, 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<Response> {
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<Response> { function logout(): Promise<Response> {
return fetch(`/apis/web/v1/logout`, { return fetch(`/apis/web/v1/logout`, {
method: "POST", method: "POST",
}) });
} }
function getCfg(): Promise<Config> { function getCfg(): Promise<Config> {
return fetch(`/apis/web/v1/config`).then(r => r.json() as Promise<Config>) return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
} }
function submitListen(id: string, ts: Date): Promise<Response> { function submitListen(id: string, ts: Date): Promise<Response> {
const form = new URLSearchParams const form = new URLSearchParams();
form.append("track_id", id) form.append("track_id", id);
const ms = new Date(ts).getTime() const ms = new Date(ts).getTime();
const unix= Math.floor(ms / 1000); const unix = Math.floor(ms / 1000);
form.append("unix", unix.toString()) form.append("unix", unix.toString());
return fetch(`/apis/web/v1/listen`, { return fetch(`/apis/web/v1/listen`, {
method: "POST", method: "POST",
body: form, body: form,
}) });
} }
function getApiKeys(): Promise<ApiKey[]> { function getApiKeys(): Promise<ApiKey[]> {
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>) return fetch(`/apis/web/v1/user/apikeys`).then(
(r) => r.json() as Promise<ApiKey[]>
);
} }
const createApiKey = async (label: string): Promise<ApiKey> => { const createApiKey = async (label: string): Promise<ApiKey> => {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('label', label) form.append("label", label);
const r = await fetch(`/apis/web/v1/user/apikeys`, { const r = await fetch(`/apis/web/v1/user/apikeys`, {
method: "POST", method: "POST",
body: form, body: form,
}); });
if (!r.ok) { if (!r.ok) {
let errorMessage = `error: ${r.status}`; let errorMessage = `error: ${r.status}`;
try { try {
const errorData: ApiError = await r.json(); const errorData: ApiError = await r.json();
if (errorData && typeof errorData.error === 'string') { if (errorData && typeof errorData.error === "string") {
errorMessage = errorData.error; errorMessage = errorData.error;
} }
} catch (e) { } catch (e) {
console.error("unexpected api error:", e); console.error("unexpected api error:", e);
}
throw new Error(errorMessage);
} }
const data: ApiKey = await r.json(); throw new Error(errorMessage);
return data; }
const data: ApiKey = await r.json();
return data;
}; };
function deleteApiKey(id: number): Promise<Response> { function deleteApiKey(id: number): Promise<Response> {
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, { return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateApiKeyLabel(id: number, label: string): Promise<Response> { function updateApiKeyLabel(id: number, label: string): Promise<Response> {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('id', String(id)) form.append("id", String(id));
form.append('label', label) form.append("label", label);
return fetch(`/apis/web/v1/user/apikeys`, { return fetch(`/apis/web/v1/user/apikeys`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function deleteItem(itemType: string, id: number): Promise<Response> { function deleteItem(itemType: string, id: number): Promise<Response> {
return fetch(`/apis/web/v1/${itemType}?id=${id}`, { return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function updateUser(username: string, password: string) { function updateUser(username: string, password: string) {
const form = new URLSearchParams const form = new URLSearchParams();
form.append('username', username) form.append("username", username);
form.append('password', password) form.append("password", password);
return fetch(`/apis/web/v1/user`, { return fetch(`/apis/web/v1/user`, {
method: "PATCH", method: "PATCH",
body: form, body: form,
}) });
} }
function getAliases(type: string, id: number): Promise<Alias[]> { function getAliases(type: string, id: number): Promise<Alias[]> {
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>) return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(
} (r) => r.json() as Promise<Alias[]>
function createAlias(type: string, id: number, alias: string): Promise<Response> { );
const form = new URLSearchParams }
form.append(`${type}_id`, String(id)) function createAlias(
form.append('alias', alias) type: string,
return fetch(`/apis/web/v1/aliases`, { id: number,
method: 'POST', alias: string
body: form, ): Promise<Response> {
}) const form = new URLSearchParams();
} form.append(`${type}_id`, String(id));
function deleteAlias(type: string, id: number, alias: string): Promise<Response> { form.append("alias", alias);
const form = new URLSearchParams return fetch(`/apis/web/v1/aliases`, {
form.append(`${type}_id`, String(id)) method: "POST",
form.append('alias', alias) body: form,
return fetch(`/apis/web/v1/aliases/delete`, { });
method: "POST", }
body: form, function deleteAlias(
}) type: string,
} id: number,
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> { alias: string
const form = new URLSearchParams ): Promise<Response> {
form.append(`${type}_id`, String(id)) const form = new URLSearchParams();
form.append('alias', alias) form.append(`${type}_id`, String(id));
return fetch(`/apis/web/v1/aliases/primary`, { form.append("alias", alias);
method: "POST", return fetch(`/apis/web/v1/aliases/delete`, {
body: form, method: "POST",
}) body: form,
});
}
function setPrimaryAlias(
type: string,
id: number,
alias: string
): Promise<Response> {
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<Album> { function getAlbum(id: number): Promise<Album> {
return fetch(`/apis/web/v1/album?id=${id}`).then(r => r.json() as Promise<Album>) return fetch(`/apis/web/v1/album?id=${id}`).then(
(r) => r.json() as Promise<Album>
);
} }
function deleteListen(listen: Listen): Promise<Response> { function deleteListen(listen: Listen): Promise<Response> {
const ms = new Date(listen.time).getTime() const ms = new Date(listen.time).getTime();
const unix= Math.floor(ms / 1000); const unix = Math.floor(ms / 1000);
return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, { return fetch(`/apis/web/v1/listen?track_id=${listen.track.id}&unix=${unix}`, {
method: "DELETE" method: "DELETE",
}) });
} }
function getExport() { function getExport() {}
function getNowPlaying(): Promise<NowPlaying> {
return fetch("/apis/web/v1/now-playing").then((r) => r.json());
} }
export { export {
getLastListens, getLastListens,
getTopTracks, getTopTracks,
getTopAlbums, getTopAlbums,
getTopArtists, getTopArtists,
getActivity, getActivity,
getStats, getStats,
search, search,
replaceImage, replaceImage,
mergeTracks, mergeTracks,
mergeAlbums, mergeAlbums,
mergeArtists, mergeArtists,
imageUrl, imageUrl,
login, login,
logout, logout,
getCfg, getCfg,
deleteItem, deleteItem,
updateUser, updateUser,
getAliases, getAliases,
createAlias, createAlias,
deleteAlias, deleteAlias,
setPrimaryAlias, setPrimaryAlias,
getApiKeys, getApiKeys,
createApiKey, createApiKey,
deleteApiKey, deleteApiKey,
updateApiKeyLabel, updateApiKeyLabel,
deleteListen, deleteListen,
getAlbum, getAlbum,
getExport, getExport,
submitListen, submitListen,
} getNowPlaying,
};
type Track = { type Track = {
id: number id: number;
title: string title: string;
artists: SimpleArtists[] artists: SimpleArtists[];
listen_count: number listen_count: number;
image: string image: string;
album_id: number album_id: number;
musicbrainz_id: string musicbrainz_id: string;
time_listened: number time_listened: number;
first_listen: number first_listen: number;
} };
type Artist = { type Artist = {
id: number id: number;
name: string name: string;
image: string, image: string;
aliases: string[] aliases: string[];
listen_count: number listen_count: number;
musicbrainz_id: string musicbrainz_id: string;
time_listened: number time_listened: number;
first_listen: number first_listen: number;
is_primary: boolean is_primary: boolean;
} };
type Album = { type Album = {
id: number, id: number;
title: string title: string;
image: string image: string;
listen_count: number listen_count: number;
is_various_artists: boolean is_various_artists: boolean;
artists: SimpleArtists[] artists: SimpleArtists[];
musicbrainz_id: string musicbrainz_id: string;
time_listened: number time_listened: number;
first_listen: number first_listen: number;
} };
type Alias = { type Alias = {
id: number id: number;
alias: string alias: string;
source: string source: string;
is_primary: boolean is_primary: boolean;
} };
type Listen = { type Listen = {
time: string, time: string;
track: Track, track: Track;
} };
type PaginatedResponse<T> = { type PaginatedResponse<T> = {
items: T[], items: T[];
total_record_count: number, total_record_count: number;
has_next_page: boolean, has_next_page: boolean;
current_page: number, current_page: number;
items_per_page: number, items_per_page: number;
} };
type ListenActivityItem = { type ListenActivityItem = {
start_time: Date, start_time: Date;
listens: number listens: number;
} };
type SimpleArtists = { type SimpleArtists = {
name: string name: string;
id: number id: number;
} };
type Stats = { type Stats = {
listen_count: number listen_count: number;
track_count: number track_count: number;
album_count: number album_count: number;
artist_count: number artist_count: number;
minutes_listened: number minutes_listened: number;
} };
type SearchResponse = { type SearchResponse = {
albums: Album[] albums: Album[];
artists: Artist[] artists: Artist[];
tracks: Track[] tracks: Track[];
} };
type User = { type User = {
id: number id: number;
username: string username: string;
role: 'user' | 'admin' role: "user" | "admin";
} };
type ApiKey = { type ApiKey = {
id: number id: number;
key: string key: string;
label: string label: string;
created_at: Date created_at: Date;
} };
type ApiError = { type ApiError = {
error: string error: string;
} };
type Config = { type Config = {
default_theme: string default_theme: string;
} };
type NowPlaying = {
currently_playing: boolean;
track: Track;
};
export type { export type {
getItemsArgs, getItemsArgs,
getActivityArgs, getActivityArgs,
Track, Track,
Artist, Artist,
Album, Album,
Listen, Listen,
SearchResponse, SearchResponse,
PaginatedResponse, PaginatedResponse,
ListenActivityItem, ListenActivityItem,
User, User,
Alias, Alias,
ApiKey, ApiKey,
ApiError, ApiError,
Config Config,
} NowPlaying,
};

@ -1,147 +1,150 @@
import { useEffect, useState } from "react" import { useState } from "react";
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query";
import { timeSince } from "~/utils/utils" import { timeSince } from "~/utils/utils";
import ArtistLinks from "./ArtistLinks" import ArtistLinks from "./ArtistLinks";
import { deleteListen, getLastListens, type getItemsArgs, type Listen, type Track } from "api/api" import {
import { Link } from "react-router" deleteListen,
import { useAppContext } from "~/providers/AppProvider" getLastListens,
getNowPlaying,
type getItemsArgs,
type Listen,
type Track,
} from "api/api";
import { Link } from "react-router";
import { useAppContext } from "~/providers/AppProvider";
interface Props { interface Props {
limit: number limit: number;
artistId?: Number artistId?: Number;
albumId?: Number albumId?: Number;
trackId?: number trackId?: number;
hideArtists?: boolean hideArtists?: boolean;
showNowPlaying?: boolean showNowPlaying?: boolean;
} }
export default function LastPlays(props: Props) { export default function LastPlays(props: Props) {
const { user } = useAppContext() const { user } = useAppContext();
const { isPending, isError, data, error } = useQuery({ const { isPending, isError, data, error } = useQuery({
queryKey: ['last-listens', { queryKey: [
limit: props.limit, "last-listens",
period: 'all_time', {
artist_id: props.artistId, limit: props.limit,
album_id: props.albumId, period: "all_time",
track_id: props.trackId artist_id: props.artistId,
}], album_id: props.albumId,
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs), track_id: props.trackId,
}) },
],
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
});
const { data: npData } = useQuery({
queryKey: ["now-playing"],
queryFn: () => getNowPlaying(),
});
const [items, setItems] = useState<Listen[] | null>(null);
const [items, setItems] = useState<Listen[] | null>(null) const handleDelete = async (listen: Listen) => {
const [nowPlaying, setNowPlaying] = useState<Track | undefined>(undefined) if (!data) return;
try {
useEffect(() => { const res = await deleteListen(listen);
fetch('/apis/web/v1/now-playing') if (res.ok || (res.status >= 200 && res.status < 300)) {
.then(r => r.json()) setItems((prev) =>
.then(r => { (prev ?? data.items).filter((i) => i.time !== listen.time)
console.log(r) );
if (r.currently_playing) { } else {
setNowPlaying(r.track) 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) { if (isPending) {
return ( return (
<div className="w-[300px] sm:w-[500px]"> <div className="w-[300px] sm:w-[500px]">
<h2>Last Played</h2> <h2>Last Played</h2>
<p>Loading...</p> <p>Loading...</p>
</div> </div>
) );
} }
if (isError) { if (isError) {
return <p className="error">Error: {error.message}</p> return <p className="error">Error: {error.message}</p>;
} }
const listens = items ?? data.items const listens = items ?? data.items;
let params = '' let params = "";
params += props.artistId ? `&artist_id=${props.artistId}` : '' params += props.artistId ? `&artist_id=${props.artistId}` : "";
params += props.albumId ? `&album_id=${props.albumId}` : '' params += props.albumId ? `&album_id=${props.albumId}` : "";
params += props.trackId ? `&track_id=${props.trackId}` : '' params += props.trackId ? `&track_id=${props.trackId}` : "";
return ( return (
<div className="text-sm sm:text-[16px]"> <div className="text-sm sm:text-[16px]">
<h2 className="hover:underline"> <h2 className="hover:underline">
<Link to={`/listens?period=all_time${params}`}>Last Played</Link> <Link to={`/listens?period=all_time${params}`}>Last Played</Link>
</h2> </h2>
<table className="-ml-4"> <table className="-ml-4">
<tbody> <tbody>
{props.showNowPlaying && nowPlaying && {props.showNowPlaying && npData && npData.currently_playing && (
<tr className="group hover:bg-[--color-bg-secondary]"> <tr className="group hover:bg-[--color-bg-secondary]">
<td className="w-[18px] pr-2 align-middle" > <td className="w-[18px] pr-2 align-middle"></td>
</td> <td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
<td Now Playing
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0" </td>
> <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
Now Playing {props.hideArtists ? null : (
</td> <>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]"> <ArtistLinks artists={npData.track.artists} /> {" "}
{props.hideArtists ? null : ( </>
<> )}
<ArtistLinks artists={nowPlaying.artists} /> {' '} <Link
</> className="hover:text-[--color-fg-secondary]"
)} to={`/track/${npData.track.id}`}
<Link >
className="hover:text-[--color-fg-secondary]" {npData.track.title}
to={`/track/${nowPlaying.id}`} </Link>
> </td>
{nowPlaying.title} </tr>
</Link> )}
</td> {listens.map((item) => (
</tr> <tr
} key={`last_listen_${item.time}`}
{listens.map((item) => ( className="group hover:bg-[--color-bg-secondary]"
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]"> >
<td className="w-[18px] pr-2 align-middle" > <td className="w-[18px] pr-2 align-middle">
<button <button
onClick={() => handleDelete(item)} onClick={() => handleDelete(item)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)" className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
aria-label="Delete" aria-label="Delete"
hidden={user === null || user === undefined} hidden={user === null || user === undefined}
> >
× ×
</button> </button>
</td> </td>
<td <td
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0" className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
title={new Date(item.time).toString()} title={new Date(item.time).toString()}
> >
{timeSince(new Date(item.time))} {timeSince(new Date(item.time))}
</td> </td>
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]"> <td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
{props.hideArtists ? null : ( {props.hideArtists ? null : (
<> <>
<ArtistLinks artists={item.track.artists} /> {' '} <ArtistLinks artists={item.track.artists} /> {" "}
</> </>
)} )}
<Link <Link
className="hover:text-[--color-fg-secondary]" className="hover:text-[--color-fg-secondary]"
to={`/track/${item.track.id}`} to={`/track/${item.track.id}`}
> >
{item.track.title} {item.track.title}
</Link> </Link>
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
) );
} }

Loading…
Cancel
Save