diff --git a/src/routes/docs/tutorials/nextjs/+layout.svelte b/src/routes/docs/tutorials/nextjs/+layout.svelte new file mode 100644 index 0000000000..fb9fb3980f --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/+layout.svelte @@ -0,0 +1,10 @@ + + + diff --git a/src/routes/docs/tutorials/nextjs/+layout.ts b/src/routes/docs/tutorials/nextjs/+layout.ts new file mode 100644 index 0000000000..562b11506f --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/+layout.ts @@ -0,0 +1,11 @@ +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = ({ url }) => { + const tutorials = import.meta.glob('./**/*.markdoc', { + eager: true + }); + return { + tutorials, + pathname: url.pathname + }; +}; diff --git a/src/routes/docs/tutorials/nextjs/+page.ts b/src/routes/docs/tutorials/nextjs/+page.ts new file mode 100644 index 0000000000..ec27ca7ab8 --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/+page.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async () => { + throw redirect(303, '/docs/tutorials/nextjs/step-1'); +}; diff --git a/src/routes/docs/tutorials/nextjs/step-1/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-1/+page.markdoc new file mode 100644 index 0000000000..e3744c3ac5 --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-1/+page.markdoc @@ -0,0 +1,28 @@ +--- +layout: tutorial +title: Build an expense tracker with Next.js +description: Build a Next.js project using Appwrite. +step: 1 +difficulty: beginner +readtime: 18 +--- + +**Expense tracker** is a simple web application that allows you to track your expenses. It is built using [Next.js](https://nextjs.org/) [app router](https://nextjs.org/docs/app) and [Appwrite](https://appwrite.io/). + +![Create project screen](/images/docs/tutorials/expense-tracker-app.png) + +# Concepts {% #concepts %} +This tutorial will introduce the following concepts: + +1. Introduction to Appwrite +2. Make first request +3. Implement authtentication +4. Intro to databases +5. Create, read, update and delete documents +5. Queries documents +6. Add pagination + + +# Prerequisites {% #prerequisites %} +1. Basic knowledge of JavaScript, [React](https://react.dev/learn) and [Next.js](https://nextjs.org/docs). +2. Have [Node.js](https://nodejs.org/en) and [NPM](https://www.npmjs.com/) installed on your computer \ No newline at end of file diff --git a/src/routes/docs/tutorials/nextjs/step-2/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-2/+page.markdoc new file mode 100644 index 0000000000..a2e58a143c --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-2/+page.markdoc @@ -0,0 +1,106 @@ +--- +layout: tutorial +title: Create Next.js app +description: Create a Next.js project using Appwrite. +step: 2 +--- + +# Create Next.js project {% #create-next-js-project %} + +Create a new Next.js project using the `create-next-app` command. + +```sh +npx create-next-app expense-tracker --eslint +cd expense-tracker +``` + +We are using the `--eslint` flags to add ESLint to our project. + +What are the options you plan to take? + +```sh +✔ Would you like to use TypeScript? … No / Yes : No +✔ Would you like to use Tailwind CSS? … No / Yes : No +✔ Would you like to use `src/` directory? … No / Yes : Yes +✔ Would you like to use App Router? (recommended) … No / Yes: Yes +✔ Would you like to customize the default import alias? … No / Yes : No + +Creating a new Next.js app in expense-tracker. +``` + +You can also follow the responses from above. We will be using `app` directory for project. Also We are not using TypeScript and Tailwind CSS. So we can skip those options. + +# Install dependencies {% #install-dependencies %} + +Install the [JavaScript Appwrite SDK](https://appwrite.io/docs/sdks). + +```sh +npm install appwrite +``` + +Install the [Appwrite CSS library](https://pink.appwrite.io/) and icons package. This will give us a nice looking UI for our app. We just have to use the classes provided by the CSS library. + +```sh +npm install "@appwrite.io/pink" "@appwrite.io/pink-icons" +``` + +Import the CSS file and icons in the top of `app/layout.js` file. + +```js +// /src/app/layout.js + +import "@appwrite.io/pink"; +import "@appwrite.io/pink-icons"; +``` + +We are going to use a custom className `lite-bg` which we can define in the `globals.css` file. + +```css +/* /src/app/globals.css */ + +.lite-bg { + background-color: hsl(var(--color-primary-100) / 0.2); +} +``` + +We will be using cookie to store the session token. So we need to install `next-cookie` package. We will also be using `react-hot-toast` to show toast messages. + +```bash +npm install next-cookie react-hot-toast +``` + +We also have to add the `Toaster` component in the `app/layout.js` file. This component will render all toasts. The `layout.js` file will look like this. + +```js +// /src/app/layout.js + +import { Toaster } from "react-hot-toast"; +import { Inter } from "next/font/google"; + +import "@appwrite.io/pink"; +import "@appwrite.io/pink-icons"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"] }); + +export default function RootLayout({ children }) { + return ( + + + {children} + + + + ); +} +``` + +# Start the development server {% #start-the-development-server %} + +You can start the development server using the following command. + +```sh +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser to see the app. \ No newline at end of file diff --git a/src/routes/docs/tutorials/nextjs/step-3/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-3/+page.markdoc new file mode 100644 index 0000000000..333764e2e7 --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-3/+page.markdoc @@ -0,0 +1,73 @@ +--- +layout: tutorial +title: Set up Appwrite +description: Learn how to set up Appwrite in your Next.js project +step: 3 +--- + +# Create project {% #create-project %} + +Head to the [Appwrite Console](https://cloud.appwrite.io/console). If this is your first time using Appwrite, create an account or log in to your existing account. Create a new project named `expanse-tracker`. + +{% only_dark %} +![Create project screen](/images/docs/quick-starts/dark/create-project.png) +{% /only_dark %} +{% only_light %} +![Create project screen](/images/docs/quick-starts/create-project.png) +{% /only_light %} + +Then, under **Add a platform**, add a **Web app**. The **Hostname** should be localhost. + +{% only_dark %} +![Add a platform](/images/docs/quick-starts/dark/add-platform.png) +{% /only_dark %} +{% only_light %} +![Add a platform](/images/docs/quick-starts/add-platform.png) +{% /only_light %} + +You can skip optional steps. + +# Initialize Appwrite SDK {% #init-sdk %} + +To use Appwrite in our Next.js app, we'll need to find our project ID. Find your project's ID in the **Settings** page. + +{% only_dark %} +![Project settings screen](/images/docs/quick-starts/dark/project-id.png) +{% /only_dark %} +{% only_light %} +![Project settings screen](/images/docs/quick-starts/project-id.png) +{% /only_light %} + +First create a file that will hold env variables. Create a new file `.env.local` in the root of your project. Replace `` with your project ID + +```js +// .env.local + +NEXT_PUBLIC_APPWRITE_ENDPOINT = https://cloud.appwrite.io/v1 +NEXT_PUBLIC_APPWRITE_PROJECT = +``` + +Create a new file `src/config/appwrite.js` to hold our Appwrite related code. +Only one instance of the `Client()` class should be created per app. +Add the following code to it. + +```js +// /src/config/appwrite.js + +import { Client, Databases, Account } from "appwrite"; + +export const config = { + APPWRITE_ENDPOINT: process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT, + APPWRITE_PROJECT: process.env.NEXT_PUBLIC_APPWRITE_PROJECT, +}; + +const client = new Client(); +client + .setEndpoint(config.APPWRITE_ENDPOINT) + .setProject(config.APPWRITE_PROJECT); + +export const account = new Account(client); +export const database = new Databases(client); +``` + +Here we are using the Appwrite endpoints and project ID from the `.env.local` file to initialize the Appwrite SDK. \ No newline at end of file diff --git a/src/routes/docs/tutorials/nextjs/step-4/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-4/+page.markdoc new file mode 100644 index 0000000000..1fd07949c2 --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-4/+page.markdoc @@ -0,0 +1,215 @@ +--- +layout: tutorial +title: Implement authtentication +description: Add Appwrite authentication to your Next.js app +step: 4 +--- + +# Signup page {% #signup-page %} + +In your project under the app directory, create a new file `app/(pages)/signup/page.js`. Next.js will automatically create a route for this page `/signup`. Let's create the signup form. + +We will be using Appwrite's `create` method to create a new user. We will also be using `createEmailSession` method to create a new session. + +```js +// /src/app/(pages)/signup/page.js + +"use client"; + +import Link from "next/link"; +import toast from "react-hot-toast"; +import { useRouter } from "next/navigation"; +import { ID } from "appwrite"; +import { useCookie } from "next-cookie"; + +import { account } from "@/config/appwrite"; + +const SignUp = (props) => { + const router = useRouter(); + const cookie = useCookie(props.cookie); + + const signup = async (e) => { + e.preventDefault(); + const form = e.target; + const formData = new FormData(form); + const email = formData.get("email"); + const password = formData.get("password"); + const name = formData.get("name"); + if (!email || !password || !name) { + toast.error("Please fill in all the fields"); + return; + } + try { + await account.create(ID.unique(), email, password, name); + await account.createEmailSession(email, password); + const jwt = await account.createJWT(); + const user = await account.get(); + cookie.set("jwt", jwt.jwt); + cookie.set("userid", user.$id); + toast.success("Signed up successfully"); + router.push("/"); + } catch (error) { + toast.error(error.message); + } + }; + + return ( +
+
+

Sign Up

+

+ Already have an account?{" "} + + Login + +

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ ); +}; + +export default SignUp; +``` + +First, we are creating the user in our Appwrite instance. After the user is created, we are creating a session using `createEmailSession` method, to login the user into our app. We then create a JWT token using the `createJWT` method, and store the token and user ID into a cookie. This allows the user to remain logged in, even if the app is closed. + +With all that done, we can redirect the user to the home page. + +# Login page {% #login-page %} + +In your project under the app directory, create a new file `app/(pages)/login/page.js`. Next.js will automatically create a route for this page `/login`. Let's create the login form. + +We will be using Appwrite's `createEmailSession` method to create a new session. + +```js +// /src/app/(pages)/login/page.js + +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; +import { useCookie } from "next-cookie"; + +import { account } from "@/config/appwrite"; + +const Login = (props) => { + const router = useRouter(); + const cookie = useCookie(props.cookie); + + const loginUser = async (e) => { + e.preventDefault(); + const form = e.target; + const formData = new FormData(form); + const email = formData.get("email"); + const password = formData.get("password"); + if (!email || !password) { + toast.error("Please fill in all the fields"); + return; + } + try { + await account.createEmailSession(email, password); + const jwt = await account.createJWT(); + const user = await account.get(); + cookie.set("jwt", jwt.jwt); + cookie.set("userid", user.$id); + toast.success("Logged in successfully"); + router.push("/"); + } catch (error) { + toast.error(error.message); + } + }; + + return ( +
+
+

