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 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) => SocketClient } } interface ContentListItem { 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 netflix: 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 { const elapsed = Date.now() - startedAt const remaining = minMs - elapsed if (remaining > 0) { await new Promise((resolve) => setTimeout(resolve, remaining)) } } function getContentKey(item: Pick): string { return `${item.type}::${item.title}::${item.year ?? 'na'}` } async function loadSocketClient(): Promise { if (typeof window === 'undefined') return null if (window.io) { return window.io('/', { transports: ['websocket', 'polling'] }) } await new Promise((resolve, reject) => { const existing = document.querySelector('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) => 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 (
{!loaded && } {alt} setLoaded(true)} loading="lazy" decoding="async" />
) } export function MoviesPage() { const [activeView, setActiveView] = useState<'catalog' | 'admin'>('catalog') const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [adminOverview, setAdminOverview] = useState(null) const [adminLoading, setAdminLoading] = useState(true) const [adminRefreshing, setAdminRefreshing] = useState(false) const [adminError, setAdminError] = useState(null) const [adminActionMessage, setAdminActionMessage] = useState(null) const [adminActionPending, setAdminActionPending] = useState(null) const [typeFilter, setTypeFilter] = useState<'all' | ContentType>('all') const [search, setSearch] = useState('') const [sortBy, setSortBy] = useState('title') const [activeGenre, setActiveGenre] = useState(null) const [flashItemKeys, setFlashItemKeys] = useState([]) const [pinnedNewItemKeys, setPinnedNewItemKeys] = useState([]) const [flashTtlKeys, setFlashTtlKeys] = useState([]) const [selectedContentKey, setSelectedContentKey] = useState(null) const typeFilterRef = useRef<'all' | ContentType>(typeFilter) const hasLoadedAdminRef = useRef(false) const previousTtlKeysRef = useRef([]) 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 ) => { 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 } setAdminActionMessage( `${result.data.details ?? 'Aksiyon tamamlandi'} | queued: ${result.data.queued}, skipped: ${result.data.skipped}` ) await loadAdminOverview(undefined, true) } catch { setAdminError('Admin aksiyonu baglanti hatasi') } finally { await waitForMinimumDuration(startedAt, MIN_BUTTON_LOADING_MS) setAdminActionPending(null) } } 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 | 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() 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 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 ( RateBubble koleksiyonu {pageTitle} {heroDescription} Toplam {items.length} Film {movieCount} Dizi {tvCount} {activeView === 'admin' && (
Yonetici Dashboard Redis, veritabani ve scrape operasyon durumu.
Ortam: {adminOverview?.environment ?? '-'}
{adminError && ( } color="orange" title="Dashboard Hatasi"> {adminError} )} {adminLoading ? ( {Array.from({ length: 4 }).map((_, idx) => ( ))} ) : ( adminOverview && ( <> Redis Key Sayisi {adminOverview.cache.keyCount} Toplam {Math.round(adminOverview.cache.totalBytes / 1024)} KB RAM {formatBytes(adminOverview.cache.redisMemory.usedBytes)}/ {adminOverview.cache.redisMemory.maxBytes ? formatBytes(adminOverview.cache.redisMemory.maxBytes) : 'Limitsiz'} TTL Dagilimi (5dk alti) {adminOverview.cache.ttlDistribution.lessThan5Min} Varsayilan TTL: {adminOverview.cache.configuredTtlSeconds}s Icerik Toplami {adminOverview.content.total} Film {adminOverview.content.byType.movie} / Dizi {adminOverview.content.byType.tvshow} Cache Hit Orani {Math.round(adminOverview.requestMetrics.cacheHitRate * 100)}% Hit {adminOverview.requestMetrics.cacheHits} / Miss {adminOverview.requestMetrics.cacheMisses} Job Durumu {adminOverview.jobs.counts.processing} Aktif, ort. {averageJobDurationSeconds}s Cache TTL Dagilimi TTL yok/suresi gecmis: {adminOverview.cache.ttlDistribution.expiredOrNoTtl} 0-5dk: {adminOverview.cache.ttlDistribution.lessThan5Min} 5-30dk: {adminOverview.cache.ttlDistribution.min5To30} 30dk+: {adminOverview.cache.ttlDistribution.min30Plus} {adminOverview.cache.sampled ? `Yuksek hacim nedeniyle ilk ${adminOverview.cache.analyzedKeyLimit} key analiz edildi.` : 'Tum cache keyleri analiz edildi.'} En yakin sure dolacak keyler {adminOverview.cache.expiringSoon.length === 0 ? ( Gosterilecek key bulunamadi. ) : ( adminOverview.cache.expiringSoon.map((item) => ( {item.mediaTitle ? `${item.key} | ${item.mediaTitle}` : item.key} {formatTtl(item.ttlSeconds)} )) )} Veritabani Ozeti 24s eklenen: {adminOverview.content.addedLast24h} 7g eklenen: {adminOverview.content.addedLast7d} Plot eksik: {adminOverview.content.metadataGaps.missingPlot} Yas siniri eksik: {adminOverview.content.metadataGaps.missingAgeRating} Backdrop eksik: {adminOverview.content.metadataGaps.missingBackdrop} En Populer Turler {adminOverview.content.topGenres.map((genre) => ( {genre.name} {genre.count} ))} Job ve Hata Ozeti Pending: {adminOverview.jobs.counts.pending} Processing: {adminOverview.jobs.counts.processing} Completed: {adminOverview.jobs.counts.completed} Failed: {adminOverview.jobs.counts.failed} Kaynak Cache: {adminOverview.requestMetrics.sourceCounts.cache} Kaynak DB: {adminOverview.requestMetrics.sourceCounts.database} Kaynak Netflix: {adminOverview.requestMetrics.sourceCounts.netflix} Son Basarisiz Isler {adminOverview.jobs.failedRecent.length === 0 ? ( Son donemde hata kaydi yok. ) : ( adminOverview.jobs.failedRecent.map((job) => (
{job.url} {job.error}
)) )}
) )}
)} {activeView === 'catalog' && ( { setTypeFilter(value as 'all' | ContentType) setActiveGenre(null) }} data={[ { label: 'Tümü', value: 'all' }, { label: 'Filmler', value: 'movie' }, { label: 'Diziler', value: 'tvshow' }, ]} /> } value={search} onChange={(event) => setSearch(event.currentTarget.value)} className="search-input" /> setSortBy(value as SortBy)} data={[ { label: ( Başlık ), value: 'title', }, { label: 'Yıl', value: 'year' }, ]} /> {allGenres.slice(0, 12).map((genre) => ( ))} {hasActiveFilters && ( )} {error && ( } color="red" title="Hata"> {error} )} {loading ? ( {Array.from({ length: 9 }).map((_, idx) => ( ))} ) : ( {visibleItems.map((item, index) => { const itemKey = getContentKey(item) const isLive = flashItemKeys.includes(itemKey) return ( openContentModal(item)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() openContentModal(item) } }} > {item.backdrop ? ( ) : ( {item.type === 'movie' ? : } )}
{item.title} {item.type === 'movie' ? 'Film' : 'Dizi'} {item.year && {item.year}} {item.ageRating && {item.ageRating}} {item.currentSeason && item.type === 'tvshow' && ( S{item.currentSeason} )} {item.plot || 'Açıklama bulunamadı.'} {item.genres.slice(0, 3).map((genre) => ( {genre} ))} Netflix ) })} )} {!loading && !error && visibleItems.length === 0 && ( Sonuç bulunamadı Filtreleri temizleyip farklı bir anahtar kelime deneyebilirsin. )} )} {selectedContent && (
{selectedContent.backdrop ? ( {selectedContent.title} ) : ( {selectedContent.type === 'movie' ? : } )}
{selectedContent.type === 'movie' ? 'Film' : 'Dizi'} {selectedContent.title}
{selectedContent.year && {selectedContent.year}} {selectedContent.ageRating && {selectedContent.ageRating}} {selectedContent.currentSeason && selectedContent.type === 'tvshow' && ( Sezon {selectedContent.currentSeason} )} {selectedContent.plot || 'Açıklama bulunamadı.'} Türler {selectedContent.genres.length > 0 ? ( selectedContent.genres.map((genre) => ( {genre} )) ) : ( Tür bilgisi yok. )} Oyuncular {selectedContent.cast && selectedContent.cast.length > 0 ? ( selectedContent.cast.slice(0, 14).map((castName) => ( {castName} )) ) : ( Oyuncu bilgisi bulunamadı. )} Netflix )} {adminActionMessage && (
{adminActionMessage}
)} ) }