-
Step:
- {stepPeriods.map((p) => (
-
setStep(p)}
- disabled={p === currentStep}
- >
- {p}
-
- ))}
+
+
+
Step:
+ {stepPeriods.map((p, i) => (
+
+ setStep(p)}
+ disabled={p === currentStep}
+ >
+ {stepDisplay(p)}
+
+
+ {i !== stepPeriods.length - 1 ? '|' : ''}
+
+ ))}
+
-
-
Range:
- {rangePeriods.map((r) => (
-
setRange(r)}
- disabled={r === currentRange}
- >
- {r}
-
- ))}
+
+
Range:
+ {rangePeriods.map((r, i) => (
+
+ setRange(r)}
+ disabled={r === currentRange}
+ >
+ {rangeDisplay(r)}
+
+
+ {i !== rangePeriods.length - 1 ? '|' : ''}
+
-
+ ))}
);
diff --git a/client/app/components/AlbumDisplay.tsx b/client/app/components/AlbumDisplay.tsx
index a7f88e4..6721199 100644
--- a/client/app/components/AlbumDisplay.tsx
+++ b/client/app/components/AlbumDisplay.tsx
@@ -2,31 +2,24 @@ import { imageUrl, type Album } from "api/api";
import { Link } from "react-router";
interface Props {
- album: Album;
- size: number;
+ album: Album
+ size: number
}
export default function AlbumDisplay({ album, size }: Props) {
- return (
-
-
-
-
-
-
-
-
-
{album.title}
-
-
{album.listen_count} plays
-
-
- );
-}
+ return (
+
+
+
+
+
+
+
+
+
{album.title}
+
+
{album.listen_count} plays
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/AllTimeStats.tsx b/client/app/components/AllTimeStats.tsx
index 6a3ebac..0a54daa 100644
--- a/client/app/components/AllTimeStats.tsx
+++ b/client/app/components/AllTimeStats.tsx
@@ -1,58 +1,45 @@
-import { useQuery } from "@tanstack/react-query";
-import { getStats, type Stats, type ApiError } from "api/api";
+import { useQuery } from "@tanstack/react-query"
+import { getStats } from "api/api"
export default function AllTimeStats() {
- const { isPending, isError, data, error } = useQuery({
- queryKey: ["stats", "all_time"],
- queryFn: ({ queryKey }) => getStats(queryKey[1]),
- });
- const header = "All time stats";
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: ['stats', 'all_time'],
+ queryFn: ({ queryKey }) => getStats(queryKey[1]),
+ })
+
+ if (isPending) {
+ return (
+
+
All Time Stats
+
Loading...
+
+ )
+ }
+ if (isError) {
+ return
Error:{error.message}
+ }
+
+ const numberClasses = 'header-font font-bold text-xl'
- if (isPending) {
return (
-
-
{header}
-
Loading...
-
- );
- } else if (isError) {
- return (
- <>
-
{header}
-
Error: {error.message}
+
All Time Stats
+
+ {data.hours_listened} Hours Listened
+
+
+ {data.listen_count} Plays
+
+
+ {data.artist_count} Artists
+
+
+ {data.album_count} Albums
+
+
+ {data.track_count} Tracks
+
- >
- );
- }
-
- const numberClasses = "header-font font-bold text-xl";
-
- return (
-
-
{header}
-
-
- {data.minutes_listened}
- {" "}
- Minutes Listened
-
-
- {data.listen_count} Plays
-
-
- {data.track_count} Tracks
-
-
- {data.album_count} Albums
-
-
- {data.artist_count} Artists
-
-
- );
-}
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/ArtistAlbums.tsx b/client/app/components/ArtistAlbums.tsx
index dda7de8..c95155a 100644
--- a/client/app/components/ArtistAlbums.tsx
+++ b/client/app/components/ArtistAlbums.tsx
@@ -1,63 +1,51 @@
-import { useQuery } from "@tanstack/react-query";
-import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api";
-import { Link } from "react-router";
+import { useQuery } from "@tanstack/react-query"
+import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
+import { Link } from "react-router"
interface Props {
- artistId: number;
- name: string;
- period: string;
+ artistId: number
+ name: string
+ period: string
}
-export default function ArtistAlbums({ artistId, name }: Props) {
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "top-albums",
- { limit: 99, period: "all_time", artist_id: artistId },
- ],
- queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
- });
+export default function ArtistAlbums({artistId, name, period}: Props) {
- if (isPending) {
- return (
-
-
Albums From This Artist
-
Loading...
-
- );
- }
- if (isError) {
- return (
-
-
Albums From This Artist
-
Error:{error.message}
-
- );
- }
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}],
+ queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
+ })
- return (
-
-
Albums featuring {name}
-
- {data.items.map((item) => (
-
-
-
-
{item.item.title}
-
- {item.item.listen_count} play
- {item.item.listen_count > 1 ? "s" : ""}
-
+ if (isPending) {
+ return (
+
+
Albums From This Artist
+
Loading...
-
- ))}
-
-
- );
-}
+ )
+ }
+ if (isError) {
+ return (
+
+
Albums From This Artist
+
Error:{error.message}
+
+ )
+ }
+
+ return (
+
+
Albums featuring {name}
+
+ {data.items.map((item) => (
+
+
+
+
{item.title}
+
{item.listen_count} play{item.listen_count > 1 ? 's' : ''}
+
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/ImageDropHandler.tsx b/client/app/components/ImageDropHandler.tsx
index 9e686ea..8557ff9 100644
--- a/client/app/components/ImageDropHandler.tsx
+++ b/client/app/components/ImageDropHandler.tsx
@@ -3,10 +3,11 @@ import { useEffect } from 'react';
interface Props {
itemType: string,
+ id: number,
onComplete: Function
}
-export default function ImageDropHandler({ itemType, onComplete }: Props) {
+export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
useEffect(() => {
const handleDragOver = (e: DragEvent) => {
console.log('dragover!!')
@@ -24,11 +25,7 @@ export default function ImageDropHandler({ itemType, onComplete }: Props) {
const formData = new FormData();
formData.append('image', imageFile);
- const pathname = window.location.pathname;
- const segments = pathname.split('/');
- const filteredSegments = segments.filter(segment => segment !== '');
- const lastSegment = filteredSegments[filteredSegments.length - 1];
- formData.append(itemType.toLowerCase()+'_id', lastSegment)
+ formData.append(itemType.toLowerCase()+'_id', String(id))
replaceImage(formData).then((r) => {
if (r.status >= 200 && r.status < 300) {
onComplete()
diff --git a/client/app/components/InterestGraph.tsx b/client/app/components/InterestGraph.tsx
deleted file mode 100644
index 9e2baaf..0000000
--- a/client/app/components/InterestGraph.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { getInterest, type getInterestArgs } from "api/api";
-import { useTheme } from "~/hooks/useTheme";
-import type { Theme } from "~/styles/themes.css";
-import { Area, AreaChart } from "recharts";
-import { RechartsDevtools } from "@recharts/devtools";
-
-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;
-}
-interface Props {
- buckets?: number;
- artistId?: number;
- albumId?: number;
- trackId?: number;
-}
-
-export default function InterestGraph({
- buckets = 16,
- artistId = 0,
- albumId = 0,
- trackId = 0,
-}: Props) {
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "interest",
- {
- buckets: buckets,
- artist_id: artistId,
- album_id: albumId,
- track_id: trackId,
- },
- ],
- queryFn: ({ queryKey }) => getInterest(queryKey[1] as getInterestArgs),
- });
-
- const { theme } = useTheme();
- const color = getPrimaryColor(theme);
-
- if (isPending) {
- return (
-
-
Interest over time
-
Loading...
-
- );
- } else if (isError) {
- return (
-
-
Interest over time
-
Error: {error.message}
-
- );
- }
-
- // Note: I would really like to have the animation for the graph, however
- // the line graph can get weirdly clipped before the animation is done
- // so I think I just have to remove it for now.
-
- return (
-
-
Interest over time
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx
index ace86fd..b1eda5e 100644
--- a/client/app/components/LastPlays.tsx
+++ b/client/app/components/LastPlays.tsx
@@ -1,156 +1,57 @@
-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";
+import { useQuery } from "@tanstack/react-query"
+import { timeSince } from "~/utils/utils"
+import ArtistLinks from "./ArtistLinks"
+import { getLastListens, type getItemsArgs } from "api/api"
+import { Link } from "react-router"
interface Props {
- limit: number;
- artistId?: Number;
- albumId?: Number;
- trackId?: number;
- hideArtists?: boolean;
- showNowPlaying?: boolean;
+ limit: number
+ artistId?: Number
+ albumId?: Number
+ trackId?: number
+ hideArtists?: 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 { data: npData } = useQuery({
- queryKey: ["now-playing"],
- queryFn: () => getNowPlaying(),
- });
- const header = "Last played";
+ 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 [items, setItems] = useState
(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);
+ if (isPending) {
+ return (
+
+
Last Played
+
Loading...
+
+ )
+ }
+ if (isError) {
+ return Error:{error.message}
}
- };
- if (isPending) {
+ let params = ''
+ params += props.artistId ? `&artist_id=${props.artistId}` : ''
+ params += props.albumId ? `&album_id=${props.albumId}` : ''
+ params += props.trackId ? `&track_id=${props.trackId}` : ''
+
return (
-
-
{header}
-
Loading...
-
- );
- } else if (isError) {
- return (
-
-
{header}
-
Error: {error.message}
-
- );
- }
-
- 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 (
-
-
- {header}
-
-
-
- {props.showNowPlaying && npData && npData.currently_playing && (
-
-
-
- Now Playing
-
-
- {props.hideArtists ? null : (
- <>
- –{" "}
- >
- )}
-
- {npData.track.title}
-
-
-
- )}
- {listens.map((item) => (
-
-
- 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}
- >
- ×
-
-
-
- {timeSince(new Date(item.time))}
-
-
- {props.hideArtists ? null : (
- <>
- –{" "}
- >
- )}
-
- {item.track.title}
-
-
-
- ))}
-
-
-
- );
-}
+
+
Last Played
+
+
+ {data.items.map((item) => (
+
+ {timeSince(new Date(item.time))}
+
+ {props.hideArtists ? <>> : <> - >}
+ {item.track.title}
+
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/PeriodSelector.tsx b/client/app/components/PeriodSelector.tsx
index 3393dc7..91bad9a 100644
--- a/client/app/components/PeriodSelector.tsx
+++ b/client/app/components/PeriodSelector.tsx
@@ -31,7 +31,7 @@ export default function PeriodSelector({ setter, current, disableCache = false }
}, []);
return (
-
+
Showing stats for:
{periods.map((p, i) => (
diff --git a/client/app/components/Popup.tsx b/client/app/components/Popup.tsx
index a032e4e..3c73cb5 100644
--- a/client/app/components/Popup.tsx
+++ b/client/app/components/Popup.tsx
@@ -1,64 +1,48 @@
-import React, { type PropsWithChildren, useEffect, useState } from 'react';
+import React, { type PropsWithChildren, useState } from 'react';
interface Props {
- inner: React.ReactNode
- position: string
- space: number
- extraClasses?: string
- hint?: string
+ inner: React.ReactNode
+ position: string
+ space: number
+ extraClasses?: string
+ hint?: string
}
export default function Popup({ inner, position, space, extraClasses, children }: PropsWithChildren
) {
const [isVisible, setIsVisible] = useState(false);
- const [showPopup, setShowPopup] = useState(true);
- useEffect(() => {
- const mediaQuery = window.matchMedia('(min-width: 640px)');
-
- const handleChange = (e: MediaQueryListEvent) => {
- setShowPopup(e.matches);
- };
-
- setShowPopup(mediaQuery.matches);
-
- mediaQuery.addEventListener('change', handleChange);
- return () => mediaQuery.removeEventListener('change', handleChange);
- }, []);
-
- let positionClasses = '';
- let spaceCSS: React.CSSProperties = {};
- if (position === 'top') {
- positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2`;
- } else if (position === 'right') {
- positionClasses = `bottom-1 -translate-x-1/2`;
- spaceCSS = { left: 70 + space };
+ let positionClasses
+ let spaceCSS = {}
+ if (position == "top") {
+ positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2`
+ } else if (position == "right") {
+ positionClasses = `bottom-1 -translate-x-1/2`
+ spaceCSS = {left: 70 + space}
}
return (
setIsVisible(true)}
- onMouseLeave={() => setIsVisible(false)}
+ className="relative"
+ onMouseEnter={() => setIsVisible(true)}
+ onMouseLeave={() => setIsVisible(false)}
>
- {children}
- {showPopup && (
-
- {inner}
-
- )}
+ {children}
+
+ {inner}
+
);
}
diff --git a/client/app/components/SearchResults.tsx b/client/app/components/SearchResults.tsx
index 0e68c3d..c0269e8 100644
--- a/client/app/components/SearchResults.tsx
+++ b/client/app/components/SearchResults.tsx
@@ -16,19 +16,19 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
const selectItem = (title: string, id: number) => {
if (selected === id) {
setSelected(0)
- onSelect({id: 0, title: ''})
+ onSelect({id: id, title: title})
} else {
setSelected(id)
onSelect({id: id, title: title})
}
}
- if (!data) {
+ if (data === undefined) {
return <>>
}
return (
- { data.artists && data.artists.length > 0 &&
+ { data.artists.length > 0 &&
<>
Artists
@@ -52,7 +52,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
>
}
- { data.albums && data.albums.length > 0 &&
+ { data.albums.length > 0 &&
<>
Albums
@@ -77,7 +77,7 @@ export default function SearchResults({ data, onSelect, selectorMode }: Props) {
>
}
- { data.tracks && data.tracks.length > 0 &&
+ { data.tracks.length > 0 &&
<>
Tracks
diff --git a/client/app/components/TopAlbums.tsx b/client/app/components/TopAlbums.tsx
index d8a3b00..4ae87bd 100644
--- a/client/app/components/TopAlbums.tsx
+++ b/client/app/components/TopAlbums.tsx
@@ -1,68 +1,42 @@
-import { useQuery } from "@tanstack/react-query";
-import ArtistLinks from "./ArtistLinks";
-import {
- getTopAlbums,
- getTopTracks,
- imageUrl,
- type getItemsArgs,
-} from "api/api";
-import { Link } from "react-router";
-import TopListSkeleton from "./skeletons/TopListSkeleton";
-import TopItemList from "./TopItemList";
+import { useQuery } from "@tanstack/react-query"
+import ArtistLinks from "./ArtistLinks"
+import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
+import { Link } from "react-router"
+import TopListSkeleton from "./skeletons/TopListSkeleton"
+import TopItemList from "./TopItemList"
interface Props {
- limit: number;
- period: string;
- artistId?: Number;
+ limit: number,
+ period: string,
+ artistId?: Number
}
-export default function TopAlbums(props: Props) {
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "top-albums",
- {
- limit: props.limit,
- period: props.period,
- artistId: props.artistId,
- page: 0,
- },
- ],
- queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
- });
+export default function TopAlbums (props: Props) {
- const header = "Top albums";
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }],
+ queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
+ })
+
+ if (isPending) {
+ return (
+
+
Top Albums
+
Loading...
+
+ )
+ }
+ if (isError) {
+ return
Error:{error.message}
+ }
- if (isPending) {
return (
-
-
{header}
-
Loading...
-
- );
- } else if (isError) {
- return (
-
-
{header}
-
Error: {error.message}
-
- );
- }
-
- return (
-
-
-
- {header}
-
-
-
-
- {data.items.length < 1 ? "Nothing to show" : ""}
-
-
- );
-}
+
+
Top Albums
+
+
+ {data.items.length < 1 ? 'Nothing to show' : ''}
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/TopArtists.tsx b/client/app/components/TopArtists.tsx
index a1db871..1c7b719 100644
--- a/client/app/components/TopArtists.tsx
+++ b/client/app/components/TopArtists.tsx
@@ -1,53 +1,43 @@
-import { useQuery } from "@tanstack/react-query";
-import ArtistLinks from "./ArtistLinks";
-import { getTopArtists, imageUrl, type getItemsArgs } from "api/api";
-import { Link } from "react-router";
-import TopListSkeleton from "./skeletons/TopListSkeleton";
-import TopItemList from "./TopItemList";
+import { useQuery } from "@tanstack/react-query"
+import ArtistLinks from "./ArtistLinks"
+import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
+import { Link } from "react-router"
+import TopListSkeleton from "./skeletons/TopListSkeleton"
+import TopItemList from "./TopItemList"
interface Props {
- limit: number;
- period: string;
- artistId?: Number;
- albumId?: Number;
+ limit: number,
+ period: string,
+ artistId?: Number
+ albumId?: Number
}
-export default function TopArtists(props: Props) {
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "top-artists",
- { limit: props.limit, period: props.period, page: 0 },
- ],
- queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
- });
+export default function TopArtists (props: Props) {
- const header = "Top artists";
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }],
+ queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
+ })
+
+ if (isPending) {
+ return (
+
+
Top Artists
+
Loading...
+
+ )
+ }
+ if (isError) {
+ return
Error:{error.message}
+ }
- if (isPending) {
return (
-
-
{header}
-
Loading...
-
- );
- } else if (isError) {
- return (
-
-
{header}
-
Error: {error.message}
-
- );
- }
-
- return (
-
-
- {header}
-
-
-
- {data.items.length < 1 ? "Nothing to show" : ""}
-
-
- );
-}
+
+
Top Artists
+
+
+ {data.items.length < 1 ? 'Nothing to show' : ''}
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx
index 4d355b7..7f68c9e 100644
--- a/client/app/components/TopItemList.tsx
+++ b/client/app/components/TopItemList.tsx
@@ -1,171 +1,142 @@
import { Link, useNavigate } from "react-router";
import ArtistLinks from "./ArtistLinks";
-import {
- imageUrl,
- type Album,
- type Artist,
- type Track,
- type PaginatedResponse,
- type Ranked,
-} from "api/api";
+import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api";
type Item = Album | Track | Artist;
-interface Props
> {
- data: PaginatedResponse;
- separators?: ConstrainBoolean;
- ranked?: boolean;
- type: "album" | "track" | "artist";
- className?: string;
+interface Props {
+ data: PaginatedResponse
+ separators?: ConstrainBoolean
+ width?: number
+ type: "album" | "track" | "artist";
}
-export default function TopItemList>({
- data,
- separators,
- type,
- className,
- ranked,
-}: Props) {
- return (
-
- {data.items.map((item, index) => {
- const key = `${type}-${item.item.id}`;
- return (
-
-
-
- );
- })}
-
- );
+export default function TopItemList({ data, separators, type, width }: Props) {
+
+ return (
+
+ {data.items.map((item, index) => {
+ const key = `${type}-${item.id}`;
+ return (
+
+
+
+ );
+ })}
+
+ );
}
-function ItemCard({
- item,
- type,
- rank,
- ranked,
-}: {
- item: Item;
- type: "album" | "track" | "artist";
- rank: number;
- ranked?: boolean;
-}) {
- const itemClasses = `flex items-center gap-2`;
+function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
- switch (type) {
- case "album": {
- const album = item as Album;
+ const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
- return (
-
- {ranked &&
{rank}
}
-
-
-
-
-
-
{album.title}
-
-
- {album.is_various_artists ? (
-
Various Artists
- ) : (
-
- )}
-
{album.listen_count} plays
-
-
- );
+ const navigate = useNavigate();
+
+ const handleItemClick = (type: string, id: number) => {
+ navigate(`/${type.toLowerCase()}/${id}`);
+ };
+
+ const handleArtistClick = (event: React.MouseEvent) => {
+ // Stop the click from navigating to the album page
+ event.stopPropagation();
+ };
+
+ // Also stop keyboard events on the inner links from bubbling up
+ const handleArtistKeyDown = (event: React.KeyboardEvent) => {
+ event.stopPropagation();
}
- case "track": {
- const track = item as Track;
- return (
-
- {ranked &&
{rank}
}
-
-
-
-
-
-
{track.title}
-
-
-
-
{track.listen_count} plays
-
-
- );
+ switch (type) {
+ case "album": {
+ const album = item as Album;
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ handleItemClick("album", album.id);
+ }
+ };
+
+ return (
+
+
handleItemClick("album", album.id)}
+ onKeyDown={handleKeyDown}
+ role="link"
+ tabIndex={0}
+ aria-label={`View album: ${album.title}`}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+
{album.title}
+
+ {album.is_various_artists ?
+
Various Artists
+ :
+
+ }
+
{album.listen_count} plays
+
+
+
+ );
+ }
+ case "track": {
+ const track = item as Track;
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ handleItemClick("track", track.id);
+ }
+ };
+
+ return (
+
+
handleItemClick("track", track.id)}
+ onKeyDown={handleKeyDown}
+ role="link"
+ tabIndex={0}
+ aria-label={`View track: ${track.title}`}
+ style={{ cursor: 'pointer' }}
+ >
+
+
+
{track.title}
+
+
+
{track.listen_count} plays
+
+
+
+ );
+ }
+ case "artist": {
+ const artist = item as Artist;
+ return (
+
+
+
+
+
{artist.name}
+
{artist.listen_count} plays
+
+
+
+ );
+ }
}
- case "artist": {
- const artist = item as Artist;
- return (
-
- {ranked &&
{rank}
}
-
-
-
-
{artist.name}
-
- {artist.listen_count} plays
-
-
-
-
- );
- }
- }
}
diff --git a/client/app/components/TopThreeAlbums.tsx b/client/app/components/TopThreeAlbums.tsx
index 2a9503d..c5136e4 100644
--- a/client/app/components/TopThreeAlbums.tsx
+++ b/client/app/components/TopThreeAlbums.tsx
@@ -1,43 +1,38 @@
-import { useQuery } from "@tanstack/react-query";
-import { getTopAlbums, type getItemsArgs } from "api/api";
-import AlbumDisplay from "./AlbumDisplay";
+import { useQuery } from "@tanstack/react-query"
+import { getTopAlbums, type getItemsArgs } from "api/api"
+import AlbumDisplay from "./AlbumDisplay"
interface Props {
- period: string;
- artistId?: Number;
- vert?: boolean;
- hideTitle?: boolean;
+ period: string
+ artistId?: Number
+ vert?: boolean
+ hideTitle?: boolean
}
-
+
export default function TopThreeAlbums(props: Props) {
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "top-albums",
- { limit: 3, period: props.period, artist_id: props.artistId, page: 0 },
- ],
- queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
- });
- if (isPending) {
- return Loading...
;
- }
- if (isError) {
- return Error:{error.message}
;
- }
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
+ queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
+ })
- console.log(data);
+ if (isPending) {
+ return Loading...
+ }
+ if (isError) {
+ return Error:{error.message}
+ }
- return (
-
- {!props.hideTitle &&
Top Three Albums }
-
- {data.items.map((item, index) => (
-
- ))}
-
-
- );
-}
+ console.log(data)
+
+ return (
+
+ {!props.hideTitle &&
Top Three Albums }
+
+ {data.items.map((item, index) => (
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/TopTracks.tsx b/client/app/components/TopTracks.tsx
index bfe31ca..b1d14c7 100644
--- a/client/app/components/TopTracks.tsx
+++ b/client/app/components/TopTracks.tsx
@@ -1,69 +1,50 @@
-import { useQuery } from "@tanstack/react-query";
-import ArtistLinks from "./ArtistLinks";
-import { getTopTracks, imageUrl, type getItemsArgs } from "api/api";
-import { Link } from "react-router";
-import TopListSkeleton from "./skeletons/TopListSkeleton";
-import { useEffect } from "react";
-import TopItemList from "./TopItemList";
+import { useQuery } from "@tanstack/react-query"
+import ArtistLinks from "./ArtistLinks"
+import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
+import { Link } from "react-router"
+import TopListSkeleton from "./skeletons/TopListSkeleton"
+import { useEffect } from "react"
+import TopItemList from "./TopItemList"
interface Props {
- limit: number;
- period: string;
- artistId?: Number;
- albumId?: Number;
+ limit: number,
+ period: string,
+ artistId?: Number
+ albumId?: Number
}
const TopTracks = (props: Props) => {
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "top-tracks",
- {
- limit: props.limit,
- period: props.period,
- artist_id: props.artistId,
- album_id: props.albumId,
- page: 0,
- },
- ],
- queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
- });
- const header = "Top tracks";
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
+ queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
+ })
+
+ if (isPending) {
+ return (
+
+
Top Tracks
+
Loading...
+
+ )
+ }
+ if (isError) {
+ return Error:{error.message}
+ }
+
+ let params = ''
+ params += props.artistId ? `&artist_id=${props.artistId}` : ''
+ params += props.albumId ? `&album_id=${props.albumId}` : ''
- if (isPending) {
return (
-
-
{header}
-
Loading...
-
- );
- } else if (isError) {
- return (
-
-
{header}
-
Error: {error.message}
-
- );
- }
- if (!data.items) return;
+
+
Top Tracks
+
+
+ {data.items.length < 1 ? 'Nothing to show' : ''}
+
+
+ )
+}
- let params = "";
- params += props.artistId ? `&artist_id=${props.artistId}` : "";
- params += props.albumId ? `&album_id=${props.albumId}` : "";
-
- return (
-
-
-
- {header}
-
-
-
-
- {data.items.length < 1 ? "Nothing to show" : ""}
-
-
- );
-};
-
-export default TopTracks;
+export default TopTracks
\ No newline at end of file
diff --git a/client/app/components/icons/MbzIcon.tsx b/client/app/components/icons/MbzIcon.tsx
deleted file mode 100644
index 1ce66ad..0000000
--- a/client/app/components/icons/MbzIcon.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-interface Props {
- size: number;
- hover?: boolean;
-}
-export default function MbzIcon({ size, hover }: Props) {
- let classNames = "";
- if (hover) {
- classNames += "icon-hover-fill";
- }
- return (
-
- );
-}
diff --git a/client/app/components/modals/Account.tsx b/client/app/components/modals/Account.tsx
index 562b53d..06d540e 100644
--- a/client/app/components/modals/Account.tsx
+++ b/client/app/components/modals/Account.tsx
@@ -1,124 +1,106 @@
-import { logout, updateUser } from "api/api";
-import { useState } from "react";
-import { AsyncButton } from "../AsyncButton";
-import { useAppContext } from "~/providers/AppProvider";
+import { logout, updateUser } from "api/api"
+import { useState } from "react"
+import { AsyncButton } from "../AsyncButton"
+import { useAppContext } from "~/providers/AppProvider"
export default function Account() {
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const [confirmPw, setConfirmPw] = useState("");
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState("");
- const [success, setSuccess] = useState("");
- const { user, setUsername: setCtxUsername } = useAppContext();
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [confirmPw, setConfirmPw] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [success, setSuccess] = useState('')
+ const { user, setUsername: setCtxUsername } = useAppContext()
- const logoutHandler = () => {
- setLoading(true);
- logout()
- .then((r) => {
- if (r.ok) {
- window.location.reload();
- } else {
- r.json().then((r) => setError(r.error));
- }
- })
- .catch((err) => setError(err));
- setLoading(false);
- };
- const updateHandler = () => {
- setError("");
- setSuccess("");
- if (password != "" && confirmPw === "") {
- setError("confirm your new password before submitting");
- return;
+ const logoutHandler = () => {
+ setLoading(true)
+ logout()
+ .then(r => {
+ if (r.ok) {
+ window.location.reload()
+ } else {
+ r.json().then(r => setError(r.error))
+ }
+ }).catch(err => setError(err))
+ setLoading(false)
}
- setError("");
- setSuccess("");
- setLoading(true);
- updateUser(username, password)
- .then((r) => {
- if (r.ok) {
- setSuccess("sucessfully updated user");
- if (username != "") {
- setCtxUsername(username);
- }
- setUsername("");
- setPassword("");
- setConfirmPw("");
- } else {
- r.json().then((r) => setError(r.error));
+ const updateHandler = () => {
+ setError('')
+ setSuccess('')
+ if (password != "" && confirmPw === "") {
+ setError("confirm your new password before submitting")
+ return
}
- })
- .catch((err) => setError(err));
- setLoading(false);
- };
+ setError('')
+ setSuccess('')
+ setLoading(true)
+ updateUser(username, password)
+ .then(r => {
+ if (r.ok) {
+ setSuccess("sucessfully updated user")
+ if (username != "") {
+ setCtxUsername(username)
+ }
+ setUsername('')
+ setPassword('')
+ setConfirmPw('')
+ } else {
+ r.json().then((r) => setError(r.error))
+ }
+ }).catch(err => setError(err))
+ setLoading(false)
+ }
- return (
- <>
- Account
-
-
-
- You're logged in as {user?.username}
-
-
- Logout
-
+ return (
+ <>
+
Account
+
+
+
You're logged in as {user?.username}
+
Logout
+
+
Update User
+
+
+ {success != "" &&
{success}
}
+ {error != "" &&
{error}
}
-
Update User
-
-
- {success != "" &&
{success}
}
- {error != "" &&
{error}
}
-
- >
- );
-}
+ >
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/modals/AddListenModal.tsx b/client/app/components/modals/AddListenModal.tsx
deleted file mode 100644
index 4fda1b3..0000000
--- a/client/app/components/modals/AddListenModal.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-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
(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 (
-
- Add Listen
-
-
setTS(new Date(e.target.value))}
- />
-
- Submit
-
-
{error}
-
-
- );
-}
diff --git a/client/app/components/modals/ApiKeysModal.tsx b/client/app/components/modals/ApiKeysModal.tsx
index c205464..a4bd822 100644
--- a/client/app/components/modals/ApiKeysModal.tsx
+++ b/client/app/components/modals/ApiKeysModal.tsx
@@ -5,183 +5,172 @@ import { useEffect, useRef, useState } from "react";
import { Copy, Trash } from "lucide-react";
type CopiedState = {
- x: number;
- y: number;
- visible: boolean;
+ x: number;
+ y: number;
+ visible: boolean;
};
export default function ApiKeysModal() {
- const [input, setInput] = useState("");
- const [loading, setLoading] = useState(false);
- const [err, setError] = useState();
- const [displayData, setDisplayData] = useState([]);
- const [copied, setCopied] = useState(null);
- const [expandedKey, setExpandedKey] = useState(null);
- const textRefs = useRef>({});
-
- const handleRevealAndSelect = (key: string) => {
- setExpandedKey(key);
- setTimeout(() => {
- const el = textRefs.current[key];
- if (el) {
- const range = document.createRange();
- range.selectNodeContents(el);
- const sel = window.getSelection();
- sel?.removeAllRanges();
- sel?.addRange(range);
- }
- }, 0);
- };
-
- const { isPending, isError, data, error } = useQuery({
- queryKey: ["api-keys"],
- queryFn: () => {
- return getApiKeys();
- },
- });
-
- useEffect(() => {
- if (data) {
- setDisplayData(data);
- }
- }, [data]);
-
- if (isError) {
- return Error: {error.message}
;
- }
- if (isPending) {
- return Loading...
;
- }
-
- const handleCopy = (e: React.MouseEvent, text: string) => {
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
- } else {
- fallbackCopy(text);
- }
-
- const parentRect = (
- e.currentTarget.closest(".relative") as HTMLElement
- ).getBoundingClientRect();
- const buttonRect = e.currentTarget.getBoundingClientRect();
-
- setCopied({
- x: buttonRect.left - parentRect.left + buttonRect.width / 2,
- y: buttonRect.top - parentRect.top - 8,
- visible: true,
+ const [input, setInput] = useState('')
+ const [loading, setLoading ] = useState(false)
+ const [err, setError ] = useState()
+ const [displayData, setDisplayData] = useState([])
+ const [copied, setCopied] = useState(null);
+ const [expandedKey, setExpandedKey] = useState(null);
+ const textRefs = useRef>({});
+
+ const handleRevealAndSelect = (key: string) => {
+ setExpandedKey(key);
+ setTimeout(() => {
+ const el = textRefs.current[key];
+ if (el) {
+ const range = document.createRange();
+ range.selectNodeContents(el);
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ sel?.addRange(range);
+ }
+ }, 0);
+ };
+
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: [
+ 'api-keys'
+ ],
+ queryFn: () => {
+ return getApiKeys();
+ },
});
- setTimeout(() => setCopied(null), 1500);
- };
+ useEffect(() => {
+ if (data) {
+ setDisplayData(data)
+ }
+ }, [data])
- const fallbackCopy = (text: string) => {
- const textarea = document.createElement("textarea");
- textarea.value = text;
- textarea.style.position = "fixed"; // prevent scroll to bottom
- document.body.appendChild(textarea);
- textarea.focus();
- textarea.select();
- try {
- document.execCommand("copy");
- } catch (err) {
- console.error("Fallback: Copy failed", err);
+ if (isError) {
+ return (
+ Error: {error.message}
+ )
}
- document.body.removeChild(textarea);
- };
-
- const handleCreateApiKey = () => {
- setError(undefined);
- if (input === "") {
- setError("a label must be provided");
- return;
+ if (isPending) {
+ return (
+ Loading...
+ )
}
- setLoading(true);
- createApiKey(input)
- .then((r) => {
- setDisplayData([r, ...displayData]);
- setInput("");
- })
- .catch((err) => setError(err.message));
- setLoading(false);
- };
- const handleDeleteApiKey = (id: number) => {
- setError(undefined);
- setLoading(true);
- deleteApiKey(id).then((r) => {
- if (r.ok) {
- setDisplayData(displayData.filter((v) => v.id != id));
- } else {
- r.json().then((r) => setError(r.error));
- }
- });
- setLoading(false);
- };
+ const handleCopy = (e: React.MouseEvent, text: string) => {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
+ } else {
+ fallbackCopy(text);
+ }
+
+ const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
+ const buttonRect = e.currentTarget.getBoundingClientRect();
+
+ setCopied({
+ x: buttonRect.left - parentRect.left + buttonRect.width / 2,
+ y: buttonRect.top - parentRect.top - 8,
+ visible: true,
+ });
+
+ setTimeout(() => setCopied(null), 1500);
+ };
+
+ const fallbackCopy = (text: string) => {
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.style.position = "fixed"; // prevent scroll to bottom
+ document.body.appendChild(textarea);
+ textarea.focus();
+ textarea.select();
+ try {
+ document.execCommand("copy");
+ } catch (err) {
+ console.error("Fallback: Copy failed", err);
+ }
+ document.body.removeChild(textarea);
+ };
+
+ const handleCreateApiKey = () => {
+ setError(undefined)
+ if (input === "") {
+ setError("a label must be provided")
+ return
+ }
+ setLoading(true)
+ createApiKey(input)
+ .then(r => {
+ setDisplayData([r, ...displayData])
+ setInput('')
+ }).catch((err) => setError(err.message))
+ setLoading(false)
+ }
- return (
-
-
API Keys
-
- {displayData.map((v) => (
-
-
{
- textRefs.current[v.key] = el;
- }}
- onClick={() => handleRevealAndSelect(v.key)}
- className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
- expandedKey === v.key ? "" : "truncate"
- }`}
- style={{ whiteSpace: "nowrap" }}
- title={v.key} // optional tooltip
- >
- {expandedKey === v.key
- ? v.key
- : `${v.key.slice(0, 8)}... ${v.label}`}
+ const handleDeleteApiKey = (id: number) => {
+ setError(undefined)
+ setLoading(true)
+ deleteApiKey(id)
+ .then(r => {
+ if (r.ok) {
+ setDisplayData(displayData.filter((v) => v.id != id))
+ } else {
+ r.json().then((r) => setError(r.error))
+ }
+ })
+ setLoading(false)
+
+ }
+
+ return (
+
+
API Keys
+
+ {displayData.map((v) => (
+
{
+ textRefs.current[v.key] = el;
+ }}
+ onClick={() => handleRevealAndSelect(v.key)}
+ className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
+ expandedKey === v.key ? '' : 'truncate'
+ }`}
+ style={{ whiteSpace: 'nowrap' }}
+ title={v.key} // optional tooltip
+ >
+ {expandedKey === v.key ? v.key : `${v.key.slice(0, 8)}... ${v.label}`}
+
+
handleCopy(e, v.key)} className="large-button px-5 rounded-md">
+
handleDeleteApiKey(v.id)} confirm>
+
+ ))}
+
-
handleCopy(e, v.key)}
- className="large-button px-5 rounded-md"
- >
-
-
-
handleDeleteApiKey(v.id)}
- confirm
- >
-
-
-
- ))}
-
-
setInput(e.target.value)}
- />
-
- Create
-
+ {err &&
{err}
}
+ {copied?.visible && (
+
+ Copied!
+
+ )}
- {err &&
{err}
}
- {copied?.visible && (
-
- Copied!
-
- )}
-
-
- );
-}
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/modals/DeleteModal.tsx b/client/app/components/modals/DeleteModal.tsx
index 227951e..98304ad 100644
--- a/client/app/components/modals/DeleteModal.tsx
+++ b/client/app/components/modals/DeleteModal.tsx
@@ -1,41 +1,40 @@
-import { deleteItem } from "api/api";
-import { AsyncButton } from "../AsyncButton";
-import { Modal } from "./Modal";
-import { useNavigate } from "react-router";
-import { useState } from "react";
+import { deleteItem } from "api/api"
+import { AsyncButton } from "../AsyncButton"
+import { Modal } from "./Modal"
+import { useNavigate } from "react-router"
+import { useState } from "react"
interface Props {
- open: boolean;
- setOpen: Function;
- title: string;
- id: number;
- type: string;
+ open: boolean
+ setOpen: Function
+ title: string,
+ id: number,
+ type: string
}
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
- const [loading, setLoading] = useState(false);
- const navigate = useNavigate();
+ const [loading, setLoading] = useState(false)
+ const navigate = useNavigate()
- const doDelete = () => {
- setLoading(true);
- deleteItem(type.toLowerCase(), id).then((r) => {
- if (r.ok) {
- navigate(-1);
- } else {
- console.log(r);
- }
- });
- };
+ const doDelete = () => {
+ setLoading(true)
+ deleteItem(type.toLowerCase(), id)
+ .then(r => {
+ if (r.ok) {
+ navigate('/')
+ } else {
+ console.log(r)
+ }
+ })
+ }
- return (
-
setOpen(false)}>
- Delete "{title}"?
- This action is irreversible!
-
-
- );
-}
+ return (
+
setOpen(false)}>
+ Delete "{title}"?
+ This action is irreversible!
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/modals/EditModal/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx
deleted file mode 100644
index a5c981e..0000000
--- a/client/app/components/modals/EditModal/EditModal.tsx
+++ /dev/null
@@ -1,165 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import {
- createAlias,
- deleteAlias,
- getAliases,
- setPrimaryAlias,
- updateMbzId,
- type Alias,
-} from "api/api";
-import { Modal } from "../Modal";
-import { AsyncButton } from "../../AsyncButton";
-import { useEffect, useState } from "react";
-import { Trash } from "lucide-react";
-import SetVariousArtists from "./SetVariousArtist";
-import SetPrimaryArtist from "./SetPrimaryArtist";
-import UpdateMbzID from "./UpdateMbzID";
-
-interface Props {
- type: string;
- id: number;
- open: boolean;
- setOpen: Function;
-}
-
-export default function EditModal({ open, setOpen, type, id }: Props) {
- const [input, setInput] = useState("");
- const [loading, setLoading] = useState(false);
- const [err, setError] = useState
();
- const [displayData, setDisplayData] = useState([]);
-
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "aliases",
- {
- type: type,
- id: id,
- },
- ],
- queryFn: ({ queryKey }) => {
- const params = queryKey[1] as { type: string; id: number };
- return getAliases(params.type, params.id);
- },
- });
-
- useEffect(() => {
- if (data) {
- setDisplayData(data);
- }
- }, [data]);
-
- if (isError) {
- return Error: {error.message}
;
- }
- if (isPending) {
- return Loading...
;
- }
-
- const handleSetPrimary = (alias: string) => {
- setError(undefined);
- setLoading(true);
- setPrimaryAlias(type, id, alias).then((r) => {
- if (r.ok) {
- window.location.reload();
- } else {
- r.json().then((r) => setError(r.error));
- }
- });
- setLoading(false);
- };
-
- const handleNewAlias = () => {
- setError(undefined);
- if (input === "") {
- setError("no input");
- return;
- }
- setLoading(true);
- createAlias(type, id, input).then((r) => {
- if (r.ok) {
- setDisplayData([
- ...displayData,
- { alias: input, source: "Manual", is_primary: false, id: id },
- ]);
- } else {
- r.json().then((r) => setError(r.error));
- }
- });
- setLoading(false);
- };
-
- const handleDeleteAlias = (alias: string) => {
- setError(undefined);
- setLoading(true);
- deleteAlias(type, id, alias).then((r) => {
- if (r.ok) {
- setDisplayData(displayData.filter((v) => v.alias != alias));
- } else {
- r.json().then((r) => setError(r.error));
- }
- });
- setLoading(false);
- };
-
- const handleClose = () => {
- setOpen(false);
- setInput("");
- };
-
- return (
-
-
-
-
Alias Manager
-
- {displayData.map((v) => (
-
-
- {v.alias} (source: {v.source})
-
-
handleSetPrimary(v.alias)}
- disabled={v.is_primary}
- >
- Set Primary
-
-
handleDeleteAlias(v.alias)}
- confirm
- disabled={v.is_primary}
- >
-
-
-
- ))}
-
-
setInput(e.target.value)}
- />
-
- Submit
-
-
- {err &&
{err}
}
-
-
- {type.toLowerCase() === "album" && (
- <>
-
-
- >
- )}
- {type.toLowerCase() === "track" && (
-
- )}
-
-
-
- );
-}
diff --git a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx
deleted file mode 100644
index e91b083..0000000
--- a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx
+++ /dev/null
@@ -1,99 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { getAlbum, type Artist } from "api/api";
-import { useEffect, useState } from "react";
-
-interface Props {
- id: number;
- type: string;
-}
-
-export default function SetPrimaryArtist({ id, type }: Props) {
- const [err, setErr] = useState("");
- const [primary, setPrimary] = useState();
- const [success, setSuccess] = useState("");
-
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "get-artists-" + type.toLowerCase(),
- {
- id: id,
- },
- ],
- queryFn: () => {
- return fetch(
- "/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
- ).then((r) => r.json()) as Promise;
- },
- });
-
- useEffect(() => {
- if (data) {
- for (let a of data) {
- if (a.is_primary) {
- setPrimary(a);
- break;
- }
- }
- }
- }, [data]);
-
- if (isError) {
- return Error: {error.message}
;
- }
- if (isPending) {
- return Loading...
;
- }
-
- const updatePrimary = (artist: number, val: boolean) => {
- setErr("");
- setSuccess("");
- fetch(
- `/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- },
- }
- ).then((r) => {
- if (r.ok) {
- setSuccess("successfully updated primary artists");
- } else {
- r.json().then((r) => setErr(r.error));
- }
- });
- };
-
- return (
-
-
Set Primary Artist
-
-
{
- for (let a of data) {
- if (a.name === e.target.value) {
- setPrimary(a);
- updatePrimary(a.id, true);
- }
- }
- }}
- >
-
- Select an artist
-
- {data.map((a) => (
-
- {a.name}
-
- ))}
-
- {err &&
{err}
}
- {success &&
{success}
}
-
-
- );
-}
diff --git a/client/app/components/modals/EditModal/SetVariousArtist.tsx b/client/app/components/modals/EditModal/SetVariousArtist.tsx
deleted file mode 100644
index bf9e3d3..0000000
--- a/client/app/components/modals/EditModal/SetVariousArtist.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-import { useQuery } from "@tanstack/react-query";
-import { getAlbum } from "api/api";
-import { useEffect, useState } from "react";
-
-interface Props {
- id: number;
-}
-
-export default function SetVariousArtists({ id }: Props) {
- const [err, setErr] = useState("");
- const [va, setVA] = useState(false);
- const [success, setSuccess] = useState("");
-
- const { isPending, isError, data, error } = useQuery({
- queryKey: [
- "get-album",
- {
- id: id,
- },
- ],
- queryFn: ({ queryKey }) => {
- const params = queryKey[1] as { id: number };
- return getAlbum(params.id);
- },
- });
-
- useEffect(() => {
- if (data) {
- setVA(data.is_various_artists);
- }
- }, [data]);
-
- if (isError) {
- return Error: {error.message}
;
- }
- if (isPending) {
- return Loading...
;
- }
-
- const updateVA = (val: boolean) => {
- setErr("");
- setSuccess("");
- fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, {
- method: "PATCH",
- }).then((r) => {
- if (r.ok) {
- setSuccess("Successfully updated album");
- } else {
- r.json().then((r) => setErr(r.error));
- }
- });
- };
-
- return (
-
-
Mark as Various Artists
-
-
{
- const val = e.target.value === "true";
- setVA(val);
- updateVA(val);
- }}
- >
- True
- False
-
- {err &&
{err}
}
- {success &&
{success}
}
-
-
- );
-}
diff --git a/client/app/components/modals/EditModal/UpdateMbzID.tsx b/client/app/components/modals/EditModal/UpdateMbzID.tsx
deleted file mode 100644
index 0654cc1..0000000
--- a/client/app/components/modals/EditModal/UpdateMbzID.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { updateMbzId } from "api/api";
-import { useState } from "react";
-import { AsyncButton } from "~/components/AsyncButton";
-
-interface Props {
- type: string;
- id: number;
-}
-
-export default function UpdateMbzID({ type, id }: Props) {
- const [err, setError] = useState();
- const [input, setInput] = useState("");
- const [loading, setLoading] = useState(false);
- const [mbzid, setMbzid] = useState<"">();
- const [success, setSuccess] = useState("");
-
- const handleUpdateMbzID = () => {
- setError(undefined);
- if (input === "") {
- setError("no input");
- return;
- }
- setLoading(true);
- updateMbzId(type, id, input).then((r) => {
- if (r.ok) {
- setSuccess("successfully updated MusicBrainz ID");
- } else {
- r.json().then((r) => setError(r.error));
- }
- });
- setLoading(false);
- };
-
- return (
-
-
Update MusicBrainz ID
-
-
setInput(e.target.value)}
- />
-
- Submit
-
-
- {err &&
{err}
}
- {success &&
{success}
}
-
- );
-}
diff --git a/client/app/components/modals/ExportModal.tsx b/client/app/components/modals/ExportModal.tsx
deleted file mode 100644
index d83d7d4..0000000
--- a/client/app/components/modals/ExportModal.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { useState } from "react";
-import { AsyncButton } from "../AsyncButton";
-import { getExport } from "api/api";
-
-export default function ExportModal() {
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState("");
-
- const handleExport = () => {
- setLoading(true);
- fetch(`/apis/web/v1/export`, {
- method: "GET",
- })
- .then((res) => {
- if (res.ok) {
- res.blob().then((blob) => {
- const url = window.URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "koito_export.json";
- document.body.appendChild(a);
- a.click();
- a.remove();
- window.URL.revokeObjectURL(url);
- setLoading(false);
- });
- } else {
- res.json().then((r) => setError(r.error));
- setLoading(false);
- }
- })
- .catch((err) => {
- setError(err);
- setLoading(false);
- });
- };
-
- return (
-
-
Export
-
- Export Data
-
- {error &&
{error}
}
-
- );
-}
diff --git a/client/app/components/modals/ImageReplaceModal.tsx b/client/app/components/modals/ImageReplaceModal.tsx
index 11319b7..d76dd61 100644
--- a/client/app/components/modals/ImageReplaceModal.tsx
+++ b/client/app/components/modals/ImageReplaceModal.tsx
@@ -5,111 +5,86 @@ import SearchResults from "../SearchResults";
import { AsyncButton } from "../AsyncButton";
interface Props {
- type: string;
- id: number;
- musicbrainzId?: string;
- open: boolean;
- setOpen: Function;
+ type: string
+ id: number
+ musicbrainzId?: string
+ open: boolean
+ setOpen: Function
}
-export default function ImageReplaceModal({
- musicbrainzId,
- type,
- id,
- open,
- setOpen,
-}: Props) {
- const [query, setQuery] = useState("");
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState("");
- const [suggestedImgLoading, setSuggestedImgLoading] = useState(true);
+export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
+ const [query, setQuery] = useState('');
+ const [loading, setLoading] = useState(false)
+ const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
- const doImageReplace = (url: string) => {
- setLoading(true);
- setError("");
- const formData = new FormData();
- formData.set(`${type.toLowerCase()}_id`, id.toString());
- formData.set("image_url", url);
- replaceImage(formData)
- .then((r) => {
- if (r.status >= 200 && r.status < 300) {
- window.location.reload();
- } else {
- r.json().then((r) => setError(r.error));
- setLoading(false);
- }
- })
- .catch((err) => setError(err));
- };
+ const doImageReplace = (url: string) => {
+ setLoading(true)
+ const formData = new FormData
+ formData.set(`${type.toLowerCase()}_id`, id.toString())
+ formData.set("image_url", url)
+ replaceImage(formData)
+ .then((r) => {
+ if (r.ok) {
+ window.location.reload()
+ } else {
+ console.log(r)
+ setLoading(false)
+ }
+ })
+ .catch((err) => console.log(err))
+ }
- const closeModal = () => {
- setOpen(false);
- setQuery("");
- setError("");
- };
+ const closeModal = () => {
+ setOpen(false)
+ setQuery('')
+ }
- return (
-
- Replace Image
-
-
setQuery(e.target.value)}
- />
- {query != "" ? (
-
-
doImageReplace(query)}
- >
- Submit
-
-
- ) : (
- ""
- )}
- {type === "Album" && musicbrainzId ? (
- <>
-
Suggested Image (Click to Apply)
-
- doImageReplace(
- `https://coverartarchive.org/release/${musicbrainzId}/front`
- )
- }
- >
-
- {suggestedImgLoading && (
-
- )}
-
setSuggestedImgLoading(false)}
- onError={() => setSuggestedImgLoading(false)}
- className={`block w-[130px] h-auto ${
- suggestedImgLoading ? "opacity-0" : "opacity-100"
- } transition-opacity duration-300`}
+ return (
+
+ Replace Image
+
+ setQuery(e.target.value)}
/>
-
-
- >
- ) : (
- ""
- )}
- {error}
-
-
- );
-}
+ { query != "" ?
+
+
doImageReplace(query)}>Submit
+
:
+ ''}
+ { type === "Album" && musicbrainzId ?
+ <>
+ Suggested Image (Click to Apply)
+ doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
+ >
+
+ {suggestedImgLoading && (
+
+ )}
+
setSuggestedImgLoading(false)}
+ onError={() => setSuggestedImgLoading(false)}
+ className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
+
+
+ >
+ : ''
+ }
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/modals/LoginForm.tsx b/client/app/components/modals/LoginForm.tsx
index 1078476..2c2afc6 100644
--- a/client/app/components/modals/LoginForm.tsx
+++ b/client/app/components/modals/LoginForm.tsx
@@ -1,74 +1,59 @@
-import { login } from "api/api";
-import { useEffect, useState } from "react";
-import { AsyncButton } from "../AsyncButton";
+import { login } from "api/api"
+import { useEffect, useState } from "react"
+import { AsyncButton } from "../AsyncButton"
export default function LoginForm() {
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState("");
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const [remember, setRemember] = useState(false);
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [remember, setRemember] = useState(false)
- const loginHandler = () => {
- if (username && password) {
- setLoading(true);
- login(username, password, remember)
- .then((r) => {
- if (r.status >= 200 && r.status < 300) {
- window.location.reload();
- } else {
- r.json().then((r) => setError(r.error));
- }
- })
- .catch((err) => setError(err));
- setLoading(false);
- } else if (username || password) {
- setError("username and password are required");
+ const loginHandler = () => {
+ if (username && password) {
+ setLoading(true)
+ login(username, password, remember)
+ .then(r => {
+ if (r.status >= 200 && r.status < 300) {
+ window.location.reload()
+ } else {
+ r.json().then(r => setError(r.error))
+ }
+ }).catch(err => setError(err))
+ setLoading(false)
+ } else if (username || password) {
+ setError("username and password are required")
+ }
}
- };
- return (
- <>
- Log In
-
-
- Logging in gives you access to admin tools , such as
- updating images, merging items, deleting items, and more.
-
-
-
{error}
-
- >
- );
-}
+ return (
+ <>
+ Log In
+
+
Logging in gives you access to admin tools , such as updating images, merging items, deleting items, and more.
+
+
{error}
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/modals/MergeModal.tsx b/client/app/components/modals/MergeModal.tsx
index c78681d..ff1079b 100644
--- a/client/app/components/modals/MergeModal.tsx
+++ b/client/app/components/modals/MergeModal.tsx
@@ -2,159 +2,124 @@ import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
-import type {
- MergeFunc,
- MergeSearchCleanerFunc,
-} from "~/routes/MediaItems/MediaLayout";
+import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
import { useNavigate } from "react-router";
interface Props {
- open: boolean;
- setOpen: Function;
- type: string;
- currentId: number;
- currentTitle: string;
- mergeFunc: MergeFunc;
- mergeCleanerFunc: MergeSearchCleanerFunc;
+ open: boolean
+ setOpen: Function
+ type: string
+ currentId: number
+ currentTitle: string
+ mergeFunc: MergeFunc
+ mergeCleanerFunc: MergeSearchCleanerFunc
}
export default function MergeModal(props: Props) {
- const [query, setQuery] = useState(props.currentTitle);
- const [data, setData] = useState();
- const [debouncedQuery, setDebouncedQuery] = useState(query);
- const [mergeTarget, setMergeTarget] = useState<{ title: string; id: number }>(
- { title: "", id: 0 }
- );
- const [mergeOrderReversed, setMergeOrderReversed] = useState(false);
- const [replaceImage, setReplaceImage] = useState(false);
- const navigate = useNavigate();
+ const [query, setQuery] = useState('');
+ const [data, setData] = useState();
+ const [debouncedQuery, setDebouncedQuery] = useState(query);
+ const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
+ const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
+ const navigate = useNavigate()
- const closeMergeModal = () => {
- props.setOpen(false);
- setQuery("");
- setData(undefined);
- setMergeOrderReversed(false);
- setMergeTarget({ title: "", id: 0 });
- };
- const toggleSelect = ({ title, id }: { title: string; id: number }) => {
- setMergeTarget({ title: title, id: id });
- };
-
- useEffect(() => {
- console.log("mergeTarget", mergeTarget);
- }, [mergeTarget]);
-
- const doMerge = () => {
- let from, to;
- if (!mergeOrderReversed) {
- from = mergeTarget;
- to = { id: props.currentId, title: props.currentTitle };
- } else {
- from = { id: props.currentId, title: props.currentTitle };
- to = mergeTarget;
+ const closeMergeModal = () => {
+ props.setOpen(false)
+ setQuery('')
+ setData(undefined)
+ setMergeOrderReversed(false)
+ setMergeTarget({title: '', id: 0})
}
- props
- .mergeFunc(from.id, to.id, replaceImage)
- .then((r) => {
- if (r.ok) {
- if (mergeOrderReversed) {
- navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
- closeMergeModal();
- } else {
- window.location.reload();
- }
+
+ const toggleSelect = ({title, id}: {title: string, id: number}) => {
+ if (mergeTarget.id === 0) {
+ setMergeTarget({title: title, id: id})
} else {
- // TODO: handle error
- console.log(r);
+ setMergeTarget({title:"", id: 0})
}
- })
- .catch((err) => console.log(err));
- };
-
- useEffect(() => {
- const handler = setTimeout(() => {
- setDebouncedQuery(query);
- if (query === "") {
- setData(undefined);
- }
- }, 300);
-
- return () => {
- clearTimeout(handler);
- };
- }, [query]);
-
- useEffect(() => {
- if (debouncedQuery) {
- search(debouncedQuery).then((r) => {
- r = props.mergeCleanerFunc(r, props.currentId);
- setData(r);
- });
}
- }, [debouncedQuery]);
- return (
+ useEffect(() => {
+ console.log(mergeTarget)
+ }, [mergeTarget])
+
+ const doMerge = () => {
+ let from, to
+ if (!mergeOrderReversed) {
+ from = mergeTarget
+ to = {id: props.currentId, title: props.currentTitle}
+ } else {
+ from = {id: props.currentId, title: props.currentTitle}
+ to = mergeTarget
+ }
+ props.mergeFunc(from.id, to.id)
+ .then(r => {
+ if (r.ok) {
+ if (mergeOrderReversed) {
+ navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`)
+ closeMergeModal()
+ } else {
+ window.location.reload()
+ }
+ } else {
+ // TODO: handle error
+ console.log(r)
+ }
+ })
+ .catch((err) => console.log(err))
+ }
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedQuery(query);
+ if (query === '') {
+ setData(undefined)
+ }
+ }, 300);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [query]);
+
+ useEffect(() => {
+ if (debouncedQuery) {
+ search(debouncedQuery).then((r) => {
+ r = props.mergeCleanerFunc(r, props.currentId)
+ setData(r);
+ });
+ }
+ }, [debouncedQuery]);
+
+ return (
- Merge {props.type}s
-
-
{ setQuery(e.target.value); e.target.select()}}
- onChange={(e) => setQuery(e.target.value)}
- />
-
- {mergeTarget.id !== 0 ? (
- <>
- {mergeOrderReversed ? (
-
- {props.currentTitle} will be merged into{" "}
- {mergeTarget.title}
-
- ) : (
-
- {mergeTarget.title} will be merged into{" "}
- {props.currentTitle}
-
- )}
-
- Merge Items
-
-
-
setMergeOrderReversed(!mergeOrderReversed)}
- />
-
Reverse merge order
+
Merge {props.type}s
+
+ > :
+ ''}
+
- );
+ )
}
diff --git a/client/app/components/modals/Modal.tsx b/client/app/components/modals/Modal.tsx
index fc6ce67..47307b0 100644
--- a/client/app/components/modals/Modal.tsx
+++ b/client/app/components/modals/Modal.tsx
@@ -32,34 +32,10 @@ export function Modal({
}
}, [isOpen, shouldRender]);
- // Handle keyboard events
+ // Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
- // Close on Escape key
- if (e.key === 'Escape') {
- onClose()
- // Trap tab navigation to the modal
- } else if (e.key === 'Tab') {
- if (modalRef.current) {
- const focusableEls = modalRef.current.querySelectorAll
(
- 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
- );
- const firstEl = focusableEls[0];
- const lastEl = focusableEls[focusableEls.length - 1];
- const activeEl = document.activeElement
-
- if (e.shiftKey && activeEl === firstEl) {
- e.preventDefault();
- lastEl.focus();
- } else if (!e.shiftKey && activeEl === lastEl) {
- e.preventDefault();
- firstEl.focus();
- } else if (!Array.from(focusableEls).find(node => node.isEqualNode(activeEl))) {
- e.preventDefault();
- firstEl.focus();
- }
- }
- };
+ if (e.key === 'Escape') onClose();
};
if (isOpen) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
@@ -94,13 +70,13 @@ export function Modal({
}`}
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
>
- {children}
🞪
+ {children}
,
document.body
diff --git a/client/app/components/modals/RenameModal.tsx b/client/app/components/modals/RenameModal.tsx
new file mode 100644
index 0000000..4a53ae6
--- /dev/null
+++ b/client/app/components/modals/RenameModal.tsx
@@ -0,0 +1,124 @@
+import { useQuery } from "@tanstack/react-query";
+import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
+import { Modal } from "./Modal";
+import { AsyncButton } from "../AsyncButton";
+import { useEffect, useState } from "react";
+import { Trash } from "lucide-react";
+
+interface Props {
+ type: string
+ id: number
+ open: boolean
+ setOpen: Function
+}
+
+export default function RenameModal({ open, setOpen, type, id }: Props) {
+ const [input, setInput] = useState('')
+ const [loading, setLoading ] = useState(false)
+ const [err, setError ] = useState
()
+ const [displayData, setDisplayData] = useState([])
+
+ const { isPending, isError, data, error } = useQuery({
+ queryKey: [
+ 'aliases',
+ {
+ type: type,
+ id: id
+ },
+ ],
+ queryFn: ({ queryKey }) => {
+ const params = queryKey[1] as { type: string; id: number };
+ return getAliases(params.type, params.id);
+ },
+ });
+
+ useEffect(() => {
+ if (data) {
+ setDisplayData(data)
+ }
+ }, [data])
+
+
+ if (isError) {
+ return (
+ Error: {error.message}
+ )
+ }
+ if (isPending) {
+ return (
+ Loading...
+ )
+ }
+ const handleSetPrimary = (alias: string) => {
+ setError(undefined)
+ setLoading(true)
+ setPrimaryAlias(type, id, alias)
+ .then(r => {
+ if (r.ok) {
+ window.location.reload()
+ } else {
+ r.json().then((r) => setError(r.error))
+ }
+ })
+ setLoading(false)
+ }
+
+ const handleNewAlias = () => {
+ setError(undefined)
+ if (input === "") {
+ setError("alias must be provided")
+ return
+ }
+ setLoading(true)
+ createAlias(type, id, input)
+ .then(r => {
+ if (r.ok) {
+ setDisplayData([...displayData, {alias: input, source: "Manual", is_primary: false, id: id}])
+ } else {
+ r.json().then((r) => setError(r.error))
+ }
+ })
+ setLoading(false)
+ }
+
+ const handleDeleteAlias = (alias: string) => {
+ setError(undefined)
+ setLoading(true)
+ deleteAlias(type, id, alias)
+ .then(r => {
+ if (r.ok) {
+ setDisplayData(displayData.filter((v) => v.alias != alias))
+ } else {
+ r.json().then((r) => setError(r.error))
+ }
+ })
+ setLoading(false)
+
+ }
+
+ return (
+ setOpen(false)}>
+ Alias Manager
+
+ {displayData.map((v) => (
+
+
{v.alias} (source: {v.source})
+
handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary
+
handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}>
+
+ ))}
+
+
setInput(e.target.value)}
+ />
+
Submit
+
+ {err &&
{err}
}
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/modals/SearchModal.tsx b/client/app/components/modals/SearchModal.tsx
index 80c95dc..ec056cf 100644
--- a/client/app/components/modals/SearchModal.tsx
+++ b/client/app/components/modals/SearchModal.tsx
@@ -4,57 +4,57 @@ import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
interface Props {
- open: boolean;
- setOpen: Function;
+ open: boolean
+ setOpen: Function
}
export default function SearchModal({ open, setOpen }: Props) {
- const [query, setQuery] = useState("");
- const [data, setData] = useState();
- const [debouncedQuery, setDebouncedQuery] = useState(query);
+ const [query, setQuery] = useState('');
+ const [data, setData] = useState();
+ const [debouncedQuery, setDebouncedQuery] = useState(query);
- const closeSearchModal = () => {
- setOpen(false);
- setQuery("");
- setData(undefined);
- };
-
- useEffect(() => {
- const handler = setTimeout(() => {
- setDebouncedQuery(query);
- if (query === "") {
- setData(undefined);
- }
- }, 300);
-
- return () => {
- clearTimeout(handler);
- };
- }, [query]);
-
- useEffect(() => {
- if (debouncedQuery) {
- search(debouncedQuery).then((r) => {
- setData(r);
- });
+ const closeSearchModal = () => {
+ setOpen(false)
+ setQuery('')
+ setData(undefined)
}
- }, [debouncedQuery]);
- return (
-
- Search
-
-
setQuery(e.target.value)}
- />
-
-
-
-
-
- );
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedQuery(query);
+ if (query === '') {
+ setData(undefined)
+ }
+ }, 300);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [query]);
+
+ useEffect(() => {
+ if (debouncedQuery) {
+ search(debouncedQuery).then((r) => {
+ setData(r);
+ });
+ }
+ }, [debouncedQuery]);
+
+ return (
+
+ Search
+
+
setQuery(e.target.value)}
+ />
+
+
+
+
+
+ )
}
diff --git a/client/app/components/modals/SettingsModal.tsx b/client/app/components/modals/SettingsModal.tsx
index 31d915b..4ae62d6 100644
--- a/client/app/components/modals/SettingsModal.tsx
+++ b/client/app/components/modals/SettingsModal.tsx
@@ -5,8 +5,6 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
import ThemeHelper from "../../routes/ThemeHelper";
import { useAppContext } from "~/providers/AppProvider";
import ApiKeysModal from "./ApiKeysModal";
-import { AsyncButton } from "../AsyncButton";
-import ExportModal from "./ExportModal";
interface Props {
open: boolean
@@ -21,7 +19,7 @@ export default function SettingsModal({ open, setOpen } : Props) {
const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto"
return (
- setOpen(false)} maxW={900}>
+ setOpen(false)} maxW={900}>
Appearance
Account
{user && (
- <>
-
- API Keys
-
- Export
- >
+
+ API Keys
+
)}
@@ -49,9 +44,6 @@ export default function SettingsModal({ open, setOpen } : Props) {
-
-
-
)
diff --git a/client/app/components/rewind/Rewind.tsx b/client/app/components/rewind/Rewind.tsx
deleted file mode 100644
index a22fe15..0000000
--- a/client/app/components/rewind/Rewind.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import { imageUrl, type RewindStats } from "api/api";
-import RewindStatText from "./RewindStatText";
-import { RewindTopItem } from "./RewindTopItem";
-
-interface Props {
- stats: RewindStats;
- includeTime?: boolean;
-}
-
-export default function Rewind(props: Props) {
- const artistimg = props.stats.top_artists[0]?.item.image;
- const albumimg = props.stats.top_albums[0]?.item.image;
- const trackimg = props.stats.top_tracks[0]?.item.image;
- if (
- !props.stats.top_artists[0] ||
- !props.stats.top_albums[0] ||
- !props.stats.top_tracks[0]
- ) {
- return Not enough data exists to create a Rewind for this period :(
;
- }
- return (
-
-
{props.stats.title}
-
a.name}
- includeTime={props.includeTime}
- />
-
- a.title}
- includeTime={props.includeTime}
- />
-
- t.title}
- includeTime={props.includeTime}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/client/app/components/rewind/RewindStatText.tsx b/client/app/components/rewind/RewindStatText.tsx
deleted file mode 100644
index 5ccec87..0000000
--- a/client/app/components/rewind/RewindStatText.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-interface Props {
- figure: string;
- text: string;
-}
-
-export default function RewindStatText(props: Props) {
- return (
-
-
-
-
- {props.figure}
-
-
-
{props.text}
-
- );
-}
diff --git a/client/app/components/rewind/RewindTopItem.tsx b/client/app/components/rewind/RewindTopItem.tsx
deleted file mode 100644
index 5093768..0000000
--- a/client/app/components/rewind/RewindTopItem.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import type { Ranked } from "api/api";
-
-type TopItemProps = {
- title: string;
- imageSrc: string;
- items: Ranked[];
- getLabel: (item: T) => string;
- includeTime?: boolean;
-};
-
-export function RewindTopItem<
- T extends {
- id: string | number;
- listen_count: number;
- time_listened: number;
- }
->({ title, imageSrc, items, getLabel, includeTime }: TopItemProps) {
- const [top, ...rest] = items;
-
- if (!top) return null;
-
- return (
-
-
-
-
-
-
-
{title}
-
-
-
-
{getLabel(top.item)}
-
- {`${top.item.listen_count} plays`}
- {includeTime
- ? ` (${Math.floor(top.item.time_listened / 60)} minutes)`
- : ``}
-
-
-
-
- {rest.map((e) => (
-
- {getLabel(e.item)}
-
- {` - ${e.item.listen_count} plays`}
- {includeTime
- ? ` (${Math.floor(e.item.time_listened / 60)} minutes)`
- : ``}
-
-
- ))}
-
-
- );
-}
diff --git a/client/app/components/sidebar/Sidebar.tsx b/client/app/components/sidebar/Sidebar.tsx
index 2bd88f3..11ff824 100644
--- a/client/app/components/sidebar/Sidebar.tsx
+++ b/client/app/components/sidebar/Sidebar.tsx
@@ -1,73 +1,36 @@
-import { ExternalLink, History, Home, Info } from "lucide-react";
+import { ExternalLink, Home, Info } from "lucide-react";
import SidebarSearch from "./SidebarSearch";
import SidebarItem from "./SidebarItem";
import SidebarSettings from "./SidebarSettings";
-import { getRewindParams, getRewindYear } from "~/utils/utils";
export default function Sidebar() {
- const iconSize = 20;
+ const iconSize = 20;
- return (
-
-
- {}}
- modal={<>>}
- >
-
-
-
- {}}
- modal={<>>}
- >
-
-
-
-
- }
- space={22}
- externalLink
- to="https://koito.io"
- name="About"
- onClick={() => {}}
- modal={<>>}
- >
-
-
-
-
-
- );
+ return (
+
+
+
+ {}} modal={<>>}>
+
+
+
+
+
+ }
+ space={22}
+ externalLink
+ to="https://koito.io"
+ name="About"
+ onClick={() => {}}
+ modal={<>>}
+ >
+
+
+
+
+
+
+ );
}
diff --git a/client/app/components/themeSwitcher/ThemeOption.tsx b/client/app/components/themeSwitcher/ThemeOption.tsx
index 7c0166b..224fcce 100644
--- a/client/app/components/themeSwitcher/ThemeOption.tsx
+++ b/client/app/components/themeSwitcher/ThemeOption.tsx
@@ -1,43 +1,22 @@
-import type { Theme } from "~/styles/themes.css";
+import type { Theme } from "~/providers/ThemeProvider";
interface Props {
- theme: Theme;
- themeName: string;
- setTheme: Function;
+ theme: Theme
+ setTheme: Function
}
-export default function ThemeOption({ theme, themeName, setTheme }: Props) {
- const capitalizeFirstLetter = (s: string) => {
- return s.charAt(0).toUpperCase() + s.slice(1);
- };
+export default function ThemeOption({ theme, setTheme }: Props) {
- return (
- setTheme(themeName)}
- className="rounded-md p-3 sm:p-5 hover:cursor-pointer flex gap-3 items-center border-2 justify-between"
- style={{
- background: theme.bg,
- color: theme.fg,
- borderColor: theme.bgSecondary,
- }}
- >
-
- {capitalizeFirstLetter(themeName)}
-
-
-
- );
-}
+ const capitalizeFirstLetter = (s: string) => {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+ }
+
+ return (
+ 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}}>
+
{capitalizeFirstLetter(theme.name)}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx
index f27d41c..e051f50 100644
--- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx
+++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx
@@ -1,78 +1,36 @@
-import { useState } from "react";
-import { useTheme } from "../../hooks/useTheme";
-import themes from "~/styles/themes.css";
-import ThemeOption from "./ThemeOption";
-import { AsyncButton } from "../AsyncButton";
+// ThemeSwitcher.tsx
+import { useEffect } from 'react';
+import { useTheme } from '../../hooks/useTheme';
+import { themes } from '~/providers/ThemeProvider';
+import ThemeOption from './ThemeOption';
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 { theme, setTheme } = useTheme();
- 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);
- }
- };
+ useEffect(() => {
+ const saved = localStorage.getItem('theme');
+ if (saved && saved !== theme) {
+ setTheme(saved);
+ } else if (!saved) {
+ localStorage.setItem('theme', theme)
+ }
+ }, []);
- return (
-
-
-
-
Select Theme
-
+ useEffect(() => {
+ if (theme) {
+ localStorage.setItem('theme', theme)
+ }
+ }, [theme]);
+
+ return (
+ <>
+
Select Theme
+
+ {themes.map((t) => (
+
+ ))}
-
- {Object.entries(themes).map(([name, themeData]) => (
-
- ))}
-
-
-
-
- );
+ >
+ );
}
diff --git a/client/app/providers/AppProvider.tsx b/client/app/providers/AppProvider.tsx
index 4b8290d..9614db8 100644
--- a/client/app/providers/AppProvider.tsx
+++ b/client/app/providers/AppProvider.tsx
@@ -1,11 +1,10 @@
-import { getCfg, type User } from "api/api";
+import 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;
@@ -23,19 +22,15 @@ export const useAppContext = () => {
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [user, setUser] = useState
(undefined);
- const [defaultTheme, setDefaultTheme] = useState(
- undefined
- );
- const [configurableHomeActivity, setConfigurableHomeActivity] =
- useState(false);
+ const [configurableHomeActivity, setConfigurableHomeActivity] = useState(false);
const [homeItems, setHomeItems] = useState(0);
const setUsername = (value: string) => {
if (!user) {
- return;
+ return
}
- setUser({ ...user, username: value });
- };
+ setUser({...user, username: value})
+ }
useEffect(() => {
fetch("/apis/web/v1/user/me")
@@ -47,19 +42,9 @@ 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");
- }
- });
}, []);
- // Block rendering the app until config is loaded
- if (user === undefined || defaultTheme === undefined) {
+ if (user === undefined) {
return null;
}
@@ -67,13 +52,10 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
user,
configurableHomeActivity,
homeItems,
- defaultTheme,
setConfigurableHomeActivity,
setHomeItems,
setUsername,
};
- return (
- {children}
- );
-};
+ return {children} ;
+};
\ No newline at end of file
diff --git a/client/app/providers/ThemeProvider.tsx b/client/app/providers/ThemeProvider.tsx
index 51563cb..cbdbf72 100644
--- a/client/app/providers/ThemeProvider.tsx
+++ b/client/app/providers/ThemeProvider.tsx
@@ -1,135 +1,259 @@
-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, type ReactNode } from 'react';
+
+// a fair number of colors aren't actually used, but i'm keeping
+// them so that I don't have to worry about colors when adding new ui elements
+export type Theme = {
+ name: string,
+ bg: string
+ bgSecondary: string
+ bgTertiary: string
+ fg: string
+ fgSecondary: string
+ fgTertiary: string
+ primary: string
+ primaryDim: string
+ accent: string
+ accentDim: string
+ error: string
+ warning: string
+ info: string
+ success: string
+}
+
+export const themes: Theme[] = [
+ {
+ name: "yuu",
+ bg: "#161312",
+ bgSecondary: "#272120",
+ bgTertiary: "#382F2E",
+ fg: "#faf5f4",
+ fgSecondary: "#CCC7C6",
+ fgTertiary: "#B0A3A1",
+ primary: "#ff826d",
+ primaryDim: "#CE6654",
+ accent: "#464DAE",
+ accentDim: "#393D74",
+ error: "#FF6247",
+ warning: "#FFC107",
+ success: "#3ECE5F",
+ info: "#41C4D8",
+ },
+ {
+ name: "varia",
+ bg: "rgb(25, 25, 29)",
+ bgSecondary: "#222222",
+ bgTertiary: "#333333",
+ fg: "#eeeeee",
+ fgSecondary: "#aaaaaa",
+ fgTertiary: "#888888",
+ primary: "rgb(203, 110, 240)",
+ primaryDim: "#c28379",
+ accent: "#f0ad0a",
+ accentDim: "#d08d08",
+ error: "#f44336",
+ warning: "#ff9800",
+ success: "#4caf50",
+ info: "#2196f3",
+ },
+ {
+ name: "midnight",
+ bg: "rgb(8, 15, 24)",
+ bgSecondary: "rgb(15, 27, 46)",
+ bgTertiary: "rgb(15, 41, 70)",
+ fg: "#dbdfe7",
+ fgSecondary: "#9ea3a8",
+ fgTertiary: "#74787c",
+ primary: "#1a97eb",
+ primaryDim: "#2680aa",
+ accent: "#f0ad0a",
+ accentDim: "#d08d08",
+ error: "#f44336",
+ warning: "#ff9800",
+ success: "#4caf50",
+ info: "#2196f3",
+ },
+ {
+ name: "catppuccin",
+ bg: "#1e1e2e",
+ bgSecondary: "#181825",
+ bgTertiary: "#11111b",
+ fg: "#cdd6f4",
+ fgSecondary: "#a6adc8",
+ fgTertiary: "#9399b2",
+ primary: "#89b4fa",
+ primaryDim: "#739df0",
+ accent: "#f38ba8",
+ accentDim: "#d67b94",
+ error: "#f38ba8",
+ warning: "#f9e2af",
+ success: "#a6e3a1",
+ info: "#89dceb",
+ },
+ {
+ name: "autumn",
+ bg: "rgb(44, 25, 18)",
+ bgSecondary: "rgb(70, 40, 18)",
+ bgTertiary: "#4b2f1c",
+ fg: "#fef9f3",
+ fgSecondary: "#dbc6b0",
+ fgTertiary: "#a3917a",
+ primary: "#d97706",
+ primaryDim: "#b45309",
+ accent: "#8c4c28",
+ accentDim: "#6b3b1f",
+ error: "#d1433f",
+ warning: "#e38b29",
+ success: "#6b8e23",
+ info: "#c084fc",
+ },
+ {
+ name: "black",
+ bg: "#000000",
+ bgSecondary: "#1a1a1a",
+ bgTertiary: "#2a2a2a",
+ fg: "#dddddd",
+ fgSecondary: "#aaaaaa",
+ fgTertiary: "#888888",
+ primary: "#08c08c",
+ primaryDim: "#08c08c",
+ accent: "#f0ad0a",
+ accentDim: "#d08d08",
+ error: "#f44336",
+ warning: "#ff9800",
+ success: "#4caf50",
+ info: "#2196f3",
+ },
+ {
+ name: "wine",
+ bg: "#23181E",
+ bgSecondary: "#2C1C25",
+ bgTertiary: "#422A37",
+ fg: "#FCE0B3",
+ fgSecondary: "#C7AC81",
+ fgTertiary: "#A78E64",
+ primary: "#EA8A64",
+ primaryDim: "#BD7255",
+ accent: "#FAE99B",
+ accentDim: "#C6B464",
+ error: "#fca5a5",
+ warning: "#fde68a",
+ success: "#bbf7d0",
+ info: "#bae6fd",
+ },
+ {
+ name: "pearl",
+ bg: "#FFFFFF",
+ bgSecondary: "#EEEEEE",
+ bgTertiary: "#E0E0E0",
+ fg: "#333333",
+ fgSecondary: "#555555",
+ fgTertiary: "#777777",
+ primary: "#007BFF",
+ primaryDim: "#0056B3",
+ accent: "#28A745",
+ accentDim: "#1E7E34",
+ error: "#DC3545",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+ {
+ name: "asuka",
+ bg: "#3B1212",
+ bgSecondary: "#471B1B",
+ bgTertiary: "#020202",
+ fg: "#F1E9E6",
+ fgSecondary: "#CCB6AE",
+ fgTertiary: "#9F8176",
+ primary: "#F1E9E6",
+ primaryDim: "#CCB6AE",
+ accent: "#41CE41",
+ accentDim: "#3BA03B",
+ error: "#DC143C",
+ warning: "#FFD700",
+ success: "#32CD32",
+ info: "#1E90FF",
+ },
+ {
+ name: "urim",
+ bg: "#101713",
+ bgSecondary: "#1B2921",
+ bgTertiary: "#273B30",
+ fg: "#D2E79E",
+ fgSecondary: "#B4DA55",
+ fgTertiary: "#7E9F2A",
+ primary: "#ead500",
+ primaryDim: "#C1B210",
+ accent: "#28A745",
+ accentDim: "#1E7E34",
+ error: "#EE5237",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+ {
+ name: "match",
+ bg: "#071014",
+ bgSecondary: "#0A181E",
+ bgTertiary: "#112A34",
+ fg: "#ebeaeb",
+ fgSecondary: "#BDBDBD",
+ fgTertiary: "#A2A2A2",
+ primary: "#fda827",
+ primaryDim: "#C78420",
+ accent: "#277CFD",
+ accentDim: "#1F60C1",
+ error: "#F14426",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+ {
+ name: "lemon",
+ bg: "#1a171a",
+ bgSecondary: "#2E272E",
+ bgTertiary: "#443844",
+ fg: "#E6E2DC",
+ fgSecondary: "#B2ACA1",
+ fgTertiary: "#968F82",
+ primary: "#f5c737",
+ primaryDim: "#C29D2F",
+ accent: "#277CFD",
+ accentDim: "#1F60C1",
+ error: "#F14426",
+ warning: "#FFC107",
+ success: "#28A745",
+ info: "#17A2B8",
+ },
+];
interface ThemeContextValue {
- themeName: string;
- theme: Theme;
+ theme: string;
setTheme: (theme: string) => void;
- resetTheme: () => void;
- setCustomTheme: (theme: Theme) => void;
- getCustomTheme: () => Theme | undefined;
}
const ThemeContext = createContext(undefined);
-function toKebabCase(str: string) {
- 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);
- }
-}
-
-function clearCustomThemeVars() {
- 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;
- }
-}
-
-export function ThemeProvider({ children }: { children: ReactNode }) {
- let defaultTheme = useAppContext().defaultTheme;
- let initialTheme = localStorage.getItem("theme") ?? defaultTheme;
- const [themeName, setThemeName] = useState(
- themes[initialTheme] ? initialTheme : defaultTheme
- );
- const [currentTheme, setCurrentTheme] = useState(() => {
- 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);
- } else {
- setTheme(defaultTheme);
- }
- }
- };
-
- 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();
- };
+export function ThemeProvider({
+ theme: initialTheme,
+ children,
+}: {
+ theme: string;
+ children: ReactNode;
+}) {
+ const [theme, setTheme] = useState(initialTheme);
useEffect(() => {
- const root = document.documentElement;
-
- root.setAttribute("data-theme", themeName);
-
- if (themeName === "custom") {
- applyCustomThemeVars(currentTheme);
- } else {
- clearCustomThemeVars();
+ if (theme) {
+ document.documentElement.setAttribute('data-theme', theme);
}
- }, [themeName, currentTheme]);
+ }, [theme]);
return (
-
+
{children}
);
}
-export { ThemeContext };
+export { ThemeContext }
\ No newline at end of file
diff --git a/client/app/root.tsx b/client/app/root.tsx
index cb0723f..e7e2415 100644
--- a/client/app/root.tsx
+++ b/client/app/root.tsx
@@ -9,19 +9,16 @@ import {
} from "react-router";
import type { Route } from "./+types/root";
-import "./themes.css";
+import './themes.css'
import "./app.css";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { ThemeProvider } from "./providers/ThemeProvider";
+import { ThemeProvider } from './providers/ThemeProvider';
import Sidebar from "./components/sidebar/Sidebar";
import Footer from "./components/Footer";
import { AppProvider } from "./providers/AppProvider";
-import { initTimezoneCookie } from "./tz";
-
-initTimezoneCookie();
// Create a client
-const queryClient = new QueryClient();
+const queryClient = new QueryClient()
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
@@ -38,23 +35,14 @@ export const links: Route.LinksFunction = () => [
export function Layout({ children }: { children: React.ReactNode }) {
return (
-
+
-
+
-
+
@@ -70,73 +58,81 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export default function App() {
+ let theme = localStorage.getItem('theme') ?? 'yuu'
+
return (
<>
-
-
-
-
-
-
-
+
+
+
+
+
+
+
>
);
}
export function HydrateFallback() {
- return null;
+ return null
}
export function ErrorBoundary() {
- const error = useRouteError();
- let message = "Oops!";
- let details = "An unexpected error occurred.";
- let stack: string | undefined;
+ const error = useRouteError();
+ let message = "Oops!";
+ let details = "An unexpected error occurred.";
+ let stack: string | undefined;
- if (isRouteErrorResponse(error)) {
- message = error.status === 404 ? "404" : "Error";
- details =
- error.status === 404
+ if (isRouteErrorResponse(error)) {
+ message = error.status === 404 ? "404" : "Error";
+ details = error.status === 404
? "The requested page could not be found."
: error.statusText || details;
- } else if (import.meta.env.DEV && error instanceof Error) {
- details = error.message;
- stack = error.stack;
- }
+ } else if (import.meta.env.DEV && error instanceof Error) {
+ details = error.message;
+ stack = error.stack;
+ }
- const title = `${message} - Koito`;
+ let theme = 'yuu'
+ try {
+ theme = localStorage.getItem('theme') ?? theme
+ } catch(err) {
+ console.log(err)
+ }
- return (
-
-
- {title}
-
-
-
-
-
-
-
-
{message}
-
{details}
+ const title = `${message} - Koito`
+
+ return (
+
+
+ {title}
+
+
+
+
+
+
+
+
{message}
+
{details}
+
+
+ {stack && (
+
+ {stack}
+
+ )}
+
+
+
-
- {stack && (
-
- {stack}
-
- )}
-
-
-
-
-
-
- );
+
+
+ );
}
diff --git a/client/app/routes.ts b/client/app/routes.ts
index b3496a1..8909928 100644
--- a/client/app/routes.ts
+++ b/client/app/routes.ts
@@ -1,14 +1,13 @@
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
- index("routes/Home.tsx"),
- route("/artist/:id", "routes/MediaItems/Artist.tsx"),
- route("/album/:id", "routes/MediaItems/Album.tsx"),
- route("/track/:id", "routes/MediaItems/Track.tsx"),
- route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
- route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
- route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
- route("/listens", "routes/Charts/Listens.tsx"),
- route("/rewind", "routes/RewindPage.tsx"),
- route("/theme-helper", "routes/ThemeHelper.tsx"),
-] satisfies RouteConfig;
+ index("routes/Home.tsx"),
+ route("/artist/:id", "routes/MediaItems/Artist.tsx"),
+ route("/album/:id", "routes/MediaItems/Album.tsx"),
+ route("/track/:id", "routes/MediaItems/Track.tsx"),
+ route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
+ route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
+ route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
+ route("/listens", "routes/Charts/Listens.tsx"),
+ route("/theme-helper", "routes/ThemeHelper.tsx"),
+] satisfies RouteConfig;
\ No newline at end of file
diff --git a/client/app/routes/Charts/AlbumChart.tsx b/client/app/routes/Charts/AlbumChart.tsx
index 7a157a8..8e68186 100644
--- a/client/app/routes/Charts/AlbumChart.tsx
+++ b/client/app/routes/Charts/AlbumChart.tsx
@@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
-import { type Album, type PaginatedResponse, type Ranked } from "api/api";
+import { type Album, type PaginatedResponse } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
- url.searchParams.set("page", page);
+ url.searchParams.set('page', page)
const res = await fetch(
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
@@ -20,9 +20,7 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
}
export default function AlbumChart() {
- const { top_albums: initialData } = useLoaderData<{
- top_albums: PaginatedResponse
>;
- }>();
+ const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse }>();
return (
(
-
-
-
- Prev
-
-
- Next
-
-
+
+
+
+ Prev
+
+
+ Next
+
+
Prev
-
+
Next
diff --git a/client/app/routes/Charts/ArtistChart.tsx b/client/app/routes/Charts/ArtistChart.tsx
index 8bc2935..bc3be16 100644
--- a/client/app/routes/Charts/ArtistChart.tsx
+++ b/client/app/routes/Charts/ArtistChart.tsx
@@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
-import { type Album, type PaginatedResponse, type Ranked } from "api/api";
+import { type Album, type PaginatedResponse } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
- url.searchParams.set("page", page);
+ url.searchParams.set('page', page)
const res = await fetch(
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
@@ -20,9 +20,7 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
}
export default function Artist() {
- const { top_artists: initialData } = useLoaderData<{
- top_artists: PaginatedResponse
>;
- }>();
+ const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse }>();
return (
(
-
-
-
- Prev
-
-
- Next
-
-
+
+
+
+ Prev
+
+
+ Next
+
+
Prev
-
+
Next
diff --git a/client/app/routes/Charts/ChartLayout.tsx b/client/app/routes/Charts/ChartLayout.tsx
index 90858bd..6690cd3 100644
--- a/client/app/routes/Charts/ChartLayout.tsx
+++ b/client/app/routes/Charts/ChartLayout.tsx
@@ -1,272 +1,262 @@
-import { useFetcher, useLocation, useNavigate } from "react-router";
-import { useEffect, useState } from "react";
-import { average } from "color.js";
-import { imageUrl, type PaginatedResponse } from "api/api";
-import PeriodSelector from "~/components/PeriodSelector";
+import {
+ useFetcher,
+ useLocation,
+ useNavigate,
+} from "react-router"
+import { useEffect, useState } from "react"
+import { average } from "color.js"
+import { imageUrl, type PaginatedResponse } from "api/api"
+import PeriodSelector from "~/components/PeriodSelector"
interface ChartLayoutProps
{
- title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played";
- initialData: PaginatedResponse;
- endpoint: string;
- render: (opts: {
- data: PaginatedResponse;
- page: number;
- onNext: () => void;
- onPrev: () => void;
- }) => React.ReactNode;
+ title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"
+ initialData: PaginatedResponse
+ endpoint: string
+ render: (opts: {
+ data: PaginatedResponse
+ page: number
+ onNext: () => void
+ onPrev: () => void
+ }) => React.ReactNode
}
export default function ChartLayout({
- title,
- initialData,
- endpoint,
- render,
+ title,
+ initialData,
+ endpoint,
+ render,
}: ChartLayoutProps) {
- const pgTitle = `${title} - Koito`;
+ const pgTitle = `${title} - Koito`
- const fetcher = useFetcher();
- const location = useLocation();
- const navigate = useNavigate();
+ const fetcher = useFetcher()
+ const location = useLocation()
+ const navigate = useNavigate()
- const currentParams = new URLSearchParams(location.search);
- const currentPage = parseInt(currentParams.get("page") || "1", 10);
+ const currentParams = new URLSearchParams(location.search)
+ const currentPage = parseInt(currentParams.get("page") || "1", 10)
- const data: PaginatedResponse = fetcher.data?.[endpoint]
- ? fetcher.data[endpoint]
- : initialData;
+ const data: PaginatedResponse = fetcher.data?.[endpoint]
+ ? fetcher.data[endpoint]
+ : initialData
- const [bgColor, setBgColor] = useState("(--color-bg)");
+ const [bgColor, setBgColor] = useState("(--color-bg)")
- useEffect(() => {
- if ((data?.items?.length ?? 0) === 0) return;
+ useEffect(() => {
+ if ((data?.items?.length ?? 0) === 0) return
- const img = (data.items[0] as any)?.item?.image;
- if (!img) return;
+ const img = (data.items[0] as any)?.image
+ if (!img) return
- average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
- setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
- });
- }, [data]);
+ average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
+ setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`)
+ })
+ }, [data])
- const period = currentParams.get("period") ?? "day";
- const year = currentParams.get("year");
- const month = currentParams.get("month");
- const week = currentParams.get("week");
+ const period = currentParams.get("period") ?? "day"
+ const year = currentParams.get("year")
+ const month = currentParams.get("month")
+ const week = currentParams.get("week")
- const updateParams = (params: Record) => {
- const nextParams = new URLSearchParams(location.search);
-
- for (const key in params) {
- const val = params[key];
- if (val !== null) {
- nextParams.set(key, val);
- } else {
- nextParams.delete(key);
- }
+ const updateParams = (params: Record) => {
+ const nextParams = new URLSearchParams(location.search)
+
+ for (const key in params) {
+ const val = params[key]
+ if (val !== null) {
+ nextParams.set(key, val)
+ } else {
+ nextParams.delete(key)
+ }
+ }
+
+ const url = `/${endpoint}?${nextParams.toString()}`
+ navigate(url, { replace: false })
}
+
+ const handleSetPeriod = (p: string) => {
+ updateParams({
+ period: p,
+ page: "1",
+ year: null,
+ month: null,
+ week: null,
+ })
+ }
+ const handleSetYear = (val: string) => {
+ if (val == "") {
+ updateParams({
+ period: period,
+ page: "1",
+ year: null,
+ month: null,
+ week: null
+ })
+ return
+ }
+ updateParams({
+ period: null,
+ page: "1",
+ year: val,
+ })
+ }
+ const handleSetMonth = (val: string) => {
+ updateParams({
+ period: null,
+ page: "1",
+ year: year ?? new Date().getFullYear().toString(),
+ month: val,
+ })
+ }
+ const handleSetWeek = (val: string) => {
+ updateParams({
+ period: null,
+ page: "1",
+ year: year ?? new Date().getFullYear().toString(),
+ month: null,
+ week: val,
+ })
+ }
- const url = `/${endpoint}?${nextParams.toString()}`;
- navigate(url, { replace: false });
- };
+ useEffect(() => {
+ fetcher.load(`/${endpoint}?${currentParams.toString()}`)
+ }, [location.search])
- const handleSetPeriod = (p: string) => {
- updateParams({
- period: p,
- page: "1",
- year: null,
- month: null,
- week: null,
- });
- };
- const handleSetYear = (val: string) => {
- if (val == "") {
- updateParams({
- period: period,
- page: "1",
- year: null,
- month: null,
- week: null,
- });
- return;
+ const setPage = (nextPage: number) => {
+ const nextParams = new URLSearchParams(location.search)
+ nextParams.set("page", String(nextPage))
+ const url = `/${endpoint}?${nextParams.toString()}`
+ fetcher.load(url)
+ navigate(url, { replace: false })
+ }
+
+ const handleNextPage = () => setPage(currentPage + 1)
+ const handlePrevPage = () => setPage(currentPage - 1)
+
+ const yearOptions = Array.from({ length: 10 }, (_, i) => `${new Date().getFullYear() - i}`)
+ const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`)
+ const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`)
+
+ const getDateRange = (): string => {
+ let from: Date
+ let to: Date
+
+ const now = new Date()
+ const currentYear = now.getFullYear()
+ const currentMonth = now.getMonth() // 0-indexed
+ const currentDate = now.getDate()
+
+ if (year && month) {
+ from = new Date(parseInt(year), parseInt(month) - 1, 1)
+ to = new Date(from)
+ to.setMonth(from.getMonth() + 1)
+ to.setDate(0)
+ } else if (year && week) {
+ const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year
+ const weekNumber = parseInt(week)
+ from = new Date(base)
+ from.setDate(base.getDate() + (weekNumber - 1) * 7)
+ to = new Date(from)
+ to.setDate(from.getDate() + 6)
+ } else if (year) {
+ from = new Date(parseInt(year), 0, 1)
+ to = new Date(parseInt(year), 11, 31)
+ } else {
+ switch (period) {
+ case "day":
+ from = new Date(now)
+ to = new Date(now)
+ break
+ case "week":
+ to = new Date(now)
+ from = new Date(now)
+ from.setDate(to.getDate() - 6)
+ break
+ case "month":
+ to = new Date(now)
+ from = new Date(now)
+ if (currentMonth === 0) {
+ from = new Date(currentYear - 1, 11, currentDate)
+ } else {
+ from = new Date(currentYear, currentMonth - 1, currentDate)
+ }
+ break
+ case "year":
+ to = new Date(now)
+ from = new Date(currentYear - 1, currentMonth, currentDate)
+ break
+ case "all_time":
+ return "All Time"
+ default:
+ return ""
+ }
+ }
+
+ const formatter = new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+
+ return `${formatter.format(from)} - ${formatter.format(to)}`
}
- updateParams({
- period: null,
- page: "1",
- year: val,
- });
- };
- const handleSetMonth = (val: string) => {
- updateParams({
- period: null,
- page: "1",
- year: year ?? new Date().getFullYear().toString(),
- month: val,
- });
- };
- const handleSetWeek = (val: string) => {
- updateParams({
- period: null,
- page: "1",
- year: year ?? new Date().getFullYear().toString(),
- month: null,
- week: val,
- });
- };
+
- useEffect(() => {
- fetcher.load(`/${endpoint}?${currentParams.toString()}`);
- }, [location.search]);
-
- const setPage = (nextPage: number) => {
- const nextParams = new URLSearchParams(location.search);
- nextParams.set("page", String(nextPage));
- const url = `/${endpoint}?${nextParams.toString()}`;
- fetcher.load(url);
- navigate(url, { replace: false });
- };
-
- const handleNextPage = () => setPage(currentPage + 1);
- const handlePrevPage = () => setPage(currentPage - 1);
-
- const yearOptions = Array.from(
- { length: 10 },
- (_, i) => `${new Date().getFullYear() - i}`
- );
- const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`);
- const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`);
-
- const getDateRange = (): string => {
- let from: Date;
- let to: Date;
-
- const now = new Date();
- const currentYear = now.getFullYear();
- const currentMonth = now.getMonth(); // 0-indexed
- const currentDate = now.getDate();
-
- if (year && month) {
- from = new Date(parseInt(year), parseInt(month) - 1, 1);
- to = new Date(from);
- to.setMonth(from.getMonth() + 1);
- to.setDate(0);
- } else if (year && week) {
- const base = new Date(parseInt(year), 0, 1); // Jan 1 of the year
- const weekNumber = parseInt(week);
- from = new Date(base);
- from.setDate(base.getDate() + (weekNumber - 1) * 7);
- to = new Date(from);
- to.setDate(from.getDate() + 6);
- } else if (year) {
- from = new Date(parseInt(year), 0, 1);
- to = new Date(parseInt(year), 11, 31);
- } else {
- switch (period) {
- case "day":
- from = new Date(now);
- to = new Date(now);
- break;
- case "week":
- to = new Date(now);
- from = new Date(now);
- from.setDate(to.getDate() - 6);
- break;
- case "month":
- to = new Date(now);
- from = new Date(now);
- if (currentMonth === 0) {
- from = new Date(currentYear - 1, 11, currentDate);
- } else {
- from = new Date(currentYear, currentMonth - 1, currentDate);
- }
- break;
- case "year":
- to = new Date(now);
- from = new Date(currentYear - 1, currentMonth, currentDate);
- break;
- case "all_time":
- return "All Time";
- default:
- return "";
- }
- }
-
- const formatter = new Intl.DateTimeFormat(undefined, {
- year: "numeric",
- month: "long",
- day: "numeric",
- });
-
- return `${formatter.format(from)} - ${formatter.format(to)}`;
- };
-
- return (
-
-
{pgTitle}
-
-
-
-
{title}
-
-
-
- handleSetYear(e.target.value)}
- className="px-2 py-1 rounded border border-gray-400"
- >
- Year
- {yearOptions.map((y) => (
-
- {y}
-
- ))}
-
- handleSetMonth(e.target.value)}
- className="px-2 py-1 rounded border border-gray-400"
- >
- Month
- {monthOptions.map((m) => (
-
- {m}
-
- ))}
-
- handleSetWeek(e.target.value)}
- className="px-2 py-1 rounded border border-gray-400"
- >
- Week
- {weekOptions.map((w) => (
-
- {w}
-
- ))}
-
-
-
-
{getDateRange()}
-
- {render({
- data,
- page: currentPage,
- onNext: handleNextPage,
- onPrev: handlePrevPage,
- })}
-
-
-
- );
+ return (
+
+
{pgTitle}
+
+
+
+
{title}
+
+
+
handleSetYear(e.target.value)}
+ className="px-2 py-1 rounded border border-gray-400"
+ >
+ Year
+ {yearOptions.map((y) => (
+ {y}
+ ))}
+
+
handleSetMonth(e.target.value)}
+ className="px-2 py-1 rounded border border-gray-400"
+ >
+ Month
+ {monthOptions.map((m) => (
+ {m}
+ ))}
+
+
handleSetWeek(e.target.value)}
+ className="px-2 py-1 rounded border border-gray-400"
+ >
+ Week
+ {weekOptions.map((w) => (
+ {w}
+ ))}
+
+
+
{getDateRange()}
+
+ {render({
+ data,
+ page: currentPage,
+ onNext: handleNextPage,
+ onPrev: handlePrevPage,
+ })}
+
+
+
+ )
}
diff --git a/client/app/routes/Charts/Listens.tsx b/client/app/routes/Charts/Listens.tsx
index 2dff3f2..6f5efdb 100644
--- a/client/app/routes/Charts/Listens.tsx
+++ b/client/app/routes/Charts/Listens.tsx
@@ -1,107 +1,66 @@
import ChartLayout from "./ChartLayout";
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
-import { deleteListen, type Listen, type PaginatedResponse } from "api/api";
+import { type Album, type Listen, type PaginatedResponse } from "api/api";
import { timeSince } from "~/utils/utils";
import ArtistLinks from "~/components/ArtistLinks";
-import { useState } from "react";
-import { useAppContext } from "~/providers/AppProvider";
export async function clientLoader({ request }: LoaderFunctionArgs) {
- const url = new URL(request.url);
- const page = url.searchParams.get("page") || "0";
- url.searchParams.set('page', page)
+ const url = new URL(request.url);
+ const page = url.searchParams.get("page") || "0";
+ url.searchParams.set('page', page)
- const res = await fetch(
- `/apis/web/v1/listens?${url.searchParams.toString()}`
- );
- if (!res.ok) {
- throw new Response("Failed to load top tracks", { status: 500 });
- }
+ const res = await fetch(
+ `/apis/web/v1/listens?${url.searchParams.toString()}`
+ );
+ if (!res.ok) {
+ throw new Response("Failed to load top tracks", { status: 500 });
+ }
- const listens: PaginatedResponse = await res.json();
- return { listens };
+ const listens: PaginatedResponse = await res.json();
+ return { listens };
}
export default function Listens() {
- const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse }>();
+ const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse }>();
- const [items, setItems] = useState(null)
- const { user } = useAppContext()
-
- const handleDelete = async (listen: Listen) => {
- if (!initialData) return
- try {
- const res = await deleteListen(listen)
- if (res.ok || (res.status >= 200 && res.status < 300)) {
- setItems((prev) => (prev ?? initialData.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 listens = items ?? initialData.items
-
return (
- (
-
-
-
- Prev
+ (
+
+
+
+ Prev
+
+
+ Next
+
+
+
+
+ {data.items.map((item) => (
+
+ {timeSince(new Date(item.time))}
+
+ {' - '}
+ {item.track.title}
+
+
+ ))}
+
+
+
+
+ Prev
- Next
+ Next
-
-
-
- {listens.map((item) => (
-
-
- 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}
- >
- ×
-
-
-
- {timeSince(new Date(item.time))}
-
-
- –{' '}
-
- {item.track.title}
-
-
-
- ))}
-
-
-
-
- Prev
-
-
- Next
-
-
-
- )}
- />
- );
+
+
+ )}
+ />
+ );
}
diff --git a/client/app/routes/Charts/TrackChart.tsx b/client/app/routes/Charts/TrackChart.tsx
index 450d022..23c1531 100644
--- a/client/app/routes/Charts/TrackChart.tsx
+++ b/client/app/routes/Charts/TrackChart.tsx
@@ -1,12 +1,12 @@
import TopItemList from "~/components/TopItemList";
import ChartLayout from "./ChartLayout";
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
-import { type Track, type PaginatedResponse, type Ranked } from "api/api";
+import { type Album, type PaginatedResponse } from "api/api";
export async function clientLoader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = url.searchParams.get("page") || "0";
- url.searchParams.set("page", page);
+ url.searchParams.set('page', page)
const res = await fetch(
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
@@ -15,14 +15,12 @@ export async function clientLoader({ request }: LoaderFunctionArgs) {
throw new Response("Failed to load top tracks", { status: 500 });
}
- const top_tracks: PaginatedResponse = await res.json();
+ const top_tracks: PaginatedResponse = await res.json();
return { top_tracks };
}
export default function TrackChart() {
- const { top_tracks: initialData } = useLoaderData<{
- top_tracks: PaginatedResponse>;
- }>();
+ const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse }>();
return (
(
-
-
-
- Prev
-
-
- Next
-
-
+
+
+
+ Prev
+
+
+ Next
+
+
Prev
-
+
Next
diff --git a/client/app/routes/Home.tsx b/client/app/routes/Home.tsx
index 597c563..04359a2 100644
--- a/client/app/routes/Home.tsx
+++ b/client/app/routes/Home.tsx
@@ -10,30 +10,30 @@ import PeriodSelector from "~/components/PeriodSelector";
import { useAppContext } from "~/providers/AppProvider";
export function meta({}: Route.MetaArgs) {
- return [{ title: "Koito" }, { name: "description", content: "Koito" }];
+ return [
+ { title: "Koito" },
+ { name: "description", content: "Koito" },
+ ];
}
export default function Home() {
- const [period, setPeriod] = useState("week");
+ const [period, setPeriod] = useState('week')
const { homeItems } = useAppContext();
return (
-
-
-
+
+
+
-
diff --git a/client/app/routes/MediaItems/Album.tsx b/client/app/routes/MediaItems/Album.tsx
index e6f413e..654fc9e 100644
--- a/client/app/routes/MediaItems/Album.tsx
+++ b/client/app/routes/MediaItems/Album.tsx
@@ -6,8 +6,6 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
-import { timeListenedString } from "~/utils/utils";
-import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
@@ -20,62 +18,40 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Album() {
const album = useLoaderData() as Album;
- const [period, setPeriod] = useState("week");
+ const [period, setPeriod] = useState('week')
- console.log(album);
+ console.log(album)
return (
-
{
- r.artists = [];
- r.tracks = [];
- for (let i = 0; i < r.albums.length; i++) {
- if (r.albums[i].id === id) {
- delete r.albums[i];
- }
- }
- return r;
- }}
- subContent={
-
- {album.listen_count !== 0 && (
-
- {album.listen_count} play{album.listen_count > 1 ? "s" : ""}
-
- )}
- {album.time_listened !== 0 && (
-
- {timeListenedString(album.time_listened)}
-
- )}
- {album.first_listen > 0 && (
-
- Listening since{" "}
- {new Date(album.first_listen * 1000).toLocaleDateString()}
-
- )}
-
- }
+ {
+ r.artists = []
+ r.tracks = []
+ for (let i = 0; i < r.albums.length; i ++) {
+ if (r.albums[i].id === id) {
+ delete r.albums[i]
+ }
+ }
+ return r
+ }}
+ subContent={<>
+ {album.listen_count && {album.listen_count} play{ album.listen_count > 1 ? 's' : ''}
}
+ >}
>
-
-
-
-
-
);
}
diff --git a/client/app/routes/MediaItems/Artist.tsx b/client/app/routes/MediaItems/Artist.tsx
index a23e4cd..b742f56 100644
--- a/client/app/routes/MediaItems/Artist.tsx
+++ b/client/app/routes/MediaItems/Artist.tsx
@@ -7,8 +7,6 @@ import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ArtistAlbums from "~/components/ArtistAlbums";
import ActivityGrid from "~/components/ActivityGrid";
-import { timeListenedString } from "~/utils/utils";
-import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
@@ -21,70 +19,48 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
export default function Artist() {
const artist = useLoaderData() as Artist;
- const [period, setPeriod] = useState("week");
+ const [period, setPeriod] = useState('week')
// remove canonical name from alias list
- console.log(artist.aliases);
+ console.log(artist.aliases)
let index = artist.aliases.indexOf(artist.name);
if (index !== -1) {
artist.aliases.splice(index, 1);
}
return (
-
{
- r.albums = [];
- r.tracks = [];
- for (let i = 0; i < r.artists.length; i++) {
- if (r.artists[i].id === id) {
- delete r.artists[i];
- }
- }
- return r;
- }}
- subContent={
-
- {artist.listen_count && (
-
- {artist.listen_count} play{artist.listen_count > 1 ? "s" : ""}
-
- )}
- {artist.time_listened !== 0 && (
-
- {timeListenedString(artist.time_listened)}
-
- )}
- {artist.first_listen > 0 && (
-
- Listening since{" "}
- {new Date(artist.first_listen * 1000).toLocaleDateString()}
-
- )}
-
- }
+ {
+ r.albums = []
+ r.tracks = []
+ for (let i = 0; i < r.artists.length; i ++) {
+ if (r.artists[i].id === id) {
+ delete r.artists[i]
+ }
+ }
+ return r
+ }}
+ subContent={<>
+ {artist.listen_count && {artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}
}
+ >}
>
-
-
-
);
}
diff --git a/client/app/routes/MediaItems/MediaLayout.tsx b/client/app/routes/MediaItems/MediaLayout.tsx
index eaf100b..18a8b78 100644
--- a/client/app/routes/MediaItems/MediaLayout.tsx
+++ b/client/app/routes/MediaItems/MediaLayout.tsx
@@ -2,208 +2,96 @@ 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, Plus, Trash } from "lucide-react";
+import { Edit, ImageIcon, Merge, 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";
-import MbzIcon from "~/components/icons/MbzIcon";
-import { Link } from "react-router";
+import RenameModal from "~/components/modals/RenameModal";
-export type MergeFunc = (
- from: number,
- to: number,
- replaceImage: boolean
-) => Promise
;
-export type MergeSearchCleanerFunc = (
- r: SearchResponse,
- id: number
-) => SearchResponse;
+export type MergeFunc = (from: number, to: number) => Promise
+export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
interface Props {
- type: "Track" | "Album" | "Artist";
- title: string;
- img: string;
- id: number;
- rank: number;
- musicbrainzId: string;
- imgItemId: number;
- mergeFunc: MergeFunc;
- mergeCleanerFunc: MergeSearchCleanerFunc;
- children: React.ReactNode;
- subContent: React.ReactNode;
+ type: "Track" | "Album" | "Artist"
+ title: string
+ img: string
+ id: number
+ musicbrainzId: string
+ imgItemId: number
+ mergeFunc: MergeFunc
+ mergeCleanerFunc: MergeSearchCleanerFunc
+ children: React.ReactNode
+ subContent: React.ReactNode
}
export default function MediaLayout(props: Props) {
- const [bgColor, setBgColor] = useState("(--color-bg)");
- const [mergeModalOpen, setMergeModalOpen] = useState(false);
- const [deleteModalOpen, setDeleteModalOpen] = useState(false);
- const [imageModalOpen, setImageModalOpen] = useState(false);
- const [renameModalOpen, setRenameModalOpen] = useState(false);
- const [addListenModalOpen, setAddListenModalOpen] = useState(false);
- const { user } = useAppContext();
+ const [bgColor, setBgColor] = useState("(--color-bg)");
+ const [mergeModalOpen, setMergeModalOpen] = useState(false);
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [imageModalOpen, setImageModalOpen] = useState(false);
+ const [renameModalOpen, setRenameModalOpen] = useState(false);
+ const { user } = useAppContext();
- useEffect(() => {
- average(imageUrl(props.img, "small"), { amount: 1 }).then((color) => {
- setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
- });
- }, [props.img]);
+ useEffect(() => {
+ average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
+ setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
+ });
+ }, [props.img]);
- const replaceImageCallback = () => {
- window.location.reload();
- };
+ const replaceImageCallback = () => {
+ window.location.reload()
+ }
- const title = `${props.title} - Koito`;
+ const title = `${props.title} - Koito`
- const mobileIconSize = 22;
- const normalIconSize = 30;
+ const mobileIconSize = 22
+ const normalIconSize = 30
- let vw = Math.max(
- document.documentElement.clientWidth || 0,
- window.innerWidth || 0
- );
+ let vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0)
- let iconSize = vw > 768 ? normalIconSize : mobileIconSize;
+ let iconSize = vw > 768 ? normalIconSize : mobileIconSize
- console.log("MBZ:", props.musicbrainzId);
-
- return (
-
-
- {title}
-
-
-
-
-
-
-
-
-
{props.type}
-
-
- {props.title}
-
- {" "}
- #{props.rank}
-
-
+ return (
+
+
+ {title}
+
+
+
+
+
+
+
+
+
{props.type}
+ {props.title}
+ {props.subContent}
+
+ { user &&
+
+ setRenameModalOpen(true)}>
+ setImageModalOpen(true)}>
+ setMergeModalOpen(true)}>
+ setDeleteModalOpen(true)}>
+
+
+
+
+
+ }
+
+ {props.children}
- {props.subContent}
-
-
- {props.musicbrainzId && (
-
-
-
- )}
- {user && (
- <>
- {props.type === "Track" && (
- <>
-
setAddListenModalOpen(true)}
- >
-
-
-
- >
- )}
-
setRenameModalOpen(true)}
- >
-
-
-
- {props.type !== "Track" && (
-
setImageModalOpen(true)}
- >
-
-
- )}
-
setMergeModalOpen(true)}
- >
-
-
-
setDeleteModalOpen(true)}
- >
-
-
-
-
-
-
- >
- )}
-
-
- {props.children}
-
-
- );
+
+ );
}
diff --git a/client/app/routes/MediaItems/Track.tsx b/client/app/routes/MediaItems/Track.tsx
index 6b6690e..bd08a8f 100644
--- a/client/app/routes/MediaItems/Track.tsx
+++ b/client/app/routes/MediaItems/Track.tsx
@@ -5,86 +5,55 @@ import LastPlays from "~/components/LastPlays";
import PeriodSelector from "~/components/PeriodSelector";
import MediaLayout from "./MediaLayout";
import ActivityGrid from "~/components/ActivityGrid";
-import { timeListenedString } from "~/utils/utils";
-import InterestGraph from "~/components/InterestGraph";
export async function clientLoader({ params }: LoaderFunctionArgs) {
- let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
- if (!res.ok) {
- throw new Response("Failed to load track", { status: res.status });
- }
- const track: Track = await res.json();
- res = await fetch(`/apis/web/v1/album?id=${track.album_id}`);
- if (!res.ok) {
- throw new Response("Failed to load album for track", {
- status: res.status,
- });
- }
- const album: Album = await res.json();
- return { track: track, album: album };
+ let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
+ if (!res.ok) {
+ throw new Response("Failed to load track", { status: res.status });
+ }
+ const track: Track = await res.json();
+ res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
+ if (!res.ok) {
+ throw new Response("Failed to load album for track", { status: res.status })
+ }
+ const album: Album = await res.json()
+ return {track: track, album: album};
}
export default function Track() {
- const { track, album } = useLoaderData();
- const [period, setPeriod] = useState("week");
+ const { track, album } = useLoaderData();
+ const [period, setPeriod] = useState('week')
- return (
-
{
- r.albums = [];
- r.artists = [];
- for (let i = 0; i < r.tracks.length; i++) {
- if (r.tracks[i].id === id) {
- delete r.tracks[i];
- }
- }
- return r;
- }}
- subContent={
-
-
- Appears on{" "}
-
- {album.title}
-
-
- {track.listen_count !== 0 && (
-
- {track.listen_count} play{track.listen_count > 1 ? "s" : ""}
-
- )}
- {track.time_listened !== 0 && (
-
- {timeListenedString(track.time_listened)}
-
- )}
- {track.first_listen > 0 && (
-
- Listening since{" "}
- {new Date(track.first_listen * 1000).toLocaleDateString()}
-
- )}
-
- }
- >
-
-
-
- );
+ return (
+
{
+ r.albums = []
+ r.artists = []
+ for (let i = 0; i < r.tracks.length; i ++) {
+ if (r.tracks[i].id === id) {
+ delete r.tracks[i]
+ }
+ }
+ return r
+ }}
+ subContent={
+
appears on {album.title}
+ {track.listen_count &&
{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}
}
+
}
+ >
+
+
+
+ )
}
diff --git a/client/app/routes/RewindPage.tsx b/client/app/routes/RewindPage.tsx
deleted file mode 100644
index ad92497..0000000
--- a/client/app/routes/RewindPage.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import Rewind from "~/components/rewind/Rewind";
-import type { Route } from "./+types/Home";
-import { imageUrl, type RewindStats } from "api/api";
-import { useEffect, useState } from "react";
-import type { LoaderFunctionArgs } from "react-router";
-import { useLoaderData } from "react-router";
-import { getRewindParams, getRewindYear } from "~/utils/utils";
-import { useNavigate } from "react-router";
-import { average } from "color.js";
-import { ChevronLeft, ChevronRight } from "lucide-react";
-
-// TODO: Bind year and month selectors to what data actually exists
-
-const months = [
- "Full Year",
- "January",
- "February",
- "March",
- "April",
- "May",
- "June",
- "July",
- "August",
- "September",
- "October",
- "November",
- "December",
-];
-
-export async function clientLoader({ request }: LoaderFunctionArgs) {
- const url = new URL(request.url);
- const year = parseInt(
- url.searchParams.get("year") || getRewindParams().year.toString()
- );
- const month = parseInt(
- url.searchParams.get("month") || getRewindParams().month.toString()
- );
-
- const res = await fetch(`/apis/web/v1/summary?year=${year}&month=${month}`);
- if (!res.ok) {
- throw new Response("Failed to load summary", { status: 500 });
- }
-
- const stats: RewindStats = await res.json();
- stats.title = `Your ${month === 0 ? "" : months[month]} ${year} Rewind`;
- return { stats };
-}
-
-export default function RewindPage() {
- const currentParams = new URLSearchParams(location.search);
- let year = parseInt(
- currentParams.get("year") || getRewindParams().year.toString()
- );
- let month = parseInt(
- currentParams.get("month") || getRewindParams().month.toString()
- );
- const navigate = useNavigate();
- const [showTime, setShowTime] = useState(false);
- const { stats: stats } = useLoaderData<{ stats: RewindStats }>();
-
- const [bgColor, setBgColor] = useState
("(--color-bg)");
-
- useEffect(() => {
- if (!stats.top_artists[0]) return;
-
- const img = (stats.top_artists[0] as any)?.item.image;
- if (!img) return;
-
- average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
- setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
- });
- }, [stats]);
-
- const updateParams = (params: Record) => {
- const nextParams = new URLSearchParams(location.search);
-
- for (const key in params) {
- const val = params[key];
-
- if (val !== null) {
- nextParams.set(key, val);
- }
- }
-
- const url = `/rewind?${nextParams.toString()}`;
-
- navigate(url, { replace: false });
- };
-
- const navigateMonth = (direction: "prev" | "next") => {
- if (direction === "next") {
- if (month === 12) {
- month = 0;
- } else {
- month += 1;
- }
- } else {
- if (month === 0) {
- month = 12;
- } else {
- month -= 1;
- }
- }
- console.log(`Month: ${month}`);
-
- updateParams({
- year: year.toString(),
- month: month.toString(),
- });
- };
- const navigateYear = (direction: "prev" | "next") => {
- if (direction === "next") {
- year += 1;
- } else {
- year -= 1;
- }
-
- updateParams({
- year: year.toString(),
- month: month.toString(),
- });
- };
-
- const pgTitle = `${stats.title} - Koito`;
-
- return (
-
-
-
{pgTitle}
-
-
-
-
-
-
-
navigateMonth("prev")}
- className="p-2 disabled:text-(--color-fg-tertiary)"
- disabled={
- // Previous month is in the future OR
- new Date(year, month - 2) > new Date() ||
- // We are looking at current year and prev would take us to full year
- (new Date().getFullYear() === year && month === 1)
- }
- >
-
-
-
- {months[month]}
-
-
navigateMonth("next")}
- className="p-2 disabled:text-(--color-fg-tertiary)"
- disabled={
- // next month is current or future month and
- month >= new Date().getMonth() &&
- // we are looking at current (or future) year
- year >= new Date().getFullYear()
- }
- >
-
-
-
-
-
navigateYear("prev")}
- className="p-2 disabled:text-(--color-fg-tertiary)"
- disabled={new Date(year - 1, month) > new Date()}
- >
-
-
-
{year}
-
navigateYear("next")}
- className="p-2 disabled:text-(--color-fg-tertiary)"
- disabled={
- // Next year date is in the future OR
- new Date(year + 1, month - 1) > new Date() ||
- // Next year date is current full year OR
- (month == 0 && new Date().getFullYear() === year + 1) ||
- // Next year date is current month
- (new Date().getMonth() === month - 1 &&
- new Date().getFullYear() === year + 1)
- }
- >
-
-
-
-
-
- Show time listened?
- setShowTime(!showTime)}
- >
-
-
- {stats !== undefined && (
-
- )}
-
-
-
- );
-}
diff --git a/client/app/routes/Root.tsx b/client/app/routes/Root.tsx
new file mode 100644
index 0000000..9672dd8
--- /dev/null
+++ b/client/app/routes/Root.tsx
@@ -0,0 +1,43 @@
+import { isRouteErrorResponse, Outlet } from "react-router";
+import Footer from "~/components/Footer";
+import type { Route } from "../+types/root";
+
+export default function Root() {
+
+ return (
+
+
+
+
+ )
+}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ let message = "Oops!";
+ let details = "An unexpected error occurred.";
+ let stack: string | undefined;
+
+ if (isRouteErrorResponse(error)) {
+ message = error.status === 404 ? "404" : "Error";
+ details =
+ error.status === 404
+ ? "The requested page could not be found."
+ : error.statusText || details;
+ } else if (import.meta.env.DEV && error && error instanceof Error) {
+ details = error.message;
+ stack = error.stack;
+ }
+
+ return (
+
+ {message}
+ {details}
+ {stack && (
+
+ {stack}
+
+ )}
+
+ );
+ }
+
\ No newline at end of file
diff --git a/client/app/routes/ThemeHelper.tsx b/client/app/routes/ThemeHelper.tsx
index fc5b7e4..7c65c6a 100644
--- a/client/app/routes/ThemeHelper.tsx
+++ b/client/app/routes/ThemeHelper.tsx
@@ -7,40 +7,8 @@ import LastPlays from "~/components/LastPlays"
import TopAlbums from "~/components/TopAlbums"
import TopArtists from "~/components/TopArtists"
import TopTracks from "~/components/TopTracks"
-import { useTheme } from "~/hooks/useTheme"
-import { themes, type Theme } from "~/styles/themes.css"
export default function ThemeHelper() {
- 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 [custom, setCustom] = useState(JSON.stringify(initialTheme, null, " "))
- const { setCustomTheme } = useTheme()
-
- const handleCustomTheme = () => {
- console.log(custom)
- try {
- const theme = JSON.parse(custom) as Theme
- console.log(theme)
- setCustomTheme(theme)
- } catch(err) {
- console.log(err)
- }
- }
const homeItems = 3
@@ -56,49 +24,43 @@ export default function ThemeHelper() {
-