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
parent 72271d1de2
commit e9be428097
9 changed files with 248 additions and 191 deletions

View File

@@ -1,17 +1,17 @@
<script setup lang="ts">
import { useMoviesStore } from '@/stores/movies'
import { useMediaStore } from '@/stores/movies'
import { computed, ref } from 'vue'
import type { MovieType } from '@/types/Movie'
import type { MediaType } from '@/types/Media'
const props = defineProps<{
movie: MovieType
movie: MediaType
}>()
const store = useMoviesStore()
const store = useMediaStore()
const imageLoadFailed = 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(() => {
if (!props.movie.PosterPath) {
@@ -27,7 +27,9 @@ const imageSource = computed(() => {
v-motion-fade-visible-once
>
<!-- 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">
<span v-if="!loaded" class="loading loading-ring loading-lg text-primary"></span>
@@ -66,7 +68,9 @@ const imageSource = computed(() => {
<!-- Body -->
<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
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-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"
@click="store.addMovie(props.movie)"
@click="store.addMedia(props.movie)"
>
Add
</button>
@@ -88,7 +92,7 @@ const imageSource = computed(() => {
v-motion-fade-visible-once
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"
@click="store.removeMovie(props.movie.Id)"
@click="store.removeMedia(props.movie.Id)"
>
Remove
</button>

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>

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>

View File

@@ -1,10 +1,10 @@
<script setup lang="ts">
import AddMoreCard from './AddMoreCard.vue'
import MovieCard from './MovieCard.vue'
import type { MovieType } from '@/types/Movie'
import MediaCard from './MediaCard.vue'
import type { MediaType } from '@/types/Media'
const props = defineProps<{
movies: MovieType[]
movies: MediaType[]
loadingMore: 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"
>
<li v-for="movie in props.movies" :key="movie.Id" v-auto-animate>
<MovieCard :movie="movie" />
<MediaCard :movie="movie" />
</li>
<li v-if="!props.isSearch">
<AddMoreCard />

View File

@@ -1,5 +1,9 @@
import type { MediaResponseType } from '@/types/Media'
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'
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> => {
const response = await instance.get(`/search/multi`, {
const response = await instance.get(`/search/multi`, {
params: {
query: query,
include_adult: true,
page: page
page: page,
},
})
@@ -81,22 +85,26 @@ export const loadMoreMovies = async (query: string, page: number): Promise<Media
return data
}
export const getMovieDetails = async (id: string) => {
const response = await instance.get(`movie/${id}`, {
})
export const getMovieDetails = async (id: string): Promise<MovieDetailsType | null> => {
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) => {
const response = await instance.get(``, {
params: {
i: id,
plot: 'full',
},
})
export const getSeriesDetails = async (id: string): Promise<TvSeriesDetailsType | null> => {
const response = await instance.get(`/tv/${id}`, {})
return response.data
if (response.status !== 200) {
return null
}
const data: TvSeriesDetailsType = mapTvSeriesDetails(response.data)
return data
}

View File

@@ -1,45 +1,43 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { MovieType } from '@/types/Movie'
import type { MediaType } from '@/types/Media'
function saveMovies(movies: MovieType[]) {
localStorage.setItem('movies', JSON.stringify(movies))
function saveMedias(medias: MediaType[]) {
localStorage.setItem('medias', JSON.stringify(medias))
}
function loadMovies(): MovieType[] {
const movies = localStorage.getItem('movies')
return movies ? JSON.parse(movies) : []
function loadMedias(): MediaType[] {
const medias = localStorage.getItem('medias')
return medias ? JSON.parse(medias) : []
}
export const useMoviesStore = defineStore('movies', () => {
const movieList = ref<MovieType[]>(loadMovies())
export const useMediaStore = defineStore('medias', () => {
const mediaList = ref<MediaType[]>(loadMedias())
function addMovie(movie: MovieType) {
if (!movieList.value.find((m) => m.Id === movie.Id)) {
movieList.value.push(movie)
saveMovies(movieList.value)
function addMedia(media: MediaType) {
if (!mediaList.value.find((m) => m.Id === media.Id)) {
mediaList.value.push(media)
saveMedias(mediaList.value)
}
}
function removeMovie(movieId: number) {
movieList.value = movieList.value.filter((m) => m.Id !== movieId)
saveMovies(movieList.value)
function removeMedia(mediaId: number) {
mediaList.value = mediaList.value.filter((m) => m.Id !== mediaId)
saveMedias(mediaList.value)
}
return { movieList, addMovie, removeMovie }
return { mediaList, addMedia, removeMedia }
})
// TODO: add state for search page
export const useSearchPageStore = defineStore('searchPage', () => {
const movieList = ref<MovieType[]>()
const mediaList = ref<MediaType[]>()
const searchPage = ref(0)
const searchQuery = ref('')
function setState(page: number, query: string, movies: MovieType[]) {
function setState(page: number, query: string, medias: MediaType[]) {
searchPage.value = page
searchQuery.value = query
movieList.value = movies
mediaList.value = medias
}
return { searchPage, searchQuery, movieList, setState }
return { searchPage, searchQuery, mediaList, setState }
})

View File

@@ -3,11 +3,11 @@ import { loadMoreMovies, searchMovies } from '@/lib/api'
import { onMounted, ref, watch } from 'vue'
import SearchBar from '@/components/SearchBar.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 { useSearchPageStore } from '@/stores/movies'
const movies = ref<MovieType[]>()
const movies = ref<MediaType[]>()
const seachError = ref<string>('')
const searchQuery = ref('')
const searchPage = ref(1)
@@ -59,12 +59,12 @@ onMounted(() => {
if (
state.searchQuery !== '' &&
state.searchPage !== 0 &&
state.movieList !== undefined &&
state.movieList.length > 0
state.mediaList !== undefined &&
state.mediaList.length > 0
) {
searchQuery.value = state.searchQuery
searchPage.value = state.searchPage
movies.value = state.movieList
movies.value = state.mediaList
}
})

View File

@@ -1,40 +1,45 @@
<script setup lang="ts">
import MovieDetails from '@/components/MovieDetails.vue'
import { getMovie } from '@/lib/api'
import MediaDetails from '@/components/MediaDetails.vue'
import { getMovieDetails, getSeriesDetails } from '@/lib/api'
import type { MovieDetailsType } from '@/types/Movie'
import type { TvSeriesDetailsType } from '@/types/TvSeries'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const movie = ref<MovieDetailsType>()
const movie = ref<MovieDetailsType | null>()
const tvSeries = ref<TvSeriesDetailsType | null>()
onMounted(async () => {
try {
const response = await getMovie(route.params.id as string)
movie.value = response
if (route.params.id && route.params.type === 'movie') {
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) {
console.error(error)
movie.value = {
Title: '',
Year: '',
imdbID: '',
Type: '',
Poster: '',
Plot: '',
Language: '',
Country: '',
imdbRating: '',
Error: true,
ErrorMessage: (error as Error).message,
}
}
})
</script>
<template>
<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>
</template>

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { useMoviesStore } from '@/stores/movies'
import { useMediaStore } from '@/stores/movies'
import MovieList from '@/components/MovieList.vue'
const store = useMoviesStore()
const store = useMediaStore()
</script>
<template>
@@ -14,8 +14,8 @@ const store = useMoviesStore()
</h1>
<MovieList
v-auto-animate
v-if="store.movieList"
:movies="store.movieList"
v-if="store.mediaList"
:movies="store.mediaList"
:loading-more="false"
:is-search="false"
/>