Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic blog layout #13

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/api/cms/blog/[id]/incrementViewCount/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse, type NextRequest } from 'next/server';

import { client } from '@/sanity/lib/client';

export const PATCH = async (_: NextRequest, { params }: { params: { id: string } }) => {
const { id: blogId } = params;

if (!blogId) {
return NextResponse.json({ error: 'No blog id provided' }, { status: 400 });
}

const patched = await client.patch(blogId).inc({ viewCount: 1 }).commit();

return NextResponse.json(patched);
};
17 changes: 17 additions & 0 deletions app/api/cms/blog/[id]/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { groq } from 'next-sanity';
import { NextResponse, type NextRequest } from 'next/server';

import { blogFullQuery, blogPreviewQuery } from '@/queries/blogs';

import { client } from '@/sanity/lib/client';

import { type Blog } from '@/types/blog';

export const GET = async (request: NextRequest, { params }: { params: { id: string } }) => {
const query = request.nextUrl.searchParams.get('style') === 'preview' ? blogPreviewQuery : blogFullQuery;
const data = await client.fetch<Blog>(groq`
*[_type == 'blog' && _id == "${params.id}"][0] { ${query} }
`);

return NextResponse.json(data, { status: 200 });
};
48 changes: 48 additions & 0 deletions app/api/dynamic-image/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ImageResponse, type NextRequest } from 'next/server';

export const runtime = 'edge';

const OGDynamicImage = ({ origin, title }: { origin: string; title: string }) => {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
fontFamily: 'Fira Mono',
paddingLeft: 112,
paddingRight: 112,
paddingTop: 72,
paddingBottom: 72,
backgroundImage: `url(${origin}/images/og-bg.png)`,
height: '100%',
width: '100%',
}}
>
<div style={{ marginTop: 96, display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: 24, color: '#606060', fontFamily: 'Fira Mono' }}>Ziga&apos;s stories</span>
<span style={{ fontSize: 64, marginTop: 16, fontFamily: 'Fira Mono' }}>{title}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div />
</div>
</div>
);
};

export const GET = async (request: NextRequest) => {
const { searchParams, origin } = new URL(request.url);

const fontData = fetch(new URL('../../../public/fonts/FiraMono-Bold.ttf', import.meta.url)).then((res) =>
res.arrayBuffer()
);
const font = await fontData;

const title = searchParams.get('title') ?? "Ziga's Stories";

return new ImageResponse(<OGDynamicImage origin={origin} title={title} />, {
width: 1200,
height: 630,
fonts: [{ name: 'Fira Mono', data: font, style: 'normal' }],
});
};
29 changes: 29 additions & 0 deletions app/api/tools/site-metadata/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse, type NextRequest } from 'next/server';
import urlMetadata from 'url-metadata';

export const GET = async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const websiteUrl = searchParams.get('url');

if (!websiteUrl) {
return NextResponse.json(
{ error: 'No website URL provided. Make sure you add `url` query param.' },
{ status: 400 }
);
}

try {
const metadata = await urlMetadata(websiteUrl);
return NextResponse.json(
{
title: metadata['og:title'] || metadata['twitter:title'],
description: metadata['og:description'] || metadata['twitter:description'],
image: metadata['og:image'] || metadata['twitter:image'],
url: metadata.url || metadata['og:url'] || metadata['twitter:url'] || websiteUrl,
},
{ status: 200 }
);
} catch (err) {
return NextResponse.json({ error: 'Something went wrong while fetching the website metadata' }, { status: 400 });
}
};
4 changes: 4 additions & 0 deletions app/blog/[slug]/_components/BlogBody/BlogBody.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const getIdFromTweetUrl = (url: string) => {
const segments = new URL(url).pathname.split('/');
return segments.at(segments.length - 1);
};
158 changes: 158 additions & 0 deletions app/blog/[slug]/_components/BlogBody/BlogBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import Skeleton from '@atlaskit/skeleton';
import OpenIcon from '@atlaskit/icon/glyph/open';
import { PortableText, type PortableTextComponentProps, toPlainText } from '@portabletext/react';
import { LinkIcon } from '@sanity/icons';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { type PropsWithChildren } from 'react';
import { TwitterTweetEmbed } from 'react-twitter-embed';
import { type PortableTextBlock } from 'sanity';

import Image from 'next/image';
import { useNextSanityImage } from 'next-sanity-image';

import { BlogCard } from '@/components/Elements/BlogCard';

import { useAppTheme } from '@/context/theme';

import { client } from '@/sanity/lib/client';

import { type ImageWithBlurHash } from '@/types/image';
import { type WebsiteMetadata } from '@/types/metadata';

import { type BlogBodyProps } from './BlogBody.types';
import { getIdFromTweetUrl } from './BlogBody.helpers';
import slugify from 'slugify';

const TwitterEmbedPreview = ({ url }: { url: string }) => {
const { theme } = useAppTheme();
const id = getIdFromTweetUrl(url);

if (!id) {
console.error(`Unable to extract Twit ID from the provided URL (${url}).`);
return null;
}

return (
<div className="[&>div>div]:mx-auto">
<TwitterTweetEmbed
placeholder={<Skeleton width="100%" height={417} borderRadius={3} />}
key={theme}
tweetId={id}
options={{ theme }}
/>
</div>
);
};

const InlineImage = ({ image, alt }: { image: ImageWithBlurHash; alt: string }) => {
const imageProps = useNextSanityImage(client, image);

if (!imageProps) {
return null;
}

return (
<div className="">
<Image {...imageProps} alt={alt} placeholder="blur" blurDataURL={image.asset.metadata.blurHash} />
</div>
);
};

const ExternalLink = ({ children, href }: PropsWithChildren<{ href: string }>) => (
<a
className="text-gray dark:text-white font-bold visited:text-gray visited:dark:text-white [&>span]:text-gray [&>span]:dark:text-white [&>span]:underline"
href={href}
target="_blank"
>
{children}
<OpenIcon size="small" label={`External link: ${children}`} />
</a>
);

const LinkPreview = ({ url }: { url: string }) => {
const { data, isLoading } = useQuery<WebsiteMetadata>({
queryFn: () => fetch(`/api/tools/site-metadata?url=${url}`).then((res) => res.json()),
queryKey: ['LinkPreview', url],
enabled: !!url,
});

if (!data || isLoading) {
return <Skeleton width="100%" borderRadius={6} height={340} />;
}

const image = data.image || `https://placehold.co/672x300?text=${data.title}`;

return (
<Link
className="no-underline block rounded overflow-hidden border border-solid border-gray-4 dark:border-gray-3 my-6"
href={url}
target="_blank"
>
<div className="relative h-[175px] md:h-[300px]">
<Image layout="fill" objectFit="cover" src={image} alt={data.title ?? data.url} />
</div>
<div className="flex flex-col p-3 pb-4">
<span className="font-bold text-gray dark:text-white no-underline truncate">
{data.title ?? 'No title provided'}
</span>
<span className="text-xs text-gray dark:text-white no-underline">{data.url}</span>
</div>
</Link>
);
};

const LinkableH2 = ({ children, text }: PropsWithChildren<{ text: string }>) => {
const slug = slugify(text);
return (
<h2 id={slug} className="font-mono font-bold text-xl mb-4 mt-8 relative group">
<a
className="text-white mr-1 absolute -left-7 top-[3px] opacity-0 group-hover:opacity-100 transition-opacity"
href={`#${slug}`}
>
<LinkIcon fontSize={24} />
</a>
{children}
</h2>
);
};

const LinkableH3 = ({ children, text }: PropsWithChildren<{ text: string }>) => {
const slug = slugify(text);
return (
<h3 id={slug} className="font-mono font-bold text-lg mb-4 mt-6 relative group">
<a
className="text-white mr-1 absolute -left-7 top-[3px] opacity-0 group-hover:opacity-100 transition-opacity"
href={`#${slug}`}
>
<LinkIcon fontSize={24} />
</a>
{children}
</h3>
);
};

export const BlogBody = ({ body }: BlogBodyProps) => (
<PortableText
value={body}
components={{
list: {
bullet: ({ children }) => <ul className="list-disc ml-4">{children}</ul>,
},
block: {
h2: ({ children, value }) => <LinkableH2 text={toPlainText(value)}>{children}</LinkableH2>,
h3: ({ children, value }) => <LinkableH3 text={toPlainText(value)}>{children}</LinkableH3>,
normal: ({ children }) => <p className="text-base my-5">{children}</p>,
},
marks: {
link: ({ text, value }) => <ExternalLink href={value.href}>{text}</ExternalLink>,
},
types: {
inlineImage: ({ value }) => <InlineImage image={value.image} alt={value.alt} />,
twitter: ({ value }) => <TwitterEmbedPreview url={value.url} />,
blog: ({ value }) => <BlogCard isCondensed blogPreview={value} />,
linkPreview: ({ value }) => <LinkPreview url={value.url} />,
},
}}
/>
);
5 changes: 5 additions & 0 deletions app/blog/[slug]/_components/BlogBody/BlogBody.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { type TypedObject } from '@sanity/types';

export type BlogBodyProps = {
body: TypedObject[];
};
1 change: 1 addition & 0 deletions app/blog/[slug]/_components/BlogBody/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './BlogBody';
20 changes: 20 additions & 0 deletions app/blog/[slug]/_components/BlogMetadata/BlogMetadata.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useQuery } from '@tanstack/react-query';

import { type BlogMetadataProps } from './BlogMetadata.types';

export const BlogMetadata = ({ blog }: BlogMetadataProps) => {
const { data } = useQuery({
queryFn: () => fetch(`/api/cms/blog/${blog._id}?style=preview`).then((res) => res.json()),
queryKey: ['Blog', blog._id],
enabled: !!blog,
initialData: blog,
});

return (
<div className="flex gap-2">
<span className="text-xs">{data?.estimatedReadingTime || 1}min reading time</span>
<span className="text-xs">•</span>
<span className="text-xs">{data?.viewCount ?? 0} views</span>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { type BlogPreview } from '@/types/blog';

export type BlogMetadataProps = {
blog: BlogPreview;
};
1 change: 1 addition & 0 deletions app/blog/[slug]/_components/BlogMetadata/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './BlogMetadata';
12 changes: 12 additions & 0 deletions app/blog/[slug]/_components/BlogQuote/BlogQuote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import QuoteIcon from '@atlaskit/icon/glyph/editor/quote';

import { type BlogQuoteProps } from './BlogQuote.types';

export const BlogQuote = ({ excerpt }: BlogQuoteProps) => (
<cite className="relative">
<span className="absolute -z-10 -top-6 -left-6 [&>span>svg]:text-gray/20 [&>span>svg]:dark:text-white/20">
<QuoteIcon label="description" size="xlarge" />
</span>
<p className="leading-8">{excerpt}</p>
</cite>
);
1 change: 1 addition & 0 deletions app/blog/[slug]/_components/BlogQuote/BlogQuote.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type BlogQuoteProps = { excerpt: string };
1 change: 1 addition & 0 deletions app/blog/[slug]/_components/BlogQuote/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './BlogQuote';
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ArrowLeftIcon, ArrowRightIcon } from '@sanity/icons';
import clsx from 'clsx';
import Link from 'next/link';

import { type BlogRelatedPreviewProps } from './BlogRelatedPreview.types';

export const BlogRelatedPreview = ({ blogPreview, direction }: BlogRelatedPreviewProps) => {
const isNext = direction === 'next';

const icon = isNext ? <ArrowLeftIcon fontSize={18} /> : <ArrowRightIcon fontSize={18} />;
const alignClassName = isNext ? 'items-start' : 'items-end';
const getLinkLabel = () => {
if (!blogPreview) {
return isNext ? 'No next story' : 'No previous story';
}

return isNext ? <>{icon} Next story</> : <>Previous story {icon}</>;
};
const href = blogPreview ? `/blog/${blogPreview.slug}` : '/blog';

return (
<Link
className={clsx(
'flex flex-col p-8 no-underline hover:bg-dark/5 hover:dark:bg-white/10 transition-all',
alignClassName
)}
href={href}
>
<span className="text-xs text-dark dark:text-white flex items-center gap-2">{getLinkLabel()}</span>
<span className="text-lg font-mono font-bold text-dark dark:text-white truncate">
{blogPreview?.title || 'Back to all stories'}
</span>
</Link>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { type BlogPreview } from '@/types/blog';

export type BlogRelatedPreviewProps = {
blogPreview: BlogPreview | null;
direction: 'prev' | 'next';
};
1 change: 1 addition & 0 deletions app/blog/[slug]/_components/BlogRelatedPreview/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './BlogRelatedPreview';
Loading