Создание сократителя урлов с помощью NextJs, Tailwind CSS и Strapi

Автор: Чибуике Нвачукву

С тех пор как началась эпоха интернета, ссылки играют неотъемлемую роль в том, как мы взаимодействуем и посещаем веб-страницы. Они служат средством доступа к различным ресурсам в сети. Удобный для человека формат ссылок, в отличие от знания реального IP-адреса веб-страницы, внес огромный вклад в их широкое использование.

Его популярность принесла с собой некоторые проблемы, поскольку все больше людей начали создавать и размещать веб-сайты для своих различных нужд, начиная от компаний, блогов и заканчивая мероприятиями, произошло быстрое увеличение количества доменных имен/URL. Теперь людям приходится запоминать длинные URL-адреса, чтобы потом зайти на сайт. Это одна из основных проблем, которую призван решить URL Shortener.

Проще говоря, сократитель URL — это сервис, который уменьшает длину URL. Для этого он сохраняет URL в своих записях, присваивает ему псевдоним (короткий текст), а затем перенаправляет любой запрос, сделанный к этому псевдониму в его записях, на URL хоста (веб-страницу).

Это руководство покажет вам, как создать службу сокращения URL, используя Next.js и Tailwind CSS для frontend и Strapi Headless CMS для backend. Вы можете найти ссылку на готовый код фронтенда здесь, а также готовый код бэкенда здесь.

Преимущества использования сокращенного URL

Прежде чем мы продолжим эту статью, необходимо получить более четкое представление о том, что делает сократитель URL. Некоторые из преимуществ включают в себя:

  1. Эстетическая привлекательность: Разве не здорово видеть приглашение на мероприятие, в ссылке которого указано только название мероприятия, в отличие от длинной ссылки, содержащей в URL дату, место проведения.
  2. Отслеживание аналитики: Как приложение, которое может быть развернуто в нескольких местах, оно снижает затраты на содержание большого числа представителей службы поддержки клиентов.
  3. Замена ссылок: Поскольку большинство сервисов сокращения URL позволяют редактировать реальный URL, мы всегда можем быть последовательны в ссылке, которой делимся, и в то же время гибко подходить к веб-странице, на которую ведет ссылка.
  4. Легче запомнить: Поскольку большинство сократителей URL-адресов имеют короткие домены, например bit.ly, TinyURL, людям проще запомнить URL-адрес, когда им им его сообщают.

Предварительные условия

Прежде чем приступить к этому уроку, вам необходимо иметь:

  • Node.js установлен на вашей локальной машине (v14+) — ознакомьтесь с инструкциями по установке Node.js в этом руководстве.
  • Базовое понимание Strapi — Начните работу с помощью этого краткого руководства
  • Базовые знания Next.js
  • Базовые знания Tailwind CSS

Что такое Next Js

Next.js — это потрясающий фреймворк React для создания высокодинамичных приложений. Он поставляется с предварительным рендерингом, рендерингом на стороне сервера, автоматическим разделением кода и многими другими отличными функциями из коробки.

Что такое Tailwind CSS

Tailwind CSS — это CSS-фреймворк для быстрого создания пользовательских интерфейсов. С Tailwind CSS мы пишем наш CSS прямо в наших HTML-классах. Это очень удобно, поскольку нам не нужно импортировать внешнюю таблицу стилей или использовать отдельную библиотеку для дизайна пользовательского интерфейса.

Что такое Strapi

Strapi — это Node.js CMS с открытым исходным кодом, которая позволяет нам разрабатывать API и легко управлять контентом без необходимости создавать проект с нуля. Она позволяет настраивать и размещать контент самостоятельно, в отличие от привычных нам жестких традиционных CMS.

Мы можем быстрее создавать API и использовать содержимое через API с помощью любого клиента REST API или GraphQL.

Создание проекта Strapi

Создать новый проект Strapi довольно просто — достаточно выполнить несколько команд:

npx create-strapi-app strapi-tutorial-shortner --quickstart
Войти в полноэкранный режим Выйти из полноэкранного режима

Измените strapi-tutorial-shortner на желаемое имя вашего проекта.

Это позволит установить и создать проект Strapi локально.

После установки браузер откроет страницу на localhost:1337, где будет предложено настроить первую учетную запись администратора для продолжения работы со Strapi.

Создание коллекции укоротителей

Далее мы создадим новый тип коллекции, который будет хранить детали каждого вопроса и соответствующие ответы.

Итак, мы создаем тип коллекции под названием shortner, который имеет эти четыре поля: alias, url, visit, user.

При нажатии кнопки «Продолжить» появится еще одно окно для выбора полей для этой коллекции. Выберите поле «Текст» из списка и укажите alias в качестве его имени.

Далее в «Базовых настройках» выбираем тип Short Text, так как alias должен быть короткой строкой.

Далее переходим на вкладку «Дополнительные настройки» и устанавливаем флажок «Обязательное поле», чтобы убедиться, что это поле является обязательным. Также мы установим флажок «Уникальное поле», чтобы предотвратить появление одинаковых псевдонимов в нашей записи.

Мы нажимаем на кнопку «Добавить другое поле», чтобы добавить поле ответа. Ниже приведена таблица, показывающая свойства всех полей, которые нам нужны в этой коллекции:

