add custom right click

This commit is contained in:
Nikita 2026-09-23 16:29:19 +05:00
parent d52e6e59de
commit 71c8dce144
10 changed files with 705 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/resume-en.pdf Normal file

Binary file not shown.

BIN
public/resume-ru.pdf Normal file

Binary file not shown.

BIN
public/resume.pdf Normal file

Binary file not shown.

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { ContextMenu } from "@/widgets/context-menu";
import { CookieConsent } from "@/widgets/cookie-consent"; import { CookieConsent } from "@/widgets/cookie-consent";
import { Cursor } from "@/widgets/cursor"; import { Cursor } from "@/widgets/cursor";
import { Preloader } from "@/widgets/preloader"; import { Preloader } from "@/widgets/preloader";
@ -32,6 +33,7 @@ const RootLayout = ({ children }: LayoutProps<"/">) => {
<Preloader /> <Preloader />
<Cursor /> <Cursor />
<ScrollCounter /> <ScrollCounter />
<ContextMenu />
{children} {children}
<CookieConsent /> <CookieConsent />
<YandexMetrika /> <YandexMetrika />

View file

@ -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);
}

View file

@ -0,0 +1 @@
export { ContextMenu } from "./ui/ContextMenu";

View file

@ -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<Lang>("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<HTMLDivElement>(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 (
<div
ref={menuRef}
role="menu"
aria-label="Контекстное меню"
className={styles.menu}
style={{
left: `${renderCoords.x}px`,
top: `${renderCoords.y}px`,
}}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
{/* Header with name and language switcher */}
<div className={styles.header}>
<div className={styles.headerInfo}>
<div className={styles.headerTitleRow}>
<span className={styles.headerTitle}>{current.name}</span>
<span
className={styles.statusDot}
title={current.statusTitle}
aria-label={current.statusTitle}
/>
</div>
<span className={styles.headerSubtitle}>{current.role}</span>
</div>
<div className={styles.langToggle} role="group" aria-label="Язык / Language">
<button
type="button"
className={`${styles.langBtn} ${lang === "ru" ? styles.langBtnActive : ""}`}
onClick={(e) => {
e.stopPropagation();
handleSetLang("ru");
}}
data-cursor-hover
aria-pressed={lang === "ru"}
>
RU
</button>
<button
type="button"
className={`${styles.langBtn} ${lang === "en" ? styles.langBtnActive : ""}`}
onClick={(e) => {
e.stopPropagation();
handleSetLang("en");
}}
data-cursor-hover
aria-pressed={lang === "en"}
>
EN
</button>
</div>
</div>
<div className={styles.divider} />
{/* Main Download: Active language resume */}
<a
href={current.primaryPdf}
download={current.primaryFilename}
className={`${styles.item} ${styles.itemPrimary}`}
role="menuitem"
data-cursor-hover
onClick={handleActionClick}
>
<span className={styles.itemIcon} aria-hidden="true">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</span>
<span className={styles.itemLabel}>{current.primaryLabel}</span>
<span className={styles.itemBadge}>{current.primaryBadge}</span>
</a>
{/* Secondary Download: Alternate language resume */}
<a
href={current.secondaryPdf}
download={current.secondaryFilename}
className={styles.item}
role="menuitem"
data-cursor-hover
onClick={handleActionClick}
>
<span className={styles.itemIcon} aria-hidden="true">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</span>
<span className={styles.itemLabel}>{current.secondaryLabel}</span>
<span className={styles.itemBadge}>{current.secondaryBadge}</span>
</a>
{/* Preview PDF */}
<a
href={current.primaryPdf}
target="_blank"
rel="noopener noreferrer"
className={styles.item}
role="menuitem"
data-cursor-hover
onClick={handleActionClick}
>
<span className={styles.itemIcon} aria-hidden="true">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</span>
<span className={styles.itemLabel}>{current.previewLabel}</span>
<span className={styles.itemIcon} aria-hidden="true" style={{ width: 14, height: 14 }}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
</span>
</a>
<div className={styles.divider} />
{/* Copy link */}
<button
type="button"
className={styles.item}
role="menuitem"
data-cursor-hover
onClick={handleCopyLink}
>
<span className={styles.itemIcon} aria-hidden="true">
{copied ? (
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="#4ade80"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</span>
<span className={styles.itemLabel}>
{copied ? current.copiedLabel : current.copyLabel}
</span>
</button>
{/* Telegram */}
<a
href="https://t.me/autli"
target="_blank"
rel="noreferrer"
className={styles.item}
role="menuitem"
data-cursor-hover
onClick={handleActionClick}
>
<span className={styles.itemIcon} aria-hidden="true">
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="currentColor"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
</span>
<span className={styles.itemLabel}>{current.telegramLabel}</span>
<span className={styles.itemIcon} aria-hidden="true" style={{ width: 14, height: 14 }}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="7" y1="17" x2="17" y2="7" />
<polyline points="7 7 17 7 17 17" />
</svg>
</span>
</a>
<div className={styles.divider} />
<div className={styles.footerHint}>
<span>{current.footerMenu}</span>
<kbd className={styles.kbd}>{current.esc}</kbd>
</div>
</div>
);
};