mirror of https://github.com/gabehf/Koito.git
Compare commits
6 Commits
d0c4d078d5
...
0186cefb52
| Author | SHA1 | Date |
|---|---|---|
|
|
0186cefb52 | 3 weeks ago |
|
|
68d922ce55 | 3 weeks ago |
|
|
621ca63b6b | 3 weeks ago |
|
|
517cc8ac28 | 3 weeks ago |
|
|
56ffe0a041 | 3 weeks ago |
|
|
164a9dc56f | 3 weeks ago |
@ -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,191 +1,197 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getActivity, type getActivityArgs, type ListenActivityItem } from "api/api"
|
import {
|
||||||
import Popup from "./Popup"
|
getActivity,
|
||||||
import { useState } from "react"
|
type getActivityArgs,
|
||||||
import { useTheme } from "~/hooks/useTheme"
|
type ListenActivityItem,
|
||||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
} from "api/api";
|
||||||
import type { Theme } from "~/styles/themes.css"
|
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(theme: Theme): string {
|
function getPrimaryColor(theme: Theme): string {
|
||||||
const value = theme.primary;
|
const value = theme.primary;
|
||||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
const rgbMatch = value.match(
|
||||||
if (rgbMatch) {
|
/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/
|
||||||
const [, r, g, b] = rgbMatch.map(Number);
|
);
|
||||||
return (
|
if (rgbMatch) {
|
||||||
'#' +
|
const [, r, g, b] = rgbMatch.map(Number);
|
||||||
[r, g, b]
|
return "#" + [r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||||
.map((n) => n.toString(16).padStart(2, '0'))
|
}
|
||||||
.join('')
|
|
||||||
);
|
return value;
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
interface Props {
|
interface Props {
|
||||||
step?: string
|
step?: string;
|
||||||
range?: number
|
range?: number;
|
||||||
month?: number
|
month?: number;
|
||||||
year?: number
|
year?: number;
|
||||||
artistId?: number
|
artistId?: number;
|
||||||
albumId?: number
|
albumId?: number;
|
||||||
trackId?: number
|
trackId?: number;
|
||||||
configurable?: boolean
|
configurable?: boolean;
|
||||||
autoAdjust?: boolean
|
autoAdjust?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityGrid({
|
export default function ActivityGrid({
|
||||||
step = 'day',
|
step = "day",
|
||||||
range = 182,
|
range = 182,
|
||||||
month = 0,
|
month = 0,
|
||||||
year = 0,
|
year = 0,
|
||||||
artistId = 0,
|
artistId = 0,
|
||||||
albumId = 0,
|
albumId = 0,
|
||||||
trackId = 0,
|
trackId = 0,
|
||||||
configurable = false,
|
configurable = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const [stepState, setStep] = useState(step);
|
||||||
const [stepState, setStep] = useState(step)
|
const [rangeState, setRange] = useState(range);
|
||||||
const [rangeState, setRange] = useState(range)
|
|
||||||
|
const { isPending, isError, data, error } = useQuery({
|
||||||
const { isPending, isError, data, error } = useQuery({
|
queryKey: [
|
||||||
queryKey: [
|
"listen-activity",
|
||||||
'listen-activity',
|
{
|
||||||
{
|
step: stepState,
|
||||||
step: stepState,
|
range: rangeState,
|
||||||
range: rangeState,
|
month: month,
|
||||||
month: month,
|
year: year,
|
||||||
year: year,
|
artist_id: artistId,
|
||||||
artist_id: artistId,
|
album_id: albumId,
|
||||||
album_id: albumId,
|
track_id: trackId,
|
||||||
track_id: trackId
|
},
|
||||||
},
|
],
|
||||||
],
|
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
});
|
||||||
});
|
|
||||||
|
const { theme, themeName } = useTheme();
|
||||||
|
const color = getPrimaryColor(theme);
|
||||||
const { theme, themeName } = useTheme();
|
|
||||||
const color = getPrimaryColor(theme);
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="w-[500px]">
|
||||||
if (isPending) {
|
<h2>Activity</h2>
|
||||||
return (
|
<p>Loading...</p>
|
||||||
<div className="w-[500px]">
|
</div>
|
||||||
<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];
|
||||||
}
|
}
|
||||||
if (isError) return <p className="error">Error:{error.message}</p>
|
lum = lum || 0;
|
||||||
|
|
||||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
// convert to decimal and change luminosity
|
||||||
function LightenDarkenColor(hex: string, lum: number) {
|
var rgb = "#",
|
||||||
// validate hex string
|
c,
|
||||||
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
i;
|
||||||
if (hex.length < 6) {
|
for (i = 0; i < 3; i++) {
|
||||||
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
c = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||||
}
|
c = Math.round(Math.min(Math.max(0, c + c * lum), 255)).toString(16);
|
||||||
lum = lum || 0;
|
rgb += ("00" + c).substring(c.length);
|
||||||
|
|
||||||
// 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 => {
|
return rgb;
|
||||||
|
}
|
||||||
// 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 getDarkenAmount = (v: number, t: number): number => {
|
||||||
const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1
|
// 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.
|
||||||
// automatically adjust the target value based on step
|
const adjustment =
|
||||||
// the smartest way to do this would be to have the api return the
|
artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1;
|
||||||
// highest value in the range. too bad im not smart
|
|
||||||
switch (stepState) {
|
// automatically adjust the target value based on step
|
||||||
case 'day':
|
// the smartest way to do this would be to have the api return the
|
||||||
t = 10 * adjustment
|
// highest value in the range. too bad im not smart
|
||||||
break;
|
switch (stepState) {
|
||||||
case 'week':
|
case "day":
|
||||||
t = 20 * adjustment
|
t = 10 * adjustment;
|
||||||
break;
|
break;
|
||||||
case 'month':
|
case "week":
|
||||||
t = 50 * adjustment
|
t = 20 * adjustment;
|
||||||
break;
|
break;
|
||||||
case 'year':
|
case "month":
|
||||||
t = 100 * adjustment
|
t = 50 * adjustment;
|
||||||
break;
|
break;
|
||||||
}
|
case "year":
|
||||||
|
t = 100 * adjustment;
|
||||||
v = Math.min(v, t)
|
break;
|
||||||
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) * .8
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHUNK_SIZE = 26 * 7;
|
v = Math.min(v, t);
|
||||||
const chunks = [];
|
if (themeName === "pearl") {
|
||||||
|
// special case for the only light theme lol
|
||||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
// could be generalized by pragmatically comparing the
|
||||||
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
// lightness of the bg vs the primary but eh
|
||||||
|
return (t - v) / t;
|
||||||
|
} else {
|
||||||
|
return ((v - t) / t) * 0.8;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-start">
|
const CHUNK_SIZE = 26 * 7;
|
||||||
<h2>Activity</h2>
|
const chunks = [];
|
||||||
{configurable ? (
|
|
||||||
<ActivityOptsSelector
|
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||||
rangeSetter={setRange}
|
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||||
currentRange={rangeState}
|
}
|
||||||
stepSetter={setStep}
|
|
||||||
currentStep={stepState}
|
return (
|
||||||
/>
|
<div className="flex flex-col items-start">
|
||||||
) : null}
|
<h2>Activity</h2>
|
||||||
|
{configurable ? (
|
||||||
{chunks.map((chunk, index) => (
|
<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
|
<div
|
||||||
key={index}
|
style={{
|
||||||
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
display: "inline-block",
|
||||||
>
|
background:
|
||||||
{chunk.map((item) => (
|
item.listens > 0
|
||||||
<div
|
? LightenDarkenColor(
|
||||||
key={new Date(item.start_time).toString()}
|
color,
|
||||||
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
getDarkenAmount(item.listens, 100)
|
||||||
>
|
)
|
||||||
<Popup
|
: "var(--color-bg-secondary)",
|
||||||
position="top"
|
}}
|
||||||
space={12}
|
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
|
||||||
extraClasses="left-2"
|
item.listens > 0
|
||||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
? ""
|
||||||
>
|
: "border-[0.5px] border-(--color-bg-tertiary)"
|
||||||
<div
|
}`}
|
||||||
style={{
|
></div>
|
||||||
display: 'inline-block',
|
</Popup>
|
||||||
background:
|
</div>
|
||||||
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>
|
||||||
);
|
))}
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,65 +1,78 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from "../../hooks/useTheme";
|
||||||
import themes from '~/styles/themes.css';
|
import themes from "~/styles/themes.css";
|
||||||
import ThemeOption from './ThemeOption';
|
import ThemeOption from "./ThemeOption";
|
||||||
import { AsyncButton } from '../AsyncButton';
|
import { AsyncButton } from "../AsyncButton";
|
||||||
|
|
||||||
export function ThemeSwitcher() {
|
export function ThemeSwitcher() {
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
const initialTheme = {
|
const initialTheme = {
|
||||||
bg: "#1e1816",
|
bg: "#1e1816",
|
||||||
bgSecondary: "#2f2623",
|
bgSecondary: "#2f2623",
|
||||||
bgTertiary: "#453733",
|
bgTertiary: "#453733",
|
||||||
fg: "#f8f3ec",
|
fg: "#f8f3ec",
|
||||||
fgSecondary: "#d6ccc2",
|
fgSecondary: "#d6ccc2",
|
||||||
fgTertiary: "#b4a89c",
|
fgTertiary: "#b4a89c",
|
||||||
primary: "#f5a97f",
|
primary: "#f5a97f",
|
||||||
primaryDim: "#d88b65",
|
primaryDim: "#d88b65",
|
||||||
accent: "#f9db6d",
|
accent: "#f9db6d",
|
||||||
accentDim: "#d9bc55",
|
accentDim: "#d9bc55",
|
||||||
error: "#e26c6a",
|
error: "#e26c6a",
|
||||||
warning: "#f5b851",
|
warning: "#f5b851",
|
||||||
success: "#8fc48f",
|
success: "#8fc48f",
|
||||||
info: "#87b8dd",
|
info: "#87b8dd",
|
||||||
}
|
};
|
||||||
|
|
||||||
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme()
|
|
||||||
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
|
|
||||||
|
|
||||||
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 (
|
const { setCustomTheme, getCustomTheme, resetTheme } = useTheme();
|
||||||
<div className='flex flex-col gap-10'>
|
const [custom, setCustom] = useState(
|
||||||
<div>
|
JSON.stringify(getCustomTheme() ?? initialTheme, null, " ")
|
||||||
<div className='flex items-center gap-3'>
|
);
|
||||||
<h2>Select Theme</h2>
|
|
||||||
<div className='mb-3'>
|
const handleCustomTheme = () => {
|
||||||
<AsyncButton onClick={resetTheme}>Reset</AsyncButton>
|
console.log(custom);
|
||||||
</div>
|
try {
|
||||||
</div>
|
const themeData = JSON.parse(custom);
|
||||||
<div className="grid grid-cols-2 items-center gap-2">
|
setCustomTheme(themeData);
|
||||||
{Object.entries(themes).map(([name, themeData]) => (
|
setCustom(JSON.stringify(themeData, null, " "));
|
||||||
<ThemeOption setTheme={setTheme} key={name} theme={themeData} themeName={name} />
|
console.log(themeData);
|
||||||
))}
|
} catch (err) {
|
||||||
</div>
|
console.log(err);
|
||||||
</div>
|
}
|
||||||
<div>
|
};
|
||||||
<h2>Use Custom Theme</h2>
|
|
||||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
return (
|
||||||
<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} />
|
<div className="flex flex-col gap-10">
|
||||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
<div>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,127 +1,131 @@
|
|||||||
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
import {
|
||||||
import { type Theme, themes } from '~/styles/themes.css';
|
createContext,
|
||||||
import { themeVars } from '~/styles/vars.css';
|
useEffect,
|
||||||
import { useAppContext } from './AppProvider';
|
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 {
|
interface ThemeContextValue {
|
||||||
themeName: string;
|
themeName: string;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
setTheme: (theme: string) => void;
|
setTheme: (theme: string) => void;
|
||||||
resetTheme: () => void;
|
resetTheme: () => void;
|
||||||
setCustomTheme: (theme: Theme) => void;
|
setCustomTheme: (theme: Theme) => void;
|
||||||
getCustomTheme: () => Theme | undefined;
|
getCustomTheme: () => Theme | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
function toKebabCase(str: string) {
|
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) {
|
function applyCustomThemeVars(theme: Theme) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
for (const [key, value] of Object.entries(theme)) {
|
for (const [key, value] of Object.entries(theme)) {
|
||||||
if (key === 'name') continue;
|
if (key === "name") continue;
|
||||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearCustomThemeVars() {
|
function clearCustomThemeVars() {
|
||||||
for (const cssVar of Object.values(themeVars)) {
|
for (const cssVar of Object.values(themeVars)) {
|
||||||
document.documentElement.style.removeProperty(cssVar);
|
document.documentElement.style.removeProperty(cssVar);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoredCustomTheme(): Theme | undefined {
|
function getStoredCustomTheme(): Theme | undefined {
|
||||||
const themeStr = localStorage.getItem('custom-theme');
|
const themeStr = localStorage.getItem("custom-theme");
|
||||||
if (!themeStr) return undefined;
|
if (!themeStr) return undefined;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(themeStr);
|
const parsed = JSON.parse(themeStr);
|
||||||
const { name, ...theme } = parsed;
|
const { name, ...theme } = parsed;
|
||||||
return theme as Theme;
|
return theme as Theme;
|
||||||
} catch {
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
children,
|
let defaultTheme = useAppContext().defaultTheme;
|
||||||
}: {
|
let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
|
||||||
children: ReactNode;
|
const [themeName, setThemeName] = useState(initialTheme);
|
||||||
}) {
|
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
|
||||||
let defaultTheme = useAppContext().defaultTheme
|
if (initialTheme === "custom") {
|
||||||
let initialTheme = localStorage.getItem("theme") ?? defaultTheme
|
const customTheme = getStoredCustomTheme();
|
||||||
const [themeName, setThemeName] = useState(initialTheme);
|
return customTheme || themes[defaultTheme];
|
||||||
const [currentTheme, setCurrentTheme] = useState<Theme>(() => {
|
|
||||||
if (initialTheme === 'custom') {
|
|
||||||
const customTheme = getStoredCustomTheme();
|
|
||||||
return customTheme || themes[defaultTheme];
|
|
||||||
}
|
|
||||||
return themes[initialTheme] || themes[defaultTheme];
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return themes[initialTheme] || themes[defaultTheme];
|
||||||
const resetTheme = () => {
|
});
|
||||||
setThemeName(defaultTheme)
|
|
||||||
localStorage.removeItem('theme')
|
const setTheme = (newThemeName: string) => {
|
||||||
setCurrentTheme(themes[defaultTheme])
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTheme = () => {
|
||||||
|
setThemeName(defaultTheme);
|
||||||
|
localStorage.removeItem("theme");
|
||||||
|
setCurrentTheme(themes[defaultTheme]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||||
|
localStorage.setItem("custom-theme", JSON.stringify(customTheme));
|
||||||
|
applyCustomThemeVars(customTheme);
|
||||||
|
setThemeName("custom");
|
||||||
|
localStorage.setItem("theme", "custom");
|
||||||
|
setCurrentTheme(customTheme);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCustomTheme = (): Theme | undefined => {
|
||||||
|
return getStoredCustomTheme();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
const setCustomTheme = useCallback((customTheme: Theme) => {
|
root.setAttribute("data-theme", themeName);
|
||||||
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
|
||||||
applyCustomThemeVars(customTheme);
|
|
||||||
setThemeName('custom');
|
|
||||||
localStorage.setItem('theme', 'custom')
|
|
||||||
setCurrentTheme(customTheme);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getCustomTheme = (): Theme | undefined => {
|
if (themeName === "custom") {
|
||||||
return getStoredCustomTheme();
|
applyCustomThemeVars(currentTheme);
|
||||||
|
} else {
|
||||||
|
clearCustomThemeVars();
|
||||||
}
|
}
|
||||||
|
}, [themeName, currentTheme]);
|
||||||
useEffect(() => {
|
|
||||||
const root = document.documentElement;
|
return (
|
||||||
|
<ThemeContext.Provider
|
||||||
root.setAttribute('data-theme', themeName);
|
value={{
|
||||||
|
themeName,
|
||||||
if (themeName === 'custom') {
|
theme: currentTheme,
|
||||||
applyCustomThemeVars(currentTheme);
|
setTheme,
|
||||||
} else {
|
resetTheme,
|
||||||
clearCustomThemeVars();
|
setCustomTheme,
|
||||||
}
|
getCustomTheme,
|
||||||
}, [themeName, currentTheme]);
|
}}
|
||||||
|
>
|
||||||
return (
|
{children}
|
||||||
<ThemeContext.Provider value={{
|
</ThemeContext.Provider>
|
||||||
themeName,
|
);
|
||||||
theme: currentTheme,
|
|
||||||
setTheme,
|
|
||||||
resetTheme,
|
|
||||||
setCustomTheme,
|
|
||||||
getCustomTheme
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ThemeContext };
|
export { ThemeContext };
|
||||||
|
|||||||
Loading…
Reference in new issue