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:
Gabe Farrell 2025-11-19 20:26:56 -05:00 committed by GitHub
parent bf0ec68cfe
commit 36f984a1a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 1887 additions and 906 deletions

View file

@ -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,
};

View file

@ -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>
);
}

View file

@ -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);

View file

@ -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>
);
}

View 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>
)
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);
};

View file

@ -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 };

View file

@ -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 />

View file

@ -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>

View file

@ -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">

View file

@ -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">

View file

@ -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>

View file

@ -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">

View file

@ -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) {

View file

@ -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: {

View file

@ -1,6 +1,6 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"name": "Koito",
"short_name": "Koito",
"icons": [
{
"src": "/web-app-manifest-192x192.png",