From d4ac96f78079bc1f2bbacc18bbda44f0ccdee437 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:44:55 -0500 Subject: [PATCH] feat: Rewind (#116) * wip * chore: update counts to allow unix timeframe * feat: add db functions for counting new items * wip: endpoint working * wip * wip: initial ui done * add header, adjust ui * add time listened toggle * fix layout, year param * param fixes --- Makefile | 8 +- assets/Jost-Regular.ttf | Bin 0 -> 61524 bytes assets/LeagueSpartan-Medium.ttf | Bin 0 -> 57540 bytes client/api/api.ts | 33 ++ client/app/app.css | 102 +++--- client/app/components/ActivityGrid.tsx | 6 +- client/app/components/AlbumDisplay.tsx | 43 ++- client/app/components/AllTimeStats.tsx | 6 +- client/app/components/ArtistAlbums.tsx | 98 +++--- client/app/components/LastPlays.tsx | 8 +- client/app/components/TopAlbums.tsx | 8 +- client/app/components/TopArtists.tsx | 8 +- client/app/components/TopThreeAlbums.tsx | 67 ++-- client/app/components/TopTracks.tsx | 8 +- client/app/components/modals/Account.tsx | 216 ++++++------ .../app/components/modals/AddListenModal.tsx | 87 ++--- client/app/components/modals/ApiKeysModal.tsx | 325 +++++++++--------- client/app/components/modals/DeleteModal.tsx | 67 ++-- .../components/modals/EditModal/EditModal.tsx | 2 +- .../modals/EditModal/SetPrimaryArtist.tsx | 174 +++++----- .../modals/EditModal/SetVariousArtist.tsx | 139 ++++---- client/app/components/modals/ExportModal.tsx | 78 +++-- .../components/modals/ImageReplaceModal.tsx | 2 +- client/app/components/modals/LoginForm.tsx | 123 ++++--- client/app/components/modals/MergeModal.tsx | 242 +++++++------ client/app/components/modals/SearchModal.tsx | 94 ++--- client/app/components/rewind/Rewind.tsx | 72 ++++ .../app/components/rewind/RewindStatText.tsx | 32 ++ .../app/components/rewind/RewindTopItem.tsx | 55 +++ client/app/components/sidebar/Sidebar.tsx | 112 +++--- .../themeSwitcher/ThemeSwitcher.tsx | 4 +- client/app/routes.ts | 21 +- client/app/routes/RewindPage.tsx | 52 +++ client/app/utils/utils.ts | 12 +- db/queries/artist.sql | 23 +- db/queries/release.sql | 22 +- db/queries/track.sql | 13 +- engine/handlers/get_summary.go | 28 ++ engine/handlers/handlers.go | 88 ++++- engine/handlers/stats.go | 10 +- engine/import_test.go | 6 +- engine/routes.go | 1 + go.mod | 5 +- go.sum | 6 + internal/db/db.go | 30 +- internal/db/opts.go | 12 +- internal/db/period.go | 17 + internal/db/psql/album.go | 4 +- internal/db/psql/artist.go | 12 +- internal/db/psql/counts.go | 104 +++++- internal/db/psql/counts_test.go | 98 +++++- internal/db/psql/top_albums.go | 4 + internal/db/psql/top_artists.go | 4 + internal/db/psql/top_tracks.go | 4 + internal/db/psql/track.go | 4 +- internal/importer/listenbrainz.go | 8 +- internal/repository/artist.sql.go | 34 +- internal/repository/release.sql.go | 31 +- internal/repository/track.sql.go | 24 +- internal/summary/image.go | 186 ++++++++++ internal/summary/summary.go | 141 ++++++++ internal/summary/summary.png | Bin 0 -> 173390 bytes internal/summary/summary_test.go | 84 +++++ test_assets/default_img.webp | Bin 0 -> 4212 bytes 64 files changed, 2252 insertions(+), 1055 deletions(-) create mode 100644 assets/Jost-Regular.ttf create mode 100644 assets/LeagueSpartan-Medium.ttf create mode 100644 client/app/components/rewind/Rewind.tsx create mode 100644 client/app/components/rewind/RewindStatText.tsx create mode 100644 client/app/components/rewind/RewindTopItem.tsx create mode 100644 client/app/routes/RewindPage.tsx create mode 100644 engine/handlers/get_summary.go create mode 100644 internal/summary/image.go create mode 100644 internal/summary/summary.go create mode 100644 internal/summary/summary.png create mode 100644 internal/summary/summary_test.go create mode 100644 test_assets/default_img.webp diff --git a/Makefile b/Makefile index 82fbd89..fbca22e 100644 --- a/Makefile +++ b/Makefile @@ -27,10 +27,10 @@ postgres.remove: postgres.remove-scratch: docker stop koito-scratch && docker rm koito-scratch -api.debug: +api.debug: postgres.start KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5432?sslmode=disable go run cmd/api/main.go -api.scratch: +api.scratch: postgres.run-scratch KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir/scratch KOITO_DATABASE_URL=postgres://postgres:secret@localhost:5433?sslmode=disable go run cmd/api/main.go api.test: @@ -45,7 +45,7 @@ client.dev: docs.dev: cd docs && yarn dev -client.deps: +client.deps: cd client && yarn install client.build: client.deps @@ -53,4 +53,4 @@ client.build: client.deps test: api.test -build: api.build client.build \ No newline at end of file +build: api.build client.build diff --git a/assets/Jost-Regular.ttf b/assets/Jost-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..326956315310800bcc52ccac41c601778ee4a597 GIT binary patch literal 61524 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⩔Jh2|4aW>w9x++-9+bcilQVu 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 Zzb%so=58f>_-1io?zT!7XOJc|{{z~6RdoOW literal 0 HcmV?d00001 diff --git a/assets/LeagueSpartan-Medium.ttf b/assets/LeagueSpartan-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..c701d8861668a54b9b691abb237ea8df4aadff8d GIT binary patch literal 57540 zcmd>ncYIXE7XQrLySwSV5mGh@B%!AldMcej=q)6qhmc?riim&+NU>m7RK)Upc0o}Q zQ4tZbfQYE5h+sja$rD8cvitj&lukh{?bvSb4Fi%N8NP@BMs|ZKBH{r@3x_v7<2Pu%sjBVta&Er@be&Gx9a+< ztN#6FN>9e7k6>(8OHE~2MfCB6)rh|l@fkHBcvqQzM7TG?9cpH@%pHB}o&6XKMxO59 z)i;)xop{9~moY5@75HUF+1#081$!O*2s3L#*^J5)2SY|N<};o#OUcZ}=9Yf1{P+lC zo%S#maA;;z<;)uMjZ4Ar1%AsWZeO}yzX?Ic?RMr1%*54fJhQM0W}=YGpGI7andaIC zLYB$4!x!Fn_qdFQQWKY(qz6eif{8iRH7znTAt{WT_%AYNA_{hWBr88Y)wlPrOtU~v z#=i9VZ~@`3KKDwu9d14E?%{SPf@bg5PkHh5I4K12S^`QBaGWp_dSvqbVA{uTol`7IuiYCg$94WB|iAer1fFE)bVu+ZD@&xz>L(OHPk%fZp zC5DP&Vz?+0BSo>eN;HXP(IRFe7y654z1cuEnw7GdY(87g?qKWK(`*ad$@Z~B>^S?4 zYuuN&=LtNO_v1tOBwo#L<7@fn!V4015J{r97>Hc26D!1>;w|y9I4*wEG|g9QuO(=i zTAo&}6oJlkJn%bM%HyLq^UyLEQ! z=~n1g>{jAd=k|o#i*9eaedMmWcXlswf870J_pjZLyZ`1PJbXOHdsKML^ti_329Mi4 zKJg6o?ChEEIl^<2XSL@l&$~Sz@qEs6v*!-ay`Bd>k9nT;GJA!1#d~FV6?zqWm3Y;8 z&GuUCb+gw)UT=AQ==G)74_;@ygS@+XXL;v)kMN%CUE|&2y~ul&_d4&Vy*GJp_de?* ze0+Sued2vmd}jJ=^EvNp^7Z$P@a^iG>6`02%y+zRxo?B-wZ6-JZ}(m6`=sxSzHj>O z^4;(It?wz{^L{43Airq8yZs*V`_kXXzny;v{~rE5{R{mU_8!#=PDd6dVmjm7k z*d6eBz;^*>0&IcqfkA=Rz%GHAfq8*NffEBO17`-#54<68Ti~&vzCnY7#srlH)d$TD zS{w9k&?iA>g6zSb!J)x%!99X|1rG=w6k!~DY9hsB2_ zhxG{?7&b1fI;=TtVc5#ByTbkz_F~xEVf(_q4LcR~dpo_IU%Mgg#WKRz9*=k-;*E&i5eFkqMYKkGMTSPkMs|zLj?9lNikud?FmhGo z-I0$(J{S2xX4ba=eO z&JI6x?Amc=$E!Qu)p29TFFGFW_-jX7yk~r1yfwaCe0F?E{NnhR<4?rf69N*_6LJ!U zCES#-K4EXdnZ&@v_{6Nl#>C}`J3965^i-#JJLh-)SLa{4M0LsUGO|lWm$_YT?{cWi ziLPC`&glAjx4>?3-72~*>ULwdXS#=U@6)}!`;zV(x*zG$u18Ui=X)GT;z{mF{z;vZ zGLpt6Els*NX=BnmNe7aCOAbsJAmnEq=#H43c(~ux?I6_-3{U_S}bgvmPv) zE#S#~AYZ_5fxUZ(uje1|-Fy$<%lGk5`F{QdKfr(B$3=V5O{9uUF+pq*?}&HBSDLPQ z!TJTj{&mn2weDJS)Y7Q6QNLS*tl`!uYpgZinq=*1&9}B#AF@7b{kQcA>oeBptuI

#9sVb%z%RmzjC6?JQWwP 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 literal 0 HcmV?d00001 diff --git a/client/api/api.ts b/client/api/api.ts index 270c90b..27d631a 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -15,6 +15,14 @@ interface getActivityArgs { album_id: number; track_id: number; } +interface timeframe { + week?: number; + month?: number; + year?: number; + from?: number; + to?: number; + period?: string; +} async function handleJson(r: Response): Promise { if (!r.ok) { @@ -281,6 +289,13 @@ function getNowPlaying(): Promise { return fetch("/apis/web/v1/now-playing").then((r) => r.json()); } +async function getRewindStats(args: timeframe): Promise { + const r = await fetch( + `/apis/web/v1/summary?week=${args.week}&month=${args.month}&year=${args.year}&from=${args.from}&to=${args.to}` + ); + return handleJson(r); +} + export { getLastListens, getTopTracks, @@ -312,6 +327,7 @@ export { getExport, submitListen, getNowPlaying, + getRewindStats, }; type Track = { id: number; @@ -404,6 +420,22 @@ type NowPlaying = { currently_playing: boolean; track: Track; }; +type RewindStats = { + title: string; + top_artists: Artist[]; + top_albums: Album[]; + top_tracks: Track[]; + minutes_listened: number; + avg_minutes_listened_per_day: number; + plays: number; + avg_plays_per_day: number; + unique_tracks: number; + unique_albums: number; + unique_artists: number; + new_tracks: number; + new_albums: number; + new_artists: number; +}; export type { getItemsArgs, @@ -422,4 +454,5 @@ export type { Config, NowPlaying, Stats, + RewindStats, }; diff --git a/client/app/app.css b/client/app/app.css index 143572c..217e955 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -1,59 +1,56 @@ -@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap'); +@import url("https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap"); @import "tailwindcss"; @theme { --font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; - --animate-fade-in-scale: fade-in-scale 0.1s ease forwards; - --animate-fade-out-scale: fade-out-scale 0.1s ease forwards; - - @keyframes fade-in-scale { - 0% { - opacity: 0; - transform: scale(0.95); - } - 100% { - opacity: 1; - transform: scale(1); - } + --animate-fade-in-scale: fade-in-scale 0.1s ease forwards; + --animate-fade-out-scale: fade-out-scale 0.1s ease forwards; + + @keyframes fade-in-scale { + 0% { + opacity: 0; + transform: scale(0.95); } - - @keyframes fade-out-scale { - 0% { - opacity: 1; - transform: scale(1); - } - 100% { - opacity: 0; - transform: scale(0.95); - } + 100% { + opacity: 1; + transform: scale(1); } - - --animate-fade-in: fade-in 0.1s ease forwards; - --animate-fade-out: fade-out 0.1s ease forwards; - - @keyframes fade-in { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } + } + + @keyframes fade-out-scale { + 0% { + opacity: 1; + transform: scale(1); } - - @keyframes fade-out { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } + 100% { + opacity: 0; + transform: scale(0.95); } - + } + + --animate-fade-in: fade-in 0.1s ease forwards; + --animate-fade-out: fade-out 0.1s ease forwards; + + @keyframes fade-in { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + + @keyframes fade-out { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } + } } - - :root { --header-xl: 36px; --header-lg: 28px; @@ -66,7 +63,7 @@ @media (min-width: 60rem) { :root { --header-xl: 78px; - --header-lg: 28px; + --header-lg: 36px; --header-md: 22px; --header-sm: 16px; --header-xl-weight: 600; @@ -74,7 +71,6 @@ } } - html, body { background-color: var(--color-bg); @@ -106,16 +102,18 @@ h1 { h2 { font-family: "League Spartan"; font-weight: var(--header-weight); - font-size: var(--header-md); - margin-bottom: 0.5em; + font-size: var(--header-lg); } h3 { font-family: "League Spartan"; - font-size: var(--header-sm); font-weight: var(--header-weight); + font-size: var(--header-md); + margin-bottom: 0.5em; } h4 { - font-size: var(--header-md); + font-family: "League Spartan"; + font-size: var(--header-sm); + font-weight: var(--header-weight); } .header-font { font-family: "League Spartan"; @@ -197,4 +195,4 @@ button.default[disabled]:hover { } button.default:hover { color: var(--color-fg-secondary); -} \ No newline at end of file +} diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 1404503..7706694 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -69,14 +69,14 @@ export default function ActivityGrid({ if (isPending) { return (

-

Activity

+

Activity

Loading...

); } else if (isError) { return (
-

Activity

+

Activity

Error: {error.message}

); @@ -148,7 +148,7 @@ export default function ActivityGrid({ return (
-

Activity

+

Activity

{configurable ? ( -
- - {album.title} - -
-
- -

{album.title}

- -

{album.listen_count} plays

-
-
- ) -} \ No newline at end of file + return ( +
+
+ + {album.title} + +
+
+ +

{album.title}

+ +

{album.listen_count} plays

+
+
+ ); +} diff --git a/client/app/components/AllTimeStats.tsx b/client/app/components/AllTimeStats.tsx index 342b954..8f1bc40 100644 --- a/client/app/components/AllTimeStats.tsx +++ b/client/app/components/AllTimeStats.tsx @@ -10,7 +10,7 @@ export default function AllTimeStats() { if (isPending) { return (
-

All Time Stats

+

All Time Stats

Loading...

); @@ -18,7 +18,7 @@ export default function AllTimeStats() { return ( <>
-

All Time Stats

+

All Time Stats

Error: {error.message}

@@ -29,7 +29,7 @@ export default function AllTimeStats() { return (
-

All Time Stats

+

All Time Stats

getTopAlbums(queryKey[1] as getItemsArgs), - }) - - if (isPending) { - return ( -
-

Albums From This Artist

-

Loading...

-
- ) - } - if (isError) { - return ( -
-

Albums From This Artist

-

Error:{error.message}

-
- ) - } +export default function ArtistAlbums({ artistId, name, period }: Props) { + 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), + }); + if (isPending) { return ( -
-

Albums featuring {name}

-
- {data.items.map((item) => ( - - {item.title} -
-

{item.title}

-

{item.listen_count} play{item.listen_count > 1 ? 's' : ''}

-
- - ))} -
-
- ) -} \ No newline at end of file +
+

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.title}

+

+ {item.listen_count} play{item.listen_count > 1 ? "s" : ""} +

+
+ + ))} +
+
+ ); +} diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index a2fd3a3..9a719d0 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -63,14 +63,14 @@ export default function LastPlays(props: Props) { if (isPending) { return (
-

Last Played

+

Last Played

Loading...

); } else if (isError) { return (
-

Last Played

+

Last Played

Error: {error.message}

); @@ -85,9 +85,9 @@ export default function LastPlays(props: Props) { return (
-

+

Last Played -

+ {props.showNowPlaying && npData && npData.currently_playing && ( diff --git a/client/app/components/TopAlbums.tsx b/client/app/components/TopAlbums.tsx index d4730e2..052e76a 100644 --- a/client/app/components/TopAlbums.tsx +++ b/client/app/components/TopAlbums.tsx @@ -33,14 +33,14 @@ export default function TopAlbums(props: Props) { if (isPending) { return (
-

Top Albums

+

Top Albums

Loading...

); } else if (isError) { return (
-

Top Albums

+

Top Albums

Error: {error.message}

); @@ -48,7 +48,7 @@ export default function TopAlbums(props: Props) { return (
-

+

Top Albums -

+
{data.items.length < 1 ? "Nothing to show" : ""} diff --git a/client/app/components/TopArtists.tsx b/client/app/components/TopArtists.tsx index fca9456..c169448 100644 --- a/client/app/components/TopArtists.tsx +++ b/client/app/components/TopArtists.tsx @@ -24,14 +24,14 @@ export default function TopArtists(props: Props) { if (isPending) { return (
-

Top Artists

+

Top Artists

Loading...

); } else if (isError) { return (
-

Top Artists

+

Top Artists

Error: {error.message}

); @@ -39,11 +39,11 @@ export default function TopArtists(props: Props) { return (
-

+

Top Artists -

+
{data.items.length < 1 ? "Nothing to show" : ""} diff --git a/client/app/components/TopThreeAlbums.tsx b/client/app/components/TopThreeAlbums.tsx index c5136e4..2a9503d 100644 --- a/client/app/components/TopThreeAlbums.tsx +++ b/client/app/components/TopThreeAlbums.tsx @@ -1,38 +1,43 @@ -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), + }); - 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}

; + } - if (isPending) { - return

Loading...

- } - if (isError) { - return

Error:{error.message}

- } + console.log(data); - console.log(data) - - return ( -
- {!props.hideTitle &&

Top Three Albums

} -
- {data.items.map((item, index) => ( - - ))} -
-
- ) -} \ No newline at end of file + return ( +
+ {!props.hideTitle &&

Top Three Albums

} +
+ {data.items.map((item, index) => ( + + ))} +
+
+ ); +} diff --git a/client/app/components/TopTracks.tsx b/client/app/components/TopTracks.tsx index 2e1c6fb..85fef79 100644 --- a/client/app/components/TopTracks.tsx +++ b/client/app/components/TopTracks.tsx @@ -31,14 +31,14 @@ const TopTracks = (props: Props) => { if (isPending) { return (
-

Top Tracks

+

Top Tracks

Loading...

); } else if (isError) { return (
-

Top Tracks

+

Top Tracks

Error: {error.message}

); @@ -51,11 +51,11 @@ const TopTracks = (props: Props) => { return (
-

+

Top Tracks -

+
{data.items.length < 1 ? "Nothing to show" : ""} diff --git a/client/app/components/modals/Account.tsx b/client/app/components/modals/Account.tsx index 06d540e..562b53d 100644 --- a/client/app/components/modals/Account.tsx +++ b/client/app/components/modals/Account.tsx @@ -1,106 +1,124 @@ -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)); } - 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) + }) + .catch((err) => setError(err)); + setLoading(false); + }; + const updateHandler = () => { + setError(""); + setSuccess(""); + if (password != "" && confirmPw === "") { + setError("confirm your new password before submitting"); + return; } + 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 -
-

Update User

-
e.preventDefault()} className="flex flex-col gap-4"> -
- setUsername(e.target.value)} - /> -
-
- Submit -
- -
e.preventDefault()} className="flex flex-col gap-4"> -
- setPassword(e.target.value)} - /> - setConfirmPw(e.target.value)} - /> -
-
- Submit -
- - {success != "" &&

{success}

} - {error != "" &&

{error}

} + return ( + <> +

Account

+
+
+

+ You're logged in as {user?.username} +

+ + Logout +
- - ) -} \ No newline at end of file +

