Spaces:
Paused
Paused
Download web/src/components/FreeApiKeyCard.vue from stremio-db/a: direct link, hf CLI and curl.
- Browser
- Download file 30.8 kB
-
https://huggingface.co/spaces/stremio-db/a/resolve/main/web/src/components/FreeApiKeyCard.vue
- Command line
-
hf download hf://spaces/stremio-db/a/web/src/components/FreeApiKeyCard.vue
-
curl -L -o FreeApiKeyCard.vue https://huggingface.co/spaces/stremio-db/a/resolve/main/web/src/components/FreeApiKeyCard.vue
30.8 kB
| <script setup lang="ts"> | |
| import { ref, computed, watch, onMounted, onUnmounted } from 'vue' | |
| import { useAuthStore } from '@/stores/auth' | |
| import { | |
| FREE_API_KEY, | |
| LANGUAGES, | |
| ALL_RATING_SOURCES, | |
| DEFAULT_RATINGS_ORDER, | |
| parseRatingsOrder, | |
| parseRatingsExclude, | |
| BADGE_STYLE_LABELS, | |
| BADGE_DIRECTION_LABELS, | |
| LABEL_STYLE_LABELS, | |
| BADGE_SIZE_LABELS, | |
| BADGE_SHAPE_LABELS, | |
| BADGE_BACKGROUND_LABELS, | |
| IMAGE_SOURCE_LABELS, | |
| POSITION_LABELS, | |
| POSTER_FIT_LABELS, | |
| } from '@/lib/constants' | |
| import RatingsOrderList from '@/components/RatingsOrderList.vue' | |
| import { Button } from '@/components/ui/button' | |
| import { Checkbox } from '@/components/ui/checkbox' | |
| import { Label } from '@/components/ui/label' | |
| import { Input } from '@/components/ui/input' | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from '@/components/ui/select' | |
| import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' | |
| import { ChevronRight, Loader2 } from 'lucide-vue-next' | |
| const isOpen = ref(false) | |
| const auth = useAuthStore() | |
| // Server's global default render settings, so the form reflects what the free | |
| // key actually produces rather than hardcoded frontend defaults. Loaded lazily | |
| // (and cached) by the store; `defaults` stays null until it resolves, in which | |
| // case every control gracefully falls back to its built-in default. | |
| const defaults = computed(() => auth.freeKeyDefaults) | |
| onMounted(() => { | |
| if (auth.freeApiKeyEnabled) auth.loadFreeKeyDefaults() | |
| }) | |
| const idType = ref<'imdb' | 'tmdb' | 'tvdb'>('imdb') | |
| const imageType = ref<'poster' | 'logo' | 'backdrop' | 'episode'>('poster') | |
| const idValue = ref('tt0013442') | |
| const lang = ref('any') | |
| const imageSize = ref<'default' | 'small' | 'medium' | 'large' | 'verylarge'>('default') | |
| const badgeStyle = ref('default') | |
| const labelStyle = ref('default') | |
| const badgeSize = ref('default') | |
| const badgeShape = ref('default') | |
| const badgeBackground = ref('default') | |
| const ratingsLimit = ref('default') | |
| const badgeDirection = ref('default') | |
| const imagePosition = ref('default') | |
| const imageSource = ref('default') | |
| const textless = ref('default') | |
| const posterSplit = ref('default') | |
| const posterFit = ref('default') | |
| // Backdrop-only edge insets (percent of dimension). Empty = use the server | |
| // default; any entered value (including 0) is sent as an explicit override. | |
| // A number input's v-model yields a number (Vue coerces `type=number`), so the | |
| // ref holds string | number and `insetParam` normalises both. | |
| const edgeInsetX = ref<string | number>('') | |
| const edgeInsetY = ref<string | number>('') | |
| const ratingsOrderList = ref<string[]>(parseRatingsOrder(DEFAULT_RATINGS_ORDER)) | |
| // Baseline the user's order is compared against to decide whether to send a | |
| // `ratings_order` override — the server's order once loaded, else the frontend default. | |
| const baselineOrder = ref<string[]>(parseRatingsOrder(DEFAULT_RATINGS_ORDER)) | |
| const blur = ref('default') | |
| const ratingsOrderChanged = computed( | |
| () => ratingsOrderList.value.join(',') !== baselineOrder.value.join(','), | |
| ) | |
| const fetchError = ref('') | |
| const fetchLoading = ref(false) | |
| const resultUrl = ref('') | |
| const resultImageType = ref<'poster' | 'logo' | 'backdrop' | 'episode'>('poster') | |
| const sizeOptions = computed(() => { | |
| if (imageType.value === 'backdrop' || imageType.value === 'episode') { | |
| return [ | |
| { value: 'default', label: 'Size: default' }, | |
| { value: 'small', label: 'Small' }, | |
| { value: 'medium', label: 'Medium' }, | |
| { value: 'large', label: 'Large' }, | |
| ] | |
| } | |
| return [ | |
| { value: 'default', label: 'Size: default' }, | |
| { value: 'medium', label: 'Medium' }, | |
| { value: 'large', label: 'Large' }, | |
| { value: 'verylarge', label: 'Very Large' }, | |
| ] | |
| }) | |
| // Rating sources to exclude from badges. Seeded from the server's exclusion list | |
| // and editable via the checkboxes below; excluded sources are dimmed in the | |
| // priority list. Sent as a `ratings_exclude` override only when changed. | |
| const ratingsExcludeList = ref<string[]>([]) | |
| const excludeBaseline = ref<string[]>([]) | |
| const ratingsExcludeChanged = computed( | |
| () => ratingsExcludeList.value.join(',') !== excludeBaseline.value.join(','), | |
| ) | |
| function isExcluded(key: string): boolean { | |
| return ratingsExcludeList.value.includes(key) | |
| } | |
| function toggleExclude(key: string, checked: boolean) { | |
| const set = new Set(ratingsExcludeList.value) | |
| if (checked) set.add(key) | |
| else set.delete(key) | |
| // Keep canonical source order so the emitted value is stable regardless of click order. | |
| ratingsExcludeList.value = ALL_RATING_SOURCES.map(s => s.key).filter(k => set.has(k)) | |
| } | |
| // When the server defaults arrive, adopt the server's rating order and exclusion | |
| // list as the new baselines. Only overwrite a list if the user hasn't edited it | |
| // yet, so a slow fetch never clobbers an in-progress edit. | |
| watch(defaults, (d) => { | |
| if (!d) return | |
| const serverOrder = parseRatingsOrder(d.ratings_order) | |
| if (!ratingsOrderChanged.value) ratingsOrderList.value = [...serverOrder] | |
| baselineOrder.value = serverOrder | |
| const serverExclude = parseRatingsExclude(d.ratings_exclude) | |
| if (!ratingsExcludeChanged.value) ratingsExcludeList.value = [...serverExclude] | |
| excludeBaseline.value = serverExclude | |
| }, { immediate: true }) | |
| // --- Per-image-type server defaults reflected in the dropdown "default" labels --- | |
| const typeDefaults = computed(() => { | |
| const d = defaults.value | |
| if (!d) return null | |
| switch (imageType.value) { | |
| case 'logo': | |
| return { badge_style: d.logo_badge_style, label_style: d.logo_label_style, badge_size: d.logo_badge_size, ratings_limit: d.logo_ratings_limit, position: null as string | null, badge_direction: null as string | null, badge_shape: d.logo_badge_shape, badge_background: d.logo_badge_background } | |
| case 'backdrop': | |
| return { badge_style: d.backdrop_badge_style, label_style: d.backdrop_label_style, badge_size: d.backdrop_badge_size, ratings_limit: d.backdrop_ratings_limit, position: d.backdrop_position, badge_direction: d.backdrop_badge_direction, badge_shape: d.backdrop_badge_shape, badge_background: d.backdrop_badge_background } | |
| case 'episode': | |
| return { badge_style: d.episode_badge_style, label_style: d.episode_label_style, badge_size: d.episode_badge_size, ratings_limit: d.episode_ratings_limit, position: d.episode_position, badge_direction: d.episode_badge_direction, badge_shape: d.episode_badge_shape, badge_background: d.episode_badge_background } | |
| default: // poster | |
| return { badge_style: d.poster_badge_style, label_style: d.poster_label_style, badge_size: d.poster_badge_size, ratings_limit: d.ratings_limit, position: d.poster_position, badge_direction: d.poster_badge_direction, badge_shape: d.poster_badge_shape, badge_background: d.poster_badge_background } | |
| } | |
| }) | |
| /** Build a "<base>: default (<resolved>)" label, or plain "<base>: default" until loaded. */ | |
| function annotate(base: string, value: string | null | undefined, map: Record<string, string>): string { | |
| if (value == null) return `${base}: default` | |
| return `${base}: default (${map[value] ?? value})` | |
| } | |
| const langDefaultLabel = computed(() => | |
| defaults.value ? `Language: any (${defaults.value.lang})` : 'Language: any', | |
| ) | |
| const ratingsLimitDefaultLabel = computed(() => { | |
| const limit = typeDefaults.value?.ratings_limit | |
| return limit == null ? 'Max badges: default' : `Max badges: default (${limit})` | |
| }) | |
| const badgeStyleDefaultLabel = computed(() => annotate('Badge style', typeDefaults.value?.badge_style, BADGE_STYLE_LABELS)) | |
| const labelStyleDefaultLabel = computed(() => annotate('Label style', typeDefaults.value?.label_style, LABEL_STYLE_LABELS)) | |
| const badgeSizeDefaultLabel = computed(() => annotate('Badge size', typeDefaults.value?.badge_size, BADGE_SIZE_LABELS)) | |
| const badgeShapeDefaultLabel = computed(() => annotate('Badge shape', typeDefaults.value?.badge_shape, BADGE_SHAPE_LABELS)) | |
| // Pills always render horizontally, so the badge style choice has no effect. | |
| // Covers both an explicit pill choice and 'default' resolving to a pill server default. | |
| const badgeShapeIsPill = computed(() => | |
| badgeShape.value === 'p' || (badgeShape.value === 'default' && typeDefaults.value?.badge_shape === 'p'), | |
| ) | |
| const badgeBackgroundDefaultLabel = computed(() => annotate('Background', typeDefaults.value?.badge_background, BADGE_BACKGROUND_LABELS)) | |
| const imageSourceDefaultLabel = computed(() => annotate('Source', defaults.value?.image_source, IMAGE_SOURCE_LABELS)) | |
| const positionDefaultLabel = computed(() => annotate('Position', typeDefaults.value?.position, POSITION_LABELS)) | |
| const badgeDirectionDefaultLabel = computed(() => annotate('Direction', typeDefaults.value?.badge_direction, BADGE_DIRECTION_LABELS)) | |
| const textlessDefaultLabel = computed(() => | |
| defaults.value ? `Textless: default (${defaults.value.textless ? 'Yes' : 'No'})` : 'Textless: default', | |
| ) | |
| const splitDefaultLabel = computed(() => | |
| defaults.value ? `Split badges: default (${defaults.value.poster_badge_split ? 'Yes' : 'No'})` : 'Split badges: default', | |
| ) | |
| const fitDefaultLabel = computed(() => annotate('Fit', defaults.value?.poster_fit, POSTER_FIT_LABELS)) | |
| const blurDefaultLabel = computed(() => | |
| defaults.value ? `Blur: default (${defaults.value.episode_blur ? 'Yes' : 'No'})` : 'Blur: default', | |
| ) | |
| // Switching image type re-applies that type's own defaults: each type carries | |
| // its own server defaults (poster_badge_style vs logo_badge_style, etc.), so the | |
| // per-type render controls reset to "default" and the dropdowns reflect the new | |
| // type's settings rather than carrying over the previous type's overrides. | |
| watch(imageType, (newType) => { | |
| const validValues = sizeOptions.value.map(o => o.value) | |
| if (!validValues.includes(imageSize.value)) { | |
| imageSize.value = 'default' | |
| } | |
| // Per-type render controls — reset on every switch so the form shows the | |
| // appropriate defaults for the newly selected image type. | |
| badgeStyle.value = 'default' | |
| labelStyle.value = 'default' | |
| badgeSize.value = 'default' | |
| ratingsLimit.value = 'default' | |
| badgeDirection.value = 'default' | |
| imagePosition.value = 'default' | |
| // Controls that only exist for one type; clearing them keeps the query string | |
| // free of params the new type would ignore. | |
| textless.value = 'default' | |
| posterSplit.value = 'default' | |
| posterFit.value = 'default' | |
| edgeInsetX.value = '' | |
| edgeInsetY.value = '' | |
| blur.value = 'default' | |
| // image_source is a single global default shared by every type, so it persists | |
| // across switches — except episode, which doesn't accept the param. | |
| if (newType === 'episode') { | |
| imageSource.value = 'default' | |
| } | |
| }) | |
| const apiBase = import.meta.env.VITE_API_URL || '' | |
| // An edge-inset field is sent as an override only when non-empty; the value is | |
| // clamped to the server's accepted 0–50 range. Accepts the string OR number a | |
| // number-typed v-model may produce. Returns null to omit the param. | |
| function insetParam(raw: string | number): string | null { | |
| const trimmed = String(raw).trim() | |
| if (trimmed === '') return null | |
| const n = Math.round(Number(trimmed)) | |
| if (!Number.isFinite(n)) return null | |
| return String(Math.min(50, Math.max(0, n))) | |
| } | |
| onUnmounted(() => { | |
| if (resultUrl.value) URL.revokeObjectURL(resultUrl.value) | |
| }) | |
| const idPlaceholder = computed(() => { | |
| if (idType.value === 'imdb') return 'tt0013442' | |
| if (idType.value === 'tmdb') return 'movie-872585 or episode-1396-S1E1' | |
| return '253573' | |
| }) | |
| const queryString = computed(() => { | |
| const params = new URLSearchParams() | |
| const langVal = lang.value === 'any' ? '' : lang.value | |
| if (langVal.trim()) params.set('lang', langVal.trim()) | |
| const sizeVal = imageSize.value === 'default' ? '' : imageSize.value | |
| if (sizeVal) params.set('imageSize', sizeVal) | |
| if (ratingsOrderChanged.value) params.set('ratings_order', ratingsOrderList.value.join(',')) | |
| // Emit even when empty, so unchecking the server's exclusions clears them. | |
| if (ratingsExcludeChanged.value) params.set('ratings_exclude', ratingsExcludeList.value.join(',')) | |
| if (badgeStyle.value !== 'default') params.set('badge_style', badgeStyle.value) | |
| if (labelStyle.value !== 'default') params.set('label_style', labelStyle.value) | |
| if (badgeSize.value !== 'default') params.set('badge_size', badgeSize.value) | |
| if (badgeShape.value !== 'default') params.set('badge_shape', badgeShape.value) | |
| if (badgeBackground.value !== 'default') params.set('badge_background', badgeBackground.value) | |
| if (ratingsLimit.value !== 'default') params.set('ratings_limit', ratingsLimit.value) | |
| if (imageType.value !== 'logo' && badgeDirection.value !== 'default') params.set('badge_direction', badgeDirection.value) | |
| if (imageType.value !== 'logo' && imagePosition.value !== 'default') params.set('position', imagePosition.value) | |
| if (imageType.value !== 'episode' && imageSource.value !== 'default') params.set('image_source', imageSource.value) | |
| if (imageType.value === 'poster' && textless.value !== 'default') params.set('textless', textless.value) | |
| if (imageType.value === 'poster' && posterSplit.value !== 'default') params.set('split', posterSplit.value) | |
| if (imageType.value === 'poster' && posterFit.value !== 'default') params.set('fit', posterFit.value) | |
| if (imageType.value === 'backdrop') { | |
| const ex = insetParam(edgeInsetX.value) | |
| if (ex !== null) params.set('edge_inset_x', ex) | |
| const ey = insetParam(edgeInsetY.value) | |
| if (ey !== null) params.set('edge_inset_y', ey) | |
| } | |
| if (imageType.value === 'episode' && blur.value !== 'default') params.set('blur', blur.value) | |
| const qs = params.toString() | |
| return qs ? `?${qs}` : '' | |
| }) | |
| const curlExample = computed(() => { | |
| const id = idValue.value.trim() || idPlaceholder.value | |
| const ext = imageType.value === 'logo' ? 'png' : 'jpg' | |
| return `curl -o ${imageType.value}.${ext} "${window.location.origin}/${FREE_API_KEY}/${idType.value}/${imageType.value}-default/${id}.${ext}${queryString.value}"` | |
| }) | |
| const resultClass = computed(() => { | |
| if (resultImageType.value === 'logo') return 'max-w-[400px]' | |
| if (resultImageType.value === 'backdrop' || resultImageType.value === 'episode') return 'max-w-[500px] rounded-lg shadow-lg' | |
| return 'max-w-[200px] rounded-lg shadow-lg' | |
| }) | |
| async function handleFetch() { | |
| const id = idValue.value.trim() | |
| if (!id) return | |
| fetchError.value = '' | |
| fetchLoading.value = true | |
| const prevUrl = resultUrl.value | |
| const ext = imageType.value === 'logo' ? 'png' : 'jpg' | |
| const url = `${apiBase}/${FREE_API_KEY}/${idType.value}/${imageType.value}-default/${id}.${ext}${queryString.value}` | |
| try { | |
| const res = await fetch(url) | |
| if (!res.ok) throw new Error(res.status === 404 ? 'Not found — check the ID and try again' : `Server error (${res.status})`) | |
| const blob = await res.blob() | |
| resultImageType.value = imageType.value | |
| resultUrl.value = URL.createObjectURL(blob) | |
| if (prevUrl) URL.revokeObjectURL(prevUrl) | |
| } catch (e) { | |
| fetchError.value = e instanceof Error && e.message ? e.message : 'Failed to fetch — check the ID and try again' | |
| if (prevUrl) URL.revokeObjectURL(prevUrl) | |
| resultUrl.value = '' | |
| } finally { | |
| fetchLoading.value = false | |
| } | |
| } | |
| </script> | |
| <template> | |
| <div v-if="auth.freeApiKeyEnabled" class="rounded-lg border border-blue-500/30 bg-blue-500/5 p-4 space-y-3"> | |
| <p class="text-sm font-medium">Free API Key Available</p> | |
| <p class="text-sm text-muted-foreground"> | |
| Use the following key for poster serving (read-only, global defaults): | |
| </p> | |
| <code class="block text-sm font-mono bg-muted px-3 py-2 rounded select-all">{{ FREE_API_KEY }}</code> | |
| <Collapsible v-model:open="isOpen"> | |
| <CollapsibleTrigger as-child> | |
| <button class="flex w-full items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"> | |
| <ChevronRight class="h-4 w-4 shrink-0 transition-transform duration-200" :class="{ 'rotate-90': isOpen }" /> | |
| Try it out | |
| </button> | |
| </CollapsibleTrigger> | |
| <CollapsibleContent class="pt-3 space-y-3"> | |
| <form class="flex flex-col gap-3" @submit.prevent="handleFetch"> | |
| <!-- | |
| The dynamic "default (resolved)" items are :key'd by their label so | |
| reka-ui re-registers the option's text when it changes — on a per-type | |
| switch or when the server defaults load after mount. Without this the | |
| closed trigger keeps the stale text reka-ui cached when the item first | |
| mounted (it only snapshots textContent once), so the summary shown on | |
| the trigger would lag the actual default for the selected image type. | |
| --> | |
| <div class="grid grid-cols-1 sm:grid-cols-2 gap-2"> | |
| <Select v-model="imageSize"> | |
| <SelectTrigger id="free-image-size" aria-label="Image size" class="bg-background"> | |
| <SelectValue placeholder="Size: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem v-for="opt in sizeOptions" :key="opt.value" :value="opt.value"> | |
| {{ opt.label }} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="lang"> | |
| <SelectTrigger id="free-lang" aria-label="Language" class="bg-background"> | |
| <SelectValue placeholder="Language: any" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="any" :key="langDefaultLabel">{{ langDefaultLabel }}</SelectItem> | |
| <SelectItem v-for="l in LANGUAGES" :key="l.code" :value="l.code"> | |
| {{ l.code }} - {{ l.name }} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="ratingsLimit"> | |
| <SelectTrigger id="free-ratings-limit" aria-label="Ratings limit" class="bg-background"> | |
| <SelectValue placeholder="Max badges: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="ratingsLimitDefaultLabel">{{ ratingsLimitDefaultLabel }}</SelectItem> | |
| <SelectItem v-for="n in 11" :key="n - 1" :value="String(n - 1)"> | |
| {{ n - 1 }} {{ n - 1 === 1 ? 'badge' : 'badges' }} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="badgeStyle" :disabled="badgeShapeIsPill"> | |
| <SelectTrigger id="free-badge-style" aria-label="Badge style" class="bg-background"> | |
| <SelectValue placeholder="Badge style: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="badgeStyleDefaultLabel">{{ badgeStyleDefaultLabel }}</SelectItem> | |
| <SelectItem value="h">Horizontal</SelectItem> | |
| <SelectItem value="v">Vertical</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="labelStyle"> | |
| <SelectTrigger id="free-label-style" aria-label="Label style" class="bg-background"> | |
| <SelectValue placeholder="Label style: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="labelStyleDefaultLabel">{{ labelStyleDefaultLabel }}</SelectItem> | |
| <SelectItem value="t">Text</SelectItem> | |
| <SelectItem value="i">Icon</SelectItem> | |
| <SelectItem value="o">Official</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="badgeSize"> | |
| <SelectTrigger id="free-badge-size" aria-label="Badge size" class="bg-background"> | |
| <SelectValue placeholder="Badge size: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="badgeSizeDefaultLabel">{{ badgeSizeDefaultLabel }}</SelectItem> | |
| <SelectItem value="xs">Extra Small</SelectItem> | |
| <SelectItem value="s">Small</SelectItem> | |
| <SelectItem value="m">Medium</SelectItem> | |
| <SelectItem value="l">Large</SelectItem> | |
| <SelectItem value="xl">Extra Large</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="badgeShape"> | |
| <SelectTrigger id="free-badge-shape" aria-label="Badge shape" class="bg-background"> | |
| <SelectValue placeholder="Badge shape: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default">{{ badgeShapeDefaultLabel }}</SelectItem> | |
| <SelectItem value="r">Rounded</SelectItem> | |
| <SelectItem value="p">Pill</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="badgeBackground"> | |
| <SelectTrigger id="free-badge-background" aria-label="Badge background" class="bg-background"> | |
| <SelectValue placeholder="Background: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default">{{ badgeBackgroundDefaultLabel }}</SelectItem> | |
| <SelectItem value="d">Default</SelectItem> | |
| <SelectItem value="k">Dark</SelectItem> | |
| <SelectItem value="t">Transparent</SelectItem> | |
| <SelectItem value="n">None</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-if="imageType !== 'episode'" v-model="imageSource"> | |
| <SelectTrigger id="free-image-source" aria-label="Image source" class="bg-background"> | |
| <SelectValue placeholder="Source: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="imageSourceDefaultLabel">{{ imageSourceDefaultLabel }}</SelectItem> | |
| <SelectItem value="t">TMDB</SelectItem> | |
| <SelectItem value="f">Fanart.tv</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <template v-if="imageType === 'poster'"> | |
| <Select v-model="textless"> | |
| <SelectTrigger id="free-textless" aria-label="Textless" class="bg-background"> | |
| <SelectValue placeholder="Textless: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="textlessDefaultLabel">{{ textlessDefaultLabel }}</SelectItem> | |
| <SelectItem value="true">Yes</SelectItem> | |
| <SelectItem value="false">No</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="posterSplit"> | |
| <SelectTrigger id="free-split" aria-label="Split badges" class="bg-background"> | |
| <SelectValue placeholder="Split badges: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="splitDefaultLabel">{{ splitDefaultLabel }}</SelectItem> | |
| <SelectItem value="true">Yes</SelectItem> | |
| <SelectItem value="false">No</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="posterFit"> | |
| <SelectTrigger id="free-fit" aria-label="Aspect ratio fit" class="bg-background"> | |
| <SelectValue placeholder="Fit: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="fitDefaultLabel">{{ fitDefaultLabel }}</SelectItem> | |
| <SelectItem value="native">Native (source ratio)</SelectItem> | |
| <SelectItem value="cover">Crop to 2:3</SelectItem> | |
| <SelectItem value="blur">Blur fill to 2:3</SelectItem> | |
| <SelectItem value="pad">Letterbox to 2:3</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </template> | |
| <template v-if="imageType !== 'logo'"> | |
| <Select v-model="imagePosition"> | |
| <SelectTrigger id="free-image-position" aria-label="Position" class="bg-background"> | |
| <SelectValue placeholder="Position: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="positionDefaultLabel">{{ positionDefaultLabel }}</SelectItem> | |
| <SelectItem value="bc">Bottom Center</SelectItem> | |
| <SelectItem value="tc">Top Center</SelectItem> | |
| <SelectItem value="l">Left</SelectItem> | |
| <SelectItem value="r">Right</SelectItem> | |
| <SelectItem value="tl">Top Left</SelectItem> | |
| <SelectItem value="tr">Top Right</SelectItem> | |
| <SelectItem value="bl">Bottom Left</SelectItem> | |
| <SelectItem value="br">Bottom Right</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="badgeDirection"> | |
| <SelectTrigger id="free-badge-direction" aria-label="Badge direction" class="bg-background"> | |
| <SelectValue placeholder="Direction: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="badgeDirectionDefaultLabel">{{ badgeDirectionDefaultLabel }}</SelectItem> | |
| <SelectItem value="h">Horizontal</SelectItem> | |
| <SelectItem value="v">Vertical</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </template> | |
| <template v-if="imageType === 'backdrop'"> | |
| <div class="flex items-center gap-2"> | |
| <Label for="free-edge-inset-x" class="text-xs text-muted-foreground shrink-0 whitespace-nowrap">Horizontal inset %</Label> | |
| <Input | |
| id="free-edge-inset-x" | |
| v-model="edgeInsetX" | |
| type="number" | |
| min="0" | |
| max="50" | |
| :placeholder="`default (${defaults?.backdrop_edge_inset_x ?? 0})`" | |
| aria-label="Backdrop horizontal edge inset (percent of width, left/right positions)" | |
| class="bg-background min-w-0" | |
| /> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <Label for="free-edge-inset-y" class="text-xs text-muted-foreground shrink-0 whitespace-nowrap">Vertical inset %</Label> | |
| <Input | |
| id="free-edge-inset-y" | |
| v-model="edgeInsetY" | |
| type="number" | |
| min="0" | |
| max="50" | |
| :placeholder="`default (${defaults?.backdrop_edge_inset_y ?? 0})`" | |
| aria-label="Backdrop vertical edge inset (percent of height, top/bottom positions)" | |
| class="bg-background min-w-0" | |
| /> | |
| </div> | |
| </template> | |
| <template v-if="imageType === 'episode'"> | |
| <Select v-model="blur"> | |
| <SelectTrigger id="free-blur" aria-label="Blur" class="bg-background"> | |
| <SelectValue placeholder="Blur: default" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="default" :key="blurDefaultLabel">{{ blurDefaultLabel }}</SelectItem> | |
| <SelectItem value="true">Yes</SelectItem> | |
| <SelectItem value="false">No</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </template> | |
| </div> | |
| <div class="space-y-1 flex flex-col items-center"> | |
| <p class="text-xs text-muted-foreground">Rating priority</p> | |
| <RatingsOrderList v-model="ratingsOrderList" :excluded="ratingsExcludeList" compact /> | |
| </div> | |
| <div class="space-y-1 flex flex-col items-center"> | |
| <p class="text-xs text-muted-foreground">Exclude ratings</p> | |
| <div class="grid grid-cols-2 gap-x-3 gap-y-1.5 max-w-sm w-full"> | |
| <div | |
| v-for="source in ALL_RATING_SOURCES" | |
| :key="source.key" | |
| class="flex items-start gap-1.5 text-left" | |
| > | |
| <Checkbox | |
| :id="`free-exclude-${source.key}`" | |
| :model-value="isExcluded(source.key)" | |
| :aria-label="`Exclude ${source.label}`" | |
| class="bg-background shrink-0 mt-0.5" | |
| @update:model-value="(v) => toggleExclude(source.key, !!v)" | |
| /> | |
| <Label | |
| :for="`free-exclude-${source.key}`" | |
| class="flex items-start gap-1.5 text-xs font-normal leading-snug cursor-pointer min-w-0" | |
| > | |
| <span | |
| class="inline-block w-2 h-2 rounded-full shrink-0 mt-1" | |
| :style="{ backgroundColor: source.color }" | |
| ></span> | |
| <span>{{ source.label }}</span> | |
| </Label> | |
| </div> | |
| </div> | |
| </div> | |
| <code class="block text-xs font-mono bg-muted px-3 py-2 rounded text-muted-foreground break-all select-all">{{ curlExample }}</code> | |
| <div class="flex flex-wrap gap-2"> | |
| <Select v-model="idType"> | |
| <SelectTrigger id="free-id-type" aria-label="ID type" class="bg-background w-auto"> | |
| <SelectValue placeholder="ID type" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="imdb">IMDb</SelectItem> | |
| <SelectItem value="tmdb">TMDb</SelectItem> | |
| <SelectItem value="tvdb">TVDB</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <Select v-model="imageType"> | |
| <SelectTrigger id="free-image-type" aria-label="Image type" class="bg-background w-auto"> | |
| <SelectValue placeholder="Image type" /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="poster">Poster</SelectItem> | |
| <SelectItem value="logo">Logo</SelectItem> | |
| <SelectItem value="backdrop">Backdrop</SelectItem> | |
| <SelectItem value="episode">Episode</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| <div class="flex gap-2 flex-1 min-w-[200px]"> | |
| <Input | |
| id="free-id-value" | |
| v-model="idValue" | |
| type="text" | |
| :placeholder="idPlaceholder" | |
| required | |
| class="flex-1 min-w-0 font-mono bg-background" | |
| /> | |
| <Button type="submit" :disabled="fetchLoading" class="shrink-0"> | |
| <Loader2 v-if="fetchLoading" class="h-4 w-4 animate-spin" /> | |
| <span v-else>Fetch</span> | |
| </Button> | |
| </div> | |
| </div> | |
| </form> | |
| <p v-if="fetchError" class="text-sm text-destructive">{{ fetchError }}</p> | |
| <div v-if="resultUrl" class="flex justify-center pt-2 overflow-hidden"> | |
| <img | |
| :src="resultUrl" | |
| alt="Fetched result" | |
| :class="['w-full', resultClass]" | |
| /> | |
| </div> | |
| </CollapsibleContent> | |
| </Collapsible> | |
| </div> | |
| </template> | |