Skip to content
Merged
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
6 changes: 2 additions & 4 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ jobs:
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'

# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# Restrict tools to prevent arbitrary command execution from external triggers
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh pr comment:*),Bash(gh search:*)"'

5 changes: 5 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npm run type-check
npx lint-staged
8 changes: 5 additions & 3 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false,
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"plugins": ["prettier-plugin-tailwindcss"]
}
46 changes: 31 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,30 @@
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"seed": "payload run ./scripts/seed-blog-posts.ts",
"getCategories": "payload run ./scripts/getCategories.ts",
"test": "jest --watch"
"test": "jest --watch",
"prepare": "husky install",
"type-check": "tsc --noEmit"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write"
]
},
"dependencies": {
"@faceless-ui/modal": "3.0.0",
"@faceless-ui/scroll-info": "2.0.0",
"@payloadcms/db-postgres": "3.69.0",
"@payloadcms/live-preview-react": "3.69.0",
"@payloadcms/next": "3.69.0",
"@payloadcms/payload-cloud": "3.69.0",
"@payloadcms/plugin-form-builder": "3.69.0",
"@payloadcms/plugin-nested-docs": "3.69.0",
"@payloadcms/plugin-redirects": "3.69.0",
"@payloadcms/plugin-search": "3.69.0",
"@payloadcms/plugin-seo": "3.69.0",
"@payloadcms/richtext-lexical": "3.69.0",
"@payloadcms/ui": "3.69.0",
"@payloadcms/db-postgres": "3.72.0",
"@payloadcms/live-preview-react": "3.72.0",
"@payloadcms/next": "3.72.0",
"@payloadcms/payload-cloud": "3.72.0",
"@payloadcms/plugin-form-builder": "3.72.0",
"@payloadcms/plugin-nested-docs": "3.72.0",
"@payloadcms/plugin-redirects": "3.72.0",
"@payloadcms/plugin-search": "3.72.0",
"@payloadcms/plugin-seo": "3.72.0",
"@payloadcms/richtext-lexical": "3.72.0",
"@payloadcms/ui": "3.72.0",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
Expand All @@ -51,8 +59,8 @@
"lucide-react": "^0.378.0",
"next": "15.5.9",
"next-sitemap": "^4.2.3",
"nodemailer": "^6.10.0",
"payload": "3.69.0",
"nodemailer": "^7.0.12",
"payload": "3.72.0",
"payload-admin-bar": "^1.0.6",
"prism-react-renderer": "^2.3.1",
"react": "19.2.3",
Expand All @@ -73,6 +81,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/d3": "^7",
"@types/escape-html": "^1.0.2",
"@types/eslint-plugin-tailwindcss": "^3",
"@types/gtag.js": "^0.0.20",
"@types/jest": "^29.5.14",
"@types/node": "22.5.4",
Expand All @@ -84,16 +93,23 @@
"copyfiles": "^2.4.1",
"eslint": "^9.16.0",
"eslint-config-next": "15.1.5",
"eslint-plugin-tailwindcss": "^3.18.2",
"husky": "^8.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^16.2.7",
"postcss": "^8.4.38",
"prettier": "^3.4.2",
"prettier": "^3.8.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.4.3",
"ts-node": "^10.9.2",
"typescript": "5.7.3"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"resolutions": {
"tar": "^7.5.2",
"esbuild": "^0.25.12"
}
}
138 changes: 70 additions & 68 deletions src/app/(frontend)/posts/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import type { Metadata } from 'next'

import Link from 'next/link'
import { RelatedPosts } from '@/blocks/RelatedPosts/Component'
import { PayloadRedirects } from '@/components/PayloadRedirects'
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { draftMode } from 'next/headers'
import React, { cache } from 'react'
import RichText from '@/components/RichText'

import type { Post } from '@/payload-types'

import { PostHero } from '@/heros/PostHero'
import { generateMeta } from '@/utilities/generateMeta'
import type { Metadata } from 'next';

import Image from 'next/image';
import Link from 'next/link';
import { RelatedPosts } from '@/blocks/RelatedPosts/Component';
import { PayloadRedirects } from '@/components/PayloadRedirects';
import configPromise from '@payload-config';
import { getPayload } from 'payload';
import { draftMode } from 'next/headers';
import React, { cache } from 'react';
import RichText from '@/components/RichText';

