feat(ui): add provider branding and admin purge confirmation modal

This commit is contained in:
2026-03-01 01:08:36 +03:00
parent c0e62e778c
commit 146edfb3dc
3 changed files with 115 additions and 11 deletions

BIN
frontend/public/prime.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -32,6 +32,7 @@ import {
import './movies-page.css' import './movies-page.css'
type ContentType = 'movie' | 'tvshow' type ContentType = 'movie' | 'tvshow'
type ContentProvider = 'netflix' | 'primevideo'
type SortBy = 'title' | 'year' type SortBy = 'title' | 'year'
const WEB_API_KEY = import.meta.env.VITE_WEB_API_KEY ?? 'web-dev-key-change-me' 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' const ADMIN_API_KEY = import.meta.env.VITE_ADMIN_API_KEY ?? 'admin-dev-key-change-me'
@@ -51,6 +52,7 @@ declare global {
} }
interface ContentListItem { interface ContentListItem {
provider?: ContentProvider
title: string title: string
year: number | null year: number | null
plot: string | null plot: string | null
@@ -143,7 +145,7 @@ interface AdminOverview {
sourceCounts: { sourceCounts: {
cache: number cache: number
database: number database: number
netflix: number scraper: number
} }
} }
} }
@@ -195,6 +197,13 @@ function getContentKey(item: Pick<ContentListItem, 'type' | 'title' | 'year'>):
return `${item.type}::${item.title}::${item.year ?? 'na'}` 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> { async function loadSocketClient(): Promise<SocketClient | null> {
if (typeof window === 'undefined') return null if (typeof window === 'undefined') return null
if (window.io) { if (window.io) {
@@ -260,6 +269,7 @@ export function MoviesPage() {
const [adminError, setAdminError] = useState<string | null>(null) const [adminError, setAdminError] = useState<string | null>(null)
const [adminActionMessage, setAdminActionMessage] = useState<string | null>(null) const [adminActionMessage, setAdminActionMessage] = useState<string | null>(null)
const [adminActionPending, setAdminActionPending] = useState<string | null>(null) const [adminActionPending, setAdminActionPending] = useState<string | null>(null)
const [purgeConfirmOpened, setPurgeConfirmOpened] = useState(false)
const [typeFilter, setTypeFilter] = useState<'all' | ContentType>('all') const [typeFilter, setTypeFilter] = useState<'all' | ContentType>('all')
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [sortBy, setSortBy] = useState<SortBy>('title') const [sortBy, setSortBy] = useState<SortBy>('title')
@@ -401,7 +411,7 @@ export function MoviesPage() {
actionKey: string, actionKey: string,
endpoint: string, endpoint: string,
body?: Record<string, unknown> body?: Record<string, unknown>
) => { ): Promise<boolean> => {
const startedAt = Date.now() const startedAt = Date.now()
setAdminActionPending(actionKey) setAdminActionPending(actionKey)
setAdminActionMessage(null) setAdminActionMessage(null)
@@ -424,21 +434,32 @@ export function MoviesPage() {
if (!result.success || !result.data) { if (!result.success || !result.data) {
setAdminError(result.error?.message || 'Admin aksiyonu basarisiz') setAdminError(result.error?.message || 'Admin aksiyonu basarisiz')
return return false
} }
setAdminActionMessage( setAdminActionMessage(
`${result.data.details ?? 'Aksiyon tamamlandi'} | queued: ${result.data.queued}, skipped: ${result.data.skipped}` `${result.data.details ?? 'Aksiyon tamamlandi'} | queued: ${result.data.queued}, skipped: ${result.data.skipped}`
) )
await loadAdminOverview(undefined, true) await loadAdminOverview(undefined, true)
return true
} catch { } catch {
setAdminError('Admin aksiyonu baglanti hatasi') setAdminError('Admin aksiyonu baglanti hatasi')
return false
} finally { } finally {
await waitForMinimumDuration(startedAt, MIN_BUTTON_LOADING_MS) await waitForMinimumDuration(startedAt, MIN_BUTTON_LOADING_MS)
setAdminActionPending(null) 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 handleAdminRefresh = async () => {
const startedAt = Date.now() const startedAt = Date.now()
await loadAdminOverview(undefined, false) await loadAdminOverview(undefined, false)
@@ -582,6 +603,10 @@ export function MoviesPage() {
() => items.find((item) => getContentKey(item) === selectedContentKey) ?? null, () => items.find((item) => getContentKey(item) === selectedContentKey) ?? null,
[items, selectedContentKey] [items, selectedContentKey]
) )
const selectedContentBrand = useMemo(
() => (selectedContent ? getProviderBrand(selectedContent) : null),
[selectedContent]
)
const movieCount = items.filter((item) => item.type === 'movie').length const movieCount = items.filter((item) => item.type === 'movie').length
const tvCount = items.filter((item) => item.type === 'tvshow').length const tvCount = items.filter((item) => item.type === 'tvshow').length
@@ -725,6 +750,14 @@ export function MoviesPage() {
> >
Stale refresh Stale refresh
</Button> </Button>
<Button
size="compact-sm"
color="red"
variant="light"
onClick={() => setPurgeConfirmOpened(true)}
>
DB tum veriyi sil
</Button>
</Group> </Group>
</Group> </Group>
@@ -904,7 +937,7 @@ export function MoviesPage() {
<Group gap="xs" mt="sm"> <Group gap="xs" mt="sm">
<Badge variant="light">Kaynak Cache: {adminOverview.requestMetrics.sourceCounts.cache}</Badge> <Badge variant="light">Kaynak Cache: {adminOverview.requestMetrics.sourceCounts.cache}</Badge>
<Badge variant="light">Kaynak DB: {adminOverview.requestMetrics.sourceCounts.database}</Badge> <Badge variant="light">Kaynak DB: {adminOverview.requestMetrics.sourceCounts.database}</Badge>
<Badge variant="light">Kaynak Netflix: {adminOverview.requestMetrics.sourceCounts.netflix}</Badge> <Badge variant="light">Kaynak Scraper: {adminOverview.requestMetrics.sourceCounts.scraper}</Badge>
</Group> </Group>
<Stack gap={6} mt="md"> <Stack gap={6} mt="md">
<Text size="sm" fw={600}> <Text size="sm" fw={600}>
@@ -1057,6 +1090,7 @@ export function MoviesPage() {
{visibleItems.map((item, index) => { {visibleItems.map((item, index) => {
const itemKey = getContentKey(item) const itemKey = getContentKey(item)
const isLive = flashItemKeys.includes(itemKey) const isLive = flashItemKeys.includes(itemKey)
const brand = getProviderBrand(item)
return ( return (
<Grid.Col key={itemKey} span={{ base: 12, sm: 6, md: 4 }}> <Grid.Col key={itemKey} span={{ base: 12, sm: 6, md: 4 }}>
<Card <Card
@@ -1099,7 +1133,7 @@ export function MoviesPage() {
</Badge> </Badge>
</Group> </Group>
<Group gap="xs"> <Group gap="xs" className="card-meta-row">
{item.year && <Badge variant="light">{item.year}</Badge>} {item.year && <Badge variant="light">{item.year}</Badge>}
{item.ageRating && <Badge variant="outline">{item.ageRating}</Badge>} {item.ageRating && <Badge variant="outline">{item.ageRating}</Badge>}
{item.currentSeason && item.type === 'tvshow' && ( {item.currentSeason && item.type === 'tvshow' && (
@@ -1109,11 +1143,11 @@ export function MoviesPage() {
)} )}
</Group> </Group>
<Text size="sm" c="dimmed" lineClamp={3}> <Text size="sm" c="dimmed" lineClamp={3} className="card-plot">
{item.plot || 'Açıklama bulunamadı.'} {item.plot || 'Açıklama bulunamadı.'}
</Text> </Text>
<Group gap={6}> <Group gap={6} className="card-genres">
{item.genres.slice(0, 3).map((genre) => ( {item.genres.slice(0, 3).map((genre) => (
<Badge key={genre} size="sm" variant="dot"> <Badge key={genre} size="sm" variant="dot">
{genre} {genre}
@@ -1122,7 +1156,7 @@ export function MoviesPage() {
</Group> </Group>
</Stack> </Stack>
<img src="/netflix.png" alt="Netflix" className="brand-stamp" /> <img src={brand.src} alt={brand.alt} className="brand-stamp" />
</Card> </Card>
</Grid.Col> </Grid.Col>
) )
@@ -1142,6 +1176,39 @@ export function MoviesPage() {
</Stack> </Stack>
)} )}
</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 <Modal
opened={Boolean(selectedContent)} opened={Boolean(selectedContent)}
onClose={closeContentModal} onClose={closeContentModal}
@@ -1159,7 +1226,7 @@ export function MoviesPage() {
title={null} title={null}
> >
{selectedContent && ( {selectedContent && (
<Stack gap="md"> <Stack gap="md" className="detail-content-stack">
<div className="detail-media-wrap"> <div className="detail-media-wrap">
{selectedContent.backdrop ? ( {selectedContent.backdrop ? (
<Image src={selectedContent.backdrop} alt={selectedContent.title} h={230} /> <Image src={selectedContent.backdrop} alt={selectedContent.title} h={230} />
@@ -1220,7 +1287,7 @@ export function MoviesPage() {
</Text> </Text>
<Group gap={6}> <Group gap={6}>
{selectedContent.cast && selectedContent.cast.length > 0 ? ( {selectedContent.cast && selectedContent.cast.length > 0 ? (
selectedContent.cast.slice(0, 14).map((castName) => ( selectedContent.cast.slice(0, 5).map((castName) => (
<Badge key={castName} size="sm" variant="light" color="gray"> <Badge key={castName} size="sm" variant="light" color="gray">
{castName} {castName}
</Badge> </Badge>
@@ -1232,7 +1299,13 @@ export function MoviesPage() {
)} )}
</Group> </Group>
</Stack> </Stack>
<img src="/netflix.png" alt="Netflix" className="detail-brand-stamp" /> {selectedContentBrand && (
<img
src={selectedContentBrand.src}
alt={selectedContentBrand.alt}
className="detail-brand-stamp"
/>
)}
</Stack> </Stack>
)} )}
</Modal> </Modal>

View File

@@ -142,6 +142,10 @@
.catalog-card { .catalog-card {
position: relative; position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 420px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
background: linear-gradient(165deg, rgba(29, 33, 44, 0.95), rgba(15, 19, 30, 0.96)); background: linear-gradient(165deg, rgba(29, 33, 44, 0.95), rgba(15, 19, 30, 0.96));
@@ -212,6 +216,8 @@
.media-wrap { .media-wrap {
position: relative; position: relative;
height: 190px;
flex: 0 0 190px;
} }
.image-shell { .image-shell {
@@ -305,6 +311,10 @@
line-height: 1.62; line-height: 1.62;
} }
.detail-content-stack {
padding-bottom: 38px;
}
.detail-brand-stamp { .detail-brand-stamp {
position: absolute; position: absolute;
right: 20px; right: 20px;
@@ -319,9 +329,30 @@
.card-content { .card-content {
position: relative; position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 168px;
z-index: 2; z-index: 2;
} }
.card-meta-row {
min-height: 26px;
align-items: center;
}
.card-plot {
min-height: 4.8em;
line-height: 1.6;
}
.card-genres {
margin-top: auto;
min-height: 24px;
padding-right: 36px;
overflow: hidden;
}
.card-title { .card-title {
font-family: 'Bricolage Grotesque', 'IBM Plex Sans', sans-serif; font-family: 'Bricolage Grotesque', 'IBM Plex Sans', sans-serif;
letter-spacing: 0.01em; letter-spacing: 0.01em;