Add frontend pages for music and books
Some checks failed
Rebuild Issue Index / build-index (push) Has been cancelled
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:
111
src/components/BookDetails/index.tsx
Normal file
111
src/components/BookDetails/index.tsx
Normal 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;
|
||||
183
src/components/MusicDetails/index.tsx
Normal file
183
src/components/MusicDetails/index.tsx
Normal 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;
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from '@server/models/Search';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Search', {
|
||||
@@ -17,9 +20,86 @@ const messages = defineMessages('components.Search', {
|
||||
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 intl = useIntl();
|
||||
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 {
|
||||
isLoadingInitialData,
|
||||
@@ -31,31 +111,125 @@ const Search = () => {
|
||||
error,
|
||||
} = useDiscover<MovieResult | TvResult | PersonResult>(
|
||||
`/api/v1/search`,
|
||||
{
|
||||
query: router.query.query,
|
||||
},
|
||||
{ query: router.query.query },
|
||||
{ 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) {
|
||||
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 (
|
||||
<>
|
||||
<PageTitle title={intl.formatMessage(messages.search)} />
|
||||
<div className="mb-5 mt-1">
|
||||
<Header>{intl.formatMessage(messages.searchresults)}</Header>
|
||||
</div>
|
||||
<ListView
|
||||
items={titles}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={
|
||||
isLoadingInitialData || (isLoadingMore && (titles?.length ?? 0) > 0)
|
||||
}
|
||||
isReachingEnd={isReachingEnd}
|
||||
onScrollBottom={fetchMore}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex gap-2 border-b border-gray-700">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
33
src/pages/book/[bookId]/index.tsx
Normal file
33
src/pages/book/[bookId]/index.tsx
Normal 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;
|
||||
33
src/pages/music/[artistId]/index.tsx
Normal file
33
src/pages/music/[artistId]/index.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user