DEV Community

German Cyganov
German Cyganov

Posted on

Building a Voice and Eye-Controlled To-Do App - Part 1

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.

App Sketch

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
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    })
}
Enter fullscreen mode Exit fullscreen mode

And not forget about types
src/types/index/ts

export interface Task {
    id: string
    name: string
}

export type TaskList = Task[]
Enter fullscreen mode Exit fullscreen mode

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.

Database relation diagram

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.

Authentication schema

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
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

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
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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))
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
Result

Demo GIF

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)