Имя поля Тип поля Обязательно Уникальный
псевдоним Короткий текст истинно истинный
url Короткий текст true false
посещение Число (целое число) false false
пользователь Число (целое) истинный false

Разрешение публичного доступа

По умолчанию, когда вы создаете API, все они будут закрыты для публичного доступа. Нам нужно сообщить Strapi, что вы согласны открыть эти проверенные конечные точки для публичного доступа. Перейдите в раздел Settings > Users & Permissions Plugin ****** > Roles и нажмите для редактирования роли Public. Далее прокрутите вниз до раздела Permissions > Shortner и установите флажок find.

Мы также будем открывать некоторые конечные точки для аутентифицированного пользователя. Нажмите кнопку «Вернуться», а затем нажмите редактировать роль Authenticated Role. На рисунке ниже показаны конечные точки, которые будут открыты для аутентифицированного пользователя: **

Настройка контроллера Shortner

Мы настраиваем контроллер shortner, который находится по адресу src/api/shortner/controllers/shortner.js, чтобы добавить в него больше функциональности для удовлетворения наших потребностей.

Для метода find у нас есть следующие сценарии:

  1. Если он вызывается аутентифицированным пользователем, мы показываем только те записи, которые принадлежат этому пользователю. Обычно это вызывается фронт-эндом, когда он хочет отобразить записи на приборной панели.
  2. Если он вызывается неаутентифицированным пользователем, мы фильтруем на основе предоставленного запроса, который обычно вызывается фронт-эндом, когда он хочет проверить, существует ли псевдоним в нашей записи. Если он найден, мы также увеличиваем поле visit в коллекции shortner, чтобы отследить посещение.

Для метода create мы используем его для создания новой записи, а также присваиваем поле user в коллекции shortner идентификатору аутентифицированного пользователя. Таким образом, только аутентифицированные пользователи имеют доступ к этой конечной точке.

Что касается метода delete; мы используем его для удаления записи из коллекции shortner, только пользователь, создавший запись, имеет право удалить ее. Это также означает, что только аутентифицированные пользователи имеют доступ к этой конечной точке.

Следовательно, замените код файла на приведенный ниже код:

    'use strict';
    /**
     *  shortner controller
     */
    const { createCoreController } = require('@strapi/strapi').factories;
    module.exports = createCoreController('api::shortner.shortner', ({ strapi }) => ({
        async find(ctx) {
            let { query } = ctx;
            const user = ctx.state.user;
            let entity;
            if (user) {
                query = { user: { '$eq': user.id } }
                entity = await strapi.service('api::shortner.shortner').find({ filters: query });
            } else {
                query = { alias: { '$eq': query.alias } }
                entity = await strapi.service('api::shortner.shortner').find({ filters: query });
                if (entity.results.length !== 0) {
                    let id = entity.results[0].id
                    let visit = Number(entity.results[0].visit) + 1
                    await strapi.service('api::shortner.shortner').update(id, { data: { visit } });
                }
            }
            const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
            return this.transformResponse(sanitizedEntity);
        },
        async create(ctx) {
            const { data } = ctx.request.body;
            const user = ctx.state.user;
            let entity;
            data.user = user.id
            entity = await strapi.service('api::shortner.shortner').create({ data });
            const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
            return this.transformResponse(sanitizedEntity);
        },
        async delete(ctx) {
            let { id } = ctx.params;
            const user = ctx.state.user;
            let entity;
            let query = { user: { '$eq': user.id }, id: { '$eq': id } }
            entity = await strapi.service('api::shortner.shortner').find({ filters: query });
            if (entity.results.length === 0) {
                return ctx.badRequest(null, [{ messages: [{ id: 'You can delete someone else content' }] }]);
            }
            entity = await strapi.service('api::shortner.shortner').delete(id);
            const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
            return this.transformResponse(sanitizedEntity);
        },
    }));
Вход в полноэкранный режим Выйти из полноэкранного режима

Создание проекта Next.js

Создание приложения Next.js

Чтобы создать приложение Next.js, откройте терминал, cd в каталог, в котором вы хотите создать приложение, и выполните следующую команду:

npx create-next-app -e with-tailwindcss nextjs-shortner
Войти в полноэкранный режим Выйти из полноэкранного режима

Это также настроит Tailwind CSS вместе с проектом.

Запуск сервера разработки Next.js

Далее мы cd во вновь созданную директорию, в нашем случае это будет nextjs-shortner:

    cd nextjs-shortner
Вход в полноэкранный режим Выход из полноэкранного режима

После этого мы запустим сервер разработки, выполнив эту команду:

    npm run dev
Войти в полноэкранный режим Выйти из полноэкранного режима

Если все было настроено нормально, то сервер Next.js теперь должен быть запущен на localhost:3000, и мы должны увидеть следующую страницу в нашем браузере:

Создание компонентов Next.js

Далее мы открываем любой текстовый редактор по нашему выбору, чтобы написать код для остальной части приложения. Откройте установленный проект, и у нас должна получиться такая структура папок:

Чтобы начать разработку интерфейса, мы удалим весь код в файле index.js и добавим код ниже:

    import React, { useContext, useEffect } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    export default function Home() {
      const { isLoggedIn, user } = useContext(MyContext)
      const router = useRouter()
      useEffect(() => {
        if (isLoggedIn) {
         return router.push("/dashboard");
        }
        return router.push("/login");
      }, [isLoggedIn])
      return null
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

Приведенный выше код использует React Context API для проверки аутентификации пользователя. Это определяет, какая страница будет показана пользователю.

Как можно видеть, мы импортируем файл context из папки lib. Нам нужно создать этот файл. Перейдите в корень проекта и создайте папку lib, затем создайте в ней файл context.js.

Внутри этого context.js мы создадим context, а также присвоим значение по умолчанию false для isLoggedIn.

    import React from 'react';
    const MyContext = React.createContext({ isLoggedIn: false });
    export default MyContext;
Вход в полноэкранный режим Выход из полноэкранного режима

Далее мы переходим к созданию двух файлов, которые мы будем условно перенаправлять на файлы Login и Register.

Next.js создает маршруты для файлов в каталоге pages. Маршрут указывает на сами файлы, их документация объясняет это довольно хорошо. Это означает, что если мы создали файл под названием dashboard.js в каталоге pages, мы можем получить к нему доступ, посетив localhost:3000/dashboard без необходимости создавать дополнительный механизм маршрутизации. Отлично, верно?

Итак, мы просто создаем два файла (Login и Register) в этом каталоге pages.

Однако, прежде чем мы погрузимся в эти две страницы, нам нужно сначала обновить содержимое страницы _app.js.

Эта страница используется Next.js для инициализации других страниц, поэтому мы можем использовать ее для достижения постоянной верстки между страницами, пользовательской обработки ошибок, а в нашем случае — для сохранения глобального состояния страниц. Подробнее об этой странице читайте здесь.

Создайте файл _app.js, если он не существует в директории pages. Удалите все, что в нем есть, и замените его код кодом, приведенным ниже:

    import React, { useState, useEffect } from 'react';
    import MyContext from '../lib/context';
    import Cookie from "js-cookie";
    import 'tailwindcss/tailwind.css'
    export default function _App({ Component, pageProps }) {
      const [user, setUser] = useState(null)
      const [urls, setUrls] = useState([])

      useEffect(() => {
        const jwt = Cookie.get("jwt");
        if (jwt) {
          fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users/me`, {
            headers: {
              Authorization: `Bearer ${jwt}`,
            },
          }).then(async (res) => {
            if (!res.ok) {
              Cookie.remove("jwt");
              setUser(null);
            }

            const user = await res.json();
            setUser(user);
          });
        }
      }, [])
      return (
        <MyContext.Provider
          value={{
            user: user,
            isLoggedIn: !!user,
            setUser,
            setUrls,
            urls
          }}
        >
          <Component {...pageProps} />
        </MyContext.Provider>
        )
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

Приведенный выше код просто оборачивается вокруг всех страниц и обрабатывает глобальное состояние с помощью React Context API.

Мы также используем пакет js-cookie npm для хранения нашего токена, чтобы сохранить сессию, даже когда пользователь обновляет страницу.

Чтобы установить его, выполните команду npm i js-cookie.

Затем мы импортируем его в наш файл.

import Cookie from "js-cookie";
Вход в полноэкранный режим Выход из полноэкранного режима

Мы используем хук useEffect для проверки наличия сохраненного токена (это означает, что пользователь вошел в систему). Если токен найден, мы делаем запрос к Strapi API, чтобы получить данные об этом пользователе. Если ошибок нет, мы сохраняем пользователя в состоянии user, в противном случае мы удаляем токен и присваиваем null состоянию user.

    useEffect(() => {
        const jwt = Cookie.get("jwt");
        if (jwt) {
          fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users/me`, {
            headers: {
              Authorization: `Bearer ${jwt}`,
            },
          }).then(async (res) => {
            if (!res.ok) {
              Cookie.remove("jwt");
              setUser(null);
            }
            const user = await res.json();
            setUser(user);
          });
        }
    }, [])
Вход в полноэкранный режим Выход из полноэкранного режима

Как можно видеть, у нас есть два состояния, user и urls, созданные с помощью хука useState. Мы уже видели использование состояния user, мы используем состояние urls для хранения массива шорткодов, которые мы получили из Strapi API.

Наконец, мы оборачиваем Component провайдером Context API, аналогично тому, как мы делаем это в Redux. Далее мы устанавливаем значения Context API в наши переменные состояния, а также в такие функции, как setUrls, setUser, чтобы другие страницы/компоненты могли получить к ним доступ.

Наконец, мы создаем новую переменную isLoggedIn, которая будет использоваться для проверки существования аутентифицированного пользователя.

    return (
        <MyContext.Provider
          value={{
            user: user,
            isLoggedIn: !!user,
            setUser,
            setUrls,
            urls
          }}
        >
          <Component {...pageProps} />
        </MyContext.Provider>
    )
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь перейдем к созданию файла Register. Добавьте приведенное ниже содержимое во вновь созданный файл pages/register.js:

    import Head from 'next/head'
    import Link from 'next/link'
    import React, { useState, useContext, useEffect } from 'react';
    import MyContext from '../lib/context';
    import { register } from '../lib/auth'
    import { useRouter } from "next/router"; 
    export default function Register() {
      const { isLoggedIn, setUser } = useContext(MyContext)
      const router = useRouter()

      let [username, setUsername] = useState("");
      let [email, setEmail] = useState("");
      let [password, setPassword] = useState("")
      const [loading, setLoading] = useState(false);
      const [errors, setErrors] = useState({});
      useEffect( () => {
        if (isLoggedIn) {
         return router.push("/dashboard");
        }
      }, [isLoggedIn])
      const submit = async () => {
        if(!username.trim()) return setErrors({ username: "Username must not be empty"})
        if(!email) return setErrors({ email: "Email must not be empty"})
        if(!password) return setErrors({ password: "Password must not be empty"})

        setLoading(true);
        const reg = await (register(username, email, password))
        setLoading(false);
        if(reg.jwt){
          setUser(reg.user);
          router.push('/dashboard')
        }else{
          setErrors({ server: reg?.error?.message || 'Error from server' });
        }
      }
      return (
        <div className="flex flex-col items-center justify-center min-h-screen py-2">
          <Head>
            <title>Create Next App</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
            <h1 className="text-6xl font-bold text-blue-600">
              Url Shortener
            </h1>

            <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
              <form className="w-full max-w-lg mt-8" onSubmit={(e) => { e.preventDefault(); submit() }}>
              <div className="flex flex-wrap -mx-3 mb-2">
                  <div className="w-full px-3 mb-6 md:mb-0">
                    <input onChange={ (e) => setUsername(e.target.value)}  placeholder="Enter username" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.username ? "border-red-500" : "border-gray-200"}`} id="grid-username" type="text" />
                    {errors.username ? (
                      <p className="text-red-500 text-xs italic">{errors.username}</p>
                    ) : ''}
                  </div>
                </div>
                <div className="flex flex-wrap -mx-3 mb-2">
                  <div className="w-full px-3 mb-6 md:mb-0">
                    <input onChange={ (e) => setEmail(e.target.value)}  placeholder="Enter email" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.email ? "border-red-500" : "border-gray-200"}`} id="grid-email" type="email" />
                    {errors.email ? (
                      <p className="text-red-500 text-xs italic">{errors.email}</p>
                    ) : ''}
                  </div>
                </div>
                <div className="flex flex-wrap -mx-3 mb-6">
                  <div className="w-full px-3">
                    <span className={`w-full inline-flex items-center rounded border border-r-1  text-gray-700 mb-2 text-sm  focus:outline-none focus:bg-white focus:border-gray-500 ${errors.password ? "border-red-500 " : " border-gray-200"}`}>
                      <input onChange={ (e) => setPassword(e.target.value)}  placeholder="******************" className="appearance-none block rounded w-full py-3 px-4 leading-tight" id="grid-password" type='password' />
                    </span>
                    {errors.password ? (
                      <p className="text-red-500 text-xs italic">{errors.password}</p>
                    ) : ''}
                  </div>
                </div>
                {errors.server ? (
                      <p className="text-red-500 text-xs italic">{errors.server}</p>
                    ) : ''}
                <div className="flex flex-row flex-wrap justify-between">

                  <span className="text-blue-600 hover:text-gray-600 pt-2 md:p-6"> <Link href="/login">Back to Login?</Link></span>
                  <button disabled={loading} className={`w-full md:w-1/2 mt-3 flex justify-center hover:bg-gray-200 hover:text-gray-900 rounded-md px-3 py-3 uppercase ${loading ? "bg-gray-200  text-black cursor-not-allowed" : "bg-gray-900  text-white cursor-pointer"}`}>
                    {loading ? (
                      <>
                        loading &nbsp;...
                      </>
                    ) : 'Register'}
                  </button>
                </div>
              </form>
            </div>
          </main>
        </div>
      )
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

