Compare commits

...

11 Commits

51 changed files with 575 additions and 194 deletions

0
.editorconfig Normal file → Executable file
View File

0
.gitattributes vendored Normal file → Executable file
View File

70
.github/workflows/deploy.yml vendored Normal file → Executable file
View File

@@ -1,44 +1,44 @@
name: Deploy to GitHub Pages
# name: Deploy to GitHub Pages
on:
push:
branches: [main]
pull_request:
branches: [main]
# on:
# push:
# branches: [main]
# pull_request:
# branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
# jobs:
# build-and-deploy:
# runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
# - uses: pnpm/action-setup@v4
# name: Install pnpm
# with:
# version: 10
# run_install: false
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
# - name: Install Node.js
# uses: actions/setup-node@v4
# with:
# node-version: 20
# cache: 'pnpm'
- name: Install dependencies
run: pnpm install
# - name: Install dependencies
# run: pnpm install
- name: Build project
run: DEPLOY_ENV=GH_PAGES pnpm build
# - name: Build project
# run: DEPLOY_ENV=GH_PAGES pnpm build
- name: Copy index.html to 404.html
run: cp dist/index.html dist/404.html
# - name: Copy index.html to 404.html
# run: cp dist/index.html dist/404.html
- name: Deploy to GitHub Pages
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist
publish_branch: gh-pages
# - name: Deploy to GitHub Pages
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# uses: peaceiris/actions-gh-pages@v4
# with:
# github_token: ${{ secrets.GITHUB_TOKEN }}
# publish_dir: ./dist
# publish_branch: gh-pages

0
.gitignore vendored Normal file → Executable file
View File

0
.prettierrc.json Normal file → Executable file
View File

0
.vscode/extensions.json vendored Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
env.d.ts vendored Normal file → Executable file
View File

0
eslint.config.ts Normal file → Executable file
View File

0
index.html Normal file → Executable file
View File

7
package.json Normal file → Executable file
View File

@@ -8,12 +8,13 @@
},
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"build": "run-p type-check \"build-only {@}\" -- && npm run inject-preloads",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "prettier --write src/"
"format": "prettier --write src/",
"inject-preloads": "node scripts/inject-preloads.js"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
@@ -45,4 +46,4 @@
"vite-plugin-vue-devtools": "^8.0.3",
"vue-tsc": "^3.1.2"
}
}
}

0
pnpm-lock.yaml generated Normal file → Executable file
View File

0
public/favicon.ico Normal file → Executable file
View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

0
public/robots.txt Normal file → Executable file
View File

42
scripts/inject-preloads.js Executable file
View File

@@ -0,0 +1,42 @@
import fs from 'fs'
import path from 'path'
const distDir = path.resolve('dist')
const manifestPath = path.join(distDir, '.vite/manifest.json')
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const indexPath = path.join(distDir, 'index.html')
let html = fs.readFileSync(indexPath, 'utf-8')
const filesToPreload = new Set()
function collectFiles(entryKey) {
const chunk = manifest[entryKey]
if (!chunk) return
filesToPreload.add(chunk.file)
for (const imp of chunk.imports || []) {
if (imp === 'index.html') continue // skip entrypoint
const dep = manifest[imp]
if (dep) {
filesToPreload.add(dep.file)
collectFiles(imp) // recurse for transitive deps
}
}
}
// automatically find all .vue entries that are dynamic (lazyloaded)
for (const key of Object.keys(manifest)) {
if (key.endsWith('.vue') && manifest[key].isDynamicEntry) {
collectFiles(key)
}
}
for (const f of filesToPreload) {
const tag = `<link rel="modulepreload" href="/${f}">`
if (!html.includes(tag)) {
html = html.replace('</head>', ` ${tag}\n</head>`)
}
}
fs.writeFileSync(indexPath, html)
console.log('Preload tags injected')

4
src/App.vue Normal file → Executable file
View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import '@/style.css'
import NavBar from './components/NavBar.vue'
import { SpeedInsights } from "@vercel/speed-insights/vue"
import { SpeedInsights } from '@vercel/speed-insights/vue'
</script>
<template>
<SpeedInsights/>
<SpeedInsights />
<NavBar />
<RouterView />

