Spaces:
Paused
Paused
FTA7700
Fix rating order hover border to brighter (not transparent); add card-flat-matching border to all 4 preview boxes
4ae30f0 Download web/src/components/RenderSettingsForm.vue from stremio-db/a: direct link, hf CLI and curl.
- Browser
- Download file 69.9 kB
-
https://huggingface.co/spaces/stremio-db/a/resolve/main/web/src/components/RenderSettingsForm.vue
- Command line
-
hf download hf://spaces/stremio-db/a/web/src/components/RenderSettingsForm.vue
-
curl -L -o RenderSettingsForm.vue https://huggingface.co/spaces/stremio-db/a/resolve/main/web/src/components/RenderSettingsForm.vue
69.9 kB
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuList, NavigationMenuTrigger } from '@/components/ui/navigation-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const testPosterPositionOptions = [
{ value: 'bc', label: 'Bottom Center' },
{ value: 'tc', label: 'Top Center' },
{ value: 'l', label: 'Left' },
{ value: 'r', label: 'Right' },
{ value: 'tl', label: 'Top Left' },
{ value: 'tr', label: 'Top Right' },
{ value: 'bl', label: 'Bottom Left' },
{ value: 'br', label: 'Bottom Right' },
]
const testPosterBadgeDirectionOptions = [
{ value: 'd', label: 'Default' },
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testPosterBadgeStyleOptions = [
{ value: 'd', label: 'Default' },
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testPosterLabelStyleOptions = [
{ value: 't', label: 'Text' },
{ value: 'i', label: 'Icon' },
{ value: 'o', label: 'Official' },
]
const testPosterBadgeSizeOptions = [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' },
]
const testPosterBadgeShapeOptions = [
{ value: 'r', label: 'Rounded' },
{ value: 'p', label: 'Pill' },
]
const testLogoBadgeStyleOptions = [
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testLogoBadgeShapeOptions = [
{ value: 'r', label: 'Rounded' },
{ value: 'p', label: 'Pill' },
]
const testLogoBadgeSizeOptions = [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' },
]
const testLogoBadgeBackgroundOptions = [
{ value: 'd', label: 'Default' },
{ value: 'k', label: 'Dark' },
{ value: 't', label: 'Transparent' },
{ value: 'n', label: 'None' },
]
const testLogoLabelStyleOptions = [
{ value: 't', label: 'Text' },
{ value: 'i', label: 'Icon' },
{ value: 'o', label: 'Official' },
]
const testEpisodePositionOptions = [
{ value: 'bc', label: 'Bottom Center' },
{ value: 'tc', label: 'Top Center' },
{ value: 'l', label: 'Left' },
{ value: 'r', label: 'Right' },
{ value: 'tl', label: 'Top Left' },
{ value: 'tr', label: 'Top Right' },
{ value: 'bl', label: 'Bottom Left' },
{ value: 'br', label: 'Bottom Right' },
]
const testEpisodeBadgeDirectionOptions = [
{ value: 'd', label: 'Auto' },
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testEpisodeBadgeStyleOptions = [
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testEpisodeLabelStyleOptions = [
{ value: 't', label: 'Text' },
{ value: 'i', label: 'Icon' },
{ value: 'o', label: 'Official' },
]
const testEpisodeBadgeSizeOptions = [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' },
]
const testEpisodeBadgeShapeOptions = [
{ value: 'r', label: 'Rounded' },
{ value: 'p', label: 'Pill' },
]
const testEpisodeBadgeBackgroundOptions = [
{ value: 'd', label: 'Default' },
{ value: 'k', label: 'Dark' },
{ value: 't', label: 'Transparent' },
{ value: 'n', label: 'None' },
]
const testPosterBadgeBackgroundOptions = [
{ value: 'd', label: 'Default' },
{ value: 'k', label: 'Dark' },
{ value: 't', label: 'Transparent' },
{ value: 'n', label: 'None' },
]
const testPosterFitOptions = [
{ value: 'native', label: 'Native (source ratio)' },
{ value: 'cover', label: 'Crop to 2:3' },
{ value: 'blur', label: 'Blur fill to 2:3' },
{ value: 'pad', label: 'Letterbox to 2:3' },
]
const testBackdropLabelStyleOptions = [
{ value: 't', label: 'Text' },
{ value: 'i', label: 'Icon' },
{ value: 'o', label: 'Official' },
]
const testBackdropBadgeShapeOptions = [
{ value: 'r', label: 'Rounded' },
{ value: 'p', label: 'Pill' },
]
const testBackdropPositionOptions = [
{ value: 'tl', label: 'Top Left' },
{ value: 'tc', label: 'Top Center' },
{ value: 'tr', label: 'Top Right' },
{ value: 'bl', label: 'Bottom Left' },
{ value: 'bc', label: 'Bottom Center' },
{ value: 'br', label: 'Bottom Right' },
{ value: 'l', label: 'Left' },
{ value: 'r', label: 'Right' },
]
const testBackdropBadgeDirectionOptions = [
{ value: 'd', label: 'Default' },
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testBackdropBadgeSizeOptions = [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' },
]
const testBackdropBadgeBackgroundOptions = [
{ value: 'd', label: 'Default' },
{ value: 'k', label: 'Dark' },
{ value: 't', label: 'Transparent' },
{ value: 'n', label: 'None' },
]
const testBackdropBadgeStyleOptions = [
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
import { Loader2, Check } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import RatingsOrderList from '@/components/RatingsOrderList.vue'
import type { SaveSettingsPayload } from '@/lib/api'
import { LANGUAGES, ALL_RATING_SOURCES, parseRatingsOrder, parseRatingsExclude } from '@/lib/constants'
export interface RenderSettings {
image_source: string
lang: string
textless: boolean
fanart_available: boolean
ratings_limit: number
ratings_order: string
ratings_exclude: string
is_default?: boolean
poster_position: string
logo_ratings_limit: number
backdrop_ratings_limit: number
poster_badge_style: string
logo_badge_style: string
backdrop_badge_style: string
poster_label_style: string
logo_label_style: string
backdrop_label_style: string
poster_badge_direction: string
poster_badge_split: boolean
poster_fit: string
poster_badge_size: string
logo_badge_size: string
backdrop_badge_size: string
backdrop_position: string
backdrop_badge_direction: string
backdrop_edge_inset_x: number
backdrop_edge_inset_y: number
episode_ratings_limit: number
episode_badge_style: string
episode_label_style: string
episode_badge_size: string
episode_position: string
episode_badge_direction: string
episode_blur: boolean
poster_badge_shape: string
logo_badge_shape: string
backdrop_badge_shape: string
episode_badge_shape: string
poster_badge_background: string
logo_badge_background: string
backdrop_badge_background: string
episode_badge_background: string
}
const props = defineProps<{
settings: RenderSettings
uid?: string
loadSettings: () => Promise<RenderSettings | null>
saveSettings: (s: SaveSettingsPayload) => Promise<string | null>
resetSettings?: () => Promise<boolean>
fetchPreview: (ratingsLimit: number, ratingsOrder: string, posterPosition?: string, badgeStyle?: string, labelStyle?: string, badgeDirection?: string, badgeSize?: string, ratingsExclude?: string, posterSplit?: boolean, badgeShape?: string, badgeBackground?: string, posterFit?: string, idType?: string, idValue?: string) => Promise<Response>
fetchLogoPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string, idType?: string, idValue?: string) => Promise<Response>
fetchBackdropPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string, edgeInsetX?: number, edgeInsetY?: number, idType?: string, idValue?: string) => Promise<Response>
fetchEpisodePreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string, idType?: string, idValue?: string) => Promise<Response>
}>()
const editFanart = ref(props.settings.image_source === 'f')
const editLang = ref(props.settings.lang || 'en')
const editTextless = ref(props.settings.textless)
const editSource = computed(() => editFanart.value ? 'f' : 't')
const editRatingsLimit = ref(props.settings.ratings_limit)
const editRatingsOrder = ref<string[]>(parseRatingsOrder(props.settings.ratings_order))
// Excluded sources are stored as only the explicitly-checked keys (unlike order,
// which is normalised to include every source).
const editRatingsExclude = ref<string[]>(parseRatingsExclude(props.settings.ratings_exclude))
const editPosterPosition = ref(props.settings.poster_position || 'bc')
const editLogoRatingsLimit = ref(props.settings.logo_ratings_limit ?? 3)
const editBackdropRatingsLimit = ref(props.settings.backdrop_ratings_limit ?? 3)
const editPosterBadgeStyle = ref(props.settings.poster_badge_style || 'd')
const editLogoBadgeStyle = ref(props.settings.logo_badge_style || 'v')
const editBackdropBadgeStyle = ref(props.settings.backdrop_badge_style || 'v')
const editPosterLabelStyle = ref(props.settings.poster_label_style || 'o')
const editLogoLabelStyle = ref(props.settings.logo_label_style || 'o')
const editBackdropLabelStyle = ref(props.settings.backdrop_label_style || 'o')
const editPosterBadgeDirection = ref(props.settings.poster_badge_direction || 'd')
const editPosterBadgeSplit = ref(props.settings.poster_badge_split ?? false)
const editPosterFit = ref(props.settings.poster_fit || 'native')
const editPosterBadgeSize = ref(props.settings.poster_badge_size || 'm')
const editLogoBadgeSize = ref(props.settings.logo_badge_size || 'm')
const editBackdropBadgeSize = ref(props.settings.backdrop_badge_size || 'm')
const editBackdropPosition = ref(props.settings.backdrop_position || 'tr')
const editBackdropBadgeDirection = ref(props.settings.backdrop_badge_direction || 'd')
const editBackdropEdgeInsetX = ref(props.settings.backdrop_edge_inset_x ?? 0)
const editBackdropEdgeInsetY = ref(props.settings.backdrop_edge_inset_y ?? 0)
const editEpisodeRatingsLimit = ref(props.settings.episode_ratings_limit ?? 1)
const editEpisodeBadgeStyle = ref(props.settings.episode_badge_style || 'v')
const editEpisodeLabelStyle = ref(props.settings.episode_label_style || 'o')
const editEpisodeBadgeSize = ref(props.settings.episode_badge_size || 'l')
const editEpisodePosition = ref(props.settings.episode_position || 'tr')
const editEpisodeBadgeDirection = ref(props.settings.episode_badge_direction || 'v')
const editEpisodeBlur = ref(props.settings.episode_blur ?? false)
const editPosterBadgeShape = ref(props.settings.poster_badge_shape || 'r')
const editLogoBadgeShape = ref(props.settings.logo_badge_shape || 'r')
const editBackdropBadgeShape = ref(props.settings.backdrop_badge_shape || 'r')
const editEpisodeBadgeShape = ref(props.settings.episode_badge_shape || 'r')
const editPosterBadgeBackground = ref(props.settings.poster_badge_background || 'd')
const editLogoBadgeBackground = ref(props.settings.logo_badge_background || 'd')
const editBackdropBadgeBackground = ref(props.settings.backdrop_badge_background || 'd')
const editEpisodeBadgeBackground = ref(props.settings.episode_badge_background || 'd')
// Which edges the current backdrop position anchors to. The horizontal inset
// only applies to left/right positions and the vertical inset only to top/bottom
// positions; centered axes hide their control (and the server ignores them).
const backdropIsTop = computed(() => ['tl', 'tc', 'tr'].includes(editBackdropPosition.value))
const backdropIsBottom = computed(() => ['bl', 'bc', 'br'].includes(editBackdropPosition.value))
const backdropIsLeft = computed(() => ['tl', 'bl', 'l'].includes(editBackdropPosition.value))
const backdropIsRight = computed(() => ['tr', 'br', 'r'].includes(editBackdropPosition.value))
const backdropShowVerticalInset = computed(() => backdropIsTop.value || backdropIsBottom.value)
const backdropShowHorizontalInset = computed(() => backdropIsLeft.value || backdropIsRight.value)
const backdropVerticalInsetLabel = computed(() => (backdropIsTop.value ? 'Space from top' : 'Space from bottom'))
const backdropHorizontalInsetLabel = computed(() => (backdropIsLeft.value ? 'Space from left' : 'Space from right'))
function applySettings(s: RenderSettings) {
editFanart.value = s.image_source === 'f'
editLang.value = s.lang || 'en'
editTextless.value = s.textless
editRatingsLimit.value = s.ratings_limit
editRatingsOrder.value = parseRatingsOrder(s.ratings_order)
editRatingsExclude.value = parseRatingsExclude(s.ratings_exclude)
editPosterPosition.value = s.poster_position || 'bc'
editLogoRatingsLimit.value = s.logo_ratings_limit ?? 3
editBackdropRatingsLimit.value = s.backdrop_ratings_limit ?? 3
editPosterBadgeStyle.value = s.poster_badge_style || 'd'
editLogoBadgeStyle.value = s.logo_badge_style || 'v'
editBackdropBadgeStyle.value = s.backdrop_badge_style || 'v'
editPosterLabelStyle.value = s.poster_label_style || 'o'
editLogoLabelStyle.value = s.logo_label_style || 'o'
editBackdropLabelStyle.value = s.backdrop_label_style || 'o'
editPosterBadgeDirection.value = s.poster_badge_direction || 'd'
editPosterBadgeSplit.value = s.poster_badge_split ?? false
editPosterFit.value = s.poster_fit || 'native'
editPosterBadgeSize.value = s.poster_badge_size || 'm'
editLogoBadgeSize.value = s.logo_badge_size || 'm'
editBackdropBadgeSize.value = s.backdrop_badge_size || 'm'
editBackdropPosition.value = s.backdrop_position || 'tr'
editBackdropBadgeDirection.value = s.backdrop_badge_direction || 'd'
editBackdropEdgeInsetX.value = s.backdrop_edge_inset_x ?? 0
editBackdropEdgeInsetY.value = s.backdrop_edge_inset_y ?? 0
editEpisodeRatingsLimit.value = s.episode_ratings_limit ?? 1
editEpisodeBadgeStyle.value = s.episode_badge_style || 'v'
editEpisodeLabelStyle.value = s.episode_label_style || 'o'
editEpisodeBadgeSize.value = s.episode_badge_size || 'l'
editEpisodePosition.value = s.episode_position || 'tr'
editEpisodeBadgeDirection.value = s.episode_badge_direction || 'v'
editEpisodeBlur.value = s.episode_blur ?? false
editPosterBadgeShape.value = s.poster_badge_shape || 'r'
editLogoBadgeShape.value = s.logo_badge_shape || 'r'
editBackdropBadgeShape.value = s.backdrop_badge_shape || 'r'
editEpisodeBadgeShape.value = s.episode_badge_shape || 'r'
editPosterBadgeBackground.value = s.poster_badge_background || 'd'
editLogoBadgeBackground.value = s.logo_badge_background || 'd'
editBackdropBadgeBackground.value = s.backdrop_badge_background || 'd'
editEpisodeBadgeBackground.value = s.episode_badge_background || 'd'
}
const currentSettings = ref<RenderSettings>(props.settings)
const saving = ref(false)
const error = ref('')
const showCheck = ref(false)
let checkTimeout: ReturnType<typeof setTimeout> | null = null
let syncing = false
watch(() => props.settings, (s) => {
syncing = true
currentSettings.value = s
applySettings(s)
nextTick(() => {
syncing = false
})
})
function revertEdits() {
syncing = true
applySettings(currentSettings.value)
nextTick(() => { syncing = false })
}
let pendingSave = false
// An emptied `v-model.number` input yields '' (and a partial entry can yield a
// non-integer); the backend types edge insets as a required i32, so send a clean,
// clamped integer to avoid a 400 mid-edit. Matches the server's 0–50 clamp.
function coerceInset(value: number): number {
return Number.isFinite(value) ? Math.min(50, Math.max(0, Math.round(value))) : 0
}
async function autoSave() {
if (saving.value) {
pendingSave = true
return
}
saving.value = true
error.value = ''
showCheck.value = false
if (checkTimeout) clearTimeout(checkTimeout)
try {
const err = await props.saveSettings({
image_source: editSource.value,
lang: editLang.value,
textless: editTextless.value,
ratings_limit: editRatingsLimit.value,
ratings_order: editRatingsOrder.value.join(','),
ratings_exclude: editRatingsExclude.value.join(','),
poster_position: editPosterPosition.value,
logo_ratings_limit: editLogoRatingsLimit.value,
backdrop_ratings_limit: editBackdropRatingsLimit.value,
poster_badge_style: editPosterBadgeStyle.value,
logo_badge_style: editLogoBadgeStyle.value,
backdrop_badge_style: editBackdropBadgeStyle.value,
poster_label_style: editPosterLabelStyle.value,
logo_label_style: editLogoLabelStyle.value,
backdrop_label_style: editBackdropLabelStyle.value,
poster_badge_direction: editPosterBadgeDirection.value,
poster_badge_split: editPosterBadgeSplit.value,
poster_fit: editPosterFit.value,
poster_badge_size: editPosterBadgeSize.value,
logo_badge_size: editLogoBadgeSize.value,
backdrop_badge_size: editBackdropBadgeSize.value,
backdrop_position: editBackdropPosition.value,
backdrop_badge_direction: editBackdropBadgeDirection.value,
backdrop_edge_inset_x: coerceInset(editBackdropEdgeInsetX.value),
backdrop_edge_inset_y: coerceInset(editBackdropEdgeInsetY.value),
episode_ratings_limit: editEpisodeRatingsLimit.value,
episode_badge_style: editEpisodeBadgeStyle.value,
episode_label_style: editEpisodeLabelStyle.value,
episode_badge_size: editEpisodeBadgeSize.value,
episode_position: editEpisodePosition.value,
episode_badge_direction: editEpisodeBadgeDirection.value,
episode_blur: editEpisodeBlur.value,
poster_badge_shape: editPosterBadgeShape.value,
logo_badge_shape: editLogoBadgeShape.value,
backdrop_badge_shape: editBackdropBadgeShape.value,
episode_badge_shape: editEpisodeBadgeShape.value,
poster_badge_background: editPosterBadgeBackground.value,
logo_badge_background: editLogoBadgeBackground.value,
backdrop_badge_background: editBackdropBadgeBackground.value,
episode_badge_background: editEpisodeBadgeBackground.value,
})
if (err) {
error.value = err
revertEdits()
} else {
const updated = await props.loadSettings()
if (updated) {
currentSettings.value = updated
}
showCheck.value = true
checkTimeout = setTimeout(() => (showCheck.value = false), 1500)
}
} catch {
error.value = 'Failed to save'
revertEdits()
} finally {
saving.value = false
if (pendingSave) {
pendingSave = false
autoSave()
}
}
}
// Auto-save on any setting change
watch(
[editSource, editLang, editTextless, editRatingsLimit, editRatingsOrder, editRatingsExclude, editPosterPosition, editLogoRatingsLimit, editBackdropRatingsLimit, editPosterBadgeStyle, editLogoBadgeStyle, editBackdropBadgeStyle, editPosterLabelStyle, editLogoLabelStyle, editBackdropLabelStyle, editPosterBadgeDirection, editPosterBadgeSplit, editPosterFit, editPosterBadgeSize, editLogoBadgeSize, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection, editBackdropEdgeInsetX, editBackdropEdgeInsetY, editEpisodeRatingsLimit, editEpisodeBadgeStyle, editEpisodeLabelStyle, editEpisodeBadgeSize, editEpisodePosition, editEpisodeBadgeDirection, editEpisodeBlur, editPosterBadgeShape, editLogoBadgeShape, editBackdropBadgeShape, editEpisodeBadgeShape, editPosterBadgeBackground, editLogoBadgeBackground, editBackdropBadgeBackground, editEpisodeBadgeBackground],
() => {
if (syncing) return
autoSave()
},
{ deep: true },
)
async function handleReset() {
if (!props.resetSettings) return
saving.value = true
error.value = ''
showCheck.value = false
if (checkTimeout) clearTimeout(checkTimeout)
try {
const ok = await props.resetSettings()
if (ok) {
await props.loadSettings()
showCheck.value = true
checkTimeout = setTimeout(() => (showCheck.value = false), 1500)
} else {
error.value = 'Failed to reset'
}
} catch {
error.value = 'Failed to reset'
} finally {
saving.value = false
}
}
// --- Preview state for poster, logo, backdrop ---
interface PreviewState {
src: string
loading: boolean
error: boolean
size: { w: number; h: number } | null
generation: number
}
function makePreviewState(): PreviewState {
return { src: '', loading: false, error: false, size: null, generation: 0 }
}
const posterPreview = ref<PreviewState>(makePreviewState())
const logoPreview = ref<PreviewState>(makePreviewState())
const backdropPreview = ref<PreviewState>(makePreviewState())
const episodePreview = ref<PreviewState>(makePreviewState())
const previewIdType = ref(localStorage.getItem('opdb_preview_id_type') || 'imdb')
const previewIdValue = ref(localStorage.getItem('opdb_preview_id_value') || '')
watch(previewIdType, (v) => localStorage.setItem('opdb_preview_id_type', v))
watch(previewIdValue, (v) => localStorage.setItem('opdb_preview_id_value', v))
function onPreviewLoad(state: PreviewState, e: Event) {
const img = e.target as HTMLImageElement
if (img.naturalWidth && img.naturalHeight) {
state.size = { w: img.naturalWidth, h: img.naturalHeight }
}
state.loading = false
state.error = false
}
async function fetchPreviewImage(
state: PreviewState,
fetcher: (ratingsLimit: number, ratingsOrder: string) => Promise<Response>,
) {
state.loading = true
state.error = false
const generation = ++state.generation
try {
const res = await fetcher(editRatingsLimit.value, editRatingsOrder.value.join(','))
if (generation !== state.generation) return
if (!res.ok) {
state.error = true
state.loading = false
return
}
const blob = await res.blob()
if (generation !== state.generation) return
if (state.src) URL.revokeObjectURL(state.src)
state.src = URL.createObjectURL(blob)
} catch {
if (generation === state.generation) {
state.error = true
state.loading = false
}
}
}
let posterPreviewTimer: ReturnType<typeof setTimeout> | null = null
let logoPreviewTimer: ReturnType<typeof setTimeout> | null = null
let backdropPreviewTimer: ReturnType<typeof setTimeout> | null = null
let episodePreviewTimer: ReturnType<typeof setTimeout> | null = null
function updatePosterPreview() {
fetchPreviewImage(posterPreview.value, (_limit, order) => props.fetchPreview(editRatingsLimit.value, order, editPosterPosition.value, editPosterBadgeStyle.value, editPosterLabelStyle.value, editPosterBadgeDirection.value, editPosterBadgeSize.value, editRatingsExclude.value.join(','), editPosterBadgeSplit.value, editPosterBadgeShape.value, editPosterBadgeBackground.value, editPosterFit.value, previewIdValue.value ? previewIdType.value : undefined, previewIdValue.value || undefined))
}
function updateLogoPreview() {
if (props.fetchLogoPreview) {
fetchPreviewImage(logoPreview.value, (_limit, order) => props.fetchLogoPreview!(editLogoRatingsLimit.value, order, editLogoBadgeStyle.value, editLogoLabelStyle.value, editLogoBadgeSize.value, editRatingsExclude.value.join(','), editLogoBadgeShape.value, editLogoBadgeBackground.value, previewIdValue.value ? previewIdType.value : undefined, previewIdValue.value || undefined))
}
}
function updateBackdropPreview() {
if (props.fetchBackdropPreview) {
fetchPreviewImage(backdropPreview.value, (_limit, order) => props.fetchBackdropPreview!(editBackdropRatingsLimit.value, order, editBackdropBadgeStyle.value, editBackdropLabelStyle.value, editBackdropBadgeSize.value, editBackdropPosition.value, editBackdropBadgeDirection.value, editRatingsExclude.value.join(','), editBackdropBadgeShape.value, editBackdropBadgeBackground.value, editBackdropEdgeInsetX.value, editBackdropEdgeInsetY.value, previewIdValue.value ? previewIdType.value : undefined, previewIdValue.value || undefined))
}
}
function updateEpisodePreview() {
if (props.fetchEpisodePreview) {
fetchPreviewImage(episodePreview.value, (_limit, order) => props.fetchEpisodePreview!(editEpisodeRatingsLimit.value, order, editEpisodeBadgeStyle.value, editEpisodeLabelStyle.value, editEpisodeBadgeSize.value, editEpisodePosition.value, editEpisodeBadgeDirection.value, editEpisodeBlur.value, editRatingsExclude.value.join(','), editEpisodeBadgeShape.value, editEpisodeBadgeBackground.value, previewIdValue.value ? previewIdType.value : undefined, previewIdValue.value || undefined))
}
}
function updateAllPreviews() {
updatePosterPreview()
updateLogoPreview()
updateBackdropPreview()
updateEpisodePreview()
}
// Global settings: refresh all previews (order and exclude affect every type)
watch([editRatingsOrder, editRatingsExclude, previewIdType, previewIdValue], () => {
if (syncing) return
if (posterPreviewTimer) clearTimeout(posterPreviewTimer)
if (logoPreviewTimer) clearTimeout(logoPreviewTimer)
if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
if (episodePreviewTimer) clearTimeout(episodePreviewTimer)
posterPreviewTimer = setTimeout(updatePosterPreview, 500)
logoPreviewTimer = setTimeout(updateLogoPreview, 500)
backdropPreviewTimer = setTimeout(updateBackdropPreview, 500)
episodePreviewTimer = setTimeout(updateEpisodePreview, 500)
}, { deep: true })
// Poster-only settings
watch([editRatingsLimit, editPosterPosition, editPosterBadgeStyle, editPosterLabelStyle, editPosterBadgeDirection, editPosterBadgeSplit, editPosterFit, editPosterBadgeSize, editPosterBadgeShape, editPosterBadgeBackground], () => {
if (syncing) return
if (posterPreviewTimer) clearTimeout(posterPreviewTimer)
posterPreviewTimer = setTimeout(updatePosterPreview, 500)
})
// Logo-only settings
watch([editLogoRatingsLimit, editLogoBadgeStyle, editLogoLabelStyle, editLogoBadgeSize, editLogoBadgeShape, editLogoBadgeBackground], () => {
if (syncing) return
if (logoPreviewTimer) clearTimeout(logoPreviewTimer)
logoPreviewTimer = setTimeout(updateLogoPreview, 500)
})
// Backdrop-only settings
watch([editBackdropRatingsLimit, editBackdropBadgeStyle, editBackdropLabelStyle, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection, editBackdropEdgeInsetX, editBackdropEdgeInsetY, editBackdropBadgeShape, editBackdropBadgeBackground], () => {
if (syncing) return
if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
backdropPreviewTimer = setTimeout(updateBackdropPreview, 500)
})
// Episode-only settings
watch([editEpisodeRatingsLimit, editEpisodeBadgeStyle, editEpisodeLabelStyle, editEpisodeBadgeSize, editEpisodePosition, editEpisodeBadgeDirection, editEpisodeBlur, editEpisodeBadgeShape, editEpisodeBadgeBackground], () => {
if (syncing) return
if (episodePreviewTimer) clearTimeout(episodePreviewTimer)
episodePreviewTimer = setTimeout(updateEpisodePreview, 500)
})
// Initial preview on mount
updateAllPreviews()
onBeforeUnmount(() => {
if (posterPreviewTimer) clearTimeout(posterPreviewTimer)
if (logoPreviewTimer) clearTimeout(logoPreviewTimer)
if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
if (episodePreviewTimer) clearTimeout(episodePreviewTimer)
if (posterPreview.value.src) URL.revokeObjectURL(posterPreview.value.src)
if (logoPreview.value.src) URL.revokeObjectURL(logoPreview.value.src)
if (backdropPreview.value.src) URL.revokeObjectURL(backdropPreview.value.src)
if (episodePreview.value.src) URL.revokeObjectURL(episodePreview.value.src)
})
const inputId = (name: string) => props.uid ? `${name}-${props.uid}` : name
function isExcluded(key: string): boolean {
return editRatingsExclude.value.includes(key)
}
function toggleExclude(key: string, checked: boolean) {
const set = new Set(editRatingsExclude.value)
if (checked) set.add(key)
else set.delete(key)
// Keep canonical source order so the saved value is stable regardless of click order.
editRatingsExclude.value = ALL_RATING_SOURCES.map(s => s.key).filter(k => set.has(k))
}
</script>
<template>
<TooltipProvider>
<!-- Row 1: Rating order | Preview title -->
<div class="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6 items-center mb-4">
<Label class="gap-0.5 font-[500]">Rating order<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>Use the arrows to reorder. Higher items have priority.</TooltipContent></Tooltip></Label>
<p class="text-sm font-[500]">Preview with real content</p>
</div>
<!-- Row 3: Language+selector | ID fields -->
<div class="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6 items-center">
<div class="flex items-center gap-3">
<Label :for="inputId('lang')" class="gap-0.5">Language<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>Best effort — falls back to English if unavailable.</TooltipContent></Tooltip></Label>
<Select
:model-value="editLang"
@update:model-value="editLang = $event as string"
>
<SelectTrigger :id="inputId('lang')" class="max-w-[200px]" data-testid="lang-select">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="lang in LANGUAGES" :key="lang.code" :value="lang.code">
{{ lang.code }} - {{ lang.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex flex-wrap items-center gap-4 max-w-md">
<div class="flex items-center gap-2">
<Label :for="inputId('preview-id-type')">ID type</Label>
<Select v-model="previewIdType">
<SelectTrigger :id="inputId('preview-id-type')" class="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="imdb">IMDb</SelectItem>
<SelectItem value="tmdb">TMDB</SelectItem>
<SelectItem value="tvdb">TVDB</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center gap-2 flex-1">
<Label :for="inputId('preview-id-value')" class="gap-0.5">ID<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>Leave blank to preview with the sample placeholder image below.</TooltipContent></Tooltip></Label>
<Input :id="inputId('preview-id-value')" v-model="previewIdValue" placeholder="e.g. tt0013442" />
</div>
<template v-if="currentSettings.fanart_available">
<div class="flex items-center gap-2">
<Checkbox
:id="inputId('fanart')"
:model-value="editFanart"
data-testid="fanart-checkbox"
@update:model-value="(v) => editFanart = !!v"
/>
<Label :for="inputId('fanart')">Prefer Fanart.tv as image source</Label>
</div>
</template>
</div>
</div>
</TooltipProvider>
<div class="grid grid-cols-1 lg:grid-cols-[300px_1fr] gap-6 items-start mt-4">
<div class="space-y-4">
<div class="space-y-2">
<div class="space-y-2">
<RatingsOrderList v-model="editRatingsOrder" />
</div>
<div class="space-y-3 pt-4">
<TooltipProvider>
<Label class="gap-0.5">Exclude ratings<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>Hide specific rating sources entirely. An excluded source never appears, freeing its slot for the next preferred source.</TooltipContent></Tooltip></Label>
</TooltipProvider>
<div class="grid grid-cols-1 gap-x-4 gap-y-1.5 max-w-sm">
<div
v-for="source in ALL_RATING_SOURCES"
:key="source.key"
class="flex items-center gap-2"
>
<Checkbox
:id="inputId(`exclude-${source.key}`)"
:model-value="isExcluded(source.key)"
:data-testid="`exclude-${source.key}-checkbox`"
@update:model-value="(v) => toggleExclude(source.key, !!v)"
/>
<span
class="inline-block w-2.5 h-2.5 rounded-full shrink-0"
:style="{ backgroundColor: source.color }"
></span>
<Label :for="inputId(`exclude-${source.key}`)" class="text-sm font-normal">{{ source.label }}</Label>
</div>
</div>
</div>
</div>
<div class="flex items-center gap-3 pt-1 min-h-[32px]">
<Button
v-if="resetSettings && !currentSettings.is_default"
variant="outline"
size="sm"
:disabled="saving"
@click="handleReset"
>
Reset to defaults
</Button>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span v-if="saving" class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Loader2 class="size-4 animate-spin" />
Saving...
</span>
<span v-else-if="showCheck" class="flex items-center gap-1.5 text-sm text-green-500">
<Check class="size-4" />
Saved
</span>
</Transition>
<span v-if="error" class="text-sm text-destructive">{{ error }}</span>
</div>
</div>
<div class="space-y-4">
<div class="grid grid-cols-1 xl:grid-cols-[auto_auto] gap-4 items-stretch justify-start">
<!-- Section 2: Poster -->
<div class="rounded-md border p-4 space-y-3">
<p class="text-sm font-[350] rounded-md border bg-muted/30 px-3 py-1 w-fit">Poster</p>
<TooltipProvider>
<div class="flex gap-4 items-stretch">
<div class="flex flex-col w-[170px]">
<div class="relative w-[170px] rounded preview-box-border" :style="posterPreview.size ? { aspectRatio: `${posterPreview.size.w} / ${posterPreview.size.h}` } : undefined">
<img
v-show="posterPreview.src && !posterPreview.error"
:src="posterPreview.src"
alt="Poster preview"
class="rounded border w-full"
@load="(e: Event) => onPreviewLoad(posterPreview, e)"
@error="posterPreview.loading = false; posterPreview.error = true"
/>
<p v-if="posterPreview.error && !posterPreview.loading" class="text-sm text-muted-foreground py-4">Failed</p>
<div v-if="posterPreview.loading" class="absolute inset-0 flex items-center justify-center rounded">
<Loader2 class="size-5 animate-spin text-white drop-shadow-md" />
</div>
</div>
<div class="flex flex-col gap-3 mt-auto rounded-md border bg-muted/30 p-3">
<div class="flex items-center gap-2">
<Checkbox
:id="inputId('textless')"
:model-value="editTextless"
data-testid="textless-checkbox"
class="border-neutral-400"
@update:model-value="(v) => editTextless = !!v"
/>
<Label :for="inputId('textless')" class="text-xs">Prefer textless posters</Label>
</div>
<div class="space-y-1">
<div class="flex items-center gap-2">
<Checkbox
:id="inputId('poster-badge-split')"
:model-value="editPosterBadgeSplit"
data-testid="poster-badge-split-checkbox"
class="border-neutral-400"
@update:model-value="(v) => editPosterBadgeSplit = !!v"
/>
<Label :for="inputId('poster-badge-split')" class="inline text-xs"><span>Split badges onto opposite sides<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>Splits badges across two opposite sides — left/right for a vertical layout, top/bottom for horizontal rows.</TooltipContent></Tooltip></span></Label>
</div>
</div>
</div>
</div>
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger>Badge position</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterPositionOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editPosterPosition === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterPosition = opt.value">{{ opt.label }} <span v-if="editPosterPosition === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge direction</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterBadgeDirectionOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editPosterBadgeDirection === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterBadgeDirection = opt.value">{{ opt.label }} <span v-if="editPosterBadgeDirection === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterBadgeStyleOptions" :key="opt.value" type="button" class="text-left text-xs disabled:opacity-50 disabled:cursor-not-allowed" :disabled="editPosterBadgeShape === 'p'" :class="editPosterBadgeStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterBadgeStyle = opt.value">{{ opt.label }} <span v-if="editPosterBadgeStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Label style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterLabelStyleOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editPosterLabelStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterLabelStyle = opt.value">{{ opt.label }} <span v-if="editPosterLabelStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge size</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterBadgeSizeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editPosterBadgeSize === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterBadgeSize = opt.value">{{ opt.label }} <span v-if="editPosterBadgeSize === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge shape</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterBadgeShapeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editPosterBadgeShape === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterBadgeShape = opt.value">{{ opt.label }} <span v-if="editPosterBadgeShape === opt.value">★</span><Tooltip v-if="opt.value === 'p'"><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help ml-0.5">?</span></TooltipTrigger><TooltipContent>Pills always render horizontally.</TooltipContent></Tooltip></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge background</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterBadgeBackgroundOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editPosterBadgeBackground === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterBadgeBackground = opt.value">{{ opt.label }} <span v-if="editPosterBadgeBackground === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger><span class="inline-flex items-center gap-0.5">Aspect ratio<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>How non-2:3 posters are fit to the standard 2:3 frame so clients don't crop them.</TooltipContent></Tooltip></span></NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testPosterFitOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editPosterFit === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editPosterFit = opt.value">{{ opt.label }} <span v-if="editPosterFit === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger><span class="inline-flex items-center gap-0.5">Max ratings<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>0 = no ratings</TooltipContent></Tooltip></span></NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex items-center gap-3 w-64">
<Input
:id="inputId('ratings-limit')"
v-model.number="editRatingsLimit"
type="number"
:min="0"
:max="10"
class="w-[80px] text-xs"
title="0 = no ratings"
/>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</TooltipProvider>
</div>
<!-- Section 3: Backdrop -->
<div v-if="fetchBackdropPreview" class="rounded-md border p-4 space-y-3">
<p class="text-sm font-[350] rounded-md border bg-muted/30 px-3 py-1 w-fit">Backdrop (series/movie)</p>
<TooltipProvider>
<div class="space-y-3">
<div class="w-[280px]">
<div class="relative w-[280px] rounded preview-box-border" :style="backdropPreview.size ? { aspectRatio: `${backdropPreview.size.w} / ${backdropPreview.size.h}` } : undefined">
<img
v-show="backdropPreview.src && !backdropPreview.error"
:src="backdropPreview.src"
alt="Backdrop preview"
class="rounded border w-full"
@load="(e: Event) => onPreviewLoad(backdropPreview, e)"
@error="backdropPreview.loading = false; backdropPreview.error = true"
/>
<p v-if="backdropPreview.error && !backdropPreview.loading" class="text-sm text-muted-foreground py-4">Failed</p>
<div v-if="backdropPreview.loading" class="absolute inset-0 flex items-center justify-center rounded">
<Loader2 class="size-5 animate-spin text-white drop-shadow-md" />
</div>
</div>
</div>
<div class="flex gap-1">
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger>Label style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testBackdropLabelStyleOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editBackdropLabelStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editBackdropLabelStyle = opt.value">{{ opt.label }} <span v-if="editBackdropLabelStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge shape</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testBackdropBadgeShapeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editBackdropBadgeShape === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editBackdropBadgeShape = opt.value">{{ opt.label }} <span v-if="editBackdropBadgeShape === opt.value">★</span><Tooltip v-if="opt.value === 'p'"><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help ml-0.5">?</span></TooltipTrigger><TooltipContent>Pills always render horizontally.</TooltipContent></Tooltip></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Position</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testBackdropPositionOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editBackdropPosition === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editBackdropPosition = opt.value">{{ opt.label }} <span v-if="editBackdropPosition === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge direction</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testBackdropBadgeDirectionOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editBackdropBadgeDirection === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editBackdropBadgeDirection = opt.value">{{ opt.label }} <span v-if="editBackdropBadgeDirection === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge size</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testBackdropBadgeSizeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editBackdropBadgeSize === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editBackdropBadgeSize = opt.value">{{ opt.label }} <span v-if="editBackdropBadgeSize === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger>Badge background</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testBackdropBadgeBackgroundOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editBackdropBadgeBackground === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editBackdropBadgeBackground = opt.value">{{ opt.label }} <span v-if="editBackdropBadgeBackground === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testBackdropBadgeStyleOptions" :key="opt.value" type="button" class="text-left text-xs disabled:opacity-50 disabled:cursor-not-allowed" :disabled="editBackdropBadgeShape === 'p'" :class="editBackdropBadgeStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editBackdropBadgeStyle = opt.value">{{ opt.label }} <span v-if="editBackdropBadgeStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Distance from edge</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="w-56">
<div v-if="backdropShowVerticalInset || backdropShowHorizontalInset" class="flex flex-wrap gap-4">
<div v-if="backdropShowVerticalInset" class="space-y-1">
<Label :for="inputId('backdrop-edge-inset-y')" class="text-xs font-normal text-muted-foreground">{{ backdropVerticalInsetLabel }}</Label>
<div class="flex items-center gap-1">
<Input
:id="inputId('backdrop-edge-inset-y')"
v-model.number="editBackdropEdgeInsetY"
type="number"
:min="0"
:max="50"
class="w-[80px] text-xs"
data-testid="backdrop-edge-inset-y"
/>
<span class="text-xs text-muted-foreground">%</span>
</div>
</div>
<div v-if="backdropShowHorizontalInset" class="space-y-1">
<Label :for="inputId('backdrop-edge-inset-x')" class="text-xs font-normal text-muted-foreground">{{ backdropHorizontalInsetLabel }}</Label>
<div class="flex items-center gap-1">
<Input
:id="inputId('backdrop-edge-inset-x')"
v-model.number="editBackdropEdgeInsetX"
type="number"
:min="0"
:max="50"
class="w-[80px] text-xs"
data-testid="backdrop-edge-inset-x"
/>
<span class="text-xs text-muted-foreground">%</span>
</div>
</div>
</div>
<p v-else class="text-xs text-muted-foreground">Not applicable for the current position.</p>
<p class="text-xs text-muted-foreground pt-1">Inset ratings from the image edge (% of size) — useful when a player crops the backdrop.</p>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger><span class="inline-flex items-center gap-0.5">Max ratings<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>0 = no ratings</TooltipContent></Tooltip></span></NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex items-center gap-3 w-64">
<Input
:id="inputId('backdrop-ratings-limit')"
v-model.number="editBackdropRatingsLimit"
type="number"
:min="0"
:max="10"
class="w-[80px] text-xs"
title="0 = no ratings"
/>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
</TooltipProvider>
</div>
<!-- Section 4: Logo -->
<div v-if="fetchLogoPreview" class="rounded-md border p-4 space-y-3">
<p class="text-sm font-[350] rounded-md border bg-muted/30 px-3 py-1 w-fit">Logo</p>
<TooltipProvider>
<div class="w-[280px]">
<div class="relative w-[280px] rounded preview-box-border bg-neutral-900 p-3" style="aspect-ratio: 1920 / 1081">
<img
v-show="logoPreview.src && !logoPreview.error"
:src="logoPreview.src"
alt="Logo preview"
class="w-full h-full object-contain"
@load="(e: Event) => onPreviewLoad(logoPreview, e)"
@error="logoPreview.loading = false; logoPreview.error = true"
/>
<p v-if="logoPreview.error && !logoPreview.loading" class="text-sm text-muted-foreground py-4">Failed</p>
<div v-if="logoPreview.loading" class="absolute inset-0 flex items-center justify-center rounded">
<Loader2 class="size-5 animate-spin text-white drop-shadow-md" />
</div>
</div>
</div>
<div class="flex gap-1">
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger>Badge style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testLogoBadgeStyleOptions" :key="opt.value" type="button" class="text-left text-xs disabled:opacity-50 disabled:cursor-not-allowed" :disabled="editLogoBadgeShape === 'p'" :class="editLogoBadgeStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editLogoBadgeStyle = opt.value">{{ opt.label }} <span v-if="editLogoBadgeStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge shape</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testLogoBadgeShapeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editLogoBadgeShape === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editLogoBadgeShape = opt.value">{{ opt.label }} <span v-if="editLogoBadgeShape === opt.value">★</span><Tooltip v-if="opt.value === 'p'"><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help ml-0.5">?</span></TooltipTrigger><TooltipContent>Pills always render horizontally.</TooltipContent></Tooltip></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge size</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testLogoBadgeSizeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editLogoBadgeSize === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editLogoBadgeSize = opt.value">{{ opt.label }} <span v-if="editLogoBadgeSize === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger>Badge background</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testLogoBadgeBackgroundOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editLogoBadgeBackground === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editLogoBadgeBackground = opt.value">{{ opt.label }} <span v-if="editLogoBadgeBackground === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Label style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testLogoLabelStyleOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editLogoLabelStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editLogoLabelStyle = opt.value">{{ opt.label }} <span v-if="editLogoLabelStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger><span class="inline-flex items-center gap-0.5">Max ratings<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>0 = no ratings</TooltipContent></Tooltip></span></NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex items-center gap-3 w-64">
<Input
:id="inputId('logo-ratings-limit')"
v-model.number="editLogoRatingsLimit"
type="number"
:min="0"
:max="10"
class="w-[80px] text-xs"
title="0 = no ratings"
/>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
</TooltipProvider>
</div>
<!-- Section 5: Episode -->
<div v-if="fetchEpisodePreview" class="rounded-md border p-4 space-y-3">
<p class="text-sm font-[350] rounded-md border bg-muted/30 px-3 py-1 w-fit">Episode</p>
<TooltipProvider>
<div class="w-[280px]">
<div class="relative w-[280px] rounded preview-box-border" :style="episodePreview.size ? { aspectRatio: `${episodePreview.size.w} / ${episodePreview.size.h}` } : undefined">
<img
v-show="episodePreview.src && !episodePreview.error"
:src="episodePreview.src"
alt="Episode preview"
class="rounded border w-full"
@load="(e: Event) => onPreviewLoad(episodePreview, e)"
@error="episodePreview.loading = false; episodePreview.error = true"
/>
<p v-if="episodePreview.error && !episodePreview.loading" class="text-sm text-muted-foreground py-4">Failed</p>
<div v-if="episodePreview.loading" class="absolute inset-0 flex items-center justify-center rounded">
<Loader2 class="size-5 animate-spin text-white drop-shadow-md" />
</div>
</div>
</div>
<div class="flex gap-1">
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold text-muted-foreground px-1">Appearance</p>
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger>Badge style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testEpisodeBadgeStyleOptions" :key="opt.value" type="button" class="text-left text-xs disabled:opacity-50 disabled:cursor-not-allowed" :disabled="editEpisodeBadgeShape === 'p'" :class="editEpisodeBadgeStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editEpisodeBadgeStyle = opt.value">{{ opt.label }} <span v-if="editEpisodeBadgeStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Label style</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testEpisodeLabelStyleOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editEpisodeLabelStyle === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editEpisodeLabelStyle = opt.value">{{ opt.label }} <span v-if="editEpisodeLabelStyle === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge size</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testEpisodeBadgeSizeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editEpisodeBadgeSize === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editEpisodeBadgeSize = opt.value">{{ opt.label }} <span v-if="editEpisodeBadgeSize === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge shape</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testEpisodeBadgeShapeOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editEpisodeBadgeShape === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editEpisodeBadgeShape = opt.value">{{ opt.label }} <span v-if="editEpisodeBadgeShape === opt.value">★</span><Tooltip v-if="opt.value === 'p'"><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help ml-0.5">?</span></TooltipTrigger><TooltipContent>Pills always render horizontally.</TooltipContent></Tooltip></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Badge background</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testEpisodeBadgeBackgroundOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editEpisodeBadgeBackground === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editEpisodeBadgeBackground = opt.value">{{ opt.label }} <span v-if="editEpisodeBadgeBackground === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div class="flex flex-col gap-1">
<p class="text-xs font-semibold text-muted-foreground px-1">Layout</p>
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger>Position</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testEpisodePositionOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editEpisodePosition === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editEpisodePosition = opt.value">{{ opt.label }} <span v-if="editEpisodePosition === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>Direction</NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex flex-col gap-0.5 w-56">
<button v-for="opt in testEpisodeBadgeDirectionOptions" :key="opt.value" type="button" class="text-left text-xs" :class="editEpisodeBadgeDirection === opt.value ? 'font-bold' : 'font-[300] text-muted-foreground'" @click="editEpisodeBadgeDirection = opt.value">{{ opt.label }} <span v-if="editEpisodeBadgeDirection === opt.value">★</span></button>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<p class="text-xs font-semibold text-muted-foreground px-1 pt-1">Behavior</p>
<NavigationMenu orientation="vertical" :viewport="false" class="items-start justify-start max-w-none flex-none">
<NavigationMenuList class="flex-none">
<NavigationMenuItem>
<NavigationMenuTrigger><span class="inline-flex items-center gap-0.5">Max ratings<Tooltip><TooltipTrigger as-child><span class="text-[11px] text-muted-foreground dark:text-white font-medium inline-block -translate-y-1 cursor-help">?</span></TooltipTrigger><TooltipContent>0 = no ratings</TooltipContent></Tooltip></span></NavigationMenuTrigger>
<NavigationMenuContent class="!left-full !top-0 !ml-2 !mt-0 z-[100]">
<div class="flex items-center gap-3 w-64">
<Input
:id="inputId('episode-ratings-limit')"
v-model.number="editEpisodeRatingsLimit"
type="number"
:min="0"
:max="10"
class="w-[80px] text-xs"
title="0 = no ratings"
/>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
<div class="flex items-center gap-2 px-1 pt-1">
<Checkbox
:id="inputId('episode-blur')"
:model-value="editEpisodeBlur"
data-testid="episode-blur-checkbox"
class="border-neutral-400"
@update:model-value="(v) => editEpisodeBlur = !!v"
/>
<Label :for="inputId('episode-blur')" class="text-xs">Blur (spoiler protection)</Label>
</div>
</div>
</div>
</TooltipProvider>
</div>
<!-- Test Section: Poster (Tabs UI) -->
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuList, NavigationMenuTrigger } from '@/components/ui/navigation-menu'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
const testPosterPositionOptions = [
{ value: 'bc', label: 'Bottom Center' },
{ value: 'tc', label: 'Top Center' },
{ value: 'l', label: 'Left' },
{ value: 'r', label: 'Right' },
{ value: 'tl', label: 'Top Left' },
{ value: 'tr', label: 'Top Right' },
{ value: 'bl', label: 'Bottom Left' },
{ value: 'br', label: 'Bottom Right' },
]
const testPosterBadgeDirectionOptions = [
{ value: 'd', label: 'Default' },
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testPosterBadgeStyleOptions = [
{ value: 'd', label: 'Default' },
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testPosterLabelStyleOptions = [
{ value: 't', label: 'Text' },
{ value: 'i', label: 'Icon' },
{ value: 'o', label: 'Official' },
]
const testPosterBadgeSizeOptions = [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' },
]
const testPosterBadgeShapeOptions = [
{ value: 'r', label: 'Rounded' },
{ value: 'p', label: 'Pill' },
]
const testLogoBadgeStyleOptions = [
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testLogoBadgeShapeOptions = [
{ value: 'r', label: 'Rounded' },
{ value: 'p', label: 'Pill' },
]
const testLogoBadgeSizeOptions = [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' },
]
const testLogoBadgeBackgroundOptions = [
{ value: 'd', label: 'Default' },
{ value: 'k', label: 'Dark' },
{ value: 't', label: 'Transparent' },
{ value: 'n', label: 'None' },
]
const testLogoLabelStyleOptions = [
{ value: 't', label: 'Text' },
{ value: 'i', label: 'Icon' },
{ value: 'o', label: 'Official' },
]
const testEpisodePositionOptions = [
{ value: 'bc', label: 'Bottom Center' },
{ value: 'tc', label: 'Top Center' },
{ value: 'l', label: 'Left' },
{ value: 'r', label: 'Right' },
{ value: 'tl', label: 'Top Left' },
{ value: 'tr', label: 'Top Right' },
{ value: 'bl', label: 'Bottom Left' },
{ value: 'br', label: 'Bottom Right' },
]
const testEpisodeBadgeDirectionOptions = [
{ value: 'd', label: 'Auto' },
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testEpisodeBadgeStyleOptions = [
{ value: 'h', label: 'Horizontal' },
{ value: 'v', label: 'Vertical' },
]
const testEpisodeLabelStyleOptions = [
{ value: 't', label: 'Text' },
{ value: 'i', label: 'Icon' },
{ value: 'o', label: 'Official' },
]
const testEpisodeBadgeSizeOptions = [
{ value: 'xs', label: 'Extra Small' },
{ value: 's', label: 'Small' },
{ value: 'm', label: 'Medium' },
{ value: 'l', label: 'Large' },
{ value: 'xl', label: 'Extra Large' },
]
const testEpisodeBadgeShapeOptions = [