feat(ui): add provider branding and admin purge confirmation modal
This commit is contained in:
BIN
frontend/public/prime.png
Normal file
BIN
frontend/public/prime.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user