I have made a Movies App with Vue 3, TypeScript and The Movie Database (TMDB) API. For aesthetics, I rely on Bootstrap 5.
In src\App.vue
I have:
<template>
<TopBar />
<router-view />
<AppFooter />
</template>
<script lang="ts">
import { defineComponent } from 'vue';
// Import components
import TopBar from './components/TopBar.vue';
import AppFooter from './components/AppFooter.vue';
export default defineComponent({
// Register components
components: {
TopBar,
AppFooter
}
});
</script>
<style lang="scss">
.app-logo {
max-height: 25px;
width: auto;
}
// Layout
#app {
min-height: 100vh;
height: auto;
display: flex;
flex-direction: column;
}
</style>
The Navbar (src\components\TopBar.vue
):
<template>
<nav class="navbar sticky-top navbar-expand-md shadow-sm">
<div class="container-fluid">
<router-link class="navbar-brand" to="/">
<img src="../assets/logo.png" class="app-logo" alt="App Logo">
</router-link>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNavigation" aria-controls="mainNavigation" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="mainNavigation">
<ul class="navbar-nav pe-md-1 navbar-expand-md">
<li class="nav-item">
<router-link class="nav-link" :class="$route.name == 'home' ? 'active':''" to="/">Now Playing</router-link>
</li>
<li class="nav-item">
<router-link class="nav-link" :class="$route.name == 'top_rated' ? 'active':''" to="/top-rated">Top Rated</router-link>
</li>
</ul>
<form ref="searchForm" class="search_form w-100 mx-auto mt-2 mt-md-0">
<div class="input-group">
<input v-on:keyup="debounceMovieSearch" v-model="searchTerm" class="form-control search-box" type="text" placeholder="Search movies...">
<div class="input-group-append">
<button class="btn" type="button">
<font-awesome-icon :icon="['fas', 'search']" />
</button>
</div>
</div>
<div v-if="isSearch" @click="isSearch = false" class="search-results shadow-sm">
<div v-if="this.movies.length">
<router-link v-for="movie in movies.slice(0, 10)" :key="movie.id" :to="`/movie/${movie.id}`">
<SearchItem :movie="movie" />
</router-link>
</div>
<div v-else>
<p class="m-0 p-2 text-center">No movies found for this search</p>
</div>
</div>
</form>
</div>
</div>
</nav>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import axios from 'axios';
import env from '../env';
import SearchItem from './SearchItem.vue';
export default defineComponent({
name: 'TopBar',
components: {SearchItem},
data: () => ({
searchForm: ref(null),
isSearch: false,
searchTerm: '',
timeOutInterval: 1000,
movies: []
}),
mounted() {
this.windowEvents();
},
methods: {
windowEvents() {
// Check for click outside the search form
window.addEventListener('click', (event) => {
if (!(this.$refs.searchForm as HTMLFormElement).contains(event.target as Node|null)) {
this.isSearch = false;
}
});
},
debounceMovieSearch() {
setTimeout(this.doMovieSearch, this.timeOutInterval)
},
doMovieSearch() {
if (this.searchTerm.length > 2) {
this.isSearch = true;
axios.get(`${env.api_url}/search/movie?api_key=${env.api_key}&query=${this.searchTerm}`).then(response => {
this.movies = response.data.results;
})
.catch(err => console.log(err));
}
},
}
});
</script>
In the views directory I have the HomeView.vue
that displays a list of movies, via the MoviesList.vue component.
In HomeView.vue:
<template>
<div class="container">
<h1 class="page-title">{{ pageTitle }}</h1>
<MoviesList listType="now_playing" />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import MoviesList from '../components/MoviesList.vue';
export default defineComponent({
name: 'HomeView',
components: {
MoviesList
},
data: () => ({
pageTitle: "Now Playing"
})
});
</script>
In MoviesList.vue:
<template>
<div class="row list">
<div
v-for="movie in movies"
:key="movie.id"
class="col-xs-12 col-sm-6 col-lg-4 col-xl-3"
>
<MovieCard :movie="movie" :genres="genres" :showRating="true" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import axios from "axios";
import env from "../env";
import MovieCard from "./MovieCard.vue";
export default defineComponent({
name: "MoviesList",
components: { MovieCard },
props: {
listType: {
type: String,
required: true,
},
},
data: () => ({
searchTerm: "",
movies: [],
genres: [],
}),
mounted() {
this.listMovies();
this.getGenres();
},
methods: {
listMovies() {
axios
.get(
`${env.api_url}/movie/${this.$props.listType}?api_key=${env.api_key}`
)
.then((response) => {
this.movies = response.data.results;
})
.catch((err) => console.log(err));
},
getGenres() {
axios
.get(`${env.api_url}/genre/movie/list?api_key=${env.api_key}`)
.then((response) => {
this.genres = response.data.genres;
})
.catch((err) => console.log(err));
},
},
});
</script>
<style scoped lang="scss">
[class*="col-"] {
display: flex;
flex-direction: column;
margin-bottom: 30px;
}
</style>
The above component is reusable. I use it in the Top Rated Movies view, by providing a different value (from the one used on the Homepage view) for the listType
prop:
<template>
<div class="container">
<h1 class="page-title">{{ pageTitle }}</h1>
<MoviesList listType="top_rated" />
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import MoviesList from "../components/MoviesList.vue";
export default defineComponent({
name: "TopRatedMoviesView",
components: {
MoviesList,
},
data: () => ({
pageTitle: "Top Rated",
})
});
</script>
In src\components\MovieCard.vue
I have:
<template>
<div class="movie card" @click="showDetails(movie.id)">
<div class="thumbnail">
<img
:src="movieCardImage"
:alt="movie.title"
class="img-fluid"
/>
</div>
<div class="card-content">
<h2 class="card-title">{{ movie.title }}</h2>
<p class="card-desc">{{ movie.overview }}</p>
<span v-if="showRating" :title="`Score: ${movie.vote_average}`" class="score">{{ movie.vote_average}}</span>
</div>
<div class="card-footer">
<p class="m-0 release">
Release date: {{ dateTime(movie.release_date) }}
</p>
<p v-if="movieGenres" class="m-0 pt-1">
<span class="genre" v-for="genre in movieGenres" :key="genre.id">
{{ genre.name }}
</span>
</p>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import moment from "moment";
export default defineComponent({
name: "MovieCard",
props: {
movie: {
type: Object,
required: true,
},
genres: {
type: Object,
required: false,
},
showRating: {
type: Boolean,
default: false,
}
},
data: () => ({
genericCardImage: require("../assets/generic-card-image.png"),
}),
methods: {
dateTime(value: any) {
return moment(value).format("MMMM DD, YYYY");
},
showDetails(movie_id: any) {
let movie_url = `/movie/${movie_id}`;
window.open(movie_url, "_self");
},
},
computed: {
movieCardImage() {
return !this.movie?.backdrop_path
? this.genericCardImage
: `https://image.tmdb.org/t/p/w500/${this.movie?.backdrop_path}`;
},
movieGenres() {
if (this.genres) {
let genres = this.genres;
return this.movie?.genre_ids
.filter((genre_id: number) => genre_id in genres)
.map((genre_id: number) => genres[genre_id])
.filter((genre: { name: string }) => genre.name.length > 0);
} else {
return [];
}
},
},
});
</script>
The app is significantly bigger: there is a Movie details view and an Actor details view, but instead of pasting it all here, I have put together Stackblitz .
Questions
- Is there any code redundancy (and ways to reduce it)?
- Do you see any bad practices?
- Any suggestions for code optimization, modularization and reusability?
1 Answer 1
This is feedback from me.
You can create a directive named composite for outside click.
In the movieCard component => you can replace template logic with computed properties. like for the date
dateTime(movie.release_date)
Regarding typescript, don't overuse any.
movie_id
type is defined. It may be a string or number.In actorDetails, you used useRoute, use is a composable mostly used with composition API. In option API it is better to use
this.$route
directly.For reusability, you can use
mixin
which will handle all the API requests. You can figure out how it can be implemented.It can be a little awkward but if you use BaseCard with a name for actor and movie.
You can register Axios as a global variable like
$api or $axios
.
Lastly, I think you need to move from Vue CLI to Vite. Use composition API instead of an option. It gives a lot of flexibility and reusability. Use tailwind (optional).
-
\$\begingroup\$ How do I "use Axios as a global variable like
$api
or$axios
"? \$\endgroup\$Razvan Zamfir– Razvan Zamfir2023年05月17日 13:38:31 +00:00Commented May 17, 2023 at 13:38 -
\$\begingroup\$ import axiso from 'axios'
const app = createApp(App);
app.config.globalProperties.$anyName = axios;
\$\endgroup\$Awais Alwaisy– Awais Alwaisy2023年05月17日 14:26:00 +00:00Commented May 17, 2023 at 14:26 -
\$\begingroup\$ And how do I use it? \$\endgroup\$Razvan Zamfir– Razvan Zamfir2023年05月17日 15:22:25 +00:00Commented May 17, 2023 at 15:22
-
\$\begingroup\$ in any vue component you can use
this.gobalVarName
. \$\endgroup\$Awais Alwaisy– Awais Alwaisy2023年05月18日 09:14:44 +00:00Commented May 18, 2023 at 9:14
listMovies
begetMovies
, it conflicts withgetGenres
as all they are doing is getting data and setting a property, 3. use event delegation for yourwindow.addEventListener(
. \$\endgroup\$