mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-13 09:30:27 -07:00
chore: initial public commit
This commit is contained in:
commit
fc9054b78c
250 changed files with 32809 additions and 0 deletions
185
client/app/components/ActivityGrid.tsx
Normal file
185
client/app/components/ActivityGrid.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getActivity, type getActivityArgs } from "api/api"
|
||||
import Popup from "./Popup"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "~/hooks/useTheme"
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
||||
|
||||
function getPrimaryColor(): string {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-primary')
|
||||
.trim();
|
||||
|
||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((n) => n.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
step?: string
|
||||
range?: number
|
||||
month?: number
|
||||
year?: number
|
||||
artistId?: number
|
||||
albumId?: number
|
||||
trackId?: number
|
||||
configurable?: boolean
|
||||
autoAdjust?: boolean
|
||||
}
|
||||
|
||||
export default function ActivityGrid({
|
||||
step = 'day',
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
autoAdjust = false,
|
||||
}: Props) {
|
||||
|
||||
const [color, setColor] = useState(getPrimaryColor())
|
||||
const [stepState, setStep] = useState(step)
|
||||
const [rangeState, setRange] = useState(range)
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'listen-activity',
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
|
||||
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const color = getPrimaryColor()
|
||||
setColor(color);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [theme]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Activity</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) return <p className="error">Error:{error.message}</p>
|
||||
|
||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||
function LightenDarkenColor(hex: string, lum: number) {
|
||||
// validate hex string
|
||||
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
||||
if (hex.length < 6) {
|
||||
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||
}
|
||||
lum = lum || 0;
|
||||
|
||||
// convert to decimal and change luminosity
|
||||
var rgb = "#", c, i;
|
||||
for (i = 0; i < 3; i++) {
|
||||
c = parseInt(hex.substring(i*2,(i*2)+2), 16);
|
||||
c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16);
|
||||
rgb += ("00"+c).substring(c.length);
|
||||
}
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
const getDarkenAmount = (v: number, t: number): number => {
|
||||
|
||||
if (autoAdjust) {
|
||||
// automatically adjust the target value based on step
|
||||
// the smartest way to do this would be to have the api return the
|
||||
// highest value in the range. too bad im not smart
|
||||
switch (stepState) {
|
||||
case 'day':
|
||||
t = 10
|
||||
break;
|
||||
case 'week':
|
||||
t = 20
|
||||
break;
|
||||
case 'month':
|
||||
t = 50
|
||||
break;
|
||||
case 'year':
|
||||
t = 100
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
v = Math.min(v, t)
|
||||
if (theme === "pearl") {
|
||||
// special case for the only light theme lol
|
||||
// could be generalized by pragmatically comparing the
|
||||
// lightness of the bg vs the primary but eh
|
||||
return ((t-v) / t)
|
||||
} else {
|
||||
return ((v-t) / t) * .8
|
||||
}
|
||||
}
|
||||
|
||||
const dotSize = 12;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<h2>Activity</h2>
|
||||
{configurable ?
|
||||
<ActivityOptsSelector rangeSetter={setRange} currentRange={rangeState} stepSetter={setStep} currentStep={stepState} />
|
||||
:
|
||||
''
|
||||
}
|
||||
<div className="grid grid-flow-col grid-rows-7 gap-[5px]">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={new Date(item.start_time).toString()}
|
||||
style={{ width: dotSize, height: dotSize }}
|
||||
>
|
||||
<Popup
|
||||
position="top"
|
||||
space={dotSize}
|
||||
extraClasses="left-2"
|
||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
display: 'inline-block',
|
||||
background:
|
||||
item.listens > 0
|
||||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
||||
: 'var(--color-bg-secondary)',
|
||||
}}
|
||||
className={`rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
client/app/components/ActivityOptsSelector.tsx
Normal file
98
client/app/components/ActivityOptsSelector.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
stepSetter: (value: string) => void;
|
||||
currentStep: string;
|
||||
rangeSetter: (value: number) => void;
|
||||
currentRange: number;
|
||||
disableCache?: boolean;
|
||||
}
|
||||
|
||||
export default function ActivityOptsSelector({
|
||||
stepSetter,
|
||||
currentStep,
|
||||
rangeSetter,
|
||||
currentRange,
|
||||
disableCache = false,
|
||||
}: Props) {
|
||||
const stepPeriods = ['day', 'week', 'month', 'year'];
|
||||
const rangePeriods = [105, 182, 365];
|
||||
|
||||
const stepDisplay = (str: string): string => {
|
||||
return str.split('_').map(w =>
|
||||
w.split('').map((char, index) =>
|
||||
index === 0 ? char.toUpperCase() : char).join('')
|
||||
).join(' ');
|
||||
};
|
||||
|
||||
const rangeDisplay = (r: number): string => {
|
||||
return `${r}`
|
||||
}
|
||||
|
||||
const setStep = (val: string) => {
|
||||
stepSetter(val);
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('activity_step_' + window.location.pathname.split('/')[1], val);
|
||||
}
|
||||
};
|
||||
|
||||
const setRange = (val: number) => {
|
||||
rangeSetter(val);
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('activity_range_' + window.location.pathname.split('/')[1], String(val));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableCache) {
|
||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
|
||||
if (cachedRange) {
|
||||
rangeSetter(cachedRange);
|
||||
}
|
||||
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
||||
if (cachedStep) {
|
||||
stepSetter(cachedStep);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-2 items-center">
|
||||
<p>Step:</p>
|
||||
{stepPeriods.map((p, i) => (
|
||||
<div key={`step_selector_${p}`}>
|
||||
<button
|
||||
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setStep(p)}
|
||||
disabled={p === currentStep}
|
||||
>
|
||||
{stepDisplay(p)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== stepPeriods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<p>Range:</p>
|
||||
{rangePeriods.map((r, i) => (
|
||||
<div key={`range_selector_${r}`}>
|
||||
<button
|
||||
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setRange(r)}
|
||||
disabled={r === currentRange}
|
||||
>
|
||||
{rangeDisplay(r)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== rangePeriods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
client/app/components/AlbumDisplay.tsx
Normal file
25
client/app/components/AlbumDisplay.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { imageUrl, type Album } from "api/api";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
album: Album
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function AlbumDisplay({ album, size }: Props) {
|
||||
return (
|
||||
<div className="flex gap-3" key={album.id}>
|
||||
<div>
|
||||
<Link to={`/album/${album.id}`}>
|
||||
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-start" style={{width: size}}>
|
||||
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
|
||||
<h4>{album.title}</h4>
|
||||
</Link>
|
||||
<p className="color-fg-secondary">{album.listen_count} plays</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
client/app/components/AllTimeStats.tsx
Normal file
45
client/app/components/AllTimeStats.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getStats } from "api/api"
|
||||
|
||||
export default function AllTimeStats() {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['stats', 'all_time'],
|
||||
queryFn: ({ queryKey }) => getStats(queryKey[1]),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[200px]">
|
||||
<h2>All Time Stats</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
const numberClasses = 'header-font font-bold text-xl'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>All Time Stats</h2>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.hours_listened}</span> Hours Listened
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.listen_count}</span> Plays
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.artist_count}</span> Artists
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.album_count}</span> Albums
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.track_count}</span> Tracks
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
client/app/components/ArtistAlbums.tsx
Normal file
51
client/app/components/ArtistAlbums.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
|
||||
interface Props {
|
||||
artistId: number
|
||||
name: string
|
||||
period: string
|
||||
}
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<h2>Albums From This Artist</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums From This Artist</h2>
|
||||
<p className="error">Error:{error.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums featuring {name}</h2>
|
||||
<div className="flex flex-wrap gap-8">
|
||||
{data.items.map((item) => (
|
||||
<Link to={`/album/${item.id}`}className="flex gap-2 items-start">
|
||||
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} />
|
||||
<div className="w-[180px] flex flex-col items-start gap-1">
|
||||
<p>{item.title}</p>
|
||||
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
client/app/components/ArtistLinks.tsx
Normal file
26
client/app/components/ArtistLinks.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
type Artist = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ArtistLinksProps = {
|
||||
artists: Artist[];
|
||||
};
|
||||
|
||||
const ArtistLinks: React.FC<ArtistLinksProps> = ({ artists }) => {
|
||||
return (
|
||||
<>
|
||||
{artists.map((artist, index) => (
|
||||
<span key={artist.id} className='color-fg-secondary'>
|
||||
<Link className="hover:text-(--color-fg-tertiary)" to={`/artist/${artist.id}`}>{artist.name}</Link>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtistLinks;
|
||||
43
client/app/components/AsyncButton.tsx
Normal file
43
client/app/components/AsyncButton.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React, { useState } from "react"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
export function AsyncButton(props: Props) {
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.confirm) {
|
||||
if (!awaitingConfirm) {
|
||||
setAwaitingConfirm(true)
|
||||
setTimeout(() => setAwaitingConfirm(false), 3000)
|
||||
return
|
||||
}
|
||||
setAwaitingConfirm(false)
|
||||
}
|
||||
|
||||
props.onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={props.loading || props.disabled}
|
||||
className={`relative px-5 py-2 rounded-md large-button flex disabled:opacity-50 items-center`}
|
||||
>
|
||||
<span className={props.loading ? 'invisible' : 'visible'}>
|
||||
{awaitingConfirm ? 'Are you sure?' : props.children}
|
||||
</span>
|
||||
{props.loading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
13
client/app/components/Footer.tsx
Normal file
13
client/app/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ExternalLinkIcon } from 'lucide-react'
|
||||
import pkg from '../../package.json'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm">
|
||||
<ul className="flex flex-col items-center w-sm justify-around">
|
||||
<li>Koito {pkg.version}</li>
|
||||
<li><a href="https://github.com/gabehf/koito" target="_blank" className="link-underline">View the source on GitHub <ExternalLinkIcon className='inline mb-1' size={14}/></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
client/app/components/GlobalThemes.tsx
Normal file
36
client/app/components/GlobalThemes.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// import { css } from '@emotion/css';
|
||||
// import { themes } from '../providers/ThemeProvider';
|
||||
|
||||
// export default function GlobalThemes() {
|
||||
// return (
|
||||
// <div
|
||||
// styles={css`
|
||||
// ${themes
|
||||
// .map(
|
||||
// (theme) => `
|
||||
// [data-theme=${theme.name}] {
|
||||
// --color-bg: ${theme.bg};
|
||||
// --color-bg-secondary: ${theme.bgSecondary};
|
||||
// --color-bg-tertiary:${theme.bgTertiary};
|
||||
// --color-fg: ${theme.fg};
|
||||
// --color-fg-secondary: ${theme.fgSecondary};
|
||||
// --color-fg-tertiary: ${theme.fgTertiary};
|
||||
// --color-primary: ${theme.primary};
|
||||
// --color-primary-dim: ${theme.primaryDim};
|
||||
// --color-secondary: ${theme.secondary};
|
||||
// --color-secondary-dim: ${theme.secondaryDim};
|
||||
// --color-error: ${theme.error};
|
||||
// --color-success: ${theme.success};
|
||||
// --color-warning: ${theme.warning};
|
||||
// --color-info: ${theme.info};
|
||||
// --color-border: var(--color-bg-tertiary);
|
||||
// --color-shadow: rgba(0, 0, 0, 0.5);
|
||||
// --color-link: var(--color-primary);
|
||||
// --color-link-hover: var(--color-primary-dim);
|
||||
// }
|
||||
// `).join('\n')
|
||||
// }
|
||||
// `}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
53
client/app/components/ImageDropHandler.tsx
Normal file
53
client/app/components/ImageDropHandler.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { replaceImage } from 'api/api';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
itemType: string,
|
||||
id: number,
|
||||
onComplete: Function
|
||||
}
|
||||
|
||||
export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
||||
useEffect(() => {
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
console.log('dragover!!')
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer?.files.length) return;
|
||||
|
||||
const imageFile = Array.from(e.dataTransfer.files).find(file =>
|
||||
file.type.startsWith('image/')
|
||||
);
|
||||
if (!imageFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
formData.append(itemType.toLowerCase()+'_id', String(id))
|
||||
replaceImage(formData).then((r) => {
|
||||
if (r.status >= 200 && r.status < 300) {
|
||||
onComplete()
|
||||
console.log("Replacement image uploaded successfully")
|
||||
} else {
|
||||
r.json().then((body) => {
|
||||
console.log(`Upload failed: ${r.statusText} - ${body}`)
|
||||
})
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(`Upload failed: ${err}`)
|
||||
})
|
||||
};
|
||||
|
||||
window.addEventListener('dragover', handleDragOver);
|
||||
window.addEventListener('drop', handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
57
client/app/components/LastPlays.tsx
Normal file
57
client/app/components/LastPlays.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import { timeSince } from "~/utils/utils"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getLastListens, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
|
||||
interface Props {
|
||||
limit: number
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
trackId?: number
|
||||
hideArtists?: boolean
|
||||
}
|
||||
|
||||
export default function LastPlays(props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['last-listens', {limit: props.limit, period: 'all_time', artist_id: props.artistId, album_id: props.albumId, track_id: props.trackId}],
|
||||
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Last Played</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
let params = ''
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
||||
params += props.trackId ? `&track_id=${props.trackId}` : ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/listens?period=all_time${params}`}>Last Played</Link></h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{data.items.map((item) => (
|
||||
<tr key={`last_listen_${item.time}`}>
|
||||
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[600px]">
|
||||
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
|
||||
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
client/app/components/PeriodSelector.tsx
Normal file
52
client/app/components/PeriodSelector.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { useEffect } from "react"
|
||||
|
||||
interface Props {
|
||||
setter: Function
|
||||
current: string
|
||||
disableCache?: boolean
|
||||
}
|
||||
|
||||
export default function PeriodSelector({ setter, current, disableCache = false }: Props) {
|
||||
const periods = ['day', 'week', 'month', 'year', 'all_time']
|
||||
|
||||
const periodDisplay = (str: string) => {
|
||||
return str.split('_').map(w => w.split('').map((char, index) =>
|
||||
index === 0 ? char.toUpperCase() : char).join('')).join(' ')
|
||||
}
|
||||
|
||||
const setPeriod = (val: string) => {
|
||||
setter(val)
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('period_selection_'+window.location.pathname.split('/')[1], val)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableCache) {
|
||||
const cached = localStorage.getItem('period_selection_' + window.location.pathname.split('/')[1]);
|
||||
if (cached) {
|
||||
setter(cached);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<p>Showing stats for:</p>
|
||||
{periods.map((p, i) => (
|
||||
<div key={`period_setter_${p}`}>
|
||||
<button
|
||||
className={`period-selector ${p === current ? 'color-fg' : 'color-fg-secondary'} ${i !== periods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setPeriod(p)}
|
||||
disabled={p === current}
|
||||
>
|
||||
{periodDisplay(p)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== periods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
client/app/components/Popup.tsx
Normal file
48
client/app/components/Popup.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React, { type PropsWithChildren, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
inner: React.ReactNode
|
||||
position: string
|
||||
space: number
|
||||
extraClasses?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export default function Popup({ inner, position, space, extraClasses, children }: PropsWithChildren<Props>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
let positionClasses
|
||||
let spaceCSS = {}
|
||||
if (position == "top") {
|
||||
positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2`
|
||||
} else if (position == "right") {
|
||||
positionClasses = `bottom-1 -translate-x-1/2`
|
||||
spaceCSS = {left: 70 + space}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className={`
|
||||
absolute
|
||||
${positionClasses}
|
||||
${extraClasses ? extraClasses : ''}
|
||||
bg-(--color-bg) color-fg border-1 border-(--color-bg-tertiary)
|
||||
px-3 py-2 rounded-lg
|
||||
transition-opacity duration-100
|
||||
${isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
||||
z-50 text-center
|
||||
flex
|
||||
`}
|
||||
style={spaceCSS}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
client/app/components/SearchResultItem.tsx
Normal file
23
client/app/components/SearchResultItem.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { Link } from "react-router"
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
onClick: React.MouseEventHandler<HTMLAnchorElement>
|
||||
img: string
|
||||
text: string
|
||||
subtext?: string
|
||||
}
|
||||
|
||||
export default function SearchResultItem(props: Props) {
|
||||
return (
|
||||
<Link to={props.to} className="px-3 py-2 flex gap-3 items-center hover:text-(--color-fg-secondary)" onClick={props.onClick}>
|
||||
<img src={props.img} alt={props.text} />
|
||||
<div>
|
||||
{props.text}
|
||||
{props.subtext ? <><br/>
|
||||
<span className="color-fg-secondary">{props.subtext}</span>
|
||||
</> : ''}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
31
client/app/components/SearchResultSelectorItem.tsx
Normal file
31
client/app/components/SearchResultSelectorItem.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Check } from "lucide-react"
|
||||
import CheckCircleIcon from "./icons/CheckCircleIcon"
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||
img: string
|
||||
text: string
|
||||
subtext?: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export default function SearchResultSelectorItem(props: Props) {
|
||||
return (
|
||||
<button className="px-3 py-2 flex gap-3 items-center hover:text-(--color-fg-secondary) hover:cursor-pointer w-full" style={{ border: props.active ? "1px solid var(--color-fg-tertiary" : ''}} onClick={props.onClick}>
|
||||
<img src={props.img} alt={props.text} />
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="flex flex-col items-start text-start">
|
||||
{props.text}
|
||||
{props.subtext ? <><br/>
|
||||
<span className="color-fg-secondary">{props.subtext}</span>
|
||||
</> : ''}
|
||||
</div>
|
||||
{
|
||||
props.active ?
|
||||
<div className="px-2"><Check size={24} /></div> : ''
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
107
client/app/components/SearchResults.tsx
Normal file
107
client/app/components/SearchResults.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { imageUrl, type SearchResponse } from "api/api"
|
||||
import { useState } from "react"
|
||||
import SearchResultItem from "./SearchResultItem"
|
||||
import SearchResultSelectorItem from "./SearchResultSelectorItem"
|
||||
|
||||
interface Props {
|
||||
data?: SearchResponse
|
||||
onSelect: Function
|
||||
selectorMode?: boolean
|
||||
}
|
||||
export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||
const [selected, setSelected] = useState(0)
|
||||
const classes = "flex flex-col items-start bg rounded w-full"
|
||||
const hClasses = "pt-4 pb-2"
|
||||
|
||||
const selectItem = (title: string, id: number) => {
|
||||
if (selected === id) {
|
||||
setSelected(0)
|
||||
onSelect({id: id, title: title})
|
||||
} else {
|
||||
setSelected(id)
|
||||
onSelect({id: id, title: title})
|
||||
}
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<div className="w-full">
|
||||
{ data.artists.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Artists</h3>
|
||||
<div className={classes}>
|
||||
{data.artists.map((artist) => (
|
||||
selectorMode ?
|
||||
<SearchResultSelectorItem
|
||||
id={artist.id}
|
||||
onClick={() => selectItem(artist.name, artist.id)}
|
||||
text={artist.name}
|
||||
img={imageUrl(artist.image, "small")}
|
||||
active={selected === artist.id}
|
||||
/> :
|
||||
<SearchResultItem
|
||||
to={`/artist/${artist.id}`}
|
||||
onClick={() => onSelect(artist.id)}
|
||||
text={artist.name}
|
||||
img={imageUrl(artist.image, "small")}
|
||||
/>
|
||||
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{ data.albums.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Albums</h3>
|
||||
<div className={classes}>
|
||||
{data.albums.map((album) => (
|
||||
selectorMode ?
|
||||
<SearchResultSelectorItem
|
||||
id={album.id}
|
||||
onClick={() => selectItem(album.title, album.id)}
|
||||
text={album.title}
|
||||
subtext={album.is_various_artists ? "Various Artists" : album.artists[0].name}
|
||||
img={imageUrl(album.image, "small")}
|
||||
active={selected === album.id}
|
||||
/> :
|
||||
<SearchResultItem
|
||||
to={`/album/${album.id}`}
|
||||
onClick={() => onSelect(album.id)}
|
||||
text={album.title}
|
||||
subtext={album.is_various_artists ? "Various Artists" : album.artists[0].name}
|
||||
img={imageUrl(album.image, "small")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{ data.tracks.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Tracks</h3>
|
||||
<div className={classes}>
|
||||
{data.tracks.map((track) => (
|
||||
selectorMode ?
|
||||
<SearchResultSelectorItem
|
||||
id={track.id}
|
||||
onClick={() => selectItem(track.title, track.id)}
|
||||
text={track.title}
|
||||
subtext={track.artists.map((a) => a.name).join(', ')}
|
||||
img={imageUrl(track.image, "small")}
|
||||
active={selected === track.id}
|
||||
/> :
|
||||
<SearchResultItem
|
||||
to={`/track/${track.id}`}
|
||||
onClick={() => onSelect(track.id)}
|
||||
text={track.title}
|
||||
subtext={track.artists.map((a) => a.name).join(', ')}
|
||||
img={imageUrl(track.image, "small")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
client/app/components/TopAlbums.tsx
Normal file
42
client/app/components/TopAlbums.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import TopItemList from "./TopItemList"
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
}
|
||||
|
||||
export default function TopAlbums (props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Albums</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-albums?period=${props.period}${props.artistId ? `&artist_id=${props.artistId}` : ''}`}>Top Albums</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="album" data={data} />
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
client/app/components/TopArtists.tsx
Normal file
43
client/app/components/TopArtists.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import TopItemList from "./TopItemList"
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
}
|
||||
|
||||
export default function TopArtists (props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }],
|
||||
queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Artists</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-artists?period=${props.period}`}>Top Artists</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="artist" data={data} />
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
client/app/components/TopItemList.tsx
Normal file
142
client/app/components/TopItemList.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { Link, useNavigate } from "react-router";
|
||||
import ArtistLinks from "./ArtistLinks";
|
||||
import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api";
|
||||
|
||||
type Item = Album | Track | Artist;
|
||||
|
||||
interface Props<T extends Item> {
|
||||
data: PaginatedResponse<T>
|
||||
separators?: ConstrainBoolean
|
||||
width?: number
|
||||
type: "album" | "track" | "artist";
|
||||
}
|
||||
|
||||
export default function TopItemList<T extends Item>({ data, separators, type, width }: Props<T>) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1" style={{width: width ?? 300}}>
|
||||
{data.items.map((item, index) => {
|
||||
const key = `${type}-${item.id}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ fontSize: 12 }}
|
||||
className={`${
|
||||
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : ''
|
||||
}`}
|
||||
>
|
||||
<ItemCard item={item} type={type} key={type+item.id} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
|
||||
|
||||
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleItemClick = (type: string, id: number) => {
|
||||
navigate(`/${type.toLowerCase()}/${id}`);
|
||||
};
|
||||
|
||||
const handleArtistClick = (event: React.MouseEvent) => {
|
||||
// Stop the click from navigating to the album page
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// Also stop keyboard events on the inner links from bubbling up
|
||||
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "album": {
|
||||
const album = item as Album;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("album", album.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("album", album.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View album: ${album.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(album.image, "small")} alt={album.title} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{album.title}</span>
|
||||
<br />
|
||||
{album.is_various_artists ?
|
||||
<span className="color-fg-secondary">Various Artists</span>
|
||||
:
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
}
|
||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "track": {
|
||||
const track = item as Track;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("track", track.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("track", track.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View track: ${track.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(track.image, "small")} alt={track.title} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{track.title}</span>
|
||||
<br />
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "artist": {
|
||||
const artist = item as Artist;
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
|
||||
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{artist.name}</span>
|
||||
<div className="color-fg-secondary">{artist.listen_count} plays</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
client/app/components/TopThreeAlbums.tsx
Normal file
38
client/app/components/TopThreeAlbums.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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
|
||||
}
|
||||
|
||||
export default function TopThreeAlbums(props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
console.log(data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!props.hideTitle && <h2>Top Three Albums</h2>}
|
||||
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
|
||||
{data.items.map((item, index) => (
|
||||
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
client/app/components/TopTracks.tsx
Normal file
50
client/app/components/TopTracks.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import { useEffect } from "react"
|
||||
import TopItemList from "./TopItemList"
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
}
|
||||
|
||||
const TopTracks = (props: Props) => {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Tracks</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
let params = ''
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-tracks?period=${props.period}${params}`}>Top Tracks</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="track" data={data}/>
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopTracks
|
||||
16
client/app/components/icons/ChartIcon.tsx
Normal file
16
client/app/components/icons/ChartIcon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function ChartIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C7.58172 0 4 3.58172 4 8C4 10.0289 4.75527 11.8814 6 13.2916V23C6 23.3565 6.18976 23.686 6.49807 23.8649C6.80639 24.0438 7.18664 24.0451 7.49614 23.8682L12 21.2946L16.5039 23.8682C16.8134 24.0451 17.1936 24.0438 17.5019 23.8649C17.8102 23.686 18 23.3565 18 23V13.2916C19.2447 11.8814 20 10.0289 20 8C20 3.58172 16.4183 0 12 0ZM6 8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8C18 11.3137 15.3137 14 12 14C8.68629 14 6 11.3137 6 8ZM16 14.9297C14.8233 15.6104 13.4571 16 12 16C10.5429 16 9.17669 15.6104 8 14.9297V21.2768L11.5039 19.2746C11.8113 19.0989 12.1887 19.0989 12.4961 19.2746L16 21.2768V14.9297Z" fill="var(--color-fg)"/> </svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
client/app/components/icons/CheckCircleIcon.tsx
Normal file
16
client/app/components/icons/CheckCircleIcon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
color?: string
|
||||
}
|
||||
export default function CheckCircleIcon({size, hover, color}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-fill"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill={color !== undefined ? `var(--${color})` : 'var(--color-fg)'} fill-rule="evenodd" d="M3 10a7 7 0 019.307-6.611 1 1 0 00.658-1.889 9 9 0 105.98 7.501 1 1 0 00-1.988.22A7 7 0 113 10zm14.75-5.338a1 1 0 00-1.5-1.324l-6.435 7.28-3.183-2.593a1 1 0 00-1.264 1.55l3.929 3.2a1 1 0 001.38-.113l7.072-8z"/> </svg></div>
|
||||
)
|
||||
}
|
||||
17
client/app/components/icons/GraphIcon.tsx
Normal file
17
client/app/components/icons/GraphIcon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function GraphIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 21H7.8C6.11984 21 5.27976 21 4.63803 20.673C4.07354 20.3854 3.6146 19.9265 3.32698 19.362C3 18.7202 3 17.8802 3 16.2V3M15 10V17M7 13V17M19 5V17M11 7V17" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
client/app/components/icons/HomeIcon.tsx
Normal file
16
client/app/components/icons/HomeIcon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function HomeIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-fill"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3103 1.77586C11.6966 1.40805 12.3034 1.40805 12.6897 1.77586L20.6897 9.39491L23.1897 11.7759C23.5896 12.1567 23.605 12.7897 23.2241 13.1897C22.8433 13.5896 22.2103 13.605 21.8103 13.2241L21 12.4524V20C21 21.1046 20.1046 22 19 22H14H10H5C3.89543 22 3 21.1046 3 20V12.4524L2.18966 13.2241C1.78972 13.605 1.15675 13.5896 0.775862 13.1897C0.394976 12.7897 0.410414 12.1567 0.810345 11.7759L3.31034 9.39491L11.3103 1.77586ZM5 10.5476V20H9V15C9 13.3431 10.3431 12 12 12C13.6569 12 15 13.3431 15 15V20H19V10.5476L12 3.88095L5 10.5476ZM13 20V15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15V20H13Z" fill="var(--color-fg)"/>
|
||||
</svg></div>
|
||||
)
|
||||
}
|
||||
16
client/app/components/icons/ImageIcon.tsx
Normal file
16
client/app/components/icons/ImageIcon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function ImageIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.2639 15.9375L12.5958 14.2834C11.7909 13.4851 11.3884 13.086 10.9266 12.9401C10.5204 12.8118 10.0838 12.8165 9.68048 12.9536C9.22188 13.1095 8.82814 13.5172 8.04068 14.3326L4.04409 18.2801M14.2639 15.9375L14.6053 15.599C15.4112 14.7998 15.8141 14.4002 16.2765 14.2543C16.6831 14.126 17.12 14.1311 17.5236 14.2687C17.9824 14.4251 18.3761 14.8339 19.1634 15.6514L20 16.4934M14.2639 15.9375L18.275 19.9565M18.275 19.9565C17.9176 20 17.4543 20 16.8 20H7.2C6.07989 20 5.51984 20 5.09202 19.782C4.71569 19.5903 4.40973 19.2843 4.21799 18.908C4.12796 18.7313 4.07512 18.5321 4.04409 18.2801M18.275 19.9565C18.5293 19.9256 18.7301 19.8727 18.908 19.782C19.2843 19.5903 19.5903 19.2843 19.782 18.908C20 18.4802 20 17.9201 20 16.8V16.4934M4.04409 18.2801C4 17.9221 4 17.4575 4 16.8V7.2C4 6.0799 4 5.51984 4.21799 5.09202C4.40973 4.71569 4.71569 4.40973 5.09202 4.21799C5.51984 4 6.07989 4 7.2 4H16.8C17.9201 4 18.4802 4 18.908 4.21799C19.2843 4.40973 19.5903 4.71569 19.782 5.09202C20 5.51984 20 6.0799 20 7.2V16.4934M17 8.99989C17 10.1045 16.1046 10.9999 15 10.9999C13.8954 10.9999 13 10.1045 13 8.99989C13 7.89532 13.8954 6.99989 15 6.99989C16.1046 6.99989 17 7.89532 17 8.99989Z" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg></div>
|
||||
)
|
||||
}
|
||||
15
client/app/components/icons/MergeIcon.tsx
Normal file
15
client/app/components/icons/MergeIcon.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function MergeIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-fill"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="var(--color-fg)" fill-rule="evenodd" d="M10,0 L10,2.60002 C12.2108812,3.04881281 13.8920863,4.95644867 13.9950026,7.27443311 L14,7.5 L14,11.2676 C14.5978,11.6134 15,12.2597 15,13 C15,14.1046 14.1046,15 13,15 C11.8954,15 11,14.1046 11,13 C11,12.3166462 11.342703,11.713387 11.8656124,11.3526403 L12,11.2676 L12,7.5 C12,6.259091 11.246593,5.19415145 10.1722389,4.73766702 L10,4.67071 L10,7 L6,3.5 L10,0 Z M3,1 C4.10457,1 5,1.89543 5,3 C5,3.68333538 4.65729704,4.28663574 4.13438762,4.6473967 L4,4.73244 L4,11.2676 C4.5978,11.6134 5,12.2597 5,13 C5,14.1046 4.10457,15 3,15 C1.89543,15 1,14.1046 1,13 C1,12.3166462 1.34270296,11.713387 1.86561238,11.3526403 L2,11.2676 L2,4.73244 C1.4022,4.38663 1,3.74028 1,3 C1,1.89543 1.89543,1 3,1 Z"/> </svg></div>
|
||||
)
|
||||
}
|
||||
16
client/app/components/icons/SearchIcon.tsx
Normal file
16
client/app/components/icons/SearchIcon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function SearchIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 6C13.7614 6 16 8.23858 16 11M16.6588 16.6549L21 21M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg></div>
|
||||
)
|
||||
}
|
||||
97
client/app/components/modals/Account.tsx
Normal file
97
client/app/components/modals/Account.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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 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 = () => {
|
||||
if (password != "" && confirmPw === "") {
|
||||
setError("confirm your 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 (
|
||||
<>
|
||||
<h2>Account</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>You're logged in as <strong>{user?.username}</strong></p>
|
||||
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
|
||||
</div>
|
||||
<h2>Update User</h2>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
||||
</div>
|
||||
{success != "" && <p className="success">{success}</p>}
|
||||
{error != "" && <p className="error">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
client/app/components/modals/AccountPage.tsx
Normal file
17
client/app/components/modals/AccountPage.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useAppContext } from "~/providers/AppProvider"
|
||||
import LoginForm from "./LoginForm"
|
||||
import Account from "./Account"
|
||||
|
||||
export default function AuthForm() {
|
||||
const { user } = useAppContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
{ user ?
|
||||
<Account />
|
||||
:
|
||||
<LoginForm />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
129
client/app/components/modals/ApiKeysModal.tsx
Normal file
129
client/app/components/modals/ApiKeysModal.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createApiKey, deleteApiKey, getApiKeys, type ApiKey } from "api/api";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Trash } from "lucide-react";
|
||||
|
||||
type CopiedState = {
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export default function ApiKeysModal() {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading ] = useState(false)
|
||||
const [err, setError ] = useState<string>()
|
||||
const [displayData, setDisplayData] = useState<ApiKey[]>([])
|
||||
const [copied, setCopied] = useState<CopiedState | null>(null);
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'api-keys'
|
||||
],
|
||||
queryFn: () => {
|
||||
return getApiKeys();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
|
||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
|
||||
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
|
||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setCopied({
|
||||
x: buttonRect.left - parentRect.left + buttonRect.width / 2, // center of button
|
||||
y: buttonRect.top - parentRect.top - 8, // above the button
|
||||
visible: true,
|
||||
});
|
||||
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<h2>API Keys</h2>
|
||||
<div className="flex flex-col gap-4 relative">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="bg p-3 rounded-md flex-grow" key={v.key}>{v.key.slice(0, 8)+'...'} {v.label}</div>
|
||||
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
|
||||
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a label for a new API key"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
|
||||
</div>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{copied?.visible && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: copied.y,
|
||||
left: copied.x,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
|
||||
>
|
||||
Copied!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
40
client/app/components/modals/DeleteModal.tsx
Normal file
40
client/app/components/modals/DeleteModal.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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
|
||||
}
|
||||
|
||||
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
||||
<h2>Delete "{title}"?</h2>
|
||||
<p>This action is irreversible!</p>
|
||||
<div className="flex flex-col mt-3 items-center">
|
||||
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
90
client/app/components/modals/ImageReplaceModal.tsx
Normal file
90
client/app/components/modals/ImageReplaceModal.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { replaceImage, search, type SearchResponse } from "api/api";
|
||||
import SearchResults from "../SearchResults";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
id: number
|
||||
musicbrainzId?: string
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
|
||||
|
||||
const doImageReplace = (url: string) => {
|
||||
setLoading(true)
|
||||
const formData = new FormData
|
||||
formData.set(`${type.toLowerCase()}_id`, id.toString())
|
||||
formData.set("image_url", url)
|
||||
replaceImage(formData)
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
console.log(r)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeModal}>
|
||||
<h2>Replace Image</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Image URL`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{ query != "" ?
|
||||
<div className="flex gap-2 mt-4">
|
||||
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton>
|
||||
</div> :
|
||||
''}
|
||||
{ type === "Album" && musicbrainzId ?
|
||||
<>
|
||||
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
|
||||
<button
|
||||
className="mt-4"
|
||||
disabled={loading}
|
||||
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
|
||||
>
|
||||
<div className={`relative`}>
|
||||
{suggestedImgLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
|
||||
onLoad={() => setSuggestedImgLoading(false)}
|
||||
onError={() => setSuggestedImgLoading(false)}
|
||||
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
59
client/app/components/modals/LoginForm.tsx
Normal file
59
client/app/components/modals/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { login } from "api/api"
|
||||
import { useEffect, useState } from "react"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
|
||||
export default function LoginForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
|
||||
const 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 (
|
||||
<>
|
||||
<h2>Log In</h2>
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
|
||||
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
|
||||
<input
|
||||
name="koito-username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
|
||||
<label htmlFor="kotio-remember">Remember me</label>
|
||||
</div>
|
||||
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
|
||||
</form>
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
125
client/app/components/modals/MergeModal.tsx
Normal file
125
client/app/components/modals/MergeModal.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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 { useNavigate } from "react-router";
|
||||
|
||||
interface Props {
|
||||
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<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
|
||||
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
||||
const closeMergeModal = () => {
|
||||
props.setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
setMergeOrderReversed(false)
|
||||
setMergeTarget({title: '', id: 0})
|
||||
}
|
||||
|
||||
const toggleSelect = ({title, id}: {title: string, id: number}) => {
|
||||
if (mergeTarget.id === 0) {
|
||||
setMergeTarget({title: title, id: id})
|
||||
} else {
|
||||
setMergeTarget({title:"", id: 0})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(mergeTarget)
|
||||
}, [mergeTarget])
|
||||
|
||||
const doMerge = () => {
|
||||
let from, to
|
||||
if (!mergeOrderReversed) {
|
||||
from = mergeTarget
|
||||
to = {id: props.currentId, title: props.currentTitle}
|
||||
} else {
|
||||
from = {id: props.currentId, title: props.currentTitle}
|
||||
to = mergeTarget
|
||||
}
|
||||
props.mergeFunc(from.id, to.id)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
if (mergeOrderReversed) {
|
||||
navigate(`/${props.type.toLowerCase()}/${mergeTarget}`)
|
||||
closeMergeModal()
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
} else {
|
||||
// TODO: handle error
|
||||
console.log(r)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === '') {
|
||||
setData(undefined)
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
r = props.mergeCleanerFunc(r, props.currentId)
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.open} onClose={closeMergeModal}>
|
||||
<h2>Merge {props.type}s</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
|
||||
{ mergeTarget.id !== 0 ?
|
||||
<>
|
||||
{mergeOrderReversed ?
|
||||
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
|
||||
:
|
||||
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
|
||||
}
|
||||
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
|
||||
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
||||
</div>
|
||||
</> :
|
||||
''}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
84
client/app/components/modals/Modal.tsx
Normal file
84
client/app/components/modals/Modal.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
maxW,
|
||||
h
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxW?: number;
|
||||
h?: number;
|
||||
}) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = useState(isOpen);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Show/hide logic
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
} else if (shouldRender) {
|
||||
setIsClosing(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
}, 100); // Match fade-out duration
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isOpen, shouldRender]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (
|
||||
modalRef.current &&
|
||||
!modalRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity duration-100 ${
|
||||
isClosing ? 'animate-fade-out' : 'animate-fade-in'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`bg-secondary rounded-lg shadow-md p-6 w-full relative max-h-3/4 overflow-y-auto transition-all duration-100 ${
|
||||
isClosing ? 'animate-fade-out-scale' : 'animate-fade-in-scale'
|
||||
}`}
|
||||
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
|
||||
>
|
||||
🞪
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
124
client/app/components/modals/RenameModal.tsx
Normal file
124
client/app/components/modals/RenameModal.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
|
||||
import { Modal } from "./Modal";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
id: number
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function RenameModal({ open, setOpen, type, id }: Props) {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading ] = useState(false)
|
||||
const [err, setError ] = useState<string>()
|
||||
const [displayData, setDisplayData] = useState<Alias[]>([])
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'aliases',
|
||||
{
|
||||
type: type,
|
||||
id: id
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => {
|
||||
const params = queryKey[1] as { type: string; id: number };
|
||||
return getAliases(params.type, params.id);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
const handleSetPrimary = (alias: string) => {
|
||||
setError(undefined)
|
||||
setLoading(true)
|
||||
setPrimaryAlias(type, id, alias)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleNewAlias = () => {
|
||||
setError(undefined)
|
||||
if (input === "") {
|
||||
setError("alias must be provided")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
createAlias(type, id, input)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setDisplayData([...displayData, {alias: input, source: "Manual", is_primary: false, id: id}])
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleDeleteAlias = (alias: string) => {
|
||||
setError(undefined)
|
||||
setLoading(true)
|
||||
deleteAlias(type, id, alias)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setDisplayData(displayData.filter((v) => v.alias != alias))
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal maxW={1000} isOpen={open} onClose={() => setOpen(false)}>
|
||||
<h2>Alias Manager</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>{v.alias} (source: {v.source})</div>
|
||||
<AsyncButton loading={loading} onClick={() => handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary</AsyncButton>
|
||||
<AsyncButton loading={loading} onClick={() => handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}><Trash size={16} /></AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a new alias"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleNewAlias}>Submit</AsyncButton>
|
||||
</div>
|
||||
{err && <p className="error">{err}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
60
client/app/components/modals/SearchModal.tsx
Normal file
60
client/app/components/modals/SearchModal.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { search, type SearchResponse } from "api/api";
|
||||
import SearchResults from "../SearchResults";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, setOpen }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
|
||||
const closeSearchModal = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === '') {
|
||||
setData(undefined)
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeSearchModal}>
|
||||
<h2>Search</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Search for an artist, album, or track"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="h-3/4 w-full">
|
||||
<SearchResults data={data} onSelect={closeSearchModal}/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
41
client/app/components/modals/SettingsModal.tsx
Normal file
41
client/app/components/modals/SettingsModal.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Modal } from "./Modal"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
|
||||
import AccountPage from "./AccountPage";
|
||||
import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
|
||||
import ThemeHelper from "../../routes/ThemeHelper";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import ApiKeysModal from "./ApiKeysModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function SettingsModal({ open, setOpen } : Props) {
|
||||
|
||||
const { user } = useAppContext()
|
||||
|
||||
const triggerClasses = "px-4 py-2 w-full hover-bg-secondary rounded-md text-start data-[state=active]:bg-[var(--color-bg-secondary)]"
|
||||
const contentClasses = "w-full px-10 overflow-y-auto"
|
||||
|
||||
return (
|
||||
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||
<Tabs defaultValue="Appearance" orientation="vertical" className="flex justify-between h-full">
|
||||
<TabsList className="w-full flex flex-col gap-1 items-start max-w-1/4 rounded-md bg p-2 grow-0">
|
||||
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
||||
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
||||
{ user && <TabsTrigger className={triggerClasses} value="API Keys">API Keys</TabsTrigger>}
|
||||
</TabsList>
|
||||
<TabsContent value="Account" className={contentClasses}>
|
||||
<AccountPage />
|
||||
</TabsContent>
|
||||
<TabsContent value="Appearance" className={contentClasses}>
|
||||
<ThemeSwitcher />
|
||||
</TabsContent>
|
||||
<TabsContent value="API Keys" className={contentClasses}>
|
||||
<ApiKeysModal />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
22
client/app/components/sidebar/Sidebar.tsx
Normal file
22
client/app/components/sidebar/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { ExternalLink, Home, Info } from "lucide-react";
|
||||
import SidebarSearch from "./SidebarSearch";
|
||||
import SidebarItem from "./SidebarItem";
|
||||
import SidebarSettings from "./SidebarSettings";
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
const iconSize = 20;
|
||||
|
||||
return (
|
||||
<div className="z-50 flex flex-col justify-between h-screen border-r-1 border-(--color-bg-tertiary) p-1 py-10 sticky left-0 top-0 bg-(--color-bg)">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}><Home size={iconSize} /></SidebarItem>
|
||||
<SidebarSearch size={iconSize} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SidebarItem icon keyHint={<ExternalLink size={14} />} space={22} externalLink to="https://koito.io" name="About" onClick={() => {}} modal={<></>}><Info size={iconSize} /></SidebarItem>
|
||||
<SidebarSettings size={iconSize} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
client/app/components/sidebar/SidebarItem.tsx
Normal file
48
client/app/components/sidebar/SidebarItem.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React, { useState } from "react";
|
||||
import Popup from "../Popup";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
to?: string;
|
||||
onClick: Function;
|
||||
children: React.ReactNode;
|
||||
modal: React.ReactNode;
|
||||
keyHint?: React.ReactNode;
|
||||
space?: number
|
||||
externalLink?: boolean
|
||||
/* true if the keyhint is an icon and not text */
|
||||
icon?: boolean
|
||||
}
|
||||
|
||||
export default function SidebarItem({ externalLink, space, keyHint, name, to, children, modal, onClick, icon }: Props) {
|
||||
const classes = "hover:cursor-pointer hover:bg-(--color-bg-tertiary) transition duration-100 rounded-md p-2 inline-block";
|
||||
|
||||
const popupInner = keyHint ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
{icon ?
|
||||
<div>
|
||||
{keyHint}
|
||||
</div>
|
||||
:
|
||||
<kbd className="px-1 text-sm rounded bg-(--color-bg-tertiary) text-(--color-fg) border border-[var(--color-fg)]">
|
||||
{keyHint}
|
||||
</kbd>
|
||||
}
|
||||
</div>
|
||||
) : name;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup position="right" space={space ?? 20} inner={popupInner}>
|
||||
{to ? (
|
||||
<Link target={externalLink ? "_blank" : ""} className={classes} to={to}>{children}</Link>
|
||||
) : (
|
||||
<a className={classes} onClick={() => onClick()}>{children}</a>
|
||||
)}
|
||||
</Popup>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
client/app/components/sidebar/SidebarSearch.tsx
Normal file
33
client/app/components/sidebar/SidebarSearch.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import SidebarItem from "./SidebarItem";
|
||||
import { Search } from "lucide-react";
|
||||
import SearchModal from "../modals/SearchModal";
|
||||
|
||||
interface Props {
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function SidebarSearch({ size } : Props) {
|
||||
const [open, setModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/' && !open) {
|
||||
e.preventDefault();
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
space={26}
|
||||
onClick={() => setModalOpen(true)}
|
||||
name="Search"
|
||||
keyHint="/"
|
||||
children={<Search size={size}/>} modal={<SearchModal open={open} setOpen={setModalOpen} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
29
client/app/components/sidebar/SidebarSettings.tsx
Normal file
29
client/app/components/sidebar/SidebarSettings.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Settings2 } from "lucide-react";
|
||||
import SettingsModal from "../modals/SettingsModal";
|
||||
import SidebarItem from "./SidebarItem";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function SidebarSettings({ size }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown= (e: KeyboardEvent) => {
|
||||
if (e.key === '\\' && !open) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SidebarItem space={30} keyHint="\" name="Settings" onClick={() => setOpen(true)} modal={<SettingsModal open={open} setOpen={setOpen} />}>
|
||||
<Settings2 size={size} />
|
||||
</SidebarItem>
|
||||
)
|
||||
}
|
||||
20
client/app/components/skeletons/TopListSkeleton.tsx
Normal file
20
client/app/components/skeletons/TopListSkeleton.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
interface Props {
|
||||
numItems: number
|
||||
}
|
||||
|
||||
export default function TopListSkeleton({ numItems }: Props) {
|
||||
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
{[...Array(numItems)].map(() => (
|
||||
<div className="flex items-center gap-2 mb-[4px]">
|
||||
<div className="w-[40px] h-[40px] bg-(--color-bg-tertiary) rounded"></div>
|
||||
<div>
|
||||
<div className="h-[14px] w-[150px] bg-(--color-bg-tertiary) rounded"></div>
|
||||
<div className="h-[12px] w-[60px] bg-(--color-bg-tertiary) rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
client/app/components/themeSwitcher/ThemeOption.tsx
Normal file
22
client/app/components/themeSwitcher/ThemeOption.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { Theme } from "~/providers/ThemeProvider";
|
||||
|
||||
interface Props {
|
||||
theme: Theme
|
||||
setTheme: Function
|
||||
}
|
||||
|
||||
export default function ThemeOption({ theme, setTheme }: Props) {
|
||||
|
||||
const capitalizeFirstLetter = (s: string) => {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={() => setTheme(theme.name)} className="rounded-md p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
|
||||
{capitalizeFirstLetter(theme.name)}
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
client/app/components/themeSwitcher/ThemeSwitcher.tsx
Normal file
36
client/app/components/themeSwitcher/ThemeSwitcher.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// ThemeSwitcher.tsx
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { themes } from '~/providers/ThemeProvider';
|
||||
import ThemeOption from './ThemeOption';
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved && saved !== theme) {
|
||||
setTheme(saved);
|
||||
} else if (!saved) {
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Select Theme</h2>
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
{themes.map((t) => (
|
||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue