Add frontend pages for music and books
Some checks failed
Rebuild Issue Index / build-index (push) Has been cancelled

- Create MusicDetails component with artist info, albums, stats, request button
- Create BookDetails component with cover, overview, request button
- Create music/[artistId] and book/[bookId] pages
- Update Search component with tabbed interface (Movies & TV, Music, Books)
- Music/Book search results with card grid and cover art
- Link to detail pages from search results
This commit is contained in:
root
2026-04-03 21:19:40 -05:00
parent 466db07e37
commit af6579163b
5 changed files with 546 additions and 12 deletions

View File

@@ -0,0 +1,111 @@
import Button from '@app/components/Common/Button';
import PageTitle from '@app/components/Common/PageTitle';
import { useUser } from '@app/hooks/useUser';
import axios from 'axios';
import { useState } from 'react';
interface BookDetailsProps {
book?: any;
}
const BookDetails = ({ book }: BookDetailsProps) => {
const { user } = useUser();
const [requesting, setRequesting] = useState(false);
const [requested, setRequested] = useState(false);
if (!book) {
return (
<div className="mt-16 text-center text-gray-400">
<h2 className="text-2xl">Book not found</h2>
</div>
);
}
const coverUrl =
book.images?.find((i: any) => i.coverType === 'cover')?.remoteUrl ||
book.images?.[0]?.remoteUrl;
const handleRequest = async () => {
setRequesting(true);
try {
await axios.post('/api/v1/request', {
mediaType: 'book',
mediaId: 0,
foreignId: book.foreignBookId,
foreignTitle: book.title,
});
setRequested(true);
} catch (e) {
console.error('Request failed', e);
}
setRequesting(false);
};
return (
<div className="media-page">
<PageTitle title={book.title} />
<div className="media-header">
<div className="media-poster">
{coverUrl && (
<img
src={coverUrl}
alt={book.title}
className="rounded-lg shadow-lg"
/>
)}
</div>
<div className="media-title">
<h1 className="text-4xl font-bold text-white">{book.title}</h1>
{book.author && (
<p className="mt-1 text-lg text-gray-400">
by {book.author.name}
</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{book.genres?.slice(0, 5).map((genre: string) => (
<span
key={genre}
className="rounded-full bg-gray-700 px-3 py-1 text-sm text-gray-300"
>
{genre}
</span>
))}
</div>
{book.pageCount > 0 && (
<p className="mt-2 text-sm text-gray-400">
{book.pageCount} pages ·{' '}
{book.releaseDate?.substring(0, 4)}
</p>
)}
<div className="media-actions mt-4">
{requested ? (
<Button disabled buttonType="success">
<span> Requested</span>
</Button>
) : (
<Button
buttonType="primary"
onClick={handleRequest}
disabled={requesting}
>
<span>
{requesting ? 'Requesting...' : '📚 Request Book'}
</span>
</Button>
)}
</div>
</div>
</div>
{book.overview && (
<div className="mt-8">
<h2 className="text-xl font-bold text-white">Overview</h2>
<p className="mt-2 text-gray-300">{book.overview}</p>
</div>
)}
</div>
);
};
export default BookDetails;

View File

@@ -0,0 +1,183 @@
import Button from '@app/components/Common/Button';
import PageTitle from '@app/components/Common/PageTitle';
import { useUser } from '@app/hooks/useUser';
import axios from 'axios';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useState } from 'react';
interface MusicDetailsProps {
artist?: any;
}
const MusicDetails = ({ artist }: MusicDetailsProps) => {
const router = useRouter();
const { user } = useUser();
const [requesting, setRequesting] = useState(false);
const [requested, setRequested] = useState(artist?.inLibrary || false);
if (!artist) {
return (
<div className="mt-16 text-center text-gray-400">
<h2 className="text-2xl">Artist not found</h2>
</div>
);
}
const posterUrl =
artist.images?.find((i: any) => i.coverType === 'poster')?.remoteUrl ||
artist.images?.[0]?.remoteUrl;
const handleRequest = async () => {
setRequesting(true);
try {
await axios.post('/api/v1/request', {
mediaType: 'music',
mediaId: 0,
foreignId: artist.foreignArtistId,
foreignTitle: artist.artistName,
});
setRequested(true);
} catch (e) {
console.error('Request failed', e);
}
setRequesting(false);
};
return (
<div className="media-page">
<PageTitle title={artist.artistName} />
<div className="media-page-bg-image">
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: posterUrl ? `url(${posterUrl})` : undefined,
filter: 'blur(16px) brightness(0.3)',
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-gray-900" />
</div>
<div className="media-header">
<div className="media-poster">
{posterUrl && (
<img
src={posterUrl}
alt={artist.artistName}
className="rounded-lg shadow-lg"
/>
)}
</div>
<div className="media-title">
<h1 className="text-4xl font-bold text-white">
{artist.artistName}
</h1>
{artist.disambiguation && (
<p className="mt-1 text-lg text-gray-400">
{artist.disambiguation}
</p>
)}
<div className="mt-2 flex flex-wrap gap-2">
{artist.genres?.slice(0, 5).map((genre: string) => (
<span
key={genre}
className="rounded-full bg-gray-700 px-3 py-1 text-sm text-gray-300"
>
{genre}
</span>
))}
</div>
<div className="media-actions mt-4">
{requested ? (
<Button disabled buttonType="success">
<span>
{artist.inLibrary ? '✓ In Library' : '✓ Requested'}
</span>
</Button>
) : (
<Button
buttonType="primary"
onClick={handleRequest}
disabled={requesting}
>
<span>
{requesting ? 'Requesting...' : '🎵 Request Artist'}
</span>
</Button>
)}
</div>
</div>
</div>
{/* Albums */}
{artist.albums && artist.albums.length > 0 && (
<div className="slider-header mt-8">
<h2 className="text-xl font-bold text-white">
Albums ({artist.albums.length})
</h2>
<div className="mt-4 grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{artist.albums.map((album: any) => {
const albumArt =
album.images?.find((i: any) => i.coverType === 'cover')
?.remoteUrl || album.images?.[0]?.remoteUrl;
return (
<div
key={album.id}
className="group relative overflow-hidden rounded-lg bg-gray-800 shadow-lg"
>
{albumArt && (
<img
src={albumArt}
alt={album.title}
className="aspect-square w-full object-cover"
/>
)}
<div className="p-2">
<h3 className="truncate text-sm font-semibold text-white">
{album.title}
</h3>
<p className="text-xs text-gray-400">
{album.releaseDate?.substring(0, 4)} · {album.albumType}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
{/* Stats */}
{artist.statistics && (
<div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">Albums</p>
<p className="text-2xl font-bold text-white">
{artist.statistics.albumCount}
</p>
</div>
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">Tracks</p>
<p className="text-2xl font-bold text-white">
{artist.statistics.totalTrackCount}
</p>
</div>
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">On Disk</p>
<p className="text-2xl font-bold text-white">
{artist.statistics.trackFileCount}
</p>
</div>
<div className="rounded-lg bg-gray-800 p-4">
<p className="text-sm text-gray-400">Size</p>
<p className="text-2xl font-bold text-white">
{(artist.statistics.sizeOnDisk / 1073741824).toFixed(1)} GB
</p>
</div>
</div>
)}
</div>
);
};
export default MusicDetails;

View File

@@ -9,7 +9,10 @@ import type {
PersonResult, PersonResult,
TvResult, TvResult,
} from '@server/models/Search'; } from '@server/models/Search';
import axios from 'axios';
import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
const messages = defineMessages('components.Search', { const messages = defineMessages('components.Search', {
@@ -17,9 +20,86 @@ const messages = defineMessages('components.Search', {
searchresults: 'Search Results', searchresults: 'Search Results',
}); });
type SearchTab = 'all' | 'music' | 'books';
const MusicResultCard = ({ artist }: { artist: any }) => {
const posterUrl =
artist.images?.find((i: any) => i.coverType === 'poster')?.remoteUrl ||
artist.images?.[0]?.remoteUrl;
return (
<Link href={`/music/${artist.foreignArtistId}`}>
<div className="group relative w-36 cursor-pointer overflow-hidden rounded-lg bg-gray-800 shadow-lg transition duration-200 hover:scale-105 sm:w-36 md:w-44">
<div className="aspect-[2/3] w-full">
{posterUrl ? (
<img
src={posterUrl}
alt={artist.name}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center bg-gray-700 text-4xl">
🎵
</div>
)}
</div>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/80 via-transparent p-2">
<div>
<h3 className="text-sm font-bold text-white">{artist.name}</h3>
<p className="text-xs text-gray-300">
{artist.artistType || 'Artist'}
{artist.inLibrary && ' · ✓ In Library'}
</p>
</div>
</div>
</div>
</Link>
);
};
const BookResultCard = ({ book }: { book: any }) => {
const coverUrl =
book.images?.find((i: any) => i.coverType === 'cover')?.remoteUrl ||
book.images?.[0]?.remoteUrl;
return (
<Link href={`/book/${book.foreignBookId}`}>
<div className="group relative w-36 cursor-pointer overflow-hidden rounded-lg bg-gray-800 shadow-lg transition duration-200 hover:scale-105 sm:w-36 md:w-44">
<div className="aspect-[2/3] w-full">
{coverUrl ? (
<img
src={coverUrl}
alt={book.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center bg-gray-700 text-4xl">
📚
</div>
)}
</div>
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/80 via-transparent p-2">
<div>
<h3 className="text-sm font-bold text-white">{book.title}</h3>
<p className="text-xs text-gray-300">
{book.author?.name || ''}
{book.releaseDate && ` · ${book.releaseDate.substring(0, 4)}`}
</p>
</div>
</div>
</div>
</Link>
);
};
const Search = () => { const Search = () => {
const intl = useIntl(); const intl = useIntl();
const router = useRouter(); const router = useRouter();
const [activeTab, setActiveTab] = useState<SearchTab>('all');
const [musicResults, setMusicResults] = useState<any[]>([]);
const [bookResults, setBookResults] = useState<any[]>([]);
const [musicLoading, setMusicLoading] = useState(false);
const [bookLoading, setBookLoading] = useState(false);
const { const {
isLoadingInitialData, isLoadingInitialData,
@@ -31,31 +111,125 @@ const Search = () => {
error, error,
} = useDiscover<MovieResult | TvResult | PersonResult>( } = useDiscover<MovieResult | TvResult | PersonResult>(
`/api/v1/search`, `/api/v1/search`,
{ { query: router.query.query },
query: router.query.query,
},
{ hideAvailable: false, hideBlocklisted: false } { hideAvailable: false, hideBlocklisted: false }
); );
// Search music and books when query changes
useEffect(() => {
const query = router.query.query as string;
if (!query) return;
setMusicLoading(true);
axios
.get(`/api/v1/music/search?query=${encodeURIComponent(query)}`)
.then((res) => setMusicResults(res.data?.results || []))
.catch(() => setMusicResults([]))
.finally(() => setMusicLoading(false));
setBookLoading(true);
axios
.get(`/api/v1/book/search?query=${encodeURIComponent(query)}`)
.then((res) => setBookResults(res.data?.results || []))
.catch(() => setBookResults([]))
.finally(() => setBookLoading(false));
}, [router.query.query]);
if (error) { if (error) {
return <ErrorPage statusCode={500} />; return <ErrorPage statusCode={500} />;
} }
const tabs: { id: SearchTab; label: string; count: number }[] = [
{ id: 'all', label: 'Movies & TV', count: titles?.length || 0 },
{ id: 'music', label: '🎵 Music', count: musicResults.length },
{ id: 'books', label: '📚 Books', count: bookResults.length },
];
return ( return (
<> <>
<PageTitle title={intl.formatMessage(messages.search)} /> <PageTitle title={intl.formatMessage(messages.search)} />
<div className="mb-5 mt-1"> <div className="mb-5 mt-1">
<Header>{intl.formatMessage(messages.searchresults)}</Header> <Header>{intl.formatMessage(messages.searchresults)}</Header>
</div> </div>
<ListView
items={titles} {/* Tabs */}
isEmpty={isEmpty} <div className="mb-6 flex gap-2 border-b border-gray-700">
isLoading={ {tabs.map((tab) => (
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0) <button
} key={tab.id}
isReachingEnd={isReachingEnd} onClick={() => setActiveTab(tab.id)}
onScrollBottom={fetchMore} className={`px-4 py-2 text-sm font-medium transition ${
/> activeTab === tab.id
? 'border-b-2 border-indigo-500 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
{tab.label}
{tab.count > 0 && (
<span className="ml-1 text-xs text-gray-500">
({tab.count})
</span>
)}
</button>
))}
</div>
{/* Movies & TV Tab */}
{activeTab === 'all' && (
<ListView
items={titles}
isEmpty={isEmpty}
isLoading={
isLoadingInitialData ||
(isLoadingMore && (titles?.length ?? 0) > 0)
}
isReachingEnd={isReachingEnd}
onScrollBottom={fetchMore}
/>
)}
{/* Music Tab */}
{activeTab === 'music' && (
<div>
{musicLoading ? (
<div className="text-center text-gray-400">Searching music...</div>
) : musicResults.length === 0 ? (
<div className="text-center text-gray-400">
No music results found.
{!router.query.query && ' Try searching for an artist or album.'}
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7">
{musicResults.map((artist) => (
<MusicResultCard
key={artist.foreignArtistId}
artist={artist}
/>
))}
</div>
)}
</div>
)}
{/* Books Tab */}
{activeTab === 'books' && (
<div>
{bookLoading ? (
<div className="text-center text-gray-400">Searching books...</div>
) : bookResults.length === 0 ? (
<div className="text-center text-gray-400">
No book results found.
{!router.query.query && ' Try searching for a title or author.'}
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7">
{bookResults.map((book) => (
<BookResultCard key={book.foreignBookId} book={book} />
))}
</div>
)}
</div>
)}
</> </>
); );
}; };

View File

@@ -0,0 +1,33 @@
import BookDetails from '@app/components/BookDetails';
import axios from 'axios';
import type { GetServerSideProps, NextPage } from 'next';
interface BookPageProps {
book?: any;
}
const BookPage: NextPage<BookPageProps> = ({ book }) => {
return <BookDetails book={book} />;
};
export const getServerSideProps: GetServerSideProps<BookPageProps> = async (
ctx
) => {
try {
const response = await axios.get(
`http://${process.env.HOST || 'localhost'}:${
process.env.PORT || 5055
}/api/v1/book/search?query=${ctx.query.bookId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return { props: { book: response.data?.results?.[0] } };
} catch {
return { props: {} };
}
};
export default BookPage;

View File

@@ -0,0 +1,33 @@
import MusicDetails from '@app/components/MusicDetails';
import axios from 'axios';
import type { GetServerSideProps, NextPage } from 'next';
interface MusicPageProps {
artist?: any;
}
const MusicPage: NextPage<MusicPageProps> = ({ artist }) => {
return <MusicDetails artist={artist} />;
};
export const getServerSideProps: GetServerSideProps<MusicPageProps> = async (
ctx
) => {
try {
const response = await axios.get(
`http://${process.env.HOST || 'localhost'}:${
process.env.PORT || 5055
}/api/v1/music/artist/${ctx.query.artistId}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return { props: { artist: response.data } };
} catch {
return { props: {} };
}
};
export default MusicPage;