Login

+

+ Don't have an account?{" "} + + Sign Up + +

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ ); +}; + +export default Login; +``` + +After the session is created, we are creating a JWT token using `createJWT` method. We are storing the JWT token and user id in the cookie. Then we are redirecting the user to the home page. \ No newline at end of file diff --git a/src/routes/docs/tutorials/nextjs/step-5/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-5/+page.markdoc new file mode 100644 index 0000000000..1e99d3f65b --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-5/+page.markdoc @@ -0,0 +1,326 @@ +--- +layout: tutorial +title: Add expenses +description: Add databases and queries to store user data in your Next.js app. +step: 5 +--- +# Create collection {% #create-collection %} +In Appwrite, data is stored as a collection of documents. Create a collection in the [Appwrite Console](https://cloud.appwrite.io/) to store our expenses. + +{% only_dark %} +![Create project screen](/images/docs/tutorials/dark/expense-tracker-collection.png) +{% /only_dark %} +{% only_light %} +![Create project screen](/images/docs/tutorials/expense-tracker-collection.png) +{% /only_light %} + +In the collection go to attributes and add the following attributes: +| Field | Type | Required | Elements | +|-------------|---------|----------|----------------| +| userId | String | Yes | | +| title | String | Yes | | +| amount | Float | Yes | | +| type | Enum | Yes | Paid, Received | + +Now that we have created a collection, we have to add permissions to the collection. Go to the settings tab, scoll to the permissions sections add the following permissions: + +{% only_dark %} +![Create project screen](/images/docs/tutorials/dark/collection-permissions.png) +{% /only_dark %} +{% only_light %} +![Create project screen](/images/docs/tutorials/collection-permissions.png) +{% /only_light %} + +You can also add permission for a specific user or a team. + +Now you will get a database ID and collection ID. First Copy the these two and add them in the `.env.local` file + +```js +// .env.local + +NEXT_PUBLIC_APPWRITE_ENDPOINT = https://cloud.appwrite.io/v1 +NEXT_PUBLIC_APPWRITE_PROJECT = +NEXT_PUBLIC_EXPENSE_DATABASE_ID = +NEXT_PUBLIC_EXPENSE_COLLECTION_ID = +``` + +Now add them in the Appwrite config in the `/config/appwrite.js` file. + +```js +// /src/config/appwrite.js + +export const config = { + APPWRITE_ENDPOINT: process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT, + APPWRITE_PROJECT: process.env.NEXT_PUBLIC_APPWRITE_PROJECT, + EXPENSE_DATABASE_ID: process.env.NEXT_PUBLIC_EXPENSE_DATABASE_ID, + EXPENSE_COLLECTION_ID: process.env.NEXT_PUBLIC_EXPENSE_COLLECTION_ID, +}; + +export const EXPENSE_DATABASE_ID = config.EXPENSE_DATABASE_ID; +export const EXPENSE_COLLECTION_ID = config.EXPENSE_COLLECTION_ID; +``` + +Here is the final Appwrite config file. + +```js +// /src/config/appwrite.js + +import { Client, Databases, Account } from "appwrite"; + +export const config = { + APPWRITE_ENDPOINT: process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT, + APPWRITE_PROJECT: process.env.NEXT_PUBLIC_APPWRITE_PROJECT, + EXPENSE_DATABASE_ID: process.env.NEXT_PUBLIC_EXPENSE_DATABASE_ID, + EXPENSE_COLLECTION_ID: process.env.NEXT_PUBLIC_EXPENSE_COLLECTION_ID, +}; + +const client = new Client(); +client + .setEndpoint(config.APPWRITE_ENDPOINT) + .setProject(config.APPWRITE_PROJECT); + +export const EXPENSE_DATABASE_ID = config.EXPENSE_DATABASE_ID; +export const EXPENSE_COLLECTION_ID = config.EXPENSE_COLLECTION_ID; + +export const account = new Account(client); +export const database = new Databases(client); +``` + +# Add new expense {% #add-new-expense %} +Now that we have a collection, we can add a new expense to the database. + +First we need to create a new API endpoint to add an expense. Create a new file `app/api/expense/route.js` and add the following code. + +```js +// /src/app/api/expense/route.js + +import { ID } from "appwrite"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + EXPENSE_COLLECTION_ID, + EXPENSE_DATABASE_ID, + account, + database, +} from "@/config/appwrite"; + +export async function POST(req) { + const body = await req.json(); + const { userId, title, amount, type } = body; + if (!userId || !title || !amount || !type) { + return new NextResponse("Missing fields", { status: 401 }); + } + + const headersList = headers(); + const jwt = headersList.get("jwt"); + + if (!jwt) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + account.client.setJWT(jwt); + + try { + await database.createDocument( + EXPENSE_DATABASE_ID, + EXPENSE_COLLECTION_ID, + ID.unique(), + { + userId, + title, + amount, + type, + } + ); + return new NextResponse("Saved successfully", { status: 200 }); + } catch (error) { + console.log(error); + return new NextResponse("Internal server error", { + status: 500, + error: error.message, + }); + } +} +``` + +In this endpoint, we use the `createDocument` method to add a new document to the database, which accepts the following parameters: `databaseId`, `collectionId`, `documentId`, `data`. + +In Appwrite, documents live in a collection, which in turn live in a database. So to add a document, we need its ID, as well as the ID of its containing collection, and the ID of the database that contains said collection. We also need the data that the document contains. + +Since we're creating a new document, it doesn't yet have an ID. We can use the `ID.unique()` method to generate a unique one for it. + +The document created by this endpoint represents a new expense. The endpoint is now available at `http://localhost:3000/api/expense`. + +# Create expense popup {% #create-expense-popup %} + +Now that we have an endpoint to add a new expense, we can create a popup to add a new expense. We will manage the in our homepage.In Next.js the `app/page.js` serves as the home page. Open the file and add the following code. + +```js +// /src/app/page.js + +"use client"; + +import React, { useState } from "react"; +import { useCookie } from "next-cookie"; +import toast from "react-hot-toast"; + +const Home = (props) => { + const [popup, setPopup] = useState(null); + const cookie = useCookie(props.cookie); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const createExpense = async (item) => { + const response = await fetch("/api/expense", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId, + title: item.title, + amount: item.amount, + type: item.type, + }), + }); + + if (response.status === 200) { + toast.success("Saved successfully"); + setPopup(null); + } + }; + + return ( + <> + {popup?.type === "new" && ( + + )} +
+

Your expenses

+ +
+ + ); +}; + +export default Home; +``` + +We are rendering a new component `CreateExpensePopup` when the user clicks on the `New Expense` button. The `CreateExpensePopup` component will be used to create a new expense. + +Let's create the `CreateExpensePopup` component. Create a new file `components/CreateExpensePopup.js` and add the following code. + +```js +// /src/components/CreateExpensePopup.js + +"use client"; + +import React from "react"; +import toast from "react-hot-toast"; + +const CreateExpensePopup = ({ setPopup, createExpense }) => { + const handleSubmit = async (e) => { + e.preventDefault(); + const form = e.target; + const formData = new FormData(form); + + const item = { + title: formData.get("title"), + amount: formData.get("amount"), + type: formData.get("type"), + }; + + if (!item.title) { + return toast.error("Title is required"); + } + if (!item.amount) { + return toast.error("Amount is required"); + } + if (!item.type) { + return toast.error("Type is required"); + } + + try { + await createExpense(item); + } catch (error) { + toast.error(error.message); + } + }; + + return ( +
+
+ +

+ Create Expense +

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ ); +}; + +export default CreateExpensePopup; +``` + +We are using the `createExpense` function on the `handleSubmit` function to create a new expense. + +Now your expense createtion form is created, you can add a new expense to the database. And you can see the expense in the Appwrite console under the documents tab in the collection. diff --git a/src/routes/docs/tutorials/nextjs/step-6/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-6/+page.markdoc new file mode 100644 index 0000000000..5f671d9979 --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-6/+page.markdoc @@ -0,0 +1,293 @@ +--- +layout: tutorial +title: Create expense list +description: Fetch user expenses from Appwrite database and display them in a list. +step: 6 +--- +# Create list API {% #create-list-api %} +We will create a new API endpoint that will fetch the user expenses from the database. Create a new file `app/api/expense/list/route.js` and add the following code. + +```js +// /src/app/api/expense/list/route.js + +import { Query } from "appwrite"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + EXPENSE_COLLECTION_ID, + EXPENSE_DATABASE_ID, + account, + database, +} from "@/config/appwrite"; + +export async function POST(req) { + const body = await req.json(); + const { userId } = body; + const headersList = headers(); + const jwt = headersList.get("jwt"); + + if (!userId || !jwt) { + return new NextResponse("Unauthorized", { status: 401 }); + } + account.client.setJWT(jwt); + + try { + const items = await database.listDocuments( + EXPENSE_DATABASE_ID, + EXPENSE_COLLECTION_ID, + [Query.equal("userId", [userId]), Query.orderDesc("$createdAt")] + ); + return new NextResponse(JSON.stringify({ items: items.documents })); + } catch (error) { + console.log(error); + return new NextResponse("Internal server error", { + status: 500, + error: error.message, + }); + } +} +``` + +With Appwrite's `listDocuments` method we can fetch the documents from the database. It will create a new API endpoint `http://localhost:3000/api/expense/list` that will return the user expenses. + +We are also using Appwrite's `Query` class to filter the documents. We have used the `Query.equal` method to filter the documents by `userId`. We have also used the `Query.orderDesc` method to order the documents by `$createdAt` in descending order. + + +# Expense list component {% #expense-list-component %} + +Let's fetch the user expenses from the database and display them in a list. Open the `app/page.js` file and add the following code. + +```js +// /src/app/page.js + +"use client"; + +import React, { useEffect, useState } from "react"; +import { useCookie } from "next-cookie"; +import toast from "react-hot-toast"; +import { useRouter } from "next/navigation"; + +import ExpenseList from "@/components/ExpenseList"; + +const Home = (props) => { + const [expenses, setExpenses] = useState(); + const [popup, setPopup] = useState(null); + const cookie = useCookie(props.cookie); + const router = useRouter(); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const getExpenses = async () => { + if (!jwt) { + router.push("/login"); + return; + } + + const response = await fetch("/api/expense/list", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId, + }), + }); + const data = await response.json(); + if (data.error === "Failed to verify JWT. Invalid token: Expired") { + cookie.remove("jwt"); + cookie.remove("userid"); + toast.error("Session expired, please login again"); + router.push("/login"); + } else { + setExpenses(data.items); + } + }; + + useEffect(() => { + getExpenses(); + }, []); + + const createExpense = async (item) => { + ... + } + + return ( + <> + + + ); +}; + +export default Home; +``` + +Here is the full code for the `app/page.js` file. + +```js +// /src/app/page.js + +"use client"; + +import React, { useEffect, useState } from "react"; +import { useCookie } from "next-cookie"; +import toast from "react-hot-toast"; +import { useRouter } from "next/navigation"; + +import ExpenseList from "@/components/ExpenseList"; +import CreateExpensePopup from "@/components/CreateExpensePopup"; + +const Home = (props) => { + const [expenses, setExpenses] = useState(); + const [popup, setPopup] = useState(null); + const cookie = useCookie(props.cookie); + const router = useRouter(); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const getExpenses = async () => { + if (!jwt) { + router.push("/login"); + return; + } + + const response = await fetch("/api/expense/list", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId, + }), + }); + const data = await response.json(); + if (data.error === "Failed to verify JWT. Invalid token: Expired") { + cookie.remove("jwt"); + cookie.remove("userid"); + toast.error("Session expired, please login again"); + router.push("/login"); + } else { + setExpenses(data.items); + } + }; + + useEffect(() => { + getExpenses(); + }, []); + + const createExpense = async (item) => { + const response = await fetch("/api/expense", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId, + title: item.title, + amount: item.amount, + type: item.type, + }), + }); + + if (response.status === 200) { + toast.success("Saved successfully"); + setPopup(null); + } + }; + + return ( + <> + {popup?.type === "new" && ( + + )} +
+

Your expenses

+ + +
+ + ); +}; + +export default Home; +``` + +Now we will render the Expenses. Create a new file `components/ExpenseList.js`. This component will receive the `expenses` array and we will map over the array and render the expenses in a list. + +```js +// /src/components/ExpenseList.js + +import React from "react"; + +const ExpenseList = ({ expenses, setPopup }) => { + return ( +
+ {expenses?.length > 0 && + expenses?.map((expense) => ( +
+
+

{expense.title}

+
+

+ {expense.type === "Paid" && "-"} + {expense.type === "Received" && "+"}${expense.amount} +

+ + {expense.type} + +
+
+
+

+ {new Date(expense.$createdAt).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + })} +

+
+ + +
+
+
+ ))} +
+ ); +}; + +export default ExpenseList; +``` + +We are also rendering edit and delete buttons. We will add the functionality for these buttons in the next step. \ No newline at end of file diff --git a/src/routes/docs/tutorials/nextjs/step-7/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-7/+page.markdoc new file mode 100644 index 0000000000..fe686afeea --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-7/+page.markdoc @@ -0,0 +1,617 @@ +--- +layout: tutorial +title: Edit and delete expenses +description: Edit and delete expenses from expense list +step: 7 +--- + +In the expense card we have added two buttons to edit and delete the expense. We will add the functionality to these buttons in this step. + +# Edit expense {% #edit-expense %} + +First create a API to edit the expense. We will create `PUT` api in the same `app/api/expense/route.js` file. We will use the `updateDocument` method of the `database` service to update the expense document. + +```js +// /src/app/api/expense/route.js + +import { ID } from "appwrite"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + EXPENSE_COLLECTION_ID, + EXPENSE_DATABASE_ID, + account, + database, +} from "@/config/appwrite"; + +// Post - create expense +export async function POST(req) { + ... +} + +// Put - update expense +export async function PUT(req) { + const body = await req.json(); + const { title, amount, type, docId } = body; + if (!title || !amount || !type || !docId) { + return new NextResponse("Missing fields", { status: 401 }); + } + + const headersList = headers(); + const jwt = headersList.get("jwt"); + + if (!jwt) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + account.client.setJWT(jwt); + + try { + await database.updateDocument( + EXPENSE_DATABASE_ID, + EXPENSE_COLLECTION_ID, + docId, + { + title, + amount, + type, + } + ); + + return new NextResponse("Updated successfully", { status: 200 }); + } catch (error) { + return new NextResponse("Internal server error", { + status: 500, + error: error.message, + }); + } +} +``` + +When the user clicks on the edit button, we will show the expense popup form with the expense details filled in. The user can then edit the expense and save it. + +We will add a click event handler to the edit button in the `ExpenseList` component. + +```js +// /src/components/ExpenseList.js + + +``` + +The `setPopup` function is passed as a prop to the `ExpenseList` component from the `ExpenseList` component. + +We will also add the `editExpense` function. This function will make a `PUT` request to the `http://localhost:3000/api/expense` endpoint to update the expense. + +Edit the `app/page.js` file to add the `editExpense` function. + +```js +// /src/app/page.js + +"use client"; + +import React, { useEffect, useState } from "react"; +import { useCookie } from "next-cookie"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; + +import ExpenseList from "@/components/ExpenseList"; +import EditExpensePopup from "@/components/EditExpensePopup"; +import CreateExpensePopup from "@/components/CreateExpensePopup"; + +const Home = (props) => { + const [expenses, setExpenses] = useState(); + const [popup, setPopup] = useState(null); + const router = useRouter(); + const cookie = useCookie(props.cookie); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const getExpenses = async () => { + ... + } + + useEffect(() => { + getExpenses(); + }, []); + + const createExpense = async (item) => { + ... + } + + const editExpense = async (item) => { + const response = await fetch("/api/expense", { + method: "PUT", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify(item), + }); + + if (response.status === 200) { + toast.success("Updated successfully"); + setPopup(null); + getExpenses(); // get the expenses again + } + } + + return ( + <> + {popup?.type === "edit" && ( + + )} + + ) +} +``` + +Let's create a `EditExpensePopup` component in the `components` folder `components/EditExpensePopup.js`. This component will be used to show the popup form to edit the expense. + +```js +// /src/components/EditExpensePopup.js + +"use client"; + +import React from "react"; + +const EditExpensePopup = ({ + title, + amount, + type, + docId, + setPopup, + editExpense, +}) => { + const handleSubmit = async (e) => { + e.preventDefault(); + const form = e.target; + const formData = new FormData(form); + + const item = { + docId, + title: formData.get("title"), + amount: formData.get("amount"), + type: formData.get("type"), + }; + + if (!item.title) { + return toast.error("Title is required"); + } + if (!item.amount) { + return toast.error("Amount is required"); + } + if (!item.type) { + return toast.error("Type is required"); + } + + try { + await editExpense(item); + } catch (error) { + toast.error(error.message); + } + }; + + return ( +
+
+ +

+ Update Expense +

+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+ ); +}; + +export default EditExpensePopup; +``` + +# Delete expense {% #delete-expense %} + +First create a API to delete the expense. We will create `DELETE` api in the same `app/api/expense/route.js` file. We will use the `deleteDocument` method of the `database` service to delete the expense document. + +```js +// /src/app/api/expense/route.js + +import { ID } from "appwrite"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + EXPENSE_COLLECTION_ID, + EXPENSE_DATABASE_ID, + account, + database, +} from "@/config/appwrite"; + +// Post - create expense +export async function Post(req) { + ... +} + +// Put - update expense +export async function PUT(req) { + ... +} + +// Delete - delete expense +export async function DELETE(req) { + const body = await req.json(); + const { docId } = body; + + if (!docId) { + return new NextResponse("Missing fields", { status: 401 }); + } + + const headersList = headers(); + const jwt = headersList.get("jwt"); + + if (!jwt) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + account.client.setJWT(jwt); + + try { + await database.deleteDocument( + EXPENSE_DATABASE_ID, + EXPENSE_COLLECTION_ID, + docId + ); + return new NextResponse("Deleted successfully", { status: 200 }); + } catch (error) { + return new NextResponse("Internal server error", { + status: 500, + error: error.message, + }); + } +} +``` + +When the user clicks on the delete button, we will show a confirmation popup to the user. If the user confirms the delete action, we will delete the expense from the database. + +We will add a click event handler to the delete button in the `ExpenseList` component. + +```js +// /src/components/ExpenseList.js + +``` + +The `setPopup` function is passed as a prop to the `ExpenseList` component from the `ExpenseList` component. This is the same state that we used to edit the expense. + +We will also add the `deleteExpense` function in the `app/page.js` file. This function will make a `DELETE` request to the `/api/expense` endpoint to delete the expense. We are using the `docId` to identify the expense to delete. + +```js +// /src/app/page.js + +"use client"; + +import React, { useState } from "react"; +import { useCookie } from "next-cookie"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; + +import DeleteExpensePopup from "@/components/DeleteExpensePopup"; +import CreateExpensePopup from "@/components/CreateExpensePopup"; +import ExpenseList from "@/components/ExpenseList"; +import EditExpensePopup from "@/components/EditExpensePopup"; + +const Home = (props) => { + const [expenses, setExpenses] = useState(); + const [popup, setPopup] = useState(null); + const router = useRouter(); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const getExpenses = async () => { + ... + } + + useEffect(() => { + getExpenses(); + }, []); + + const createExpense = async (item) => { + ... + } + + const editExpense = async (item) => { + ... + } + + const deleteExpense = async (docId) => { + const response = await fetch("/api/expense", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ docId: docId }), + }); + + if (response.status === 200) { + toast.success("Deleted successfully"); + setPopup(null); + getExpenses(); // get the expenses again + } + }; + + return ( + <> + {popup?.type === "delete" && ( + + )} + + ); +}; + +export default Home; +``` + +Let's create a `DeleteExpensePopup` component in the `components` folder `components/DeleteExpensePopup.js`. This component will be used to show the popup form to delete the expense. + +```js +// /src/components/DeleteExpensePopup.js + +const DeleteExpensePopup = ({ popup, setPopup, deleteExpense }) => { + return ( +
+
+

+ Delete Expense +

+
+ + +
+
+
+ ); +}; + +export default DeleteExpensePopup; +``` + + +Here is the full code of `app/page.js` file after edit and delete expense functionality. + +```js +// /src/app/page.js + +"use client"; + +import React, { useState } from "react"; +import { useCookie } from "next-cookie"; +import { useRouter } from "next/navigation"; +import toast from "react-hot-toast"; + +import DeleteExpensePopup from "@/components/DeleteExpensePopup"; +import CreateExpensePopup from "@/components/CreateExpensePopup"; +import ExpenseList from "@/components/ExpenseList"; +import EditExpensePopup from "@/components/EditExpensePopup"; + +const Home = (props) => { + const [expenses, setExpenses] = useState(); + const [popup, setPopup] = useState(null); + const router = useRouter(); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const getExpenses = async () => { + if (!jwt) { + router.push("/login"); + return; + } + const response = await fetch("/api/expense/list", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId: userId, + }), + }); + const data = await response.json(); + if (data.message === "Failed to verify JWT. Invalid token: Expired") { + cookie.remove("jwt"); + cookie.remove("userid"); + toast.error("Session expired, please login again"); + router.push("/login"); + } else { + setExpenses(data.items); + setTotal(data.total); + } + }; + + useEffect(() => { + getExpenses(); + }, []); + + const createExpense = async (title, amount, type) => { + const response = await fetch("/api/expense", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId: userId, + title: title, + amount: amount, + type: type, + }), + }); + + if (response.status === 200) { + toast.success("Saved successfully"); + setPopup(null); + getExpenses(); + } + }; + + const editExpense = async (docId, title, amount, type) => { + const response = await fetch("/api/expense", { + method: "PUT", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + docId: docId, + title: title, + amount: amount, + type: type, + }), + }); + + if (response.status === 200) { + toast.success("Updated successfully"); + setPopup(null); + getExpenses(); + } + }; + + const deleteExpense = async (docId) => { + const response = await fetch("/api/expense", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ docId: docId }), + }); + + if (response.status === 200) { + toast.success("Deleted successfully"); + setPopup(null); + getExpenses(); // get the expenses again + } + }; + + return ( + <> + {popup?.type === "new" && ( + + )} + {popup?.type === "edit" && ( + + )} + {popup?.type === "delete" && ( + + )} +
+

Your expenses

+ + +
+ + ); +}; + +export default Home; +``` \ No newline at end of file diff --git a/src/routes/docs/tutorials/nextjs/step-8/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-8/+page.markdoc new file mode 100644 index 0000000000..738097b3e0 --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-8/+page.markdoc @@ -0,0 +1,379 @@ +--- +layout: tutorial +title: Add pagination +description: Add pagination to the expense list +step: 8 +--- + +# Add pagination in API {% #add-pagination-api %} +We will add pagination to the API. We edit our `app/api/expense/route.js` and add the pagination logic: + +```js +// /src/app/api/expense/route.js + +import { Query } from "appwrite"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +import { + EXPENSE_COLLECTION_ID, + EXPENSE_DATABASE_ID, + account, + database, +} from "@/config/appwrite"; + +export async function POST(req) { + const body = await req.json(); + const { userId, limit, start } = body; // Add limit and start + const headersList = headers(); + const jwt = headersList.get("jwt"); + + if (!userId || !jwt) { + return new NextResponse("Unauthorized", { status: 401 }); + } + account.client.setJWT(jwt); + + try { + const items = await database.listDocuments( + EXPENSE_DATABASE_ID, + EXPENSE_COLLECTION_ID, + [ + Query.equal("userId", [userId]), + Query.orderDesc("$createdAt"), + Query.limit(limit), // Query limit + Query.offset(parseInt(start)), // Query start offset + ] + ); + return new NextResponse( + JSON.stringify({ items: items.documents, total: items.total }) + ); + } catch (error) { + console.log(error); + return new NextResponse("Internal server error", { + status: 500, + error: error.message, + }); + } +} +``` + +We are now accepting `limit` and `start` as parameters. `limit` is the number of items to return and `start` is the offset from the beginning of the list. We are also returning the `total` number of items in the collection. + +We are using the `Query.limit` to limit the number of items returned and `Query.offset` to offset the items returned. + +Learn more about [Appwrite Query](https://appwrite.io/docs/products/databases/queries) and [Appwrite Pagination](https://appwrite.io/docs/products/databases/pagination) in the Appwrite documentation. + + +# Pagination in the frontend {% #add-pagination-frontend %} +We will now add pagination in the frontend. Edit the `app/page.js` file and add the following code: + +```js +// /src/app/page.js + +"use client"; + +import React, { useEffect, useState } from "react"; +import { useCookie } from "next-cookie"; +import toast from "react-hot-toast"; +import { useRouter } from "next/router"; + +import ExpenseList from "@/components/ExpenseList"; +import DeleteExpensePopup from "@/components/DeleteExpensePopup"; +import CreateExpensePopup from "@/components/CreateExpensePopup"; +import ExpenseList from "@/components/ExpenseList"; +import EditExpensePopup from "@/components/EditExpensePopup"; + +const Home = (props) => { + const [expenses, setExpenses] = useState(); + const [popup, setPopup] = useState(null); + // New state to track the start + const [start, setStart] = useState(0); + // New state to track the total + const [total, setTotal] = useState(0); + const limit = 5; // Add limit per page + const cookie = useCookie(props.cookie); + const router = useRouter(); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const getExpenses = async () => { + if (!jwt) { + router.push("/login"); + return; + } + + const response = await fetch("/api/expense/list", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId, start, limit, // Pass the start and limit + }), + }); + const data = await response.json(); + if (data.error === "Failed to verify JWT. Invalid token: Expired") { + cookie.remove("jwt"); + cookie.remove("userid"); + toast.error("Session expired, please login again"); + router.push("/login"); + } else { + setExpenses(data.items); + // Set the total + setTotal(data.total); + } + }; + + useEffect(() => { + getExpenses(); + }, [start]); + + // Calculate the number of pages + const pages = Math.ceil(total / limit); + + const createExpense = async (item) => { + ... + } + + const editExpense = async (item) => { + ... + } + + const deleteExpense = async (docId) => { + ... + } + + return ( + <> + // Popup components from previous steps +
+

Your expenses

