Criando um Clone do Google Keep [Parte 1]

Você provavelmente conhece o famoso app de notas da Google, o Google Keep. Com ele é possível criar notas, lembretes, listas, e compartilhá-las com outros usuários. É o pacote completo para quem precisa fazer anotações de modo geral, e é onde gosto de guardar algumas ideias e coisas que vou precisar em algum momento.

Sendo uma aplicação que uso no meu dia-a-dia, achei interessante tentar fazer um clone dela com as tecnologias que tenho utilizado nos meus projetos: Node.js, ReactJS e TypeScript. Então decidi escrever esta série de posts, não só para mostrar o resultado final, mas também para passar pelas principais “peças” por trás da solução.

Se ao final da leitura você quiser botar as mãos no código, rodá-lo localmente (ou simplesmente se divertir 😁) o repositório está disponível no meu GitHub.

Antes de mais nada, vamos regular as expectativas, né? Por “Clone” não entenda uma cópia perfeita da aplicação inteira (e muito menos da infraestrutura monstruosa que a Google deve possuir para mantê-la no ar rodando suavemente para o mundo inteiro 🤪).

O objetivo era reproduzir a aparência e as principais funcionalidades da aplicação original e, obviamente, alguns “cortes” tiveram que ser feitos para manter a simplicidade. Alguns deles são:

  • O compartilhamento de notas;
  • A criação e notificação de lembretes; e
  • A adição de elementos além de texto nas notas (desenhos, imagens e listas).

Apesar dos “cortes”, não economizei nas boas práticas, modularização, testes (100% de cobertura no backend) e no uso de uma stack moderna e eficiente para chegar no resultado que apresento a seguir.

Vamos lá?

Tudo começa com a interface

O primeiro passo para mim foi observar a interface do Google Keep e transformá-la em páginas e componentes no meu projeto React. Quanto às páginas, pela sidebar podemos ver que temos:

  • A que aparece na imagem abaixo, uma espécie de Home em que podemos ver todas as notas;
  • A de notas com lembretes próximos (que deixei de fora por não implementar os lembretes); mas também temos
  • As páginas em que as notas são filtradas por “tag” (uma espécie de categoria; no meu caso as tags são “Ideias para Apps” e “teste”);
  • Uma página de notas arquivadas;
  • E outra para a lixeira (que também decidi deixar de fora).

Em questão de componentes mais “macro”, podemos ver um header (cabeçalho) e uma sidebar (barra lateral), que são comuns a todas as páginas, além das notas em si.

Por conta dessa repetição e por não haver nenhuma diferença significativa entre as páginas, no código só há uma página (que chamei de Home) ao invés de três, e as notas são buscadas de acordo com a URL requisitada (veremos em seguida).

Google Keep UI 1

(Nada de espiar minhas notas, hein! 😂)

Tem essa barra/caixa para criação de notas, que se expande e colapsa de acordo com a interação do usuário (na imagem abaixo está expandida), e cada nota fica contida num bloquinho, que pode ser de diferentes cores e exibe algumas opções ao passarmos o mouse por cima (hover).

Google Keep UI 2

Por fim, temos um modal para criação e edição das tags do usuário, que pode ser exibido a partir de qualquer uma das páginas anteriores:

Google Keep UI 3

Diferente da aplicação original, no Google Keep Clone não há integração com a conta do Google, então eu criei uma página de Login e outra de SignUp seguindo as cores da marca e a aparência característica dos inputs. Ah, também coloquei uma página para quando o usuário esquece a senha e outra para quando vai fazer a redefinição da mesma, após receber um e-mail.

Google Keep Clone Login page

Ah, falando em cores, isso me lembra os ícones usados no app, que são bem característicos dos produtos da Google, pois seguem o Material Design, um design system desenvolvido pela empresa. Os ícones são open-source e gratuitos, então pude usá-los sem problemas. Para facilitar a inserção deles no código — sem ter que baixar e gerenciar os arquivos de cada ícone —, utilizei a lib react-icons que tem os ícones desse e de vários outros sistemas populares.

Para poder obter um resultado o mais próximo possível do Keep original, outro aspecto no que diz respeito ao design são as fontes. Uma das fontes utilizadas no Keep é a Google Sans, mas ela é proprietária, então não pude usar. No entanto, encontrei a Montserrat, que tem alguma semelhança com ela. A outra fonte utilizada é a Roboto, essa sim livre para uso.

Criando o projeto

Como comentei, utilizei React para desenvolver o frontend do Keep Clone. O React, caso você não conheça, é uma biblioteca JavaScript de código aberto com foco em criar interfaces de usuário em páginas web. Ela é mantida pelo Facebook, Instagram e outras empresas, bem como a própria comunidade.

Para dar início ao projeto utilizei o Create-React-App, como explico neste post:

npx create-react-app google-keep-clone --template=typescript

Como dá para notar pelo parâmetro template, decidi utilizar TypeScript no projeto (no backend também, como veremos no próximo post). Resumindo, o TypeScript nada mais é que um superset (“superconjunto”) de JavaScript desenvolvido pela Microsoft, que adiciona tipagem e alguns outros recursos ao código.

Ele ajuda a nos dar mais segurança como programadores, saber que estamos passando e recebendo os parâmetros corretamente, etc. Foi isso que me levou a adotá-lo no desenvolvimento do Keep Clone, bem como em todos os meus últimos projetos.

Outras ferramentas que utilizei, que não interferem diretamente na aplicação, mas trazem algumas melhorias no processo de desenvolvimento foram o EditorConfig, o ESLint, e o Prettier. (Configurá-los provavelmente foi a parte mais chatinha de todo o processo 😆.) São ferramentas que servem basicamente para manter a consistência no estilo do código.

Falando em código, acho que tá na hora de ver algum, não? Vamos começar do “topo”, para depois ir mergulhando um pouco nos detalhes da solução.

Rotas

Sendo uma SPA (Single-Page Application), o nosso app React roda em uma única página, se assemelhando a um aplicativo desktop ou um mobile, e toda a navegação na aplicação acontece nessa única página. O conteúdo é carregado de uma vez ou, no nosso caso, obtido dinamicamente.

Portanto, uma parte importante do app é o que chamamos de roteamento, isto é, de acordo com a URL acessada (por exemplo keepclone.com/notes ou keepclone.com/archive) nós podemos querer exibir componentes diferentes. Isso pode ser obtido através da famosa biblioteca react-router-dom.

Este é o arquivo em que declaro as rotas:

// src/routes/index.tsx

import React from 'react';
import { Switch } from 'react-router-dom';
import Route from './Route';

import Login from '../pages/Login';
import SignUp from '../pages/SignUp';
import ForgotPassword from '../pages/ForgotPassword';
import ResetPassword from '../pages/ResetPassword';
import Home from '../pages/Home';

const Routes: React.FC = () => (
  <Switch>
    <Route path="/login" component={Login} />
    <Route path="/signup" component={SignUp} />
    <Route path="/forgot-password" component={ForgotPassword} />
    <Route path="/reset-password" component={ResetPassword} />

    <Route isPrivate path="/" exact component={Home} />
    <Route isPrivate path="/archive" component={Home} />
    <Route isPrivate path="/tags/:id" component={Home} />
  </Switch>
);

export default Routes;

Algumas observações:

  • Utilizei o Switch da biblioteca para que fosse renderizada somente uma rota por vez (i.e., não queremos duas páginas ao mesmo tempo);
  • Route é um componente personalizado (que está em outro arquivo) que criei para poder receber o parâmetro isPrivate. Com isso, faço o tratamento de redirecionar o usuário para a página de login caso não esteja autenticado. Ou, de redirecioná-lo para a página de notas caso esteja tentando acessar uma página “pública” (ou seja, uma das quatro primeiras ali);
  • Como mencionei antes, utilizei a mesma página de Home para as três rotas que exibem notas: a principal, a do arquivo, e a que filtra por tag. Em outras palavras, dá pra ver que as três últimas rotas ali renderizam o mesmo component.

Falando em Home… É um bom ponto do código para começarmos a explorar.

Usando hooks

Pensando nos dados que teremos que exibir (as notas, obviamente, e as tags na sidebar), estes virão da nossa API, do backend. Eu poderia mantê-los no state (estado) desse componente Home, mas isso implicaria na passagem deles como props pra outros componentes em diferentes níveis e no aumento da complexidade do arquivo da Home (só a ideia já me incomoda 🥴).

Para contornar essa situação, uma das opções é criar uma espécie de state global, o que poderia ser obtido com o famoso React Redux, mas optei por utilizar custom hooks associados à Context Api (documentação para custom hooks e Context Api).

Abaixo você pode ver boa parte do código para o hook de notas — algumas partes foram omitidas com um “[...]” para manter o post num comprimento razoável. Também criei um hook para tags, outro para autenticação e outro para os toasts (as mensagens que são exibidas em caso de sucesso/erro), mas eles seguem a mesma linha, então se tiver curiosidade está tudo lá no código completo no GitHub.

