diff --git a/apps/docs/app/examples/ExamplesLayout.tsx b/apps/docs/app/examples/ExamplesLayout.tsx new file mode 100644 index 00000000..ed006194 --- /dev/null +++ b/apps/docs/app/examples/ExamplesLayout.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { Header } from '@/components/Header'; +import { type ReactNode, useEffect, useState } from 'react'; + +import { ExamplesSidebar } from './ExamplesSidebar'; + +export interface ExamplesLayoutProps { + children: ReactNode; +} + +export function ExamplesLayout({ children }: ExamplesLayoutProps) { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + + const handleMobileMenuToggle = () => { + setIsMobileMenuOpen((prev) => !prev); + }; + + const handleMobileMenuClose = () => { + setIsMobileMenuOpen(false); + }; + + // Prevent body scroll when mobile menu is open + useEffect(() => { + if (isMobileMenuOpen) { + document.body.classList.add('overflow-hidden'); + } else { + document.body.classList.remove('overflow-hidden'); + } + + return () => { + document.body.classList.remove('overflow-hidden'); + }; + }, [isMobileMenuOpen]); + + return ( + <> +
+
+ + {children} +
+ + ); +} diff --git a/apps/docs/app/examples/ExamplesSidebar.tsx b/apps/docs/app/examples/ExamplesSidebar.tsx new file mode 100644 index 00000000..09f7a039 --- /dev/null +++ b/apps/docs/app/examples/ExamplesSidebar.tsx @@ -0,0 +1,87 @@ +'use client'; + +import NavLink from '@/components/NavLink'; +import { useEffect, useState } from 'react'; + +interface ExamplesSidebarProps { + isMobileOpen?: boolean; + onMobileClose?: () => void; +} + +const EXAMPLES = [ + { id: 'theme-carousel', label: 'Theme Carousel' }, + { id: 'custom-chrome', label: 'Custom Chrome' }, + { id: 'hover-actions', label: 'Hover Actions' }, + { id: 'ai-code-review', label: 'AI Code Review' }, + { id: 'pr-review', label: 'PR Review' }, + { id: 'git-blame', label: 'Git Blame' }, +] as const; + +export function ExamplesSidebar({ + isMobileOpen = false, + onMobileClose, +}: ExamplesSidebarProps) { + const [activeSection, setActiveSection] = useState(EXAMPLES[0].id); + + useEffect(() => { + const handleScroll = () => { + // Find the section that's currently in view + for (let i = EXAMPLES.length - 1; i >= 0; i--) { + const example = EXAMPLES[i]; + const element = document.getElementById(example.id); + if (element) { + const rect = element.getBoundingClientRect(); + if (rect.top <= 120) { + setActiveSection(example.id); + return; + } + } + } + // Default to first section + setActiveSection(EXAMPLES[0].id); + }; + + window.addEventListener('scroll', handleScroll); + handleScroll(); + + return () => window.removeEventListener('scroll', handleScroll); + }, []); + + // Handle initial hash + useEffect(() => { + if (window.location.hash) { + const id = window.location.hash.slice(1); + const element = document.getElementById(id); + if (element) { + setActiveSection(id); + element.scrollIntoView({ behavior: 'instant', block: 'start' }); + } + } + }, []); + + return ( + <> + {isMobileOpen && ( +
+ )} + + + + ); +} diff --git a/apps/docs/app/examples/ai-code-review/AICodeReview.tsx b/apps/docs/app/examples/ai-code-review/AICodeReview.tsx new file mode 100644 index 00000000..96aba758 --- /dev/null +++ b/apps/docs/app/examples/ai-code-review/AICodeReview.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { IconCheck, IconSparkle, IconX } from '@/components/icons'; +import { FileDiff } from '@pierre/diffs/react'; +import type { PreloadFileDiffResult } from '@pierre/diffs/ssr'; +import { useState } from 'react'; + +import type { AIAnnotation } from './constants'; + +// ============================================================================= +// Main Component +// ============================================================================= + +interface AICodeReviewProps { + prerenderedDiff: PreloadFileDiffResult; +} + +export function AICodeReview({ prerenderedDiff }: AICodeReviewProps) { + const [annotations, setAnnotations] = useState( + prerenderedDiff.annotations ?? [] + ); + const [resolvedCount, setResolvedCount] = useState(0); + + const handleResolve = (lineNumber: number) => { + setAnnotations((prev) => prev.filter((a) => a.lineNumber !== lineNumber)); + setResolvedCount((c) => c + 1); + }; + + const handleDismiss = (lineNumber: number) => { + setAnnotations((prev) => prev.filter((a) => a.lineNumber !== lineNumber)); + }; + + return ( +
+
+
+ +

