Создание блога с помощью Next.js и Ghost

Недавно я решил, что мне нужен блог для моего портфолио.

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

Итак, я остановился на следующем стеке

  • Next.js — для создания нашего фронтенда
  • Tailwind CSS — для стилизации нашего фронтенда
  • Vercel — для развертывания нашего фронтенда
  • Ghost — для написания постов в виде безголовой CMS
  • Digital Ocean — для развертывания Ghost

Давайте приступим 🥸.

Развертывание Ghost на Digital Ocean

Ghost предлагает элегантный редактор блогов, похожий на Medium.

Digital Ocean предлагает высокую доступность с большим объемом хранилища за относительно низкую цену (при желании вы также можете развернуть здесь свой фронт-энд). Есть и бесплатные альтернативы, но я столкнулся с некоторыми проблемами с конфигурацией и базой данных.

Необходимые условия: вам нужен домен (вы можете получить его на Namecheap за приличную цену) или поддомен (не поддиректория, как www.domain.xyz/blog).

Создание призрачного дуплета Digital Ocean

  1. Создайте Ghost Droplet в Digital Ocean
  2. Выберите размер дуплета (я выбрал самый дешевый — обычный SSD Intel за $6/месяц).
  3. Определите другие конфигурации, такие как регион центра данных, который должен быть ближе всего к вашей аудитории. Я также выбрал SSH.
  4. После создания у вас будет IP-адрес.

Теперь установите ghost, войдя на сервер, чтобы завершить настройку.

ssh root@use_your_droplet_ip
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Вам нужно зайти в менеджер доменных имен (например, Namecheap) и настроить запись A, используя IP-адрес вашего дроплета. Вы также можете выбрать вариант добавления домена в Digital Ocean.

Когда вы подключитесь по ssh, вам будет предложено ввести следующие данные

  1. URL-адрес блога, который вы настроили с помощью IP-адреса
  2. Электронный адрес (для SSL).

Теперь ваш блог должен работать по этому адресу.

Конфигурация призрака

Перейдите на URL вашего блога и настройте свой аккаунт.

Установите учетную запись на приватную, так как фронт-энд будет создан с использованием Next.js (Settings > General).

Настройка Next.js и Tailwind

Настройте Next.js

npx create-next-app@latest --ts
Войдите в полноэкранный режим Выход из полноэкранного режима

Следуйте этому руководству, чтобы быстро настроить Tailwind CSS.

Выполните npm run dev, и ваш сайт должен быть запущен на localhost:3000/.

Заставить записи блога отображаться

Давайте избавимся от большинства вещей в index.tsx и будем иметь только заголовок блога.

import type { NextPage } from 'next';
import Head from 'next/head';

const Home: NextPage = () => {
  return (
    <div>
      <Head>
        <title>Welcome to My Blog</title>
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <main>
        <h1 className='text-7xl p-4'>Blog</h1>
      </main>
    </div>
  );
};

export default Home;
Войдите в полноэкранный режим Выход из полноэкранного режима

Чтобы взаимодействовать с Ghost, нам нужно использовать его API. Мы можем использовать их конечную точку /posts/, чтобы получить записи нашего блога.

Нам нужно сохранить две переменные

  1. Ключ API контента
  2. URL блога

Перейдите в Настройки > Интеграции > Добавить пользовательскую интеграцию

В приложении Next создайте .env.local и добавьте две переменные.

CONTENT_API_KEY=<your-key>
BLOG_URL=https://blog.domain.dev
Войти в полноэкранный режим Выйти из полноэкранного режима

Создайте новый файл lib/ghost.ts.

  • Импортируйте две переменные окружения
  • Верните результат
const { CONTENT_API_KEY, BLOG_URL } = process.env;