Update User

+
e.preventDefault()} + className="flex flex-col gap-4" + > +
+ setUsername(e.target.value)} + /> +
+
+ + Submit + +
+ +
e.preventDefault()} + className="flex flex-col gap-4" + > +
+ setPassword(e.target.value)} + /> + setConfirmPw(e.target.value)} + /> +
+
+ + Submit + +
+ + {success != "" &&

{success}

} + {error != "" &&

{error}

} +
+ + ); +} diff --git a/client/app/components/modals/AddListenModal.tsx b/client/app/components/modals/AddListenModal.tsx index 2776d3e..4fda1b3 100644 --- a/client/app/components/modals/AddListenModal.tsx +++ b/client/app/components/modals/AddListenModal.tsx @@ -5,53 +5,56 @@ import { submitListen } from "api/api"; import { useNavigate } from "react-router"; interface Props { - open: boolean - setOpen: Function - trackid: number + 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 [ts, setTS] = useState(new Date()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const navigate = useNavigate(); - const close = () => { - setOpen(false) - } + 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 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())}`; - }; + 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}

-
-
- ) + 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 a4bd822..c205464 100644 --- a/client/app/components/modals/ApiKeysModal.tsx +++ b/client/app/components/modals/ApiKeysModal.tsx @@ -5,172 +5,183 @@ 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(); - }, + 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, }); - useEffect(() => { - if (data) { - setDisplayData(data) - } - }, [data]) + setTimeout(() => setCopied(null), 1500); + }; - if (isError) { - return ( -

Error: {error.message}

- ) + 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 (isPending) { - return ( -

Loading...

- ) + 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); + }; - 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) - } + 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 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}`} -
- - handleDeleteApiKey(v.id)} confirm> -
- ))} -
- setInput(e.target.value)} - /> - Create + 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}`}
- {err &&

{err}

} - {copied?.visible && ( -
- Copied! -
- )} + + handleDeleteApiKey(v.id)} + confirm + > + + +
+ ))} +
+ setInput(e.target.value)} + /> + + Create +
-
- ) -} \ No newline at end of file + {err &&

{err}

} + {copied?.visible && ( +
+ Copied! +
+ )} +
+
+ ); +} diff --git a/client/app/components/modals/DeleteModal.tsx b/client/app/components/modals/DeleteModal.tsx index 98304ad..06bfdaf 100644 --- a/client/app/components/modals/DeleteModal.tsx +++ b/client/app/components/modals/DeleteModal.tsx @@ -1,40 +1,41 @@ -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('/') - } 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!

-
- Yes, Delete It -
-
- ) -} \ No newline at end of file + return ( + setOpen(false)}> +

Delete "{title}"?

+

This action is irreversible!

+
+ + Yes, Delete It + +
+
+ ); +} diff --git a/client/app/components/modals/EditModal/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx index 971fc9d..cbced25 100644 --- a/client/app/components/modals/EditModal/EditModal.tsx +++ b/client/app/components/modals/EditModal/EditModal.tsx @@ -108,7 +108,7 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
-

Alias Manager

+

Alias Manager

{displayData.map((v) => (
diff --git a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx index b96536f..e91b083 100644 --- a/client/app/components/modals/EditModal/SetPrimaryArtist.tsx +++ b/client/app/components/modals/EditModal/SetPrimaryArtist.tsx @@ -1,99 +1,99 @@ import { useQuery } from "@tanstack/react-query"; import { getAlbum, type Artist } from "api/api"; -import { useEffect, useState } from "react" +import { useEffect, useState } from "react"; interface Props { - id: number - type: string + 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; - }, - }); + const [err, setErr] = useState(""); + const [primary, setPrimary] = useState(); + const [success, setSuccess] = useState(""); - useEffect(() => { - if (data) { - for (let a of data) { - if (a.is_primary) { - setPrimary(a) - break - } - } + 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...

- ) + } } + }, [data]); - 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" + 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); - } - } - }} - > - - {data.map((a) => ( - - ))} - - {err &&

{err}

} - {success &&

{success}

} -
-
- ); -} \ No newline at end of file + }} + > + + {data.map((a) => ( + + ))} + + {err &&

{err}

} + {success &&

{success}

} +
+
+ ); +} diff --git a/client/app/components/modals/EditModal/SetVariousArtist.tsx b/client/app/components/modals/EditModal/SetVariousArtist.tsx index c35f332..bf9e3d3 100644 --- a/client/app/components/modals/EditModal/SetVariousArtist.tsx +++ b/client/app/components/modals/EditModal/SetVariousArtist.tsx @@ -1,80 +1,77 @@ import { useQuery } from "@tanstack/react-query"; import { getAlbum } from "api/api"; -import { useEffect, useState } from "react" +import { useEffect, useState } from "react"; interface Props { - id: number + 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); - }, + 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)); + } }); + }; - 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

-
- - {err &&

{err}

} - {success &&

{success}

} -
-
- ) -} \ No newline at end of file + return ( +
+

Mark as Various Artists

+
+ + {err &&

{err}

} + {success &&

{success}

} +
+
+ ); +} diff --git a/client/app/components/modals/ExportModal.tsx b/client/app/components/modals/ExportModal.tsx index 25c8ddf..d83d7d4 100644 --- a/client/app/components/modals/ExportModal.tsx +++ b/client/app/components/modals/ExportModal.tsx @@ -3,43 +3,45 @@ import { AsyncButton } from "../AsyncButton"; import { getExport } from "api/api"; export default function ExportModal() { - const [loading, setLoading] = useState(false) - const [error, setError] = useState('') + 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) - }) - } + 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}

} -
- ) -} \ No newline at end of file + return ( +
+

Export

+ + Export Data + + {error &&

{error}

} +
+ ); +} diff --git a/client/app/components/modals/ImageReplaceModal.tsx b/client/app/components/modals/ImageReplaceModal.tsx index 53954f5..11319b7 100644 --- a/client/app/components/modals/ImageReplaceModal.tsx +++ b/client/app/components/modals/ImageReplaceModal.tsx @@ -50,7 +50,7 @@ export default function ImageReplaceModal({ return ( -

Replace Image

+

Replace Image

{ - 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.

-
e.preventDefault()}> - setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> -
- setRemember(!remember)} /> - -
- Login - -

{error}

-
- - ) -} \ No newline at end of file + return ( + <> +

Log In

+
+

+ Logging in gives you access to admin tools, such as + updating images, merging items, deleting items, and more. +

+
e.preventDefault()} + > + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> +
+ setRemember(!remember)} + /> + +
+ + Login + + +

{error}

+
+ + ); +} diff --git a/client/app/components/modals/MergeModal.tsx b/client/app/components/modals/MergeModal.tsx index d4bec44..61e2618 100644 --- a/client/app/components/modals/MergeModal.tsx +++ b/client/app/components/modals/MergeModal.tsx @@ -2,128 +2,158 @@ 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(''); - 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 [replaceImage, setReplaceImage] = useState(false); + const navigate = useNavigate(); + const closeMergeModal = () => { + props.setOpen(false); + setQuery(""); + setData(undefined); + setMergeOrderReversed(false); + setMergeTarget({ title: "", id: 0 }); + }; - 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 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} + 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(); + } } else { - from = {id: props.currentId, title: props.currentTitle} - to = mergeTarget + // TODO: handle error + console.log(r); } - 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() - } - } else { - // TODO: handle error - console.log(r) - } - }) - .catch((err) => console.log(err)) + }) + .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); + }); } - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedQuery(query); - if (query === '') { - setData(undefined) - } - }, 300); + }, [debouncedQuery]); - return () => { - clearTimeout(handler); - }; - }, [query]); - - useEffect(() => { - if (debouncedQuery) { - search(debouncedQuery).then((r) => { - r = props.mergeCleanerFunc(r, props.currentId) - setData(r); - }); - } - }, [debouncedQuery]); - - return ( + return ( -

Merge {props.type}s

-
- 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 {props.type}s

+
+ setQuery(e.target.value)} + /> + + {mergeTarget.id !== 0 ? ( + <> + {mergeOrderReversed ? ( +

+ {props.currentTitle} will be merged into{" "} + {mergeTarget.title} +

+ ) : ( +

+ {mergeTarget.title} will be merged into{" "} + {props.currentTitle} +

+ )} +
- setMergeOrderReversed(!mergeOrderReversed)} /> - + setMergeOrderReversed(!mergeOrderReversed)} + /> +
- { - (props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") && -
- setReplaceImage(!replaceImage)} /> + {(props.type.toLowerCase() === "album" || + props.type.toLowerCase() === "artist") && ( +
+ setReplaceImage(!replaceImage)} + /> -
- } - : - ''} -
+
+ )} + + ) : ( + "" + )} +
- ) + ); } diff --git a/client/app/components/modals/SearchModal.tsx b/client/app/components/modals/SearchModal.tsx index ec056cf..80c95dc 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) + 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); + }); } + }, [debouncedQuery]); - 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)} - /> -
- -
-
-
- ) + return ( + +

Search

+
+ setQuery(e.target.value)} + /> +
+ +
+
+
+ ); } diff --git a/client/app/components/rewind/Rewind.tsx b/client/app/components/rewind/Rewind.tsx new file mode 100644 index 0000000..e45ee2a --- /dev/null +++ b/client/app/components/rewind/Rewind.tsx @@ -0,0 +1,72 @@ +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].image; + const albumimg = props.stats.top_albums[0].image; + const trackimg = props.stats.top_tracks[0].image; + 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 new file mode 100644 index 0000000..5ccec87 --- /dev/null +++ b/client/app/components/rewind/RewindStatText.tsx @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..171a8f8 --- /dev/null +++ b/client/app/components/rewind/RewindTopItem.tsx @@ -0,0 +1,55 @@ +type TopItemProps = { + title: string; + imageSrc: string; + items: T[]; + 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)}

+ + {`${top.listen_count} plays`} + {includeTime + ? ` (${Math.floor(top.time_listened / 60)} minutes)` + : ``} + +
+
+ + {rest.map((e) => ( +
+ {getLabel(e)} + + {` - ${e.listen_count} plays`} + {includeTime + ? ` (${Math.floor(e.time_listened / 60)} minutes)` + : ``} + +
+ ))} +
+
+ ); +} diff --git a/client/app/components/sidebar/Sidebar.tsx b/client/app/components/sidebar/Sidebar.tsx index 1a42e67..15ac8b5 100644 --- a/client/app/components/sidebar/Sidebar.tsx +++ b/client/app/components/sidebar/Sidebar.tsx @@ -1,55 +1,73 @@ -import { ExternalLink, Home, Info } from "lucide-react"; +import { ExternalLink, History, Home, Info } from "lucide-react"; import SidebarSearch from "./SidebarSearch"; import SidebarItem from "./SidebarItem"; import SidebarSettings from "./SidebarSettings"; +import { getRewindYear } from "~/utils/utils"; export default function Sidebar() { - const iconSize = 20; + const iconSize = 20; - return ( -
-
- {}} modal={<>}> - - - -
-
- } - space={22} - externalLink - to="https://koito.io" - name="About" - onClick={() => {}} - modal={<>} - > - - - -
-
- ); + " + > +
+ {}} + modal={<>} + > + + + + {}} + modal={<>} + > + + +
+
+ } + space={22} + externalLink + to="https://koito.io" + name="About" + onClick={() => {}} + modal={<>} + > + + + +
+
+ ); } diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index 25670b2..62374be 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -44,7 +44,7 @@ export function ThemeSwitcher() {
-

Select Theme

+

Select Theme

Reset
@@ -61,7 +61,7 @@ export function ThemeSwitcher() {
-

Use Custom Theme

+

Use Custom Theme