Compare commits

...

5 Commits

8 changed files with 246 additions and 126 deletions

View File

@@ -23,7 +23,7 @@ const imageSource = computed(() => {
</script>
<template>
<figure class="overflow-hidden flex items-center justify-center bg-gray-50">
<figure class="overflow-hidden flex items-center justify-center bg-gray-50 aspect-2/3">
<span v-if="!loaded" class="loading loading-ring loading-lg text-primary"></span>
<div v-else-if="imageLoadFailed" class="flex items-center justify-center">
@@ -44,6 +44,7 @@ const imageSource = computed(() => {
</div>
<img
v-motion-fade-visible-once
v-show="loaded && !imageLoadFailed"
:src="imageSource"
:alt="props.alt"

View File

@@ -3,6 +3,7 @@ import { useMediaStore } from '@/stores/media'
import { computed } from 'vue'
import type { MediaType } from '@/types/Media'
import ImageWithFallback from './ImageWithFallback.vue'
import ScoreRing from './ScoreRing.vue'
const props = defineProps<{
media: MediaType
@@ -20,14 +21,13 @@ const alreadyAdded = computed(() => store.mediaList.some((media) => media.Id ===
<template>
<div
class="card relative w-full h-full overflow-hidden bg-white/70 backdrop-blur-md border border-gray-200/60 shadow-md hover:shadow-xl transition-all duration-300"
class="card relative w-full h-full overflow-hidden bg-white/70 backdrop-blur-md border border-gray-200/60 shadow-xs hover:shadow-xl transition-all duration-300"
v-motion-fade-visible-once
>
<!-- Poster wrapper with fixed aspect -->
<div class="relative w-full">
<router-link
:to="{ name: 'details', params: { type: props.media.MediaType, id: props.media.Id } }"
class="block w-full aspect-2/3 overflow-hidden bg-gray-100"
class="block w-full aspect-2/3 overflow-hidden bg-gray-100 relative"
>
<ImageWithFallback
:src="props.media.PosterPath"
@@ -35,96 +35,114 @@ const alreadyAdded = computed(() => store.mediaList.some((media) => media.Id ===
size="w300"
class="w-full h-full object-cover transform transition-transform duration-500 hover:scale-105"
/>
<!-- Glassy gradient shadow overlay -->
<div
class="absolute bottom-0 left-0 right-0 h-16 bg-linear-to-t from-white/40 via-white/20 to-transparent backdrop-blur-[0.5px] pointer-events-none"
></div>
</router-link>
<!-- Radial progress placed on the right edge, centered on the seam between poster and body -->
<div class="absolute left-3 bottom-0 z-20 translate-y-1/2" aria-hidden="true">
<div
class="radial-progress bg-primary text-primary-content border-primary border-4 font-bold flex items-center justify-center"
:style="{
'--value': props.media.VoteAverage * 10,
'--size': '2rem',
'--thickness': '3px',
}"
:aria-valuenow="props.media.VoteAverage"
role="progressbar"
>
<span class="flex items-baseline text-[0.7em]">
{{ props.media.VoteAverage === 0 ? '?' : (props.media.VoteAverage * 10).toFixed(0) }}
<span class="text-[0.5em] relative -top-1 ml-0.5">%</span>
</span>
</div>
<div class="absolute right-3 bottom-0 z-20 translate-y-1/2" aria-hidden="true">
<ScoreRing :VoteAverage="props.media.VoteAverage" />
</div>
</div>
<!-- Body: add top padding to ensure title sits below the ring seam -->
<!-- Body -->
<div class="card-body p-4 flex flex-col pt-6">
<router-link
:to="{ name: 'details', params: { type: props.media.MediaType, id: props.media.Id } }"
class="block"
>
<h2
class="card-title text-base font-semibold bg-linear-to-r from-gray-700 to-gray-500 bg-clip-text text-transparent wrap-break-word"
class="card-title text-base font-semibold bg-linear-to-r from-gray-700 to-gray-500 bg-clip-text text-transparent break-words"
>
{{ props.media.Title }}
</h2>
<time
class="text-sm text-gray-400"
:datetime="new Date(props.media.ReleaseDate).toISOString()"
:datetime="
props.media.ReleaseDate === '' && props.media.FirstAirDate === ''
? 'unknown'
: new Date(
props.media.ReleaseDate === ''
? props.media.FirstAirDate
: props.media.ReleaseDate,
).toISOString()
"
>
{{
new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(props.media.ReleaseDate))
props.media.ReleaseDate === '' && props.media.FirstAirDate === ''
? 'unknown'
: new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(
new Date(
props.media.ReleaseDate === ''
? props.media.FirstAirDate
: props.media.ReleaseDate,
),
)
}}
</time>
</router-link>
<div class="card-actions justify-end mt-auto">
<button
v-motion-fade-visible-once
v-if="!alreadyAdded"
class="btn btn-circle btn-md px-1.5 bg-linear-to-r from-gray-100 to-gray-200 border-gray-300 text-gray-700 hover:from-gray-200 hover:to-gray-300 hover:text-gray-900 transition"
@click="emit('add-media', props.media)"
<!-- Actions row -->
<div class="card-actions flex items-center justify-between mt-auto">
<!-- Type badge -->
<span
class="badge badge-outline text-xs font-medium px-2 py-1"
:class="props.media.Adult ? 'badge-error' : ''"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
{{ props.media.MediaType === 'movie' ? 'Movie' : 'Show' }}
</span>
<!-- Bookmark buttons -->
<div class="flex gap-2">
<button
v-motion-fade-visible-once
v-if="!alreadyAdded"
class="btn btn-circle btn-md px-1.5 bg-linear-to-r from-gray-100 to-gray-200 border-gray-300 text-gray-700 hover:from-gray-200 hover:to-gray-300 hover:text-gray-900 transition"
@click="emit('add-media', props.media)"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
/>
</svg>
</button>
<button
v-motion-fade-visible-once
v-else
class="btn btn-circle btn-md px-1.5 bg-linear-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="emit('remove-media', props.media.Id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"
/>
</svg>
</button>
<button
v-motion-fade-visible-once
v-else
class="btn btn-circle btn-md px-1.5 bg-linear-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="emit('remove-media', props.media.Id)"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m3 3 1.664 1.664M21 21l-1.5-1.5m-5.485-1.242L12 17.25 4.5 21V8.742m.164-4.078a2.15 2.15 0 0 1 1.743-1.342 48.507 48.507 0 0 1 11.186 0c1.1.128 1.907 1.077 1.907 2.185V19.5M4.664 4.664 19.5 19.5"
/>
</svg>
</button>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m3 3 1.664 1.664M21 21l-1.5-1.5m-5.485-1.242L12 17.25 4.5 21V8.742m.164-4.078a2.15 2.15 0 0 1 1.743-1.342 48.507 48.507 0 0 1 11.186 0c1.1.128 1.907 1.077 1.907 2.185V19.5M4.664 4.664 19.5 19.5"
/>
</svg>
</button>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import type { MovieDetailsType } from '@/types/Movie'
import type { TvSeriesDetailsType } from '@/types/TvSeries'
import { computed } from 'vue'
import { useMediaStore } from '@/stores/media'
import type { MediaType } from '@/types/Media'
@@ -8,38 +7,26 @@ import ImageWithFallback from './ImageWithFallback.vue'
import MediaTypeBadge from './MediaTypeBadge.vue'
const props = defineProps<{
type: 'movie' | 'tv'
media?: MediaType
movie?: MovieDetailsType | null
tvSeries?: TvSeriesDetailsType | null
}>()
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,
props.movie ? store.mediaList.some((m: MediaType) => m.Id === props.movie!.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"
class="flex flex-col lg:flex-row gap-12 items-center max-w-6xl w-full bg-white/70 border border-gray-200/60 rounded-xl p-8 transition"
v-motion-fade-visible-once
>
<!-- Poster -->
<ImageWithFallback
:src="type === 'movie' ? props.movie!.PosterPath : props.tvSeries!.PosterPath"
:src="props.movie!.PosterPath"
alt="Poster"
size="w500"
class="shrink-0 w-full max-w-sm rounded-lg shadow-lg transform transition-transform duration-500 hover:scale-105"
@@ -48,51 +35,29 @@ const alreadyAdded = computed(() =>
<!-- 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>
{{ props.movie!.Title }}
<span class="text-gray-400 text-lg font-normal"
>({{ props.movie!.ReleaseDate?.slice(0, 4) }})</span
>
</h1>
<p v-if="props.type === 'movie' && props.movie!.Tagline" class="italic text-gray-500 mb-2">
<p v-if="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 }}
{{ props.movie!.Overview }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 mb-6">
<MediaTypeBadge
v-for="g in props.type === 'movie' ? props.movie!.Genres : props.tvSeries!.Genres"
:key="g.id"
:text="g.name"
/>
<MediaTypeBadge
v-if="props.type === 'movie'"
:text="`Runtime: ${props.movie!.Runtime} min`"
/>
<MediaTypeBadge
v-if="props.type === 'tv'"
:text="`Seasons: ${props.tvSeries!.NumberOfSeasons}`"
/>
<MediaTypeBadge v-for="g in props.movie!.Genres" :key="g.id" :text="g.name" />
<MediaTypeBadge :text="`Runtime: ${props.movie!.Runtime} min`" />
</div>
<!-- Actions -->
<div class="card-actions" v-auto-animate>
<template v-if="(props.movie && props.media) || (props.tvSeries && props.media)">
<template v-if="props.movie && props.media">
<button
v-if="!alreadyAdded"
class="btn px-6 bg-linear-to-r from-gray-100 to-gray-200 border border-gray-300 text-gray-700 hover:from-gray-200 hover:to-gray-300"
@@ -113,8 +78,8 @@ const alreadyAdded = computed(() =>
:to="{
name: 'watch',
params: {
name: props.type === 'movie' ? props.movie!.Title : props.tvSeries!.Name,
id: props.type === 'movie' ? props.movie!.Id : props.tvSeries!.Id,
name: props.movie!.Title,
id: props.movie!.Id,
},
}"
>

21
src/components/ScoreRing.vue Executable file
View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
const props = defineProps<{ VoteAverage: number }>()
</script>
<template>
<div
class="radial-progress bg-primary text-primary-content border-primary border-4 font-bold flex items-center justify-center"
:style="{
'--value': props.VoteAverage * 10,
'--size': '2rem',
'--thickness': '3px',
}"
:aria-valuenow="props.VoteAverage"
role="progressbar"
>
<span class="flex items-baseline text-[0.7em]">
{{ props.VoteAverage === 0 ? '?' : (props.VoteAverage * 10).toFixed(0) }}
<span class="text-[0.5em] relative -top-1 ml-0.5">%</span>
</span>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useMediaStore } from '@/stores/media'
import type { MediaType } from '@/types/Media'
import ImageWithFallback from './ImageWithFallback.vue'
import MediaTypeBadge from './MediaTypeBadge.vue'
import type { TvSeriesDetailsType } from '@/types/TvSeries'
const props = defineProps<{
media?: MediaType
tvSeries?: TvSeriesDetailsType | null
}>()
const store = useMediaStore()
const alreadyAdded = computed(() =>
props.tvSeries ? store.mediaList.some((m: MediaType) => m.Id === props.tvSeries!.Id) : false,
)
</script>
<template>
<!-- Hero -->
<div
class="flex flex-col lg:flex-row gap-12 items-center max-w-6xl w-full bg-white/70 border border-gray-200/60 rounded-xl p-8 transition"
v-motion-fade-visible-once
>
<!-- Poster -->
<ImageWithFallback
:src="props.tvSeries!.PosterPath"
alt="Poster"
size="w500"
class="shrink-0 w-full max-w-sm rounded-lg shadow-lg transform transition-transform duration-500 hover:scale-105"
/>
<!-- Text -->
<div class="flex-1">
<h1 class="text-4xl font-bold text-gray-800 mb-2">
{{ props.tvSeries!.Name }}
<span class="text-gray-400 text-lg font-normal"
>({{ props.tvSeries!.FirstAirDate?.slice(0, 4) }})</span
>
</h1>
<p v-if="props.tvSeries!.Tagline" class="italic text-gray-500 mb-2">
{{ props.tvSeries!.Tagline }}
</p>
<p class="text-gray-600 leading-relaxed mb-6">
{{ props.tvSeries!.Overview }}
</p>
<!-- Badges -->
<div class="flex flex-wrap gap-2 mb-6">
<MediaTypeBadge v-for="g in props.tvSeries!.Genres" :key="g.id" :text="g.name" />
<MediaTypeBadge :text="`Seasons: ${props.tvSeries!.NumberOfSeasons}`" />
<MediaTypeBadge :text="`Episodes: ${props.tvSeries!.NumberOfEpisodes}`" />
</div>
<!-- Actions -->
<div class="card-actions" v-auto-animate>
<template v-if="props.tvSeries && props.media">
<button
v-if="!alreadyAdded"
class="btn px-6 bg-linear-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.media)"
>
Add to list
</button>
<button
v-else
class="btn px-6 bg-linear-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.media.Id)"
>
Remove from library
</button>
</template>
<!-- <RouterLink
:to="{
name: 'watch',
params: {
name: props.tvSeries!.Name,
id: props.tvSeries!.Id,
},
}"
>
<button
class="btn relative flex items-center gap-2 px-6 bg-linear-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

@@ -41,7 +41,7 @@ export const loadMedia = async (
const responses = await Promise.all(requests)
// If only one type was requested, handle that directly
if (filters.onlyMovies) {
if (filters.onlyMovies && !filters.onlySeries) {
const movieSearch = responses[0]
if (movieSearch.status !== 200) {
return {
@@ -62,7 +62,7 @@ export const loadMedia = async (
}
}
if (filters.onlySeries) {
if (filters.onlySeries && !filters.onlyMovies) {
const tvSearch = responses[0]
if (tvSearch.status !== 200) {
return {

View File

@@ -41,7 +41,6 @@ export const useSearchPageStore = defineStore('searchPage', () => {
searchQuery.value = query
mediaList.value = medias
searchFilters.value = filters
console.log('setState', page, query, medias, filters)
}
return { searchPage, searchQuery, mediaList, searchFilters, setState }
})

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import ErrorAlert from '@/components/alerts/ErrorAlert.vue'
import MediaDetails from '@/components/MediaDetails.vue'
import MovieDetails from '@/components/MovieDetails.vue'
import TVSeriesDetails from '@/components/TVSeriesDetails.vue'
import { getMovieDetails, getSeriesDetails } from '@/lib/api'
import { mapMovieDetailsToMedia, mapTvSeriesDetailsToMedia } from '@/types/MediaMap'
import type { MovieDetailsType } from '@/types/Movie'
@@ -57,17 +58,23 @@ onMounted(async () => {
<ErrorAlert v-if="errorMessage" :message="errorMessage" />
<!-- Loading -->
<div v-else-if="isLoading" class="flex justify-center items-center">
<!-- Loading -->
<div v-else-if="isLoading" class="flex justify-center items-center min-h-screen">
<span class="loading loading-ring loading-lg text-primary"></span>
</div>
<!-- Details -->
<MediaDetails
v-else-if="movieDetails || tvSeriesDetails"
:type="route.params.type === 'movie' ? 'movie' : 'tv'"
<MovieDetails
v-else-if="movieDetails"
:media="media"
:movie="movieDetails"
:tv-series="tvSeriesDetails"
v-motion-fade-visible-once
/>
<TVSeriesDetails
v-else-if="tvSeriesDetails"
:media="media"
:tvSeries="tvSeriesDetails"
v-motion-fade-visible-once
/>