From c0841aab20fedf53c5726371368f6cb875566300 Mon Sep 17 00:00:00 2001 From: szbk Date: Sat, 28 Feb 2026 12:00:22 +0300 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20admin=20dashboard=20+=20i?= =?UTF-8?q?=C3=A7erik=20katalo=C4=9Fu=20UI,=20realtime=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.css: IBM Plex Sans + Bricolage Grotesque font'ları import edildi; CSS custom property sistemi (--bg-base, --accent-main vb.) tanımlandı; body'ye fixed radial gradient + grid overlay arka plan eklendi. - main.tsx: MantineProvider tema güncellendi — IBM Plex Sans/Bricolage Grotesque font ailesi, responsive heading boyutları, özel radius/shadow değerleri ayarlandı. - App.css: Gereksiz yorum temizlendi, stil yönetimi route-level CSS'e taşındı. - MoviesPage.tsx (büyük güncelleme): • Katalog görünümü: film/dizi grid, arama, sıralama, backdrop modal. • Admin Dashboard görünümü: cache özeti, content istatistikleri, job durum sayaçları, failed job listesi, cache expiry tablosu, metrics (hit/miss oranı, kaynak dağılımı). • Admin aksiyonlar: cache temizleme, cache ısıtma, başarısız job yeniden kuyruklama, eski içerik yenileme. • Socket.IO entegrasyonu: content:event dinlenerek katalog anlık güncelleniyor; metrics:updated ile dashboard metrikleri canlı akıyor. • Reconnect davranışı: bağlantı kopunca her görünüm kendi snapshot'ını otomatik yeniliyor. - movies-page.css (yeni): MoviesPage'e özel kart, backdrop, istatistik kutusu ve animasyon stilleri. - vite.config.ts: /socket.io proxy kuralı eklendi (ws: true) — dev sunucusu üzerinden WebSocket bağlantısı backend'e yönlendiriliyor. - frontend/.env.example (yeni): VITE_API_BASE_URL, VITE_WEB_API_KEY, VITE_ADMIN_API_KEY değişken şablonu eklendi. Co-Authored-By: Claude Sonnet 4.6 --- frontend/.env.example | 8 + frontend/src/App.css | 3 +- frontend/src/index.css | 55 +- frontend/src/main.tsx | 21 +- frontend/src/pages/MoviesPage.tsx | 1296 +++++++++++++++++++++++++--- frontend/src/pages/movies-page.css | 431 +++++++++ frontend/vite.config.ts | 5 + 7 files changed, 1679 insertions(+), 140 deletions(-) create mode 100644 frontend/.env.example create mode 100644 frontend/src/pages/movies-page.css diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..6d708d5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,8 @@ +# Frontend (Vite) environment variables +# Copy to frontend/.env.local for local development + +# Must match backend API_KEY_WEB value +VITE_WEB_API_KEY=web-frontend-key-change-me-in-production + +# Must match backend API_KEY_ADMIN value (for admin dashboard endpoints) +VITE_ADMIN_API_KEY=admin-key-super-secret-change-me diff --git a/frontend/src/App.css b/frontend/src/App.css index 3d503dc..f4dc07c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,2 +1 @@ -/* App styles - Mantine handles most styling */ - +/* Page-level styles are collocated in route-level css files. */ diff --git a/frontend/src/index.css b/frontend/src/index.css index 447e557..4307d8e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,10 +1,51 @@ -body { - margin: 0; - padding: 0; - background-color: #1a1a1b; - min-height: 100vh; +@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;700;800&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap'); + +:root { + --bg-base: #090a0e; + --bg-alt: #10131d; + --surface: rgba(19, 22, 33, 0.72); + --surface-strong: rgba(27, 33, 48, 0.94); + --text-main: #f4f5f8; + --text-muted: #adb4c2; + --line-soft: rgba(255, 255, 255, 0.12); + --accent-main: #eb2338; + --accent-warm: #ff9b42; } -#root { - min-height: 100vh; +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +html { + scrollbar-gutter: stable both-edges; +} + +body { + margin: 0; + color: var(--text-main); + font-family: 'IBM Plex Sans', sans-serif; + background: + radial-gradient(1200px 700px at 18% -8%, rgba(235, 35, 56, 0.22), transparent 58%), + radial-gradient(900px 640px at 90% 10%, rgba(255, 155, 66, 0.14), transparent 58%), + linear-gradient(140deg, var(--bg-base), var(--bg-alt)); + background-attachment: fixed; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.22; + z-index: -1; + background-image: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + background-size: 42px 42px, 42px 42px; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0cfb5e4..a053644 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,11 +3,28 @@ import { createRoot } from 'react-dom/client' import { MantineProvider, createTheme } from '@mantine/core' import '@mantine/core/styles.css' import './index.css' -import App from './App.tsx' +import App from './App' const theme = createTheme({ primaryColor: 'red', - fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif', + fontFamily: '"IBM Plex Sans", sans-serif', + headings: { + fontFamily: '"Bricolage Grotesque", "IBM Plex Sans", sans-serif', + sizes: { + h1: { fontSize: 'clamp(2rem, 4vw, 3.6rem)', lineHeight: '1.02', fontWeight: '700' }, + h2: { fontSize: 'clamp(1.4rem, 2.6vw, 2.1rem)', lineHeight: '1.1', fontWeight: '700' }, + h3: { fontSize: '1.12rem', lineHeight: '1.2', fontWeight: '650' }, + }, + }, + radius: { + md: '14px', + lg: '20px', + xl: '28px', + }, + shadows: { + md: '0 18px 45px rgba(0, 0, 0, 0.28)', + xl: '0 30px 70px rgba(0, 0, 0, 0.36)', + }, }) createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/pages/MoviesPage.tsx b/frontend/src/pages/MoviesPage.tsx index 5563fc3..ec7758c 100644 --- a/frontend/src/pages/MoviesPage.tsx +++ b/frontend/src/pages/MoviesPage.tsx @@ -1,22 +1,54 @@ -import { useEffect, useMemo, useState } from 'react' +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, IconDeviceTv, IconMovie } from '@tabler/icons-react' +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 @@ -25,6 +57,7 @@ interface ContentListItem { ageRating: string | null type: ContentType genres: string[] + cast?: string[] backdrop: string | null currentSeason: number | null } @@ -37,172 +70,1177 @@ interface ContentListResponse { } } +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(() => { - const controller = new AbortController() + typeFilterRef.current = typeFilter + }, [typeFilter]) - const loadContent = async () => { - setLoading(true) - setError(null) - try { - const params = new URLSearchParams() - params.append('limit', '100') - if (typeFilter !== 'all') { - params.append('type', typeFilter) - } + useEffect(() => { + if (!adminActionMessage) return + const timer = setTimeout(() => { + setAdminActionMessage(null) + }, 2600) + return () => clearTimeout(timer) + }, [adminActionMessage]) - const response = await fetch(`/api/content?${params.toString()}`, { - method: 'GET', - headers: { - 'X-API-Key': 'web-dev-key-change-me', - }, - signal: controller.signal, - }) + useEffect(() => { + if (activeView !== 'catalog') return + if (flashItemKeys.length === 0) return - const data: ContentListResponse = await response.json() - if (data.success && data.data) { - setItems(data.data) - return - } + const timer = setTimeout(() => { + setFlashItemKeys([]) + }, 1900) - setError(data.error?.message || 'Liste alınamadı') - } catch { - if (!controller.signal.aborted) { - setError('Bağlantı hatası') - } - } finally { - if (!controller.signal.aborted) { - setLoading(false) + 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) } } } + } - void loadContent() + 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 Listesi' - if (typeFilter === 'tvshow') return 'Dizi Listesi' - return 'Film ve Dizi Listesi' + 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} - - Veriler doğrudan veritabanından okunur. + + {heroDescription} -
- setTypeFilter(value as 'all' | ContentType)} - data={[ - { label: 'Tümü', value: 'all' }, - { label: 'Filmler', value: 'movie' }, - { label: 'Diziler', value: 'tvshow' }, - ]} - /> +
+ + + + Toplam + + + {items.length} + + + + + Film + + + {movieCount} + + + + + Dizi + + + {tvCount} + + + - {error && ( - } color="red" title="Hata"> - {error} - - )} - - {loading ? ( - - + + + + - ) : ( - - {items.map((item) => ( - - - - {item.backdrop ? ( - {item.title} - ) : ( - - {item.type === 'movie' ? ( - - ) : ( - - )} - - )} - + - - - - {item.title} - - - {item.type === 'movie' ? 'Film' : 'Dizi'} - - + {activeView === 'admin' && ( + + + +
+ Yonetici Dashboard + + Redis, veritabani ve scrape operasyon durumu. + +
+ + + Ortam: {adminOverview?.environment ?? '-'} + + + + + + + +
- - {item.year && {item.year}} - {item.ageRating && {item.ageRating}} - {item.currentSeason && item.type === 'tvshow' && ( - - S{item.currentSeason} - - )} - + {adminError && ( + } color="orange" title="Dashboard Hatasi"> + {adminError} + + )} - - {item.plot || 'Açıklama bulunamadı.'} - + {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 + + + + - - {item.genres.slice(0, 3).map((genre) => ( - - {genre} - - ))} - -
+ + + + 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)} + + + )) + )} + + + - Netflix - - - ))} - + + + + 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} + +
+ )) + )} +
+
+
+
+ + + ) + )} +
+ )} - {!loading && !error && items.length === 0 && ( - - Kayıt bulunamadı. - + {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} +
+ )} ) } diff --git a/frontend/src/pages/movies-page.css b/frontend/src/pages/movies-page.css new file mode 100644 index 0000000..3560216 --- /dev/null +++ b/frontend/src/pages/movies-page.css @@ -0,0 +1,431 @@ +.catalog-page { + position: relative; +} + +.catalog-hero { + background: + linear-gradient(130deg, rgba(235, 35, 56, 0.14), rgba(13, 17, 28, 0.96) 48%), + radial-gradient(circle at 80% 18%, rgba(255, 155, 66, 0.2), transparent 45%); + border-color: rgba(255, 255, 255, 0.14); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.28); +} + +.eyebrow { + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.68); +} + +.stats-wrap { + align-items: stretch; +} + +.stat-pill { + min-width: 88px; + text-align: right; + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.15); +} + +.toolbar { + position: sticky; + top: 16px; + z-index: 20; + backdrop-filter: blur(8px); + background: rgba(14, 18, 28, 0.82); + border-color: rgba(255, 255, 255, 0.12); +} + +.admin-panel { + background: linear-gradient(150deg, rgba(15, 20, 32, 0.88), rgba(11, 14, 23, 0.92)); + border-color: rgba(255, 255, 255, 0.13); +} + +.view-nav { + position: sticky; + top: 12px; + z-index: 30; + backdrop-filter: blur(10px); + background: rgba(10, 15, 26, 0.84); + border-color: rgba(255, 255, 255, 0.14); +} + +.catalog-view-shell { + padding-inline: clamp(0.5rem, 1.4vw, 1.35rem); +} + +.nav-pill { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.02); + transition: transform 180ms ease, border-color 180ms ease, background-color 180ms ease; +} + +.nav-pill .mantine-Button-label, +.nav-pill .mantine-Button-section { + transition: color 180ms ease, opacity 180ms ease; +} + +.nav-pill.is-active { + border-color: rgba(235, 35, 56, 0.95); + box-shadow: 0 0 0 1px rgba(235, 35, 56, 0.26) inset; +} + +.nav-pill.is-active .mantine-Button-label, +.nav-pill.is-active .mantine-Button-section { + color: #f8fbff; + opacity: 1; +} + +.nav-pill.is-inactive { + border-color: rgba(255, 255, 255, 0.24); +} + +.nav-pill.is-inactive .mantine-Button-label, +.nav-pill.is-inactive .mantine-Button-section { + color: rgba(218, 226, 240, 0.86); + opacity: 0.94; +} + +.nav-pill:hover { + transform: translateY(-1px); + border-color: rgba(255, 155, 66, 0.45); +} + +.nav-pill.is-inactive:hover .mantine-Button-label, +.nav-pill.is-inactive:hover .mantine-Button-section { + color: rgba(246, 250, 255, 0.96); +} + +.admin-card { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.1); +} + +.list-row { + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.01); + min-width: 0; +} + +.cache-key-text { + min-width: 0; + flex: 1; + margin-right: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-input { + min-width: 260px; +} + +.genre-chip { + cursor: pointer; + border-color: rgba(255, 255, 255, 0.22); + transition: transform 140ms ease, box-shadow 140ms ease, border-color 140ms ease; +} + +.genre-chip:hover { + transform: translateY(-1px); +} + +.genre-chip:focus-visible { + outline: 2px solid rgba(255, 155, 66, 0.95); + outline-offset: 2px; + border-color: rgba(255, 155, 66, 0.95); +} + +.catalog-card { + position: relative; + overflow: hidden; + cursor: pointer; + background: linear-gradient(165deg, rgba(29, 33, 44, 0.95), rgba(15, 19, 30, 0.96)); + border-color: rgba(255, 255, 255, 0.11); + transition: transform 220ms ease, box-shadow 220ms ease; +} + +.catalog-card:hover { + transform: translateY(-7px); + box-shadow: 0 28px 65px rgba(0, 0, 0, 0.42); +} + +.catalog-card:focus-visible { + outline: 2px solid rgba(255, 155, 66, 0.92); + outline-offset: 3px; + transform: translateY(-4px); + box-shadow: 0 24px 54px rgba(0, 0, 0, 0.42); +} + +.live-fade-in { + animation: live-fade-in 1500ms ease; +} + +@keyframes live-fade-in { + 0% { + opacity: 0.55; + transform: translateY(10px) scale(0.985); + box-shadow: 0 0 0 rgba(235, 35, 56, 0), 0 0 0 rgba(255, 214, 102, 0); + } + 20% { + opacity: 1; + transform: translateY(0) scale(1.004); + box-shadow: 0 0 0 2px rgba(255, 214, 102, 0.56), 0 0 28px rgba(255, 214, 102, 0.42); + } + 55% { + opacity: 1; + transform: translateY(0) scale(1); + box-shadow: 0 0 0 2px rgba(235, 35, 56, 0.35), 0 0 20px rgba(235, 35, 56, 0.22); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + box-shadow: 0 0 0 rgba(235, 35, 56, 0), 0 0 0 rgba(255, 214, 102, 0); + } +} + +.live-ttl-flash { + animation: ttl-flash 1050ms cubic-bezier(0.22, 1, 0.36, 1); +} + +@keyframes ttl-flash { + 0% { + border-color: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 rgba(0, 0, 0, 0); + background: rgba(255, 255, 255, 0.01); + } + 38% { + border-color: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.34), inset 0 0 28px rgba(0, 0, 0, 0.42); + background: linear-gradient(90deg, rgba(0, 0, 0, 0.32), rgba(255, 255, 255, 0.012)); + } + 100% { + border-color: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 0 0 rgba(0, 0, 0, 0); + background: rgba(255, 255, 255, 0.01); + } +} + +.media-wrap { + position: relative; +} + +.image-shell { + position: relative; + height: 190px; +} + +.image-skeleton { + position: absolute; + inset: 0; + z-index: 1; +} + +.lazy-image { + position: relative; + z-index: 2; +} + +.lazy-image img { + filter: blur(14px); + transform: scale(1.05); + opacity: 0.86; + transition: filter 320ms ease, transform 420ms ease, opacity 240ms ease; +} + +.lazy-image.is-loaded img { + filter: blur(0); + transform: scale(1); + opacity: 1; +} + +.media-fallback { + background: linear-gradient(160deg, rgba(35, 38, 52, 0.95), rgba(24, 28, 39, 0.95)); + color: rgba(255, 255, 255, 0.34); +} + +.media-overlay { + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(to top, rgba(8, 10, 16, 0.86), rgba(8, 10, 16, 0.05) 55%); +} + +.detail-modal-content { + background: linear-gradient(150deg, rgba(16, 20, 31, 0.97), rgba(11, 14, 24, 0.97)); + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.detail-modal-header { + background: transparent; +} + +.detail-modal-body { + padding-top: 0.6rem; + position: relative; +} + +.detail-media-wrap { + position: relative; + overflow: hidden; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.detail-media-overlay { + position: absolute; + inset: 0; + pointer-events: none; + background: + linear-gradient(to top, rgba(8, 10, 16, 0.92), rgba(8, 10, 16, 0.1) 58%), + radial-gradient(circle at 76% 22%, rgba(235, 35, 56, 0.22), transparent 42%); +} + +.detail-title-group { + position: absolute; + left: 16px; + bottom: 14px; + right: 16px; + z-index: 2; + display: flex; + flex-direction: column; + gap: 8px; +} + +.detail-title { + color: #f5f8fc; + text-shadow: 0 5px 18px rgba(0, 0, 0, 0.4); +} + +.detail-plot { + line-height: 1.62; +} + +.detail-brand-stamp { + position: absolute; + right: 20px; + bottom: 16px; + width: 34px; + height: 34px; + object-fit: contain; + opacity: 0.96; + filter: drop-shadow(0 7px 16px rgba(0, 0, 0, 0.44)); + pointer-events: none; +} + +.card-content { + position: relative; + z-index: 2; +} + +.card-title { + font-family: 'Bricolage Grotesque', 'IBM Plex Sans', sans-serif; + letter-spacing: 0.01em; +} + +.brand-stamp { + position: absolute; + right: 12px; + bottom: 12px; + width: 30px; + height: 30px; + object-fit: contain; + opacity: 0.92; + filter: drop-shadow(0 6px 14px rgba(0, 0, 0, 0.38)); +} + +.empty-state { + background: linear-gradient(145deg, rgba(22, 26, 37, 0.9), rgba(14, 17, 26, 0.95)); + border-color: rgba(255, 255, 255, 0.12); +} + +.admin-toast { + position: fixed; + right: 22px; + bottom: 22px; + z-index: 80; + max-width: min(420px, calc(100vw - 24px)); + padding: 12px 14px; + border-radius: 12px; + border: 1px solid rgba(84, 224, 187, 0.4); + background: linear-gradient(145deg, rgba(8, 40, 42, 0.95), rgba(10, 20, 32, 0.95)); + color: #dffcf2; + font-size: 0.92rem; + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.35); + animation: toast-in 180ms ease; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.reveal { + opacity: 0; + transform: translateY(10px); + animation: reveal-in 420ms ease forwards; +} + +@keyframes reveal-in { + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 48rem) { + .view-nav { + top: 8px; + } + + .view-nav > div { + overflow-x: auto; + white-space: nowrap; + width: 100%; + } + + .toolbar { + top: 10px; + } + + .search-input { + min-width: 100%; + } + + .catalog-view-shell { + padding-inline: 0; + } + + .stats-wrap { + width: 100%; + justify-content: flex-start; + } + + .admin-toast { + right: 10px; + bottom: 10px; + } +} + +@media (prefers-reduced-motion: reduce) { + .reveal, + .catalog-card, + .genre-chip, + .lazy-image img, + .live-fade-in, + .live-ttl-flash { + animation: none; + transition: none; + transform: none; + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index be25542..08efaad 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ target: 'http://localhost:3000', changeOrigin: true, }, + '/socket.io': { + target: 'http://localhost:3000', + changeOrigin: true, + ws: true, + }, }, }, css: {