import type { Post } from '@/payload-types';

import { PostHero } from '@/heros/PostHero';
import { generateMeta } from '@/utilities/generateMeta';
import {
generateArticleStructuredData,
generateBreadcrumbStructuredData,
} from '@/utilities/generateStructuredData'
import PageClient from './page.client'
import { LivePreviewListener } from '@/components/LivePreviewListener'
} from '@/utilities/generateStructuredData';
import PageClient from './page.client';
import { LivePreviewListener } from '@/components/LivePreviewListener';

// Enable ISR - revalidate every 60 seconds in production
export const revalidate = 60
export const revalidate = 60;

export async function generateStaticParams() {
const payload = await getPayload({ config: configPromise })
const payload = await getPayload({ config: configPromise });
const posts = await payload.find({
collection: 'posts',
draft: false,
Expand All @@ -34,57 +35,57 @@ export async function generateStaticParams() {
select: {
slug: true,
},
})
});

const params = posts.docs.map(({ slug }) => {
return { slug }
})
return { slug };
});

return params
return params;
}

type Args = {
params: Promise<{
slug?: string
}>
}
slug?: string;
}>;
};

export default async function Post({ params: paramsPromise }: Args) {
const { isEnabled: draft } = await draftMode()
const { slug = '' } = await paramsPromise
const url = '/posts/' + slug
const post = await queryPostBySlug({ slug })
const { isEnabled: draft } = await draftMode();
const { slug = '' } = await paramsPromise;
const url = '/posts/' + slug;
const post = await queryPostBySlug({ slug });

if (!post) return <PayloadRedirects url={url} />
if (!post) return <PayloadRedirects url={url} />;

// Fetch related posts by category
const firstCategory = post.categories?.[0]
const categoryId = typeof firstCategory === 'object' ? firstCategory?.id : null
const firstCategory = post.categories?.[0];
const categoryId = typeof firstCategory === 'object' ? firstCategory?.id : null;
const categoryBasedPosts = categoryId
? await queryPostsByCategory({ categoryId, excludeSlug: slug })
: []
: [];

// Generate structured data for SEO
const articleStructuredData = generateArticleStructuredData(post)
const articleStructuredData = generateArticleStructuredData(post);

const breadcrumbItems = [
{ name: 'Home', url: '/' },
{ name: 'Articles', url: '/posts' },
]
];

if (post.categories && post.categories.length > 0) {
const firstCategory = post.categories[0]
const firstCategory = post.categories[0];
if (typeof firstCategory === 'object' && firstCategory?.title) {
breadcrumbItems.push({
name: firstCategory.title,
url: `/posts?category=${firstCategory.slug}`,
})
});
}
}

breadcrumbItems.push({ name: post.title, url: `/posts/${post.slug}` })
breadcrumbItems.push({ name: post.title, url: `/posts/${post.slug}` });

const breadcrumbStructuredData = generateBreadcrumbStructuredData(breadcrumbItems)
const breadcrumbStructuredData = generateBreadcrumbStructuredData(breadcrumbItems);

