feat: PageSpeed Insights improvements

This commit is contained in:
Kalidou Diagne
2025-08-11 17:01:56 +02:00
parent 7b4084c4f7
commit 8a1b7ac457
20 changed files with 604 additions and 121 deletions

16
.browserslistrc Normal file
View File

@@ -0,0 +1,16 @@
# Modern browser targets - avoid unnecessary transpilation
# Supports ES6+ features natively, reducing bundle size and improving performance
# Chrome 91+ (released June 2021) - supports ES2022 features
chrome >= 91
# Firefox 90+ (released July 2021) - supports ES2022 features
firefox >= 90
# Safari 14.1+ (released April 2021) - supports ES2021 features
safari >= 14.1
# Edge 91+ (released June 2021) - supports ES2022 features
edge >= 91
# Avoid supporting very old browsers that require heavy transpilation
not ie > 0
not dead
not < 0.25%

13
.swcrc Normal file
View File

@@ -0,0 +1,13 @@
{
"jsc": {
"target": "es2022"
},
"env": {
"targets": {
"chrome": "91",
"firefox": "90",
"safari": "14.1",
"edge": "91"
}
}
}

265
CLAUDE.md Normal file
View File

@@ -0,0 +1,265 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is the Privacy Stewards of Ethereum (pse.dev) website - a Next.js 14 application showcasing cryptographic research, projects, and articles. The site uses a modern architecture with App Router, MDX content management, and comprehensive performance optimizations.
## Development Commands
### Core Development
```bash
yarn dev # Start development server
yarn build # Production build
yarn start # Start production server
yarn preview # Build and start locally
yarn clean # Remove build artifacts
```
### Code Quality
```bash
yarn lint # Run ESLint
yarn lint:fix # Fix ESLint issues automatically
yarn typecheck # TypeScript type checking
yarn format:check # Check Prettier formatting
yarn format:write # Apply Prettier formatting
```
### Testing
```bash
yarn test # Run all tests once (CI mode)
yarn test:watch # Run tests in watch mode
yarn test:ui # Open Vitest UI runner
yarn test:coverage # Generate coverage report
yarn test:validation # Run setup validation tests
yarn test:ci # CI-optimized test run
```
## Architecture Overview
### Next.js App Router Structure
- **App Directory**: Uses Next.js 14 App Router (`/app` directory)
- **Route Groups**: Pages organized in `(pages)` route group for clean URLs
- **Dynamic Routes**: Blog articles use `[slug]` and projects use `[id]`
- **API Routes**: RESTful endpoints in `/app/api/` for content, search, RSS, YouTube
### Content Management System
- **File-based CMS**: Content stored in `/content/` directory
- Articles: `/content/articles/` (Markdown with YAML frontmatter)
- Projects: `/content/projects/` (Markdown with rich metadata)
- **Templates**: Use `_article-template.md` and `_project-template.md` for new content
- **Assets**: Article images in `/public/articles/[article-name]/`
- **Processing**: Custom content library handles markdown parsing with `gray-matter`
### Component Architecture
```
/components/
├── ui/ # Reusable UI primitives (shadcn/ui + Radix)
├── blog/ # Blog-specific components
├── project/ # Project-specific components
├── sections/ # Page sections and layouts
├── search/ # Search functionality
└── layouts/ # Layout components and providers
```
### State Management
- **Global State**: React Context via `GlobalProvider` for theme and app state
- **Data Fetching**: TanStack Query for async state management
- **Providers**: Nested pattern (Global → Projects → Theme → children)
### Content Features
- **Math Rendering**: KaTeX for LaTeX formulas (`$inline$` and `$$block$$`)
- **Code Highlighting**: Prism.js with syntax highlighting
- **Rich Markdown**: Custom table components, accordions, footnotes
- **Search**: Algolia integration with Fuse.js fallback
- **SEO**: Comprehensive metadata generation and RSS feeds
## Development Workflow
### Adding Articles
1. Copy `/content/articles/_article-template.md`
2. Rename to `kebab-case-title.md`
3. Update frontmatter (authors, title, image, tldr, date, tags, projects)
4. Create matching folder in `/public/articles/[article-name]/` for images
5. Write content using Markdown with KaTeX math support
### Adding Projects
1. Copy `/content/projects/_project-template.md`
2. Rename to `project-id.md`
3. Configure frontmatter:
- **Required**: id, name, image, section, projectStatus, tldr
- **Optional**: category, license, tags, links, team, youtubeLinks, extraLinks
4. Write project description using Markdown
### Content Frontmatter Structure
**Articles**:
```yaml
authors: ["Name"] # Author names
title: "Article Title" # Display title
image: "/articles/name/cover.webp" # Cover image
tldr: "Brief summary" # Short description
date: "2024-01-01" # Publication date
canonical: "external-url" # Optional canonical URL
tags: ["tag1", "tag2"] # Optional categorization
projects: ["project-id"] # Optional project links
```
**Projects**:
```yaml
id: "unique-project-id" # Kebab-case identifier
name: "Project Display Name" # Human-readable name
section: "pse" # pse|grant|collaboration|archived
projectStatus: "active" # active|inactive|maintained
image: "/projects/id/cover.webp" # Cover image path
tldr: "One-line description" # Brief summary
category: "research" # research|devtools|application
tags:
keywords: ["tag1", "tag2"] # Technical keywords
themes: ["privacy"] # High-level themes
types: ["research"] # Project types
builtWith: ["typescript"] # Technologies
links:
github: "github-url" # Project links
website: "website-url"
team: # Team member details
- name: "Member Name"
role: "Role"
links:
github: "github-url"
youtubeLinks: ["video-urls"] # YouTube videos
extraLinks: # Categorized action links
buildWith: [...]
play: [...]
research: [...]
learn: [...]
```
## Performance Optimizations
### Modern JavaScript Build
- **Browser Targets**: Chrome 91+, Firefox 90+, Safari 14.1+, Edge 91+ (see `.browserslistrc`)
- **TypeScript Config**: ES2022 target with modern library support
- **SWC Compiler**: Rust-based compilation with minimal transpilation (`.swcrc`)
- **Bundle Optimization**: Tree shaking, code splitting, optimized imports
### Image and Asset Optimization
- **Next.js Image**: Automatic WebP/AVIF conversion and responsive sizing
- **Font Loading**: Google Fonts with `display: swap` and fallbacks
- **Resource Hints**: Preconnect/DNS prefetch for external domains
### Render Performance
- **Lazy Analytics**: Matomo tracking loads with `lazyOnload` strategy
- **Critical CSS**: Inlined base styles for faster initial render
- **Static Generation**: Extensive use of SSG for content pages
## Testing Architecture
### Test Structure
```
/tests/
├── test-utils.tsx # Custom render with providers
├── setup.tsx # Global test setup
├── mocks/ # Mock implementations
└── *.test.tsx # Individual test files
```
### Test Utilities
- **Custom Render**: Use `@/tests/test-utils` render function for provider context
- **Environment**: jsdom preconfigured for DOM testing
- **Mocks**: Browser APIs and external libraries mocked in `/tests/mocks/`
- **Path Aliases**: `@/` alias points to project root in tests
### Test Commands Context
- `test:validation` runs sanity checks for project setup
- Coverage reports generated in `/coverage/` directory
- UI runner available for interactive test development
## API Architecture
### Endpoint Structure
```
/app/api/
├── articles/route.ts # Article listing with filtering
├── projects/route.ts # Project data endpoints
├── search/route.ts # Global search functionality
├── rss/route.ts # RSS feed generation
└── youtube/ # YouTube integration
├── route.ts # Channel data
└── videos/route.ts # Video endpoints
```
### Response Patterns
- Consistent JSON response format
- Error handling with appropriate HTTP status codes
- Route-level caching with revalidation strategies
## External Integrations
- **YouTube API**: Video embedding and metadata fetching
- **Matomo Analytics**: Privacy-focused tracking (loads lazily)
- **Discord Bot**: Server-side integration capabilities (`/common/discord`)
- **Algolia Search**: Primary search with local Fuse.js fallback
## Build and Performance Notes
### Bundle Analysis
- Use `yarn build` to see route-by-route bundle sizes
- Shared chunks optimized for caching across routes
- Dynamic imports used for code splitting where beneficial
### Performance Monitoring
- PageSpeed Insights optimizations implemented
- Modern JavaScript delivery reduces transpilation overhead
- Aggressive caching strategies for static content
### Environment Configuration
- Development/production environment variables supported
- Node.js 22.x required (see `engines` in package.json)
- Yarn 4.x with Corepack for package management
## Key Development Patterns
### Content Updates
- Always run `yarn test:validation` after content changes
- Images should be optimized (prefer WebP format, <300KB)
- Article folder names must exactly match markdown file names (without .md)
### Code Style
- TypeScript strict mode enabled
- ESLint + Prettier configured with import sorting
- Path aliases (`@/`) for clean imports
- Husky + lint-staged for pre-commit quality checks
### Contribution Guidelines
- Internal PRs: Tag @kalidiagne, @psedesign for review
- Use #website-pse Discord channel for questions
- Staging/dev branch for feature PRs, main for minor updates
- Two approvals required for main branch changes

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"
import { getArticles } from "@/lib/content"
import { NextRequest, NextResponse } from "next/server"
// Cache control
export const revalidate = 60 // Revalidate cache after 60 seconds
// Cache control - Extended for better performance
export const revalidate = 1800 // Revalidate cache after 30 minutes
export const dynamic = "force-dynamic" // Ensure the route is always evaluated
export async function GET(request: NextRequest) {
@@ -20,10 +20,17 @@ export async function GET(request: NextRequest) {
project,
})
return NextResponse.json({
articles,
success: true,
})
return NextResponse.json(
{
articles,
success: true,
},
{
headers: {
"Cache-Control": "public, s-maxage=1800, stale-while-revalidate=3600",
},
}
)
} catch (error) {
console.error("Error fetching articles:", error)
return NextResponse.json(

View File

@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server"
import { getProjects } from "@/lib/content"
import { NextRequest, NextResponse } from "next/server"
// Cache control
export const revalidate = 60 // Revalidate cache after 60 seconds
// Cache control - Extended for better performance
export const revalidate = 1800 // Revalidate cache after 30 minutes
export const dynamic = "force-dynamic" // Ensure the route is always evaluated
export async function GET(request: NextRequest) {
@@ -15,7 +15,11 @@ export async function GET(request: NextRequest) {
try {
const projects = getProjects({ tag, limit, status })
return NextResponse.json(projects ?? [])
return NextResponse.json(projects ?? [], {
headers: {
"Cache-Control": "public, s-maxage=1800, stale-while-revalidate=3600",
},
})
} catch (error) {
console.error("Error fetching projects:", error)
return NextResponse.json(

View File

@@ -1,6 +1,9 @@
import algoliasearch from "algoliasearch"
import { NextRequest, NextResponse } from "next/server"
// Cache search results for better performance
export const revalidate = 900 // Revalidate cache after 15 minutes
const appId =
process.env.ALGOLIA_APP_ID || process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || ""
const apiKey =
@@ -61,11 +64,19 @@ export async function GET(request: NextRequest) {
const index = searchClient.initIndex(indexName)
const response = await index.search(transformedQuery, { hitsPerPage })
return NextResponse.json({
hits: response.hits,
status: "success",
availableIndexes: allIndexes,
})
return NextResponse.json(
{
hits: response.hits,
status: "success",
availableIndexes: allIndexes,
},
{
headers: {
"Cache-Control":
"public, s-maxage=900, stale-while-revalidate=1800",
},
}
)
}
// Otherwise search across all configured indexes
@@ -88,11 +99,18 @@ export async function GET(request: NextRequest) {
(result) => result.hits && result.hits.length > 0
)
return NextResponse.json({
results: nonEmptyResults,
status: "success",
availableIndexes: allIndexes,
})
return NextResponse.json(
{
results: nonEmptyResults,
status: "success",
availableIndexes: allIndexes,
},
{
headers: {
"Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800",
},
}
)
} catch (error: any) {
console.error("Global search error:", error)
return NextResponse.json(

View File

@@ -11,7 +11,14 @@ export async function GET() {
const videos = await getVideosFromRSS()
return NextResponse.json({ videos })
return NextResponse.json(
{ videos },
{
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=7200",
},
}
)
} catch (error) {
console.error("Error fetching videos:", error)
return NextResponse.json({ videos: [] })

View File

@@ -1,5 +1,8 @@
import { NextRequest, NextResponse } from "next/server"
// Cache video data for better performance
export const revalidate = 1800 // Revalidate cache after 30 minutes
interface YoutubeVideoResponse {
items: {
id: string
@@ -23,11 +26,12 @@ interface YoutubeVideoResponse {
}[]
}
// Helper function to add CORS headers
// Helper function to add CORS and cache headers
function corsHeaders() {
return {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Cache-Control": "public, s-maxage=1800, stale-while-revalidate=3600",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
}

View File

@@ -1,17 +1,16 @@
import "@/globals.css"
import Script from "next/script"
import { Metadata, Viewport } from "next"
import { ThemeProvider } from "./components/layouts/ThemeProvider"
import { GlobalProviderLayout } from "@/components/layouts/GlobalProviderLayout"
import { SiteFooter } from "@/components/site-footer"
import { SiteHeader } from "@/components/site-header"
import { TailwindIndicator } from "@/components/tailwind-indicator"
import { ThemeProvider } from "./components/layouts/ThemeProvider"
import { siteConfig } from "@/config/site"
import { cn } from "@/lib/utils"
import { Metadata, Viewport } from "next"
import { DM_Sans, Inter, Space_Grotesk } from "next/font/google"
import Script from "next/script"
// Optimized font loading with combined weights
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
@@ -42,8 +41,6 @@ const sans = DM_Sans({
adjustFontFallback: true,
})
const fonts = [inter, display, sans]
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "white" },
@@ -127,7 +124,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
className={cn(inter.variable, display.variable, sans.variable)}
suppressHydrationWarning
>
<Script id="matomo-tracking" strategy="afterInteractive">
<Script id="matomo-tracking" strategy="lazyOnload">
{`
var _paq = window._paq = window._paq || [];
/* tracker methods like "setCustomDimension" should be called before "trackPageView" */
@@ -142,7 +139,38 @@ export default function RootLayout({ children }: RootLayoutProps) {
})();
`}
</Script>
<head />
<head>
{/* Font preloading for critical fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
{/* External service preconnects */}
<link rel="preconnect" href="https://cdn.matomo.cloud" />
<link rel="dns-prefetch" href="https://psedev.matomo.cloud" />
{/* YouTube preconnects for video content */}
<link rel="preconnect" href="https://www.youtube.com" />
<link rel="preconnect" href="https://img.youtube.com" />
<link rel="preconnect" href="https://i.ytimg.com" />
{/* Static asset preloading */}
<link rel="prefetch" href="/favicon.svg" />
{/* Critical resource preloading */}
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
{/* External service optimization */}
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />
{/* Algolia search preconnect for faster search */}
<link rel="preconnect" href="https://latency-dsn.algolia.net" />
<link rel="dns-prefetch" href="https://search.algolia.com" />
</head>
<body suppressHydrationWarning>
<GlobalProviderLayout>
<ThemeProvider>

View File

@@ -1,23 +1,30 @@
import { ReactNode } from "react"
import { AppContent } from "./ui/app-content"
import { ReactNode } from "react"
type BannerProps = {
title: ReactNode
subtitle?: string
children?: ReactNode
headingLevel?: "h2" | "h3" | "h4"
}
const Banner = ({ title, subtitle, children }: BannerProps) => {
const Banner = ({
title,
subtitle,
children,
headingLevel = "h2",
}: BannerProps) => {
const HeadingTag = headingLevel
return (
<section className="relative bg-background text-center">
<div className="py-16">
<AppContent className="flex flex-col gap-6">
<div className="flex flex-col items-center text-center">
{typeof title === "string" ? (
<h6 className="py-4 font-sans text-base font-bold uppercase tracking-[4px] text-primary dark:text-white">
<HeadingTag className="py-4 font-sans text-base font-bold uppercase tracking-[4px] text-primary dark:text-white">
{title}
</h6>
</HeadingTag>
) : (
title
)}

View File

@@ -2,10 +2,11 @@
import { Icons } from "../icons"
import { Button } from "../ui/button"
import { LABELS } from "@/app/labels"
import { Article } from "@/lib/content"
import { cn } from "@/lib/utils"
import Image from "next/image"
import Link from "next/link"
import { LABELS } from "@/app/labels"
interface ArticleInEvidenceCardProps {
article: Article
@@ -169,7 +170,7 @@ export const ArticleInEvidenceCard = ({
>
<div
className={cn(
"relative flex flex-col gap-5 w-full items-center after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-20 group-hover:after:opacity-80 transition-opacity duration-300 after:z-[0]",
"relative flex flex-col gap-5 w-full items-center overflow-hidden after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-20 group-hover:after:opacity-80 transition-opacity duration-300 after:z-[0]",
{
"aspect-video": !className?.includes("h-full"),
"min-h-[148px]": !backgroundCover,
@@ -177,12 +178,15 @@ export const ArticleInEvidenceCard = ({
},
className
)}
style={{
backgroundImage: `url(${article.image ?? "/fallback.webp"})`,
backgroundSize: "cover",
backgroundPosition: "center centers",
}}
>
<Image
src={article.image ?? "/fallback.webp"}
alt={article.title}
fill
className="object-cover -z-10"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false}
/>
{backgroundCover && (
<ArticleContent backgroundCover={backgroundCover} />
)}

View File

@@ -1,9 +1,11 @@
"use client"
import Link from "next/link"
import { Button } from "../ui/button"
import { Markdown } from "../ui/markdown"
import { Article } from "@/lib/content"
import { getBackgroundImage } from "@/lib/utils"
import { Button } from "../ui/button"
import Image from "next/image"
import Link from "next/link"
export const ArticleListCard = ({
article,
@@ -38,16 +40,18 @@ export const ArticleListCard = ({
rel="noreferrer"
>
<div className="grid grid-cols-[80px_1fr] lg:grid-cols-[120px_1fr] items-center gap-4 lg:gap-10">
<div
className="size-[80px] lg:size-[120px] rounded-full bg-slate-200"
style={{
backgroundImage: backgroundImage
? `url(${backgroundImage})`
: undefined,
backgroundSize: "cover",
backgroundPosition: "center",
}}
></div>
<div className="relative size-[80px] lg:size-[120px] rounded-full bg-slate-200 overflow-hidden">
{backgroundImage ? (
<Image
src={backgroundImage}
alt={article.title}
fill
className="object-cover"
sizes="(max-width: 1024px) 80px, 120px"
priority={false}
/>
) : null}
</div>
<div className="flex flex-col gap-4 lg:gap-5">
<span className="text-[10px] font-bold tracking-[2.1px] text-tuatara-400 font-sans uppercase dark:text-tuatara-100">
{formattedDate}

View File

@@ -1,10 +1,11 @@
import { LABELS } from "@/app/labels"
import { AppContent } from "../ui/app-content"
import { getArticles, Article } from "@/lib/content"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { Button } from "../ui/button"
import { Icons } from "../icons"
import { AppContent } from "../ui/app-content"
import { Button } from "../ui/button"
import { LABELS } from "@/app/labels"
import { getArticles, Article } from "@/lib/content"
import { cn } from "@/lib/utils"
import Image from "next/image"
import Link from "next/link"
const ArticleInEvidenceCard = ({
article,
@@ -56,25 +57,30 @@ const ArticleInEvidenceCard = ({
)
}
console.log("article", article.image)
return (
<AsLinkWrapper href={`/blog/${article.id}`} asLink={asLink}>
<div
className={cn(
"min-h-[177px] lg:min-h-[190px] relative flex flex-col gap-5 w-full items-center after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-20 group-hover:after:opacity-80 transition-opacity duration-300 after:z-[0]",
"min-h-[177px] lg:min-h-[190px] rounded-[6px] relative flex flex-col gap-5 w-full items-center overflow-hidden after:absolute after:inset-0 after:content-[''] after:bg-black after:opacity-50 group-hover:after:opacity-80 transition-opacity duration-300 after:z-[1]",
{
"aspect-video": !className?.includes("h-full"),
},
className
)}
style={{
backgroundImage: `url(${article.image ?? "/fallback.webp"})`,
backgroundSize: "cover",
backgroundPosition: "center centers",
}}
>
<Image
src={article.image ?? "/fallback.webp"}
alt={article.title}
fill
className="object-cover -z-[1] absolute inset-0"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority={false}
/>
<div
className={cn(
"duration-200 flex flex-col gap-[10px] text-left relative z-[1] w-full h-full",
"duration-200 flex flex-col gap-[10px] text-left relative z-[2] w-full h-full",
{
"px-5 lg:px-16 py-6 lg:py-16 ": size === "lg",
"px-6 py-4 lg:p-8": size === "sm",

View File

@@ -1,7 +1,6 @@
import { ReactNode } from "react"
import { AppContent } from "./ui/app-content"
import { Label } from "./ui/label"
import { ReactNode } from "react"
type PageHeaderProps = {
title: ReactNode
@@ -31,9 +30,9 @@ const PageHeader = ({
<div className="flex flex-col gap-4 md:gap-8">
<Label.PageTitle label={title} size={size} />
{subtitle && (
<h6 className="font-sans text-base font-normal text-primary md:text-[18px] md:leading-[27px] dark:text-tuatara-100">
<p className="font-sans text-base font-normal text-primary md:text-[18px] md:leading-[27px] dark:text-tuatara-100">
{subtitle}
</h6>
</p>
)}
</div>
{actions}

View File

@@ -1,14 +1,14 @@
"use client"
import { AppLink } from "../app-link"
import { Icons } from "../icons"
import { AppContent } from "../ui/app-content"
import { Button } from "../ui/button"
import { LABELS } from "@/app/labels"
import { useYoutube } from "@/hooks/useYoutube"
import { ArrowRight } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { Button } from "../ui/button"
import { AppContent } from "../ui/app-content"
import { Icons } from "../icons"
import { useYoutube } from "@/hooks/useYoutube"
import { AppLink } from "../app-link"
interface Video {
id: string
@@ -41,9 +41,9 @@ const VideoCard = ({ video }: { video: Video }) => {
<Icons.play />
</div>
</div>
<h3 className="font-sans text-sm font-normal line-clamp-3 text-white group-hover:text-tuatara-400 transition-colors">
<h4 className="font-sans text-sm font-normal line-clamp-3 text-white group-hover:text-tuatara-400 transition-colors">
{video.title}
</h3>
</h4>
</Link>
)
}
@@ -74,9 +74,9 @@ export const HomepageVideoFeed = () => {
<section className="mx-auto px-6 lg:px-8 py-10 lg:py-16 bg-tuatara-950 dark:bg-black w-full">
<AppContent className="flex flex-col gap-8 lg:max-w-[1200px] w-full">
<div className="col-span-1 lg:col-span-4">
<h2 className="font-sans text-base font-bold uppercase tracking-[4px] text-white text-center">
<h3 className="font-sans text-base font-bold uppercase tracking-[4px] text-white text-center">
{LABELS.HOMEPAGE.VIDEOS}
</h2>
</h3>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_1fr_1fr_280px] gap-10 lg:gap-8 lg:divide-x divide-[#626262]">
<div className="lg:col-span-3">

View File

@@ -1,10 +1,9 @@
"use client"
import { LABELS } from "@/app/labels"
import { Icons } from "../icons"
import { AppContent } from "../ui/app-content"
import { Button } from "../ui/button"
import { LABELS } from "@/app/labels"
import Link from "next/link"
type WhatWeDoContent = {
@@ -37,16 +36,16 @@ export const WhatWeDo = () => {
<div className="flex flex-col justify-center bg-anakiwa-975 py-16">
<AppContent className="mx-auto lg:max-w-[845px] w-full">
<section className="flex flex-col gap-10">
<h6 className="font-sans text-base font-bold uppercase tracking-[4px] text-primary text-center text-white">
<h2 className="font-sans text-base font-bold uppercase tracking-[4px] text-primary text-center text-white">
What we do
</h6>
</h2>
<div className="grid grid-cols-1 gap-10 lg:grid-cols-2 max-auto">
{content.map((item, index) => (
<div className="flex flex-col gap-6 w-full lg:max-w-[300px]">
<article className="flex flex-col gap-2" key={index}>
<h6 className="font-sans text-xl font-medium text-white">
<h3 className="font-sans text-xl font-medium text-white">
{item.title}
</h6>
</h3>
<p className="font-sans text-base font-normal text-white">
{item.description}
</p>

View File

@@ -1,24 +1,23 @@
"use client"
import { useState } from "react"
import NextImage from "next/image"
import CloseVector from "@/public/icons/close-fill.svg"
import { NavItem } from "@/types/nav"
import { siteConfig } from "@/config/site"
import { interpolate } from "@/lib/utils"
import { useAppSettings } from "@/hooks/useAppSettings"
import { AppLink } from "./app-link"
import { Icons } from "./icons"
import { LABELS } from "@/app/labels"
import { useGlobalProvider } from "@/app/providers/GlobalProvider"
import {
Discord,
Github,
Mirror,
Twitter,
} from "@/components/svgs/social-medias"
import { LABELS } from "@/app/labels"
import { Icons } from "./icons"
import { siteConfig } from "@/config/site"
import { useAppSettings } from "@/hooks/useAppSettings"
import { interpolate } from "@/lib/utils"
import CloseVector from "@/public/icons/close-fill.svg"
import { NavItem } from "@/types/nav"
import { SunMedium as SunIcon, Moon as MoonIcon } from "lucide-react"
import { useGlobalProvider } from "@/app/providers/GlobalProvider"
import { AppLink } from "./app-link"
import NextImage from "next/image"
import { useState } from "react"
export const SiteHeaderMobile = () => {
const [header, setHeader] = useState(false)
@@ -28,7 +27,12 @@ export const SiteHeaderMobile = () => {
return (
<div className="flex items-center md:hidden">
<button type="button" onClick={() => setHeader(true)}>
<button
type="button"
onClick={() => setHeader(true)}
aria-label="Open navigation menu"
aria-expanded={header}
>
<Icons.Burgher
size={24}
className="text-[#171C1B] dark:text-anakiwa-400"
@@ -36,21 +40,20 @@ export const SiteHeaderMobile = () => {
</button>
{header && (
<div
className="z-5 fixed inset-0 flex justify-end bg-black opacity-50"
className="z-40 fixed inset-0 flex justify-end bg-black opacity-50"
onClick={() => setHeader(false)}
></div>
)}
{header && (
<div className="fixed inset-y-0 right-0 z-10 flex w-[257px] flex-col bg-black text-white">
<div className="fixed inset-y-0 right-0 z-50 flex w-[257px] flex-col bg-black text-white">
<div className="flex justify-end p-[37px]">
<NextImage
src={CloseVector}
alt="closeVector"
className="cursor-pointer"
<button
onClick={() => setHeader(false)}
width={24}
height={24}
/>
aria-label="Close navigation menu"
className="cursor-pointer"
>
<NextImage src={CloseVector} alt="" width={24} height={24} />
</button>
</div>
<div className="flex w-full flex-col px-[16px] text-base font-medium">
{MAIN_NAV.map((item: NavItem, index) => {
@@ -70,7 +73,10 @@ export const SiteHeaderMobile = () => {
})}
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className=" ml-auto mt-10"
className="ml-auto mt-10"
aria-label={
isDarkMode ? "Switch to light mode" : "Switch to dark mode"
}
>
{isDarkMode ? (
<SunIcon
@@ -104,14 +110,14 @@ export const SiteHeaderMobile = () => {
</div>
<div className="flex gap-5 text-white">
<h1>{LABELS.COMMON.FOOTER.PRIVACY_POLICY}</h1>
<h1>{LABELS.COMMON.FOOTER.TERMS_OF_USE}</h1>
<span>{LABELS.COMMON.FOOTER.PRIVACY_POLICY}</span>
<span>{LABELS.COMMON.FOOTER.TERMS_OF_USE}</span>
</div>
<h1 className="text-center text-gray-400">
<p className="text-center text-gray-400">
{interpolate(LABELS.COMMON.LAST_UPDATED_AT, {
date: "January 16, 2024",
})}
</h1>
</p>
</div>
</div>
)}

View File

@@ -1,9 +1,8 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import * as React from "react"
const buttonVariants = cva(
"font-sans inline-flex items-center justify-center duration-200 rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background w-fit",
@@ -66,19 +65,58 @@ export interface ButtonProps
VariantProps<typeof buttonVariants> {
asChild?: boolean
icon?: LucideIcon
accessibleName?: string
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant, size, asChild = false, children, icon, ...props },
{
className,
variant,
size,
asChild = false,
children,
icon,
accessibleName,
...props
},
ref
) => {
const Comp = asChild ? Slot : "button"
const Icon = icon
// Generate accessible name from children text if not provided
const getAccessibleName = () => {
if (accessibleName) return accessibleName
if (typeof children === "string") return children
// If children is complex, try to extract text content
if (React.isValidElement(children)) {
// For simple elements, try to get text content
const textContent = React.Children.toArray(children)
.map((child) => {
if (typeof child === "string") return child
if (
React.isValidElement(child) &&
typeof child.props.children === "string"
) {
return child.props.children
}
return ""
})
.join(" ")
.trim()
return textContent || undefined
}
return undefined
}
const accessibleNameValue = getAccessibleName()
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
aria-label={accessibleNameValue}
{...props}
>
{Icon && <Icon size={18} />}

View File

@@ -41,14 +41,71 @@ const nextConfig = {
formats: ["image/avif", "image/webp"],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
minimumCacheTTL: 60,
minimumCacheTTL: 31536000, // 1 year cache for images
dangerouslyAllowSVG: true,
contentDispositionType: "attachment",
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
},
// Static asset caching headers
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "X-DNS-Prefetch-Control",
value: "on"
}
]
},
{
source: "/favicon.(ico|svg|png)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable"
}
]
},
{
source: "/:path*\\.(css|js|woff|woff2|ttf|otf)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable"
}
]
},
{
source: "/:path*\\.(jpg|jpeg|png|gif|webp|svg|avif)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable"
}
]
},
{
source: "/articles/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=31536000, immutable"
}
]
}
]
},
compiler: {
// Remove React DevTools in production
// eslint-disable-next-line no-undef
removeConsole: process.env.NODE_ENV === "production",
},
experimental: {
mdxRs: true,
optimizePackageImports: ["@heroicons/react", "lucide-react"],
// Use modern compilation target
esmExternals: true,
},
swcMinify: true,
}

View File

@@ -1,6 +1,7 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": ["dom", "dom.iterable", "es2022"],
"target": "es2022",
"allowJs": true,
"skipLibCheck": true,
"strict": true,