This commit is contained in:
joffrey-b 2026-04-21 10:39:35 +00:00 committed by GitHub
commit 568cbb83db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 63 additions and 9 deletions

View file

@ -455,6 +455,7 @@ type ApiError = {
}; };
type Config = { type Config = {
default_theme: string; default_theme: string;
date_format: string;
}; };
type NowPlaying = { type NowPlaying = {
currently_playing: boolean; currently_playing: boolean;

View file

@ -9,6 +9,8 @@ import { useState } from "react";
import { useTheme } from "~/hooks/useTheme"; import { useTheme } from "~/hooks/useTheme";
import ActivityOptsSelector from "./ActivityOptsSelector"; import ActivityOptsSelector from "./ActivityOptsSelector";
import type { Theme } from "~/styles/themes.css"; import type { Theme } from "~/styles/themes.css";
import { useAppContext } from "~/providers/AppProvider";
import { formatDate } from "~/utils/utils";
function getPrimaryColor(theme: Theme): string { function getPrimaryColor(theme: Theme): string {
const value = theme.primary; const value = theme.primary;
@ -65,6 +67,7 @@ export default function ActivityGrid({
const { theme } = useTheme(); const { theme } = useTheme();
const color = getPrimaryColor(theme); const color = getPrimaryColor(theme);
const { dateFormat } = useAppContext();
if (isPending) { if (isPending) {
return ( return (
@ -165,7 +168,7 @@ export default function ActivityGrid({
position="top" position="top"
space={12} space={12}
extraClasses="left-2" extraClasses="left-2"
inner={`${new Date(item.start_time).toLocaleDateString()} ${ inner={`${formatDate(new Date(item.start_time), dateFormat)} ${
item.listens item.listens
} plays`} } plays`}
> >
@ -194,3 +197,4 @@ export default function ActivityGrid({
</div> </div>
); );
} }

View file

@ -6,6 +6,7 @@ interface AppContextType {
configurableHomeActivity: boolean; configurableHomeActivity: boolean;
homeItems: number; homeItems: number;
defaultTheme: string; defaultTheme: string;
dateFormat: string;
setConfigurableHomeActivity: (value: boolean) => void; setConfigurableHomeActivity: (value: boolean) => void;
setHomeItems: (value: number) => void; setHomeItems: (value: number) => void;
setUsername: (value: string) => void; setUsername: (value: string) => void;
@ -26,6 +27,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [defaultTheme, setDefaultTheme] = useState<string | undefined>( const [defaultTheme, setDefaultTheme] = useState<string | undefined>(
undefined undefined
); );
const [dateFormat, setDateFormat] = useState<string>("");
const [configurableHomeActivity, setConfigurableHomeActivity] = const [configurableHomeActivity, setConfigurableHomeActivity] =
useState<boolean>(false); useState<boolean>(false);
const [homeItems, setHomeItems] = useState<number>(0); const [homeItems, setHomeItems] = useState<number>(0);
@ -55,6 +57,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
} else { } else {
setDefaultTheme("yuu"); setDefaultTheme("yuu");
} }
setDateFormat(cfg.date_format ?? "");
}); });
}, []); }, []);
@ -68,6 +71,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
configurableHomeActivity, configurableHomeActivity,
homeItems, homeItems,
defaultTheme, defaultTheme,
dateFormat,
setConfigurableHomeActivity, setConfigurableHomeActivity,
setHomeItems, setHomeItems,
setUsername, setUsername,
@ -77,3 +81,4 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
<AppContext.Provider value={contextValue}>{children}</AppContext.Provider> <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>
); );
}; };

View file

