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 0000000..3269563 Binary files /dev/null and b/assets/Jost-Regular.ttf differ diff --git a/assets/LeagueSpartan-Medium.ttf b/assets/LeagueSpartan-Medium.ttf new file mode 100644 index 0000000..c701d88 Binary files /dev/null and b/assets/LeagueSpartan-Medium.ttf differ 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