return (
<>
Expand Down Expand Up @@ -134,7 +135,7 @@ export default async function Post({ params: paramsPromise }: Args) {
href={post.url}
target="_blank"
rel="noopener noreferrer"
className="from-brand-magenta via-brand-violet to-brand-teal text-neutral-white group flex items-center justify-between gap-4 rounded-lg bg-gradient-to-r p-6 shadow-lg transition-all hover:opacity-90 hover:shadow-xl"
className="group flex items-center justify-between gap-4 rounded-lg bg-gradient-to-r from-brand-magenta via-brand-violet to-brand-teal p-6 text-neutral-white shadow-lg transition-all hover:opacity-90 hover:shadow-xl"
>
<div className="flex items-center gap-3">
<svg
Expand Down Expand Up @@ -189,7 +190,7 @@ export default async function Post({ params: paramsPromise }: Args) {
)}

{/* Content container with optimal reading width */}
<div className="bg-neutral-white mx-auto max-w-[48rem] rounded-lg p-8 shadow-lg md:p-12">
<div className="mx-auto max-w-[48rem] rounded-lg bg-neutral-white p-8 shadow-lg md:p-12">
{/* Article metadata for SEO */}
<div className="mb-6 flex flex-wrap gap-4 border-b border-neutral-200 pb-6 text-sm text-neutral-600">
{post.updatedAt && post.updatedAt !== post.createdAt && (
Expand Down Expand Up @@ -245,9 +246,9 @@ export default async function Post({ params: paramsPromise }: Args) {
>
{category.title}
</a>
)
);
}
return null
return null;
})}
</div>
</div>
Expand All @@ -257,7 +258,7 @@ export default async function Post({ params: paramsPromise }: Args) {
{/* Author bio section */}
{post.populatedAuthors && post.populatedAuthors.length > 0 && (
<div className="mx-auto mt-12 max-w-[48rem]">
<div className="bg-neutral-white rounded-lg p-6 shadow-lg md:p-8">
<div className="rounded-lg bg-neutral-white p-6 shadow-lg md:p-8">
<h3 className="mb-4 text-xl font-bold text-neutral-900">About the Author</h3>
{post.populatedAuthors.map((author, index) => {
if (author?.name) {
Expand All @@ -269,7 +270,7 @@ export default async function Post({ params: paramsPromise }: Args) {
itemType="https://schema.org/Person"
>
<div className="flex-shrink-0">
<div className="from-brand-violet to-brand-magenta text-neutral-white flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br text-xl font-bold">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-br from-brand-violet to-brand-magenta text-xl font-bold text-neutral-white">
{author.name.charAt(0).toUpperCase()}
</div>
</div>
Expand All @@ -280,9 +281,9 @@ export default async function Post({ params: paramsPromise }: Args) {
<p className="mt-1 text-neutral-600">Contributor at Decentralizard</p>
</div>
</div>
)
);
}
return null
return null;
})}
</div>
</div>
Expand All @@ -300,14 +301,15 @@ export default async function Post({ params: paramsPromise }: Args) {
<Link
key={relatedPost.id}
href={`/posts/${relatedPost.slug}`}
className="bg-neutral-white group overflow-hidden rounded-lg shadow-md transition-all hover:shadow-lg"
className="group overflow-hidden rounded-lg bg-neutral-white shadow-md transition-all hover:shadow-lg"
>
{relatedPost.heroImage && typeof relatedPost.heroImage === 'object' && (
<div className="aspect-video overflow-hidden bg-neutral-200">
<img
<div className="relative aspect-video overflow-hidden bg-neutral-200">
<Image
src={relatedPost.heroImage.url || ''}
alt={relatedPost.title}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
fill
className="object-cover transition-transform group-hover:scale-105"
/>
</div>
)}
Expand Down Expand Up @@ -343,20 +345,20 @@ export default async function Post({ params: paramsPromise }: Args) {
</div>
</article>
</>
)
);
}

export async function generateMetadata({ params: paramsPromise }: Args): Promise<Metadata> {
const { slug = '' } = await paramsPromise
const post = await queryPostBySlug({ slug })
const { slug = '' } = await paramsPromise;
const post = await queryPostBySlug({ slug });

return generateMeta({ doc: post })
return generateMeta({ doc: post });
}

const queryPostBySlug = cache(async ({ slug }: { slug: string }) => {
const { isEnabled: draft } = await draftMode()
const { isEnabled: draft } = await draftMode();

const payload = await getPayload({ config: configPromise })
const payload = await getPayload({ config: configPromise });

const result = await payload.find({
collection: 'posts',
Expand All @@ -370,16 +372,16 @@ const queryPostBySlug = cache(async ({ slug }: { slug: string }) => {
equals: slug,
},
},
})
});

return result.docs?.[0] || null
})
return result.docs?.[0] || null;
});

const queryPostsByCategory = cache(
async ({ categoryId, excludeSlug }: { categoryId: number; excludeSlug: string }) => {
const { isEnabled: draft } = await draftMode()
const { isEnabled: draft } = await draftMode();

const payload = await getPayload({ config: configPromise })
const payload = await getPayload({ config: configPromise });

const result = await payload.find({
collection: 'posts',
Expand All @@ -402,8 +404,8 @@ const queryPostsByCategory = cache(
},
],
},
})
});

return result.docs || []
},
)
return result.docs || [];
}
);
Loading