Приведенный выше код регистрирует пользователей на платформе, позволяя нам позже создать защищенную страницу, чтобы люди могли заходить, создавать, управлять и отслеживать свои сокращенные URL.

Мы также используем хук useContext для получения значений состояния и функций:

    import React, { useState, useContext, useEffect } from 'react';
    import MyContext from '../lib/context';
    const { isLoggedIn, setUser } = useContext(MyContext)
Вход в полноэкранный режим Выход из полноэкранного режима

Также мы используем хук useEffect для применения промежуточного программного обеспечения на странице, чтобы только неаутентифицированный пользователь мог получить доступ к странице. Для этого мы используем состояние isLoggedIn:

    import React, { useState, useContext, useEffect } from 'react';
    useEffect( () => {
        if (isLoggedIn) {
          return router.push("/dashboard");
        }
    }, [isLoggedIn])
Вход в полноэкранный режим Выход из полноэкранного режима

Если пользователь прошел аутентификацию, мы перенаправляем его обратно на приборную панель.

Метод submit обрабатывает регистрацию пользователя, проверяет и устанавливает состояние user для подписанного пользователя в случае успеха, а затем перенаправляет пользователя на его приборную панель:

    const submit = async () => {
        if(!username.trim()) return setErrors({ username: "Username must not be empty"})
        if(!email) return setErrors({ email: "Email must not be empty"})
        if(!password) return setErrors({ password: "Password must not be empty"})

        setLoading(true);
        const reg = await (register(username, email, password))
        setLoading(false);
        if (reg.jwt) {
          setUser(reg.user);
          router.push('/dashboard')
        } else{
          setErrors({ server: reg?.error?.message || 'Error from server' });
        }
      }
Вход в полноэкранный режим Выход из полноэкранного режима

Как видно, мы используем функцию register, которая обрабатывает отправку запроса к Strapi API:

import { register } from '../lib/auth'
const reg = await register(username, email, password)
Вход в полноэкранный режим Выход из полноэкранного режима

Далее мы создаем этот файл (auth.js) в папке lib. Этот файл выполняет аутентифицированные запросы к нашему API и обрабатывает другие функции, связанные с аутентификацией, например, выход из системы. Добавьте в файл содержимое, приведенное ниже:

    import Cookie from "js-cookie";
    const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337";

    export const register = async (username, email, password) => {
         try {
            let response = await fetch(`${API_URL}/api/auth/local/register`, {
                method: 'POST',
                body: JSON.stringify({ username, email, password }),
                headers: {
                    'Content-Type': 'application/json'
                },
            });
            response = await response.json();
            if (response) {
                Cookie.set("jwt", response.jwt);
            }
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }

    };
    export const login = async (identifier, password) => {
       try {
            let response = await fetch(`${API_URL}/api/auth/local`, {
                method: 'POST',
                body: JSON.stringify({ identifier, password }),
                headers: {
                    'Content-Type': 'application/json'
                },
            });
            response = await response.json();
            if (response) {
                Cookie.set("jwt", response.jwt);
            }
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }

    };
    export const logout = () => {
        Cookie.remove("jwt");
    };
Вход в полноэкранный режим Выход из полноэкранного режима

Как видно, мы используем пакет js-cookie для присвоения маркера jwt, когда пользователь входит или регистрируется, а также для удаления этого маркера, когда пользователь выходит из системы.

Это также приведет нас к созданию .env в корне нашего проекта. Внутри него мы будем иметь:

 NEXT_PUBLIC_API_URL=http://localhost:1337
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь мы должны создать файл Login. Добавьте приведенное ниже содержимое в только что созданный файл pages/login.js:

    import Head from 'next/head'
    import React, { useState, useEffect, useContext } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    import { login } from '../lib/auth'
    import Link from 'next/link'
    export default function Login() {

      let [email, setEmail] = useState("");
      let [password, setPassword] = useState("")
      const [loading, setLoading] = useState(false);
      const [errors, setErrors] = useState({});
      const { isLoggedIn, setUser } = useContext(MyContext)
      const router = useRouter()
      const signIn = async () => {
        if(!email) return setErrors({ email: "Email must not be empty"})
        if(!password) return setErrors({ password: "Password must not be empty"})

        setLoading(true);
        const reg = await (login(email, password))
        setLoading(false);
        if(reg.jwt){
          setUser(reg.user);
          router.push('/')
        }else{
          setErrors({ server: reg?.error?.message || 'Error from server' });
        }
      }
      useEffect( () => {
        if (isLoggedIn) {
         return router.push("/dashboard");
        }
      }, [isLoggedIn])

      return (
        <div className="flex flex-col items-center justify-center min-h-screen py-2">
          <Head>
            <title>Create Next App</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
          <main className="flex flex-col items-center justify-center w-full flex-1 px-20 text-center">
            <h1 className="text-6xl font-bold text-blue-600">
              Url Shortener
            </h1>

            <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
              <form className="w-full max-w-lg mt-8" onSubmit={(e) => { e.preventDefault(); signIn(email, password) }}>
                <div className="flex flex-wrap -mx-3 mb-2">
                  <div className="w-full px-3 mb-6 md:mb-0">
                    <input onChange={ (e) => setEmail(e.target.value)} placeholder="Enter email..." className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.email ? "border-red-500" : "border-gray-200"}`} id="grid-email" type="email" />
                    {errors.email ? (
                      <p className="text-red-500 text-xs italic">{errors.email}</p>
                    ) : ''}
                  </div>
                </div>
                <div className="flex flex-wrap -mx-3 mb-6">
                  <div className="w-full px-3">
                    <span className={`w-full inline-flex items-center rounded border border-r-1 text-gray-700 mb-2 text-sm  focus:outline-none focus:bg-white focus:border-gray-500 ${errors.password ? "border-red-500 " : " border-gray-200"}`}>
                      <input onChange={ (e) => setPassword(e.target.value)} placeholder="******************" className="appearance-none block rounded w-full py-3 px-4 leading-tight" id="grid-password" type='password' />
                    </span>
                    {errors.password ? (
                      <p className="text-red-500 text-xs italic">{errors.password}</p>
                    ) : ''}
                  </div>
                </div>
                {errors.server ? (
                      <p className="text-red-500 text-xs italic">{errors.server}</p>
                    ) : ''}
                <div className="flex flex-row flex-wrap justify-between">
                  <button disabled={loading} className={`w-full md:w-1/2 mt-3 flex justify-center align-center hover:bg-gray-200 hover:text-gray-900 rounded-md px-2 py-3 uppercase ${loading ? "bg-gray-200  text-black cursor-not-allowed" : "bg-gray-900  text-white cursor-pointer"}`}>
                    {loading ? (
                      <>
                        loading &nbsp;...
                      </>
                    ) : 'LOG IN'}
                  </button>
                  <span className="text-blue-600 hover:text-gray-600 pt-2 md:p-6"> <Link href="/register">Register</Link></span>
                </div>
              </form>
            </div>
          </main>

        </div>
      )
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

Приведенный выше код позволяет пользователям войти в систему и получить доступ к защищенной приборной панели. Он похож на регистрацию, только не создает пользователей, а проверяет их существование в записи и аутентифицирует их.

Здесь также используется файл lib/auth.js, который мы уже видели.

Остальные страницы, которые мы сейчас рассмотрим, следующие:

  1. Страница Dashboard: Мы будем использовать ее для удаления и просмотра сокращенных URL.
  2. Страница добавления урла: Используется для добавления сокращенного URL.
  3. Страница псевдонима: Эта страница используется для перенаправления на URL, если псевдоним найден в нашей записи.

Построение страницы приборной панели

Как обсуждалось ранее, эта страница отображает все созданные записи, а также позволяет пользователю проверять их и удалять.

Создайте файл с именем dashboard.js в папке pages pages/dashboard.js. В качестве его содержимого вставьте приведенный ниже код:

    import Head from 'next/head'
    import React, { useEffect, useContext, useState } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    import Link from 'next/link';
    import { logout } from '../lib/auth'
    import { get, deleteAlias } from '../lib/shortener'

    export default function Dashboard() {
        const { isLoggedIn, setUser, user, setUrls, urls } = useContext(MyContext)
        const router = useRouter()
        const getAll = async () => {
            let short = await get()
            if (!short) return
            setUrls(short?.data?.attributes?.results || null)
        }
        const deleteShort = async (id) => {
            if (!id) return
            let deleted = await deleteAlias(id)
            if (deleted.data && !deleted.error) {
                await getAll()
            }
        }
        useEffect(() => {
            if (!isLoggedIn) {
                return router.push("/login");
            }
            getAll()
        }, [urls.length])

        const signOut = () => {
            logout()
            setUser(null)
            router.push('/login')
        }

        return (
            <div className="flex flex-col items-center justify-center min-h-screen py-2">
                <Head>
                    <title>Dashboard</title>
                    <link rel="icon" href="/favicon.ico" />
                </Head>
                <header className="flex justify-between align-center p-4 h-32 w-full text-6xl font-bold text-blue-600">
                    <h1 className="text-6xl font-bold text-blue-600">
                        Url Shortener
                    </h1>
                    <span className="text-sm font-bold text-red-600 cursor-pointer" onClick={() => signOut()}>Logout</span>
                </header>
                <main className="flex flex-col items-center w-full mt-0 flex-1 px-8 text-center">

                    <p className="flex flex-wrap w-full text-lg font-bold">
                        Welcome {user?.username || ""}
                    </p>
                    <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
                        <div className="shadow  border-b w-full  overflow-hidden border-gray-200 sm:rounded-lg">
                            <table className="min-w-full divide-y divide-gray-200">
                                <thead>
                                    <tr>
                                        <th scope="col" className="px-6 py-3 bg-gray-50 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                                            Url
                                        </th>
                                        <th scope="col" className="px-6 py-3 bg-gray-50 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                                            Alias/Shortned
                                        </th>
                                        <th scope="col" className="px-6 py-3 bg-gray-50 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                                            No of hits
                                        </th>
                                        <th scope="col" className="px-6 py-3 bg-gray-50">
                                            <span className="sr-only">Remove</span>
                                        </th>
                                    </tr>
                                </thead>
                                <tbody className="bg-white divide-y divide-gray-200">
                                    {(!urls || urls.length == 0) && (
                                        <tr>
                                            <td colSpan="3" className="px-2 py-4 whitespace-nowrap cursor-pointer">
                                                No record found
                                            </td>
                                        </tr>
                                    )}
                                    {urls && urls.map(short =>
                                    (
                                        <tr className="hover:bg-gray-200" key={short.id}>
                                            <td className="px-2 py-4 whitespace-nowrap cursor-pointer" title = "Open Url" onClick={() => { window.open(`${short.url}`, 'blank') }}>
                                                <div className="text-sm text-gray-900">{short?.url || 'N/A'}</div>
                                            </td>
                                            <td className="px-2 py-4 whitespace-nowrap cursor-pointer" title = "Test Alias" onClick={() => { window.open(`/${short.alias}`, 'blank') }}>
                                                <div className="text-sm text-gray-900">{short?.alias || 'N/A'}</div>
                                            </td>
                                            <td className="px-2 py-4 whitespace-nowrap cursor-pointer">
                                                <span className="px-2  text-xs leading-5 font-semibold rounded-full ">
                                                    <div className="text-sm text-gray-500">
                                                        {short?.visit || 0}
                                                    </div>
                                                </span>
                                            </td>
                                            <td className="px-2 py-2 whitespace-nowrap text-center text-sm font-medium">
                                                <button onClick={() => deleteShort(short.id)} className="text-red-600 hover:text-red-900 mx-1">Delete</button>
                                            </td>
                                        </tr>
                                    )
                                    )}
                                </tbody>
                            </table>
                        </div>
                    </div>
                </main>
                <Link href="/addUrl">
                    <button className="absolute rounded-full text-white font-bold text-lg p-2 bg-blue-800 w-12 h-12 m-4 right-0 bottom-0 hover:bg-blue-400"> + </button>
                </Link>
            </div>
        )
    }
Вход в полноэкранный режим Выйти из полноэкранного режима

В двух словах, мы используем это, чтобы показать пользователям их сокращенные URL. Как видно, мы используем хук useEffect, чтобы предотвратить доступ неаутентифицированных пользователей к странице.

Также у нас есть функции для удаления записи, получения всех записей и выхода пользователей из системы.

Функции, обрабатывающие delete и get, вызывают центральный вспомогательный файл shortener под названием shortener.js:

    import { get, deleteAlias } from '../lib/shortener'
Вход в полноэкранный режим Выход из полноэкранного режима

Мы используем этот файл для обработки всех функций, связанных с шорткатом. Поэтому мы создадим этот файл в папке lib, lib/shortener.js, и добавим приведенный ниже код в качестве его содержимого:

    import Cookie from "js-cookie";
    const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:1337";

    export const get = async () => {
        const token = Cookie.get("jwt");
        try {
            let response = await fetch(`${API_URL}/api/shortners`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
            });
            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    };

    export const getSingle = async (alias) => {
        try {
            let response = await fetch(`${API_URL}/api/shortners?alias=${alias}`, {
                method: 'GET',
                headers: {
                    'Content-Type': 'application/json'
                },
            });
            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    }

    export const create = async (url, alias) => {
        const token = Cookie.get("jwt");
        try {
            let response = await fetch(`${API_URL}/api/shortners`, {
                method: 'POST',
                body: JSON.stringify({ data: { url, alias } }),
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
            });
            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    };

    export const deleteAlias = async (id) => {
        const token = Cookie.get("jwt");

        try {
            let response = await fetch(`${API_URL}/api/shortners/${id}`, {
                method: 'DELETE',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${token}`
                },
            });

            response = await response.json();
            return response
        } catch (e) {
            return { error: 'An error occured' }
        }
    };
