a / web /src /components /ImageListView.vue
FTA7700
Add API alias column to admin image lists
6512c5c
Raw History Blame Contribute Delete
10 kB
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuery } from '@tanstack/vue-query'
import { Eye, Loader2, Download } from 'lucide-vue-next'
import RefreshButton from '@/components/RefreshButton.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
interface ImageMeta {
cache_key: string
release_date: string | null
created_at: number
updated_at: number
api_key_alias: string | null
}
interface ListResponse {
items: ImageMeta[]
total: number
page: number
page_size: number
}
const props = defineProps<{
kind: 'poster' | 'logo' | 'backdrop' | 'episode'
listFn: (page: number, pageSize: number) => Promise<Response>
imageFn: (key: string) => Promise<Response>
fetchFn: (idType: string, idValue: string) => Promise<Response>
}>()
const route = useRoute()
const router = useRouter()
const page = computed({
get: () => {
const p = Number(route.query.page)
return p > 0 ? p : 1
},
set: (v: number) => {
router.replace({ query: { ...route.query, page: v === 1 ? undefined : String(v) } })
},
})
const pageSize = 50
const { data, isPending, isFetching, refetch } = useQuery<ListResponse>({
queryKey: computed(() => ['admin', props.kind + 's', page.value]),
queryFn: async () => {
const res = await props.listFn(page.value, pageSize)
if (!res.ok) throw new Error(`Failed to fetch ${props.kind}s`)
return res.json()
},
})
const previewOpen = ref(false)
const previewKey = ref('')
const previewUrl = ref<string | null>(null)
const previewLoading = ref(false)
async function openPreview(cacheKey: string) {
previewKey.value = cacheKey
previewOpen.value = true
previewLoading.value = true
previewUrl.value = null
try {
const res = await props.imageFn(cacheKey)
if (res.ok) {
const blob = await res.blob()
previewUrl.value = URL.createObjectURL(blob)
}
} finally {
previewLoading.value = false
}
}
function closePreview() {
previewOpen.value = false
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
}
onUnmounted(() => {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
})
const fetchModalOpen = ref(false)
const fetchIdType = ref('imdb')
const fetchIdValue = ref('')
const fetchLoading = ref(false)
const fetchError = ref('')
async function fetchImage() {
const idValue = fetchIdValue.value.trim()
if (!idValue) return
fetchLoading.value = true
fetchError.value = ''
try {
const res = await props.fetchFn(fetchIdType.value, idValue)
if (!res.ok) {
const text = await res.text()
try { fetchError.value = JSON.parse(text).error || text } catch { fetchError.value = text || `Error ${res.status}` }
return
}
const fetchedKey = `${fetchIdType.value}/${idValue}`
// Use the image bytes from the fetch response directly for preview,
// since the cache key used by imageFn may not match the full variant key.
const blob = await res.blob()
fetchIdValue.value = ''
fetchModalOpen.value = false
previewKey.value = fetchedKey
previewOpen.value = true
previewLoading.value = false
previewUrl.value = URL.createObjectURL(blob)
refetch()
} catch (e) {
fetchError.value = e instanceof Error ? e.message : 'Fetch failed'
} finally {
fetchLoading.value = false
}
}
function openFetchModal() {
fetchError.value = ''
fetchModalOpen.value = true
}
function parseKey(cacheKey: string) {
const idx = cacheKey.indexOf('/')
if (idx === -1) return { idType: cacheKey, idValue: '' }
return { idType: cacheKey.slice(0, idx), idValue: cacheKey.slice(idx + 1) }
}
function formatDate(epoch: number) {
return new Date(epoch * 1000).toLocaleDateString()
}
function relativeTime(epoch: number) {
const diff = Date.now() / 1000 - epoch
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
return `${Math.floor(diff / 86400)}d ago`
}
const totalPages = computed(() => data.value ? Math.ceil(data.value.total / data.value.page_size) : 0)
const kindLabel = computed(() => props.kind)
const kindLabelPlural = computed(() => props.kind + 's')
function prevPage() {
if (page.value > 1) page.value--
}
function nextPage() {
if (page.value < totalPages.value) page.value++
}
const previewSizeClass = computed(() => {
if (props.kind === 'backdrop') return 'max-w-2xl'
if (props.kind === 'episode') return 'max-w-xl'
return 'max-w-md'
})
const skeletonClass = computed(() => {
if (props.kind === 'poster') return 'h-[400px] w-[270px] max-w-full rounded-md'
if (props.kind === 'logo') return 'h-[200px] w-[400px] max-w-full rounded-md'
return 'h-[270px] w-[480px] max-w-full rounded-md'
})
</script>
<template>
<div class="space-y-4">
<div class="flex justify-end gap-2">
<Button variant="outline" size="sm" @click="openFetchModal">
<Download class="size-4 mr-1" />
Fetch
</Button>
<RefreshButton :fetching="isFetching" @refresh="refetch()" />
</div>
<div v-if="isPending" class="space-y-3">
<Skeleton v-for="i in 5" :key="i" class="h-10 w-full" />
</div>
<template v-else-if="data">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-10"></TableHead>
<TableHead>ID Type</TableHead>
<TableHead>ID Value</TableHead>
<TableHead>Release Date</TableHead>
<TableHead>API Alias</TableHead>
<TableHead>Last Updated</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="data.items.length === 0">
<TableCell colspan="7" class="text-center text-muted-foreground">No {{ kindLabelPlural }} cached yet.</TableCell>
</TableRow>
<TableRow v-for="item in data.items" :key="item.cache_key" class="cursor-pointer" @click="openPreview(item.cache_key)">
<TableCell>
<Eye class="size-4 text-muted-foreground" />
</TableCell>
<TableCell class="font-mono text-xs">{{ parseKey(item.cache_key).idType }}</TableCell>
<TableCell class="font-mono text-xs">{{ parseKey(item.cache_key).idValue }}</TableCell>
<TableCell>{{ item.release_date || '—' }}</TableCell>
<TableCell>{{ item.api_key_alias || 'Default' }}</TableCell>
<TableCell>{{ relativeTime(item.updated_at) }}</TableCell>
<TableCell>{{ formatDate(item.created_at) }}</TableCell>
</TableRow>
</TableBody>
</Table>
<div class="flex items-center justify-between">
<p class="text-sm text-muted-foreground">
{{ data.total }} {{ data.total === 1 ? kindLabel : kindLabelPlural }} total
</p>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" :disabled="page <= 1" @click="prevPage">Previous</Button>
<span class="text-sm">Page {{ page }} of {{ totalPages }}</span>
<Button variant="outline" size="sm" :disabled="page >= totalPages" @click="nextPage">Next</Button>
</div>
</div>
</template>
<Dialog :open="fetchModalOpen" @update:open="(v: boolean) => { if (!v) fetchModalOpen = false }">
<DialogContent class="max-w-sm">
<DialogHeader>
<DialogTitle>Fetch {{ kindLabel.charAt(0).toUpperCase() + kindLabel.slice(1) }}</DialogTitle>
</DialogHeader>
<form class="space-y-4" @submit.prevent="fetchImage">
<div class="space-y-2">
<Label>ID Type</Label>
<Select v-model="fetchIdType">
<SelectTrigger data-testid="fetch-id-type-select">
<SelectValue placeholder="Select ID type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="imdb">IMDb</SelectItem>
<SelectItem value="tmdb">TMDb</SelectItem>
<SelectItem value="tvdb">TVDB</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>ID Value</Label>
<Input v-model="fetchIdValue" placeholder="e.g. tt1234567" />
</div>
<p v-if="fetchError" class="text-sm text-destructive">{{ fetchError }}</p>
<div class="flex justify-end">
<Button type="submit" :disabled="fetchLoading || !fetchIdValue.trim()">
<Loader2 v-if="fetchLoading" class="size-4 animate-spin mr-1" />
Fetch
</Button>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog :open="previewOpen" @update:open="(v: boolean) => { if (!v) closePreview() }">
<DialogContent :class="previewSizeClass">
<DialogHeader>
<DialogTitle class="min-w-0 break-all pr-8 font-mono text-sm">{{ previewKey }}</DialogTitle>
</DialogHeader>
<div class="flex items-center justify-center min-h-[200px] min-w-0">
<Skeleton v-if="previewLoading" :class="skeletonClass" />
<img
v-else-if="previewUrl"
:src="previewUrl"
:alt="previewKey"
class="max-h-[70vh] max-w-full rounded-md object-contain"
/>
<p v-else class="text-sm text-muted-foreground">Failed to load {{ kindLabel }}</p>
</div>
</DialogContent>
</Dialog>
</div>
</template>