From f699b9d151137d825ca50a50dfea7a051ef04441 Mon Sep 17 00:00:00 2001
From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com>
Date: Thu, 23 Apr 2026 18:43:54 +0300
Subject: [PATCH 1/3] Improve accessibility and SEO metadata baseline
---
next.config.ts | 3 +++
src/app/admin/layout.tsx | 6 +++++-
src/app/admin/login/layout.tsx | 17 +++++++++++++++++
src/app/admin/login/page.tsx | 9 ++++++---
src/components/layout/Layout.tsx | 5 ++++-
src/components/layout/layout.module.css | 21 +++++++++++++++++++++
6 files changed, 56 insertions(+), 5 deletions(-)
create mode 100644 src/app/admin/login/layout.tsx
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/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/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}
diff --git a/src/components/layout/layout.module.css b/src/components/layout/layout.module.css
index 721dd64..71b0b3d 100644
--- a/src/components/layout/layout.module.css
+++ b/src/components/layout/layout.module.css
@@ -4,6 +4,27 @@
flex-direction: column;
}
+.skipLink {
+ position: absolute;
+ left: 12px;
+ top: -40px;
+ z-index: 1200;
+ border: 1px solid var(--color-text-primary);
+ background: var(--color-base-background);
+ color: var(--color-text-primary);
+ padding: 8px 12px;
+ text-decoration: none;
+ font-size: 13px;
+ line-height: 1;
+ border-radius: var(--radius-1, 4px);
+}
+
+.skipLink:focus-visible {
+ top: 12px;
+ outline: 2px solid var(--color-text-primary);
+ outline-offset: 2px;
+}
+
.main {
flex: 1;
padding: 0 0 var(--space-12);
From fa0768d09552a463b06280062b62495a3822634f Mon Sep 17 00:00:00 2001
From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com>
Date: Sun, 10 May 2026 21:03:55 +0300
Subject: [PATCH 2/3] Update social media links in navbar and footer
---
cms-extract/src/lib/api-response 2.ts | 33 ++
cms-extract/src/lib/github-api 2.ts | 83 ++++
cms-extract/src/lib/svg-upload 2.ts | 133 ++++++
package-lock.json | 82 ++++
package.json | 2 +
public/cases/rzd/screen-05 2.png | Bin 0 -> 1329295 bytes
src/components/home/HomePage.tsx | 7 +-
src/components/layout/Header.tsx | 18 +-
.../cases/my-perfect-greek-vacation.json | 404 ++----------------
src/content/home.json | 6 +-
.../case-content-validation.test 2.ts | 96 +++++
src/lib/__tests__/github-api.test 2.ts | 51 +++
src/lib/__tests__/svg-upload.test 2.ts | 34 ++
src/lib/api-response 2.ts | 54 +++
src/lib/case-content-validation 2.ts | 328 ++++++++++++++
src/lib/github-api 2.ts | 83 ++++
src/lib/svg-upload 2.ts | 133 ++++++
17 files changed, 1178 insertions(+), 369 deletions(-)
create mode 100644 cms-extract/src/lib/api-response 2.ts
create mode 100644 cms-extract/src/lib/github-api 2.ts
create mode 100644 cms-extract/src/lib/svg-upload 2.ts
create mode 100644 public/cases/rzd/screen-05 2.png
create mode 100644 src/lib/__tests__/case-content-validation.test 2.ts
create mode 100644 src/lib/__tests__/github-api.test 2.ts
create mode 100644 src/lib/__tests__/svg-upload.test 2.ts
create mode 100644 src/lib/api-response 2.ts
create mode 100644 src/lib/case-content-validation 2.ts
create mode 100644 src/lib/github-api 2.ts
create mode 100644 src/lib/svg-upload 2.ts
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