Вход в полноэкранный режим Выйти из полноэкранного режима

Создание страницы добавления URL

Как обсуждалось ранее, эта страница управляет созданием сокращенных URL-адресов. Создайте файл addUrl.js в папке pages, pages/addUrl.js.

Затем добавьте приведенное ниже содержимое в качестве его нового содержимого:

    import Head from 'next/head';
    import Link from 'next/link';
    import React, { useEffect, useContext, useState } from 'react';
    import MyContext from '../lib/context';
    import { useRouter } from "next/router";
    import { logout } from '../lib/auth';
    import { create } from '../lib/shortener';

    export default function AddUrl() {
        const { isLoggedIn, setUser } = useContext(MyContext)
        const [url, setUrl] = useState("");
        const [alias, setAlias] = useState("");
        const [loading, setLoading] = useState(false);
        const [errors, setErrors] = useState({});
        const router = useRouter();
        useEffect(() => {
            if (!isLoggedIn) {
                return router.push("/login");
            }
        }, [isLoggedIn]);
        const shorten = async () => {
            if (!url) return setErrors({ url: "Url must not be empty" })
            if (!alias) return setErrors({ alias: "Alias must not be empty" })
            setLoading(true);
            const short = await(create(url, alias))
            setLoading(false);
            if (short.data && !short.error) {
                router.push('/dashboard')
            } else {
                setErrors({ server: short?.error?.message || 'Error from server' });
            }
        }
        const signOut = () => {
            logout();
            setUser(null);
            router.push('/login');
        }
        return (
            <div className="flex flex-col items-center justify-center min-h-screen py-2">
                <Head>
                    <title>Add Url</title>
                    <link rel="icon" href="/favicon.ico" />
                </Head>
                <header className="flex justify-between align-center p-4 h-32 w-full text-6xl font-bold text-blue-600">
                    <h1 className="text-6xl font-bold text-blue-600">
                        Url Shortener
                    </h1>
                    <span className="text-sm font-bold text-red-600 cursor-pointer" onClick={() => signOut()}>Logout</span>
                </header>
                <main className="flex flex-col items-center w-full mt-0 flex-1 px-8 text-center">

                    <p className="flex flex-wrap w-full text-lg font-bold">
                        Fill the form
                    </p>
                    <div className="flex flex-wrap items-center justify-around max-w-4xl mt-6 sm:w-full">
                        <form className="w-full max-w-lg mt-8" onSubmit={(e) => { e.preventDefault(); shorten() }}>
                            <div className="flex flex-wrap -mx-3 mb-2">
                                <div className="w-full px-3 mb-6 md:mb-0">
                                    <input onChange={(e) => setUrl(e.target.value)} placeholder="Enter url" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.url ? "border-red-500" : "border-gray-200"}`} id="grid-url" type="text" />
                                    {errors.url ? (
                                        <p className="text-red-500 text-xs italic">{errors.url}</p>
                                    ) : ''}
                                </div>
                            </div>
                            <div className="flex flex-wrap -mx-3 mb-2">
                                <div className="w-full px-3 mb-6 md:mb-0">
                                    <input onChange={(e) => setAlias(e.target.value)} placeholder="Enter alias" className={`appearance-none block w-full text-gray-700 mb-4 border rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500 ${errors.alias ? "border-red-500" : "border-gray-200"}`} id="grid-alias" type="text" />
                                    {errors.alias ? (
                                        <p className="text-red-500 text-xs italic">{errors.alias}</p>
                                    ) : ''}
                                </div>
                            </div>
                            {errors.server ? (
                                <p className="text-red-500 text-xs italic">{errors.server}</p>
                            ) : ''}
                            <div className="flex flex-row flex-wrap justify-between">
                                <span className="text-blue-600 hover:text-gray-600 pt-2 md:p-6"> <Link href="/dashboard"> Back to Dashboard</Link></span>
                                <button disabled={loading} className={`w-full md:w-1/2 mt-3 flex justify-center hover:bg-gray-200 hover:text-gray-900 rounded-md px-3 py-3 uppercase ${loading ? "bg-gray-200  text-black cursor-not-allowed" : "bg-gray-900  text-white cursor-pointer"}`}>
                                    {loading ? (
                                        <>
                                            loading &nbsp;...
                                        </>
                                    ) : 'Shorten'}
                                </button>
                            </div>
                        </form>
                    </div>
                </main>
            </div>
        )
    }
Войти в полноэкранный режим Выйти из полноэкранного режима

Это довольно просто понять, мы просто используем файл shortener в папке lib, чтобы сделать запрос к нашему Strapi API для добавления записи.

Мы также используем хук useEffect, чтобы предотвратить доступ неаутентифицированных пользователей к странице.

Создание страницы псевдонимов

Эта страница отвечает за проверку существования псевдонима в нашей записи и соответствующее перенаправление пользователя.

Впоследствии, если псевдоним найден в нашей записи, Strapi API регистрирует это как посещение псевдонима, что дает нам возможность просматривать аналитику конкретного псевдонима.

Мы создаем файл [alias].js в папке pages, pages/[alias].js. Если это выглядит странно, проверьте, как строить страницы с динамическими маршрутами в Next.js.

Далее вставьте содержимое ниже в качестве содержимого этого файла:

    import { useRouter } from "next/router";
    import { useEffect } from "react";
    import { getSingle } from "../lib/shortener";

    const AliasView = ({ error }) => {
        const router = useRouter()
        useEffect(() => {
            if (error) {
                return router.push('/')
            }
        }, [])
        return null
    };

    export async function getServerSideProps({ params }) {
        const url = await getSingle(params.alias)
        if (url.data && (url.data?.attributes?.results[0] || false) && !url.error) {
            return {
                redirect: {
                    destination: url.data.attributes.results[0].url,
                    permanent: false,
                },
            }
        }
        return {
            props: { error: "error" }
        }
    }

    export default AliasView;

As can be seen, we use the `getServerSideProps` to check if the alias exists in our record, if so we redirect to the actual URL.


    export async function getServerSideProps({ params }) {
        const url = await getSingle(params.alias)
        if (url.data && (url.data?.attributes?.results[0] || false) && !url.error) {
            return {
                redirect: {
                    destination: url.data.attributes.results[0].url,
                    permanent: false,
                },
            }
        }
        return {
            props: { error: "error" }
        }
    }

If we can’t find it, we pass the `error` prop to the actual component:


    return {
        props: { error: "error" }
    }

Then in our component, we redirect the user to the home page since the alias isn't in our record. 


    const AliasView = ({ error }) => {
        const router = useRouter()
        useEffect(() => {
            if (error) {
                return router.push('/')
            }
        }, [])
        return null
    };
Вход в полноэкранный режим Выйти из полноэкранного режима

Если пользователь аутентифицирован, он будет перенаправлен на страницу Dashboard, в противном случае он будет перенаправлен на страницу Login. Реализовали ли мы эту функцию на странице Index.js? Да, реализовали!

И это все для раздела кода фронтенд-части этого руководства. Если вы зашли так далеко, я должен сказать, что вы молодцы!

Хватит об этих технических вещах, в следующем разделе мы увидим демонстрацию готового приложения.

Тестирование готового приложения

Готовое приложение выглядит следующим образом:

Заключение

Преимущества, которые приносит сократитель URL, невозможно переоценить. Это видно по быстрому появлению компаний, играющих в этой сфере. Вы можете добавить больше функций в этот проект, просто сделав форк репозитория (найденного в начале этого руководства) и испачкав руки. Лучше оставить воображение на то, чего вы можете достичь!

Это руководство продемонстрировало, как легко можно создать службу сократителя URL за 20 минут, используя такие технологии, как Next.js и Strapi. В очередной раз Strapi показал нам, что он справляется с поставленной задачей, когда дело доходит до создания отличных API!

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *