DEV Community

Cover image for AI-Powered Note Taker & Summarizer I Built From Scratch
Pratyush Srivastava
Pratyush Srivastava

Posted on

AI-Powered Note Taker & Summarizer I Built From Scratch

Introduction:

Motivation behind it was quite simple honestly, I just wanted to test some AI based features, so I thought why not make a simple project out of it. This is a very minimal AI based notetaker that has some good functionalities built in, like searching with debouncing, AI based summarization of notes, Markdown preview etc. It was a simple project that was so fun to make, hopefully you'll like it too, so let's start building SHALL WE !


Features

  • Debouncing based note search
  • Pinning and sorting of notes
  • Markdown preview of notes
  • AI based summarization of notes, during creation as well as after creation

Folder Structure

note-taker/
โ”œโ”€ node_modules/
โ”œโ”€ public/
โ”œโ”€ src/
โ”‚  โ”œโ”€ assets/
โ”‚  โ”œโ”€ components/
โ”‚  โ”‚  โ”œโ”€ Navbar.jsx
โ”‚  โ”‚  โ”œโ”€ NoteInput.jsx
โ”‚  โ”‚  โ”œโ”€ NotesCard.jsx
โ”‚  โ”‚  โ”œโ”€ NotesList.jsx
โ”‚  โ”‚  โ””โ”€ SearchBar.jsx
โ”‚  โ”œโ”€ hooks/
โ”‚  โ”‚  โ””โ”€ useDebounce.jsx
โ”‚  โ”œโ”€ store/
โ”‚  โ”‚  โ””โ”€ notesStore.js
โ”‚  โ”œโ”€ utils/
โ”‚  โ”‚  โ”œโ”€ styles.js
โ”‚  โ”‚  โ””โ”€ summarizeNote.js
โ”‚  โ”œโ”€ App.jsx
โ”‚  โ”œโ”€ index.css
โ”‚  โ””โ”€ main.jsx
โ”œโ”€ .env
โ”œโ”€ .gitignore
โ”œโ”€ eslint.config.js
โ”œโ”€ index.html
โ”œโ”€ package-lock.json
โ”œโ”€ package.json
โ”œโ”€ README.md
โ””โ”€ vite.config.js
Enter fullscreen mode Exit fullscreen mode

We are going to be only concerned with:

Components Folder:

Contains all the components required, they are as follows:

  • Navbar: Pretty much self explanatory
import React from 'react'

const Navbar = () => {
  return (
    <nav className=''>
      <ul className='h-[50px] w-screen flex items-center justify-between shadow-md p-2'>
        <li>AI note taker</li>
        {/* <li>day/night</li> */}
      </ul>
    </nav>
  )
}

export default Navbar
Enter fullscreen mode Exit fullscreen mode
  • NoteInput: Has a textarea with two buttons, one will save, other will summarize then save. It has two important functions:
    • handleSubmit: In this we provide a new object called newNote that will provide the base structure of how our note object is stored, it has a random id generated every time the new object created, date and pinned fields.
    • handleSummary: It is a async function that will use aiSummary function(provided down in another file) that will call api endpoint and AI model will process and summarized text will be sent back that will be stored in summary field in the object.
import { styles } from "../utils/styles"
import { useState } from "react"
import { useStore } from "../store/notesStore"
import {aiSummary} from '../utils/summarizeNote'
const NoteInput = () => {
  const [text, setText] = useState('')
  const addNote = useStore(state => state.addNotes) // <- function that is present in zustand store
  const handleSubmit = e => {
    e.preventDefault()
    const newNote = {
      id: Math.random().toString(16).slice(2),
      content: text,
      date: new Date(),
      pinned: false
    }
    addNote(newNote)
  }
  const handleSummary = async() => {
    const res = await aiSummary(text)
    if(!res.ok) {
      return 
    }
    const data = await res.json()
    const content = data.choices[0].message.content
    const newNote = {
      id: Math.random().toString(16).slice(2),
      content: text,
      summary: content,
      date: new Date(),
      pinned: false
    }
    addNote(newNote)
  }

  return (
    <form onSubmit={handleSubmit} action="" className='w-full flex flex-col items-center gap-2 py-10'>
      <textarea name="" id="" placeholder='Enter the note here' className='w-1/2 h-[200px] shadow-sm p-2'value={text} onChange={e => setText(e.target.value)}/>
      <div className='gap-10 flex items-center'>
        <button type="submit" className={styles.button}>Add Note</button>
        <button type="button" onClick={handleSummary} className={styles.button}>Summarize and add</button>
      </div>
    </form>
  )
}

export default NoteInput
Enter fullscreen mode Exit fullscreen mode
  • NotesCard: Displays note content and has additional button, for pinning, deleting and summarizing of notes, it also has tag added, so that the cards, can display markdown preview, but in my testing I found out that headings are not shown correctly, so I used its components attribute that gives the functionality to add css based on tags, so I provided customization to three of the tags, h1, h2, and p.

Something to consider in this file is the handleSummarize function, in which we again call the aiSummary function taken from summarizeNote.js and it is responsible for fetching data from AI endpoint, the difference here from previous case is we already had the text from the user from note param so we just have to pass it to the function.

The response from the endpoint is in this format:


So we use this to line of code to use the response part.

   const content = data.choices[0].message.content
Enter fullscreen mode Exit fullscreen mode
import Markdown from "react-markdown"
import { useStore } from "../store/notesStore"
import { useState } from "react"
import { styles } from "../utils/styles"
import {aiSummary} from '../utils/summarizeNote'

const NotesCard = ({ note }) => {
  const deleteNote = useStore(state => state.deleteNote)
  const pinNote = useStore(state => state.pinNote)
  const updateNote = useStore(state => state.updateNote)
  const [showSummary, setShowSummary] = useState(false)
  // console.log(note)
  const handleSummarize = async() => {
    const res = await aiSummary(note.content)
    if(!res.ok) return
    const data = await res.json()
    const content = data.choices[0].message.content
    const updatedNote = {
      ...note,
      summary: content
    }
    updateNote(updatedNote)
  }
  return (
    <div className="h-[200px] w-[200px] p-2 bg-gray-200 shadow-md rounded-sm flex flex-col justify-between relative">

  {note.pinned && <div className="text-xl absolute left-[0px] top-[-10px]">&#128204;</div>}

  {/* Scrollable content area */}
  <div className="w-full overflow-y-auto max-h-[120px] pr-1 mb-1">
    <Markdown
      components={{
        h1: ({ node, ...props }) => <h1 className="text-xl font-bold" {...props} />,
        h2: ({ node, ...props }) => <h2 className="text-lg font-semibold" {...props} />,
        p: ({ node, ...props }) => <p className="text-sm" {...props} />,
      }}
    >
      {showSummary && note.summary ? note?.summary : note.content}
    </Markdown>
  </div>

  {/* Fixed button actions */}
  <div className="p-2 flex gap-2 items-center justify-between w-full">
    <button className={`${styles.cardbtn} border-red-400 hover:bg-red-500`} onClick={() => pinNote(note)}>&#128204;</button>
    <button className={`${styles.cardbtn} border-gray-400 hover:bg-gray-400`} onClick={() => deleteNote(note)}>&#128465;</button>
    {!note.summary && <button onClick={handleSummarize} className={`${styles.cardbtn} px-2 border-black hover:bg-black hover:text-white`}>...</button>}
    {note.summary && <button className={`${styles.cardbtn}`} onClick={() => setShowSummary(prev => !prev)}>&#129523;</button>}
  </div>

</div>

  )
}


export default NotesCard
Enter fullscreen mode Exit fullscreen mode
  • NotesList: Container for cards. In this we're using the useDebounce hook and providing it 500ms of delay, you can absolutely change this value according to the use case, but in this case 500ms is enough, so I used it, and provided searchTerm as a param as well.
import NotesCard from "./NotesCard"
import { useStore } from "../store/notesStore"
import {useDebounce} from '../hooks/useDebounce'

const NotesList = () => {
  const notes = useStore(state => state.notes)
  const searchTerm = useStore(state => state.searchTerm)
  const debounced = useDebounce(searchTerm, 500)
  return (
    <div className="flex gap-10 items-center justify-center flex-wrap py-4">
      {notes.filter(i => i.content.includes(debounced) && i.pinned).map(item => <NotesCard note={item} />)}
      {notes.filter(i => i.content.includes(debounced) && !i.pinned).map(item => <NotesCard note={item} />)}
    </div>
  )
}

export default NotesList
Enter fullscreen mode Exit fullscreen mode
  • SearchBar.jsx: It just contains an input to get the search term.
import { useStore } from "../store/notesStore"
import { useState } from "react"

const SearchBar = () => {
  const search = useStore(state => state.search)
  const searchTerm = useStore(state => state.searchTerm)
  return (
    <div className='py-2'>
      <input value={searchTerm} onChange={e => {
        search(e.target.value)
      }} className='w-full h-[50px] shadow-sm p-2' type="text" name="" id="" placeholder='&#128269;'/>
    </div>
  )
}

export default SearchBar
Enter fullscreen mode Exit fullscreen mode

Hooks Folder:

Contains useDebounce hook, which will help in debouncing (obviously, haha), if you don't know what debouncing, its basically a process of delaying the time to search according to query,

Let say you are typing a "word", so search will activate on every letter, like on "w", "o", "r" and "d", in smaller applications this is fine, but quite taxing in large scale, so we wait for certain amount of time to execute the search, so instead on every letter, search will happen on when whole "word" is typed in bar, it will save resources.

import {useState, useEffect} from 'react'
export const useDebounce = (value, delay) => {
    const [debouncedValue, setDebouncedValue] = useState('')
    useEffect(() => {
        const handler = setTimeout(() => {
            setDebouncedValue(value)
        }, delay)
        return () => clearTimeout(handler)
    }, [value, delay])
    return debouncedValue
}

Enter fullscreen mode Exit fullscreen mode

Store Folder:

Contains store.js that has zustand store in it. Now it can be said that this file is second most important in this project, because it contains all the states and functions related to the operations that we need to perform. Notes state is an array that contains objects in the form:

{
id: Math.random().toString(16).slice(2),
content: text,
summary: content,
date: new Date(),
pinned: false
}

id is random id, content is the main text provided by user and summary is the response from AI, date is the date of creation whereas pinned is the boolean property that tell whether the note is pinned or not.

addNotes, updateNote, deleteNote, pinNote and search are functions that does their work of adding, updating (mainly summary adding), deleting, pinning respectively.

import {create} from 'zustand'

export const useStore = create(set => ({
    notes: [],
    searchTerm: '',
    addNotes: (note) => set(state => ({notes: [...state.notes, note]})),
    updateNote: note => set(state => ({notes: state.notes.map(i => i.id === note.id ? note : i)})),
    deleteNote: (note) => set(state => ({notes: state.notes.filter(item => item.id !== note.id)})),
    pinNote: (note) => set(state => ({notes: state.notes.map(item => item.id === note.id ? {
        ...note,
        pinned: true
    }:item)})),
    search: term => set(state => ({searchTerm: term}))
}))

Enter fullscreen mode Exit fullscreen mode

Utils:

Contains styles.js that doesn't have much styles ๐Ÿ˜…, its just one object with two properties,

export const styles = {
    button: "border p-2 rounded-sm hover:bg-gray-500 hover:text-white",
    cardbtn: "p-1 border shadow-sm rounded-sm"
}
Enter fullscreen mode Exit fullscreen mode

and summarizeNote.js is the most imp. file here because it contains the function required to communicate to the AI, (I have used openrouter key here for this to work, you can make a free account and use this), here I have given a basic message config for the model to work as expected, and not mess up everything ๐Ÿ˜…

export const aiSummary = (content) =>  fetch('https://openrouter.ai/api/v1/chat/completions', {
    method: 'POST',
    headers: {
        Authorization: `Bearer ${import.meta.env.VITE_KEY}`, // <- place key here, but better to use this variable and make a .env file for this
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({
        model: "qwen/qwen3-8b:free",
        messages: [
            {
                "role": "system",
                "content": "You are a helpful assistant that summarizes text."
            },
            {
                "role": "user",
                "content": `Summarize this note: ${content}`
            }
        ]
    }),
});
Enter fullscreen mode Exit fullscreen mode

Libraries used:


React-markdown for markdown preview, zustand for state management, and tailwindcss for styling(although I know that I did not use it much)

So This will be all, although it is small project, it can really help in understanding some concepts like debouncing and using AI according to a use case

Repo Link

Any feedback is appreciated, and comment down below, would you try this project ? and give me a follow if you want more projects, tips and anything related to js, until next time, bye ๐Ÿ‘‹

Top comments (0)