AI Code Review

+
+

+ Use annotations to display inline AI suggestions, warnings, and code + improvements. Each annotation can have custom actions like accept, + dismiss, or apply fix. +

+
+ + {annotations.length > 0 && ( +
+ + + {annotations.length} suggestion{annotations.length !== 1 && 's'}{' '} + remaining + {resolvedCount > 0 && ` · ${resolvedCount} resolved`} + +
+ )} + +
+ ( + handleResolve(annotation.lineNumber)} + onDismiss={() => handleDismiss(annotation.lineNumber)} + /> + )} + /> +
+
+ ); +} + +// ============================================================================= +// Annotation Card +// ============================================================================= + +function AIAnnotationCard({ + annotation, + onResolve, + onDismiss, +}: { + annotation: AIAnnotation; + onResolve: () => void; + onDismiss: () => void; +}) { + const bgColor = + annotation.type === 'warning' + ? 'bg-amber-950/80 border-amber-800/50' + : annotation.type === 'suggestion' + ? 'bg-purple-950/80 border-purple-800/50' + : 'bg-blue-950/80 border-blue-800/50'; + + const iconColor = + annotation.type === 'warning' + ? 'text-amber-400' + : annotation.type === 'suggestion' + ? 'text-purple-400' + : 'text-blue-400'; + + return ( +
+
+
+ +
+

{annotation.message}