export async function getPosts() {
  const res: any = await fetch(
    `${BLOG_URL}/ghost/api/v3/content/posts/?key=${CONTENT_API_KEY}&fields=title,slug,custom_excerpt,feature_image,reading_time,published_at,meta_title,meta_description&formats=html`
  ).then((res) => res.json());

  const posts = res.posts;

  return posts;
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Теперь мы можем использовать getStaticProps, чтобы добавить наши посты в качестве реквизитов на нашу страницу для рендеринга.

Я также взглянул на объект, возвращаемый getPosts, чтобы создать несколько типов.

import type { NextPage } from 'next';
import Head from 'next/head';
import { getPosts } from '../lib/ghost';
import { GetStaticProps } from 'next/types';

export const getStaticProps: GetStaticProps = async () => {
  const posts = await getPosts();

  if (!posts) {
    return {
      notFound: true,
    };
  }

  return {
    props: { posts },
    revalidate: 120, // in secs, at most 1 request to ghost cms backend
  };
};

interface IBlogProps {
  posts: Post[];
}

export type Post = {
  title: string;
  slug: string;
  custom_excerpt: string;
  feature_image: string;
  html: string;
  reading_time: number;
  published_at: Date;
  meta_title: string;
  meta_description: string;
};

const Home: NextPage<IBlogProps> = ({ posts }) => {
  return (
    <div>
      <Head>
        <title>Welcome to My Blog</title>
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <main>
        <h1 className='text-7xl p-4'>Blog</h1>
        <ul>
          {posts.map((post) => (
            <li key={post.slug} className='px-4 py-2'>
              {post.title}
              {/** Add a Divider line */}
              {post.slug !== posts[posts.length - 1].slug && (
                <div className='relative flex py-5 items-center'>
                  <div className='flex-grow border-t border-gray-300 mr-24'></div>
                </div>
              )}
            </li>
          ))}
        </ul>
      </main>
    </div>
  );
};

export default Home;
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте стилизуем заголовки немного больше, рефакторингом кода в собственный компонент.

Создайте новый файл /components/blogCard.tsx.

import Link from 'next/link';
import type { Post } from '../pages/index';

interface IBlogCardProps {
  post: Post;
}

const BlogCard = ({ post }: IBlogCardProps) => {
  const { title, slug, reading_time, published_at } = post;

  const options: Intl.DateTimeFormatOptions = {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  };

  return (
    <div>
      <Link href='/post/[slug]' as={`/post/${slug}`}>
        <a className='md:text-2xl text-xl font-bold hover:text-indigo-200 transition duration-300'>
          {title}
        </a>
      </Link>
      <div className='md:flex'>
        <div className='flex pt-4'>
          <p className='italic md:text-sm text-xs'>
            🗓 {new Date(published_at).toLocaleDateString('en-US', options)}
          </p>
          <p className='pl-7 text-gray-300 font-light md:text-sm text-xs'>
            {reading_time} min
          </p>
        </div>
      </div>
    </div>
  );
};

export default BlogCard;
Войдите в полноэкранный режим Выйти из полноэкранного режима

Элемент Link в настоящее время ведет нас к динамическому маршруту, который нам нужно будет настроить в следующем разделе.

Вернитесь к index.tsx и замените {post.title} компонентом BlogCard.

...
<li key={post.slug} className='px-4 py-2'>
  <BlogCard post={post} />
...
Войдите в полноэкранный режим Выход из полноэкранного режима

Рендеринг одной записи блога

В настоящее время наши ссылки никуда не ведут. Нам нужно получить содержимое блога из Ghost в конечной точке /posts/slug/{slug}/ и отобразить это содержимое.

Давайте добавим новую функцию getPost в ghost.ts, чтобы сделать именно это.

...
export async function getPost(slug: string) {
  const res: any = await fetch(
    `${BLOG_URL}/ghost/api/v3/content/posts/slug/${slug}?key=${CONTENT_API_KEY}&fields=title,slug,custom_excerpt,feature_image,reading_time,published_at,meta_title,meta_description&formats=html`
  ).then((res) => res.json());

  const posts = res.posts;

  return posts[0];
}
Вход в полноэкранный режим Выход из полноэкранного режима

Создайте новый файл pages/post/[slug].tsx, который будет представлять динамический маршрут, где [slug] является параметром. Этот маршрут соответствует тому, что мы имели в элементе Link href.

В этом файле нам необходимо

  • Определить getStaticProps, который захватит содержимое нашего поста
  • Определить getStaticPaths для генерации статических путей для динамического маршрута
  • Загрузите содержимое блога
import { useRouter } from 'next/router';
import { GetStaticPaths, GetStaticProps, NextPage } from 'next/types';
import { ParsedUrlQuery } from 'querystring';
import { Post } from '..';
import { getPost, getPosts } from '../../lib/ghost';

interface IContextParams extends ParsedUrlQuery {
  slug: string;
}

export const getStaticProps: GetStaticProps = async (context) => {
  const { slug } = context.params as IContextParams;
  const post: string = await getPost(slug);

  if (!post) {
    return {
      notFound: true,
    };
  }

  return {
    props: { post },
    revalidate: 120, // in secs, at most 1 request to ghost cms backend
  };
};

export const getStaticPaths: GetStaticPaths<IContextParams> = async () => {
  const posts = await getPosts();

  const paths = posts.map((post: Post) => ({
    params: { slug: post.slug },
  }));

  return { paths, fallback: 'blocking' };
};

interface ISlugPostProps {
  post: Post;
}

const Post: NextPage<ISlugPostProps> = ({ post }) => {
  const router = useRouter();

  if (router.isFallback) {
    return <LoadingPage />;
  }

  return <BlogContent post={post} />;
};

const LoadingPage = () => {
  return (
    <div className='flex items-center justify-center'>
      <h1 className='md:text-5xl text-3xl md:pb-12 pb-8'>Loading...</h1>
    </div>
  );
};

const BlogContent = ({ post }: ISlugPostProps) => {
  const { title, published_at, reading_time, html } = post;

  const options: Intl.DateTimeFormatOptions = {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
  };

  return (
    <article className='flex flex-col items-start justify-center w-full max-w-2xl mx-auto'>
      {/** TITLE */}
      <div className='flex items-center justify-center'>
        <h1 className='md:text-5xl text-3xl md:pb-12 pb-8 pt-4'>{title}</h1>
      </div>
      {/** DATE + READING TIME */}
      <div className='flex pb-6'>
        <p className='italic px-3 tag'>
          🗓 {new Date(published_at).toLocaleDateString('en-US', options)}
        </p>
        <p className='pl-7 text-gray-300 font-light md:text-sm text-xs'>
          {reading_time} min
        </p>
      </div>
      {/** CONTENT */}
      <section>
        <div dangerouslySetInnerHTML={{ __html: html }}></div>
      </section>
    </article>
  );
};

export default Post;
Войдите в полноэкранный режим Выйти из полноэкранного режима

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

Однако это выглядит не очень красиво…

Стилизация записей блога

Используя инструменты разработчика Chrome, мы можем просмотреть HTML записи нашего блога и увидеть, какие классы используются, и придать им стиль. По умолчанию Tailwind избавляется от стилей для типичных элементов <h1>, <h2> и т.д., поэтому мы должны определить их сами.

Создайте новый файл styles/BlogPost.module.css, и мы сможем стилизовать элементы по своему вкусу.

.postFullContent {
    min-height: 230px;
    line-height: 1.6em;

    @apply text-lg relative
}

.postFullContent a {
    color: black;
    box-shadow: inset 0 -1px 0 black;

    @apply hover:text-indigo-200 transition duration-300
}

.postContent {
    @apply flex flex-col items-center
}

.postContent p {
    @apply mb-6 min-w-full
}

.postContent h2 {
    line-height: 1.25em;
    @apply md:text-3xl text-xl font-semibold mx-0 my-2 min-w-full
}

.postContent h3 {
    line-height: 1.25em;
    @apply md:text-2xl text-lg font-semibold mx-0 mt-2 mb-1 min-w-full
}

.postContent ol {
    list-style: auto;
    padding: revert;
    @apply mb-6 min-w-full
}

.postContent ul {

    list-style: disc;
    padding: revert;
    @apply mb-6 min-w-full
}

.postContent li {
    word-break: break-word;
    line-height: 1.6em;
    @apply my-2 pl-1
}

.postContent blockquote {
    margin: 0 0 1.5em;
    padding: 0 1.5em;
    border-left: 3px solid #D4C3F9;
    @apply min-w-full
}

.postContent code {
    color: #fff;
    background: #000;
    border-radius: 3px;
    padding: 0 5px 2px;
    line-height: 1em;
    font-size: .8em;
}

.postContent pre {
    overflow-x: auto;
    margin: 1.5em 0 3em;
    padding: 20px;
    max-width: 100%;
    border: 1px solid #000;
    color: #e5eff5;
    background: #0e0f11;
    border-radius: 5px;

    @apply min-w-full
}

.postContent pre > code {
    background: transparent;
}

.postContent figure {
    display: block;
    padding: 0;
    border: 0;
    font: inherit;
    font-size: 100%;
    vertical-align: baseline;
    @apply mt-3 mb-9
}

.postContent figure figcaption {
    @apply font-light text-sm text-center my-4
}

.postContent em {
    @apply font-medium
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавьте эти стили в файл pages/post/[slug].tsx.

...
import styles from '../../styles/BlogPost.module.css';
...
{/** CONTENT */}
<section className={styles.postFullContent}>
  <div
    className={styles.postContent}
    dangerouslySetInnerHTML={{ __html: html }}
  ></div>
</section>;
Войти в полноэкранный режим Выйти из полноэкранного режима

Однако некоторые вещи должны быть оформлены на глобальном уровне.

Используя базовую тему Ghost, мне пришлось добавить следующее в styles/globals.css

...
.kg-bookmark-container {
    color: black;
    min-height: 148px;
    border-radius: 3px;

    @apply flex md:flex-row flex-col 
}

a.kg-bookmark-container {
    word-break: break-word;
    transition: all .2s ease-in-out;
    background-color: transparent;
    @apply border-2 border-black
}

.kg-bookmark-content {
    align-items: flex-start;
    padding: 20px; 

    @apply flex flex-grow flex-col justify-start md:order-none order-2
}

.kg-bookmark-title {
    line-height: 1.5em;
    @apply hover:text-indigo-200 transition duration-300 font-semibold text-[#372772]
}

.kg-bookmark-description {
    color: black;
    display: -webkit-box;
    overflow-y: hidden;
    margin-top: 12px;
    max-height: 48px;
    color: #5d7179;
    font-size: 12px;
    line-height: 1.5em;
    font-weight: 400;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
}

.kg-bookmark-metadata {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    margin-top: 14px;
    color: #5d7179;
    font-size: 1.5rem;
    font-weight: 400;
}

.kg-bookmark-icon {
    margin-right: 8px;

    @apply md:w-[22px] md:h-[22px] w-[18px] h-[18px]
}

.kg-bookmark-publisher {
    overflow: hidden;
    max-width: 240px;
    line-height: 1.5em;
    text-overflow: ellipsis;
    white-space: nowrap;
    @apply text-sm font-light
}

.kg-bookmark-thumbnail {
    position: relative;
    min-width: 33%;
    max-height: 100%;
    @apply min-h-[160px]
}

.kg-bookmark-thumbnail img {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    border-radius: 0 3px 3px 0;
    -o-object-fit: cover;
    object-fit: cover;
    display: block;
}

.kg-image {
    max-width: 100%;
}

.kg-gallery-container {
    display: flex;
    flex-direction: column;
    max-width: 1040px;
    width: 100vw;
}

.kg-gallery-row {
    display: flex;
    flex-direction: row;
    justify-content: center;
}

.kg-gallery-image:not(:first-of-type) {
    margin: 0 0 0 0.75em;
}

.kg-gallery-image {
    flex: 1.5 1 0%;
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Развертывание на Vercel

Для развертывания вашего блога мы можем бесплатно использовать Vercel.

  1. Разместите свой код на GitHub
  2. Создайте учетную запись Vercel на GitHub https://vercel.com/signup.
  3. Импортируйте свой блог
  4. Добавьте переменные окружения в Vercel (поскольку они не передаются через Git).

Теперь каждый раз, когда вы вносите изменения в код и переносите их в основную ветку, Vercel будет автоматически развертывать это новое изменение.

Вам не нужно будет вносить изменения в код, если вы добавите новую запись в блог, она появится автоматически!

Мир — получайте удовольствие от написания ✌🏽.

Передавайте привет 👋🏼
🐦 Twitter
👩🏽💻 Github

Оцените статью
devanswers.ru
Добавить комментарий