2
src/components/AddMoreCard.vue Normal file → Executable file
View File

@@ -3,7 +3,7 @@
<template>
<router-link
to="/add"
class="card relative w-full h-full flex items-center justify-center aspect-2/3 bg-white/40 backdrop-blur-md border-2 border-dashed border-gray-300 rounded-lg shadow-sm hover:shadow-md hover:border-gray-400 transition-all duration-300"
class="card relative w-full h-full flex items-center justify-center aspect-2/3 bg-white/40 backdrop-blur-md rounded-lg transition-all duration-300"
v-motion-fade-visible-once
>
<!-- Plus icon -->

3
src/components/ImageWithFallback.vue Normal file → Executable file
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"

0
src/components/LoadingSpinner.vue Normal file → Executable file
View File

144
src/components/MediaCard.vue Normal file → Executable file
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,51 +21,128 @@ 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 -->
<router-link
:to="{ name: 'details', params: { type: props.media.MediaType, id: props.media.Id } }"
>
<ImageWithFallback
:src="props.media.PosterPath"
:alt="props.media.Title"
size="w300"
class="aspect-2/3 object-cover w-full h-full transform transition-transform duration-500 hover:scale-105"
/>
</router-link>
<!-- Body -->
<div class="card-body p-4 flex flex-col">
<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 relative"
>
<ImageWithFallback
:src="props.media.PosterPath"
:alt="props.media.Title"
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 right-3 bottom-0 z-20 translate-y-1/2" aria-hidden="true">
<ScoreRing :VoteAverage="props.media.VoteAverage" />
</div>
</div>
<!-- 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"
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>
<p class="text-sm text-gray-400">{{ props.media.ReleaseDate.slice(0, 4) }}</p>
<time
class="text-sm text-gray-400"
:datetime="
props.media.ReleaseDate === '' && props.media.FirstAirDate === ''
? 'unknown'
: new Date(
props.media.ReleaseDate === ''
? props.media.FirstAirDate
: props.media.ReleaseDate,
).toISOString()
"
>
{{
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-sm px-4 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 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' : ''"
>
Add
</button>
<button
v-motion-fade-visible-once
v-else
class="btn btn-sm px-4 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)"
>
Remove
</button>
{{ 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)"
>
<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)"
>
<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>

85
src/components/MediaFilters.vue Executable file
View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { SearchFilters } from '@/types/SearchFilters'
import { reactive, watch } from 'vue'
const props = defineProps<{
modelValue: SearchFilters // optional initial value
}>()
const emit = defineEmits<{
(e: 'update:filters', value: SearchFilters): void
}>()
const local = reactive<SearchFilters>({
includeAdult: props.modelValue?.includeAdult,
onlyMovies: props.modelValue?.onlyMovies,
onlySeries: props.modelValue?.onlySeries,
})
function toggleIncludeAdult(e: Event) {
local.includeAdult = (e.target as HTMLInputElement).checked
}
function toggleOnlyMovies(e: Event) {
const checked = (e.target as HTMLInputElement).checked
if (!checked && !local.onlySeries) {
local.onlySeries = true
}
local.onlyMovies = checked
}
function toggleOnlySeries(e: Event) {
const checked = (e.target as HTMLInputElement).checked
if (!checked && !local.onlyMovies) {
local.onlyMovies = true
}
local.onlySeries = checked
}
watch(
() => ({ ...local }),
(next) => {
// ensureAtLeastOneGenre()
const out: SearchFilters = {
includeAdult: next.includeAdult,
onlyMovies: next.onlyMovies,
onlySeries: next.onlySeries,
}
emit('update:filters', out)
},
{ deep: true, immediate: true },
)
</script>
<template>
<form class="flex gap-2 justify-center mb-8">
<label>
<input
class="btn"
:class="local.includeAdult ? 'btn-error' : ''"
type="checkbox"
:checked="local.includeAdult"
@change="toggleIncludeAdult"
aria-label="Adult"
/>
</label>
<label>
<input
class="btn"
type="checkbox"
:checked="local.onlyMovies"
@change="toggleOnlyMovies"
aria-label="Movies"
/>
</label>
<label>
<input
class="btn"
type="checkbox"
:checked="local.onlySeries"
@change="toggleOnlySeries"
aria-label="TV Series"
/>
</label>
</form>
</template>

8
src/components/MediaList.vue Normal file → Executable file
View File

@@ -32,9 +32,13 @@ const emit = defineEmits<{
<ul
v-auto-animate
v-else
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-6"
>
<li v-for="media in props.medias" :key="media.Id" v-auto-animate>
<li
v-for="media in props.medias.filter((media) => media.PosterPath)"
:key="media.Id"
v-auto-animate
>
<MediaCard
:media="media"
@add-media="$emit('add-media', $event)"

0
src/components/MediaTypeBadge.vue Normal file → Executable file
View File

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,
},
}"
>

0
src/components/NavBar.vue Normal file → Executable file
View File

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>

2
src/components/SearchBar.vue Normal file → Executable file
View File

@@ -13,7 +13,7 @@ function onSubmit() {
</script>
<template>
<form @submit.prevent="onSubmit" class="flex justify-center mb-8">
<form @submit.prevent="onSubmit" class="flex justify-center mb-3">
<div
class="flex items-center w-full max-w-md bg-white/70 backdrop-blur-md border border-gray-200/60 shadow-sm hover:shadow-md transition rounded-lg overflow-hidden"
>

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>

0
src/components/alerts/ErrorAlert.vue Normal file → Executable file
View File

136
src/lib/api.ts Normal file → Executable file
View File

@@ -1,7 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { MediaResponseType } from '@/types/Media'
import { mapMedia } from '@/types/MediaMap'
import type { MovieDetailsType } from '@/types/Movie'
import { mapMovieDetails } from '@/types/MovieMap'
import type { SearchFilters } from '@/types/SearchFilters'
import type { TvSeriesDetailsType } from '@/types/TvSeries'
import { mapTvSeriesDetails } from '@/types/TvSeriesMap'
import axios from 'axios'
@@ -18,71 +20,99 @@ const instance = axios.create({
},
})
export const searchMovies = async (query: string): Promise<MediaResponseType> => {
const response = await instance.get(`/search/multi`, {
params: {
query: query,
include_adult: true,
},
})
export const loadMedia = async (
query: string,
page = 1,
filters: SearchFilters,
): Promise<MediaResponseType> => {
const params = {
query,
include_adult: filters.includeAdult || false,
page,
}
const requests: Promise<any>[] = []
if (filters.onlyMovies) {
requests.push(instance.get(`/search/movie`, { params }))
}
if (filters.onlySeries) {
requests.push(instance.get(`/search/tv`, { params }))
}
if (response.status !== 200) {
const responses = await Promise.all(requests)
// If only one type was requested, handle that directly
if (filters.onlyMovies && !filters.onlySeries) {
const movieSearch = responses[0]
if (movieSearch.status !== 200) {
return {
Results: [],
Page: 0,
totalResults: 0,
totalPages: 0,
ErrorMessage: movieSearch.data.Error,
}
}
const movies = movieSearch.data.results.map((r: any) => mapMedia({ ...r, media_type: 'movie' }))
return {
Results: movies,
Page: movieSearch.data.page,
totalResults: movieSearch.data.total_results,
totalPages: movieSearch.data.total_pages,
ErrorMessage: '',
}
}
if (filters.onlySeries && !filters.onlyMovies) {
const tvSearch = responses[0]
if (tvSearch.status !== 200) {
return {
Results: [],
Page: 0,
totalResults: 0,
totalPages: 0,
ErrorMessage: tvSearch.data.Error,
}
}
const tv = tvSearch.data.results.map((r: any) => mapMedia({ ...r, media_type: 'tv' }))
return {
Results: tv,
Page: tvSearch.data.page,
totalResults: tvSearch.data.total_results,
totalPages: tvSearch.data.total_pages,
ErrorMessage: '',
}
}
// Otherwise, both
const [movieSearch, tvSearch] = responses
if (movieSearch.status !== 200 || tvSearch.status !== 200) {
return {
Results: [],
Page: 0,
totalResults: 0,
totalPages: 0,
ErrorMessage: response.data.Error,
ErrorMessage: movieSearch?.data?.Error || tvSearch?.data?.Error,
}
}
const filtered = response.data.results.filter((result: { media_type: string }) => {
return result.media_type === 'movie' || result.media_type === 'tv'
})
const filteredMovies = movieSearch.data.results.map((r: any) =>
mapMedia({ ...r, media_type: 'movie' }),
)
const filteredTV = tvSearch.data.results.map((r: any) => mapMedia({ ...r, media_type: 'tv' }))
const data: MediaResponseType = {
Results: filtered.map(mapMedia),
Page: response.data.page,
totalResults: response.data.total_results,
totalPages: response.data.total_pages,
const res: any[] = []
for (let i = 0; i < Math.max(filteredMovies.length, filteredTV.length); i++) {
if (i < filteredMovies.length) res.push(filteredMovies[i])
if (i < filteredTV.length) res.push(filteredTV[i])
}
return {
Results: res,
Page: Math.max(movieSearch.data.page, tvSearch.data.page),
totalResults: movieSearch.data.total_results + tvSearch.data.total_results,
totalPages: Math.max(movieSearch.data.total_pages, tvSearch.data.total_pages),
ErrorMessage: '',
}
return data
}
export const loadMoreMovies = async (query: string, page: number): Promise<MediaResponseType> => {
const response = await instance.get(`/search/multi`, {
params: {
query: query,
include_adult: true,
page: page,
},
})
if (response.status !== 200) {
return {
Results: [],
Page: 0,
totalResults: 0,
totalPages: 0,
ErrorMessage: response.data.Error,
}
}
const filtered = response.data.results.filter((result: { media_type: string }) => {
return result.media_type === 'movie' || result.media_type === 'tv'
})
const data: MediaResponseType = {
Results: filtered.map(mapMedia),
Page: response.data.page,
totalResults: response.data.total_results,
totalPages: response.data.total_pages,
ErrorMessage: '',
}
return data
}
export const getMovieDetails = async (id: string): Promise<MovieDetailsType | null> => {

0
src/main.ts Normal file → Executable file
View File

0
src/router/index.ts Normal file → Executable file
View File

7
src/stores/media.ts Normal file → Executable file
View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import type { MediaType } from '@/types/Media'
import type { SearchFilters } from '@/types/SearchFilters'
function saveMedias(medias: MediaType[]) {
localStorage.setItem('medias', JSON.stringify(medias))
@@ -33,11 +34,13 @@ export const useSearchPageStore = defineStore('searchPage', () => {
const mediaList = ref<MediaType[]>()
const searchPage = ref(0)
const searchQuery = ref('')
const searchFilters = ref<SearchFilters>()
function setState(page: number, query: string, medias: MediaType[]) {
function setState(page: number, query: string, medias: MediaType[], filters: SearchFilters) {
searchPage.value = page
searchQuery.value = query
mediaList.value = medias
searchFilters.value = filters
}
return { searchPage, searchQuery, mediaList, setState }
return { searchPage, searchQuery, mediaList, searchFilters, setState }
})

0
src/style.css Normal file → Executable file
View File

0
src/types/Media.ts Normal file → Executable file
View File

0
src/types/MediaMap.ts Normal file → Executable file
View File

0
src/types/Movie.ts Normal file → Executable file
View File

0
src/types/MovieMap.ts Normal file → Executable file
View File

5
src/types/SearchFilters.ts Executable file
View File

@@ -0,0 +1,5 @@
export type SearchFilters = {
includeAdult: boolean
onlyMovies: boolean
onlySeries: boolean
}

0
src/types/TvSeries.ts Normal file → Executable file
View File

0
src/types/TvSeriesMap.ts Normal file → Executable file
View File

39
src/views/AddView.vue Normal file → Executable file
View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { loadMoreMovies, searchMovies } from '@/lib/api'
import { loadMedia } from '@/lib/api'
import { onMounted, ref, watch } from 'vue'
import SearchBar from '@/components/SearchBar.vue'
import MediaList from '@/components/MediaList.vue'
import type { MediaType } from '@/types/Media'
import ErrorAlert from '@/components/alerts/ErrorAlert.vue'
import { useSearchPageStore, useMediaStore } from '@/stores/media'
import MediaFilters from '@/components/MediaFilters.vue'
import type { SearchFilters } from '@/types/SearchFilters'
const medias = ref<MediaType[]>()
const seachError = ref<string>('')
@@ -17,6 +19,13 @@ const isLoadingMore = ref(false)
const state = useSearchPageStore()
const store = useMediaStore()
// Initialize filters with stored values from the state, defaulting to initial values if not available
const filters = ref<SearchFilters>({
includeAdult: state.searchFilters?.includeAdult ?? false,
onlyMovies: state.searchFilters?.onlyMovies ?? true,
onlySeries: state.searchFilters?.onlySeries ?? false,
})
const handleAddMedia = (media: MediaType) => {
store.addMedia(media)
}
@@ -29,8 +38,7 @@ async function searchMovie() {
try {
isSearching.value = true
searchPage.value = 1
const result = await searchMovies(searchQuery.value)
console.log(result)
const result = await loadMedia(searchQuery.value, searchPage.value, filters.value)
if (result.totalResults === 0) {
medias.value = []
seachError.value = 'No results found'
@@ -39,7 +47,7 @@ async function searchMovie() {
seachError.value = ''
medias.value = result.Results
state.setState(searchPage.value, searchQuery.value, result.Results)
state.setState(searchPage.value, searchQuery.value, result.Results, filters.value)
} catch (error) {
medias.value = []
console.error(error)
@@ -53,9 +61,14 @@ async function loadMore() {
try {
isLoadingMore.value = true
searchPage.value++
const result = await loadMoreMovies(searchQuery.value, searchPage.value)
const result = await loadMedia(searchQuery.value, searchPage.value, filters.value)
medias.value?.push(...result.Results)
state.setState(searchPage.value, searchQuery.value, medias.value ? medias.value : [])
state.setState(
searchPage.value,
searchQuery.value,
medias.value ? medias.value : [],
filters.value,
)
} catch (error) {
searchPage.value = 1
seachError.value = (error as Error).message
@@ -91,6 +104,18 @@ watch(searchQuery, () => {
timeoutId = null
}, 500)
})
watch(filters, () => {
if (searchQuery.value.length > 2) {
state.setState(
searchPage.value,
searchQuery.value,
medias.value ? medias.value : [],
filters.value,
)
searchMovie()
}
})
</script>
<template>
@@ -105,6 +130,8 @@ watch(searchQuery, () => {
<!-- Search bar -->
<SearchBar v-model="searchQuery" @submit="searchMovie" />
<MediaFilters @update:filters="(f) => (filters = f)" :modelValue="filters" />
<!-- Loading spinner -->
<div v-if="isSearching" class="flex justify-center my-16">
<span class="loading loading-ring loading-lg text-primary"></span>

19
src/views/DetailsView.vue Normal file → Executable file
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
/>

0
src/views/HomeView.vue Normal file → Executable file
View File

0
src/views/ListView.vue Normal file → Executable file
View File

0
src/views/NotFoundView.vue Normal file → Executable file
View File

0
src/views/WatchView.vue Normal file → Executable file
View File

0
tsconfig.app.json Normal file → Executable file
View File

0
tsconfig.json Normal file → Executable file
View File

0
tsconfig.node.json Normal file → Executable file
View File

3
vite.config.ts Normal file → Executable file
View File

@@ -20,4 +20,7 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
manifest: true
},
})