@ -6,8 +6,9 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector"; import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils"; import { timeListenedString, formatDate } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph"; import InterestGraph from "~/components/InterestGraph";
import { useAppContext } from "~/providers/AppProvider";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/album?id=${params.id}`); const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
@ -21,6 +22,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Album() { export default function Album() {
const album = useLoaderData() as Album; const album = useLoaderData() as Album;
const [period, setPeriod] = useState("week"); const [period, setPeriod] = useState("week");
const { dateFormat } = useAppContext();
console.log(album); console.log(album);
@ -59,7 +61,7 @@ export default function Album() {
{album.first_listen > 0 && ( {album.first_listen > 0 && (
<p title={new Date(album.first_listen * 1000).toLocaleString()}> <p title={new Date(album.first_listen * 1000).toLocaleString()}>
Listening since{" "} Listening since{" "}
{new Date(album.first_listen * 1000).toLocaleDateString()} {formatDate(new Date(album.first_listen * 1000), dateFormat)}
</p> </p>
)} )}
</div> </div>
@ -79,3 +81,4 @@ export default function Album() {
</MediaLayout> </MediaLayout>
); );
} }

View file

@ -7,8 +7,9 @@ import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums"; import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils"; import { timeListenedString, formatDate } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph"; import InterestGraph from "~/components/InterestGraph";
import { useAppContext } from "~/providers/AppProvider";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`); const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
@ -22,6 +23,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Artist() { export default function Artist() {
const artist = useLoaderData() as Artist; const artist = useLoaderData() as Artist;
const [period, setPeriod] = useState("week"); const [period, setPeriod] = useState("week");
const { dateFormat } = useAppContext();
// remove canonical name from alias list // remove canonical name from alias list
console.log(artist.aliases); console.log(artist.aliases);
@ -65,7 +67,7 @@ export default function Artist() {
{artist.first_listen > 0 && ( {artist.first_listen > 0 && (
<p title={new Date(artist.first_listen * 1000).toLocaleString()}> <p title={new Date(artist.first_listen * 1000).toLocaleString()}>
Listening since{" "} Listening since{" "}
{new Date(artist.first_listen * 1000).toLocaleDateString()} {formatDate(new Date(artist.first_listen * 1000), dateFormat)}
</p> </p>
)} )}
</div> </div>
@ -88,3 +90,4 @@ export default function Artist() {
</MediaLayout> </MediaLayout>
); );
} }

View file