+ + {annotation.suggestion && ( +
+                {annotation.suggestion}
+              
+ )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/docs/app/examples/ai-code-review/constants.ts b/apps/docs/app/examples/ai-code-review/constants.ts new file mode 100644 index 00000000..f9e81c94 --- /dev/null +++ b/apps/docs/app/examples/ai-code-review/constants.ts @@ -0,0 +1,113 @@ +import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS'; +import { + type DiffLineAnnotation, + type FileContents, + parseDiffFromFile, +} from '@pierre/diffs'; +import type { PreloadFileDiffOptions } from '@pierre/diffs/ssr'; + +export interface AIAnnotation { + type: 'suggestion' | 'warning' | 'info'; + message: string; + suggestion?: string; +} + +const OLD_FILE: FileContents = { + name: 'auth.ts', + contents: `import { hash, compare } from 'bcrypt'; +import { sign, verify } from 'jsonwebtoken'; + +const SECRET = 'my-secret-key'; +const SALT_ROUNDS = 10; + +export async function hashPassword(password: string) { + return hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hashed: string) { + return compare(password, hashed); +} + +export function createToken(userId: string) { + return sign({ userId }, SECRET, { expiresIn: '24h' }); +} + +export function verifyToken(token: string) { + try { + return verify(token, SECRET); + } catch { + return null; + } +} +`, +}; + +const NEW_FILE: FileContents = { + name: 'auth.ts', + contents: `import { hash, compare } from 'bcrypt'; +import { sign, verify } from 'jsonwebtoken'; + +const SECRET = process.env.JWT_SECRET!; +const SALT_ROUNDS = 12; + +export async function hashPassword(password: string): Promise { + return hash(password, SALT_ROUNDS); +} + +export async function verifyPassword( + password: string, + hashed: string +): Promise { + return compare(password, hashed); +} + +export function createToken(userId: string, role: string = 'user'): string { + return sign({ userId, role }, SECRET, { expiresIn: '1h' }); +} + +export function verifyToken(token: string): TokenPayload | null { + try { + return verify(token, SECRET) as TokenPayload; + } catch { + return null; + } +} + +interface TokenPayload { + userId: string; + role: string; +} +`, +}; + +const ANNOTATIONS: DiffLineAnnotation[] = [ + { + side: 'additions', + lineNumber: 4, + metadata: { + type: 'warning', + message: + 'Using non-null assertion on environment variable. Consider adding runtime validation.', + suggestion: `const SECRET = process.env.JWT_SECRET; +if (!SECRET) throw new Error('JWT_SECRET not configured');`, + }, + }, + { + side: 'additions', + lineNumber: 18, + metadata: { + type: 'suggestion', + message: 'Consider using a shorter token expiry for better security.', + }, + }, +]; + +export const AI_CODE_REVIEW_EXAMPLE: PreloadFileDiffOptions = { + fileDiff: parseDiffFromFile(OLD_FILE, NEW_FILE), + options: { + theme: 'pierre-dark', + diffStyle: 'unified', + unsafeCSS: CustomScrollbarCSS, + }, + annotations: ANNOTATIONS, +}; diff --git a/apps/docs/app/examples/custom-chrome/FullCustomHeader.tsx b/apps/docs/app/examples/custom-chrome/FullCustomHeader.tsx new file mode 100644 index 00000000..8574833e --- /dev/null +++ b/apps/docs/app/examples/custom-chrome/FullCustomHeader.tsx @@ -0,0 +1,339 @@ +'use client'; + +import { + IconAt, + IconCheck, + IconCopyFill, + IconDiffModifiedFill, + IconDiffSplit, + IconDiffUnified, + IconExpandRow, + IconHunkDivider, + IconMoon, + IconSquircleLgFill, + IconSun, +} from '@/components/icons'; +import { FileDiff } from '@pierre/diffs/react'; +import type { PreloadFileDiffResult } from '@pierre/diffs/ssr'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +// ============================================================================= +// Local Components +// ============================================================================= + +type ThemeType = 'dark' | 'light'; +type HunkSeparatorOption = 'simple' | 'metadata' | 'line-info'; + +function IconButton({ + onClick, + icon, + title, +}: { + onClick: () => void; + icon: ReactNode; + title: string; +}) { + return ( + + ); +} + +function SegmentedButtonGroup({ + value, + onChange, + options, + themeType, +}: { + value: T; + onChange: (value: T) => void; + options: { value: T; icon: ReactNode; title: string }[]; + themeType: ThemeType; +}) { + const groupBg = themeType === 'dark' ? 'bg-neutral-900' : 'bg-neutral-100'; + + const getButtonClasses = (isActive: boolean) => { + const bg = isActive + ? themeType === 'dark' + ? 'bg-blue-900 text-blue-300' + : 'bg-blue-200 text-blue-600' + : themeType === 'dark' + ? 'text-neutral-300 hover:text-neutral-500' + : 'text-neutral-500 hover:text-neutral-700'; + + return `flex h-6 w-6 items-center justify-center cursor-pointer rounded ${bg}`; + }; + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +function Kbd({ + children, + themeType, +}: { + children: ReactNode; + themeType: ThemeType; +}) { + return ( + + {children} + + ); +} + +function VerticalDivider({ themeType }: { themeType: ThemeType }) { + return ( +
+ ); +} + +function DiffstatSquares({ + additions, + deletions, +}: { + additions: number; + deletions: number; +}) { + const total = additions + deletions; + const SQUARE_COUNT = 5; + const greenCount = + total === 0 ? 0 : Math.round((additions / total) * SQUARE_COUNT); + const redCount = total === 0 ? 0 : SQUARE_COUNT - greenCount; + + const squares: ('green' | 'red' | 'neutral')[] = []; + for (let i = 0; i < greenCount; i++) squares.push('green'); + for (let i = 0; i < redCount; i++) squares.push('red'); + while (squares.length < SQUARE_COUNT) squares.push('neutral'); + + return ( +
+ {squares.map((color, i) => ( + + ))} +
+ ); +} + +// ============================================================================= +// Main Component +// ============================================================================= + +interface FullCustomHeaderProps { + prerenderedDiff: PreloadFileDiffResult; +} + +export function FullCustomHeader({ prerenderedDiff }: FullCustomHeaderProps) { + const [copied, setCopied] = useState(false); + const [themeType, setThemeType] = useState( + prerenderedDiff.options?.themeType === 'light' ? 'light' : 'dark' + ); + const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>( + prerenderedDiff.options?.diffStyle ?? 'unified' + ); + const [hunkSeparators, setHunkSeparators] = + useState('line-info'); + + const fileDiff = prerenderedDiff.fileDiff; + const additions = fileDiff.hunks.reduce((acc, h) => acc + h.additionLines, 0); + const deletions = fileDiff.hunks.reduce((acc, h) => acc + h.deletionLines, 0); + + const handleCopy = () => { + void navigator.clipboard.writeText(fileDiff.name); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const containerClasses = + themeType === 'dark' + ? 'border-neutral-800 bg-neutral-900 text-neutral-200' + : 'border-neutral-200 bg-neutral-50 text-neutral-900'; + + const footerClasses = + themeType === 'dark' + ? 'border-neutral-800 bg-neutral-900 text-neutral-400' + : 'border-neutral-200 bg-neutral-50 text-neutral-500'; + + return ( +
+
+

Custom Header & Footer

+

+ Use disableFileHeader: true to completely replace the + built-in chrome with your own header, footer, and toolbar. +

+
+ +
+
+
+ +
+
+ + {fileDiff.name} + + +
+
+
+ +
+
+ +{additions} + -{deletions} + +
+ + + setDiffStyle((c) => (c === 'split' ? 'unified' : 'split')) + } + icon={ + diffStyle === 'split' ? ( + + ) : ( + + ) + } + title={ + diffStyle === 'split' ? 'Switch to unified' : 'Switch to split' + } + /> + + setThemeType((c) => (c === 'dark' ? 'light' : 'dark')) + } + icon={ + themeType === 'dark' ? ( + + ) : ( + + ) + } + title={ + themeType === 'dark' ? 'Switch to light' : 'Switch to dark' + } + /> +
+
+ + + +
+
+ , + title: 'Expandable', + }, + { + value: 'simple', + icon: , + title: 'Simple', + }, + { + value: 'metadata', + icon: , + title: 'Metadata', + }, + ]} + /> + + {fileDiff.hunks.length} hunk{fileDiff.hunks.length !== 1 && 's'} + +
+
+ + ↑↓ + Navigate + + + ⌘C + Copy + +
+
+
+
+ ); +} diff --git a/apps/docs/app/examples/custom-chrome/constants.ts b/apps/docs/app/examples/custom-chrome/constants.ts new file mode 100644 index 00000000..2ea44f10 --- /dev/null +++ b/apps/docs/app/examples/custom-chrome/constants.ts @@ -0,0 +1,88 @@ +import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS'; +import { type FileContents, parseDiffFromFile } from '@pierre/diffs'; +import type { PreloadFileDiffOptions } from '@pierre/diffs/ssr'; + +const OLD_FILE: FileContents = { + name: 'utils.ts', + contents: `// String utilities +export function capitalize(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return str.slice(0, maxLength) + '...'; +} + +// Array utilities +export function unique(array: T[]): T[] { + return [...new Set(array)]; +} + +export function shuffle(array: T[]): T[] { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; +} + +// Object utilities +export function pick(obj: T, keys: K[]): Pick { + const result = {} as Pick; + for (const key of keys) { + result[key] = obj[key]; + } + return result; +} + +export function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} +`, +}; + +const NEW_FILE: FileContents = { + name: 'utils.ts', + contents: `// String utilities +export function capitalize(str: string): string { + if (!str) return str; + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function truncate(str: string, max: number, ellipsis = '…'): string { + if (str.length <= max) return str; + return str.slice(0, max) + ellipsis; +} + +// Array utilities +export function unique(array: T[]): T[] { + return [...new Set(array)]; +} + +// Object utilities +export function pick(obj: T, keys: K[]): Pick { + const result = {} as Pick; + for (const key of keys) { + result[key] = obj[key]; + } + return result; +} + +export function deepClone(obj: T): T { + return structuredClone(obj); +} +`, +}; + +export const FULL_CUSTOM_HEADER_EXAMPLE: PreloadFileDiffOptions = { + fileDiff: parseDiffFromFile(OLD_FILE, NEW_FILE), + options: { + theme: { dark: 'pierre-dark', light: 'pierre-light' }, + themeType: 'dark', + diffStyle: 'unified', + disableFileHeader: true, + unsafeCSS: CustomScrollbarCSS, + }, +}; diff --git a/apps/docs/app/examples/git-blame/GitBlameView.tsx b/apps/docs/app/examples/git-blame/GitBlameView.tsx new file mode 100644 index 00000000..704526a2 --- /dev/null +++ b/apps/docs/app/examples/git-blame/GitBlameView.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { IconArrowDownRight, IconCommit } from '@/components/icons'; +import { FileDiff } from '@pierre/diffs/react'; +import type { PreloadFileDiffResult } from '@pierre/diffs/ssr'; +import Link from 'next/link'; +import { useState } from 'react'; + +// ============================================================================= +// Fake blame data - maps line numbers to commits +// ============================================================================= + +interface BlameInfo { + author: string; + avatar: string; + message: string; + date: string; + hash: string; +} + +const DEFAULT_BLAME: BlameInfo = { + author: 'Jacob', + avatar: '/avatars/avatar_fat.jpg', + message: 'Initial project setup with package.json', + date: '3w', + hash: 'a1b2c3d', +}; + +const BLAME_DATA: Array<{ minLine: number; info: BlameInfo }> = [ + { + minLine: 14, + info: { + author: 'Mark', + avatar: '/avatars/avatar_mdo.jpg', + message: 'Upgrade to Next.js 15 and React 19', + date: '1w', + hash: 'i7j8k9l', + }, + }, + { + minLine: 7, + info: { + author: 'Amadeus', + avatar: '/avatars/avatar_amadeus.jpg', + message: 'Add Turbopack for faster builds', + date: '2d', + hash: 'e4f5g6h', + }, + }, + { minLine: 1, info: DEFAULT_BLAME }, +]; + +function getBlameForLine(lineNumber: number): BlameInfo { + const entry = BLAME_DATA.find((e) => lineNumber >= e.minLine); + return entry?.info ?? DEFAULT_BLAME; +} + +// ============================================================================= +// Main Component +// ============================================================================= + +interface GitBlameViewProps { + prerenderedDiff: PreloadFileDiffResult; +} + +export function GitBlameView({ prerenderedDiff }: GitBlameViewProps) { + const [hoveredLine, setHoveredLine] = useState(null); + + const blame = hoveredLine != null ? getBlameForLine(hoveredLine) : null; + + return ( +
+
+
+ +

Git Blame (GitLens Style)

+
+

+ Use onLineEnter and onLineLeave to track + hover state, then render blame info with{' '} + renderHoverUtility. Displays author, commit message, and + timestamp - just like GitLens in VS Code. +

+
+ +
+ { + setHoveredLine(lineNumber); + }, + onLineLeave: () => { + setHoveredLine(null); + }, + }} + renderHoverUtility={() => { + if (blame == null) return null; + + return ( +
+ {blame.author} +
+ + {blame.author} + + · + {blame.hash} + · + {blame.message} + · + {blame.date} +
+
+ ); + }} + /> +
+ +
+ +

+ The onLineEnter callback updates React state with the + hovered line number. This triggers a re-render, and{' '} + renderHoverUtility uses that state to look up blame data + and render an inline tooltip. In a real app, you'd fetch this from + your git provider's API. +

+
+
+ ); +} diff --git a/apps/docs/app/examples/git-blame/constants.ts b/apps/docs/app/examples/git-blame/constants.ts new file mode 100644 index 00000000..291e725b --- /dev/null +++ b/apps/docs/app/examples/git-blame/constants.ts @@ -0,0 +1,58 @@ +import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS'; +import { type FileContents, parseDiffFromFile } from '@pierre/diffs'; +import type { PreloadFileDiffOptions } from '@pierre/diffs/ssr'; + +const OLD_FILE: FileContents = { + name: 'config.json', + contents: `{ + "name": "my-app", + "version": "1.0.0", + "description": "A simple application", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test": "jest" + }, + "dependencies": { + "express": "^4.18.0" + } +} +`, +}; + +const NEW_FILE: FileContents = { + name: 'config.json', + contents: `{ + "name": "my-app", + "version": "2.0.0", + "description": "A modern web application", + "type": "module", + "main": "src/index.ts", + "scripts": { + "start": "tsx src/index.ts", + "dev": "tsx watch src/index.ts", + "build": "tsc", + "test": "vitest" + }, + "dependencies": { + "express": "^4.21.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "tsx": "^4.19.0", + "vitest": "^2.1.0" + } +} +`, +}; + +export const GIT_BLAME_EXAMPLE: PreloadFileDiffOptions = { + fileDiff: parseDiffFromFile(OLD_FILE, NEW_FILE), + options: { + theme: 'pierre-dark', + diffStyle: 'unified', + unsafeCSS: CustomScrollbarCSS, + enableHoverUtility: true, + }, +}; diff --git a/apps/docs/app/examples/hover-actions/HoverActions.tsx b/apps/docs/app/examples/hover-actions/HoverActions.tsx new file mode 100644 index 00000000..369333e0 --- /dev/null +++ b/apps/docs/app/examples/hover-actions/HoverActions.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { + IconBookmark, + IconBug, + IconComment, + IconCopyFill, +} from '@/components/icons'; +import type { AnnotationSide } from '@pierre/diffs'; +import { FileDiff } from '@pierre/diffs/react'; +import type { PreloadFileDiffResult } from '@pierre/diffs/ssr'; +import { useState } from 'react'; + +// ============================================================================= +// Main Component +// ============================================================================= + +interface HoverActionsProps { + prerenderedDiff: PreloadFileDiffResult; +} + +interface ToastMessage { + id: number; + text: string; + type: 'success' | 'info'; +} + +interface HoveredLine { + lineNumber: number; + side: AnnotationSide; +} + +export function HoverActions({ prerenderedDiff }: HoverActionsProps) { + const [toasts, setToasts] = useState([]); + const [nextId, setNextId] = useState(0); + const [hoveredLine, setHoveredLine] = useState(null); + + const addToast = (text: string, type: 'success' | 'info' = 'info') => { + const id = nextId; + setNextId((n) => n + 1); + setToasts((t) => [...t, { id, text, type }]); + setTimeout(() => { + setToasts((t) => t.filter((toast) => toast.id !== id)); + }, 2000); + }; + + const lineInfo = + hoveredLine != null + ? hoveredLine.side === 'additions' + ? `+${hoveredLine.lineNumber}` + : hoveredLine.side === 'deletions' + ? `-${hoveredLine.lineNumber}` + : `${hoveredLine.lineNumber}` + : ''; + + return ( +
+
+

