mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
Pre-release version v0.0.14 (#96)
* add dev branch container to workflow * correctly set the default range of ActivityGrid * fix: set name/short_name to koito (#61) * fix dev container push workflow * fix: race condition with using getComputedStyle primary color for dynamic activity grid darkening (#76) * Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening Instead just use the color from the current theme directly. Tested works on initial load and theme changes. Fixes https://github.com/gabehf/Koito/issues/75 * Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading * fix: set first artist listed as primary by default (#81) * feat: add server-side configuration with default theme (#90) * docs: add example for usage of the main listenbrainz instance (#71) * docs: add example for usage of the main listenbrainz instance * Update scrobbler.md --------- Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * feat: add server-side cfg and default theme * fix: repair custom theme --------- Co-authored-by: m0d3rnX <jesper@posteo.de> * docs: add default theme cfg option to docs * feat: add ability to manually scrobble track (#91) * feat: add button to manually scrobble from ui * fix: ensure timestamp is in the past, log fix * test: add integration test * feat: add first listened to dates for media items (#92) * fix: ensure error checks for ErrNoRows * feat: add now playing endpoint and ui (#93) * wip * feat: add now playing * fix: set default theme when config is not set * feat: fetch images from subsonic server (#94) * fix: useQuery instead of useEffect for now playing * feat: custom artist separator regex (#95) * Fix race condition with using getComputedStyle primary color for dynamic activity grid darkening Instead just use the color from the current theme directly. Tested works on initial load and theme changes. Fixes https://github.com/gabehf/Koito/issues/75 * Rework theme provider to provide the actual Theme object throughtout the app, in addition to the name Split name out of the Theme struct to simplify custom theme saving/reading * feat: add server-side configuration with default theme (#90) * docs: add example for usage of the main listenbrainz instance (#71) * docs: add example for usage of the main listenbrainz instance * Update scrobbler.md --------- Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * feat: add server-side cfg and default theme * fix: repair custom theme --------- Co-authored-by: m0d3rnX <jesper@posteo.de> * fix: rebase errors --------- Co-authored-by: pet <128837728+againstpetra@users.noreply.github.com> Co-authored-by: mlandry <mike.landry@gmail.com> Co-authored-by: m0d3rnX <jesper@posteo.de>
This commit is contained in:
parent
bf0ec68cfe
commit
36f984a1a2
56 changed files with 1887 additions and 906 deletions
|
|
@ -1,327 +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<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 getLastListens(
|
||||
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>> {
|
||||
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>>)
|
||||
} 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<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>>)
|
||||
}
|
||||
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>>);
|
||||
} 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<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>> {
|
||||
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<PaginatedResponse<Album>>)
|
||||
} else {
|
||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
||||
}
|
||||
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<PaginatedResponse<Album>>
|
||||
);
|
||||
} else {
|
||||
return fetch(baseUri).then(
|
||||
(r) => r.json() as Promise<PaginatedResponse<Album>>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
||||
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>>)
|
||||
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>>
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
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> {
|
||||
q = encodeURIComponent(q)
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
||||
q = encodeURIComponent(q);
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(
|
||||
(r) => r.json() as Promise<SearchResponse>
|
||||
);
|
||||
}
|
||||
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
function mergeAlbums(from: number, to: number, replaceImage: boolean): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
||||
method: "POST",
|
||||
})
|
||||
function mergeAlbums(
|
||||
from: number,
|
||||
to: number,
|
||||
replaceImage: boolean
|
||||
): Promise<Response> {
|
||||
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<Response> {
|
||||
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}&replace_image=${replaceImage}`, {
|
||||
method: "POST",
|
||||
})
|
||||
function mergeArtists(
|
||||
from: number,
|
||||
to: number,
|
||||
replaceImage: boolean
|
||||
): Promise<Response> {
|
||||
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 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> {
|
||||
return fetch(`/apis/web/v1/logout`, {
|
||||
method: "POST",
|
||||
})
|
||||
return fetch(`/apis/web/v1/logout`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
function getCfg(): Promise<Config> {
|
||||
return fetch(`/apis/web/v1/config`).then((r) => r.json() as Promise<Config>);
|
||||
}
|
||||
|
||||
function submitListen(id: string, ts: Date): Promise<Response> {
|
||||
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<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 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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<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))
|
||||
form.append('alias', alias)
|
||||
return fetch(`/apis/web/v1/aliases`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
function createAlias(
|
||||
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`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
function deleteAlias(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/delete`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
function deleteAlias(
|
||||
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/delete`, {
|
||||
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 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> {
|
||||
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> {
|
||||
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<NowPlaying> {
|
||||
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,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
getAlbum,
|
||||
getExport,
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
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<T> = {
|
||||
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;
|
||||
};
|
||||
type NowPlaying = {
|
||||
currently_playing: boolean;
|
||||
track: Track;
|
||||
};
|
||||
|
||||
export type {
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError
|
||||
}
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError,
|
||||
Config,
|
||||
NowPlaying,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,200 +1,197 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
|
||||
import Popup from "./Popup"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "~/hooks/useTheme"
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getActivity,
|
||||
type getActivityArgs,
|
||||
type ListenActivityItem,
|
||||
} from "api/api";
|
||||
import Popup from "./Popup";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "~/hooks/useTheme";
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector";
|
||||
import type { Theme } from "~/styles/themes.css";
|
||||
|
||||
function getPrimaryColor(): string {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-primary')
|
||||
.trim();
|
||||
function getPrimaryColor(theme: Theme): string {
|
||||
const value = theme.primary;
|
||||
const rgbMatch = value.match(
|
||||
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
|
||||
);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((n) => n.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
step?: string
|
||||
range?: number
|
||||
month?: number
|
||||
year?: number
|
||||
artistId?: number
|
||||
albumId?: number
|
||||
trackId?: number
|
||||
configurable?: boolean
|
||||
autoAdjust?: boolean
|
||||
step?: string;
|
||||
range?: number;
|
||||
month?: number;
|
||||
year?: number;
|
||||
artistId?: number;
|
||||
albumId?: number;
|
||||
trackId?: number;
|
||||
configurable?: boolean;
|
||||
autoAdjust?: boolean;
|
||||
}
|
||||
|
||||
export default function ActivityGrid({
|
||||
step = 'day',
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
}: Props) {
|
||||
step = "day",
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
}: Props) {
|
||||
const [stepState, setStep] = useState(step);
|
||||
const [rangeState, setRange] = useState(range);
|
||||
|
||||
const [color, setColor] = useState(getPrimaryColor())
|
||||
const [stepState, setStep] = useState(step)
|
||||
const [rangeState, setRange] = useState(range)
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'listen-activity',
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"listen-activity",
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
|
||||
const { theme, themeName } = useTheme();
|
||||
const color = getPrimaryColor(theme);
|
||||
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const color = getPrimaryColor()
|
||||
setColor(color);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [theme]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Activity</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) return <p className="error">Error:{error.message}</p>
|
||||
|
||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||
function LightenDarkenColor(hex: string, lum: number) {
|
||||
// validate hex string
|
||||
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
||||
if (hex.length < 6) {
|
||||
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||
}
|
||||
lum = lum || 0;
|
||||
|
||||
// convert to decimal and change luminosity
|
||||
var rgb = "#", c, i;
|
||||
for (i = 0; i < 3; i++) {
|
||||
c = parseInt(hex.substring(i*2,(i*2)+2), 16);
|
||||
c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16);
|
||||
rgb += ("00"+c).substring(c.length);
|
||||
}
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
const getDarkenAmount = (v: number, t: number): number => {
|
||||
|
||||
// really ugly way to just check if this is for all items and not a specific item.
|
||||
// is it jsut better to just pass the target in as a var? probably.
|
||||
const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1
|
||||
|
||||
// automatically adjust the target value based on step
|
||||
// the smartest way to do this would be to have the api return the
|
||||
// highest value in the range. too bad im not smart
|
||||
switch (stepState) {
|
||||
case 'day':
|
||||
t = 10 * adjustment
|
||||
break;
|
||||
case 'week':
|
||||
t = 20 * adjustment
|
||||
break;
|
||||
case 'month':
|
||||
t = 50 * adjustment
|
||||
break;
|
||||
case 'year':
|
||||
t = 100 * adjustment
|
||||
break;
|
||||
}
|
||||
|
||||
v = Math.min(v, t)
|
||||
if (theme === "pearl") {
|
||||
// special case for the only light theme lol
|
||||
// could be generalized by pragmatically comparing the
|
||||
// lightness of the bg vs the primary but eh
|
||||
return ((t-v) / t)
|
||||
} else {
|
||||
return ((v-t) / t) * .8
|
||||
}
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 26 * 7;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<h2>Activity</h2>
|
||||
{configurable ? (
|
||||
<ActivityOptsSelector
|
||||
rangeSetter={setRange}
|
||||
currentRange={rangeState}
|
||||
stepSetter={setStep}
|
||||
currentStep={stepState}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{chunks.map((chunk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
||||
>
|
||||
{chunk.map((item) => (
|
||||
<div
|
||||
key={new Date(item.start_time).toString()}
|
||||
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
||||
>
|
||||
<Popup
|
||||
position="top"
|
||||
space={12}
|
||||
extraClasses="left-2"
|
||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background:
|
||||
item.listens > 0
|
||||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
||||
: 'var(--color-bg-secondary)',
|
||||
}}
|
||||
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
|
||||
item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'
|
||||
}`}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-[500px]">
|
||||
<h2>Activity</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isError) return <p className="error">Error:{error.message}</p>;
|
||||
|
||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||
function LightenDarkenColor(hex: string, lum: number) {
|
||||
// validate hex string
|
||||
hex = String(hex).replace(/[^0-9a-f]/gi, "");
|
||||
if (hex.length < 6) {
|
||||
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
}
|
||||
lum = lum || 0;
|
||||
|
||||
// convert to decimal and change luminosity
|
||||
var rgb = "#",
|
||||
c,
|
||||
i;
|
||||
for (i = 0; i < 3; i++) {
|
||||
c = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||
c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16);
|
||||
rgb += ("00" + c).substring(c.length);
|
||||
}
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
const getDarkenAmount = (v: number, t: number): number => {
|
||||
// really ugly way to just check if this is for all items and not a specific item.
|
||||
// is it jsut better to just pass the target in as a var? probably.
|
||||
const adjustment =
|
||||
artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1;
|
||||
|
||||
// automatically adjust the target value based on step
|
||||
// the smartest way to do this would be to have the api return the
|
||||
// highest value in the range. too bad im not smart
|
||||
switch (stepState) {
|
||||
case "day":
|
||||
t = 10 * adjustment;
|
||||
break;
|
||||
case "week":
|
||||
t = 20 * adjustment;
|
||||
break;
|
||||
case "month":
|
||||
t = 50 * adjustment;
|
||||
break;
|
||||
case "year":
|
||||
t = 100 * adjustment;
|
||||
break;
|
||||
}
|
||||
|
||||
v = Math.min(v, t);
|
||||
if (themeName === "pearl") {
|
||||
// special case for the only light theme lol
|
||||
// could be generalized by pragmatically comparing the
|
||||
// lightness of the bg vs the primary but eh
|
||||
return (t - v) / t;
|
||||
} else {
|
||||
return ((v - t) / t) * 0.8;
|
||||
}
|
||||
};
|
||||
|
||||
const CHUNK_SIZE = 26 * 7;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<h2>Activity</h2>
|
||||
{configurable ? (
|
||||
<ActivityOptsSelector
|
||||
rangeSetter={setRange}
|
||||
currentRange={rangeState}
|
||||
stepSetter={setStep}
|
||||
currentStep={stepState}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{chunks.map((chunk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
||||
>
|
||||
{chunk.map((item) => (
|
||||
<div
|
||||
key={new Date(item.start_time).toString()}
|
||||
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
||||
>
|
||||
<Popup
|
||||
position="top"
|
||||
space={12}
|
||||
extraClasses="left-2"
|
||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${
|
||||
item.listens
|
||||
} plays`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
background:
|
||||
item.listens > 0
|
||||
? LightenDarkenColor(
|
||||
color,
|
||||
getDarkenAmount(item.listens, 100)
|
||||
)
|
||||
: "var(--color-bg-secondary)",
|
||||
}}
|
||||
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
|
||||
item.listens > 0
|
||||
? ""
|
||||
: "border-[0.5px] border-(--color-bg-tertiary)"
|
||||
}`}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ export default function ActivityOptsSelector({
|
|||
|
||||
useEffect(() => {
|
||||
if (!disableCache) {
|
||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
|
||||
// TODO: the '182' here overwrites the default range as configured in the ActivityGrid. This is bad. Only one of these should determine the default.
|
||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '182');
|
||||
if (cachedRange) rangeSetter(cachedRange);
|
||||
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
||||
if (cachedStep) stepSetter(cachedStep);
|
||||
|
|
|
|||
|
|
@ -1,109 +1,150 @@
|
|||
import { 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 } 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
|
||||
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<Listen[] | null>(null)
|
||||
const [items, setItems] = useState<Listen[] | null>(null);
|
||||
|
||||
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 (
|
||||
<div className="w-[300px] sm:w-[500px]">
|
||||
<h2>Last Played</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>
|
||||
}
|
||||
|
||||
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}` : ''
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="text-sm sm:text-[16px]">
|
||||
<h2 className="hover:underline">
|
||||
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
|
||||
</h2>
|
||||
<table className="-ml-4">
|
||||
<tbody>
|
||||
{listens.map((item) => (
|
||||
<tr key={`last_listen_${item.time}`} className="group hover:bg-[--color-bg-secondary]">
|
||||
<td className="w-[18px] pr-2 align-middle" >
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||
aria-label="Delete"
|
||||
hidden={user === null || user === undefined}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
||||
title={new Date(item.time).toString()}
|
||||
>
|
||||
{timeSince(new Date(item.time))}
|
||||
</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||
{props.hideArtists ? null : (
|
||||
<>
|
||||
<ArtistLinks artists={item.track.artists} /> –{' '}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
className="hover:text-[--color-fg-secondary]"
|
||||
to={`/track/${item.track.id}`}
|
||||
>
|
||||
{item.track.title}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
<div className="w-[300px] sm:w-[500px]">
|
||||
<h2>Last Played</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
|
||||
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}` : "";
|
||||
|
||||
return (
|
||||
<div className="text-sm sm:text-[16px]">
|
||||
<h2 className="hover:underline">
|
||||
<Link to={`/listens?period=all_time${params}`}>Last Played</Link>
|
||||
</h2>
|
||||
<table className="-ml-4">
|
||||
<tbody>
|
||||
{props.showNowPlaying && npData && npData.currently_playing && (
|
||||
<tr className="group hover:bg-[--color-bg-secondary]">
|
||||
<td className="w-[18px] pr-2 align-middle"></td>
|
||||
<td className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0">
|
||||
Now Playing
|
||||
</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||
{props.hideArtists ? null : (
|
||||
<>
|
||||
<ArtistLinks artists={npData.track.artists} /> –{" "}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
className="hover:text-[--color-fg-secondary]"
|
||||
to={`/track/${npData.track.id}`}
|
||||
>
|
||||
{npData.track.title}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{listens.map((item) => (
|
||||
<tr
|
||||
key={`last_listen_${item.time}`}
|
||||
className="group hover:bg-[--color-bg-secondary]"
|
||||
>
|
||||
<td className="w-[18px] pr-2 align-middle">
|
||||
<button
|
||||
onClick={() => handleDelete(item)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-(--color-fg-tertiary) hover:text-(--color-error)"
|
||||
aria-label="Delete"
|
||||
hidden={user === null || user === undefined}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</td>
|
||||
<td
|
||||
className="color-fg-tertiary pr-2 sm:pr-4 text-sm whitespace-nowrap w-0"
|
||||
title={new Date(item.time).toString()}
|
||||
>
|
||||
{timeSince(new Date(item.time))}
|
||||
</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[400px] sm:max-w-[600px]">
|
||||
{props.hideArtists ? null : (
|
||||
<>
|
||||
<ArtistLinks artists={item.track.artists} /> –{" "}
|
||||
</>
|
||||
)}
|
||||
<Link
|
||||
className="hover:text-[--color-fg-secondary]"
|
||||
to={`/track/${item.track.id}`}
|
||||
>
|
||||
{item.track.title}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
57
client/app/components/modals/AddListenModal.tsx
Normal file
57
client/app/components/modals/AddListenModal.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useState } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { submitListen } from "api/api";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
trackid: number
|
||||
}
|
||||
|
||||
export default function AddListenModal({ open, setOpen, trackid }: Props) {
|
||||
const [ts, setTS] = useState<Date>(new Date);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const close = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
setLoading(true)
|
||||
submitListen(trackid.toString(), ts)
|
||||
.then(r => {
|
||||
if(r.ok) {
|
||||
setLoading(false)
|
||||
navigate(0)
|
||||
} else {
|
||||
r.json().then(r => setError(r.error))
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatForDatetimeLocal = (d: Date) => {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={close}>
|
||||
<h2>Add Listen</h2>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={formatForDatetimeLocal(ts)}
|
||||
onChange={(e) => setTS(new Date(e.target.value))}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton>
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +1,20 @@
|
|||
import type { Theme } from "~/providers/ThemeProvider";
|
||||
import type { Theme } from "~/styles/themes.css";
|
||||
|
||||
interface Props {
|
||||
theme: Theme
|
||||
themeName: string
|
||||
setTheme: Function
|
||||
}
|
||||
|
||||
export default function ThemeOption({ theme, setTheme }: Props) {
|
||||
export default function ThemeOption({ theme, themeName, setTheme }: Props) {
|
||||
|
||||
const capitalizeFirstLetter = (s: string) => {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={() => setTheme(theme.name)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
|
||||
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(theme.name)}</div>
|
||||
<div onClick={() => setTheme(themeName)} className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
|
||||
<div className="text-xs sm:text-sm">{capitalizeFirstLetter(themeName)}</div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
||||
|
|
|
|||
|
|
@ -1,69 +1,78 @@
|
|||
// ThemeSwitcher.tsx
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import themes from '~/styles/themes.css';
|
||||
import ThemeOption from './ThemeOption';
|
||||
import { AsyncButton } from '../AsyncButton';
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "../../hooks/useTheme";
|
||||
import themes from "~/styles/themes.css";
|
||||
import ThemeOption from "./ThemeOption";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const initialTheme = {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
}
|
||||
|
||||
const { setCustomTheme, getCustomTheme } = useTheme()
|
||||
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
|
||||
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom)
|
||||
try {
|
||||
const theme = JSON.parse(custom)
|
||||
theme.name = "custom"
|
||||
setCustomTheme(theme)
|
||||
delete theme.name
|
||||
setCustom(JSON.stringify(theme, null, " "))
|
||||
console.log(theme)
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
const { setTheme } = useTheme();
|
||||
const initialTheme = {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setTheme(theme)
|
||||
}
|
||||
}, [theme]);
|
||||
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
|
||||
const [custom, setCustom] = useState(
|
||||
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-10'>
|
||||
<div>
|
||||
<h2>Select Theme</h2>
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
{themes.map((t) => (
|
||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Use Custom Theme</h2>
|
||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} />
|
||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom);
|
||||
try {
|
||||
const themeData = JSON.parse(custom);
|
||||
setCustomTheme(themeData);
|
||||
setCustom(JSON.stringify(themeData, null, " "));
|
||||
console.log(themeData);
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h2>Select Theme</h2>
|
||||
<div className="mb-3">
|
||||
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
{Object.entries(themes).map(([name, themeData]) => (
|
||||
<ThemeOption
|
||||
setTheme={setTheme}
|
||||
key={name}
|
||||
theme={themeData}
|
||||
themeName={name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Use Custom Theme</h2>
|
||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||
<textarea
|
||||
name="custom-theme"
|
||||
onChange={(e) => setCustom(e.target.value)}
|
||||
id="custom-theme-input"
|
||||
className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md"
|
||||
value={custom}
|
||||
/>
|
||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { User } from "api/api";
|
||||
import { getCfg, type User } from "api/api";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface AppContextType {
|
||||
user: User | null | undefined;
|
||||
configurableHomeActivity: boolean;
|
||||
homeItems: number;
|
||||
defaultTheme: string;
|
||||
setConfigurableHomeActivity: (value: boolean) => void;
|
||||
setHomeItems: (value: number) => void;
|
||||
setUsername: (value: string) => void;
|
||||
|
|
@ -22,15 +23,19 @@ export const useAppContext = () => {
|
|||
|
||||
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null | undefined>(undefined);
|
||||
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
|
||||
const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [configurableHomeActivity, setConfigurableHomeActivity] =
|
||||
useState<boolean>(false);
|
||||
const [homeItems, setHomeItems] = useState<number>(0);
|
||||
|
||||
const setUsername = (value: string) => {
|
||||
if (!user) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
setUser({...user, username: value})
|
||||
}
|
||||
setUser({ ...user, username: value });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/apis/web/v1/user/me")
|
||||
|
|
@ -42,9 +47,19 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
|||
|
||||
setConfigurableHomeActivity(true);
|
||||
setHomeItems(12);
|
||||
|
||||
getCfg().then((cfg) => {
|
||||
console.log(cfg);
|
||||
if (cfg.default_theme !== "") {
|
||||
setDefaultTheme(cfg.default_theme);
|
||||
} else {
|
||||
setDefaultTheme("yuu");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (user === undefined) {
|
||||
// Block rendering the app until config is loaded
|
||||
if (user === undefined || defaultTheme === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -52,10 +67,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
|||
user,
|
||||
configurableHomeActivity,
|
||||
homeItems,
|
||||
defaultTheme,
|
||||
setConfigurableHomeActivity,
|
||||
setHomeItems,
|
||||
setUsername,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
|
||||
};
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,95 +1,131 @@
|
|||
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
import { type Theme } from '~/styles/themes.css';
|
||||
import { themeVars } from '~/styles/vars.css';
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { type Theme, themes } from "~/styles/themes.css";
|
||||
import { themeVars } from "~/styles/vars.css";
|
||||
import { useAppContext } from "./AppProvider";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
setCustomTheme: (theme: Theme) => void;
|
||||
getCustomTheme: () => Theme | undefined;
|
||||
themeName: string;
|
||||
theme: Theme;
|
||||
setTheme: (theme: string) => void;
|
||||
resetTheme: () => void;
|
||||
setCustomTheme: (theme: Theme) => void;
|
||||
getCustomTheme: () => Theme | undefined;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function toKebabCase(str: string) {
|
||||
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
||||
return str.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
}
|
||||
|
||||
function applyCustomThemeVars(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === 'name') continue;
|
||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||
}
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === "name") continue;
|
||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCustomThemeVars() {
|
||||
for (const cssVar of Object.values(themeVars)) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
}
|
||||
for (const cssVar of Object.values(themeVars)) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
theme: initialTheme,
|
||||
children,
|
||||
}: {
|
||||
theme: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [theme, setThemeName] = useState(initialTheme);
|
||||
function getStoredCustomTheme(): Theme | undefined {
|
||||
const themeStr = localStorage.getItem("custom-theme");
|
||||
if (!themeStr) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(themeStr);
|
||||
const { name, ...theme } = parsed;
|
||||
return theme as Theme;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const setTheme = (theme: string) => {
|
||||
setThemeName(theme)
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
let defaultTheme = useAppContext().defaultTheme;
|
||||
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
|
||||
const [themeName, setThemeName] = useState(initialTheme);
|
||||
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
|
||||
if (initialTheme === "custom") {
|
||||
const customTheme = getStoredCustomTheme();
|
||||
return customTheme || themes[defaultTheme];
|
||||
}
|
||||
return themes[initialTheme] || themes[defaultTheme];
|
||||
});
|
||||
|
||||
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
||||
applyCustomThemeVars(customTheme);
|
||||
setTheme('custom');
|
||||
}, []);
|
||||
|
||||
const getCustomTheme = (): Theme | undefined => {
|
||||
const themeStr = localStorage.getItem('custom-theme');
|
||||
if (!themeStr) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
let theme = JSON.parse(themeStr) as Theme
|
||||
return theme
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
const setTheme = (newThemeName: string) => {
|
||||
setThemeName(newThemeName);
|
||||
if (newThemeName === "custom") {
|
||||
const customTheme = getStoredCustomTheme();
|
||||
if (customTheme) {
|
||||
setCurrentTheme(customTheme);
|
||||
} else {
|
||||
// Fallback to default theme if no custom theme found
|
||||
setThemeName(defaultTheme);
|
||||
setCurrentTheme(themes[defaultTheme]);
|
||||
}
|
||||
} else {
|
||||
const foundTheme = themes[newThemeName];
|
||||
if (foundTheme) {
|
||||
localStorage.setItem("theme", newThemeName);
|
||||
setCurrentTheme(foundTheme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const resetTheme = () => {
|
||||
setThemeName(defaultTheme);
|
||||
localStorage.removeItem("theme");
|
||||
setCurrentTheme(themes[defaultTheme]);
|
||||
};
|
||||
|
||||
root.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme)
|
||||
console.log(theme)
|
||||
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
|
||||
applyCustomThemeVars(customTheme);
|
||||
setThemeName("custom");
|
||||
localStorage.setItem("theme", "custom");
|
||||
setCurrentTheme(customTheme);
|
||||
}, []);
|
||||
|
||||
if (theme === 'custom') {
|
||||
const saved = localStorage.getItem('custom-theme');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as Theme;
|
||||
applyCustomThemeVars(parsed);
|
||||
} catch (err) {
|
||||
console.error('Invalid custom theme in localStorage', err);
|
||||
}
|
||||
} else {
|
||||
setTheme('yuu')
|
||||
}
|
||||
} else {
|
||||
clearCustomThemeVars()
|
||||
}
|
||||
}, [theme]);
|
||||
const getCustomTheme = (): Theme | undefined => {
|
||||
return getStoredCustomTheme();
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
|
||||
root.setAttribute("data-theme", themeName);
|
||||
|
||||
if (themeName === "custom") {
|
||||
applyCustomThemeVars(currentTheme);
|
||||
} else {
|
||||
clearCustomThemeVars();
|
||||
}
|
||||
}, [themeName, currentTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
themeName,
|
||||
theme: currentTheme,
|
||||
setTheme,
|
||||
resetTheme,
|
||||
setCustomTheme,
|
||||
getCustomTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ThemeContext };
|
||||
|
|
|
|||
|
|
@ -58,12 +58,10 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
export default function App() {
|
||||
let theme = localStorage.getItem('theme') ?? 'yuu'
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex-col flex sm:flex-row">
|
||||
<Sidebar />
|
||||
|
|
@ -99,18 +97,12 @@ export function ErrorBoundary() {
|
|||
stack = error.stack;
|
||||
}
|
||||
|
||||
let theme = 'yuu'
|
||||
try {
|
||||
theme = localStorage.getItem('theme') ?? theme
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
|
||||
const title = `${message} - Koito`
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<ThemeProvider>
|
||||
<title>{title}</title>
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export default function Home() {
|
|||
<TopArtists period={period} limit={homeItems} />
|
||||
<TopAlbums period={period} limit={homeItems} />
|
||||
<TopTracks period={period} limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.7)} />
|
||||
<LastPlays showNowPlaying={true} limit={Math.floor(homeItems * 2.7)} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export default function Album() {
|
|||
subContent={<div className="flex flex-col gap-2 items-start">
|
||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
||||
{<p title={Math.floor(album.time_listened / 60) + " minutes"}>{timeListenedString(album.time_listened)}</p>}
|
||||
{<p title={new Date(album.first_listen * 1000).toLocaleString()}>Listening since {new Date(album.first_listen * 1000).toLocaleDateString()}</p>}
|
||||
</div>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export default function Artist() {
|
|||
subContent={<div className="flex flex-col gap-2 items-start">
|
||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
||||
{<p title={Math.floor(artist.time_listened / 60) + " minutes"}>{timeListenedString(artist.time_listened)}</p>}
|
||||
{<p title={new Date(artist.first_listen * 1000).toLocaleString()}>Listening since {new Date(artist.first_listen * 1000).toLocaleDateString()}</p>}
|
||||
</div>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@ import React, { useEffect, useState } from "react";
|
|||
import { average } from "color.js";
|
||||
import { imageUrl, type SearchResponse } from "api/api";
|
||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
||||
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
|
||||
import { Edit, ImageIcon, Merge, Plus, Trash } from "lucide-react";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import MergeModal from "~/components/modals/MergeModal";
|
||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||
import DeleteModal from "~/components/modals/DeleteModal";
|
||||
import RenameModal from "~/components/modals/EditModal/EditModal";
|
||||
import EditModal from "~/components/modals/EditModal/EditModal";
|
||||
import AddListenModal from "~/components/modals/AddListenModal";
|
||||
|
||||
export type MergeFunc = (from: number, to: number, replaceImage: boolean) => Promise<Response>
|
||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||
|
|
@ -32,6 +33,7 @@ export default function MediaLayout(props: Props) {
|
|||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const [addListenModalOpen, setAddListenModalOpen] = useState(false);
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -80,6 +82,12 @@ export default function MediaLayout(props: Props) {
|
|||
</div>
|
||||
{ user &&
|
||||
<div className="absolute left-1 sm:right-1 sm:left-auto -top-9 sm:top-1 flex gap-3 items-center">
|
||||
{ props.type === "Track" &&
|
||||
<>
|
||||
<button title="Add Listen" className="hover:cursor-pointer" onClick={() => setAddListenModalOpen(true)}><Plus size={iconSize} /></button>
|
||||
<AddListenModal open={addListenModalOpen} setOpen={setAddListenModalOpen} trackid={props.id} />
|
||||
</>
|
||||
}
|
||||
<button title="Edit Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={iconSize} /></button>
|
||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={iconSize} /></button>
|
||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={iconSize} /></button>
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ export default function Track() {
|
|||
subContent={<div className="flex flex-col gap-2 items-start">
|
||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
||||
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
|
||||
{<p title={Math.floor(track.time_listened / 60) + " minutes"}>{timeListenedString(track.time_listened)}</p>}
|
||||
{<p title={new Date(track.first_listen * 1000).toLocaleString()}>Listening since {new Date(track.first_listen * 1000).toLocaleDateString()}</p>}
|
||||
</div>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import { themes, type Theme } from "~/styles/themes.css"
|
|||
|
||||
export default function ThemeHelper() {
|
||||
const initialTheme = {
|
||||
name: "custom",
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
|
|
@ -36,9 +35,6 @@ export default function ThemeHelper() {
|
|||
console.log(custom)
|
||||
try {
|
||||
const theme = JSON.parse(custom) as Theme
|
||||
if (theme.name !== "custom") {
|
||||
throw new Error("theme name must be 'custom'")
|
||||
}
|
||||
console.log(theme)
|
||||
setCustomTheme(theme)
|
||||
} catch(err) {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import { globalStyle } from "@vanilla-extract/css"
|
|||
import { themeVars } from "./vars.css"
|
||||
|
||||
export type Theme = {
|
||||
name: string,
|
||||
bg: string
|
||||
bgSecondary: string
|
||||
bg: string
|
||||
bgSecondary: string
|
||||
bgTertiary: string
|
||||
fg: string
|
||||
fg: string
|
||||
fgSecondary: string
|
||||
fgTertiary: string
|
||||
primary: string
|
||||
|
|
@ -23,9 +22,8 @@ export const THEME_KEYS = [
|
|||
'--color'
|
||||
]
|
||||
|
||||
export const themes: Theme[] = [
|
||||
{
|
||||
name: "yuu",
|
||||
export const themes: Record<string, Theme> = {
|
||||
yuu: {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
|
|
@ -41,8 +39,7 @@ export const themes: Theme[] = [
|
|||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
},
|
||||
{
|
||||
name: "varia",
|
||||
varia: {
|
||||
bg: "rgb(25, 25, 29)",
|
||||
bgSecondary: "#222222",
|
||||
bgTertiary: "#333333",
|
||||
|
|
@ -58,8 +55,7 @@ export const themes: Theme[] = [
|
|||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "midnight",
|
||||
midnight: {
|
||||
bg: "rgb(8, 15, 24)",
|
||||
bgSecondary: "rgb(15, 27, 46)",
|
||||
bgTertiary: "rgb(15, 41, 70)",
|
||||
|
|
@ -75,8 +71,7 @@ export const themes: Theme[] = [
|
|||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "catppuccin",
|
||||
catppuccin: {
|
||||
bg: "#1e1e2e",
|
||||
bgSecondary: "#181825",
|
||||
bgTertiary: "#11111b",
|
||||
|
|
@ -92,8 +87,7 @@ export const themes: Theme[] = [
|
|||
success: "#a6e3a1",
|
||||
info: "#89dceb",
|
||||
},
|
||||
{
|
||||
name: "autumn",
|
||||
autumn: {
|
||||
bg: "rgb(44, 25, 18)",
|
||||
bgSecondary: "rgb(70, 40, 18)",
|
||||
bgTertiary: "#4b2f1c",
|
||||
|
|
@ -109,8 +103,7 @@ export const themes: Theme[] = [
|
|||
success: "#6b8e23",
|
||||
info: "#c084fc",
|
||||
},
|
||||
{
|
||||
name: "black",
|
||||
black: {
|
||||
bg: "#000000",
|
||||
bgSecondary: "#1a1a1a",
|
||||
bgTertiary: "#2a2a2a",
|
||||
|
|
@ -126,8 +119,7 @@ export const themes: Theme[] = [
|
|||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "wine",
|
||||
wine: {
|
||||
bg: "#23181E",
|
||||
bgSecondary: "#2C1C25",
|
||||
bgTertiary: "#422A37",
|
||||
|
|
@ -143,97 +135,92 @@ export const themes: Theme[] = [
|
|||
success: "#bbf7d0",
|
||||
info: "#bae6fd",
|
||||
},
|
||||
{
|
||||
name: "pearl",
|
||||
bg: "#FFFFFF",
|
||||
bgSecondary: "#EEEEEE",
|
||||
bgTertiary: "#E0E0E0",
|
||||
fg: "#333333",
|
||||
fgSecondary: "#555555",
|
||||
pearl: {
|
||||
bg: "#FFFFFF",
|
||||
bgSecondary: "#EEEEEE",
|
||||
bgTertiary: "#E0E0E0",
|
||||
fg: "#333333",
|
||||
fgSecondary: "#555555",
|
||||
fgTertiary: "#777777",
|
||||
primary: "#007BFF",
|
||||
primary: "#007BFF",
|
||||
primaryDim: "#0056B3",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#DC3545",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#DC3545",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "asuka",
|
||||
bg: "#3B1212",
|
||||
bgSecondary: "#471B1B",
|
||||
bgTertiary: "#020202",
|
||||
fg: "#F1E9E6",
|
||||
fgSecondary: "#CCB6AE",
|
||||
asuka: {
|
||||
bg: "#3B1212",
|
||||
bgSecondary: "#471B1B",
|
||||
bgTertiary: "#020202",
|
||||
fg: "#F1E9E6",
|
||||
fgSecondary: "#CCB6AE",
|
||||
fgTertiary: "#9F8176",
|
||||
primary: "#F1E9E6",
|
||||
primary: "#F1E9E6",
|
||||
primaryDim: "#CCB6AE",
|
||||
accent: "#41CE41",
|
||||
accentDim: "#3BA03B",
|
||||
error: "#DC143C",
|
||||
warning: "#FFD700",
|
||||
success: "#32CD32",
|
||||
info: "#1E90FF",
|
||||
accent: "#41CE41",
|
||||
accentDim: "#3BA03B",
|
||||
error: "#DC143C",
|
||||
warning: "#FFD700",
|
||||
success: "#32CD32",
|
||||
info: "#1E90FF",
|
||||
},
|
||||
{
|
||||
name: "urim",
|
||||
bg: "#101713",
|
||||
bgSecondary: "#1B2921",
|
||||
bgTertiary: "#273B30",
|
||||
fg: "#D2E79E",
|
||||
fgSecondary: "#B4DA55",
|
||||
urim: {
|
||||
bg: "#101713",
|
||||
bgSecondary: "#1B2921",
|
||||
bgTertiary: "#273B30",
|
||||
fg: "#D2E79E",
|
||||
fgSecondary: "#B4DA55",
|
||||
fgTertiary: "#7E9F2A",
|
||||
primary: "#ead500",
|
||||
primary: "#ead500",
|
||||
primaryDim: "#C1B210",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#EE5237",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#EE5237",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "match",
|
||||
bg: "#071014",
|
||||
bgSecondary: "#0A181E",
|
||||
bgTertiary: "#112A34",
|
||||
fg: "#ebeaeb",
|
||||
fgSecondary: "#BDBDBD",
|
||||
match: {
|
||||
bg: "#071014",
|
||||
bgSecondary: "#0A181E",
|
||||
bgTertiary: "#112A34",
|
||||
fg: "#ebeaeb",
|
||||
fgSecondary: "#BDBDBD",
|
||||
fgTertiary: "#A2A2A2",
|
||||
primary: "#fda827",
|
||||
primary: "#fda827",
|
||||
primaryDim: "#C78420",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "lemon",
|
||||
bg: "#1a171a",
|
||||
bgSecondary: "#2E272E",
|
||||
bgTertiary: "#443844",
|
||||
fg: "#E6E2DC",
|
||||
fgSecondary: "#B2ACA1",
|
||||
lemon: {
|
||||
bg: "#1a171a",
|
||||
bgSecondary: "#2E272E",
|
||||
bgTertiary: "#443844",
|
||||
fg: "#E6E2DC",
|
||||
fgSecondary: "#B2ACA1",
|
||||
fgTertiary: "#968F82",
|
||||
primary: "#f5c737",
|
||||
primary: "#f5c737",
|
||||
primaryDim: "#C29D2F",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export default themes
|
||||
|
||||
themes.forEach((theme) => {
|
||||
const selector = `[data-theme="${theme.name}"]`
|
||||
Object.entries(themes).forEach(([name, theme]) => {
|
||||
const selector = `[data-theme="${name}"]`
|
||||
|
||||
globalStyle(selector, {
|
||||
vars: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"name": "Koito",
|
||||
"short_name": "Koito",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue