mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-16 19:05:54 -07:00
chore: initial public commit
This commit is contained in:
commit
fc9054b78c
250 changed files with 32809 additions and 0 deletions
57
client/app/routes/MediaItems/Album.tsx
Normal file
57
client/app/routes/MediaItems/Album.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useState } from "react";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import { mergeAlbums, type Album } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album", { status: 500 });
|
||||
}
|
||||
const album: Album = await res.json();
|
||||
return album;
|
||||
}
|
||||
|
||||
export default function Album() {
|
||||
const album = useLoaderData() as Album;
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
console.log(album)
|
||||
|
||||
return (
|
||||
<MediaLayout type="Album"
|
||||
title={album.title}
|
||||
img={album.image}
|
||||
id={album.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={album.id}
|
||||
mergeFunc={mergeAlbums}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.artists = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.albums.length; i ++) {
|
||||
if (r.albums[i].id === id) {
|
||||
delete r.albums[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex gap-20 mt-10">
|
||||
<LastPlays limit={30} albumId={album.id} />
|
||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||
<ActivityGrid autoAdjust configurable albumId={album.id} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
66
client/app/routes/MediaItems/Artist.tsx
Normal file
66
client/app/routes/MediaItems/Artist.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { useState } from "react";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import { mergeArtists, type Artist } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ArtistAlbums from "~/components/ArtistAlbums";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load artist", { status: 500 });
|
||||
}
|
||||
const artist: Artist = await res.json();
|
||||
return artist;
|
||||
}
|
||||
|
||||
export default function Artist() {
|
||||
const artist = useLoaderData() as Artist;
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
// remove canonical name from alias list
|
||||
console.log(artist.aliases)
|
||||
let index = artist.aliases.indexOf(artist.name);
|
||||
if (index !== -1) {
|
||||
artist.aliases.splice(index, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaLayout type="Artist"
|
||||
title={artist.name}
|
||||
img={artist.image}
|
||||
id={artist.id}
|
||||
musicbrainzId={artist.musicbrainz_id}
|
||||
imgItemId={artist.id}
|
||||
mergeFunc={mergeArtists}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.artists.length; i ++) {
|
||||
if (r.artists[i].id === id) {
|
||||
delete r.artists[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-20">
|
||||
<div className="flex gap-15 mt-10 flex-wrap">
|
||||
<LastPlays limit={20} artistId={artist.id} />
|
||||
<TopTracks limit={8} period={period} artistId={artist.id} />
|
||||
<ActivityGrid configurable autoAdjust artistId={artist.id} />
|
||||
</div>
|
||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
88
client/app/routes/MediaItems/MediaLayout.tsx
Normal file
88
client/app/routes/MediaItems/MediaLayout.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { average } from "color.js";
|
||||
import { imageUrl, type SearchResponse } from "api/api";
|
||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
||||
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import MergeModal from "~/components/modals/MergeModal";
|
||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||
import DeleteModal from "~/components/modals/DeleteModal";
|
||||
import RenameModal from "~/components/modals/RenameModal";
|
||||
|
||||
export type MergeFunc = (from: number, to: number) => Promise<Response>
|
||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||
|
||||
interface Props {
|
||||
type: "Track" | "Album" | "Artist"
|
||||
title: string
|
||||
img: string
|
||||
id: number
|
||||
musicbrainzId: string
|
||||
imgItemId: number
|
||||
mergeFunc: MergeFunc
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
||||
children: React.ReactNode
|
||||
subContent: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MediaLayout(props: Props) {
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||
});
|
||||
}, [props.img]);
|
||||
|
||||
const replaceImageCallback = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const title = `${props.title} - Koito`
|
||||
|
||||
return (
|
||||
<main
|
||||
className="w-full flex flex-col flex-grow"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
|
||||
transition: '1000',
|
||||
}}
|
||||
>
|
||||
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta
|
||||
name="description"
|
||||
content={title}
|
||||
/>
|
||||
<div className="w-19/20 mx-auto pt-12">
|
||||
<div className="flex gap-8 relative">
|
||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="w-sm shadow-(--color-shadow) shadow-lg" />
|
||||
<div className="flex flex-col items-start">
|
||||
<h3>{props.type}</h3>
|
||||
<h1>{props.title}</h1>
|
||||
{props.subContent}
|
||||
</div>
|
||||
{ user &&
|
||||
<div className="absolute right-1 flex gap-3 items-center">
|
||||
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={30} /></button>
|
||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={30} /></button>
|
||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={30} /></button>
|
||||
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={30} /></button>
|
||||
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
|
||||
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
|
||||
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />
|
||||
<DeleteModal open={deleteModalOpen} setOpen={setDeleteModalOpen} title={props.title} id={props.id} type={props.type} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
59
client/app/routes/MediaItems/Track.tsx
Normal file
59
client/app/routes/MediaItems/Track.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { mergeTracks, type Album, type Track } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load track", { status: res.status });
|
||||
}
|
||||
const track: Track = await res.json();
|
||||
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album for track", { status: res.status })
|
||||
}
|
||||
const album: Album = await res.json()
|
||||
return {track: track, album: album};
|
||||
}
|
||||
|
||||
export default function Track() {
|
||||
const { track, album } = useLoaderData();
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
return (
|
||||
<MediaLayout type="Track"
|
||||
title={track.title}
|
||||
img={track.image}
|
||||
id={track.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={track.album_id}
|
||||
mergeFunc={mergeTracks}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.artists = []
|
||||
for (let i = 0; i < r.tracks.length; i ++) {
|
||||
if (r.tracks[i].id === id) {
|
||||
delete r.tracks[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<div className="flex flex-col gap-4 items-start">
|
||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
||||
</div>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex gap-20 mt-10">
|
||||
<LastPlays limit={20} trackId={track.id}/>
|
||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue