Hi, I invite you to join me in creating another TODO app. I hope we can learn a lot during the creation.
Task list
- Create basic functionality
- Add styles and animations
- Add voice control and responses
- Add cursor control with gaze
- Protect tasks with face/voice recognition and possibly fingerprint recognition
I will post an article each time after I finish a task, so if you have any questions or suggestions please speak up.
Create basic functionality
I don't want to overload the application with logic at the start. So let's leave only the basic actions of creating and deleting a task.
Create a project
npx create-next-app@latest
What is your project named? todo-ai
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? No
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) No
Would you like to customize the default import alias (@/*)? No
and install the dependencies npm i --save @chakra-ui/next-js @chakra-ui/react @emotion/react @emotion/styled framer-motion swr
At this stage things like chakra-ui or framer-motion will be unnecessary, but when we will style and animate the application they will be useful, so let's use them right away.
Creating the skeleton
src/pages/_app.tsx
import type { AppProps } from "next/app";
import { ChakraProvider } from '@chakra-ui/react'
import { SWRConfig } from "swr";
export default function App({ Component, pageProps }: AppProps) {
return (
<SWRConfig
value={{
refreshInterval: 3000,
}}
>
<ChakraProvider>
<Component {...pageProps} />
</ChakraProvider>
</SWRConfig>
)
}
src/pages/index.tsx
import Head from "next/head";
import { TaskCreator } from "@/components/TaskCreator";
import { TaskList } from "@/components/TaskList";
export default function Home() {
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<TaskCreator />
<TaskList />
</main>
</>
);
}
Create a components
src/components/TaskCreator/index.tsx
import { createTask } from "@/api"
import { useTasks } from "@/hooks/useTasks"
import { Button, FormControl, Input, InputGroup, InputRightElement, useToast } from "@chakra-ui/react"
import { ChangeEvent, ChangeEventHandler, FormEvent, FormEventHandler, ReactEventHandler, useState } from "react"
export const TaskCreator = () => {
const toast = useToast()
const { tasks, refresh } = useTasks()
const [taskName, setTaskName] = useState('')
const handleChange = (event: ChangeEvent<HTMLInputElement>) => setTaskName(event.target.value)
const handleAddTask = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
createTask(taskName).then((task) => {
refresh([...tasks, task])
}).catch(() => {
toast({
title: 'Error on creating a task',
status: 'error'
})
})
}
return (
<form onSubmit={handleAddTask}>
<InputGroup>
<Input variant='outline' placeholder="Input a task" onChange={handleChange} />
<InputRightElement>
<Button type='submit'>
Add
</Button>
</InputRightElement>
</InputGroup>
</form>
)
}
src/components/TaskList/index.tsx
import { Card, CardBody, Heading, Spinner, Stack, useToast } from "@chakra-ui/react"
import { removeTask } from "@/api"
import { Task } from "@/types"
import { useTasks } from "@/hooks/useTasks"
import { useDelay } from "@/hooks/useDelay"
type onDeleteHandler = (id: string) => void
type TaskCardProps = Task & {
onDelete: onDeleteHandler
}
const TaskCard: React.FC<TaskCardProps> = ({ id, name, onDelete }) => {
const handleDelete = () => {
onDelete(id)
}
return (
<Card
direction={{ base: 'column', sm: 'row' }}
overflow='hidden'
variant='outline'
>
<CardBody>
<Heading size='md'>{name}</Heading>
</CardBody>
<button onClick={handleDelete}>
Delete task
</button>
</Card>
)
}
export const TaskList = () => {
const toast = useToast()
const { tasks, error, isLoading, refresh } = useTasks()
const isLoadingDelayed = useDelay(isLoading, {
initialValue: false,
})
const handleDeleteTask = (id: string) => {
removeTask(id).then(({ id }) => {
refresh([...tasks.filter((task: Task) => task.id !== id)])
}).catch(() => {
toast({
title: 'Error on delete task',
status: 'error'
})
})
}
if (error) {
return <div>
{error.message}
</div>
}
return isLoadingDelayed ? <Spinner size='xl' /> : (
<Stack spacing={4}>
{...tasks.map((task, idx) => (
<TaskCard key={task.id || `${task.name}_${idx}`} {...task} onDelete={handleDeleteTask} />
))}
</Stack>
)
}
The hooks
useDelay
- delays changes of variable state - it's almost like debounce hook but with different initial state. Used to not show the download spinner if the download was fast enough.
src/hooks/useDelay.ts
import { useEffect, useState } from "react";
interface UseDelayParams<T> {
initialValue: T
delay?: number
}
export const useDelay = <T>(value: T, { initialValue, delay = 300 }: UseDelayParams<T>) => {
const [currentValue, setCurrentValue] = useState(initialValue)
useEffect(() => {
let timer: NodeJS.Timeout;
timer = setTimeout(() => {
setCurrentValue(value)
}, delay)
return () => {
timer && clearTimeout(timer)
}
}, [value, delay])
return currentValue
}
useTask
- encapsulates the logic of task handling. Basically it is just a wrapper over useSWR
.
src/hooks/useTasks.ts
import { FetchError, fetchTasks } from "@/api"
import useSWR from "swr"
export const useTasks = () => {
const { data = [], error, isLoading, mutate } = useSWR('/api/tasks', fetchTasks)
return {
tasks: data,
error: error as FetchError,
isLoading: isLoading,
refresh: mutate
}
}
Since I don't want to complicate the application by using something like redux or mobx or even a small zustand. Tasks will be stored here on the server, and access to them will be through the api that we will write a bit later.
The missing parts
src/api/index.ts
import { Task, TaskList } from '@/types'
export class FetchError extends Error {
constructor(message: string, public status: number) {
super(message);
}
}
const fetcher = async (url: string, init?: RequestInit | undefined) => {
const res = await fetch(url, init)
if (!res.ok) {
const message = (await res.json())?.message
const error = new FetchError(message, res.status)
throw error
}
return res.json()
}
export const fetchTasks = async (): Promise<TaskList> => {
return fetcher('/api/tasks')
}
export const createTask = async (name: string): Promise<Task> => {
return fetcher('/api/tasks', {
method: 'POST',
body: JSON.stringify({
name
}, null, 0)
})
}
export const removeTask = async (id: string): Promise<Task> => {
return fetcher('/api/tasks', {
method: 'DELETE',
body: JSON.stringify({
id
}, null, 0)
})
}
And not forget about types
src/types/index/ts
export interface Task {
id: string
name: string
}
export type TaskList = Task[]
API
We need three methods GET
, POST
and DELETE
. To get, create and delete tasks.
For storage a relational database is used, with the following schema.
Authentication
As you can see there is a user here, but where can he come from?
I don't want to add a registration and login form for now. So we will create a new user ourselves when a request comes in without the necessary cookies. And if the request has the necessary cookies, we will take the user's id from them and give back the data associated with it.
Initialize database
First, let's install the dependencies for the server.
npm i --save @prisma/client cookie jose
npm i --save-dev prisma @types/cookie
Next, initialize prisma.
npx prisma init --datasource-provider sqlite
Create a schema.
prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
tasks Task[]
}
model Task {
id String @id @default(cuid())
name String
owner User @relation(fields: [ownerId], references: [id])
ownerId String
}
Run the migration to create the database and types.
npx prisma migrate dev --name init
Write functions for necessary CRUD operations.
db/tasks.ts
import prisma from "./prisma"
export const addTask = (userId: string, taskName: string) => {
return prisma.task.create({
data: {
name: taskName,
ownerId: userId
}
})
}
export const deleteTask = (taskId: string) => {
return prisma.task.delete({
where: {
id: taskId
}
})
}
export const getTasks = (userId: string) => {
return prisma.task.findMany({
where: {
ownerId: userId
}
})
}
export const getTask = (taskId: string) => {
return prisma.task.findUnique({
where: {
id: taskId
}
})
}
db/users.ts
import prisma from "./prisma"
export const createUser = () => {
return prisma.user.create({ data: {} })
}
export const getUser = (id: string) => {
return prisma.user.findUnique({
where: {
id: id
}
})
}
And PrismaClient initialization code.
db/prisma.ts
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare global {
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>
}
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
The API
src/pages/api/tasks.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { serialize } from 'cookie'
import { createUser, getUser } from '@/db/users'
import { createSession, decrypt, encrypt } from '@/session'
import { addTask, deleteTask, getTask, getTasks } from '@/db/tasks'
import { User } from '@prisma/client'
const taskHandlers = {
'GET': async (req: NextApiRequest, res: NextApiResponse, userId: string) => {
const tasks = await getTasks(userId)
return res.status(200).json(tasks)
},
'POST': async (req: NextApiRequest, res: NextApiResponse, userId: string) => {
const { name } = JSON.parse(req.body)
if (name) {
const task = await addTask(userId, name)
return res.status(200).json(task)
} else {
return res.status(406).send({ message: 'Not Acceptable' })
}
},
'DELETE': async (req: NextApiRequest, res: NextApiResponse, userId: string) => {
const { id } = JSON.parse(req.body)
if (id) {
const processedTask = await getTask(id)
if (processedTask?.ownerId !== userId) {
return res.status(403).send({ message: 'Forbidden' })
}
const task = await deleteTask(id)
return res.status(200).json(task)
} else {
return res.status(406).send({ message: 'Not Acceptable' })
}
},
}
type AllowedMethods = keyof typeof taskHandlers
const authorize = async (req: NextApiRequest, res: NextApiResponse) => {
const initializeNewUser = async (req: NextApiRequest, res: NextApiResponse) => {
const user = await createUser()
const sessionData = createSession(user.id)
const encryptedSessionData = await encrypt(sessionData)
const cookie = serialize('session', encryptedSessionData, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
expires: sessionData.expires,
path: '/'
})
res.setHeader('Set-Cookie', cookie)
return user
}
const session = req.cookies['session']
let user!: User | null
if (!session) {
user = await initializeNewUser(req, res)
} else {
try {
const userId = (await decrypt(session)).payload.userId as string
user = await getUser(userId)
} catch (e) {
user = await initializeNewUser(req, res)
}
}
return user
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const user = await authorize(req, res)
if (!user) {
return res.status(401).send({ message: 'Unauthorized' })
}
if (req.method && req.method in taskHandlers) {
return await taskHandlers[req.method as AllowedMethods](req, res, user.id)
} else {
return res.status(501).send({ message: 'Not Implemented' })
}
} catch (e) {
return res.status(500).send({ message: 'Internal Error' })
}
}
export const config = {
api: {
bodyParser: {
sizeLimit: '1mb',
},
},
maxDuration: 5,
}
Session managment
src/session.ts
import { SignJWT, jwtVerify } from "jose"
const secretKey = process.env.SECRET_KEY
const key = new TextEncoder().encode(secretKey)
interface Session {
userId: String
expires: Date
}
export const encrypt = async (payload: Session) => {
return new SignJWT(payload as any).setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime(payload.expires).sign(key)
}
export const decrypt = async (input: string) => {
return jwtVerify<Session>(input, key, {
algorithms: ['HS256']
})
}
export const createSession = (userId: string): Session => {
const expires = new Date(new Date().setDate(new Date().getDate() + 1));
return {
userId,
expires
}
}
export const updateSession = async (enryptedSession: string): Promise<Session> => {
const parsed = await decrypt(enryptedSession)
return {
...parsed.payload,
expires: new Date(new Date().setDate(new Date().getDate() + 1))
}
}
Our session will be considered valid one day after creation, let's make it so that the session expiration time is updated with each request and we can use the application every day without losing the task list too often.
To do this, let's write middleware that updates the session.
src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
import { encrypt, updateSession } from './session'
export async function middleware(req: NextRequest) {
const res = NextResponse.next()
const session = req.cookies.get('session')?.value
if (!session) {
return res
}
try {
const updatedSession = await updateSession(session)
res.cookies.set({
name: 'session',
value: await encrypt(updatedSession),
expires: updatedSession.expires,
httpOnly: true
})
} finally {
return res
}
}
That's all for the functional part. In the next part, we'll give it a little bit prettier look and add some animations too. Thank you for creating part one with me. If you have any questions or suggestions please leave them in the comments.
The full code is available at GitHub
Top comments (0)