Compare commits

..

10 commits

30 changed files with 10285 additions and 124 deletions

View file

@ -9,4 +9,9 @@ EMAIL_CONTACT_ADDRESS=
EMAIL_CONTACT_DEST= EMAIL_CONTACT_DEST=
NEXT_PUBLIC_HCAPTCHA_SITE_KEY= NEXT_PUBLIC_HCAPTCHA_SITE_KEY=
HCAPTCHA_SECRET= HCAPTCHA_SECRET=
NEXT_PUBLIC_SANITY_PROJECT_ID=
SANITY_AUTH_TOKEN=
NEXT_PUBLIC_SANITY_DATASET=
SANITY_WEBHOOK_SECRET=

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 SVR.JS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,123 @@
/**
* okaidia theme for JavaScript, CSS and HTML
* Loosely based on Monokai textmate theme by http://www.monokai.nl/
* @author ocodia
*/
code[class*="language-"],
pre[class*="language-"] {
color: #f8f8f2;
background: none;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #272822;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #8292a2;
}
.token.punctuation {
color: #f8f8f2;
}
.token.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.constant,
.token.symbol,
.token.deleted {
color: #f92672;
}
.token.boolean,
.token.number {
color: #ae81ff;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #a6e22e;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string,
.token.variable {
color: #f8f8f2;
}
.token.atrule,
.token.attr-value,
.token.function,
.token.class-name {
color: #e6db74;
}
.token.keyword {
color: #66d9ef;
}
.token.regex,
.token.important {
color: #fd971f;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View file

@ -0,0 +1,98 @@
code[class*="language-"],
pre[class*="language-"] {
color: #f8f8f2;
background: 0 0;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #272822;
}
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
.token.cdata,
.token.comment,
.token.doctype,
.token.prolog {
color: #8292a2;
}
.token.punctuation {
color: #f8f8f2;
}
.token.namespace {
opacity: 0.7;
}
.token.constant,
.token.deleted,
.token.property,
.token.symbol,
.token.tag {
color: #f92672;
}
.token.boolean,
.token.number {
color: #ae81ff;
}
.token.attr-name,
.token.builtin,
.token.char,
.token.inserted,
.token.selector,
.token.string {
color: #a6e22e;
}
.language-css .token.string,
.style .token.string,
.token.entity,
.token.operator,
.token.url,
.token.variable {
color: #f8f8f2;
}
.token.atrule,
.token.attr-value,
.token.class-name,
.token.function {
color: #e6db74;
}
.token.keyword {
color: #66d9ef;
}
.token.important,
.token.regex {
color: #fd971f;
}
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View file

@ -0,0 +1,205 @@
import { client, urlFor } from "@/sanity/lib/client";
import { PortableText } from "@portabletext/react";
import Image from "next/image";
import Link from "next/link";
import { ArrowLeft, Rss } from "lucide-react";
import { notFound } from "next/navigation";
import { format } from "date-fns";
import PrismLoader from "@/components/PrismLoader";
import Prism from "prismjs";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-python";
import "prismjs/components/prism-php";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-sql";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-markdown";
import "prismjs/components/prism-json";
import "prismjs/components/prism-perl";
import "./_styles/prism-twilight.css";
import "./_styles/prism.twilight.min.css";
async function getData(slug) {
const query = `
*[_type == "blog" && slug.current == '${slug.replace(/'/g, "\\'")}'] {
"currentSlug": slug.current,
title,
content,
smallDescription,
titleImage,
_createdAt
}[0]`;
const data = await client.fetch(query, {}, { cache: "no-store" });
return data;
}
export const dynamic = "force-static";
export async function generateMetadata(props) {
const params = await props.params;
const data = await getData(params.slug);
if (!data) {
return {
title: "404 Not Found - MERNMail",
openGraph: {
title: "404 Not Found - MERNMail"
},
twitter: {
title: "404 Not Found - MERNMail"
}
};
}
return {
title: `${data.title} - MERNMail`,
description: data.smallDescription,
openGraph: {
title: `${data.title} - MERNMail`,
description: data.smallDescription,
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog/${data.currentSlug}`,
type: "website",
images: [
{
url: data.titleImage
? urlFor(data.titleImage).url()
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog-missing.png`,
width: 2560,
height: 1440,
alt: `${data.title} - MERNMail`
}
]
},
twitter: {
card: "summary_large_image",
site: "@MERNMail",
title: `${data.title} - MERNMail`,
description: data.smallDescription,
images: [
data.titleImage
? urlFor(data.titleImage).url()
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog-missing.png`
],
creator: "@MERNMail"
}
};
}
const customPortableTextComponents = {
types: {
image: ({ value }) => {
return (
<div className="my-8">
<Image
src={urlFor(value).url()}
alt={value.alt || "Blog Image"}
width={1200}
height={800}
className="w-full h-auto rounded-lg"
/>
{value.caption && (
<p className="mt-2 text-center text-sm text-muted-foreground">
{value.caption}
</p>
)}
</div>
);
},
code: ({ value }) => {
const language = value.language || "none";
const grammar = Prism.languages[language];
if (language != "none" && !grammar) {
console.error(`No grammar found for language: "${language}"`);
}
return (
<div className="relative my-8">
<pre
className={`language-${language} p-4 rounded-md overflow-x-auto text-sm`}
>
<code className={`language-${language}`}>{value.code}</code>
</pre>
{language == "none" ? "" : <PrismLoader />}
</div>
);
}
}
};
export default async function BlogSlugArticle(props) {
const params = await props.params;
const data = await getData(params.slug);
if (!data) {
notFound();
}
const formattedDate = format(new Date(data._createdAt), "MMMM d, yyyy");
return (
<>
<section className="max-w-5xl container mx-auto py-8 md:py-28 flex flex-col items-center px-4">
<div className="w-full mx-auto flex flex-row justify-between items-center">
<Link
href="/blog"
className="group text-primary transition-all flex items-center justify-center mx-0 px-2 hover:bg-accent hover:text-accent-foreground h-11 rounded-lg"
>
<ArrowLeft className="mr-2 w-5 h-5 group-hover:translate-x-1 transition-all align-top" />
<span className="align-middle">Back</span>
</Link>
<Link
href="/rss.xml"
rel="alternate"
type="application/rss+xml"
className="shrink-0 mx-0 px-2 text-primary hover:underline"
>
<Rss className="mr-1 inline align-top" />
<span className="align-middle">Subscribe to RSS</span>
</Link>
</div>
<header className="text-start mb-8 w-full">
<div className="mb-2">
<h1 className="text-3xl md:text-5xl mb-8 py-4 font-bold">
{data.title}
</h1>
<Image
src={
data.titleImage
? urlFor(data.titleImage).url()
: "/blog-missing.png"
}
alt={data.title}
width={1200}
height={800}
priority
className="w-full h-auto object-cover rounded-md"
/>
<p className="mt-4 text-lg md:text-xl text-muted-foreground">
Published on: {formattedDate}
</p>
</div>
</header>
<div className="mb-6 w-full border-border border" />
<article className="prose prose-a:text-primary w-full max-w-full md:prose-lg dark:prose-invert">
<PortableText
value={data.content}
components={customPortableTextComponents}
/>
</article>
</section>
</>
);
}
export async function generateStaticParams() {
const query = `*[_type == 'blog']{
"slug": slug.current,
}`;
const slugsRaw = await client.fetch(query);
return slugsRaw;
}

65
app/(root)/blog/page.jsx Normal file
View file

@ -0,0 +1,65 @@
import { Rss } from "lucide-react";
import Link from "next/link";
import BlogCards from "@/components/BlogCards";
export const dynamic = "force-static";
export const metadata = {
title: "Blog - MERNMail",
description:
"Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.",
openGraph: {
title: "Blog - MERNMail",
description:
"Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.",
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog`,
type: "website",
images: [
{
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`,
width: 2560,
height: 1440,
alt: "Blog - MERNMail"
}
]
},
twitter: {
card: "summary_large_image",
site: "@MERNMail",
title: "Blog - MERNMail",
description:
"Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.",
images: [
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`
],
creator: "@MERNMail"
}
};
async function Blog() {
return (
<section
id="blog"
className="max-w-screen-xl mx-auto px-4 py-6 md:py-28 flex items-center justify-center flex-col"
>
<h1 className="text-3xl md:text-5xl pb-1 md:pb-2 font-bold">
MERNMail Blog
</h1>
<p className="text-muted-foreground flex items-center justify-center my-2">
Our blog has email-related tips and updates about MERNMail.
<Link
href="/rss.xml"
rel="alternate"
type="application/rss+xml"
className="shrink-0 mx-0 px-2 text-primary hover:underline"
>
<Rss className="mr-1 inline align-top" />
<span className="align-middle">RSS feed</span>
</Link>
</p>
<BlogCards page={1} />
</section>
);
}
export default Blog;

View file

@ -0,0 +1,74 @@
import { Rss } from "lucide-react";
import Link from "next/link";
import BlogCards from "@/components/BlogCards";
export const dynamic = "force-static";
export async function generateMetadata({ params }) {
const obtainedParams = await params;
return {
title: "Blog - MERNMail",
description:
"Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.",
openGraph: {
title: "Blog - MERNMail",
description:
"Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.",
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog/page/${params.id}`,
type: "website",
images: [
{
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`,
width: 2560,
height: 1440,
alt: "Blog - MERNMail"
}
]
},
twitter: {
card: "summary_large_image",
site: "@MERNMail",
title: "Blog - MERNMail",
description:
"Welcome to the MERNMail Blog! Explore our latest blog posts featuring email tips. Stay tuned for the latest MERNMail updates.",
images: [
`${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`
],
creator: "@MERNMail"
}
};
}
async function Blog(props) {
const params = await props.params;
// Optionally, you can fetch some initial data here if needed.
let id = parseInt(params.id);
if (isNaN(id)) id = 1;
return (
<section
id="blog"
className="max-w-screen-xl mx-auto px-4 py-6 md:py-28 flex items-center justify-center flex-col"
>
<h1 className="text-3xl md:text-5xl pb-1 md:pb-2 font-bold">
MERNMail Blog
</h1>
<p className="text-muted-foreground flex items-center justify-center my-2">
Our blog has email-related tips and updates about MERNMail.
<Link
href="/rss.xml"
rel="alternate"
type="application/rss+xml"
className="shrink-0 mx-0 px-2 text-primary hover:underline"
>
<Rss className="mr-1 inline align-top" />
<span className="align-middle">RSS feed</span>
</Link>
</p>
<BlogCards page={id} />
</section>
);
}
export default Blog;

View file

@ -22,7 +22,7 @@ function Contact() {
</h1> </h1>
<div className="flex flex-col md:flex-row mb-6"> <div className="flex flex-col md:flex-row mb-6">
<form <form
className="border-border border bg-card text-card-foreground rounded-lg mx-2 md:mx-4 max-md:mb-8 p-6 w-full self-center" className="border-border border bg-card text-card-foreground rounded-lg mx-2 md:mx-4 max-md:mb-8 p-6 w-full self-center overflow-x-auto"
onSubmit={async (e) => { onSubmit={async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
@ -90,7 +90,7 @@ function Contact() {
id="contact-message" id="contact-message"
className="block h-44 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary" className="block h-44 bg-accent text-accent-foreground w-full rounded-md px-2 py-1 focus:outline focus:outline-2 focus:outline-primary"
/> />
<div className="my-5"> <div className="my-5 w-[300px] mx-auto">
<HCaptcha <HCaptcha
ref={captchaRef} ref={captchaRef}
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY} sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY}
@ -103,7 +103,9 @@ function Contact() {
<button <button
type="submit" type="submit"
className="bg-primary text-primary-foreground p-2 rounded-md text-center w-full hover:bg-primary/75 disabled:bg-primary/50 transition-colors" className="bg-primary text-primary-foreground p-2 rounded-md text-center w-full hover:bg-primary/75 disabled:bg-primary/50 transition-colors"
disabled={!name || !email || !isEmail(email) || !message} disabled={
!name || !email || !isEmail(email) || !message || !hCaptchaToken
}
> >
<Send className="inline align-top mr-2" /> <Send className="inline align-top mr-2" />
<span className="align-middle">Send</span> <span className="align-middle">Send</span>

View file

@ -0,0 +1,80 @@
/**
* This code is responsible for revalidating the cache when a post or author is updated.
*
* It is set up to receive a validated GROQ-powered Webhook from Sanity.io:
* https://www.sanity.io/docs/webhooks
*
* 1. Go to the API section of your Sanity project on sanity.io/manage or run `npx sanity hook create`
* 2. Click "Create webhook"
* 3. Set the URL to https://YOUR_NEXTJS_SITE_URL/api/revalidate
* 4. Dataset: Choose desired dataset or leave at default "all datasets"
* 5. Trigger on: "Create", "Update", and "Delete"
* 6. Filter: _type == "blog"
* 7. Projection: Leave empty
* 8. Status: Enable webhook
* 9. HTTP method: POST
* 10. HTTP Headers: Leave empty
* 11. API version: v2023-08-08
* 12. Include drafts: No
* 13. Secret: Set to the same value as SANITY_REVALIDATE_SECRET (create a random secret if you haven't yet)
* 14. Save the cofiguration
*/
import { isValidSignature, SIGNATURE_HEADER_NAME } from "@sanity/webhook";
import { client } from "@/sanity/lib/client";
import { revalidatePath } from "next/cache";
import { NextResponse } from "next/server";
const secret = `${process.env.SANITY_WEBHOOK_SECRET}`;
export async function POST(req) {
const body = await req.json();
const rawBody = JSON.stringify(body);
if (
!(await isValidSignature(
rawBody,
req.headers.get(SIGNATURE_HEADER_NAME) ?? "",
secret.trim()
))
) {
return NextResponse.json({ message: "Invalid signature" }, { status: 401 });
}
try {
if (body._type == "blog") {
if (body.slug.current) {
revalidatePath(`/blog/${body.slug.current}`);
revalidatePath("/sitemap.xml");
revalidatePath("/rss.xml");
}
revalidatePath("/blog");
// Change in /blog/page/[id] route and in BlogCards component too!
const cardsPerPage = 6;
const totalPostsQuery = `count(*[_type == 'blog'])`;
const totalPosts = await client.fetch(
totalPostsQuery,
{},
{ cache: "no-store" }
);
const totalPages = Math.ceil(totalPosts / cardsPerPage);
for (let i = 1; i <= totalPages + 1; i++) {
revalidatePath(`/blog/page/${i.toString()}`);
}
return NextResponse.json({
message: `Revalidated "${body._type}" with slug "${body.slug.current}"`
});
}
return NextResponse.json({ message: "No managed type" });
} catch (err) {
return NextResponse.json(
{ message: "Error revalidating" },
{ status: 500 }
);
}
}

9
app/robots.js Normal file
View file

@ -0,0 +1,9 @@
export default function robots() {
return {
rules: {
userAgent: "*",
allow: "/"
},
sitemap: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/sitemap.xml`
};
}

49
app/rss.xml/route.js Normal file
View file

@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import RSS from "rss";
import { client, urlFor } from "@/sanity/lib/client";
import { toHTML } from "@portabletext/to-html";
export const dynamic = "force-static";
export async function GET() {
const postsQuery = `*[_type == 'blog'] | order(_createdAt desc) {
title,
"slug": slug.current,
content,
titleImage,
_createdAt
}`;
const posts = await client.fetch(postsQuery);
const feed = new RSS({
title: "MERNMail Blog",
description: "Explore the latest blog posts from MERNMail",
feed_url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/rss.xml`,
site_url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}`,
image_url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/metadata/mernmail-cover.png`,
language: "en-US",
pubDate: new Date().toUTCString()
});
posts.forEach((post) => {
feed.item({
title: post.title,
description: toHTML(post.content),
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog/${post.slug}`,
date: new Date(post._createdAt).toUTCString(),
enclosure: {
url: post.titleImage
? urlFor(post.titleImage).url()
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog-missing.png`
},
author: "MERNMail"
});
});
return new NextResponse(feed.xml({ indent: true }), {
headers: {
"Content-Type": "application/xml"
}
});
}

37
app/sitemap.js Normal file
View file

@ -0,0 +1,37 @@
import { client } from "@/sanity/lib/client";
import docLinks from "@/constants/docLinks";
async function getAllBlogPostSlugs() {
const query = `*[_type == 'blog'] { "slug": slug.current }`;
const slugs = await client.fetch(query, {}, { cache: "no-store" });
return slugs;
}
export default async function sitemap() {
const blogPostSlugs = await getAllBlogPostSlugs();
const baseRoutes = [
"/",
"/blog",
"/contact",
"/contribute",
"/privacy",
"/tos"
].map((route) => ({
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}${route}`,
lastModified: new Date().toISOString().split("T")[0]
}));
const docsRoutes = docLinks.map((docLink) => ({
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}${docLink.href}`,
lastModified: new Date().toISOString().split("T")[0]
}));
const blogRoutes = blogPostSlugs.map((slug) => ({
url: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/blog/${slug.slug}`,
lastModified: new Date().toISOString().split("T")[0]
}));
return [...baseRoutes, ...docsRoutes, ...blogRoutes];
}

View file

@ -0,0 +1,19 @@
/**
* This route is responsible for the built-in authoring environment using Sanity Studio.
* All routes under your studio path is handled by this file using Next.js' catch-all routes:
* https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes
*
* You can learn more about the next-sanity package here:
* https://github.com/sanity-io/next-sanity
*/
import { NextStudio } from "next-sanity/studio";
import config from "../../../sanity.config";
export const dynamic = "force-static";
export { metadata, viewport } from "next-sanity/studio";
export default function StudioPage() {
return <NextStudio config={config} />;
}

151
components/BlogCards.jsx Normal file
View file

@ -0,0 +1,151 @@
import React from "react";
import Link from "next/link";
import Image from "next/image";
import { ChevronLeft, ChevronRight, ExternalLink } from "lucide-react";
import { client, urlFor } from "@/sanity/lib/client";
import { format } from "date-fns";
import PropTypes from "prop-types";
const BlogCards = async (props) => {
"use server";
// Change in /blog/page/[id] route and in /api/revalidate route too!
const cardsPerPage = 6;
const currentPage = props.page;
const query = `*[_type == 'blog'] | order(_createdAt desc) {
title,
smallDescription,
"currentSlug": slug.current,
titleImage,
_createdAt
}[${(currentPage - 1) * cardsPerPage}...${currentPage * cardsPerPage}]`;
const posts = await client.fetch(query, {}, { cache: "no-store" });
const totalPostsQuery = `count(*[_type == 'blog'])`;
const totalPosts = await client.fetch(
totalPostsQuery,
{},
{ cache: "no-store" }
);
const totalPages = Math.ceil(totalPosts / cardsPerPage);
let begPage = currentPage - 2;
let endPage = currentPage + 2;
if (endPage > totalPages) {
begPage -= endPage - totalPages;
endPage = totalPages;
}
if (begPage < 1) {
endPage += 1 - begPage;
begPage = 1;
}
return (
<>
<section className="w-full flex flex-col md:flex-row md:flex-wrap max-w-6xl mx-auto">
{posts.map((post, idx) => {
const formattedDate = format(
new Date(post._createdAt),
"MMMM d, yyyy"
);
const truncatedDescription =
post.smallDescription.length > 130
? post.smallDescription.substring(0, 130) + "..."
: post.smallDescription;
return (
<div className="overflow-hidden sm:w-1/2 lg:w-1/3 p-4" key={idx}>
<div className="group text-card-foreground bg-card rounded-lg border-border border">
<Link href={`/blog/${post.currentSlug}`} className="block">
<div className="relative overflow-hidden rounded-t-lg">
<Image
src={
post.titleImage
? urlFor(post.titleImage).url()
: "/blog-missing.png"
}
alt={post.title}
width={500}
height={300}
priority
className="w-full object-cover transition-transform duration-200 group-hover:scale-105"
/>
</div>
<div className="p-4">
<div className="flex flex-row items-center justify-between mb-2 py-2">
<h2 className="text-xl font-semibold leading-tight">
{post.title}
</h2>
<div className="text-sm text-muted-foreground opacity-0 group-hover:opacity-100 duration-300">
<ExternalLink />
</div>
</div>
<p className="text-sm text-muted-foreground">
{truncatedDescription}
</p>
<p className="text-xs text-muted-foreground mt-2">
Published on: {formattedDate}
</p>
</div>
</Link>
</div>
</div>
);
})}
</section>
{
<div className="flex-center mt-12">
{totalPages > 1 && (
<nav className="mx-auto flex w-full justify-center">
<ul className="flex flex-row items-center gap-1">
{currentPage > 1 && (
<li>
<Link
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 gap-1 pr-2.5"
href={`/blog/page/${currentPage - 1}`}
>
<ChevronLeft />
</Link>
</li>
)}
{Array.from({ length: totalPages > 5 ? 5 : totalPages }).map(
(_, i) => (
<li key={i}>
<Link
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 w-10"
href={`/blog/page/${begPage + i}`}
isActive={currentPage === begPage + i}
>
{begPage + i}
</Link>
</li>
)
)}
{currentPage < totalPages && (
<li>
<Link
className="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 gap-1 pr-2.5"
href={`/blog/page/${currentPage + 1}`}
>
<ChevronRight />
</Link>
</li>
)}
</ul>
</nav>
)}
</div>
}
</>
);
};
BlogCards.propTypes = {
page: PropTypes.number.isRequired
};
export default BlogCards;

View file

@ -9,7 +9,7 @@ function FAQ() {
const ref = useRef({}); const ref = useRef({});
return ( return (
<section className="mx-auto px-3 py-24 max-w-screen-xl"> <section className="mx-auto px-3 py-16 md:py-24 max-w-screen-xl">
<h2 className="text-center font-bold text-4xl md:text-5xl hyphens-auto mb-4"> <h2 className="text-center font-bold text-4xl md:text-5xl hyphens-auto mb-4">
Frequently Asked Questions Frequently Asked Questions
</h2> </h2>

View file

@ -2,7 +2,7 @@ import { features } from "@/constants";
function Features() { function Features() {
return ( return (
<section className="mx-auto px-3 py-24 max-w-screen-xl"> <section className="mx-auto px-3 py-16 md:py-24 max-w-screen-xl">
<h2 className="text-center font-bold text-4xl md:text-5xl hyphens-auto"> <h2 className="text-center font-bold text-4xl md:text-5xl hyphens-auto">
Experience Effortless <span className="text-primary">Email</span> Experience Effortless <span className="text-primary">Email</span>
</h2> </h2>

View file

@ -20,7 +20,7 @@ function Footer() {
<span key={link.href}> <span key={link.href}>
<Link <Link
href={link.href} href={link.href}
className="text-base hover:text-primary hover:underline" className="text-base hover:text-primary hover:underline transition-colors duration-100"
> >
{link.label} {link.label}
</Link> </Link>
@ -34,7 +34,7 @@ function Footer() {
<span key={link.href}> <span key={link.href}>
<Link <Link
href={link.href} href={link.href}
className="text-base hover:text-primary hover:underline" className="text-base hover:text-primary hover:underline transition-colors duration-100"
> >
{link.label} {link.label}
</Link> </Link>
@ -54,7 +54,8 @@ function Footer() {
<div className="border-t mb-6 border-gray-300 dark:border-white/30"></div> <div className="border-t mb-6 border-gray-300 dark:border-white/30"></div>
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0 px-4"> <div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0 px-4">
<span className="text-sm font-light"> <span className="text-sm font-light">
Copyright © 2023-{currentYear}{" "} Copyright © {currentYear == 2024 ? "" : "2024-"}
{currentYear}{" "}
<Link <Link
href={footerLinks.footerBottom.rightsReserved.href} href={footerLinks.footerBottom.rightsReserved.href}
className="text-primary font-semibold" className="text-primary font-semibold"

View file

@ -0,0 +1,26 @@
"use client";
import { useEffect } from "react";
import Prism from "prismjs";
import "prismjs/themes/prism-okaidia.css";
import "prismjs/components/prism-javascript";
import "prismjs/components/prism-python";
import "prismjs/components/prism-php";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-sql";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-markdown";
import "prismjs/components/prism-json";
import "prismjs/components/prism-perl";
import "prismjs/components/prism-markup";
import "prismjs/components/prism-markup-templating";
import "prismjs/components/prism-handlebars";
export default function PrismLoader() {
useEffect(() => {
if (Prism) {
Prism.highlightAll();
}
}, []);
return null;
}

9261
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,10 +13,19 @@
}, },
"dependencies": { "dependencies": {
"@hcaptcha/react-hcaptcha": "^1.11.0", "@hcaptcha/react-hcaptcha": "^1.11.0",
"@portabletext/react": "^3.1.0",
"@portabletext/to-html": "^2.0.13",
"@sanity/code-input": "^4.1.4",
"@sanity/icons": "^3.4.0",
"@sanity/image-url": "^1.1.0",
"@sanity/vision": "^3.63.0",
"@sanity/webhook": "4.0.2-bc",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"date-fns": "^4.1.0",
"globby": "^14.0.2", "globby": "^14.0.2",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "^15.0.2", "next": "^15.0.2",
"next-sanity": "^9.8.10",
"next-themes": "^0.4.3", "next-themes": "^0.4.3",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
@ -25,6 +34,9 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"rehype-prism": "^2.3.3", "rehype-prism": "^2.3.3",
"rss": "^1.2.2",
"sanity": "^3.63.0",
"styled-components": "^6.1.13",
"validator": "^13.12.0" "validator": "^13.12.0"
}, },
"devDependencies": { "devDependencies": {

BIN
public/blog-missing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

10
sanity.cli.js Normal file
View file

@ -0,0 +1,10 @@
/**
* This configuration file lets you run `$ sanity [command]` in this folder
* Go to https://www.sanity.io/docs/cli to learn more.
**/
import { defineCliConfig } from "sanity/cli";
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET;
export default defineCliConfig({ api: { projectId, dataset } });

31
sanity.config.js Normal file
View file

@ -0,0 +1,31 @@
"use client";
/**
* This configuration is used to for the Sanity Studio that’s mounted on the `/app/studio/[[...tool]]/page.jsx` route
*/
import { visionTool } from "@sanity/vision";
import { defineConfig } from "sanity";
import { structureTool } from "sanity/structure";
import { codeInput } from "@sanity/code-input";
// Go to https://www.sanity.io/docs/api-versioning to learn how API versioning works
import { apiVersion, dataset, projectId } from "./sanity/env";
import { schemaTypes } from "./sanity/schemaTypes";
export default defineConfig({
basePath: "/studio",
projectId,
dataset,
// Add and edit the content schema in the './sanity/schemaTypes' folder
schema: {
types: schemaTypes
},
plugins: [
structureTool(),
// Vision is for querying with GROQ from inside the Studio
// https://www.sanity.io/docs/the-vision-plugin
visionTool({ defaultApiVersion: apiVersion }),
codeInput()
]
});

6
sanity/env.js Normal file
View file

@ -0,0 +1,6 @@
export const apiVersion =
process.env.NEXT_PUBLIC_SANITY_API_VERSION || "2024-11-08";
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET;
export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID;
export const token = process.env.SANITY_AUTH_TOKEN;

18
sanity/lib/client.js Normal file
View file

@ -0,0 +1,18 @@
import { createClient } from "next-sanity";
import imageUrlBuilder from "@sanity/image-url";
import { apiVersion, dataset, projectId, token } from "../env";
export const client = createClient({
projectId,
dataset,
apiVersion,
token,
useCdn: false // Set to false if statically generating pages, using ISR or tag-based revalidation
});
const builder = imageUrlBuilder(client);
export function urlFor(source) {
return builder.image(source);
}

10
sanity/lib/image.js Normal file
View file

@ -0,0 +1,10 @@
import createImageUrlBuilder from "@sanity/image-url";
import { dataset, projectId } from "../env";
// https://www.sanity.io/docs/image-url
const builder = createImageUrlBuilder({ projectId, dataset });
export const urlFor = (source) => {
return builder.image(source);
};

13
sanity/lib/live.js Normal file
View file

@ -0,0 +1,13 @@
// Querying with "sanityFetch" will keep content automatically updated
// Before using it, import and render "<SanityLive />" in your layout, see
// https://github.com/sanity-io/next-sanity#live-content-api for more information.
import { defineLive } from "next-sanity";
import { client } from "./client";
export const { sanityFetch, SanityLive } = defineLive({
client: client.withConfig({
// Live content is currently only available on the experimental API
// https://www.sanity.io/docs/api-versioning
apiVersion: "vX"
})
});

View file

@ -0,0 +1,51 @@
export default {
name: "blog",
type: "document",
title: "Blog",
fields: [
{
name: "title",
type: "string",
title: "Title"
},
{
name: "slug",
type: "slug",
title: "Slug Title",
options: {
source: "title"
}
},
{
name: "titleImage",
type: "image",
title: "Title Image"
},
// {
// name: 'publishedAt',
// title: 'Published At',
// type: 'datetime',
// },
{
name: "smallDescription",
type: "text",
title: "Small Description"
},
{
name: "content",
type: "array",
title: "Content",
of: [
{
type: "block"
},
{
type: "image"
},
{
type: "code"
}
]
}
]
};

View file

@ -0,0 +1,3 @@
import blog from "./blog";
export const schemaTypes = [blog];

11
sanity/structure.js Normal file
View file

@ -0,0 +1,11 @@
// https://www.sanity.io/docs/structure-builder-cheat-sheet
export const structure = (S) =>
S.list()
.title("Blog")
.items([
S.documentTypeListItem("blog").title("Posts"),
S.divider(),
...S.documentTypeListItems().filter(
(item) => item.getId() && !["blog"].includes(item.getId())
)
]);