// src/hooks/notes.tsx

import React, { createContext, useState, useCallback, useContext } from 'react';

// Aqui declaramos a tipagem de uma "Nota", para poder ser utilizada nos trechos seguintes

interface Note {
  id: string;
  title: string;
  body: string;
  color: number;
  tag?: {
    id: string;
    name: string;
  };
}

// Aqui declaramos a tipagem do que o nosso hook expõe no Context
// (todas essas funções são implementadas abaixo no NotesProvider)

interface NotesContextData {
  getNotes(): Note[];
  setNotes(data: { notes: Note[]; notesCount?: number }): void;
  addNotes(data: { notes: Note[]; notesCount?: number }): void;
  addNote(note: Note): void;
  updateNote(note: Note): void;
  removeNote(id: string): void;
  getNotesQuery(): string;
  setNotesQuery(query: string): void;
  getNotesCount(): number;
  getCurrentPage(): number;
  setCurrentPage(count: number): void;
}

const NotesContext = createContext<NotesContextData>({} as NotesContextData);

const NotesProvider: React.FC = ({ children }) => {
  // Armazenamos os dados que queremos em state
  const [allNotes, setAllNotes] = useState<Note[]>([]);
  const [query, setQuery] = useState('');
  const [count, setCount] = useState(0);
  const [page, setPage] = useState(0);

  const getNotes = useCallback((): Note[] => {
    return allNotes;
  }, [allNotes]);

  const updateNote = useCallback(
    (note: Note) => {
      const updatedNotes = [...allNotes];
      const noteIndex = updatedNotes.findIndex(
        noteItem => noteItem.id === note.id,
      );
      updatedNotes[noteIndex] = note;
      setAllNotes(updatedNotes);
    },
    [allNotes],
  );

  const setNotes = useCallback(
    ({ notes, notesCount }: { notes: Note[]; notesCount?: number }) => {
      setAllNotes(notes);
      if (notesCount) setCount(notesCount);
    },
    [],
  );

  const addNote = useCallback((note: Note) => {
    setAllNotes(state => [note, ...state]);
    setCount(state => state + 1);
  }, []);

  const removeNote = useCallback((id: string) => {
    setAllNotes(state => state.filter(note => note.id !== id));
    setCount(state => state - 1);
  }, []);

  [...]

  return (
    <NotesContext.Provider
      value=
    >
      {children}
    </NotesContext.Provider>
  );
};

function useNotes(): NotesContextData {
  const context = useContext(NotesContext);

  if (!context) {
    throw new Error('useNotes must be used within a NotesProvider');
  }

  return context;
}

export { NotesProvider, useNotes };

A página Home

Sabendo da existência desses hooks customizados, podemos vê-los sendo utilizados na página Home:

[...]