@ -5,8 +5,9 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector"; import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout"; import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid"; import ActivityGrid from "~/components/ActivityGrid";
import { timeListenedString } from "~/utils/utils"; import { timeListenedString, formatDate } from "~/utils/utils";
import InterestGraph from "~/components/InterestGraph"; import InterestGraph from "~/components/InterestGraph";
import { useAppContext } from "~/providers/AppProvider";
export async function clientLoader({ params }: LoaderFunctionArgs) { export async function clientLoader({ params }: LoaderFunctionArgs) {
let res = await fetch(`/apis/web/v1/track?id=${params.id}`); let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
@ -27,6 +28,7 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Track() { export default function Track() {
const { track, album } = useLoaderData(); const { track, album } = useLoaderData();
const [period, setPeriod] = useState("week"); const [period, setPeriod] = useState("week");
const { dateFormat } = useAppContext();
return ( return (
<MediaLayout <MediaLayout
@ -69,7 +71,7 @@ export default function Track() {
{track.first_listen > 0 && ( {track.first_listen > 0 && (
<p title={new Date(track.first_listen * 1000).toLocaleString()}> <p title={new Date(track.first_listen * 1000).toLocaleString()}>
Listening since{" "} Listening since{" "}
{new Date(track.first_listen * 1000).toLocaleDateString()} {formatDate(new Date(track.first_listen * 1000), dateFormat)}
</p> </p>
)} )}
</div> </div>
@ -88,3 +90,4 @@ export default function Track() {
</MediaLayout> </MediaLayout>
); );
} }

View file

@ -52,7 +52,15 @@ function timeSince(date: Date) {
return "just now"; return "just now";
} }
export { timeSince }; function formatDate(date: Date, format: string): string {
if (!format) return date.toLocaleDateString();
const dd = String(date.getDate()).padStart(2, "0");
const mm = String(date.getMonth() + 1).padStart(2, "0");
const yyyy = String(date.getFullYear());
return format.replace("DD", dd).replace("MM", mm).replace("YYYY", yyyy);
}
export { timeSince, formatDate };
type hsl = { type hsl = {
h: number; h: number;
@ -119,3 +127,4 @@ const timeListenedString = (seconds: number) => {
export { hexToHSL, timeListenedString, getRewindYear, getRewindParams }; export { hexToHSL, timeListenedString, getRewindYear, getRewindParams };
export type { hsl }; export type { hsl };

View file

@ -26,6 +26,9 @@ If the environment variable is defined without **and** with the suffix at the sa
##### KOITO_DEFAULT_THEME ##### KOITO_DEFAULT_THEME
- Default: `yuu` - Default: `yuu`
- Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher. - Description: The lowercase name of the default theme to be used by the client. Overridden if a user picks a theme in the theme switcher.
##### KOITO_DATE_FORMAT
- Default: `Browser locale`
- Description: The format to use for dates in the client. Supports the tokens `DD`, `MM`, and `YYYY` with `/`, `-`, and `.` as separators. For example, `DD/MM/YYYY` produces `21/04/2026`. When not set, dates are formatted using the browser's locale.
##### KOITO_LOGIN_GATE ##### KOITO_LOGIN_GATE
- Default: `false` - Default: `false`
- Description: When `true`, Koito will not show any statistics unless the user is logged in. - Description: When `true`, Koito will not show any statistics unless the user is logged in.

View file

@ -9,10 +9,15 @@ import (
type ServerConfig struct { type ServerConfig struct {
DefaultTheme string `json:"default_theme"` DefaultTheme string `json:"default_theme"`
DateFormat string `json:"date_format"`
} }
func GetCfgHandler() http.HandlerFunc { func GetCfgHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
utils.WriteJSON(w, http.StatusOK, ServerConfig{DefaultTheme: cfg.DefaultTheme()}) utils.WriteJSON(w, http.StatusOK, ServerConfig{
DefaultTheme: cfg.DefaultTheme(),
DateFormat: cfg.DateFormat(),
})
} }
} }

View file

@ -33,6 +33,7 @@ const (
DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME"
DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD"
DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME" DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME"
DATE_FORMAT_ENV = "KOITO_DATE_FORMAT"
DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER"
DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE"
DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ"
@ -69,6 +70,7 @@ type config struct {
defaultPw string defaultPw string
defaultUsername string defaultUsername string
defaultTheme string defaultTheme string
dateFormat string
disableDeezer bool disableDeezer bool
disableCAA bool disableCAA bool
disableMusicBrainz bool disableMusicBrainz bool
@ -186,6 +188,14 @@ func loadConfig(getenv func(string) string, version string) (*config, error) {
} }
cfg.defaultTheme = getenv(DEFAULT_THEME_ENV) cfg.defaultTheme = getenv(DEFAULT_THEME_ENV)
rawDateFormat := getenv(DATE_FORMAT_ENV)
if rawDateFormat != "" {
validFormat := regexp.MustCompile(`^(DD|MM|YYYY)([-/.](DD|MM|YYYY)){2}$`)
if !validFormat.MatchString(rawDateFormat) {
return nil, fmt.Errorf("loadConfig: %s must use DD, MM, and YYYY tokens with a single / - or . separator (e.g. DD/MM/YYYY)", DATE_FORMAT_ENV)
}
}
cfg.dateFormat = rawDateFormat
cfg.configDir = getenv(CONFIG_DIR_ENV) cfg.configDir = getenv(CONFIG_DIR_ENV)
if cfg.configDir == "" { if cfg.configDir == "" {
@ -244,3 +254,4 @@ func parseBool(s string) bool {
return false return false
} }
} }

View file

@ -90,6 +90,12 @@ func DefaultTheme() string {
return globalConfig.defaultTheme return globalConfig.defaultTheme
} }
func DateFormat() string {
lock.RLock()
defer lock.RUnlock()
return globalConfig.dateFormat
}
func FullImageCacheEnabled() bool { func FullImageCacheEnabled() bool {
lock.RLock() lock.RLock()
defer lock.RUnlock() defer lock.RUnlock()
@ -204,3 +210,4 @@ func ForceTZ() *time.Location {
defer lock.RUnlock() defer lock.RUnlock()
return globalConfig.forceTZ return globalConfig.forceTZ
} }