Files
ratebubble/frontend/src/pages/MoviesPage.tsx

1320 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}