const Home: React.FC = () => {
  [...]

  // Usamos algumas funções dos hooks customizados de Toast, Tags e Notes
  const { addToast } = useToast();
  const { setTags, selectTag } = useTags();
  const {
    getNotes,
    setNotes,
    addNotes,
    [...]
  } = useNotes();

  const { id: tagId } = useParams<HomeParams>();
  // Se o usuário estiver acessando a página de uma tag,
  // a URL será no formato /tags/:id, em que "id" é o id da tag desejada.
  // Aqui extraímos esse valor.

  const { pathname } = useLocation();
  // Isso retorna o trecho da URL depois do domínio
  // Por exemplo, se o usuário está em keepclone.com/tags/abe9db84-39d8-49fb-8858-8ab2d9717f60
  // o pathname será "/tags/abe9db84-39d8-49fb-8858-8ab2d9717f60"

  // Para sabermos em que "página" estamos
  const isArchive = pathname === '/archive';
  const isTag = !!tagId;
  const isHome = !isArchive && !isTag;
    
  // Função que busca as tags e notas do usuário, de acordo com a página em que está
  const fetchTagsAndNotes = useCallback(
    async ({
      isFirstQuery,
      query: queryStr,
    }: {
      isFirstQuery: boolean;
      query?: string;
    }) => {
      try {
        const { data: tags } = await api.getAllTags();
        setTags(tags);

        const page = isFirstQuery ? 1 : getCurrentPage() + 1;
        const query = queryStr !== undefined ? queryStr : getNotesQuery();

        if (isArchive) {
          // Archive page
          selectTag('archive');

          const {
            data: { data: archivedNotes, count },
          } = await api.getAllNotes({
            status: NoteStatus.ARCHIVED,
            query,
            page,
          });

          if (isFirstQuery)
            setNotes({ notes: archivedNotes, notesCount: count });
          else addNotes({ notes: archivedNotes, notesCount: count });

          setCurrentPage(page);
        } else if (isTag) {
          // Tag page
          selectTag(tagId);

          [...]
           
        } else if (isHome) {
          // Home page
          selectTag('notes');

          [...]
           
        }
      } catch (err) {
        addToast({
          type: 'error',
          title: 'Erro ao acessar suas notas',
          description:
            'Verifique a conexão com a internet e recarregue a página.',
        });
      }
    },
    [
      ...
    ],
  );

  // Executa a função fetchTagsAndNotes() sempre que trocamos de página, basicamente
  useEffect(() => {
    fetchTagsAndNotes({ isFirstQuery: true });
  }, [isArchive, isTag, tagId, isHome]);

  // Pegamos as notas que foram colocadas no hook e as armazenamos nesta variável
  // atualizando-a automaticamente sempre que elas forem alteradas
  const notes = useMemo(() => getNotes(), [getNotes]);

  [...]

  return (
    <Container>
      <Header fetch={fetchTagsAndNotes} onToggleSidebar={toggleSidebar} />
      <Contents>
        <Sidebar show={showSidebar} />
        <BarAndNotes showSidebar={showSidebar}>
          <Bar>
            <CreateNoteBar />
          </Bar>
          <InfiniteScroll
            dataLength={getNotesCount()}
            next={() => fetchTagsAndNotes({ isFirstQuery: false })}
            hasMore={notes.length < getNotesCount()}
            loader={<h4>Carregando...</h4>}
          >
            <Notes>
              {notes.length > 0 ? (
                notes.map(note => (
                  <NoteBlock
                    key={note.id}
                    note={note}
                    isArchive={isArchive}
                    onOpenNote={handleOpenNote}
                  />
                ))
              ) : (
                <NoNotes>
                  <MdLabelOutline size="12rem" />
                  <span>
                    {isTag ? 'Não há notas com este marcador' : 'Não há notas'}
                  </span>
                </NoNotes>
              )}
            </Notes>
          </InfiniteScroll>
        </BarAndNotes>
      </Contents>

      <Modal
        isOpen={noteModalIsOpen}
        onAfterOpen={afterOpenNoteModal}
        onRequestClose={closeNoteModal}
        style={modalStyle}
        contentLabel="Selected note modal"
      >
        <NoteBlock
          note={openedNote}
          isModal
          isArchive={isArchive}
          onCloseNote={handleCloseNote}
        />
      </Modal>
    </Container>
  );
};

export default Home;

Alguns comentários no começo desse código já explicam o que é feito, mas queria observar alguns pontos do final, onde o componente é de fato retornado pela função Home.

O Keep original tem uma funcionalidade que torna a paginação das notas quase imperceptível pro usuário, que é o que chamamos de infinite scrolling (rolagem infinita). Isto é, inicialmente carregamos uma primeira “fatia” de dados e, quando o usuário está “rolando” (scrolling) pelo conteúdo e se aproximando do fim, carregamos a próxima fatia.

Assim não sobrecarregamos com o transporte de muitos dados de uma só vez e ao mesmo tempo mantemos suave a experiência do usuário, sem que ele tenha que clicar para trocar de uma página para a próxima.

Achei importante implementar esse aspecto no Keep Clone e, para isso, contei com a ajuda do componente InfiniteScroll do pacote react-infinite-scroll-component, que automatiza parte do processo. No meu caso, peço que ele chame a função fetchTagsAndNotes(), que carrega os dados necessários da API.

Falando em pacotes auxiliares, outro que utilizei foi o react-modal, que disponibiliza um componente Modal — aquela janelinha que sobrepõe o conteúdo da tela —, assim não tive que criá-lo. Na Home ele foi utilizado para exibir uma nota quando ela é selecionada e, a partir da Sidebar, o modal de adição/edição de tags.

Pegando os dados da API

Ah, e como fazemos para acessar os dados da nossa API? Uma lib excelente para fazer isso sem stress é o axios e, para não ter código relacionado aos endpoints do backend espalhado pela aplicação, escolhi abstrair a api num arquivo próprio, a partir do qual exponho os endpoints como funções:

// src/services/api.ts

import axios, { AxiosInstance, AxiosResponse } from 'axios';

export const baseAPI = (baseURL: string): AxiosInstance => {
  const api = axios.create({
    baseURL,
  });
  return api;
};