+ + + // Pagination buttons will be here +
+ + ); +}; + +export default Home; +``` + +We are using the `start` and `limit` to get the expenses. We are also calculating the number of pages using the `total` and `limit`. + +We are also calculating `pages` using the `total` and `limit` with the [Math.ceil](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/ceil) function. + +Now, after the rendering the list, we will add the pagination buttons. Edit `app/page.js` and add the following code: + +```jsx +// /src/app/page.js + +
+

Your expenses

+ +
+ {expenses?.length > 0 && + [...Array(pages)].map((_, i) => ( + + ))} +
+
+``` + +We are making an array of `pages` and then rendering the buttons. We are also using the `start` to highlight the current page. + +Here is the final code for the `app/page.js` file after adding pagination: + +```jsx +// /src/app/page.js + +"use client"; + +import React, { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { useRouter } from "next/navigation"; +import { useCookie } from "next-cookie"; + +import DeleteExpensePopup from "@/components/DeleteExpensePopup"; +import CreateExpensePopup from "@/components/CreateExpensePopup"; +import ExpenseList from "@/components/ExpenseList"; +import EditExpensePopup from "@/components/EditExpensePopup"; + +const Home = (props) => { + const [expenses, setExpenses] = useState(); + const [popup, setPopup] = useState(null); + const [start, setStart] = useState(0); + const [total, setTotal] = useState(0); + const limit = 4; + const router = useRouter(); + const cookie = useCookie(props.cookie); + const jwt = cookie.get("jwt"); + const userId = cookie.get("userid"); + + const getExpenses = async () => { + if (!jwt) { + router.push("/login"); + return; + } + const response = await fetch("/api/expense/list", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId: userId, + start: start, + limit: limit, + }), + }); + const data = await response.json(); + if (data.message === "Failed to verify JWT. Invalid token: Expired") { + cookie.remove("jwt"); + cookie.remove("userid"); + toast.error("Session expired, please login again"); + router.push("/login"); + } else { + setExpenses(data.items); + setTotal(data.total); + } + }; + + useEffect(() => { + getExpenses(); + }, [start]); + + const createExpense = async (title, amount, type) => { + const response = await fetch("/api/expense", { + method: "POST", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + userId: userId, + title: title, + amount: amount, + type: type, + }), + }); + + if (response.status === 200) { + toast.success("Saved successfully"); + setPopup(null); + getExpenses(); + } + }; + + const editExpense = async (docId, title, amount, type) => { + const response = await fetch("/api/expense", { + method: "PUT", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ + docId: docId, + title: title, + amount: amount, + type: type, + }), + }); + + if (response.status === 200) { + toast.success("Updated successfully"); + setPopup(null); + getExpenses(); + } + }; + + const deleteExpense = async (docId) => { + const response = await fetch("/api/expense", { + method: "DELETE", + headers: { + "Content-Type": "application/json", + jwt, + }, + body: JSON.stringify({ docId: docId }), + }); + + if (response.status === 200) { + toast.success("Deleted successfully"); + setPopup(null); + getExpenses(); + setStart(0); + } + }; + + const pages = Math.ceil(total / limit); + + return ( + <> + {popup?.type === "new" && ( + + )} + {popup?.type === "edit" && ( + + )} + {popup?.type === "delete" && ( + + )} +
+

+ Your expenses +

+ + +
+ {expenses?.length > 0 && + [...Array(pages)].map((_, i) => ( + + ))} +
+
+ + ); +}; + +export default Home; +``` \ No newline at end of file diff --git a/src/routes/docs/tutorials/nextjs/step-9/+page.markdoc b/src/routes/docs/tutorials/nextjs/step-9/+page.markdoc new file mode 100644 index 0000000000..b1004caa93 --- /dev/null +++ b/src/routes/docs/tutorials/nextjs/step-9/+page.markdoc @@ -0,0 +1,12 @@ +--- +layout: tutorial +title: Next steps +description: View your Next.js project powered by Appwrite authentication and databases. +step: 9 +--- + +# Test your project {% #test-project %} +Run your project with `npm run dev` and open [http://localhost:3000](http://localhost:3000) in your browser. + + +![Create project screen](/images/docs/tutorials/expense-tracker-app.png) diff --git a/static/images/docs/tutorials/collection-permissions.png b/static/images/docs/tutorials/collection-permissions.png new file mode 100644 index 0000000000..a89ed025a6 Binary files /dev/null and b/static/images/docs/tutorials/collection-permissions.png differ diff --git a/static/images/docs/tutorials/dark/collection-permissions.png b/static/images/docs/tutorials/dark/collection-permissions.png new file mode 100644 index 0000000000..bf8e575d05 Binary files /dev/null and b/static/images/docs/tutorials/dark/collection-permissions.png differ diff --git a/static/images/docs/tutorials/dark/expense-tracker-collection.png b/static/images/docs/tutorials/dark/expense-tracker-collection.png new file mode 100644 index 0000000000..94fdeb3597 Binary files /dev/null and b/static/images/docs/tutorials/dark/expense-tracker-collection.png differ diff --git a/static/images/docs/tutorials/expense-tracker-app.png b/static/images/docs/tutorials/expense-tracker-app.png new file mode 100644 index 0000000000..579c60e85b Binary files /dev/null and b/static/images/docs/tutorials/expense-tracker-app.png differ diff --git a/static/images/docs/tutorials/expense-tracker-collection.png b/static/images/docs/tutorials/expense-tracker-collection.png new file mode 100644 index 0000000000..b5c789aad7 Binary files /dev/null and b/static/images/docs/tutorials/expense-tracker-collection.png differ