Добро пожаловать во второй пост из нашей серии Almost Netflix! Мы будем развивать вчерашний проект и создавать веб-фронтенд для нашего клона Netflix! В этом посте мы подробно рассмотрим создание клона с помощью VueJS. В последующих постах этой серии мы будем создавать фронтенды для других платформ, таких как Flutter, iOS и Android!
В этой статье речь пойдет о вебе, так что давайте начнем!
В этой статье невозможно написать каждый кусочек кода 😬 Вы прочитаете обо всех основных концепциях, компонентах и взаимодействии с Appwrite. Тем не менее, если вы хотите проверить каждый уголок нашего веб-приложения Almost Netflix, вы можете ознакомиться с исходным кодом на GitHub, где хранится все приложение.
Я решил разместить проект на Vercel! Вы можете посмотреть предварительный просмотр живого демо Netflix Clone.
🤔 Что такое Appwrite?
Appwrite — это бэкенд с открытым исходным кодом как сервис, который абстрагирует все сложности, связанные с созданием современного приложения, предоставляя вам набор REST API для ваших основных потребностей бэкенда. Appwrite обрабатывает аутентификацию и авторизацию пользователей, базы данных, файловые хранилища, облачные функции, веб-крючки и многое другое! Если чего-то не хватает, вы можете расширить Appwrite, используя ваш любимый язык бэкенда.
📃 Требования
Прежде чем мы начнем, у нас должен быть запущен экземпляр Appwrite и настроен проект Almost Netflix. Если вы еще не настроили проект, вы можете обратиться к нашей предыдущей записи в блоге.
Для создания Almost Netflix мы будем использовать Vue.js из-за его достойной простоты и принудительной структуры. Я считаю, что читать компоненты Vue просто, и любой веб-разработчик может понять, чего пытается добиться код.
Для управления маршрутизацией, импортом и структурой папок мы будем придерживаться NuxtJS, интуитивно понятного фреймворка Vue.
И последнее, но не менее важное: для стилизации компонентов мы будем использовать Tailwind CSS. Tailwind CSS немного усложняет чтение HTML-кода, но обеспечивает быстрое создание прототипов, позволяя нам воссоздать пользовательский интерфейс Netflix в мгновение ока.
Больше ничего, обещаю! Если вы не знаете некоторые технологии, использованные в этом проекте, то сейчас самый подходящий момент продолжить статью, чтобы начать их изучать. В конце концов, мы разработчики, и нам нужно учиться каждый день 😎 Забавный факт, с помощью этого проекта я изучил NuxtJS.
🛠️ Создаем проект Nuxt
Благодаря фантастической документации Tailwind CSS, мы можем посетить их документ Install Tailwind CSS with Nuxt.js, который шаг за шагом поможет нам создать проект NuxtJS и добавить Tailwind CSS.
После того, как проект настроен, мы удалим все файлы из папок components
и pages
. Они содержат шаблоны для начала работы, но нам это не понадобится 😏 Чтобы увидеть, как работает наша установка, давайте создадим файл pages/index.vue
и поместим в него простой HTML:
<template>
<h1 class="text-blue-500 text-4xl">
Almost Netflix 🎬
</h1>
</template>
Убедитесь, что npm run dev
все еще запущен в фоновом режиме. Мы можем зайти на сайт http://localhost:3000/
и увидеть наш большой синий заголовок, если все работает хорошо.
Давайте немного адаптируем наш проект, используя пользовательские шрифты. Я решил использовать шрифт Inter, так как он довольно близок к шрифту Netflix. Благодаря Google Fonts мы можем внести небольшие изменения в наш assets/css/main.css
, чтобы обновить все шрифты на нашем сайте:
@tailwind base;
@tailwind components;
@tailwind utilities;
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
* {
font-family: 'Inter', sans-serif;
}
Наконец, давайте подготовим все активы в нашем проекте, скопировав их из папки static на GitHub. Все, что нам нужно сделать, это скачать их и поместить в папку static
. Это обеспечит нам готовность всех логотипов, иконок и фонов для последующего использования в HTML.
Отлично, проект готов! Давайте продолжим, подготовив службы Appwrite к взаимодействию с сервером Appwrite.
🤖 Служба Appwrite
Создадим файл services/appwrite.ts
и подготовим несколько функций для ознакомления. Мы будем использовать этот файл для прямого взаимодействия с Appwrite SDK. Тем самым мы отделим логику взаимодействия с сервером от остальной логики приложения, что приведет к более читабельному коду.
Давайте начнем с подготовки переменной Appwrite SDK:
import { Appwrite, Models, Query } from "appwrite";
const sdk = new Appwrite();
sdk
.setEndpoint("http://localhost/v1")
.setProject("almostNetflix");
Убедитесь, что вы используете свою собственную конечную точку и ID проекта. Пожалуйста, не спрашивайте, что случилось с
almostNetfix1
. Я этим не горжусь 😅.
Поскольку мы используем TypeScript, давайте также добавим определения, чтобы мы могли использовать их позже для описания данных, которые мы получаем от Appwrite:
export type AppwriteMovie = {
name: string,
description: string,
durationMinutes: number,
thumbnailImageId: string,
releaseDate: number,
ageRestriction: string,
relationId?: string
} & Models.Document;
export type AppwriteWatchlist = {
movieId: string,
userId: string
} & Models.Document;
Теперь, когда у нас готовы типы и SDK, давайте создадим и экспортируем сам AppwriteService
. Внутри также добавим несколько функций для аутентификации, чтобы у нас была отправная точка для будущих компонентов аутентификации:
export const AppwriteService = {
// Register new user into Appwrite
async register(name: string, email: string, password: string): Promise<void> {
await sdk.account.create("unique()", email, password, name);
},
// Login existing user into his account
async login(email: string, password: string): Promise<void> {
await sdk.account.createSession(email, password);
},
// Logout from server removing the session on backend
async logout(): Promise<boolean> {
try {
await sdk.account.deleteSession("current");
return true;
} catch (err) {
// If error occured, we should not redirect to login page
return false;
}
},
// Figure out if user is logged in or not
async getAuthStatus(): Promise<boolean> {
try {
await sdk.account.get();
return true;
} catch (err) {
// If there is error, user is not logged in
return false;
}
},
};
Отлично! Теперь у нас есть наш AppwriteService, готовый к использованию приложением Vue, и несколько функций аутентификации, уже настроенных. Мы можем обратиться к этому файлу в любое время в будущем и добавить больше функций, чтобы убедиться, что этот файл является нашим «шлюзом» к Appwrite.
Поскольку AppwriteService готов к аутентификации, мы должны реализовать компоненты Vue для этого, верно?
🔐 Аутентификация
Прежде чем мы начнем, давайте обновим наш pages/index.vue
, чтобы иметь приветственное сообщение и кнопки для перенаправления посетителя на страницы входа и регистрации. Поскольку я не хочу делать эту статью о HTML и Tailwind CSS, вы можете посмотреть файл Index на GitHub.
Мы можем скопировать pages/login.vue
из файла login и pages/register.vue
из файла register точно таким же образом, хотя мы рассмотрим эти две страницы подробнее.
При копировании файлов index, login и register на них уже настроено
middleware
. Возможно, вам придется временно удалить их, чтобы страницы загружались правильно. Мы будем создавать промежуточное ПО в последующих разделах.
В pages/login.vue
мы создаем форму и слушаем ее отправку:
<form @submit.prevent="onLogin()">
<input v-model="email" type="email" />
<input v-model="pass" type="password"/>
<button type="submit">Sign In</button>
</form>
Затем мы создаем метод onLogin
, в котором мы обращаемся к AppwriteService и перенаправляем в приложение после успешного входа:
export default Vue.extend({
data: () => {
return {
email: '',
pass: '',
}
},
methods: {
async onLogin() {
try {
await AppwriteService.login(this.email, this.pass)
this.$router.push('/app')
} catch (err: any) {
alert(err.message)
}
},
},
})
Вы также можете заметить, что мы используем данные для внутрикомпонентного управления состоянием, и благодаря атрибуту v-model
Vue, значение из ввода автоматически сохраняется в переменной.
Взглянув на pages/register.vue
, мы выполняем тот же процесс с разными значениями. Единственное основное отличие заключается в нашей функции onRegister
(альтернатива onLogin
), которая также проверяет, совпадают ли пароли и согласен ли пользователь с условиями:
export default Vue.extend({
data: () => {
return {
name: '',
email: '',
pass: '',
passAgain: '',
agreeTerms: false,
}
},
methods: {
async onRegister() {
if (this.pass !== this.passAgain) {
alert('Passwords need to match.')
return
}
if (!this.agreeTerms) {
alert('You have to agree to our terms.')
return
}
try {
await AppwriteService.register(this.name, this.email, this.pass)
await AppwriteService.login(this.email, this.pass)
this.$router.push('/app')
} catch (err: any) {
alert(err.message)
}
},
},
})
Обратите внимание, что сразу после регистрации мы также вводим пользователя с теми же учетными данными. Это позволяет нам перенаправить пользователя непосредственно в приложение вместо того, чтобы просить его войти в систему.
Чтобы завершить процесс входа, нам нужно создать pages/app/index.vue
, первую страницу, которую увидит пользователь, когда войдет в систему. На самом деле, позвольте мне показать вам один трюк…
Когда пользователь входит в систему, я хочу, чтобы он увидел список всех фильмов, но я также хочу, чтобы URL был app/movies
. Это позволит мне в будущем сделать страницы типа app/watchlist
, app/profiles
или app/tv-shows
.
Для этого мы создадим очень простой компонент pages/app/index.vue
. Единственное, что будет делать этот компонент, это перенаправлять на новый путь app/movies
:
<template></template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
middleware: [
function ({ redirect }) {
redirect('/app/movies')
},
],
})
</script>
Теперь мы создаем новый файл pages/app/movies.vue
и помещаем туда логику фильмов. В итоге, после успешного входа в систему вы будете перенаправлены на /app
, но вы даже не увидите эту страницу, потому что будете перенаправлены сразу на /app/movies
.
А пока давайте поместим простой текст приветствия в наш файл pages/app/movies.vue
:
<template>
<h1>Welcome logged in user 👋</h1>
</template>
Мы закончили с аутентификацией! О, подождите… Пока я играю с сайтом, я заметил, что могу вручную изменить URL в браузере на /app
и приложение позволит мне увидеть страницу с фильмами 😬 Давайте посмотрим, как мы можем использовать промежуточное ПО для принудительного перенаправления на определенные страницы в зависимости от того, вошел пользователь в систему или нет.
Промежуточное ПО для аутентификации
Среднее ПО можно использовать для ограничения посещения пользователем определенных страниц. В нашем сценарии мы не хотим разрешать пользователю посещать страницу фильмов, если он не вошел в систему. Сначала создадим middleware/only-authenticated.ts
с простой логикой, которая проверяет текущий статус пользователя и перенаправляет на вход, если пользователь не авторизован:
import { Middleware } from "@nuxt/types";
import { AppwriteService } from "../services/appwrite";
const middleware: Middleware = async ({ redirect }) => {
const isLoggedIn = await AppwriteService.getAuthStatus();
if (isLoggedIn) {
// OK
} else {
return redirect("/login");
}
}
export default middleware;
Благодаря этому промежуточному ПО пользователь сможет посетить маршрут, если он авторизован, но будет перенаправлен, если нет. Но какой маршрут? 🤔
Чтобы использовать это промежуточное ПО, нам нужно применить его к определенной странице. Поскольку мы не хотим, чтобы пользователь посещал страницу фильмов, мы обновим pages/app/movies.ts
:
<template>
<h1>Welcome logged in user 👋</h1>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
middleware: 'only-authenticated',
})
</script>
Вот так ✨ мы защитили нашу страницу, и разрешаем только вошедшим в систему пользователям просматривать нашу страницу фильмов. Очень быстро, давайте сделаем прямо противоположное для остальных наших страниц — перенаправим пользователя в приложение, если он уже вошел в систему. Мы делаем это для того, чтобы предотвратить попадание пользователя на страницу входа, если он уже вошел в систему.
Для этого мы создадим еще одно промежуточное ПО в middleware/only-unauthenticated.ts
:
import { Middleware } from "@nuxt/types";
import { AppwriteService } from "../services/appwrite";
const middleware: Middleware = async ({ redirect }) => {
const isLoggedIn = await AppwriteService.getAuthStatus();
if (isLoggedIn) {
return redirect("/app");
} else {
// OK
}
}
export default middleware;
Заметьте, в этом компоненте мы сделали все с точностью до наоборот. Если пользователь не вошел в систему, все в порядке, но мы принудительно перенаправляем его на страницу приложения, если он вошел.
Теперь давайте добавим этот промежуточный компонент only-unauthenticated
на все 3 страницы pages/index.vue
, pages/login.vue
и pages/register.vue
.
Давайте попробуем! Если мы вошли в систему и попытаемся посетить /login
, мы вернемся на страницу фильмов. Отлично! Мы успешно реализовали промежуточное ПО для защиты определенных страниц нашего приложения от неаутентифицированных пользователей.
🏗 Макет приложения
В каждом приложении некоторые части повторяются на всех страницах. В большинстве случаев это верхний и нижний колонтитулы, но это также может быть секция героя или пузырек чата. Чтобы предотвратить повторение этой части кода, мы можем создать из нее макет и использовать макет на наших страницах, подобно тому, как мы использовали промежуточное ПО. Сначала создадим простой макет и используем его на странице нашего фильма. Для этого создадим layouts/app.vue
:
<template>
<h1>Header</h1>
<hr>
<Nuxt />
<hr>
<h1>Footer</h1>
</template>
Мы использовали специальный HTML тег <Nuxt />
, который означает, что если страница использует этот макет, то содержимое страницы будет размещено именно там, где мы поместили наш тег <Nuxt />
. Это очень удобно, если мы хотим разместить страницу между верхним и нижним колонтитулом.
Чтобы использовать наш макет app
, мы упоминаем его на странице нашего фильма. Мы просто обновляем pages/app/movies.vue
:
<!-- ... -->
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
layout: 'app',
// ...
})
</script>
Теперь мы можем видеть, как наш верхний и нижний колонтитулы обертывают страницу с фильмами. Потрясающе! Давайте создадим настоящий макет Netflix, не так ли?
Во-первых, давайте обновим наш AppwriteService, поскольку нам нужно будет показать фотографию профиля пользователя в заголовке. Заголовок также должен включать трендовый фильм, если мы находимся на целевой странице. Для начала давайте создадим функцию, которая будет выдавать нам изображение профиля пользователя:
export const AppwriteService = {
// ...
// Generate profile photo from initials
async getProfilePhoto(): Promise<URL> {
let name = "Anonymous";
try {
const account = await sdk.account.get();
if (account.name) {
// If we have name, use that for initials
name = account.name;
} else {
// If not, use email. That is 100% available always
name = account.email;
}
} catch (err) {
// Means we don't have account, fallback to anonymous image
}
// Generate URL from previously picked keyword (name)
return sdk.avatars.getInitials(name, 50, 50);
}
};
Мы также должны подготовить функцию для предварительного просмотра изображения обложки фильма. Для этого нам понадобится отдельная функция, поскольку этот главный трендовый фильм занимает весь сайт одним огромным изображением:
export const AppwriteService = {
// ...
// Same as above. Generates URL, setting some limits on size and format
getMainThumbnail(imageId: string): URL {
return sdk.storage.getFilePreview(imageId, 2000, undefined, "top", undefined, undefined, undefined, undefined, undefined, undefined, undefined, "webp");
}
};
Наконец, давайте реализуем метод для получения популярных фильмов из нашей базы данных:
export const AppwriteService = {
// ...
// Simple query to get the most trading movie
async getMainMovie(): Promise<AppwriteMovie> {
const response = await sdk.database.listDocuments<AppwriteMovie>("movies", [], 1, undefined, undefined, undefined, ["trendingIndex"], ["DESC"]);
return response.documents[0];
}
};
Когда все эти методы готовы, мы можем начать использовать их в нашем макете. Давайте посетим файл макета приложения на GitHub и скопируем его содержимое на нашу страницу. Наш макет выглядит прекрасно, и мы уже получили наш первый фильм! Это начинает выглядеть почти как Netflix 🎉
🎬 Страница фильмов
Нам нужно показать ряды фильмов для различных категорий на нашей странице фильмов, таких как Популярные на этой неделе
или Новые релизы
. Прежде чем внедрить это в нашу страницу, нам понадобятся методы для получения данных из Appwrite.
Прежде всего, давайте создадим конфигурацию категорий в одной переменной внутри нашего AppwriteService, которую мы сможем использовать повторно позже:
export type AppwriteCategory = {
title: string;
queries: string[];
orderAttributes: string[];
orderTypes: string[];
collectionName?: string;
}
export const AppwriteMovieCategories: AppwriteCategory[] = [
{
title: "Popular this week",
queries: [],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
{
title: "Only on Almost Netflix",
queries: [
Query.equal("isOriginal", true)
],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
{
title: "New releases",
queries: [
Query.greaterEqual('releaseDate', 2018),
],
orderAttributes: ["releaseDate"],
orderTypes: ["DESC"]
},
{
title: "Movies longer than 2 hours",
queries: [
Query.greaterEqual('durationMinutes', 120)
],
orderAttributes: ["durationMinutes"],
orderTypes: ["DESC"]
},
{
title: "Love is in the air",
queries: [
Query.search('genres', "Romance")
],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
{
title: "Animated worlds",
queries: [
Query.search('genres', "Animation")
],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
{
title: "It's getting scarry",
queries: [
Query.search('genres', "Horror")
],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
{
title: "Sci-Fi awaits...",
queries: [
Query.search('genres', "Science Fiction")
],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
{
title: "Anime?",
queries: [
Query.search('tags', "anime")
],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
{
title: "Thriller!",
queries: [
Query.search('genres', "Thriller")
],
orderAttributes: ["trendingIndex"],
orderTypes: ["DESC"]
},
];
export const AppwriteService = {
// ...
};
Мы только что настроили все различные категории, которые мы хотим показывать на нашей домашней странице, каждая из которых имеет название, запросы и конфигурацию сортировки. Давайте также подготовим функцию для получения списка фильмов, где input является одной из этих категорий:
export const AppwriteService = {
// ...
// List movies. Most important function
async getMovies(perPage: number, category: AppwriteCategory, cursorDirection: 'before' | 'after' = 'after', cursor: string | undefined = undefined): Promise<{
documents: AppwriteMovie[],
hasNext: boolean;
}> {
// Get queries from category configuration. Used so this function is generic and can be easily re-used
const queries = category.queries;
const collectionName = category.collectionName ? category.collectionName : "movies";
let documents = [];
// Fetch data with configuration from category
// Limit increased +1 on purpose so we know if there is next page
let response: Models.DocumentList<any> = await sdk.database.listDocuments<AppwriteMovie | AppwriteWatchlist>(collectionName, queries, perPage + 1, undefined, cursor, cursorDirection, category.orderAttributes, category.orderTypes);
// Create clone of documents we got, but depeding on cursor direction, remove additional document we fetched by setting limit to +1
if (cursorDirection === "after") {
documents.push(...response.documents.filter((_d, dIndex) => dIndex < perPage));
} else {
documents.push(...response.documents.filter((_d, dIndex) => dIndex > 0 || response.documents.length === perPage));
}
if (category.collectionName) {
const nestedResponse = await sdk.database.listDocuments<AppwriteMovie>("movies", [
Query.equal("$id", documents.map((d) => d.movieId))
], documents.length);
documents = nestedResponse.documents.map((d) => {
return {
...d,
relationId: response.documents.find((d2) => d2.movieId === d.$id).$id
}
}).sort((a, b) => {
const aIndex = response.documents.findIndex((d) => d.movieId === a.$id);
const bIndex = response.documents.findIndex((d) => d.movieId === b.$id);
return aIndex < bIndex ? -1 : 1;
})
}
// Return documents, but also figure out if there was this +1 document we requested. If yes, there is next page. If not, there is not
return {
documents: documents as AppwriteMovie[],
hasNext: response.documents.length === perPage + 1
};
}
};
Обратите внимание, что мы принимаем ограничение на количество страниц и курсор в нашей функции, чтобы обеспечить правильную пагинацию. Мы также возвращаем булево значение hasNext
, которое говорит, существует ли следующая страница или нет. Все это станет на свои места, когда мы начнем реализовывать страницу с фильмами, поскольку там нам понадобится эта система пагинации.
Прежде чем мы покинем наш AppwriteService, мы реализуем еще одну функцию, которая позволит нам предварительно просматривать обложки фильмов. Эта функция будет похожа на ту, которую мы создали для трендового фильма, но мы можем настроить конфигурацию на меньшую ширину, так как она не будет занимать так много экрана, как это делает трендовый фильм:
export const AppwriteService = {
// ...
// Generate URL that will resize image to 500px from original potemtially 4k image
// Also, use webp format for better performance
getThumbnail(imageId: string): URL {
return sdk.storage.getFilePreview(imageId, 500, undefined, "top", undefined, undefined, undefined, undefined, undefined, undefined, undefined, "webp");
}
};
Ура, ApprwiteService готов! 😎 Давайте обновим нашу страницу фильмов в pages/app/movies.vue
, и просмотрим категории приложений, показывая список фильмов для каждой из них:
<template>
<div>
<div class="flex flex-col space-y-20">
<movie-list
v-for="category in categories"
:key="category.title"
:category="category"
/>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import {
AppwriteMovieCategories,
} from '~/services/appwrite'
export default Vue.extend({
data: () => {
return {
categories: AppwriteMovieCategories,
}
},
})
</script>
Теперь сложная часть… Нам нужно создать этот <movie-list>
, который мы только что использовали. Такой компонент должен использовать наш AppwriteService для получения списка фильмов внутри категории и управлять пагинацией, чтобы мы могли прокручивать категорию.
Сначала создадим компонент и напишем HTML, который будет циклически просматривать список фильмов:
<template>
<div>
<h1 class="text-4xl text-zinc-200">{{ category.title }}</h1>
<div
v-if="movies.length > 0"
class="relative grid grid-cols-2 gap-4 mt-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"
>
<Movie
v-for="(movie, index) in movies"
:isPaginationEnabled="true"
:onPageChange="onPageChange"
:moviesLength="movies.length"
:isLoading="isLoading"
:isCursorAllowed="isCursorAllowed"
class="col-span-1"
:key="movie.$id"
:appwrite-id="movie.$id"
:movie="movie"
:index="index"
/>
</div>
<div v-if="movies.length <= 0" class="relative mt-6 text-zinc-500">
<p>This list is empty at the moment...</p>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
props: ['category'],
});
</script>
Теперь давайте реализуем логику для подготовки этого массива фильмов:
export default Vue.extend({
// ...
data: () => {
const width = window.innerWidth
let perPage: number
// Depending on the device size, use different page size
if (width < 640) {
perPage = 2
} else if (width < 768) {
perPage = 3
} else if (width < 1024) {
perPage = 4
} else if (width < 1280) {
perPage = 5
} else {
perPage = 6
}
return {
perPage,
isLoading: true,
isBeforeAllowed: false,
isAfterAllowed: true,
movies: [] as AppwriteMovie[],
lastCursor: undefined as undefined | string,
lastDirection: undefined as undefined | 'before' | 'after',
}
},
async created() {
// When component loads, fetch movie list with defaults for pagination (no cursor)
const data = await AppwriteService.getMovies(
this.perPage,
this.$props.category
)
// Store fetched data into component variables
this.movies = data.documents
this.isLoading = false
this.isAfterAllowed = data.hasNext
},
});
Наконец, добавим методы, которые позволят нам переходить по категориям постранично:
export default Vue.extend({
// ...
isCursorAllowed(index: number) {
// Simply use variables we fill during fetching data from API
// Depending on index (direction) we want to return different variables
if (index === 0) {
return this.isBeforeAllowed
}
if (index === this.movies.length - 1) {
return this.isAfterAllowed
}
},
async onPageChange(direction: 'before' | 'after') {
// Show spinners instead of arrows
this.isLoading = true
// Use relation ID if provided
const lastRelationId =
direction === 'before'
? this.movies[0].relationId
: this.movies[this.movies.length - 1].relationId
// Depending on direction, get ID of last document we have
let lastId = lastRelationId
? lastRelationId
: direction === 'before'
? this.movies[0].$id
: this.movies[this.movies.length - 1].$id
// Fetch new list of movies using direction and last document ID
const newMovies = await AppwriteService.getMovies(
this.perPage,
this.$props.category,
direction,
lastId
)
// Fetch status if movie is on My List or not
await this.LOAD_FAVOURITE(newMovies.documents.map((d) => d.$id))
// Now lets figure out if we have previous and next page...
// Let's start with saying we have them both, then we will set it to false if we are sure there isnt any
// By setting default to true, we never hide it when we shouldnt.. Worst case scenario, we show it when we shoulding, resulsing in you seing the arrow, but taking no effect and then dissapearing
this.isBeforeAllowed = true
this.isAfterAllowed = true
// If we dont get any documents, it means we got to edge-case when we thought there is next/previous page, but there isnt
if (newMovies.documents.length === 0) {
// Depending on direction, set that arrow to disabled
if (direction === 'before') {
this.isBeforeAllowed = false
} else {
this.isAfterAllowed = false
}
} else {
// If we got some documents, store them to component variable and keep both arrows enabled
this.movies = newMovies.documents
}
// If our Appwrite service says there isn' next page, then...
if (!newMovies.hasNext) {
// Depnding on direction, set that specific direction to disabled
if (direction === 'before') {
this.isBeforeAllowed = false
} else {
this.isAfterAllowed = false
}
}
// Store cursor and direction if I ever need to refresh the current page
this.lastDirection = direction
this.lastCursor = lastId
// Hide spinners, show arrows again
this.isLoading = false
},
});
Вы можете найти весь компонент в файле компонента списка фильмов.
Вау, это был аттракцион 🥵 Давайте закончим с созданием компонента <Movie>
в components/Movie.vue
для рендеринга одного конкретного фильма. Мы можем использовать файл компонента movie в качестве ссылки.
Отлично, у нас есть готовые списки фильмов! Нам не хватает последней функции, позволяющей пользователям нажимать на фильм, чтобы увидеть подробности. Чтобы заставить ее работать, вы можете скопировать файл модального фильма, файл модального фильтра и файл модального магазина. Поскольку эти файлы относятся только к HTML, Tailwind CSS и управлению состояниями Vue, было бы не совсем по теме рассматривать их по одному. Не волнуйтесь, там нет ничего интересного 😅.
Единственный недостающий фрагмент нашей головоломки — это список наблюдения. Давайте реализуем его!
🔖 Страница списка наблюдения
Как всегда, давайте начнем с подготовки бэкенд-коммуникаций в нашем AppwriteService. Нам понадобятся две функции для обновления нашего списка просмотра — одна для удаления, другая для добавления новых фильмов в мой список просмотра:
export const AppwriteService = {
// ...
async addToMyList(movieId: string): Promise<boolean> {
try {
const { $id: userId } = await sdk.account.get();
await sdk.database.createDocument("watchlists", "unique()", {
userId,
movieId,
createdAt: Math.round(Date.now() / 1000)
});
return true;
} catch (err: any) {
alert(err.message);
return false;
}
},
async deleteFromMyList(movieId: string): Promise<boolean> {
try {
const { $id: userId } = await sdk.account.get();
const watchlistResponse = await sdk.database.listDocuments<AppwriteWatchlist>("watchlists", [
Query.equal("userId", userId),
Query.equal("movieId", movieId)
], 1);
const watchlistId = watchlistResponse.documents[0].$id;
await sdk.database.deleteDocument("watchlists", watchlistId);
return true;
} catch (err: any) {
alert(err.message);
return false;
}
}
};
Для правильного управления состоянием в будущем нам понадобится еще одна функция, чтобы, когда у нас будет список фильмов, мы могли выяснить, какие из них уже находятся в списке просмотра пользователя:
export const AppwriteService = {
// ...
async getOnlyMyList(movieIds: string[]): Promise<string[]> {
const { $id: userId } = await sdk.account.get();
const watchlistResponse = await sdk.database.listDocuments<AppwriteWatchlist>("watchlists", [
Query.equal("userId", userId),
Query.equal("movieId", movieIds)
], movieIds.length);
return watchlistResponse.documents.map((d) => d.movieId);
}
};
Теперь давайте создадим страницу /app/my-list
, на которой люди смогут увидеть свой список просмотра. Для этого мы создадим файл /pages/app/my-list.vue
. К счастью, мы можем повторно использовать логику категорий, чтобы правильно отобразить список фильмов:
<template>
<div>
<movie-list :category="category" />
</div>
</template>
<script lang="ts">
import Vue from 'vue'
import { AppwriteCategory } from '../../services/appwrite'
export default Vue.extend({
middleware: 'only-authenticated',
layout: 'app',
data() {
const category: AppwriteCategory = {
collectionName: 'watchlists',
title: 'Movies in My List',
queries: [],
orderAttributes: [],
orderTypes: [],
}
return {
category,
}
},
})
</script>
Затем давайте настроим управление состоянием, которое будет источником истины для всего приложения о том, находится ли фильм уже в списке просмотра или нет. Для этого мы можем скопировать файл my list store с GitHub.
Наконец, мы определим компонент, который будет служить кнопкой для добавления/удаления фильма из списка просмотра. Мы можем найти этот компонент в файле компонента watchlist.
Хотите верьте, хотите нет, но клон Netflix готов! 🥳 Мы должны разместить его на хостинге, чтобы каждый мог его увидеть, верно?
🚀 Развертывание
Мы развернем наш Nuxt-проект на Vercel. Я влюбился в эту платформу благодаря простоте развертывания, а также тому, что эта платформа бесплатна практически для всех ваших побочных проектов.
После создания репозитория для нашего проекта на GitHub, мы создаем новый проект на Vercel, указывающий на этот репозиторий. Мы настраиваем процесс сборки на использование npm run generate
для сборки, dist
в качестве выходной папки и npm install
в качестве команды установки. Мы ждем, пока Vercel закончит сборку, и нам будет представлен пользовательский поддомен Vercel, содержащий наш веб-сайт.
Когда мы посещаем его, мы замечаем, что начинаем получать сетевые ошибки 😬 Мы смотрим в консоль и замечаем ошибку CORS от Appwrite… Но почему? 🤔
До сих пор мы разрабатывали сайт только локально, то есть использовали имя хоста localhost
. К счастью, Appwrite разрешает всю связь с localhost
, чтобы облегчить разработку. Поскольку теперь мы находимся на хосте Vercel, Appwrite больше не доверяет ему, и нам нужно настроить его как производственную платформу. Чтобы сделать это, мы заходим на сайт Appwrite Console и вводим наш проект. Если мы прокрутим немного вниз в нашей приборной панели, мы заметим раздел Platforms
. Здесь нам нужно добавить новую веб-платформу с присвоенным вам именем хоста Vercel.
После добавления платформы Appwrite теперь доверяет нашему развертыванию на Vercel, и мы можем начать его использовать! 🥳 Хотите верьте, хотите нет, но мы только что создали клон Netflix с помощью Appwrite ( почти ) .
👨🎓 Заключение
Мы успешно клонировали фильмы Netflix с помощью Appwrite. Как видите, с Appwrite ваше воображение не ограничено! Чтобы стать частью сообщества Appwrite, вы можете присоединиться к нашему серверу сообщества Discord. Мне не терпится увидеть вас и посмотреть на то, что вы создадите с помощью Appwrite 🤩.
Этот проект еще не закончен! 😎 В следующих выпусках Appwrite мы будем улучшать этот клон Netflix и добавлять больше функций. Вы можете быть готовы к потоковому видео, пользовательским изменениям в бэкенде и многому другому!
Вот несколько удобных ссылок и ресурсов:
- Почти Netflix для Web
- Appwrite Github
- Документация Appwrite
🔗 Узнать больше
Вы можете использовать следующие ресурсы, чтобы узнать больше и получить помощь относительно Appwrite и его сервисов
- 🚀 Appwrite Github
- 📜 Appwrite Docs
- 💬 Сообщество Discord