mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-04 20:43:03 +00:00
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
import { Calendar, Clock, Eye, Film, Gamepad2, Plus, Star, Tv, X } from 'lucide-solid'
|
|
import { Dialog as ArkDialog } from '@ark-ui/solid/dialog'
|
|
import { Portal } from 'solid-js/web'
|
|
import { Show, splitProps, type ParentComponent } from 'solid-js'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Progress } from '@/components/ui/progress'
|
|
import type { MediaItem } from '@/types/domain'
|
|
import { cn } from '@/utils/cn'
|
|
import { formatRating, formatRuntime } from '@/utils/format'
|
|
import { mediaBadgeVariant, mediaMeta, mediaMonogram, mediaTypeLabel, mediaYear } from '@/utils/media'
|
|
|
|
export interface MediaDetailDialogProps {
|
|
item: MediaItem
|
|
progressPercent?: number
|
|
open?: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
onAddToWatchlist?: () => void
|
|
onMarkWatched?: () => void
|
|
onContinueWatching?: () => void
|
|
}
|
|
|
|
const MediaTypeIcon = (props: { type: MediaItem['type']; class?: string }) => {
|
|
const icons = {
|
|
movie: Film,
|
|
show: Tv,
|
|
game: Gamepad2,
|
|
}
|
|
const Icon = icons[props.type]
|
|
return <Icon class={cn('h-4 w-4', props.class)} />
|
|
}
|
|
|
|
export const MediaDetailDialog: ParentComponent<MediaDetailDialogProps> = (props) => {
|
|
const [local] = splitProps(props, [
|
|
'item',
|
|
'progressPercent',
|
|
'open',
|
|
'onOpenChange',
|
|
'onAddToWatchlist',
|
|
'onMarkWatched',
|
|
'onContinueWatching',
|
|
'children',
|
|
])
|
|
|
|
const tone = () => mediaBadgeVariant(local.item.type)
|
|
|
|
return (
|
|
<ArkDialog.Root open={local.open} onOpenChange={(details) => local.onOpenChange?.(details.open)}>
|
|
<ArkDialog.Trigger class="w-full text-left">
|
|
<Show when={local.children} fallback={<MediaDetailTrigger item={local.item} progressPercent={local.progressPercent} />}>
|
|
{local.children}
|
|
</Show>
|
|
</ArkDialog.Trigger>
|
|
|
|
<Portal>
|
|
<ArkDialog.Backdrop
|
|
class={cn(
|
|
'fixed inset-0 z-50 bg-black/70 backdrop-blur-sm',
|
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
|
'data-[state=open]:duration-200 data-[state=closed]:duration-150',
|
|
)}
|
|
/>
|
|
<ArkDialog.Positioner class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<ArkDialog.Content
|
|
class={cn(
|
|
'relative w-full max-w-2xl max-h-[85vh] overflow-hidden',
|
|
'rounded-2xl bg-surface-container shadow-panel',
|
|
'border border-outline-variant/15',
|
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
|
|
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
|
|
'data-[state=open]:duration-200 data-[state=closed]:duration-150',
|
|
'focus:outline-none',
|
|
)}
|
|
>
|
|
{/* Hero Section */}
|
|
<div class="relative h-48 overflow-hidden">
|
|
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 via-surface-container to-secondary/10" />
|
|
<div class="absolute inset-0 bg-gradient-to-t from-surface-container via-transparent to-transparent" />
|
|
|
|
{/* Monogram */}
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<p class="font-display text-8xl font-bold text-fg/5">
|
|
{mediaMonogram(local.item.title)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Close button */}
|
|
<ArkDialog.CloseTrigger
|
|
class={cn(
|
|
'absolute right-4 top-4 z-10',
|
|
'flex h-8 w-8 items-center justify-center rounded-lg',
|
|
'bg-surface-high text-muted-fg',
|
|
'hover:bg-surface-highest hover:text-fg',
|
|
'transition-all duration-200',
|
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/30',
|
|
)}
|
|
>
|
|
<X class="h-4 w-4" />
|
|
</ArkDialog.CloseTrigger>
|
|
|
|
{/* Type badge */}
|
|
<div class="absolute left-6 bottom-6 flex items-center gap-3">
|
|
<Badge variant={tone()} class="flex items-center gap-1.5">
|
|
<MediaTypeIcon type={local.item.type} />
|
|
{mediaTypeLabel(local.item.type)}
|
|
</Badge>
|
|
<span class="text-xs font-semibold uppercase tracking-widest text-muted-fg">
|
|
{mediaYear(local.item.releaseDate)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Header */}
|
|
<div class="flex flex-col space-y-1.5 px-6 pt-6 pb-0">
|
|
<ArkDialog.Title class="text-2xl font-semibold text-fg pr-8">
|
|
{local.item.title}
|
|
</ArkDialog.Title>
|
|
<div class="flex items-center gap-4 text-sm text-muted-fg">
|
|
<Show when={local.item.type !== 'game'}>
|
|
<span class="flex items-center gap-1.5">
|
|
<Clock class="h-3.5 w-3.5" />
|
|
{formatRuntime(local.item.runtimeMinutes, local.item.type)}
|
|
</span>
|
|
</Show>
|
|
<span class="flex items-center gap-1.5">
|
|
<Calendar class="h-3.5 w-3.5" />
|
|
{new Date(local.item.releaseDate).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
</span>
|
|
<span class="flex items-center gap-1.5 text-tertiary">
|
|
<Star class="h-3.5 w-3.5 fill-current" />
|
|
{formatRating(local.item.rating)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
|
{/* Progress */}
|
|
<Show when={typeof local.progressPercent === 'number'}>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between text-sm">
|
|
<span class="text-muted-fg">Your progress</span>
|
|
<span class="font-semibold text-fg tabular-nums">{local.progressPercent}%</span>
|
|
</div>
|
|
<Progress value={local.progressPercent} variant="accent" />
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Genres */}
|
|
<Show when={local.item.genres.length > 0}>
|
|
<div class="flex flex-wrap gap-2">
|
|
{local.item.genres.map((genre) => (
|
|
<span class="rounded-lg bg-surface-high px-3 py-1 text-xs font-medium text-muted-fg">
|
|
{genre}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Overview */}
|
|
<div class="space-y-2">
|
|
<h4 class="text-sm font-semibold text-fg">Overview</h4>
|
|
<p class="text-sm leading-relaxed text-muted-fg">{local.item.overview}</p>
|
|
</div>
|
|
|
|
{/* Platforms (for games) */}
|
|
<Show when={local.item.type === 'game' && local.item.platforms.length > 0}>
|
|
<div class="space-y-2">
|
|
<h4 class="text-sm font-semibold text-fg">Platforms</h4>
|
|
<div class="flex flex-wrap gap-2">
|
|
{local.item.platforms.map((platform) => (
|
|
<span class="rounded-lg bg-secondary/10 px-3 py-1 text-xs font-medium text-secondary">
|
|
{platform}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* Meta info */}
|
|
<div class="flex items-center gap-4 text-xs text-muted-fg">
|
|
<span>Provider: {local.item.provider.toUpperCase()}</span>
|
|
<span>ID: {local.item.providerId}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div class="flex items-center justify-end gap-3 border-t border-outline-variant/15 px-6 py-4">
|
|
<Show when={local.onAddToWatchlist}>
|
|
{(onAdd) => (
|
|
<Button variant="ghost" onClick={onAdd()}>
|
|
<Plus class="h-4 w-4" />
|
|
Watchlist
|
|
</Button>
|
|
)}
|
|
</Show>
|
|
<Show when={local.onMarkWatched}>
|
|
{(onMark) => (
|
|
<Button variant="secondary" onClick={onMark()}>
|
|
<Eye class="h-4 w-4" />
|
|
Mark Watched
|
|
</Button>
|
|
)}
|
|
</Show>
|
|
<Show when={local.onContinueWatching}>
|
|
{(onContinue) => (
|
|
<Button variant="primary" onClick={onContinue()}>
|
|
Continue
|
|
</Button>
|
|
)}
|
|
</Show>
|
|
</div>
|
|
</ArkDialog.Content>
|
|
</ArkDialog.Positioner>
|
|
</Portal>
|
|
</ArkDialog.Root>
|
|
)
|
|
}
|
|
|
|
const MediaDetailTrigger = (props: { item: MediaItem; progressPercent?: number }) => {
|
|
return (
|
|
<article class="group rounded-2xl bg-surface-container overflow-hidden transition-all duration-300 hover:bg-surface-high hover:scale-[1.02] hover:shadow-ambient">
|
|
<div class="relative h-40 overflow-hidden">
|
|
<div class="absolute inset-0 bg-gradient-to-br from-surface-low via-surface-container to-surface-high" />
|
|
<div class="absolute inset-0 flex items-center justify-center">
|
|
<p class="font-display text-5xl font-semibold text-fg/10">
|
|
{mediaMonogram(props.item.title)}
|
|
</p>
|
|
</div>
|
|
<div class="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/50 to-transparent" />
|
|
<div class="absolute right-3 top-3 flex items-center gap-2">
|
|
<Badge variant={mediaBadgeVariant(props.item.type)}>
|
|
{mediaTypeLabel(props.item.type)}
|
|
</Badge>
|
|
<span class="text-[10px] font-semibold uppercase tracking-widest text-muted-fg">
|
|
{mediaYear(props.item.releaseDate)}
|
|
</span>
|
|
</div>
|
|
<Show when={typeof props.progressPercent === 'number'}>
|
|
<div class="absolute bottom-0 left-0 right-0 h-1 bg-surface-highest">
|
|
<div
|
|
class="h-full bg-primary transition-all duration-300"
|
|
style={{ width: `${Math.min(100, Math.max(0, props.progressPercent ?? 0))}%` }}
|
|
/>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
<div class="p-4 space-y-2">
|
|
<h4 class="line-clamp-1 text-sm font-semibold text-fg">{props.item.title}</h4>
|
|
<p class="line-clamp-2 min-h-[2.25rem] text-xs text-muted-fg leading-relaxed">
|
|
{props.item.overview}
|
|
</p>
|
|
|
|
<div class="flex items-center justify-between text-xs text-muted-fg pt-1">
|
|
<span>{mediaMeta(props.item) || formatRuntime(props.item.runtimeMinutes, props.item.type)}</span>
|
|
<span class="font-semibold text-tertiary">{formatRating(props.item.rating)} ★</span>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|