Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
145
app/components/detail/FlightTracker.vue
Normal file
145
app/components/detail/FlightTracker.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
flightCode: string
|
||||
}>()
|
||||
|
||||
const info = ref<any>(null)
|
||||
const fr24Url = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
async function loadInfo() {
|
||||
if (loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch<any>('/api/flight-info', {
|
||||
query: { flightno: props.flightCode }
|
||||
})
|
||||
fr24Url.value = data.fr24Url || `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
|
||||
if (data.found) info.value = data.flight
|
||||
} catch {
|
||||
fr24Url.value = `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function formatAltitude(ft: number) {
|
||||
return `${Math.round(ft * 0.3048)}m (FL${Math.round(ft / 100)})`
|
||||
}
|
||||
|
||||
function formatSpeed(knots: number) {
|
||||
return `${Math.round(knots * 1.852)} km/h`
|
||||
}
|
||||
|
||||
function formatDelay(min: number | null) {
|
||||
if (min == null || min === 0) return null
|
||||
if (min > 0) return `+${min} min`
|
||||
return `${min} min`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Toggle button -->
|
||||
<UButton
|
||||
:label="loading ? 'Cargando...' : (info ? 'Info del vuelo' : 'Ver info en vivo')"
|
||||
:icon="info ? 'i-lucide-radar' : 'i-lucide-radio'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:loading="loading"
|
||||
@click="loadInfo"
|
||||
/>
|
||||
|
||||
<!-- Flight info panel -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-96"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 max-h-96"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="info" class="mt-2 overflow-hidden">
|
||||
<div class="rounded-lg bg-neutral-50 dark:bg-neutral-800/50 p-3 space-y-2 text-sm">
|
||||
<!-- Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="info.onGround ? 'bg-amber-500' : info.altitude > 0 ? 'bg-green-500 animate-pulse' : 'bg-neutral-400'"
|
||||
/>
|
||||
<span class="font-medium">{{ info.status }}</span>
|
||||
</div>
|
||||
<a
|
||||
:href="info.fr24Url"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 hover:underline"
|
||||
>
|
||||
Flightradar24
|
||||
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft & airline -->
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div v-if="info.aircraft">
|
||||
<span class="text-muted">Avion</span>
|
||||
<p class="font-medium">{{ info.aircraftAge || info.aircraft }}</p>
|
||||
</div>
|
||||
<div v-if="info.registration">
|
||||
<span class="text-muted">Matricula</span>
|
||||
<p class="font-medium">{{ info.registration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live data (if in flight) -->
|
||||
<div v-if="info.altitude > 0 && !info.onGround" class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span class="text-muted">Altitud</span>
|
||||
<p class="font-medium">{{ formatAltitude(info.altitude) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">Velocidad</span>
|
||||
<p class="font-medium">{{ formatSpeed(info.speed) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">Rumbo</span>
|
||||
<p class="font-medium">{{ info.heading }}°</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delays -->
|
||||
<div v-if="formatDelay(info.departureDelay) || formatDelay(info.arrivalDelay)" class="flex gap-4 text-xs">
|
||||
<div v-if="formatDelay(info.departureDelay)">
|
||||
<span class="text-muted">Retraso salida</span>
|
||||
<p class="font-medium" :class="info.departureDelay > 0 ? 'text-red-500' : 'text-green-500'">
|
||||
{{ formatDelay(info.departureDelay) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="formatDelay(info.arrivalDelay)">
|
||||
<span class="text-muted">Retraso llegada</span>
|
||||
<p class="font-medium" :class="info.arrivalDelay > 0 ? 'text-red-500' : 'text-green-500'">
|
||||
{{ formatDelay(info.arrivalDelay) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Not found — link to FR24 anyway -->
|
||||
<div v-if="loaded && !info && !loading" class="mt-1">
|
||||
<a
|
||||
:href="fr24Url"
|
||||
target="_blank"
|
||||
class="text-xs text-muted hover:text-primary-500 transition-colors"
|
||||
>
|
||||
No hay datos en vivo · Ver historial en Flightradar24
|
||||
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
49
app/components/detail/ItineraryTimeline.vue
Normal file
49
app/components/detail/ItineraryTimeline.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
defineProps<{ trip: Trip }>()
|
||||
|
||||
function formatFullDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
function legDuration(leg: Trip['legs'][0]) {
|
||||
const segs = leg.segments
|
||||
if (!segs.length) return ''
|
||||
const depMs = segs[0].departureTimestamp * 1000
|
||||
const arrMs = segs[segs.length - 1].arrivalTimestamp * 1000
|
||||
const diffMin = Math.round((arrMs - depMs) / 60000)
|
||||
const h = Math.floor(diffMin / 60)
|
||||
const m = diffMin % 60
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<UCard v-for="(leg, i) in trip.legs" :key="i">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :label="i === 0 ? 'Ida' : 'Vuelta'" :color="i === 0 ? 'primary' : 'info'" variant="subtle" />
|
||||
<span class="text-sm text-neutral-500">{{ formatFullDate(leg.segments[0].departureDate) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<UIcon name="i-lucide-timer" class="text-xs" />
|
||||
{{ legDuration(leg) }}
|
||||
<template v-if="leg.segments.length > 1">
|
||||
· {{ leg.segments.length - 1 }} escala{{ leg.segments.length > 2 ? 's' : '' }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DetailSegmentCard
|
||||
v-for="(seg, j) in leg.segments"
|
||||
:key="j"
|
||||
:segment="seg"
|
||||
:show-divider="j > 0"
|
||||
/>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
44
app/components/detail/PriceVerifier.vue
Normal file
44
app/components/detail/PriceVerifier.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { PassengersCount } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
bookingToken: string
|
||||
originalPrice: number
|
||||
passengers: PassengersCount
|
||||
}>()
|
||||
|
||||
const { checkPrice } = useFlightSearch()
|
||||
const checkedPrice = ref<number | null>(null)
|
||||
const checking = ref(false)
|
||||
|
||||
async function verify() {
|
||||
checking.value = true
|
||||
try {
|
||||
const data = await checkPrice(props.bookingToken, props.passengers)
|
||||
checkedPrice.value = data.trip.totalCost
|
||||
} catch {
|
||||
checkedPrice.value = -1
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">Verificar precio actual</h3>
|
||||
<p class="text-sm text-neutral-500">Comprueba si el precio sigue disponible</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="checkedPrice !== null">
|
||||
<UBadge v-if="checkedPrice === -1" label="No disponible" color="error" />
|
||||
<UBadge v-else-if="checkedPrice <= originalPrice" :label="`${checkedPrice.toFixed(0)}€`" color="success" />
|
||||
<UBadge v-else :label="`${checkedPrice.toFixed(0)}€ (subio)`" color="warning" />
|
||||
</template>
|
||||
<UButton label="Verificar" icon="i-lucide-refresh-cw" :loading="checking" @click="verify" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
57
app/components/detail/RelatedFlights.vue
Normal file
57
app/components/detail/RelatedFlights.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
from: string
|
||||
to: string
|
||||
}>()
|
||||
|
||||
const { trips, loading, fetchRouteFlights } = useRouteFlights()
|
||||
|
||||
onMounted(() => {
|
||||
if (props.from && props.to) {
|
||||
fetchRouteFlights(props.from, props.to)
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(d: string) {
|
||||
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading || trips.length > 0">
|
||||
<h3 class="font-semibold mb-3">Otros vuelos {{ from }} → {{ to }}</h3>
|
||||
<div v-if="loading" class="space-y-2">
|
||||
<USkeleton v-for="i in 3" :key="i" class="h-12" />
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(trip, i) in trips.slice(0, 5)"
|
||||
:key="i"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-medium">
|
||||
{{ formatTime(trip.legs[0]?.segments[0]?.departureDate) }}
|
||||
→
|
||||
{{ formatTime(trip.legs[0]?.segments.at(-1)?.arrivalDate || '') }}
|
||||
</span>
|
||||
<span class="text-muted">
|
||||
{{ formatDate(trip.legs[0]?.segments[0]?.departureDate) }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{{ trip.legs[0]?.segments[0]?.company?.code }}{{ trip.legs[0]?.segments[0]?.number }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ trip.totalCost.toFixed(0) }}€
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
164
app/components/detail/SegmentCard.vue
Normal file
164
app/components/detail/SegmentCard.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import type { Segment } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment
|
||||
showDivider?: boolean
|
||||
}>()
|
||||
|
||||
const { resolve } = useAirlineNames()
|
||||
const { getBookingUrl, getAirlineWebsite } = useBookingUrl()
|
||||
|
||||
function formatTime(d: string) {
|
||||
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const segmentDate = computed(() => props.segment.departureDate.slice(0, 10))
|
||||
|
||||
const segmentLink = computed(() => ({
|
||||
path: '/results',
|
||||
query: {
|
||||
mode: 'oneway',
|
||||
dep: props.segment.departureCode,
|
||||
dest: props.segment.arrivalCode,
|
||||
from: segmentDate.value,
|
||||
to: segmentDate.value,
|
||||
adults: '1'
|
||||
}
|
||||
}))
|
||||
|
||||
const flightCode = computed(() => `${props.segment.company.code}${props.segment.number}`)
|
||||
const trackerUrl = computed(() => `https://www.flightradar24.com/data/flights/${flightCode.value.toLowerCase()}`)
|
||||
const airlineName = computed(() => resolve(props.segment.company.code, props.segment.company.name))
|
||||
|
||||
const bookingUrl = computed(() => getBookingUrl({
|
||||
airlineCode: props.segment.company.code,
|
||||
origin: props.segment.departureCode,
|
||||
destination: props.segment.arrivalCode,
|
||||
date: props.segment.departureDate
|
||||
}))
|
||||
const airlineWebsite = computed(() => getAirlineWebsite(props.segment.company.code))
|
||||
|
||||
// Fetch cheapest price for this specific route
|
||||
const segmentPrice = ref<number | null>(null)
|
||||
const loadingPrice = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadingPrice.value = true
|
||||
try {
|
||||
const date = props.segment.departureDate.slice(0, 10)
|
||||
const data = await $fetch<any>('/api/search', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
departures: [props.segment.departureCode],
|
||||
local: 'en',
|
||||
departureDateInterval: {
|
||||
begin: `${date}T00:00:00+00:00`,
|
||||
end: `${date}T00:00:00+00:00`
|
||||
},
|
||||
stops: [{
|
||||
locations: [props.segment.arrivalCode],
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}],
|
||||
endInSameLocation: false,
|
||||
maxStops: 0,
|
||||
fixStopsOrder: false,
|
||||
stopLength: { min: 0, max: 0, isSet: false },
|
||||
maxResults: 1,
|
||||
passengersCount: { adult: 1, child: 0, infant: 0 }
|
||||
}
|
||||
})
|
||||
if (data.trips?.length) {
|
||||
segmentPrice.value = data.trips[0].totalCost
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
loadingPrice.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 py-3 -mx-2 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
:class="{ 'border-t border-neutral-100 dark:border-neutral-800': showDivider }"
|
||||
>
|
||||
<!-- Departure -->
|
||||
<div class="text-center w-20">
|
||||
<p class="text-xl font-semibold">{{ formatTime(segment.departureDate) }}</p>
|
||||
<p class="text-sm font-medium">{{ segment.departureCode }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ segment.departureCity }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Flight info -->
|
||||
<div class="flex-1 flex flex-col items-center gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
:href="trackerUrl"
|
||||
target="_blank"
|
||||
class="text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:text-primary-500 transition-colors"
|
||||
title="Ver en Flightradar24"
|
||||
@click.stop
|
||||
>
|
||||
<span v-if="airlineName" class="font-medium">{{ airlineName }} · </span>
|
||||
<span>{{ segment.company.code }} {{ segment.number }}</span>
|
||||
<UIcon name="i-lucide-radar" class="inline ml-0.5 text-[10px] opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700 relative">
|
||||
<UIcon name="i-lucide-plane" class="absolute -top-2 left-1/2 -translate-x-1/2 text-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrival -->
|
||||
<div class="text-center w-20">
|
||||
<p class="text-xl font-semibold">{{ formatTime(segment.arrivalDate) }}</p>
|
||||
<p class="text-sm font-medium">{{ segment.arrivalCode }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ segment.arrivalCity }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Price (links to one-way search) -->
|
||||
<NuxtLink :to="segmentLink" class="shrink-0 text-right w-16 hover:opacity-80 transition-opacity" @click.stop>
|
||||
<template v-if="loadingPrice">
|
||||
<USkeleton class="h-5 w-12 ml-auto" />
|
||||
</template>
|
||||
<template v-else-if="segmentPrice != null">
|
||||
<p class="text-sm font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ segmentPrice.toFixed(0) }}€
|
||||
</p>
|
||||
<p class="text-xs text-muted">solo ida</p>
|
||||
</template>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Flight tracker (expandable) -->
|
||||
<div class="pl-24 -mt-1 mb-1">
|
||||
<DetailFlightTracker :flight-code="flightCode" />
|
||||
</div>
|
||||
|
||||
<!-- Booking links -->
|
||||
<div class="pl-24 -mt-1 mb-2 flex items-center gap-2">
|
||||
<a
|
||||
:href="bookingUrl"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-ticket" class="text-sm" />
|
||||
Reservar vuelo
|
||||
</a>
|
||||
<a
|
||||
v-if="airlineWebsite"
|
||||
:href="airlineWebsite"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-globe" class="text-sm" />
|
||||
Web de {{ airlineName || segment.company.code }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
31
app/components/detail/ShareButton.vue
Normal file
31
app/components/detail/ShareButton.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
price: number
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function share() {
|
||||
const url = window.location.href
|
||||
const text = `${props.title} - ${props.price.toFixed(0)}€`
|
||||
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title: text, url })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(`${text}\n${url}`)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
:label="copied ? 'Copiado!' : 'Compartir'"
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'"
|
||||
:color="copied ? 'success' : 'neutral'"
|
||||
variant="outline"
|
||||
@click="share"
|
||||
/>
|
||||
</template>
|
||||
52
app/components/detail/WatchlistToggle.vue
Normal file
52
app/components/detail/WatchlistToggle.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
bookingToken: string
|
||||
routeSummary: string
|
||||
departureCode: string
|
||||
arrivalCode: string
|
||||
departureDate: string
|
||||
price: number
|
||||
passengers: { adult: number; child: number; infant: number }
|
||||
}>()
|
||||
|
||||
const user = useSupabaseUser()
|
||||
const { isWatched, getWatchedItem, add, remove } = useWatchlist()
|
||||
|
||||
const toggling = ref(false)
|
||||
const watched = computed(() => isWatched(props.bookingToken))
|
||||
|
||||
async function toggle() {
|
||||
if (!user.value) {
|
||||
await navigateTo('/auth')
|
||||
return
|
||||
}
|
||||
|
||||
toggling.value = true
|
||||
if (watched.value) {
|
||||
const item = getWatchedItem(props.bookingToken)
|
||||
if (item) await remove(item.id)
|
||||
} else {
|
||||
await add({
|
||||
bookingToken: props.bookingToken,
|
||||
routeSummary: props.routeSummary,
|
||||
departureCode: props.departureCode,
|
||||
arrivalCode: props.arrivalCode,
|
||||
departureDate: props.departureDate,
|
||||
price: props.price,
|
||||
passengers: props.passengers
|
||||
})
|
||||
}
|
||||
toggling.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
:icon="watched ? 'i-lucide-heart' : 'i-lucide-heart'"
|
||||
:color="watched ? 'error' : 'neutral'"
|
||||
:variant="watched ? 'soft' : 'ghost'"
|
||||
:loading="toggling"
|
||||
size="sm"
|
||||
@click.stop="toggle"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user