Hover Actions

+

+ Use onLineEnter and onLineLeave callbacks to + track hover state, then render contextual actions with{' '} + renderHoverUtility. Perfect for code review workflows + with comment, bookmark, and bug report actions. +

+
+ +
+ { + setHoveredLine({ lineNumber, side: annotationSide }); + }, + onLineLeave: () => { + setHoveredLine(null); + }, + }} + renderHoverUtility={() => { + if (hoveredLine == null) return null; + + return ( +
+ } + label="Copy line" + onClick={() => addToast(`Copied line ${lineInfo}`, 'success')} + /> + } + label="Add comment" + onClick={() => + addToast(`Comment on line ${lineInfo}`, 'info') + } + /> + } + label="Bookmark" + onClick={() => + addToast(`Bookmarked line ${lineInfo}`, 'success') + } + /> + } + label="Report issue" + onClick={() => + addToast(`Bug report for line ${lineInfo}`, 'info') + } + /> +
+ ); + }} + /> + + {/* Toast container */} +
+ {toasts.map((toast) => ( +
+ {toast.text} +
+ ))} +
+
+
+ ); +} + +// ============================================================================= +// Action Button +// ============================================================================= + +function ActionButton({ + icon, + label, + onClick, +}: { + icon: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/docs/app/examples/hover-actions/constants.ts b/apps/docs/app/examples/hover-actions/constants.ts new file mode 100644 index 00000000..965231c2 --- /dev/null +++ b/apps/docs/app/examples/hover-actions/constants.ts @@ -0,0 +1,63 @@ +import { CustomScrollbarCSS } from '@/components/CustomScrollbarCSS'; +import { type FileContents, parseDiffFromFile } from '@pierre/diffs'; +import type { PreloadFileDiffOptions } from '@pierre/diffs/ssr'; + +const OLD_FILE: FileContents = { + name: 'api/users.ts', + contents: `export async function getUser(id: string) { + const response = await fetch(\`/api/users/\${id}\`); + return response.json(); +} + +export async function updateUser(id: string, data: any) { + const response = await fetch(\`/api/users/\${id}\`, { + method: 'PUT', + body: JSON.stringify(data), + }); + return response.json(); +} +`, +}; + +const NEW_FILE: FileContents = { + name: 'api/users.ts', + contents: `import { z } from 'zod'; + +const UserSchema = z.object({ + name: z.string().min(1), + email: z.string().email(), + role: z.enum(['admin', 'user', 'guest']), +}); + +export async function getUser(id: string) { + const response = await fetch(\`/api/users/\${id}\`); + if (!response.ok) { + throw new Error(\`Failed to fetch user: \${response.status}\`); + } + return response.json(); +} + +export async function updateUser(id: string, data: z.infer) { + const validated = UserSchema.parse(data); + const response = await fetch(\`/api/users/\${id}\`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(validated), + }); + if (!response.ok) { + throw new Error(\`Failed to update user: \${response.status}\`); + } + return response.json(); +} +`, +}; + +export const HOVER_ACTIONS_EXAMPLE: PreloadFileDiffOptions = { + fileDiff: parseDiffFromFile(OLD_FILE, NEW_FILE), + options: { + theme: 'pierre-dark', + diffStyle: 'unified', + unsafeCSS: CustomScrollbarCSS, + enableHoverUtility: true, + }, +}; diff --git a/apps/docs/app/examples/page.tsx b/apps/docs/app/examples/page.tsx new file mode 100644 index 00000000..ea055270 --- /dev/null +++ b/apps/docs/app/examples/page.tsx @@ -0,0 +1,74 @@ +import Footer from '@/components/Footer'; +import { preloadFileDiff } from '@pierre/diffs/ssr'; + +import { ExamplesLayout } from './ExamplesLayout'; +import { AICodeReview } from './ai-code-review/AICodeReview'; +import { AI_CODE_REVIEW_EXAMPLE } from './ai-code-review/constants'; +import { FullCustomHeader } from './custom-chrome/FullCustomHeader'; +import { FULL_CUSTOM_HEADER_EXAMPLE } from './custom-chrome/constants'; +import { GitBlameView } from './git-blame/GitBlameView'; +import { GIT_BLAME_EXAMPLE } from './git-blame/constants'; +import { HoverActions } from './hover-actions/HoverActions'; +import { HOVER_ACTIONS_EXAMPLE } from './hover-actions/constants'; +import { PRReview } from './pr-review/PRReview'; +import { PR_REVIEW_EXAMPLES } from './pr-review/constants'; +import { ThemeCarousel } from './theme-carousel/ThemeCarousel'; +import { THEME_CAROUSEL_EXAMPLE } from './theme-carousel/constants'; + +export default async function ExamplesPage() { + const [ + customHeaderDiff, + aiCodeReviewDiff, + gitBlameDiff, + themeCarouselDiff, + hoverActionsDiff, + ...prReviewDiffs + ] = await Promise.all([ + preloadFileDiff(FULL_CUSTOM_HEADER_EXAMPLE), + preloadFileDiff(AI_CODE_REVIEW_EXAMPLE), + preloadFileDiff(GIT_BLAME_EXAMPLE), + preloadFileDiff(THEME_CAROUSEL_EXAMPLE), + preloadFileDiff(HOVER_ACTIONS_EXAMPLE), + ...PR_REVIEW_EXAMPLES.map((ex) => preloadFileDiff(ex)), + ]); + + return ( +
+ +
+
+
+

