search page
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Search, Clock } from '../../lib/icons'
|
||||
import { usePosterGridClasses } from '../../lib/density'
|
||||
|
||||
const SUGGESTED: string[] = [
|
||||
'Top action movies',
|
||||
'Christopher Nolan',
|
||||
'Oscar winners',
|
||||
'A24',
|
||||
'Studio Ghibli',
|
||||
'Denis Villeneuve',
|
||||
]
|
||||
|
||||
export function EmptyState({
|
||||
recents,
|
||||
onPick,
|
||||
onClearRecents,
|
||||
}: { recents: string[]; onPick: (s: string) => void; onClearRecents: () => void }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="max-w-2xl mx-auto pt-6 sm:pt-12"
|
||||
>
|
||||
<div className="text-center mb-12">
|
||||
<div className="relative w-16 h-16 mx-auto mb-5">
|
||||
<div className="absolute inset-0 rounded-full bg-accent/15 blur-2xl" />
|
||||
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
||||
<Search size={22} className="text-text-2" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="font-display text-[22px] sm:text-[26px] text-text-1 font-semibold tracking-tight">
|
||||
What are you looking for?
|
||||
</h1>
|
||||
<p className="text-[13.5px] text-text-3 mt-2">
|
||||
Search across your library and TMDB for movies, shows, episodes, people, and music.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{recents.length > 0 && (
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">
|
||||
Recent
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClearRecents}
|
||||
className="text-[11px] text-text-4 hover:text-text-2 transition-colors focus-ring rounded px-1"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{recents.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onPick(r)}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md bg-elevated/60 ring-1 ring-border text-[12.5px] text-text-2 hover:text-text-1 hover:ring-border-strong hover:bg-elevated transition-all duration-200 focus-ring"
|
||||
>
|
||||
<Clock size={11} className="text-text-4" />
|
||||
<span className="truncate max-w-[200px]">{r}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h2 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em] mb-3">
|
||||
Try searching
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SUGGESTED.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onPick(s)}
|
||||
className="inline-flex items-center h-8 px-3 rounded-md bg-elevated/40 ring-1 ring-border text-[12.5px] text-text-2 hover:text-accent hover:ring-accent/40 hover:bg-elevated/70 transition-all duration-200 focus-ring"
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoadingState() {
|
||||
const grid = usePosterGridClasses()
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-12"
|
||||
>
|
||||
{/* People skeleton */}
|
||||
<section>
|
||||
<div className="skeleton h-3 w-20 rounded mb-4" />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-5 gap-y-7">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 p-2.5">
|
||||
<div className="skeleton w-20 h-20 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-3 w-2/3 rounded" />
|
||||
<div className="skeleton h-2.5 w-1/3 rounded" />
|
||||
<div className="skeleton h-2.5 w-3/4 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{/* Posters skeleton */}
|
||||
<section>
|
||||
<div className="skeleton h-3 w-24 rounded mb-4" />
|
||||
<div className={grid}>
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="skeleton aspect-[2/3] rounded-lg" />
|
||||
<div className="mt-2.5 space-y-1.5">
|
||||
<div className="skeleton h-3 w-3/4 rounded" />
|
||||
<div className="skeleton h-2.5 w-1/2 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NoResults({ query }: { query: string }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-center py-20 max-w-md mx-auto"
|
||||
>
|
||||
<div className="relative w-14 h-14 mx-auto mb-4">
|
||||
<div className="absolute inset-0 rounded-full bg-text-5/10 blur-xl" />
|
||||
<div className="relative w-full h-full rounded-full bg-gradient-to-br from-elevated to-surface ring-1 ring-border grid place-items-center">
|
||||
<Search size={20} className="text-text-3" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-display text-[18px] text-text-1 font-semibold tracking-tight">
|
||||
Nothing matched "{query}"
|
||||
</p>
|
||||
<p className="text-[13px] text-text-3 mt-2 leading-relaxed">
|
||||
Check the spelling, try a shorter query, or search for an actor or director.
|
||||
</p>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { motion } from 'framer-motion'
|
||||
import { User, Clock, Calendar, Star, Disc3, Music, FileVideo } from '../../lib/icons'
|
||||
import { getBestImage, getStoredServerUrl } from '../../api/jellyfin'
|
||||
import { getTmdbImageUrl } from '../../api/tmdb'
|
||||
import { formatRuntime } from '../../lib/format'
|
||||
import { usePosterGridClasses } from '../../lib/density'
|
||||
import PosterCard from '../../components/ui/PosterCard'
|
||||
import type { BaseItemDto } from '../../api/types'
|
||||
|
||||
export function SectionHeader({
|
||||
label,
|
||||
hint,
|
||||
count,
|
||||
}: { label: string; hint?: string; count?: number }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between mb-4">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="text-[11px] font-semibold text-text-2 uppercase tracking-[0.14em]">
|
||||
{label}
|
||||
</h2>
|
||||
{hint && <span className="text-[11px] text-text-4">{hint}</span>}
|
||||
</div>
|
||||
{count != null && (
|
||||
<span className="text-[11px] text-text-4 tabular-nums">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TmdbPerson {
|
||||
id: number
|
||||
name?: string
|
||||
profile_path?: string | null
|
||||
known_for_department?: string
|
||||
known_for?: Array<{ title?: string; name?: string }>
|
||||
}
|
||||
|
||||
export function PeopleSection({ people }: { people: TmdbPerson[] }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader label="People" count={people.length} />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-x-5 gap-y-7">
|
||||
{people.map((p, i) => {
|
||||
const knownFor = (p.known_for || [])
|
||||
.slice(0, 2)
|
||||
.map(k => k.title || k.name)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
return (
|
||||
<motion.button
|
||||
key={p.id}
|
||||
onClick={() => navigate(`/person/${p.id}`)}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.24, delay: Math.min(i * 0.025, 0.2), ease: [0.16, 1, 0.3, 1] }}
|
||||
className="group flex items-center gap-4 p-2.5 -mx-2.5 rounded-xl hover:bg-elevated/50 transition-colors duration-200 text-left focus-ring"
|
||||
>
|
||||
<div className="relative w-20 h-20 rounded-full overflow-hidden bg-elevated shrink-0 ring-1 ring-border group-hover:ring-accent/40 transition-shadow duration-200">
|
||||
{p.profile_path ? (
|
||||
<img
|
||||
src={getTmdbImageUrl(p.profile_path, 'w185')}
|
||||
alt={p.name}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
|
||||
<User size={26} className="text-text-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-display text-[15px] text-text-1 font-semibold truncate tracking-tight">
|
||||
{p.name}
|
||||
</p>
|
||||
{p.known_for_department && (
|
||||
<p className="text-[11px] text-text-3 mt-0.5">
|
||||
{p.known_for_department}
|
||||
</p>
|
||||
)}
|
||||
{knownFor && (
|
||||
<p className="text-[12px] text-text-4 mt-1 line-clamp-2 leading-snug">
|
||||
{knownFor}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function MoviesShowsSection({
|
||||
cards,
|
||||
localCount,
|
||||
tmdbExtraCount,
|
||||
}: { cards: BaseItemDto[]; localCount: number; tmdbExtraCount: number }) {
|
||||
const navigate = useNavigate()
|
||||
const grid = usePosterGridClasses()
|
||||
const hint =
|
||||
localCount && tmdbExtraCount
|
||||
? `${localCount} in your library, ${tmdbExtraCount} on TMDB`
|
||||
: localCount
|
||||
? `${localCount} in your library`
|
||||
: tmdbExtraCount
|
||||
? `${tmdbExtraCount} on TMDB`
|
||||
: undefined
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader label="Movies & TV" hint={hint} count={cards.length} />
|
||||
<div className={grid}>
|
||||
{cards.map((item, i) => (
|
||||
<motion.div
|
||||
key={item.Id || i}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.32, delay: Math.min(i * 0.025, 0.2), ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<PosterCard
|
||||
item={item}
|
||||
priority={i < 6}
|
||||
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function EpisodesSection({ items }: { items: BaseItemDto[] }) {
|
||||
const navigate = useNavigate()
|
||||
const serverUrl = getStoredServerUrl()
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionHeader label="Episodes" count={items.length} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{items.map((ep, i) => {
|
||||
// Episode still lives in the Primary image type for Episodes;
|
||||
// Thumb is rarely populated. Try Primary first, fall back to
|
||||
// backdrop (which falls back to parent series), then thumb.
|
||||
const thumb =
|
||||
getBestImage(serverUrl, ep, 'primary', 480) ||
|
||||
getBestImage(serverUrl, ep, 'backdrop', 480) ||
|
||||
getBestImage(serverUrl, ep, 'thumb', 480) ||
|
||||
''
|
||||
const seasonEp =
|
||||
ep.ParentIndexNumber != null && ep.IndexNumber != null
|
||||
? `S${String(ep.ParentIndexNumber).padStart(2, '0')}E${String(ep.IndexNumber).padStart(2, '0')}`
|
||||
: ''
|
||||
const seriesName = ep.SeriesName || ''
|
||||
const runtime = ep.RunTimeTicks ? formatRuntime(ep.RunTimeTicks) : ''
|
||||
return (
|
||||
<motion.button
|
||||
key={ep.Id || i}
|
||||
onClick={() => ep.Id && navigate(`/item/${ep.Id}`)}
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.26, delay: Math.min(i * 0.02, 0.2), ease: [0.16, 1, 0.3, 1] }}
|
||||
className="group flex items-stretch gap-4 p-2.5 rounded-xl bg-elevated/40 ring-1 ring-border hover:ring-border-strong hover:bg-elevated/70 transition-all duration-200 text-left focus-ring"
|
||||
>
|
||||
<div className="relative w-[144px] aspect-video rounded-md overflow-hidden bg-black shrink-0 ring-1 ring-border">
|
||||
{thumb ? (
|
||||
<img src={thumb} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center text-text-4">
|
||||
<FileVideo size={22} />
|
||||
</div>
|
||||
)}
|
||||
{seasonEp && (
|
||||
<span className="absolute top-1.5 left-1.5 inline-flex items-center h-[18px] px-1.5 rounded text-[10px] font-bold tracking-wider uppercase bg-black/70 text-white backdrop-blur-sm tabular-nums">
|
||||
{seasonEp}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex flex-col py-0.5">
|
||||
{seriesName && (
|
||||
<p className="text-[11px] uppercase tracking-[0.12em] text-text-3 truncate font-semibold">
|
||||
{seriesName}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[14px] text-text-1 font-medium truncate mt-0.5 tracking-tight">
|
||||
{ep.Name || 'Untitled'}
|
||||
</p>
|
||||
<div className="mt-auto flex items-center gap-3 text-[11.5px] text-text-3 tabular-nums">
|
||||
{runtime && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Clock size={11} className="text-text-4" /> {runtime}
|
||||
</span>
|
||||
)}
|
||||
{ep.ProductionYear && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Calendar size={11} className="text-text-4" /> {ep.ProductionYear}
|
||||
</span>
|
||||
)}
|
||||
{typeof ep.CommunityRating === 'number' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star size={11} className="text-accent" />
|
||||
{ep.CommunityRating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function MusicSection({
|
||||
albums,
|
||||
artists,
|
||||
tracks,
|
||||
}: { albums: BaseItemDto[]; artists: BaseItemDto[]; tracks: BaseItemDto[] }) {
|
||||
return (
|
||||
<section className="space-y-10">
|
||||
{artists.length > 0 && <MusicGrid label="Artists" items={artists} circular icon={User} />}
|
||||
{albums.length > 0 && <MusicGrid label="Albums" items={albums} icon={Disc3} />}
|
||||
{tracks.length > 0 && <TrackList items={tracks} />}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function MusicGrid({
|
||||
label,
|
||||
items,
|
||||
circular,
|
||||
icon: Icon,
|
||||
}: {
|
||||
label: string
|
||||
items: BaseItemDto[]
|
||||
circular?: boolean
|
||||
icon: typeof User
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const serverUrl = getStoredServerUrl()
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader label={label} count={items.length} />
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 gap-4 gap-y-6">
|
||||
{items.map((item, i) => {
|
||||
const img = getBestImage(serverUrl, item, 'primary', 240)
|
||||
const augmented = item as BaseItemDto & { AlbumArtist?: string; Artists?: string[] }
|
||||
const subtitle =
|
||||
augmented.AlbumArtist ||
|
||||
(Array.isArray(augmented.Artists) ? augmented.Artists.slice(0, 2).join(', ') : '') ||
|
||||
''
|
||||
return (
|
||||
<motion.button
|
||||
key={item.Id || i}
|
||||
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, delay: Math.min(i * 0.025, 0.2), ease: [0.16, 1, 0.3, 1] }}
|
||||
className="group text-left focus-ring rounded-lg"
|
||||
>
|
||||
<div
|
||||
className={`relative aspect-square overflow-hidden bg-elevated ring-1 ring-border group-hover:ring-border-strong transition-shadow duration-200 ${
|
||||
circular ? 'rounded-full' : 'rounded-lg'
|
||||
}`}
|
||||
>
|
||||
{img ? (
|
||||
<img src={img} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<div className="w-full h-full grid place-items-center bg-gradient-to-br from-elevated to-surface">
|
||||
<Icon size={28} className="text-text-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2.5 text-[13px] text-text-1 font-medium truncate tracking-tight">
|
||||
{item.Name || 'Untitled'}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-[11.5px] text-text-3 truncate mt-0.5">{subtitle}</p>
|
||||
)}
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TrackList({ items }: { items: BaseItemDto[] }) {
|
||||
const navigate = useNavigate()
|
||||
const serverUrl = getStoredServerUrl()
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader label="Tracks" count={items.length} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
|
||||
{items.map((item, i) => {
|
||||
const img = getBestImage(serverUrl, item, 'primary', 96)
|
||||
const augmented = item as BaseItemDto & { AlbumArtist?: string; Artists?: string[]; Album?: string }
|
||||
const sub = [
|
||||
augmented.AlbumArtist || (Array.isArray(augmented.Artists) ? augmented.Artists[0] : ''),
|
||||
augmented.Album,
|
||||
].filter(Boolean).join(' - ')
|
||||
const runtime = item.RunTimeTicks ? formatRuntime(item.RunTimeTicks) : ''
|
||||
return (
|
||||
<motion.button
|
||||
key={item.Id || i}
|
||||
onClick={() => item.Id && navigate(`/item/${item.Id}`)}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.22, delay: Math.min(i * 0.02, 0.2), ease: [0.16, 1, 0.3, 1] }}
|
||||
className="group flex items-center gap-3 p-2 rounded-lg hover:bg-elevated/60 transition-colors duration-150 text-left focus-ring"
|
||||
>
|
||||
<div className="w-11 h-11 rounded-md overflow-hidden bg-elevated shrink-0 ring-1 ring-border grid place-items-center">
|
||||
{img ? (
|
||||
<img src={img} alt="" className="w-full h-full object-cover" loading="lazy" />
|
||||
) : (
|
||||
<Music size={16} className="text-text-3" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[13.5px] text-text-1 truncate font-medium tracking-tight">
|
||||
{item.Name || 'Untitled'}
|
||||
</p>
|
||||
{sub && <p className="text-[11.5px] text-text-3 truncate">{sub}</p>}
|
||||
</div>
|
||||
{runtime && (
|
||||
<span className="text-[11px] text-text-4 tabular-nums shrink-0">{runtime}</span>
|
||||
)}
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user