feature/search-prototype (#1)

### Description
This pull request migrates to Nuxt 4.0.0, adds docker, and uses Orval and OpenAPI 3.0 specification to interact with backend

Reviewed-on: #1
Co-authored-by: bivashy <botyrbojey@gmail.com>
Co-committed-by: bivashy <botyrbojey@gmail.com>
This commit is contained in:
2025-07-19 18:05:51 +00:00
committed by bivashy
parent 0bf60ad783
commit 57b071d47f
71 changed files with 2977 additions and 737 deletions

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { Button } from '~/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '~/components/ui/dialog';
import { kodik, type KodikTranslation } from '~/openapi/extractor';
const route = useRoute();
const animeId = ref(route.params.slug as string)
const results = ref<KodikTranslation[]>([])
const isLoading = ref(false)
const error = ref<unknown>(null)
const isSelectingEpisode = ref(false)
const currentSelectionItem = ref<KodikTranslation | null>(null)
watchEffect(async () => {
if (!animeId.value) return
try {
isLoading.value = true
error.value = null
const response = await kodik({ id: animeId.value })
results.value = response?.data?.translations || []
} catch (err) {
error.value = err
console.error('Failed to fetch anime details:', err)
} finally {
isLoading.value = false
}
})
function episodesSelect(item: KodikTranslation) {
currentSelectionItem.value = item
isSelectingEpisode.value = true
}
function closeDialog() {
console.log('Dialog closed')
isSelectingEpisode.value = false
}
</script>
<template>
<div v-for="item in results" :key="item.id" class="flex w-[32rem]">
<div @click="episodesSelect(item)">
<span>{{ item.title }}</span>
<span>{{ item.episodeCount }}</span>
</div>
</div>
<p>{{ route.params.slug }}</p>
<Dialog v-bind:open="isSelectingEpisode">
<DialogTrigger as-child>
<Button variant="outline">
Select episode
</Button>
</DialogTrigger>
<DialogContent class="sm:max-w-[425px] max-h-[90dvh] overflow-y-auto" :close="closeDialog">
<DialogHeader>
<DialogTitle>Select episode</DialogTitle>
</DialogHeader>
<div class="grid gap-4 py-4">
<div v-for="n in currentSelectionItem?.episodeCount || 0" :key="n"
class="flex items-center justify-between">
<NuxtLink
:to="{ path: '/watch', query: { mediaType: currentSelectionItem?.mediaType, mediaId: currentSelectionItem?.mediaId, mediaHash: currentSelectionItem?.mediaHash, episode: n } }"
class="flex items-center justify-between gap-2 w-full">
<span>Episode {{ n }}</span>
<Button variant="outline" @click="closeDialog">Watch</Button>
</NuxtLink>
</div>
</div>
</DialogContent>
</Dialog>
</template>

82
app/pages/index.vue Normal file
View File

@ -0,0 +1,82 @@
<script setup lang="ts">
import kodikImage from "assets/img/kodik.png";
import shikimoriImage from "assets/img/shikimori.png";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from "~/components/ui/select";
import { toast } from "vue-sonner";
import 'vue-sonner/style.css'
import { Icon } from "#components";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
const router = useRouter()
const searchProvider = ref('kodik')
const displaySearchProvider = computed(() => {
let label = '';
let image;
if (searchProvider.value === 'kodik') {
label = 'Kodik';
image = kodikImage;
}
if (searchProvider.value === 'shikimori') {
label = 'Shikimori';
image = shikimoriImage;
}
return {
label,
image
}
})
const search = defineModel<string>("")
function querySearch() {
if (!search.value || search.value.trim() === "") {
toast('Please enter a value');
return;
}
router.push({ path: '/search', query: { title: search.value, provider: searchProvider.value } })
.catch(err => {
console.error('Navigation error:', err);
toast.error('Failed to navigate to search results');
});
}
</script>
<template>
<div class="bg-background flex-1 w-screen flex justify-center items-center gap-1">
<form @submit.prevent="querySearch" class="flex items-center gap-2">
<Select v-model="searchProvider">
<SelectTrigger>
<SelectValue placeholder="Select an provider">
<img :src="displaySearchProvider.image" alt="" class="w-5 h-5 rounded border border-gray-300">
<span>{{ displaySearchProvider.label }}</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Providers</SelectLabel>
<SelectItem value="kodik">
<img :src="kodikImage" alt="" class="w-5 h-5 rounded border border-gray-300">
<span>Kodik</span>
</SelectItem>
<SelectItem value="shikimori">
<img :src="shikimoriImage" alt="" class="w-5 h-5 rounded border border-gray-300">
Shikimori
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Input v-model="search" class="w-64" type="text" placeholder="Search anime..." />
<Button type="submit">
<Icon name="heroicons:magnifying-glass-16-solid" class="w-6 h-6" />
</Button>
</form>
</div>
</template>

52
app/pages/search.vue Normal file
View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import { AnimeCard } from '~/components/ui/anime-card'
import { search, type Result } from '~/openapi/search'
const route = useRoute()
const searchQuery = ref(route.query.title as string || '')
const results = ref<Result[]>([])
const isLoading = ref(false)
const error = ref<unknown>(null)
watchEffect(async () => {
if (!searchQuery.value) return
try {
isLoading.value = true
error.value = null
const response = await search({ title: searchQuery.value })
results.value = response.data.results || []
} catch (err) {
error.value = err
console.error('Search failed:', err)
} finally {
isLoading.value = false
}
}, {
flush: 'post'
})
</script>
<template>
<div>
<div v-if="isLoading">
Loading results...
</div>
<div v-if="error">
Error loading results: {{ error }}
</div>
<div v-if="results.length > 0"
class="grid grid-cols-[repeat(auto-fill,16rem)] justify-around gap-8 grid-flow-row">
<div v-for="item in results" :key="item.id" class="flex w-[16rem]">
<AnimeCard :item="item" />
</div>
</div>
<div v-if="!isLoading && !error && results.length === 0 && searchQuery">
No results found for "{{ searchQuery }}"
</div>
</div>
</template>

99
app/pages/watch.vue Normal file
View File

@ -0,0 +1,99 @@
<template>
<div>
<h1>Watch Page</h1>
<div v-if="mediaId">
<p>Media Type: {{ mediaType }}</p>
<p>Media ID: {{ mediaId }}</p>
<p>Media Hash: {{ mediaHash }}</p>
<p>Episode: {{ episode }}</p>
<div v-if="hlsUrl">
<Player :id="mediaId.toString()" :src="hlsUrl" />
</div>
<!-- Loading and Error States -->
<div v-if="isLoading" class="loading">Loading video...</div>
<div v-if="error" class="error">Error loading video: {{ error }}</div>
</div>
<div v-else>
<p>No media selected.</p>
</div>
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { Player } from '~/components/ui/player'
import { video, type KodikTranslationDTO, type KodikVideoLinks, type VideoParams } from '~/openapi/extractor'
const route = useRoute()
const mediaType = route.query.mediaType
const mediaId = route.query.mediaId
const mediaHash = route.query.mediaHash
const episode = route.query.episode
const results = ref<KodikVideoLinks | null>(null)
const isLoading = ref(false)
const error = ref<unknown>(null)
const hlsUrl = ref<string | null>(null)
const playerOptions = ref({
autoplay: false,
controls: true,
responsive: true,
fluid: true,
sources: [{
src: hlsUrl.value,
type: 'application/x-mpegURL'
}]
})
watchEffect(async () => {
if (!mediaType || !mediaId || !mediaHash || !episode) return
try {
isLoading.value = true
error.value = null
const translationDto: KodikTranslationDTO = {
mediaType: mediaType as string,
mediaId: mediaId as string,
mediaHash: mediaHash as string,
}
const videoParams: VideoParams = {
translationDTO: translationDto,
episode: Number(episode as string),
quality: '360',
}
const response = await video(videoParams)
results.value = response?.data || null
if (results.value?.links) {
const qualities = Object.keys(results.value.links)
const bestQuality = qualities.includes('360') ? '360' :
qualities[0]
const hlsLink = results.value.links[bestQuality ?? '360']?.find(link =>
link.type?.includes('hls') || link.src?.includes('.m3u8')
)
console.log(bestQuality)
if (hlsLink?.src) {
hlsUrl.value = hlsLink.src
if (playerOptions.value?.sources[0]) {
playerOptions.value.sources[0].src = hlsUrl.value
}
} else {
throw new Error('No HLS stream found in response')
}
}
} catch (err) {
error.value = err
console.error('Failed to fetch video:', err)
} finally {
isLoading.value = false
}
})
</script>