diff --git a/src/pages/search/empty-states.tsx b/src/pages/search/empty-states.tsx new file mode 100644 index 0000000..5c6969a --- /dev/null +++ b/src/pages/search/empty-states.tsx @@ -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 ( + +
+
+
+
+ +
+
+

+ What are you looking for? +

+

+ Search across your library and TMDB for movies, shows, episodes, people, and music. +

+
+ + {recents.length > 0 && ( +
+
+

+ Recent +

+ +
+
+ {recents.map(r => ( + + ))} +
+
+ )} + +
+

+ Try searching +

+
+ {SUGGESTED.map(s => ( + + ))} +
+
+ + ) +} + +export function LoadingState() { + const grid = usePosterGridClasses() + return ( + + {/* People skeleton */} +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+
+ {/* Posters skeleton */} +
+
+
+ {Array.from({ length: 12 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+
+
+ ) +} + +export function NoResults({ query }: { query: string }) { + return ( + +
+
+
+ +
+
+

+ Nothing matched "{query}" +

+

+ Check the spelling, try a shorter query, or search for an actor or director. +

+ + ) +} diff --git a/src/pages/search/sections.tsx b/src/pages/search/sections.tsx new file mode 100644 index 0000000..aa969a1 --- /dev/null +++ b/src/pages/search/sections.tsx @@ -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 ( +
+
+

+ {label} +

+ {hint && {hint}} +
+ {count != null && ( + {count} + )} +
+ ) +} + +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 ( +
+ +
+ {people.map((p, i) => { + const knownFor = (p.known_for || []) + .slice(0, 2) + .map(k => k.title || k.name) + .filter(Boolean) + .join(', ') + return ( + 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" + > +
+ {p.profile_path ? ( + {p.name} + ) : ( +
+ +
+ )} +
+
+

+ {p.name} +

+ {p.known_for_department && ( +

+ {p.known_for_department} +

+ )} + {knownFor && ( +

+ {knownFor} +

+ )} +
+
+ ) + })} +
+
+ ) +} + +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 ( +
+ +
+ {cards.map((item, i) => ( + + item.Id && navigate(`/item/${item.Id}`)} + /> + + ))} +
+
+ ) +} + +export function EpisodesSection({ items }: { items: BaseItemDto[] }) { + const navigate = useNavigate() + const serverUrl = getStoredServerUrl() + + return ( +
+ +
+ {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 ( + 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" + > +
+ {thumb ? ( + + ) : ( +
+ +
+ )} + {seasonEp && ( + + {seasonEp} + + )} +
+
+ {seriesName && ( +

+ {seriesName} +

+ )} +

+ {ep.Name || 'Untitled'} +

+
+ {runtime && ( + + {runtime} + + )} + {ep.ProductionYear && ( + + {ep.ProductionYear} + + )} + {typeof ep.CommunityRating === 'number' && ( + + + {ep.CommunityRating.toFixed(1)} + + )} +
+
+
+ ) + })} +
+
+ ) +} + +export function MusicSection({ + albums, + artists, + tracks, +}: { albums: BaseItemDto[]; artists: BaseItemDto[]; tracks: BaseItemDto[] }) { + return ( +
+ {artists.length > 0 && } + {albums.length > 0 && } + {tracks.length > 0 && } +
+ ) +} + +function MusicGrid({ + label, + items, + circular, + icon: Icon, +}: { + label: string + items: BaseItemDto[] + circular?: boolean + icon: typeof User +}) { + const navigate = useNavigate() + const serverUrl = getStoredServerUrl() + return ( +
+ +
+ {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 ( + 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" + > +
+ {img ? ( + + ) : ( +
+ +
+ )} +
+

+ {item.Name || 'Untitled'} +

+ {subtitle && ( +

{subtitle}

+ )} +
+ ) + })} +
+
+ ) +} + +function TrackList({ items }: { items: BaseItemDto[] }) { + const navigate = useNavigate() + const serverUrl = getStoredServerUrl() + return ( +
+ +
+ {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 ( + 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" + > +
+ {img ? ( + + ) : ( + + )} +
+
+

+ {item.Name || 'Untitled'} +

+ {sub &&

{sub}

} +
+ {runtime && ( + {runtime} + )} +
+ ) + })} +
+
+ ) +}