隆重推出 Almost Netflix:一款使用 Vue 和 Appwrite 构建的 Netflix 克隆应用。
欢迎来到“几乎是Netflix”系列的第二篇文章!我们将继续昨天的项目搭建,为我们的Netflix克隆版构建一个Web前端!在本文中,我们将深入探讨如何使用VueJS构建这个克隆版。在本系列的后续文章中,我们将为其他平台(例如Flutter、iOS和Android)构建前端!
这次的主题是互联网,那么让我们开始吧!
本文不可能列出所有代码😬 您将了解所有基本概念、组件以及与 Appwrite 的通信方式。不过,如果您想深入了解我们这款“Almost Netflix”Web 应用的方方面面,可以查看包含整个应用的GitHub 源代码。
我决定将项目托管在 Vercel 上!您可以查看Netflix 克隆版实时演示的预览。
🤔 Appwrite是什么?
Appwrite是一个开源的后端即服务(BaaS),它通过提供一套REST API来满足您的核心后端需求,从而抽象化构建现代应用程序的所有复杂性。Appwrite可以处理用户身份验证和授权、数据库、文件存储、云函数、Webhook等等!如果缺少任何功能,您可以使用自己喜欢的后端语言来扩展Appwrite。
📃 要求
开始之前,请确保 Appwrite 实例已启动并运行,并且 Almost Netflix 项目已设置完毕。如果您尚未设置项目,可以参考我们之前的博客文章。
为了构建 Almost Netflix,我们将使用Vue.js ,因为它简洁易懂,结构清晰。我相信 Vue 组件的代码很容易理解,任何 Web 开发人员都能明白代码的意图。
为了管理路由、导入和文件夹结构,我们将坚持使用NuxtJS,这是一个直观的 Vue 框架。
最后,我们将使用Tailwind CSS来设置组件样式。Tailwind CSS 会使 HTML 代码的可读性略有下降,但它可以实现快速原型设计,让我们能够瞬间重现 Netflix 的用户界面。
好了,我保证!如果你还不了解本项目中使用的一些技术,现在正是继续阅读本文开始学习它们的最佳时机。总而言之,我们是开发者,需要每天学习😎 有趣的是,我就是通过这个项目学习了 NuxtJS。
🛠️ 创建 Nuxt 项目
感谢 Tailwind CSS 出色的文档,我们可以访问他们的“使用 Nuxt.js 安装 Tailwind CSS”文档,该文档将一步一步地指导我们创建 NuxtJS 项目并添加 Tailwind CSS。
项目设置完成后,我们删除 `<files>`components和 ` pages<folder>` 文件夹中的所有文件。这些文件夹包含一些模板,可以帮助我们快速上手,但我们用不到它们😏 为了验证设置是否有效,我们创建一个文件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;
}
最后,让我们从 GitHub 上的static 文件夹复制所有资源到项目中。只需下载并将它们放入我们的static文件夹即可。这样就能确保所有 logo、图标和背景都已准备就绪,可以稍后在 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,您可以直接在 GitHub 上查看Index 文件。
我们可以用完全相同的方式pages/login.vue从登录文件和注册表文件pages/register.vue中复制内容,不过我们将更仔细地研究一下这两个文件。
复制 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-modelVue 属性,输入值会自动存储在变量中。
观察结果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>
现在我们创建一个名为 `movies.txt` 的新文件pages/app/movies.vue,并将电影逻辑放入其中。简而言之,登录成功后,您将被重定向到 `movies.txt` /app,但您甚至不会看到此页面,因为您会/app/movies立即被重定向到 `movies.txt`。
现在,让我们在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.vuepages/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 了🎉
🎬 电影页面
Popular this week我们需要在电影页面上按不同类别(例如电影或电视剧)显示电影列表New releases。在将此功能集成到页面之前,我们需要一些方法来从 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 = {
// ...
};
我们刚刚配置好了要在首页显示的所有不同类别,每个类别都有标题、查询条件和排序方式。接下来,我们还要编写一个函数,用于获取电影列表,输入参数为以下类别之一:
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来渲染一部特定的电影。我们可以参考电影组件文件。
完美!我们的电影列表已经准备就绪!现在还差最后一个功能:允许用户点击电影查看详情。要实现这个功能,您可以复制电影模态框文件、筛选模态框文件和模态框存储文件。由于这些文件仅与 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>
接下来,我们来设置状态管理,它将作为整个应用程序的权威信息来源,用于判断电影是否已在观看列表中。为此,我们可以从 GitHub 复制mylist store 文件。
最后,我们定义了一个组件,它将作为按钮用于将电影添加到观看列表/从观看列表中删除电影。我们可以在观看列表组件文件中找到该组件。
信不信由你,Netflix 的克隆版已经准备好了!🥳 我们应该把它放到网上,让所有人都能看到,对吧?
🚀 部署
我们将把 Nuxt 项目部署到Vercel上。我爱上这个平台是因为它部署起来很方便,而且几乎所有业余项目都可以免费使用。
在 GitHub 上为我们的项目创建仓库后,我们在 Vercel 上创建一个新项目,并指向该仓库。我们配置构建流程,指定npm run generate构建路径、dist输出文件夹和npm install安装命令。等待 Vercel 完成构建后,我们将获得一个包含我们网站的自定义 Vercel 子域名。
当我们访问它时,发现开始出现网络错误😬 我们查看控制台,发现 Appwrite 出现 CORS 错误……但这是为什么呢?🤔
到目前为止,我们一直在本地开发网站,也就是说我们使用的是主机名localhost。幸运的是,Appwrite 允许所有通信都通过该localhost主机名进行,这大大简化了开发过程。由于我们现在使用的是 Vercel 主机名,Appwrite 不再信任该主机名,因此我们需要将其配置为生产平台。为此,我们访问 Appwrite 控制台网站并进入我们的项目。在控制面板中向下滚动一点,我们会看到“平台”Platforms部分。在这里,我们需要添加一个新的 Web 平台,并将分配给您的主机名 Vercel 添加到该部分。
添加平台后,Appwrite 现在信任我们在 Vercel 上的部署,我们可以开始使用了!🥳 信不信由你,我们刚刚使用 Appwrite 创建了一个 Netflix 克隆版(几乎)。
👨🎓 结论
我们已经成功使用 Appwrite 克隆了 Netflix 电影。正如你所见,Appwrite 的强大之处在于,你的想象力就是你的极限!想要成为 Appwrite 社区的一员,欢迎加入我们的Discord社区服务器。我迫不及待地想见到你,看看你用 Appwrite 创造出了什么!🤩
这个项目还没结束!😎 随着 Appwrite 即将发布的版本,我们将不断改进这款 Netflix 克隆应用,并添加更多功能。敬请期待视频流媒体播放、后端自定义修改等等!
以下是一些实用链接和资源:
🔗了解更多
您可以使用以下资源了解更多关于 Appwrite 及其服务的信息并获得帮助
文章来源:https://dev.to/appwrite/introducing-almost-netflix-a-netflix-clone-built-with-vue-and-appwrite-34nb