/// //////////////////// MODELS //////////////////// ///

interface User {
  id: string;
  email: string;
  password: string;
}

interface Tag {
  id: string;
  user_id: string;
  name: string;
}

interface Note {
  id: string;
  title: string;
  body: string;
  color: number;
  status: number;
  tag?: Tag;
}

/// //////////////// USERS Requests //////////////// ///

[...]

/// /////////// NOTES Requests/Responses /////////// ///

interface GetAllNotesReq {
  tagId?: string;
  query?: string;
  status: number;
  page?: number;
}

interface CreateNoteReq {
  title?: string;
  body: string;
  color: number;
  tag_id?: string;
}

[...]

/// /////////// TAGS Requests/Responses /////////// ///

[...]

// É aqui que a mágica acontece:

class api {
  static axiosInstance: AxiosInstance = baseAPI('http://localhost:3333');
  // (essa URL deve ser alterada em produção, preferencialmente para uma variável de ambiente)

  /// /////////// USERS Endpoints /////////// ///

  [...]

  /// /////////// NOTES Endpoints /////////// ///

  static async getAllNotes({
    tagId,
    query,
    status,
    page,
  }: GetAllNotesReq): Promise<AxiosResponse<GetAllNotesRes>> {
    const response = await api.axiosInstance.get<GetAllNotesRes>('notes', {
      params: {
        tag_id: tagId,
        query,
        status,
        page,
      },
    });
    return response;
  }

  static async createNote(body: CreateNoteReq): Promise<AxiosResponse<Note>> {
    const response = await api.axiosInstance.post<Note>('notes', body);
    return response;
  }

  [...]

  /// /////////// TAGS Endpoints /////////// ///

  [...]
}

export default api;

(O código é longo, então só deu pra mostrar uns pedacinhos, mas dá pra entender a ideia geral, né?)

Isso permite que a gente simplesmente chame api.getAllTags(), por exemplo, onde necessário. E, quando a API sofrer modificações, provavelmente estaremos alterando a assinatura dessas funções, o que, com a ajuda do TypeScript, gerará erros onde elas não estiverem sendo chamadas com a tipagem correta.

E muito mais

Tem vários componentes menores, como o CircularButton, o NoteBlock, e a SearchBar, por exemplo, que acabaram sendo criados para fazer tudo funcionar.

Para codá-los, usei a lib styled-components que permite misturar a sintaxe moderna (ES6) de JavaScript com CSS, recebendo parâmetros para customizar e dinamizar os elementos HTML. Um pequeno exemplo é o SidebarItem, que é como chamei cada um daqueles itens que vão na Sidebar. Ele é usado assim:

// Recorte de src/components/Sidebar/index.tsx

<SidebarItem
  onClick={() => selectTag('archive')}
  selected={isSelected('archive')}
>
  <MdArchive size={24} />
  <span>Arquivo</span>
</SidebarItem>

Na sua declaração podemos ver que ele é uma tag div e especifiquei que pode receber o parâmetro booleano selected. De acordo com o valor dessa variável, a sua cor de fundo muda:

// Recorte de src/components/Sidebar/styles.tsx

interface SidebarItemProps {
  selected?: boolean;
}

export const SidebarItem = styled.div<SidebarItemProps>`
  display: flex;
  flex-direction: row;
  align-items: center;
  width: 100%;
  height: 4.8rem;
  padding-left: 1.2rem;
  background-color: ${props =>
    props.selected ? 'var(--color-primary-light)' : 'transparent'}; // <==========
  border-radius: 0 2.5rem 2.5rem 0;
  cursor: pointer;

  svg {
    fill: var(--color-icon-base);
    margin: 0 1.2rem;
  }

  span {
    margin-left: 2rem;
    margin-right: 1rem;
    font-size: 1.4rem;
    font-family: 'Roboto';
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  &:hover {
    background-color: ${props =>
      props.selected ? 'var(--color-primary-light)' : '#f1f3f4'}; // <==========
  }
`;

Tem vááárias outras coisas bem interessantes mas, para não me estender, fico por aqui.

Se você quiser como fazer deploy desse frontend, uma das opções é utilizar o Netlify, como explico neste post.

No próximo post, exploraremos como foi montado o backend para autenticar os usuários e prover todas essas notas, tags, etc. Lembrando que se você quiser acessar o código completo do projeto, ele está disponível neste repositório no meu GitHub. E qualquer coisa é só entrar em contato!

Clique aqui para ir para a Parte 2

E aí, gostou dessa primeira parte? O que mais você gostaria de ver?