search page

This commit is contained in:
2026-03-31 13:09:57 +03:00
parent 1078149613
commit c79604bc69
2 changed files with 495 additions and 0 deletions
+158
View File
@@ -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>
)
}
+337
View File
@@ -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>
)
}