diff --git a/cms-extract/src/lib/api-response 2.ts b/cms-extract/src/lib/api-response 2.ts new file mode 100644 index 0000000..d8f9ef5 --- /dev/null +++ b/cms-extract/src/lib/api-response 2.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; + +export function apiError( + status: number, + code: string, + message: string, + details?: unknown +) { + return NextResponse.json( + { + ok: false, + error: { + code, + message, + details, + }, + }, + { status } + ); +} + +export function apiSuccess>( + data: T, + status = 200 +) { + return NextResponse.json( + { + ok: true, + ...data, + }, + { status } + ); +} diff --git a/cms-extract/src/lib/github-api 2.ts b/cms-extract/src/lib/github-api 2.ts new file mode 100644 index 0000000..f6fd63d --- /dev/null +++ b/cms-extract/src/lib/github-api 2.ts @@ -0,0 +1,83 @@ +const DEFAULT_RETRY_ATTEMPTS = 3; +const DEFAULT_RETRY_BASE_DELAY_MS = 300; +const DEFAULT_REQUEST_TIMEOUT_MS = 10_000; + +const RETRYABLE_STATUS_CODES = new Set([408, 409, 425, 429, 500, 502, 503, 504]); + +export interface GitHubRetryOptions { + attempts?: number; + baseDelayMs?: number; + timeoutMs?: number; +} + +export async function fetchGitHubWithRetry( + url: string, + init: RequestInit, + options: GitHubRetryOptions = {} +): Promise { + const attempts = Math.max(1, options.attempts ?? DEFAULT_RETRY_ATTEMPTS); + const baseDelayMs = Math.max(0, options.baseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS); + const timeoutMs = Math.max(1, options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS); + + let lastError: unknown; + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { + ...init, + signal: controller.signal, + }); + clearTimeout(timeout); + + if (!shouldRetryStatus(response.status) || attempt === attempts) { + return response; + } + + await wait(getRetryDelayMs(response, baseDelayMs, attempt)); + } catch (error) { + clearTimeout(timeout); + lastError = error; + + if (attempt === attempts) { + break; + } + + await wait(baseDelayMs * 2 ** (attempt - 1)); + } + } + + if (lastError instanceof Error) { + throw lastError; + } + + throw new Error("GitHub request failed after retries"); +} + +function shouldRetryStatus(status: number): boolean { + return RETRYABLE_STATUS_CODES.has(status); +} + +function getRetryDelayMs( + response: Response, + baseDelayMs: number, + attempt: number +): number { + const retryAfter = response.headers.get("retry-after"); + if (!retryAfter) { + return baseDelayMs * 2 ** (attempt - 1); + } + + const seconds = Number.parseInt(retryAfter, 10); + if (Number.isFinite(seconds) && seconds >= 0) { + return seconds * 1000; + } + + return baseDelayMs * 2 ** (attempt - 1); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/cms-extract/src/lib/svg-upload 2.ts b/cms-extract/src/lib/svg-upload 2.ts new file mode 100644 index 0000000..597d709 --- /dev/null +++ b/cms-extract/src/lib/svg-upload 2.ts @@ -0,0 +1,133 @@ +import { optimize, type Config } from "svgo"; + +export const GITHUB_FILE_WARNING_BYTES = 50 * 1024 * 1024; // 50 MiB +export const PLATFORM_MAX_FILE_BYTES = 100 * 1024 * 1024; // GitHub/Vercel hard cap +export const DEFAULT_SVG_TARGET_BYTES = 2 * 1024 * 1024; // CMS budget + +const DANGEROUS_SVG_PATTERN = + /<\s*script\b|<\s*foreignObject\b|\son[a-z]+\s*=|(?:xlink:)?href\s*=\s*["'][^"']*javascript:| PLATFORM_MAX_FILE_BYTES) { + throw new SvgUploadError( + `SVG is too large (${formatBytes(originalBytes)}). Max allowed is ${formatBytes(PLATFORM_MAX_FILE_BYTES)}.`, + 413 + ); + } + + assertSafeSvg(svgInput); + + let optimized = runSvgo(svgInput, buildBaseConfig()); + let usedAggressivePass = false; + + if (Buffer.byteLength(optimized, "utf-8") > targetBytes) { + optimized = runSvgo(optimized, buildAggressiveConfig()); + usedAggressivePass = true; + } + + assertSafeSvg(optimized); + + const optimizedBytes = Buffer.byteLength(optimized, "utf-8"); + if (optimizedBytes > PLATFORM_MAX_FILE_BYTES) { + throw new SvgUploadError( + `Optimized SVG is too large (${formatBytes(optimizedBytes)}). Max allowed is ${formatBytes(PLATFORM_MAX_FILE_BYTES)}.`, + 413 + ); + } + + return { + content: optimized, + originalBytes, + optimizedBytes, + usedAggressivePass, + }; +} + +function assertSafeSvg(svgContent: string): void { + if (!/<\s*svg\b/i.test(svgContent)) { + throw new SvgUploadError("Invalid SVG: root tag is missing."); + } + + if (DANGEROUS_SVG_PATTERN.test(svgContent)) { + throw new SvgUploadError( + "Unsafe SVG detected. Remove scripts, foreignObject, inline handlers, javascript: URLs, and DTD entities." + ); + } +} + +function runSvgo(input: string, config: Config): string { + const result = optimize(input, config); + return result.data; +} + +function buildBaseConfig(): Config { + return { + multipass: true, + js2svg: { indent: 0, pretty: false }, + plugins: [ + { + name: "preset-default", + }, + ], + }; +} + +function buildAggressiveConfig(): Config { + return { + multipass: true, + js2svg: { indent: 0, pretty: false }, + plugins: [ + { + name: "preset-default", + params: { + overrides: { + cleanupNumericValues: { floatPrecision: 2 }, + convertPathData: { floatPrecision: 2 }, + }, + }, + }, + "removeDimensions", + "sortAttrs", + ], + }; +} + +function formatBytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`; +} diff --git a/jest.config.js b/jest.config.js index 2d0ae47..bb86ab2 100644 --- a/jest.config.js +++ b/jest.config.js @@ -12,6 +12,8 @@ const customJestConfig = { moduleNameMapper: { // Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths) '^@/(.*)$': '/src/$1', + // Stub out heavy icon bundle that exceeds jest-runtime buffer limits + '^@gravity-ui/icons$': '/src/__mocks__/@gravity-ui/icons.js', }, testEnvironment: 'jest-environment-jsdom', } diff --git a/next.config.ts b/next.config.ts index 0b9586c..e6648f3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + turbopack: { + root: process.cwd(), + }, // Add caching headers for better performance async headers() { return [ diff --git a/package-lock.json b/package-lock.json index ecee3b8..f0f00f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "@gravity-ui/uikit": "^7.35.2", "@keystatic/core": "^0.5.50", "@keystatic/next": "^5.0.4", + "@vercel/analytics": "^2.0.1", + "@vercel/speed-insights": "^2.0.0", "next": "16.2.1", "react": "19.2.4", "react-dom": "19.2.4", @@ -6081,6 +6083,86 @@ "@urql/core": "^5.0.0" } }, + "node_modules/@vercel/analytics": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", + "integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==", + "license": "MIT", + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "nuxt": ">= 3", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, + "node_modules/@vercel/speed-insights": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@vercel/speed-insights/-/speed-insights-2.0.0.tgz", + "integrity": "sha512-jwkNcrTeafWxjmWq4AHBaptSqZiJkYU5adLC9QBSqeim0GcqDMgN5Ievh8OG1rJ6W3A4l1oiP7qr9CWxGuzu3w==", + "license": "Apache-2.0", + "peerDependencies": { + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "nuxt": ">= 3", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", diff --git a/package.json b/package.json index e6e67e7..cdabe90 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@gravity-ui/uikit": "^7.35.2", "@keystatic/core": "^0.5.50", "@keystatic/next": "^5.0.4", + "@vercel/analytics": "^2.0.1", + "@vercel/speed-insights": "^2.0.0", "next": "16.2.1", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/public/cases/rzd/screen-05 2.png b/public/cases/rzd/screen-05 2.png new file mode 100644 index 0000000..a60b500 Binary files /dev/null and b/public/cases/rzd/screen-05 2.png differ diff --git a/src/__mocks__/@gravity-ui/icons.js b/src/__mocks__/@gravity-ui/icons.js new file mode 100644 index 0000000..4bb0df5 --- /dev/null +++ b/src/__mocks__/@gravity-ui/icons.js @@ -0,0 +1,13 @@ +// Lightweight stub – the real bundle is too large for jest-runtime's readFileBuffer. +// Returns a Proxy so any named icon import resolves to a no-op React component. +const handler = { + get(_target, prop) { + if (prop === '__esModule') return true; + // Return a minimal functional component for every icon + const Icon = () => null; + Icon.displayName = prop; + return Icon; + }, +}; + +module.exports = new Proxy({}, handler); diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index e9e0bbc..bd5f442 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,11 +1,15 @@ import type { Metadata } from "next"; export const metadata: Metadata = { - title: "Admin", + title: "CMS — Dima Ginzburg", + description: "Internal content management area.", robots: { index: false, follow: false, }, + alternates: { + canonical: "/admin", + }, }; type AdminLayoutProps = { diff --git a/src/app/admin/login/layout.tsx b/src/app/admin/login/layout.tsx new file mode 100644 index 0000000..b649bfd --- /dev/null +++ b/src/app/admin/login/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "CMS Login — Dima Ginzburg", + description: "Internal CMS login page.", + robots: { + index: false, + follow: false, + }, + alternates: { + canonical: "/admin/login", + }, +}; + +export default function AdminLoginLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx index 9b3476b..0b54178 100644 --- a/src/app/admin/login/page.tsx +++ b/src/app/admin/login/page.tsx @@ -1,7 +1,6 @@ "use client"; -import { useState } from "react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import styles from "./login.module.css"; @@ -102,7 +101,11 @@ export default function AdminLoginPage() { - {error ?

{error}

: null} + {error ? ( +

+ {error} +

+ ) : null} diff --git a/src/components/home/HomePage.tsx b/src/components/home/HomePage.tsx index 67b8563..a73ba95 100644 --- a/src/components/home/HomePage.tsx +++ b/src/components/home/HomePage.tsx @@ -56,12 +56,11 @@ export default function HomePage({ data, featuredCases }: HomePageProps) { return (
-

- Portfolio -

-

Portfolio

+

+ Portfolio +

{data.hero.headline}

diff --git a/src/components/home/__tests__/HomePage.test.tsx b/src/components/home/__tests__/HomePage.test.tsx index 1330b68..0279da9 100644 --- a/src/components/home/__tests__/HomePage.test.tsx +++ b/src/components/home/__tests__/HomePage.test.tsx @@ -118,8 +118,6 @@ describe('HomePage', () => { subtitle: 'Featured subtitle 1', coverSrc: '/featured1.png', coverAlt: 'Featured case 1', - facts: [], - sections: [], }, { slug: 'case-2', @@ -127,8 +125,6 @@ describe('HomePage', () => { subtitle: 'Featured subtitle 2', coverSrc: '/featured2.png', coverAlt: 'Featured case 2', - facts: [], - sections: [], }, ]; @@ -147,8 +143,6 @@ describe('HomePage', () => { subtitle: 'Valid subtitle', coverSrc: '/valid.png', coverAlt: 'Valid case', - facts: [], - sections: [], }, // Missing title - should be filtered out { @@ -157,8 +151,6 @@ describe('HomePage', () => { subtitle: 'Invalid subtitle', coverSrc: '/invalid.png', coverAlt: 'Invalid case', - facts: [], - sections: [], }, // Missing slug - should be filtered out { @@ -167,8 +159,6 @@ describe('HomePage', () => { subtitle: 'Another invalid subtitle', coverSrc: '/invalid2.png', coverAlt: 'Another invalid case', - facts: [], - sections: [], }, // Missing optional properties - should work with defaults { @@ -177,8 +167,6 @@ describe('HomePage', () => { subtitle: '', coverSrc: '', coverAlt: '', - facts: [], - sections: [], }, ]; @@ -210,8 +198,6 @@ describe('HomePage', () => { subtitle: `Subtitle ${i}`, coverSrc: `/case${i}.png`, coverAlt: `Case ${i}`, - facts: [], - sections: [], })); render(); diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index cdcb600..6fe4614 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -31,12 +31,28 @@ export default function Header() { + github + + linkedin + + twitter + connect diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 31a1989..e9dae93 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -15,8 +15,11 @@ type LayoutProps = { export default function Layout({ children }: LayoutProps) { return (
+ + Skip to main content +
-
+
{children}