diff --git a/public/Nikita_Egorov_Resume.pdf b/public/Nikita_Egorov_Resume.pdf new file mode 100644 index 0000000..84cf2d0 Binary files /dev/null and b/public/Nikita_Egorov_Resume.pdf differ diff --git a/public/Nikita_Egorov_Resume_EN.pdf b/public/Nikita_Egorov_Resume_EN.pdf new file mode 100644 index 0000000..75d3291 Binary files /dev/null and b/public/Nikita_Egorov_Resume_EN.pdf differ diff --git a/public/Nikita_Egorov_Resume_RU.pdf b/public/Nikita_Egorov_Resume_RU.pdf new file mode 100644 index 0000000..84cf2d0 Binary files /dev/null and b/public/Nikita_Egorov_Resume_RU.pdf differ diff --git a/public/resume-en.pdf b/public/resume-en.pdf new file mode 100644 index 0000000..75d3291 Binary files /dev/null and b/public/resume-en.pdf differ diff --git a/public/resume-ru.pdf b/public/resume-ru.pdf new file mode 100644 index 0000000..84cf2d0 Binary files /dev/null and b/public/resume-ru.pdf differ diff --git a/public/resume.pdf b/public/resume.pdf new file mode 100644 index 0000000..84cf2d0 Binary files /dev/null and b/public/resume.pdf differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e54a1c3..ed03c65 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; +import { ContextMenu } from "@/widgets/context-menu"; import { CookieConsent } from "@/widgets/cookie-consent"; import { Cursor } from "@/widgets/cursor"; import { Preloader } from "@/widgets/preloader"; @@ -32,6 +33,7 @@ const RootLayout = ({ children }: LayoutProps<"/">) => { + {children} diff --git a/src/static/context-menu/context-menu.module.css b/src/static/context-menu/context-menu.module.css new file mode 100644 index 0000000..20296d1 --- /dev/null +++ b/src/static/context-menu/context-menu.module.css @@ -0,0 +1,221 @@ +.menu { + position: fixed; + z-index: 9990; + + display: flex; + flex-direction: column; + min-width: 265px; + max-width: calc(100vw - 24px); + padding: 8px; + + background: rgba(14, 14, 16, 0.92); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 16px; + box-shadow: + 0 20px 48px rgba(0, 0, 0, 0.7), + 0 0 0 1px rgba(255, 255, 255, 0.04); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + + color: #fff; + user-select: none; + -webkit-user-select: none; + + animation: menuIn 0.16s cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} + +@keyframes menuIn { + from { + opacity: 0; + transform: scale(0.96) translateY(-4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px 10px; +} + +.headerInfo { + display: flex; + flex-direction: column; + gap: 3px; +} + +.headerTitleRow { + display: flex; + align-items: center; + gap: 6px; +} + +.headerTitle { + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; + color: #fff; + line-height: 1.2; +} + +.headerSubtitle { + font-size: 11px; + color: rgba(255, 255, 255, 0.45); + line-height: 1.2; +} + +.langToggle { + display: inline-flex; + align-items: center; + padding: 2px; + background: rgba(255, 255, 255, 0.07); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + gap: 2px; + flex-shrink: 0; +} + +.langBtn { + padding: 3px 7px; + font-family: inherit; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + color: rgba(255, 255, 255, 0.55); + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + line-height: 1; + transition: + background 0.15s ease, + color 0.15s ease; +} + +.langBtn:hover { + color: #000; + background: rgba(255, 255, 255, 0.88); +} + +.langBtnActive, +.langBtnActive:hover { + color: #000 !important; + background: #fff !important; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.statusDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 6px #4ade80; + flex-shrink: 0; +} + +.divider { + height: 1px; + margin: 4px 2px; + background: rgba(255, 255, 255, 0.08); +} + +.item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + + border: none; + border-radius: 10px; + background: transparent; + color: rgba(255, 255, 255, 0.88); + + font-family: inherit; + font-size: 13px; + font-weight: 500; + line-height: 1; + text-decoration: none; + text-align: left; + cursor: pointer; + + transition: + background 0.15s ease, + color 0.15s ease, + transform 0.12s ease; +} + +.item:hover { + background: rgba(255, 255, 255, 0.09); + color: #fff; +} + +.item:active { + background: rgba(255, 255, 255, 0.14); + transform: scale(0.99); +} + +.itemPrimary { + background: rgba(255, 255, 255, 0.05); + color: #fff; + font-weight: 600; +} + +.itemPrimary:hover { + background: rgba(255, 255, 255, 0.14); +} + +.itemIcon { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + color: rgba(255, 255, 255, 0.7); +} + +.item:hover .itemIcon { + color: #fff; +} + +.itemLabel { + flex: 1; + white-space: nowrap; +} + +.itemBadge { + padding: 2px 6px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.03em; + color: #fff; + background: rgba(255, 255, 255, 0.12); + border-radius: 6px; +} + +.footerHint { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px 4px; + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + letter-spacing: 0.02em; +} + +.kbd { + padding: 1px 5px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + font-family: inherit; + font-size: 9px; + color: rgba(255, 255, 255, 0.5); +} diff --git a/src/widgets/context-menu/index.ts b/src/widgets/context-menu/index.ts new file mode 100644 index 0000000..2d88031 --- /dev/null +++ b/src/widgets/context-menu/index.ts @@ -0,0 +1 @@ +export { ContextMenu } from "./ui/ContextMenu"; diff --git a/src/widgets/context-menu/ui/ContextMenu.tsx b/src/widgets/context-menu/ui/ContextMenu.tsx new file mode 100644 index 0000000..2fb23ee --- /dev/null +++ b/src/widgets/context-menu/ui/ContextMenu.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import styles from "@/static/context-menu/context-menu.module.css"; + +const PADDING = 12; +const ESTIMATED_WIDTH = 275; +const ESTIMATED_HEIGHT = 345; + +type Lang = "ru" | "en"; + +const content = { + ru: { + name: "Никита Егоров", + role: "Frontend & Web Developer", + statusTitle: "Открыт к предложениям", + primaryPdf: "/Nikita_Egorov_Resume_RU.pdf", + primaryFilename: "Nikita_Egorov_Resume_RU.pdf", + primaryLabel: "Скачать резюме (RU)", + primaryBadge: "RU", + secondaryPdf: "/Nikita_Egorov_Resume_EN.pdf", + secondaryFilename: "Nikita_Egorov_Resume_EN.pdf", + secondaryLabel: "Скачать резюме (EN)", + secondaryBadge: "EN", + previewLabel: "Открыть в новой вкладке", + copyLabel: "Скопировать ссылку", + copiedLabel: "Ссылка скопирована!", + telegramLabel: "Написать в Telegram", + footerMenu: "Меню сайта", + esc: "Esc", + }, + en: { + name: "Nikita Egorov", + role: "Frontend & Web Developer", + statusTitle: "Available for hire", + primaryPdf: "/Nikita_Egorov_Resume_EN.pdf", + primaryFilename: "Nikita_Egorov_Resume_EN.pdf", + primaryLabel: "Download Resume (EN)", + primaryBadge: "EN", + secondaryPdf: "/Nikita_Egorov_Resume_RU.pdf", + secondaryFilename: "Nikita_Egorov_Resume_RU.pdf", + secondaryLabel: "Download Resume (RU)", + secondaryBadge: "RU", + previewLabel: "Open in new tab", + copyLabel: "Copy website link", + copiedLabel: "Link copied!", + telegramLabel: "Message on Telegram", + footerMenu: "Site menu", + esc: "Esc", + }, +}; + +export const ContextMenu = () => { + const [isOpen, setIsOpen] = useState(false); + const [lang, setLang] = useState("ru"); + const [coords, setCoords] = useState({ x: 0, y: 0 }); + const [renderCoords, setRenderCoords] = useState({ x: 0, y: 0 }); + const [copied, setCopied] = useState(false); + const menuRef = useRef(null); + + + const handleSetLang = (newLang: Lang) => { + setLang(newLang); + try { + localStorage.setItem("portfolio_lang", newLang); + } catch { + // ignore + } + }; + + // Synchronous layout adjustment with actual DOM dimensions + useLayoutEffect(() => { + if (!isOpen || !menuRef.current) return; + + const rect = menuRef.current.getBoundingClientRect(); + const width = rect.width || ESTIMATED_WIDTH; + const height = rect.height || ESTIMATED_HEIGHT; + + let nextX = Math.max(PADDING, coords.x); + let nextY = Math.max(PADDING, coords.y); + + if (nextX + width > window.innerWidth - PADDING) { + nextX = Math.max(PADDING, window.innerWidth - width - PADDING); + } + if (nextY + height > window.innerHeight - PADDING) { + nextY = Math.max(PADDING, window.innerHeight - height - PADDING); + } + + setRenderCoords({ x: Math.round(nextX), y: Math.round(nextY) }); + }, [isOpen, coords, lang]); + + // Contextmenu listener + useEffect(() => { + const onContextMenu = (e: MouseEvent) => { + // 1. Allow native menu if Shift key is pressed (developer/power user override) + if (e.shiftKey) { + setIsOpen(false); + return; + } + + // 2. If user right clicks on open menu, prevent default and keep it + if ( + menuRef.current && + e.target instanceof Node && + menuRef.current.contains(e.target) + ) { + e.preventDefault(); + return; + } + + // 3. Allow native menu on text inputs, textareas and editable elements + const target = e.target; + if ( + target instanceof Element && + target.closest("input, textarea, [contenteditable='true'], select") + ) { + setIsOpen(false); + return; + } + + // 4. Allow native menu if user has selected text on the page (for native Copy) + const selection = window.getSelection(); + if (selection && selection.toString().trim().length > 0) { + setIsOpen(false); + return; + } + + // Intercept browser context menu + e.preventDefault(); + + let targetX = Math.max(PADDING, e.clientX); + let targetY = Math.max(PADDING, e.clientY); + + // Initial clamp with estimated dimensions to avoid frame jump + if (targetX + ESTIMATED_WIDTH > window.innerWidth - PADDING) { + targetX = Math.max(PADDING, window.innerWidth - ESTIMATED_WIDTH - PADDING); + } + if (targetY + ESTIMATED_HEIGHT > window.innerHeight - PADDING) { + targetY = Math.max(PADDING, window.innerHeight - ESTIMATED_HEIGHT - PADDING); + } + + try { + const saved = localStorage.getItem("portfolio_lang") as Lang | null; + if (saved === "ru" || saved === "en") { + setLang(saved); + } + } catch { + // ignore + } + + setCoords({ x: e.clientX, y: e.clientY }); + setRenderCoords({ x: Math.round(targetX), y: Math.round(targetY) }); + setCopied(false); + setIsOpen(true); + }; + + window.addEventListener("contextmenu", onContextMenu); + return () => { + window.removeEventListener("contextmenu", onContextMenu); + }; + }, []); + + // Close handlers: Escape, outside click, scroll, resize, window blur + useEffect(() => { + if (!isOpen) return; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setIsOpen(false); + } + }; + + const onPointerDown = (e: PointerEvent) => { + if ( + menuRef.current && + e.target instanceof Node && + !menuRef.current.contains(e.target) + ) { + setIsOpen(false); + } + }; + + const onScroll = () => { + setIsOpen(false); + }; + + const onResize = () => { + setIsOpen(false); + }; + + const onBlur = () => { + setIsOpen(false); + }; + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("pointerdown", onPointerDown); + window.addEventListener("scroll", onScroll, { capture: true, passive: true }); + window.addEventListener("resize", onResize, { passive: true }); + window.addEventListener("blur", onBlur); + + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("scroll", onScroll, { capture: true }); + window.removeEventListener("resize", onResize); + window.removeEventListener("blur", onBlur); + }; + }, [isOpen]); + + const handleCopyLink = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(window.location.href); + setCopied(true); + setTimeout(() => { + setCopied(false); + setIsOpen(false); + }, 700); + } catch { + setIsOpen(false); + } + }; + + const handleActionClick = () => { + setIsOpen(false); + }; + + if (!isOpen) return null; + + const current = content[lang]; + + return ( +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + {/* Header with name and language switcher */} +
+
+
+ {current.name} + +
+ {current.role} +
+ +
+ + +
+
+ +
+ + {/* Main Download: Active language resume */} + + + {current.primaryLabel} + {current.primaryBadge} + + + {/* Secondary Download: Alternate language resume */} + + + {current.secondaryLabel} + {current.secondaryBadge} + + + {/* Preview PDF */} + + + {current.previewLabel} + + + +
+ + {/* Copy link */} + + + {/* Telegram */} + + + {current.telegramLabel} + + + +
+ +
+ {current.footerMenu} + {current.esc} +
+
+ ); +};