mirror of
https://github.com/privacy-scaling-explorations/pse.dev.git
synced 2026-04-23 03:01:03 -04:00
feat: PageSpeed Insights improvements
This commit is contained in:
16
.browserslistrc
Normal file
16
.browserslistrc
Normal 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
13
.swcrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"jsc": {
|
||||
"target": "es2022"
|
||||
},
|
||||
"env": {
|
||||
"targets": {
|
||||
"chrome": "91",
|
||||
"firefox": "90",
|
||||
"safari": "14.1",
|
||||
"edge": "91"
|
||||
}
|
||||
}
|
||||
}
|
||||
265
CLAUDE.md
Normal file
265
CLAUDE.md
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: [] })
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": ["dom", "dom.iterable", "es2022"],
|
||||
"target": "es2022",
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user