a / web /src /components /RenderSettingsForm.vue
FTA7700
Fix rating order hover border to brighter (not transparent); add card-flat-matching border to all 4 preview boxes
4ae30f0
Raw History Blame Contribute Delete
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 = [