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 {
|
||||
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",
|
||||
})
|
||||
}
|
||||
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 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,
|
||||
})
|
||||
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 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 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>)
|
||||
|
||||
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,
|
||||
})
|
||||
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[]>)
|
||||
}
|
||||
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 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,
|
||||
})
|
||||
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 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 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,
|
||||
getCfg,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
getAlbum,
|
||||
getExport,
|
||||
submitListen,
|
||||
}
|
||||
getLastListens,
|
||||
getTopTracks,
|
||||
getTopAlbums,
|
||||
getTopArtists,
|
||||
getActivity,
|
||||
getStats,
|
||||
search,
|
||||
replaceImage,
|
||||
mergeTracks,
|
||||
mergeAlbums,
|
||||
mergeArtists,
|
||||
imageUrl,
|
||||
login,
|
||||
logout,
|
||||
getCfg,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
getAlbum,
|
||||
getExport,
|
||||
submitListen,
|
||||
getNowPlaying,
|
||||
};
|
||||
type Track = {
|
||||
id: number
|
||||
title: string
|
||||
artists: SimpleArtists[]
|
||||
listen_count: number
|
||||
image: string
|
||||
album_id: number
|
||||
musicbrainz_id: string
|
||||
time_listened: number
|
||||
first_listen: number
|
||||
}
|
||||
id: number;
|
||||
title: string;
|
||||
artists: SimpleArtists[];
|
||||
listen_count: number;
|
||||
image: string;
|
||||
album_id: number;
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
};
|
||||
type Artist = {
|
||||
id: number
|
||||
name: string
|
||||
image: string,
|
||||
aliases: string[]
|
||||
listen_count: number
|
||||
musicbrainz_id: string
|
||||
time_listened: number
|
||||
first_listen: number
|
||||
is_primary: boolean
|
||||
}
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
aliases: string[];
|
||||
listen_count: number;
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
is_primary: boolean;
|
||||
};
|
||||
type Album = {
|
||||
id: number,
|
||||
title: string
|
||||
image: string
|
||||
listen_count: number
|
||||
is_various_artists: boolean
|
||||
artists: SimpleArtists[]
|
||||
musicbrainz_id: string
|
||||
time_listened: number
|
||||
first_listen: number
|
||||
}
|
||||
id: number;
|
||||
title: string;
|
||||
image: string;
|
||||
listen_count: number;
|
||||
is_various_artists: boolean;
|
||||
artists: SimpleArtists[];
|
||||
musicbrainz_id: string;
|
||||
time_listened: number;
|
||||
first_listen: number;
|
||||
};
|
||||
type Alias = {
|
||||
id: number
|
||||
alias: string
|
||||
source: string
|
||||
is_primary: boolean
|
||||
}
|
||||
id: number;
|
||||
alias: string;
|
||||
source: string;
|
||||
is_primary: boolean;
|
||||
};
|
||||
type Listen = {
|
||||
time: string,
|
||||
track: Track,
|
||||
}
|
||||
time: string;
|
||||
track: Track;
|
||||
};
|
||||
type PaginatedResponse<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
|
||||
}
|
||||
default_theme: string;
|
||||
};
|
||||
type NowPlaying = {
|
||||
currently_playing: boolean;
|
||||
track: Track;
|
||||
};
|
||||
|
||||
export type {
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError,
|
||||
Config
|
||||
}
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError,
|
||||
Config,
|
||||
NowPlaying,
|
||||
};
|
||||
|
||||
@ -1,191 +1,197 @@
|
||||
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"
|
||||
|
||||
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(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('')
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
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("");
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
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 { theme, themeName } = useTheme();
|
||||
const color = getPrimaryColor(theme);
|
||||
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Activity</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
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 { 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);
|
||||
|
||||
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];
|
||||
}
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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) * .8
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
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) => (
|
||||
};
|
||||
|
||||
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
|
||||
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>
|
||||
))}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,65 +1,78 @@
|
||||
import { 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 { 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, 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)
|
||||
}
|
||||
}
|
||||
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",
|
||||
};
|
||||
|
||||
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>
|
||||
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 (
|
||||
<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,127 +1,131 @@
|
||||
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';
|
||||
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 {
|
||||
themeName: string;
|
||||
theme: Theme;
|
||||
setTheme: (theme: string) => void;
|
||||
resetTheme: () => 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);
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
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];
|
||||
}
|
||||
|
||||
const resetTheme = () => {
|
||||
setThemeName(defaultTheme)
|
||||
localStorage.removeItem('theme')
|
||||
setCurrentTheme(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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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) => {
|
||||
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
||||
applyCustomThemeVars(customTheme);
|
||||
setThemeName('custom');
|
||||
localStorage.setItem('theme', 'custom')
|
||||
setCurrentTheme(customTheme);
|
||||
}, []);
|
||||
root.setAttribute("data-theme", themeName);
|
||||
|
||||
const getCustomTheme = (): Theme | undefined => {
|
||||
return getStoredCustomTheme();
|
||||
if (themeName === "custom") {
|
||||
applyCustomThemeVars(currentTheme);
|
||||
} else {
|
||||
clearCustomThemeVars();
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}, [themeName, currentTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
themeName,
|
||||
theme: currentTheme,
|
||||
setTheme,
|
||||
resetTheme,
|
||||
setCustomTheme,
|
||||
getCustomTheme,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ThemeContext };
|
||||
|
||||
Loading…
Reference in new issue