+ A collection of examples showcasing how you can customize and + extend @pierre/diffs. +

+
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ ); +} diff --git a/apps/docs/app/examples/pr-review/PRReview.tsx b/apps/docs/app/examples/pr-review/PRReview.tsx new file mode 100644 index 00000000..d0f06323 --- /dev/null +++ b/apps/docs/app/examples/pr-review/PRReview.tsx @@ -0,0 +1,239 @@ +'use client'; + +import { + IconCheck, + IconComment, + IconInReview, + IconMergedFill, +} from '@/components/icons'; +import { FileDiff } from '@pierre/diffs/react'; +import type { PreloadFileDiffResult } from '@pierre/diffs/ssr'; +import { useState } from 'react'; + +import { DIFFS } from './constants'; + +// ============================================================================= +// File Status Badge +// ============================================================================= + +function StatusBadge({ status }: { status: 'modified' | 'added' | 'deleted' }) { + const colors = { + modified: 'bg-yellow-900/50 text-yellow-300 border-yellow-700', + added: 'bg-green-900/50 text-green-300 border-green-700', + deleted: 'bg-red-900/50 text-red-300 border-red-700', + }; + + return ( + + {status} + + ); +} + +// ============================================================================= +// Main Component +// ============================================================================= + +interface PRReviewProps { + prerenderedDiffs: PreloadFileDiffResult[]; +} + +export function PRReview({ prerenderedDiffs }: PRReviewProps) { + const [approvals, setApprovals] = useState>({}); + const [expandedFiles, setExpandedFiles] = useState>( + new Set(DIFFS.map((d) => d.diff.name)) + ); + const [comments, setComments] = useState>({}); + + const allApproved = + DIFFS.length > 0 && DIFFS.every((d) => approvals[d.diff.name]); + + const toggleExpanded = (name: string) => { + setExpandedFiles((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }; + + const toggleApproval = (name: string) => { + setApprovals((prev) => ({ ...prev, [name]: !prev[name] })); + }; + + const totalAdditions = DIFFS.reduce( + (acc, d) => + acc + d.diff.hunks.reduce((h, hunk) => h + hunk.additionLines, 0), + 0 + ); + const totalDeletions = DIFFS.reduce( + (acc, d) => + acc + d.diff.hunks.reduce((h, hunk) => h + hunk.deletionLines, 0), + 0 + ); + + return ( +
+
+
+ +

Pull Request Review

+
+

+ Build a complete PR review experience with file navigation, approval + tracking, and inline comments. Collapse files, approve changes, and + track review progress. +

+
+ + {/* PR Header */} +
+
+
+
+ {allApproved ? ( + + ) : ( + + )} +
+
+

+ feat: Add error handling to useAuth hook +

+

+ #42 opened by{' '} + @developer +

+
+
+ +
+ + +{totalAdditions} + + + -{totalDeletions} + + + {DIFFS.length} file{DIFFS.length !== 1 && 's'} + +
+
+ + {/* Files list */} +
+ {prerenderedDiffs.map((prerenderedDiff, idx) => { + const { diff, status } = DIFFS[idx]; + const isExpanded = expandedFiles.has(diff.name); + const isApproved = approvals[diff.name]; + const comment = comments[diff.name] ?? ''; + + return ( +
+ {/* File header */} +
+ +
+ + +{diff.hunks.reduce((a, h) => a + h.additionLines, 0)} + + + -{diff.hunks.reduce((a, h) => a + h.deletionLines, 0)} + + +
+
+ + {/* Expanded content */} + {isExpanded && ( +
+ + + {/* Comment box */} +
+
+ +