1320 lines
47 KiB
TypeScript
1320 lines
47 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
||
import {
|
||
Alert,
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Container,
|
||
Divider,
|
||
Grid,
|
||
Group,
|
||
Image,
|
||
Loader,
|
||
Modal,
|
||
Paper,
|
||
SegmentedControl,
|
||
Skeleton,
|
||
Stack,
|
||
Text,
|
||
TextInput,
|
||
Title,
|
||
} from '@mantine/core'
|
||
import {
|
||
IconAlertCircle,
|
||
IconChartBar,
|
||
IconDeviceTv,
|
||
IconLayoutDashboard,
|
||
IconMovie,
|
||
IconSearch,
|
||
IconSortAscendingLetters,
|
||
IconX,
|
||
} from '@tabler/icons-react'
|
||
import './movies-page.css'
|
||
|
||
type ContentType = 'movie' | 'tvshow'
|
||
type ContentProvider = 'netflix' | 'primevideo'
|
||
type SortBy = 'title' | 'year'
|
||
const WEB_API_KEY = import.meta.env.VITE_WEB_API_KEY ?? 'web-dev-key-change-me'
|
||
const ADMIN_API_KEY = import.meta.env.VITE_ADMIN_API_KEY ?? 'admin-dev-key-change-me'
|
||
type RealtimeContentAction = 'created' | 'updated' | 'deleted'
|
||
const MIN_BUTTON_LOADING_MS = 420
|
||
|
||
interface SocketClient {
|
||
on: (event: string, handler: (...args: unknown[]) => void) => void
|
||
off: (event: string, handler: (...args: unknown[]) => void) => void
|
||
disconnect: () => void
|
||
}
|
||
|
||
declare global {
|
||
interface Window {
|
||
io?: (url?: string, options?: Record<string, unknown>) => SocketClient
|
||
}
|
||
}
|
||
|
||
interface ContentListItem {
|
||
provider?: ContentProvider
|
||
title: string
|
||
year: number | null
|
||
plot: string | null
|
||
ageRating: string | null
|
||
type: ContentType
|
||
genres: string[]
|
||
cast?: string[]
|
||
backdrop: string | null
|
||
currentSeason: number | null
|
||
}
|
||
|
||
interface ContentListResponse {
|
||
success: boolean
|
||
data?: ContentListItem[]
|
||
error?: {
|
||
message: string
|
||
}
|
||
}
|
||
|
||
interface ContentRealtimeEvent {
|
||
action: RealtimeContentAction
|
||
url: string
|
||
content?: ContentListItem
|
||
occurredAt: string
|
||
}
|
||
|
||
interface AdminOverview {
|
||
generatedAt: string
|
||
environment: 'development' | 'production' | 'test'
|
||
cache: {
|
||
configuredTtlSeconds: number
|
||
keyCount: number
|
||
analyzedKeyLimit: number
|
||
sampled: boolean
|
||
totalBytes: number
|
||
redisMemory: {
|
||
usedBytes: number
|
||
maxBytes: number | null
|
||
}
|
||
ttlDistribution: {
|
||
expiredOrNoTtl: number
|
||
lessThan5Min: number
|
||
min5To30: number
|
||
min30Plus: number
|
||
}
|
||
expiringSoon: Array<{
|
||
key: string
|
||
mediaTitle?: string | null
|
||
cachedAt?: number | null
|
||
ttlSeconds: number
|
||
}>
|
||
}
|
||
content: {
|
||
total: number
|
||
byType: {
|
||
movie: number
|
||
tvshow: number
|
||
}
|
||
addedLast24h: number
|
||
addedLast7d: number
|
||
metadataGaps: {
|
||
missingPlot: number
|
||
missingAgeRating: number
|
||
missingBackdrop: number
|
||
}
|
||
topGenres: Array<{
|
||
name: string
|
||
count: number
|
||
}>
|
||
}
|
||
jobs: {
|
||
counts: {
|
||
pending: number
|
||
processing: number
|
||
completed: number
|
||
failed: number
|
||
}
|
||
averageDurationMs: number
|
||
failedRecent: Array<{
|
||
id: string
|
||
url: string
|
||
error: string
|
||
updatedAt: string
|
||
}>
|
||
}
|
||
requestMetrics: {
|
||
cacheHits: number
|
||
cacheMisses: number
|
||
cacheHitRate: number
|
||
sourceCounts: {
|
||
cache: number
|
||
database: number
|
||
scraper: number
|
||
}
|
||
}
|
||
}
|
||
|
||
interface AdminOverviewResponse {
|
||
success: boolean
|
||
data?: AdminOverview
|
||
error?: {
|
||
message: string
|
||
}
|
||
}
|
||
|
||
function formatTtl(ttlSeconds: number): string {
|
||
if (ttlSeconds < 60) return `${ttlSeconds}s`
|
||
if (ttlSeconds < 3600) return `${Math.ceil(ttlSeconds / 60)}dk`
|
||
if (ttlSeconds < 86400) {
|
||
const hours = Math.floor(ttlSeconds / 3600)
|
||
const minutes = Math.floor((ttlSeconds % 3600) / 60)
|
||
return minutes > 0 ? `${hours}sa ${minutes}dk` : `${hours}sa`
|
||
}
|
||
|
||
const days = Math.floor(ttlSeconds / 86400)
|
||
const hours = Math.floor((ttlSeconds % 86400) / 3600)
|
||
return hours > 0 ? `${days}g ${hours}sa` : `${days}g`
|
||
}
|
||
|
||
function formatBytes(value: number): string {
|
||
if (!Number.isFinite(value) || value <= 0) return '0 B'
|
||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
let size = value
|
||
let unitIndex = 0
|
||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||
size /= 1024
|
||
unitIndex += 1
|
||
}
|
||
const decimals = size >= 10 ? 0 : 1
|
||
return `${size.toFixed(decimals)} ${units[unitIndex]}`
|
||
}
|
||
|
||
async function waitForMinimumDuration(startedAt: number, minMs: number): Promise<void> {
|
||
const elapsed = Date.now() - startedAt
|
||
const remaining = minMs - elapsed
|
||
if (remaining > 0) {
|
||
await new Promise<void>((resolve) => setTimeout(resolve, remaining))
|
||
}
|
||
}
|
||
|
||
function getContentKey(item: Pick<ContentListItem, 'type' | 'title' | 'year'>): string {
|
||
return `${item.type}::${item.title}::${item.year ?? 'na'}`
|
||
}
|
||
|
||
function getProviderBrand(item: Pick<ContentListItem, 'provider'>): { src: string; alt: string } {
|
||
if (item.provider === 'primevideo') {
|
||
return { src: '/prime.png', alt: 'Prime Video' }
|
||
}
|
||
return { src: '/netflix.png', alt: 'Netflix' }
|
||
}
|
||
|
||
async function loadSocketClient(): Promise<SocketClient | null> {
|
||
if (typeof window === 'undefined') return null
|
||
if (window.io) {
|
||
return window.io('/', { transports: ['websocket', 'polling'] })
|
||
}
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
const existing = document.querySelector<HTMLScriptElement>('script[data-socket-io-client="1"]')
|
||
if (existing && window.io) {
|
||
resolve()
|
||
return
|
||
}
|
||
|
||
if (existing) {
|
||
existing.addEventListener('load', () => resolve(), { once: true })
|
||
existing.addEventListener('error', () => reject(new Error('Failed to load socket client')), { once: true })
|
||
return
|
||
}
|
||
|
||
const script = document.createElement('script')
|
||
script.src = '/socket.io/socket.io.js'
|
||
script.async = true
|
||
script.dataset.socketIoClient = '1'
|
||
script.onload = () => resolve()
|
||
script.onerror = () => reject(new Error('Failed to load socket client'))
|
||
document.head.appendChild(script)
|
||
})
|
||
|
||
const ioClient = (window as unknown as {
|
||
io?: (url?: string, options?: Record<string, unknown>) => SocketClient
|
||
}).io
|
||
if (typeof ioClient !== 'function') return null
|
||
return ioClient('/', { transports: ['websocket', 'polling'] })
|
||
}
|
||
|
||
function BackdropImage({ src, alt }: { src: string; alt: string }) {
|
||
const [loaded, setLoaded] = useState(false)
|
||
|
||
return (
|
||
<div className="image-shell">
|
||
{!loaded && <Skeleton height={190} radius={0} className="image-skeleton" />}
|
||
<Image
|
||
src={src}
|
||
alt={alt}
|
||
h={190}
|
||
className={`lazy-image ${loaded ? 'is-loaded' : ''}`}
|
||
onLoad={() => setLoaded(true)}
|
||
loading="lazy"
|
||
decoding="async"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function MoviesPage() {
|
||
const [activeView, setActiveView] = useState<'catalog' | 'admin'>('catalog')
|
||
const [items, setItems] = useState<ContentListItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [adminOverview, setAdminOverview] = useState<AdminOverview | null>(null)
|
||
const [adminLoading, setAdminLoading] = useState(true)
|
||
const [adminRefreshing, setAdminRefreshing] = useState(false)
|
||
const [adminError, setAdminError] = useState<string | null>(null)
|
||
const [adminActionMessage, setAdminActionMessage] = useState<string | null>(null)
|
||
const [adminActionPending, setAdminActionPending] = useState<string | null>(null)
|
||
const [purgeConfirmOpened, setPurgeConfirmOpened] = useState(false)
|
||
const [typeFilter, setTypeFilter] = useState<'all' | ContentType>('all')
|
||
const [search, setSearch] = useState('')
|
||
const [sortBy, setSortBy] = useState<SortBy>('title')
|
||
const [activeGenre, setActiveGenre] = useState<string | null>(null)
|
||
const [flashItemKeys, setFlashItemKeys] = useState<string[]>([])
|
||
const [pinnedNewItemKeys, setPinnedNewItemKeys] = useState<string[]>([])
|
||
const [flashTtlKeys, setFlashTtlKeys] = useState<string[]>([])
|
||
const [selectedContentKey, setSelectedContentKey] = useState<string | null>(null)
|
||
const typeFilterRef = useRef<'all' | ContentType>(typeFilter)
|
||
const hasLoadedAdminRef = useRef(false)
|
||
const previousTtlKeysRef = useRef<string[]>([])
|
||
|
||
useEffect(() => {
|
||
typeFilterRef.current = typeFilter
|
||
}, [typeFilter])
|
||
|
||
useEffect(() => {
|
||
if (!adminActionMessage) return
|
||
const timer = setTimeout(() => {
|
||
setAdminActionMessage(null)
|
||
}, 2600)
|
||
return () => clearTimeout(timer)
|
||
}, [adminActionMessage])
|
||
|
||
useEffect(() => {
|
||
if (activeView !== 'catalog') return
|
||
if (flashItemKeys.length === 0) return
|
||
|
||
const timer = setTimeout(() => {
|
||
setFlashItemKeys([])
|
||
}, 1900)
|
||
|
||
return () => clearTimeout(timer)
|
||
}, [activeView, flashItemKeys])
|
||
|
||
useEffect(() => {
|
||
if (flashTtlKeys.length === 0) return
|
||
const timer = setTimeout(() => {
|
||
setFlashTtlKeys([])
|
||
}, 1800)
|
||
return () => clearTimeout(timer)
|
||
}, [flashTtlKeys])
|
||
|
||
const loadAdminOverview = async (signal?: AbortSignal, background = false) => {
|
||
const isInitialLoad = !hasLoadedAdminRef.current && !background
|
||
if (isInitialLoad) {
|
||
setAdminLoading(true)
|
||
setAdminRefreshing(false)
|
||
setAdminError(null)
|
||
} else if (!background) {
|
||
setAdminRefreshing(true)
|
||
setAdminError(null)
|
||
}
|
||
try {
|
||
const response = await fetch('/api/admin/overview', {
|
||
method: 'GET',
|
||
headers: {
|
||
'X-API-Key': ADMIN_API_KEY,
|
||
},
|
||
signal,
|
||
})
|
||
|
||
const data: AdminOverviewResponse = await response.json()
|
||
if (data.success && data.data) {
|
||
setAdminOverview(data.data)
|
||
hasLoadedAdminRef.current = true
|
||
return
|
||
}
|
||
|
||
if (!background) {
|
||
setAdminError(data.error?.message || 'Dashboard verileri alınamadı')
|
||
}
|
||
} catch {
|
||
if (!signal?.aborted && !background) {
|
||
setAdminError('Dashboard bağlantı hatası')
|
||
}
|
||
} finally {
|
||
if (!signal?.aborted) {
|
||
if (isInitialLoad) {
|
||
setAdminLoading(false)
|
||
} else if (!background) {
|
||
setAdminRefreshing(false)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadContent = async (
|
||
signal?: AbortSignal,
|
||
background = false,
|
||
filter: 'all' | ContentType = typeFilterRef.current
|
||
) => {
|
||
if (!background) {
|
||
setLoading(true)
|
||
setError(null)
|
||
}
|
||
try {
|
||
const params = new URLSearchParams()
|
||
params.append('limit', '100')
|
||
if (filter !== 'all') {
|
||
params.append('type', filter)
|
||
}
|
||
|
||
const response = await fetch(`/api/content?${params.toString()}`, {
|
||
method: 'GET',
|
||
headers: {
|
||
'X-API-Key': WEB_API_KEY,
|
||
},
|
||
signal,
|
||
})
|
||
|
||
const data: ContentListResponse = await response.json()
|
||
if (data.success && data.data) {
|
||
setItems(data.data)
|
||
return
|
||
}
|
||
|
||
if (!background) {
|
||
setError(data.error?.message || 'Liste alınamadı')
|
||
}
|
||
} catch {
|
||
if (!signal?.aborted && !background) {
|
||
setError('Bağlantı hatası')
|
||
}
|
||
} finally {
|
||
if (!signal?.aborted && !background) {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
void loadAdminOverview(controller.signal)
|
||
return () => controller.abort()
|
||
}, [])
|
||
|
||
const runAdminAction = async (
|
||
actionKey: string,
|
||
endpoint: string,
|
||
body?: Record<string, unknown>
|
||
): Promise<boolean> => {
|
||
const startedAt = Date.now()
|
||
setAdminActionPending(actionKey)
|
||
setAdminActionMessage(null)
|
||
setAdminError(null)
|
||
try {
|
||
const response = await fetch(endpoint, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-API-Key': ADMIN_API_KEY,
|
||
},
|
||
body: body ? JSON.stringify(body) : undefined,
|
||
})
|
||
|
||
const result = (await response.json()) as {
|
||
success: boolean
|
||
data?: { queued: number; skipped: number; details?: string }
|
||
error?: { message: string }
|
||
}
|
||
|
||
if (!result.success || !result.data) {
|
||
setAdminError(result.error?.message || 'Admin aksiyonu basarisiz')
|
||
return false
|
||
}
|
||
|
||
setAdminActionMessage(
|
||
`${result.data.details ?? 'Aksiyon tamamlandi'} | queued: ${result.data.queued}, skipped: ${result.data.skipped}`
|
||
)
|
||
await loadAdminOverview(undefined, true)
|
||
return true
|
||
} catch {
|
||
setAdminError('Admin aksiyonu baglanti hatasi')
|
||
return false
|
||
} finally {
|
||
await waitForMinimumDuration(startedAt, MIN_BUTTON_LOADING_MS)
|
||
setAdminActionPending(null)
|
||
}
|
||
}
|
||
|
||
const handlePurgeAllContent = async () => {
|
||
const success = await runAdminAction('purge-content', '/api/admin/content/purge')
|
||
if (!success) return
|
||
|
||
setPurgeConfirmOpened(false)
|
||
setSelectedContentKey(null)
|
||
await loadContent(undefined, true, typeFilterRef.current)
|
||
}
|
||
|
||
const handleAdminRefresh = async () => {
|
||
const startedAt = Date.now()
|
||
await loadAdminOverview(undefined, false)
|
||
await waitForMinimumDuration(startedAt, MIN_BUTTON_LOADING_MS)
|
||
}
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
void loadContent(controller.signal, false, typeFilter)
|
||
|
||
return () => controller.abort()
|
||
}, [typeFilter])
|
||
|
||
useEffect(() => {
|
||
let socket: SocketClient | null = null
|
||
let adminRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const scheduleAdminRefresh = () => {
|
||
if (adminRefreshTimer) return
|
||
adminRefreshTimer = setTimeout(() => {
|
||
adminRefreshTimer = null
|
||
void loadAdminOverview(undefined, true)
|
||
}, 220)
|
||
}
|
||
|
||
const attachSocket = async () => {
|
||
try {
|
||
socket = await loadSocketClient()
|
||
if (!socket) return
|
||
|
||
const onConnect = () => {
|
||
void loadContent(undefined, true, typeFilterRef.current)
|
||
void loadAdminOverview(undefined, true)
|
||
}
|
||
|
||
const onContentEvent = (...args: unknown[]) => {
|
||
const event = args[0] as ContentRealtimeEvent | undefined
|
||
if (!event) return
|
||
|
||
if (event.action === 'deleted') {
|
||
void loadContent(undefined, true, typeFilterRef.current)
|
||
return
|
||
}
|
||
|
||
if (!event.content) return
|
||
const incoming = event.content
|
||
const incomingKey = getContentKey(incoming)
|
||
setItems((prev) => {
|
||
const next = [...prev]
|
||
const existingIndex = next.findIndex((item) => getContentKey(item) === incomingKey)
|
||
if (existingIndex >= 0) {
|
||
next[existingIndex] = incoming
|
||
} else {
|
||
next.unshift(incoming)
|
||
}
|
||
return next
|
||
})
|
||
|
||
setFlashItemKeys((prev) => (prev.includes(incomingKey) ? prev : [...prev, incomingKey]))
|
||
if (event.action === 'created') {
|
||
setPinnedNewItemKeys((prev) => [incomingKey, ...prev.filter((key) => key !== incomingKey)].slice(0, 40))
|
||
}
|
||
}
|
||
|
||
const onCacheOrMetrics = () => {
|
||
scheduleAdminRefresh()
|
||
}
|
||
|
||
socket.on('connect', onConnect)
|
||
socket.on('content:event', onContentEvent)
|
||
socket.on('cache:event', onCacheOrMetrics)
|
||
socket.on('metrics:updated', onCacheOrMetrics)
|
||
} catch {
|
||
// Realtime is best-effort; UI remains functional with REST snapshots.
|
||
}
|
||
}
|
||
|
||
void attachSocket()
|
||
|
||
return () => {
|
||
if (adminRefreshTimer) clearTimeout(adminRefreshTimer)
|
||
socket?.disconnect()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const currentTtlKeys = adminOverview?.cache.expiringSoon.map((item) => item.key) ?? []
|
||
const previousKeys = previousTtlKeysRef.current
|
||
const newKeys = currentTtlKeys.filter((key) => !previousKeys.includes(key))
|
||
if (newKeys.length > 0) {
|
||
setFlashTtlKeys((prev) => Array.from(new Set([...prev, ...newKeys])))
|
||
}
|
||
previousTtlKeysRef.current = currentTtlKeys
|
||
}, [adminOverview])
|
||
|
||
const pageTitle = useMemo(() => {
|
||
if (typeFilter === 'movie') return 'Film Arşivi'
|
||
if (typeFilter === 'tvshow') return 'Dizi Arşivi'
|
||
return 'Film ve Dizi Arşivi'
|
||
}, [typeFilter])
|
||
|
||
const allGenres = useMemo(() => {
|
||
const genreSet = new Set<string>()
|
||
for (const item of items) {
|
||
for (const genre of item.genres) {
|
||
genreSet.add(genre)
|
||
}
|
||
}
|
||
return Array.from(genreSet).sort((a, b) => a.localeCompare(b, 'tr'))
|
||
}, [items])
|
||
|
||
const visibleItems = useMemo(() => {
|
||
const searchText = search.trim().toLocaleLowerCase('tr')
|
||
|
||
const filtered = items.filter((item) => {
|
||
const matchesSearch =
|
||
searchText.length === 0 ||
|
||
item.title.toLocaleLowerCase('tr').includes(searchText) ||
|
||
item.plot?.toLocaleLowerCase('tr').includes(searchText)
|
||
|
||
const matchesGenre = !activeGenre || item.genres.includes(activeGenre)
|
||
return matchesSearch && matchesGenre
|
||
})
|
||
|
||
return [...filtered].sort((a: ContentListItem, b: ContentListItem) => {
|
||
const aPinnedIndex = pinnedNewItemKeys.indexOf(getContentKey(a))
|
||
const bPinnedIndex = pinnedNewItemKeys.indexOf(getContentKey(b))
|
||
const aPinned = aPinnedIndex >= 0
|
||
const bPinned = bPinnedIndex >= 0
|
||
if (aPinned || bPinned) {
|
||
if (aPinned && bPinned) return aPinnedIndex - bPinnedIndex
|
||
return aPinned ? -1 : 1
|
||
}
|
||
if (sortBy === 'year') {
|
||
return (b.year ?? 0) - (a.year ?? 0)
|
||
}
|
||
return a.title.localeCompare(b.title, 'tr')
|
||
})
|
||
}, [items, search, sortBy, activeGenre, pinnedNewItemKeys])
|
||
const selectedContent = useMemo(
|
||
() => items.find((item) => getContentKey(item) === selectedContentKey) ?? null,
|
||
[items, selectedContentKey]
|
||
)
|
||
const selectedContentBrand = useMemo(
|
||
() => (selectedContent ? getProviderBrand(selectedContent) : null),
|
||
[selectedContent]
|
||
)
|
||
|
||
const movieCount = items.filter((item) => item.type === 'movie').length
|
||
const tvCount = items.filter((item) => item.type === 'tvshow').length
|
||
const hasActiveFilters = typeFilter !== 'all' || search.trim().length > 0 || activeGenre !== null || sortBy !== 'title'
|
||
const averageJobDurationSeconds = adminOverview ? Math.max(1, Math.round(adminOverview.jobs.averageDurationMs / 1000)) : 0
|
||
const heroDescription =
|
||
activeView === 'catalog'
|
||
? 'Filtrele, ara ve arşivi hızlıca tara. Film ve dizi keşfini tek akışta yönet.'
|
||
: 'Operasyon görünümü: cache sağlığı, veri kalitesi ve scrape job durumlarını izle.'
|
||
|
||
const openContentModal = (item: ContentListItem) => {
|
||
setSelectedContentKey(getContentKey(item))
|
||
}
|
||
|
||
const closeContentModal = () => {
|
||
setSelectedContentKey(null)
|
||
}
|
||
|
||
return (
|
||
<Container size="xl" py="xl" className="catalog-page">
|
||
<Stack gap="lg">
|
||
<Paper className="catalog-hero" radius="xl" p="xl" withBorder>
|
||
<Group justify="space-between" align="end" gap="md">
|
||
<Stack gap={8}>
|
||
<Text className="eyebrow">RateBubble koleksiyonu</Text>
|
||
<Title order={1}>{pageTitle}</Title>
|
||
<Text c="dimmed" maw={620}>
|
||
{heroDescription}
|
||
</Text>
|
||
</Stack>
|
||
<Group gap="sm" className="stats-wrap">
|
||
<Paper className="stat-pill" radius="xl" p="sm" withBorder>
|
||
<Text size="xs" c="dimmed">
|
||
Toplam
|
||
</Text>
|
||
<Text fw={700} size="lg">
|
||
{items.length}
|
||
</Text>
|
||
</Paper>
|
||
<Paper className="stat-pill" radius="xl" p="sm" withBorder>
|
||
<Text size="xs" c="dimmed">
|
||
Film
|
||
</Text>
|
||
<Text fw={700} size="lg">
|
||
{movieCount}
|
||
</Text>
|
||
</Paper>
|
||
<Paper className="stat-pill" radius="xl" p="sm" withBorder>
|
||
<Text size="xs" c="dimmed">
|
||
Dizi
|
||
</Text>
|
||
<Text fw={700} size="lg">
|
||
{tvCount}
|
||
</Text>
|
||
</Paper>
|
||
</Group>
|
||
</Group>
|
||
</Paper>
|
||
|
||
<Paper radius="lg" p="xs" withBorder className="view-nav">
|
||
<Group gap={8} wrap="nowrap">
|
||
<Button
|
||
className={`nav-pill ${activeView === 'catalog' ? 'is-active' : 'is-inactive'}`}
|
||
variant={activeView === 'catalog' ? 'filled' : 'subtle'}
|
||
leftSection={<IconChartBar size={16} />}
|
||
onClick={() => setActiveView('catalog')}
|
||
>
|
||
Icerik Katalogu
|
||
</Button>
|
||
<Button
|
||
className={`nav-pill ${activeView === 'admin' ? 'is-active' : 'is-inactive'}`}
|
||
variant={activeView === 'admin' ? 'filled' : 'subtle'}
|
||
leftSection={<IconLayoutDashboard size={16} />}
|
||
onClick={() => setActiveView('admin')}
|
||
>
|
||
Yonetici Dashboard
|
||
</Button>
|
||
</Group>
|
||
</Paper>
|
||
|
||
{activeView === 'admin' && (
|
||
<Paper radius="lg" p="lg" withBorder className="admin-panel">
|
||
<Stack gap="md">
|
||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||
<div>
|
||
<Title order={2}>Yonetici Dashboard</Title>
|
||
<Text size="sm" c="dimmed">
|
||
Redis, veritabani ve scrape operasyon durumu.
|
||
</Text>
|
||
</div>
|
||
<Group gap="xs">
|
||
<Badge variant="light" color="gray">
|
||
Ortam: {adminOverview?.environment ?? '-'}
|
||
</Badge>
|
||
<Button
|
||
size="compact-sm"
|
||
variant="outline"
|
||
loading={adminRefreshing}
|
||
onClick={() => void handleAdminRefresh()}
|
||
>
|
||
Yenile
|
||
</Button>
|
||
<Button
|
||
size="compact-sm"
|
||
variant="light"
|
||
loading={adminActionPending === 'warmup-cache'}
|
||
onClick={() => void runAdminAction('warmup-cache', '/api/admin/cache/warmup')}
|
||
>
|
||
DB to Redis
|
||
</Button>
|
||
<Button
|
||
size="compact-sm"
|
||
variant="light"
|
||
loading={adminActionPending === 'clear-cache'}
|
||
onClick={() => void runAdminAction('clear-cache', '/api/admin/cache/clear')}
|
||
>
|
||
Cache temizle
|
||
</Button>
|
||
<Button
|
||
size="compact-sm"
|
||
variant="light"
|
||
loading={adminActionPending === 'retry-failed'}
|
||
onClick={() =>
|
||
void runAdminAction('retry-failed', '/api/admin/jobs/retry-failed', {
|
||
limit: 10,
|
||
})
|
||
}
|
||
>
|
||
Failed retry
|
||
</Button>
|
||
<Button
|
||
size="compact-sm"
|
||
variant="light"
|
||
loading={adminActionPending === 'refresh-stale'}
|
||
onClick={() =>
|
||
void runAdminAction('refresh-stale', '/api/admin/content/refresh-stale', {
|
||
days: 30,
|
||
limit: 20,
|
||
})
|
||
}
|
||
>
|
||
Stale refresh
|
||
</Button>
|
||
<Button
|
||
size="compact-sm"
|
||
color="red"
|
||
variant="light"
|
||
onClick={() => setPurgeConfirmOpened(true)}
|
||
>
|
||
DB tum veriyi sil
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
|
||
{adminError && (
|
||
<Alert icon={<IconAlertCircle size={16} />} color="orange" title="Dashboard Hatasi">
|
||
{adminError}
|
||
</Alert>
|
||
)}
|
||
|
||
{adminLoading ? (
|
||
<Grid>
|
||
{Array.from({ length: 4 }).map((_, idx) => (
|
||
<Grid.Col key={idx} span={{ base: 12, sm: 6, md: 3 }}>
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Skeleton height={14} width="55%" />
|
||
<Skeleton height={28} width="35%" mt="sm" />
|
||
</Paper>
|
||
</Grid.Col>
|
||
))}
|
||
</Grid>
|
||
) : (
|
||
adminOverview && (
|
||
<>
|
||
<Grid>
|
||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text size="xs" c="dimmed">
|
||
Redis Key Sayisi
|
||
</Text>
|
||
<Title order={3}>{adminOverview.cache.keyCount}</Title>
|
||
<Text size="xs" c="dimmed">
|
||
Toplam {Math.round(adminOverview.cache.totalBytes / 1024)} KB
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
RAM {formatBytes(adminOverview.cache.redisMemory.usedBytes)}/
|
||
{adminOverview.cache.redisMemory.maxBytes
|
||
? formatBytes(adminOverview.cache.redisMemory.maxBytes)
|
||
: 'Limitsiz'}
|
||
</Text>
|
||
</Paper>
|
||
</Grid.Col>
|
||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text size="xs" c="dimmed">
|
||
TTL Dagilimi (5dk alti)
|
||
</Text>
|
||
<Title order={3}>{adminOverview.cache.ttlDistribution.lessThan5Min}</Title>
|
||
<Text size="xs" c="dimmed">
|
||
Varsayilan TTL: {adminOverview.cache.configuredTtlSeconds}s
|
||
</Text>
|
||
</Paper>
|
||
</Grid.Col>
|
||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text size="xs" c="dimmed">
|
||
Icerik Toplami
|
||
</Text>
|
||
<Title order={3}>{adminOverview.content.total}</Title>
|
||
<Text size="xs" c="dimmed">
|
||
Film {adminOverview.content.byType.movie} / Dizi {adminOverview.content.byType.tvshow}
|
||
</Text>
|
||
</Paper>
|
||
</Grid.Col>
|
||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text size="xs" c="dimmed">
|
||
Cache Hit Orani
|
||
</Text>
|
||
<Title order={3}>{Math.round(adminOverview.requestMetrics.cacheHitRate * 100)}%</Title>
|
||
<Text size="xs" c="dimmed">
|
||
Hit {adminOverview.requestMetrics.cacheHits} / Miss {adminOverview.requestMetrics.cacheMisses}
|
||
</Text>
|
||
</Paper>
|
||
</Grid.Col>
|
||
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text size="xs" c="dimmed">
|
||
Job Durumu
|
||
</Text>
|
||
<Title order={3}>{adminOverview.jobs.counts.processing}</Title>
|
||
<Text size="xs" c="dimmed">
|
||
Aktif, ort. {averageJobDurationSeconds}s
|
||
</Text>
|
||
</Paper>
|
||
</Grid.Col>
|
||
</Grid>
|
||
|
||
<Grid>
|
||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text fw={600}>Cache TTL Dagilimi</Text>
|
||
<Group gap="xs" mt="xs">
|
||
<Badge variant="light">TTL yok/suresi gecmis: {adminOverview.cache.ttlDistribution.expiredOrNoTtl}</Badge>
|
||
<Badge variant="light">0-5dk: {adminOverview.cache.ttlDistribution.lessThan5Min}</Badge>
|
||
<Badge variant="light">5-30dk: {adminOverview.cache.ttlDistribution.min5To30}</Badge>
|
||
<Badge variant="light">30dk+: {adminOverview.cache.ttlDistribution.min30Plus}</Badge>
|
||
</Group>
|
||
<Text size="xs" c="dimmed" mt="sm">
|
||
{adminOverview.cache.sampled
|
||
? `Yuksek hacim nedeniyle ilk ${adminOverview.cache.analyzedKeyLimit} key analiz edildi.`
|
||
: 'Tum cache keyleri analiz edildi.'}
|
||
</Text>
|
||
<Stack gap={6} mt="md">
|
||
<Text size="sm" fw={600}>
|
||
En yakin sure dolacak keyler
|
||
</Text>
|
||
{adminOverview.cache.expiringSoon.length === 0 ? (
|
||
<Text size="sm" c="dimmed">
|
||
Gosterilecek key bulunamadi.
|
||
</Text>
|
||
) : (
|
||
adminOverview.cache.expiringSoon.map((item) => (
|
||
<Group
|
||
key={item.key}
|
||
justify="space-between"
|
||
className={`list-row ${flashTtlKeys.includes(item.key) ? 'live-ttl-flash' : ''}`}
|
||
>
|
||
<Text
|
||
size="sm"
|
||
className="cache-key-text"
|
||
title={item.mediaTitle ? `${item.key} | ${item.mediaTitle}` : item.key}
|
||
>
|
||
{item.mediaTitle ? `${item.key} | ${item.mediaTitle}` : item.key}
|
||
</Text>
|
||
<Badge variant="outline" title={`${item.ttlSeconds}s`}>
|
||
{formatTtl(item.ttlSeconds)}
|
||
</Badge>
|
||
</Group>
|
||
))
|
||
)}
|
||
</Stack>
|
||
</Paper>
|
||
</Grid.Col>
|
||
|
||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||
<Stack gap="md">
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text fw={600}>Veritabani Ozeti</Text>
|
||
<Group gap="xs" mt="xs">
|
||
<Badge variant="light">24s eklenen: {adminOverview.content.addedLast24h}</Badge>
|
||
<Badge variant="light">7g eklenen: {adminOverview.content.addedLast7d}</Badge>
|
||
<Badge color="orange" variant="light">
|
||
Plot eksik: {adminOverview.content.metadataGaps.missingPlot}
|
||
</Badge>
|
||
<Badge color="yellow" variant="light">
|
||
Yas siniri eksik: {adminOverview.content.metadataGaps.missingAgeRating}
|
||
</Badge>
|
||
<Badge color="grape" variant="light">
|
||
Backdrop eksik: {adminOverview.content.metadataGaps.missingBackdrop}
|
||
</Badge>
|
||
</Group>
|
||
<Stack gap={6} mt="md">
|
||
<Text size="sm" fw={600}>
|
||
En Populer Turler
|
||
</Text>
|
||
{adminOverview.content.topGenres.map((genre) => (
|
||
<Group key={genre.name} justify="space-between" className="list-row">
|
||
<Text size="sm">{genre.name}</Text>
|
||
<Badge variant="dot">{genre.count}</Badge>
|
||
</Group>
|
||
))}
|
||
</Stack>
|
||
</Paper>
|
||
|
||
<Paper withBorder radius="md" p="md" className="admin-card">
|
||
<Text fw={600}>Job ve Hata Ozeti</Text>
|
||
<Group gap="xs" mt="xs">
|
||
<Badge variant="light">Pending: {adminOverview.jobs.counts.pending}</Badge>
|
||
<Badge variant="light">Processing: {adminOverview.jobs.counts.processing}</Badge>
|
||
<Badge color="green" variant="light">
|
||
Completed: {adminOverview.jobs.counts.completed}
|
||
</Badge>
|
||
<Badge color="red" variant="light">
|
||
Failed: {adminOverview.jobs.counts.failed}
|
||
</Badge>
|
||
</Group>
|
||
<Group gap="xs" mt="sm">
|
||
<Badge variant="light">Kaynak Cache: {adminOverview.requestMetrics.sourceCounts.cache}</Badge>
|
||
<Badge variant="light">Kaynak DB: {adminOverview.requestMetrics.sourceCounts.database}</Badge>
|
||
<Badge variant="light">Kaynak Scraper: {adminOverview.requestMetrics.sourceCounts.scraper}</Badge>
|
||
</Group>
|
||
<Stack gap={6} mt="md">
|
||
<Text size="sm" fw={600}>
|
||
Son Basarisiz Isler
|
||
</Text>
|
||
{adminOverview.jobs.failedRecent.length === 0 ? (
|
||
<Text size="sm" c="dimmed">
|
||
Son donemde hata kaydi yok.
|
||
</Text>
|
||
) : (
|
||
adminOverview.jobs.failedRecent.map((job) => (
|
||
<div key={job.id} className="list-row">
|
||
<Text size="sm" fw={500} lineClamp={1}>
|
||
{job.url}
|
||
</Text>
|
||
<Text size="xs" c="red.3" lineClamp={1}>
|
||
{job.error}
|
||
</Text>
|
||
</div>
|
||
))
|
||
)}
|
||
</Stack>
|
||
</Paper>
|
||
</Stack>
|
||
</Grid.Col>
|
||
</Grid>
|
||
</>
|
||
)
|
||
)}
|
||
</Stack>
|
||
</Paper>
|
||
)}
|
||
|
||
{activeView === 'catalog' && (
|
||
<Stack gap="md" px={{ base: 'xs', sm: 'lg' }} className="catalog-view-shell">
|
||
<Paper radius="lg" p="md" className="toolbar" withBorder>
|
||
<Stack gap="sm">
|
||
<Group justify="space-between" gap="sm" wrap="wrap">
|
||
<SegmentedControl
|
||
value={typeFilter}
|
||
onChange={(value) => {
|
||
setTypeFilter(value as 'all' | ContentType)
|
||
setActiveGenre(null)
|
||
}}
|
||
data={[
|
||
{ label: 'Tümü', value: 'all' },
|
||
{ label: 'Filmler', value: 'movie' },
|
||
{ label: 'Diziler', value: 'tvshow' },
|
||
]}
|
||
/>
|
||
|
||
<Group gap="sm" wrap="wrap">
|
||
<TextInput
|
||
placeholder="Başlık veya açıklama ara"
|
||
leftSection={<IconSearch size={16} />}
|
||
value={search}
|
||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||
className="search-input"
|
||
/>
|
||
<SegmentedControl
|
||
value={sortBy}
|
||
onChange={(value) => setSortBy(value as SortBy)}
|
||
data={[
|
||
{
|
||
label: (
|
||
<Group gap={6} wrap="nowrap">
|
||
<IconSortAscendingLetters size={14} />
|
||
<span>Başlık</span>
|
||
</Group>
|
||
),
|
||
value: 'title',
|
||
},
|
||
{ label: 'Yıl', value: 'year' },
|
||
]}
|
||
/>
|
||
</Group>
|
||
</Group>
|
||
|
||
<Group gap="xs" wrap="wrap" justify="space-between">
|
||
<Group gap="xs" wrap="wrap">
|
||
<Button
|
||
variant={activeGenre ? 'light' : 'filled'}
|
||
size="compact-sm"
|
||
radius="xl"
|
||
className="genre-chip"
|
||
onClick={() => setActiveGenre(null)}
|
||
aria-pressed={!activeGenre}
|
||
>
|
||
Tüm Türler
|
||
</Button>
|
||
{allGenres.slice(0, 12).map((genre) => (
|
||
<Button
|
||
key={genre}
|
||
variant={activeGenre === genre ? 'filled' : 'light'}
|
||
size="compact-sm"
|
||
radius="xl"
|
||
className="genre-chip"
|
||
onClick={() => setActiveGenre(activeGenre === genre ? null : genre)}
|
||
aria-pressed={activeGenre === genre}
|
||
>
|
||
{genre}
|
||
</Button>
|
||
))}
|
||
</Group>
|
||
{hasActiveFilters && (
|
||
<Button
|
||
variant="outline"
|
||
size="compact-sm"
|
||
radius="xl"
|
||
className="genre-chip"
|
||
leftSection={<IconX size={14} />}
|
||
onClick={() => {
|
||
setTypeFilter('all')
|
||
setSearch('')
|
||
setSortBy('title')
|
||
setActiveGenre(null)
|
||
}}
|
||
>
|
||
Filtreleri temizle
|
||
</Button>
|
||
)}
|
||
</Group>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
{error && (
|
||
<Alert icon={<IconAlertCircle size={16} />} color="red" title="Hata">
|
||
{error}
|
||
</Alert>
|
||
)}
|
||
|
||
{loading ? (
|
||
<Grid>
|
||
{Array.from({ length: 9 }).map((_, idx) => (
|
||
<Grid.Col key={idx} span={{ base: 12, sm: 6, md: 4 }}>
|
||
<Card withBorder radius="lg" p="md" className="catalog-card">
|
||
<Skeleton height={180} radius="md" />
|
||
<Stack mt="md" gap="xs">
|
||
<Skeleton height={16} width="75%" />
|
||
<Skeleton height={12} width="45%" />
|
||
<Skeleton height={12} width="90%" />
|
||
<Skeleton height={12} width="70%" />
|
||
</Stack>
|
||
</Card>
|
||
</Grid.Col>
|
||
))}
|
||
</Grid>
|
||
) : (
|
||
<Grid>
|
||
{visibleItems.map((item, index) => {
|
||
const itemKey = getContentKey(item)
|
||
const isLive = flashItemKeys.includes(itemKey)
|
||
const brand = getProviderBrand(item)
|
||
return (
|
||
<Grid.Col key={itemKey} span={{ base: 12, sm: 6, md: 4 }}>
|
||
<Card
|
||
shadow="md"
|
||
radius="lg"
|
||
padding="md"
|
||
withBorder
|
||
tabIndex={0}
|
||
role="button"
|
||
aria-label={`${item.title} detaylarini ac`}
|
||
aria-haspopup="dialog"
|
||
className={`catalog-card reveal ${isLive ? 'live-fade-in' : ''}`}
|
||
style={{ animationDelay: `${Math.min(index * 45, 420)}ms` }}
|
||
onClick={() => openContentModal(item)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Enter' || event.key === ' ') {
|
||
event.preventDefault()
|
||
openContentModal(item)
|
||
}
|
||
}}
|
||
>
|
||
<Card.Section className="media-wrap">
|
||
{item.backdrop ? (
|
||
<BackdropImage src={item.backdrop} alt={item.title} />
|
||
) : (
|
||
<Group h={190} justify="center" className="media-fallback">
|
||
{item.type === 'movie' ? <IconMovie size={54} /> : <IconDeviceTv size={54} />}
|
||
</Group>
|
||
)}
|
||
<div className="media-overlay" />
|
||
</Card.Section>
|
||
|
||
<Stack gap="xs" mt="md" pb="lg" className="card-content">
|
||
<Group justify="space-between" align="start" gap="xs">
|
||
<Text fw={700} lineClamp={2} className="card-title">
|
||
{item.title}
|
||
</Text>
|
||
<Badge color={item.type === 'movie' ? 'red' : 'blue'}>
|
||
{item.type === 'movie' ? 'Film' : 'Dizi'}
|
||
</Badge>
|
||
</Group>
|
||
|
||
<Group gap="xs" className="card-meta-row">
|
||
{item.year && <Badge variant="light">{item.year}</Badge>}
|
||
{item.ageRating && <Badge variant="outline">{item.ageRating}</Badge>}
|
||
{item.currentSeason && item.type === 'tvshow' && (
|
||
<Badge variant="light" color="grape">
|
||
S{item.currentSeason}
|
||
</Badge>
|
||
)}
|
||
</Group>
|
||
|
||
<Text size="sm" c="dimmed" lineClamp={3} className="card-plot">
|
||
{item.plot || 'Açıklama bulunamadı.'}
|
||
</Text>
|
||
|
||
<Group gap={6} className="card-genres">
|
||
{item.genres.slice(0, 3).map((genre) => (
|
||
<Badge key={genre} size="sm" variant="dot">
|
||
{genre}
|
||
</Badge>
|
||
))}
|
||
</Group>
|
||
</Stack>
|
||
|
||
<img src={brand.src} alt={brand.alt} className="brand-stamp" />
|
||
</Card>
|
||
</Grid.Col>
|
||
)
|
||
})}
|
||
</Grid>
|
||
)}
|
||
|
||
{!loading && !error && visibleItems.length === 0 && (
|
||
<Paper radius="lg" p="xl" ta="center" withBorder className="empty-state">
|
||
<Stack gap="sm" align="center">
|
||
<Loader size="sm" type="dots" color="red" />
|
||
<Title order={3}>Sonuç bulunamadı</Title>
|
||
<Text c="dimmed">Filtreleri temizleyip farklı bir anahtar kelime deneyebilirsin.</Text>
|
||
</Stack>
|
||
</Paper>
|
||
)}
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
<Modal
|
||
opened={purgeConfirmOpened}
|
||
onClose={() => {
|
||
if (adminActionPending === 'purge-content') return
|
||
setPurgeConfirmOpened(false)
|
||
}}
|
||
centered
|
||
lockScroll={false}
|
||
radius="md"
|
||
title="Veritabani verilerini sil"
|
||
>
|
||
<Stack gap="md">
|
||
<Text size="sm" c="dimmed">
|
||
Bu islem geri alinmaz. Icerik tablosundaki tum film/dizi kayitlari silinecek.
|
||
</Text>
|
||
<Group justify="flex-end">
|
||
<Button
|
||
variant="default"
|
||
onClick={() => setPurgeConfirmOpened(false)}
|
||
disabled={adminActionPending === 'purge-content'}
|
||
>
|
||
Vazgec
|
||
</Button>
|
||
<Button
|
||
color="red"
|
||
loading={adminActionPending === 'purge-content'}
|
||
onClick={() => void handlePurgeAllContent()}
|
||
>
|
||
Evet, tumunu sil
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
<Modal
|
||
opened={Boolean(selectedContent)}
|
||
onClose={closeContentModal}
|
||
centered
|
||
size="xl"
|
||
radius="lg"
|
||
lockScroll={false}
|
||
transitionProps={{ duration: 90, transition: 'fade' }}
|
||
classNames={{
|
||
content: 'detail-modal-content',
|
||
header: 'detail-modal-header',
|
||
body: 'detail-modal-body',
|
||
}}
|
||
overlayProps={{ blur: 4, opacity: 0.8 }}
|
||
title={null}
|
||
>
|
||
{selectedContent && (
|
||
<Stack gap="md" className="detail-content-stack">
|
||
<div className="detail-media-wrap">
|
||
{selectedContent.backdrop ? (
|
||
<Image src={selectedContent.backdrop} alt={selectedContent.title} h={230} />
|
||
) : (
|
||
<Group h={230} justify="center" className="media-fallback">
|
||
{selectedContent.type === 'movie' ? <IconMovie size={58} /> : <IconDeviceTv size={58} />}
|
||
</Group>
|
||
)}
|
||
<div className="detail-media-overlay" />
|
||
<div className="detail-title-group">
|
||
<Badge color={selectedContent.type === 'movie' ? 'red' : 'blue'}>
|
||
{selectedContent.type === 'movie' ? 'Film' : 'Dizi'}
|
||
</Badge>
|
||
<Title order={2} className="detail-title">
|
||
{selectedContent.title}
|
||
</Title>
|
||
</div>
|
||
</div>
|
||
|
||
<Group gap="xs">
|
||
{selectedContent.year && <Badge variant="light">{selectedContent.year}</Badge>}
|
||
{selectedContent.ageRating && <Badge variant="outline">{selectedContent.ageRating}</Badge>}
|
||
{selectedContent.currentSeason && selectedContent.type === 'tvshow' && (
|
||
<Badge variant="light" color="grape">
|
||
Sezon {selectedContent.currentSeason}
|
||
</Badge>
|
||
)}
|
||
</Group>
|
||
|
||
<Text c="dimmed" size="sm" className="detail-plot">
|
||
{selectedContent.plot || 'Açıklama bulunamadı.'}
|
||
</Text>
|
||
|
||
<Divider color="rgba(255,255,255,0.14)" />
|
||
|
||
<Stack gap={8}>
|
||
<Text fw={600} size="sm">
|
||
Türler
|
||
</Text>
|
||
<Group gap={6}>
|
||
{selectedContent.genres.length > 0 ? (
|
||
selectedContent.genres.map((genre) => (
|
||
<Badge key={genre} size="sm" variant="dot">
|
||
{genre}
|
||
</Badge>
|
||
))
|
||
) : (
|
||
<Text size="sm" c="dimmed">
|
||
Tür bilgisi yok.
|
||
</Text>
|
||
)}
|
||
</Group>
|
||
</Stack>
|
||
|
||
<Stack gap={8}>
|
||
<Text fw={600} size="sm">
|
||
Oyuncular
|
||
</Text>
|
||
<Group gap={6}>
|
||
{selectedContent.cast && selectedContent.cast.length > 0 ? (
|
||
selectedContent.cast.slice(0, 5).map((castName) => (
|
||
<Badge key={castName} size="sm" variant="light" color="gray">
|
||
{castName}
|
||
</Badge>
|
||
))
|
||
) : (
|
||
<Text size="sm" c="dimmed">
|
||
Oyuncu bilgisi bulunamadı.
|
||
</Text>
|
||
)}
|
||
</Group>
|
||
</Stack>
|
||
{selectedContentBrand && (
|
||
<img
|
||
src={selectedContentBrand.src}
|
||
alt={selectedContentBrand.alt}
|
||
className="detail-brand-stamp"
|
||
/>
|
||
)}
|
||
</Stack>
|
||
)}
|
||
</Modal>
|
||
{adminActionMessage && (
|
||
<div className="admin-toast" role="status" aria-live="polite">
|
||
{adminActionMessage}
|
||
</div>
|
||
)}
|
||
</Container>
|
||
)
|
||
}
|