refactor: rename MovieCard to MediaCard and update to support multi-media types

- Rename MovieCard.vue to MediaCard.vue and update all related references
- Add MediaDetails.vue component to handle both movie and TV series details
- Remove old MovieDetails.vue component
- Update MovieList.vue to use MediaCard instead of MovieCard
- Modify API functions to handle both movie and TV series details
- Update store from useMoviesStore to useMediaStore with new naming conventions
- Update type references from MovieType to MediaType
- Add support for TV series details in DetailsView.vue

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2025-10-22 22:27:01 +03:30
co-authored by Qwen-Coder
parent 72271d1de2
commit e9be428097
9 changed files with 248 additions and 191 deletions
@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMoviesStore } from '@/stores/movies' import { useMediaStore } from '@/stores/movies'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { MovieType } from '@/types/Movie' import type { MediaType } from '@/types/Media'
const props = defineProps<{ const props = defineProps<{
movie: MovieType movie: MediaType
}>() }>()
const store = useMoviesStore() const store = useMediaStore()
const imageLoadFailed = ref(false) const imageLoadFailed = ref(false)
const loaded = ref(false) const loaded = ref(false)
const alreadyAdded = computed(() => store.movieList.some((movie) => movie.Id === props.movie.Id)) const alreadyAdded = computed(() => store.mediaList.some((media) => media.Id === props.movie.Id))
const imageSource = computed(() => { const imageSource = computed(() => {
if (!props.movie.PosterPath) { if (!props.movie.PosterPath) {
@@ -27,7 +27,9 @@ const imageSource = computed(() => {
v-motion-fade-visible-once v-motion-fade-visible-once
> >
<!-- Poster --> <!-- Poster -->
<router-link :to="{ name: 'details', params: { type: props.movie.MediaType, id: props.movie.Id } }"> <router-link
:to="{ name: 'details', params: { type: props.movie.MediaType, id: props.movie.Id } }"
>
<figure class="overflow-hidden flex items-center justify-center aspect-[2/3] bg-gray-50"> <figure class="overflow-hidden flex items-center justify-center aspect-[2/3] bg-gray-50">
<span v-if="!loaded" class="loading loading-ring loading-lg text-primary"></span> <span v-if="!loaded" class="loading loading-ring loading-lg text-primary"></span>
@@ -66,7 +68,9 @@ const imageSource = computed(() => {
<!-- Body --> <!-- Body -->
<div class="card-body p-4 flex flex-col"> <div class="card-body p-4 flex flex-col">
<router-link :to="{ name: 'details', params: { type: props.movie.MediaType, id: props.movie.Id } }"> <router-link
:to="{ name: 'details', params: { type: props.movie.MediaType, id: props.movie.Id } }"
>
<h2 <h2
class="card-title text-base font-semibold bg-gradient-to-r from-gray-700 to-gray-500 bg-clip-text text-transparent" class="card-title text-base font-semibold bg-gradient-to-r from-gray-700 to-gray-500 bg-clip-text text-transparent"
> >
@@ -80,7 +84,7 @@ const imageSource = computed(() => {
v-motion-fade-visible-once v-motion-fade-visible-once
v-if="!alreadyAdded" v-if="!alreadyAdded"
class="btn btn-sm px-4 bg-gradient-to-r from-gray-100 to-gray-200 border border-gray-300 text-gray-700 hover:from-gray-200 hover:to-gray-300 hover:text-gray-900 transition" class="btn btn-sm px-4 bg-gradient-to-r from-gray-100 to-gray-200 border border-gray-300 text-gray-700 hover:from-gray-200 hover:to-gray-300 hover:text-gray-900 transition"
@click="store.addMovie(props.movie)" @click="store.addMedia(props.movie)"
> >
Add Add
</button> </button>
@@ -88,7 +92,7 @@ const imageSource = computed(() => {
v-motion-fade-visible-once v-motion-fade-visible-once
v-else v-else
class="btn btn-sm px-4 bg-gradient-to-r from-red-50 to-red-100 border border-red-200 text-red-600 hover:from-red-100 hover:to-red-200 hover:text-red-700 transition" class="btn btn-sm px-4 bg-gradient-to-r from-red-50 to-red-100 border border-red-200 text-red-600 hover:from-red-100 hover:to-red-200 hover:text-red-700 transition"
@click="store.removeMovie(props.movie.Id)" @click="store.removeMedia(props.movie.Id)"
> >
Remove Remove
</button> </button>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { MovieDetailsType } from '@/types/Movie'
import type { TvSeriesDetailsType } from '@/types/TvSeries'
import { computed , ref } from 'vue'
import { useMediaStore } from '@/stores/movies'
import type { MediaType } from '@/types/Media'
const props = defineProps<{
type: 'movie' | 'tv'
movie?: MovieDetailsType | null
tvSeries?: TvSeriesDetailsType | null
}>()
const imageLoaded = ref(false)
const store = useMediaStore()
const alreadyAdded = computed(() =>
props.type === 'movie' && props.movie
? store.mediaList.some((m: MediaType) => m.Id === props.movie!.Id)
: props.type === 'tv' && props.tvSeries
? store.mediaList.some((ts: MediaType) => ts.Id === props.tvSeries!.Id)
: false,
)
</script>
<template>
<!-- Error -->
<!-- <ErrorAlert
v-if="props.type === 'movie' && props.movie?.Error"
:message="props.movie.ErrorMessage"
/> -->
<!-- Hero -->
<div
class="flex flex-col lg:flex-row gap-12 items-center max-w-6xl w-full bg-white/70 backdrop-blur-md border border-gray-200/60 shadow-md rounded-xl p-8 transition"
v-motion-fade-visible-once
>
<!-- Poster -->
<figure class="flex-shrink-0">
<span v-if="!imageLoaded" class="loading loading-ring loading-lg text-primary"></span>
<img
v-show="imageLoaded"
:src="
type === 'movie'
? 'https://image.tmdb.org/t/p/w500' + props.movie!.PosterPath
: 'https://image.tmdb.org/t/p/w500' + props.tvSeries!.PosterPath
"
alt="Poster"
class="w-full max-w-sm rounded-lg shadow-lg transform transition-transform duration-500 hover:scale-105"
@load="imageLoaded = true"
/>
</figure>
<!-- Text -->
<div class="flex-1">
<h1 class="text-4xl font-bold text-gray-800 mb-2">
<template v-if="props.type === 'movie'">
{{ props.movie!.Title }}
<span class="text-gray-400 text-lg font-normal"
>({{ props.movie!.ReleaseDate?.slice(0, 4) }})</span
>
</template>
<template v-else>
{{ props.tvSeries!.Name }}
<span class="text-gray-400 text-lg font-normal"
>({{ props.tvSeries!.FirstAirDate?.slice(0, 4) }})</span
>
</template>
</h1>
<p v-if="props.type === 'movie' && props.movie!.Tagline" class="italic text-gray-500 mb-2">
{{ props.movie!.Tagline }}
</p>
<p v-if="props.type === 'tv' && props.tvSeries!.Tagline" class="italic text-gray-500 mb-2">
{{ props.tvSeries!.Tagline }}
</p>
<p class="text-gray-600 leading-relaxed mb-6">
{{ props.type === 'movie' ? props.movie!.Overview : props.tvSeries!.Overview }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 mb-6">
<span
v-for="g in props.type === 'movie' ? props.movie!.Genres : props.tvSeries!.Genres"
:key="g.id"
class="px-3 py-1 rounded-md text-sm bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 border border-gray-300"
>
{{ g.name }}
</span>
<span
v-if="props.type === 'movie'"
class="px-3 py-1 rounded-md text-sm bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 border border-gray-300"
>
Runtime: {{ props.movie!.Runtime }} min
</span>
<span
v-if="props.type === 'tv'"
class="px-3 py-1 rounded-md text-sm bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 border border-gray-300"
>
Seasons: {{ props.tvSeries!.NumberOfSeasons }}
</span>
</div>
<!-- Actions -->
<!-- TODO: Fix this -->
<div class="card-actions" v-auto-animate>
<!-- <template v-if="props.type === 'movie' && props.movie">
<button
v-if="!alreadyAdded"
class="btn px-6 bg-gradient-to-r from-gray-100 to-gray-200 border border-gray-300 text-gray-700 hover:from-gray-200 hover:to-gray-300"
@click="store.addMedia(props.movie)"
>
Add to list
</button>
<button
v-else
class="btn px-6 bg-gradient-to-r from-red-50 to-red-100 border border-red-200 text-red-600 hover:from-red-100 hover:to-red-200"
@click="store.removeMedia(props.movie.ImdbId)"
>
Remove from library
</button>
</template> -->
<RouterLink
:to="{
name: 'watch',
params: {
name: props.type === 'movie' ? props.movie!.Title : props.tvSeries!.Name,
id: props.type === 'movie' ? props.movie!.Id : props.tvSeries!.Id,
},
}"
>
<button
class="btn relative flex items-center gap-2 px-6 bg-gradient-to-r from-indigo-500 to-violet-500 text-white border-0 shadow-md hover:opacity-90 transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M6.5 5.5a1 1 0 0 1 1.52-.85l6 4.5a1 1 0 0 1 0 1.7l-6 4.5A1 1 0 0 1 6.5 14.5v-9z"
clip-rule="evenodd"
/>
</svg>
<span>Watch</span>
<span class="badge badge-sm badge-secondary absolute -top-2 -right-2">Beta</span>
</button>
</RouterLink>
</div>
</div>
</div>
</template>
-114
View File
@@ -1,114 +0,0 @@
<script setup lang="ts">
import type { MovieDetailsType } from '@/types/Movie'
import { computed, ref } from 'vue'
import ErrorAlert from './alerts/ErrorAlert.vue'
import { useMoviesStore } from '@/stores/movies'
const props = defineProps<{ movie: MovieDetailsType | undefined }>()
const imageLoaded = ref(false)
const store = useMoviesStore()
const alreadyAdded = computed(() =>
props.movie ? store.movieList.some((movie) => movie.imdbID === props.movie!.imdbID) : false,
)
</script>
<template>
<ErrorAlert v-if="props.movie?.Error" :message="props.movie.ErrorMessage" />
<!-- Loading state -->
<div v-if="props.movie === undefined" class="flex justify-center items-center">
<span class="loading loading-ring loading-lg text-primary"></span>
</div>
<!-- Hero content -->
<div
v-else
class="flex flex-col lg:flex-row gap-12 items-center max-w-6xl w-full bg-white/70 backdrop-blur-md border border-gray-200/60 shadow-md rounded-xl p-8 transition"
v-motion-fade-visible-once
>
<!-- Poster -->
<figure class="flex-shrink-0">
<span v-if="!imageLoaded" class="loading loading-ring loading-lg text-primary"></span>
<img
v-show="imageLoaded"
:src="props.movie.Poster"
alt="Poster"
class="w-full max-w-sm rounded-lg shadow-lg transform transition-transform duration-500 hover:scale-105"
@load="imageLoaded = true"
/>
</figure>
<!-- Text -->
<div class="flex-1">
<h1 class="text-4xl font-bold text-gray-800 mb-4">
{{ props.movie.Title }}
<span class="text-gray-400 text-lg font-normal">({{ props.movie.Year }})</span>
</h1>
<p class="text-gray-600 leading-relaxed mb-6">{{ props.movie.Plot }}</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 mb-6">
<span
class="px-3 py-1 rounded-md text-sm bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 border border-gray-300"
>
Language: {{ props.movie.Language }}
</span>
<span
class="px-3 py-1 rounded-md text-sm bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 border border-gray-300"
>
Country: {{ props.movie.Country }}
</span>
<span
class="px-3 py-1 rounded-md text-sm bg-gradient-to-r from-gray-100 to-gray-200 text-gray-700 border border-gray-300"
>
IMDB Rating: {{ props.movie.imdbRating }}
</span>
</div>
<!-- Actions -->
<div class="card-actions" v-auto-animate>
<button
v-if="!alreadyAdded"
class="btn px-6 bg-gradient-to-r from-gray-100 to-gray-200 border border-gray-300 text-gray-700 hover:from-gray-200 hover:to-gray-300"
@click="store.addMovie(props.movie)"
>
Add to list
</button>
<button
v-else
class="btn px-6 bg-gradient-to-r from-red-50 to-red-100 border border-red-200 text-red-600 hover:from-red-100 hover:to-red-200"
@click="store.removeMovie(props.movie.imdbID)"
>
Remove from library
</button>
<RouterLink
:to="{ name: 'watch', params: { name: props.movie.Title, id: props.movie.imdbID } }"
>
<button
class="btn relative flex items-center gap-2 px-6 bg-gradient-to-r from-indigo-500 to-violet-500 text-white border-0 shadow-md hover:opacity-90 transition"
>
<!-- Icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M6.5 5.5a1 1 0 0 1 1.52-.85l6 4.5a1 1 0 0 1 0 1.7l-6 4.5A1 1 0 0 1 6.5 14.5v-9z"
clip-rule="evenodd"
/>
</svg>
<span>Watch</span>
<span class="badge badge-sm badge-secondary absolute -top-2 -right-2">Beta</span>
</button>
</RouterLink>
</div>
</div>
</div>
</template>
+4 -4
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import AddMoreCard from './AddMoreCard.vue' import AddMoreCard from './AddMoreCard.vue'
import MovieCard from './MovieCard.vue' import MediaCard from './MediaCard.vue'
import type { MovieType } from '@/types/Movie' import type { MediaType } from '@/types/Media'
const props = defineProps<{ const props = defineProps<{
movies: MovieType[] movies: MediaType[]
loadingMore: boolean loadingMore: boolean
isSearch: boolean isSearch: boolean
}>() }>()
@@ -30,7 +30,7 @@ const emit = defineEmits<{ (e: 'loaded', id: string): void; (e: 'loadMore'): voi
class="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5" class="grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5"
> >
<li v-for="movie in props.movies" :key="movie.Id" v-auto-animate> <li v-for="movie in props.movies" :key="movie.Id" v-auto-animate>
<MovieCard :movie="movie" /> <MediaCard :movie="movie" />
</li> </li>
<li v-if="!props.isSearch"> <li v-if="!props.isSearch">
<AddMoreCard /> <AddMoreCard />
+22 -14
View File
@@ -1,5 +1,9 @@
import type { MediaResponseType } from '@/types/Media' import type { MediaResponseType } from '@/types/Media'
import { mapMedia } from '@/types/MediaMap' import { mapMedia } from '@/types/MediaMap'
import type { MovieDetailsType } from '@/types/Movie'
import { mapMovieDetails } from '@/types/MovieMap'
import type { TvSeriesDetailsType } from '@/types/TvSeries'
import { mapTvSeriesDetails } from '@/types/TvSeriesMap'
import axios from 'axios' import axios from 'axios'
const TMDB_READ_API_KEY = const TMDB_READ_API_KEY =
@@ -48,11 +52,11 @@ export const searchMovies = async (query: string): Promise<MediaResponseType> =>
} }
export const loadMoreMovies = async (query: string, page: number): Promise<MediaResponseType> => { export const loadMoreMovies = async (query: string, page: number): Promise<MediaResponseType> => {
const response = await instance.get(`/search/multi`, { const response = await instance.get(`/search/multi`, {
params: { params: {
query: query, query: query,
include_adult: true, include_adult: true,
page: page page: page,
}, },
}) })
@@ -81,22 +85,26 @@ export const loadMoreMovies = async (query: string, page: number): Promise<Media
return data return data
} }
export const getMovieDetails = async (id: string) => { export const getMovieDetails = async (id: string): Promise<MovieDetailsType | null> => {
const response = await instance.get(`movie/${id}`, { const response = await instance.get(`/movie/${id}`, {})
})
if (response.status !== 200) {
return null
}
const data: MovieDetailsType = mapMovieDetails(response.data)
return response.data return data
} }
export const getSeriesDetails = async (id: string) => { export const getSeriesDetails = async (id: string): Promise<TvSeriesDetailsType | null> => {
const response = await instance.get(``, { const response = await instance.get(`/tv/${id}`, {})
params: {
i: id,
plot: 'full',
},
})
return response.data if (response.status !== 200) {
return null
}
const data: TvSeriesDetailsType = mapTvSeriesDetails(response.data)
return data
} }
+20 -22
View File
@@ -1,45 +1,43 @@
import { ref } from 'vue' import { ref } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { MovieType } from '@/types/Movie' import type { MediaType } from '@/types/Media'
function saveMovies(movies: MovieType[]) { function saveMedias(medias: MediaType[]) {
localStorage.setItem('movies', JSON.stringify(movies)) localStorage.setItem('medias', JSON.stringify(medias))
} }
function loadMovies(): MovieType[] { function loadMedias(): MediaType[] {
const movies = localStorage.getItem('movies') const medias = localStorage.getItem('medias')
return movies ? JSON.parse(movies) : [] return medias ? JSON.parse(medias) : []
} }
export const useMoviesStore = defineStore('movies', () => { export const useMediaStore = defineStore('medias', () => {
const movieList = ref<MovieType[]>(loadMovies()) const mediaList = ref<MediaType[]>(loadMedias())
function addMovie(movie: MovieType) { function addMedia(media: MediaType) {
if (!movieList.value.find((m) => m.Id === movie.Id)) { if (!mediaList.value.find((m) => m.Id === media.Id)) {
movieList.value.push(movie) mediaList.value.push(media)
saveMovies(movieList.value) saveMedias(mediaList.value)
} }
} }
function removeMovie(movieId: number) { function removeMedia(mediaId: number) {
movieList.value = movieList.value.filter((m) => m.Id !== movieId) mediaList.value = mediaList.value.filter((m) => m.Id !== mediaId)
saveMovies(movieList.value) saveMedias(mediaList.value)
} }
return { movieList, addMovie, removeMovie } return { mediaList, addMedia, removeMedia }
}) })
// TODO: add state for search page
export const useSearchPageStore = defineStore('searchPage', () => { export const useSearchPageStore = defineStore('searchPage', () => {
const movieList = ref<MovieType[]>() const mediaList = ref<MediaType[]>()
const searchPage = ref(0) const searchPage = ref(0)
const searchQuery = ref('') const searchQuery = ref('')
function setState(page: number, query: string, movies: MovieType[]) { function setState(page: number, query: string, medias: MediaType[]) {
searchPage.value = page searchPage.value = page
searchQuery.value = query searchQuery.value = query
movieList.value = movies mediaList.value = medias
} }
return { searchPage, searchQuery, movieList, setState } return { searchPage, searchQuery, mediaList, setState }
}) })
+5 -5
View File
@@ -3,11 +3,11 @@ import { loadMoreMovies, searchMovies } from '@/lib/api'
import { onMounted, ref, watch } from 'vue' import { onMounted, ref, watch } from 'vue'
import SearchBar from '@/components/SearchBar.vue' import SearchBar from '@/components/SearchBar.vue'
import MovieList from '@/components/MovieList.vue' import MovieList from '@/components/MovieList.vue'
import type { MovieType } from '@/types/Movie' import type { MediaType } from '@/types/Media'
import ErrorAlert from '@/components/alerts/ErrorAlert.vue' import ErrorAlert from '@/components/alerts/ErrorAlert.vue'
import { useSearchPageStore } from '@/stores/movies' import { useSearchPageStore } from '@/stores/movies'
const movies = ref<MovieType[]>() const movies = ref<MediaType[]>()
const seachError = ref<string>('') const seachError = ref<string>('')
const searchQuery = ref('') const searchQuery = ref('')
const searchPage = ref(1) const searchPage = ref(1)
@@ -59,12 +59,12 @@ onMounted(() => {
if ( if (
state.searchQuery !== '' && state.searchQuery !== '' &&
state.searchPage !== 0 && state.searchPage !== 0 &&
state.movieList !== undefined && state.mediaList !== undefined &&
state.movieList.length > 0 state.mediaList.length > 0
) { ) {
searchQuery.value = state.searchQuery searchQuery.value = state.searchQuery
searchPage.value = state.searchPage searchPage.value = state.searchPage
movies.value = state.movieList movies.value = state.mediaList
} }
}) })
+24 -19
View File
@@ -1,40 +1,45 @@
<script setup lang="ts"> <script setup lang="ts">
import MovieDetails from '@/components/MovieDetails.vue' import MediaDetails from '@/components/MediaDetails.vue'
import { getMovie } from '@/lib/api' import { getMovieDetails, getSeriesDetails } from '@/lib/api'
import type { MovieDetailsType } from '@/types/Movie' import type { MovieDetailsType } from '@/types/Movie'
import type { TvSeriesDetailsType } from '@/types/TvSeries'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const movie = ref<MovieDetailsType>() const movie = ref<MovieDetailsType | null>()
const tvSeries = ref<TvSeriesDetailsType | null>()
onMounted(async () => { onMounted(async () => {
try { try {
const response = await getMovie(route.params.id as string) if (route.params.id && route.params.type === 'movie') {
movie.value = response movie.value = await getMovieDetails(route.params.id as string)
} else if (route.params.id && route.params.type === 'tv') {
tvSeries.value = await getSeriesDetails(route.params.id as string)
} else {
}
} catch (error) { } catch (error) {
console.error(error) console.error(error)
movie.value = {
Title: '',
Year: '',
imdbID: '',
Type: '',
Poster: '',
Plot: '',
Language: '',
Country: '',
imdbRating: '',
Error: true,
ErrorMessage: (error as Error).message,
}
} }
}) })
</script> </script>
<template> <template>
<div class="container mx-auto px-4 py-8"> <div class="container mx-auto px-4 py-8">
<MovieDetails :movie="movie" v-motion-fade-visible-once /> <div
v-if="(route.params.type === 'movie' && !movie) || (route.params.type === 'tv' && !tvSeries)"
class="flex justify-center items-center"
>
<span class="loading loading-ring loading-lg text-primary"></span>
</div>
<MediaDetails
v-if="movie || tvSeries"
:type="(route.params.type as 'movie' | 'tv') || undefined"
:movie="movie"
:tv-series="tvSeries"
v-motion-fade-visible-once
/>
</div> </div>
</template> </template>
+4 -4
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMoviesStore } from '@/stores/movies' import { useMediaStore } from '@/stores/movies'
import MovieList from '@/components/MovieList.vue' import MovieList from '@/components/MovieList.vue'
const store = useMoviesStore() const store = useMediaStore()
</script> </script>
<template> <template>
@@ -14,8 +14,8 @@ const store = useMoviesStore()
</h1> </h1>
<MovieList <MovieList
v-auto-animate v-auto-animate
v-if="store.movieList" v-if="store.mediaList"
:movies="store.movieList" :movies="store.mediaList"
:loading-more="false" :loading-more="false"
:is-search="false" :is-search="false"
/> />