mirror of
https://github.com/XFox111/my-website.git
synced 2026-04-22 07:28:01 +03:00
init: First version
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
@import "../theme.scss";
|
||||
|
||||
.button
|
||||
{
|
||||
@include formBase;
|
||||
|
||||
cursor: pointer;
|
||||
gap: $spacingSNudge;
|
||||
justify-content: center;
|
||||
text-align: left;
|
||||
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
&.iconBefore > :first-child,
|
||||
&.iconAfter > :last-child
|
||||
{
|
||||
font-size: $fontSizeBase600;
|
||||
height: $fontSizeBase600;
|
||||
width: $fontSizeBase600;
|
||||
}
|
||||
|
||||
&:not(.content)
|
||||
{
|
||||
min-width: 40px;
|
||||
padding: $spacingXS;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.secondary
|
||||
{
|
||||
border-color: transparent;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible
|
||||
{
|
||||
border: $strokeWidthThin solid $colorNeutralForeground1;
|
||||
color: $colorNeutralForeground1;
|
||||
}
|
||||
}
|
||||
|
||||
&.primary
|
||||
{
|
||||
background-image: linear-gradient($colorNeutralBackgroundInverted, $colorNeutralBackgroundInverted);
|
||||
|
||||
&:not(:disabled, [disabled])
|
||||
{
|
||||
|
||||
&:hover,
|
||||
&:focus-visible
|
||||
{
|
||||
color: $colorNeutralForegroundInverted;
|
||||
|
||||
&:active
|
||||
{
|
||||
background-image: linear-gradient($colorNeutralForeground3Pressed, $colorNeutralForeground3Pressed);
|
||||
color: $colorNeutralForegroundInverted2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link, { LinkProps } from "next/link";
|
||||
import React, { useMemo } from "react";
|
||||
import cls from "./Button.module.scss";
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
as = "button",
|
||||
iconAfter,
|
||||
icon,
|
||||
appearance = "primary",
|
||||
children,
|
||||
...props
|
||||
}) =>
|
||||
{
|
||||
const Component = as === "button" && !props.href ?
|
||||
"button" :
|
||||
as === "next" ?
|
||||
Link : "a";
|
||||
|
||||
const classList: string = useMemo(() =>
|
||||
{
|
||||
const list: string[] = [ cls.button, cls[appearance] ];
|
||||
|
||||
// We need these classes to differentiate content in CSS
|
||||
if (icon)
|
||||
list.push(cls.iconBefore);
|
||||
if (iconAfter)
|
||||
list.push(cls.iconAfter);
|
||||
if (children)
|
||||
list.push(cls.content);
|
||||
|
||||
if (props.className)
|
||||
list.push(props.className);
|
||||
|
||||
return list.join(" ");
|
||||
}, [appearance, children, icon, iconAfter, props.className]);
|
||||
|
||||
return (
|
||||
<Component { ...props as any } className={ classList }>
|
||||
{ icon }
|
||||
{ children }
|
||||
{ iconAfter }
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
||||
// Since we want to render button as both "a" and "button" (depending on the props) we do a little trick here
|
||||
// Shorthand types
|
||||
type HtmlButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "disabled">;
|
||||
type HtmlAnchorProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "type">;
|
||||
type NextLinkProps = Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof LinkProps> & LinkProps & {
|
||||
children?: React.ReactNode;
|
||||
} & React.RefAttributes<HTMLAnchorElement>;
|
||||
|
||||
type ButtonOrAnchorProps =
|
||||
| ({ as?: "a"; href?: string; } & HtmlAnchorProps) // If href is present, it must be an <a>
|
||||
| ({ as?: "button"; href?: undefined; } & HtmlButtonProps) // If href is absent, it is a <button>
|
||||
| ({ as: "next"; } & NextLinkProps);
|
||||
|
||||
// Extend the common props
|
||||
type CommonProps =
|
||||
{
|
||||
appearance?: "primary" | "secondary";
|
||||
|
||||
iconAfter?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export type ButtonProps = ButtonOrAnchorProps & CommonProps;
|
||||
@@ -0,0 +1,81 @@
|
||||
@import "../theme.scss";
|
||||
|
||||
.footer
|
||||
{
|
||||
@include flex(row);
|
||||
@include align(flex-end, space-between);
|
||||
|
||||
.info
|
||||
{
|
||||
@include caption1;
|
||||
@include flex(column);
|
||||
gap: $spacingS;
|
||||
padding: $spacingM;
|
||||
color: $colorNeutralForeground2;
|
||||
|
||||
.copyright
|
||||
{
|
||||
@include body2($fontFamilyBaseAlt);
|
||||
color: $colorNeutralForeground1;
|
||||
}
|
||||
|
||||
.nextjs
|
||||
{
|
||||
@include flex(row);
|
||||
gap: $spacingXS;
|
||||
align-items: flex-end;
|
||||
|
||||
> a
|
||||
{
|
||||
padding: $spacingXXS;
|
||||
padding-bottom: $spacingNone;
|
||||
|
||||
> img
|
||||
{
|
||||
height: 16px;
|
||||
width: auto;
|
||||
transition: filter $durationFast $curveEasyEaseMax;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible
|
||||
{
|
||||
> img
|
||||
{
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.illustration
|
||||
{
|
||||
@include flex(column);
|
||||
|
||||
justify-content: flex-end;
|
||||
align-self: flex-end;
|
||||
height: 100px;
|
||||
max-width: 100%;
|
||||
|
||||
z-index: -1;
|
||||
|
||||
> img
|
||||
{
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
max-height: 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1024px)
|
||||
{
|
||||
flex-flow: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.info
|
||||
{
|
||||
margin-right: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import Package from "@/../package.json";
|
||||
import { footerImage, nextjsLogo } from "@/_assets/illustrations";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import cls from "./Footer.module.scss";
|
||||
|
||||
const Footer: React.FC = () => (
|
||||
<footer className={ cls.footer }>
|
||||
<div className={ cls.info }>
|
||||
<p className={ cls.copyright }>
|
||||
{ `©${new Date().getFullYear()} ${Package.author.name}` }
|
||||
</p>
|
||||
<p>
|
||||
This site was created with help of some third-party tools and services.
|
||||
</p>
|
||||
{ process.env.CLARITY_ID &&
|
||||
<p>
|
||||
This site is using Microsoft Clarity for analytics purposes.<br aria-hidden />
|
||||
By using this site you agree that we and Microsoft can collect and use this data.
|
||||
</p>
|
||||
}
|
||||
<p>
|
||||
<Link href="/attribution" prefetch={ false }>
|
||||
See license disclosure and privacy policy for more information
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className={ cls.nextjs }>
|
||||
<span aria-hidden>Built with</span>
|
||||
<a aria-label="Built with Next.js" href="https://nextjs.org/" target="_blank">
|
||||
<Image aria-hidden src={ nextjsLogo.src } alt={ nextjsLogo.alt } />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={ `${cls.illustration} illustration` }>
|
||||
<Image src={ footerImage.src } alt={ footerImage.alt } />
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,55 @@
|
||||
@import "../theme.scss";
|
||||
|
||||
.header
|
||||
{
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
|
||||
@include flex(column);
|
||||
|
||||
background-color: $colorNeutralBackground1;
|
||||
padding: $spacingS $spacingM;
|
||||
|
||||
// Header container
|
||||
> div
|
||||
{
|
||||
@include maxCenter;
|
||||
@include flex(row);
|
||||
@include align(center, space-between);
|
||||
|
||||
gap: $spacingM;
|
||||
|
||||
.navigation
|
||||
{
|
||||
@media screen and (max-width: 1040px)
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.socials
|
||||
{
|
||||
flex-grow: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sidemenu
|
||||
{
|
||||
@media screen and (min-width: 1041px)
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px)
|
||||
{
|
||||
.socials,
|
||||
.resume
|
||||
{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import TitleLogo from "@/_data/TitleLogo";
|
||||
import links from "@/_data/links";
|
||||
import React from "react";
|
||||
import Button from "./Button";
|
||||
import cls from "./Header.module.scss";
|
||||
import NavigationLinks from "./NavigationLinks";
|
||||
import Sidemenu from "./Sidemenu";
|
||||
import SocialLinks from "./SocialLinks";
|
||||
|
||||
const Header: React.FC = () => (
|
||||
<header className={ cls.header }>
|
||||
<div>
|
||||
<TitleLogo />
|
||||
|
||||
<NavigationLinks className={ cls.navigation } />
|
||||
<SocialLinks className={ cls.socials } size={ 40 } />
|
||||
|
||||
<Button className={ cls.resume } as="next" href={ links.resume }>
|
||||
Download resume
|
||||
</Button>
|
||||
|
||||
<Sidemenu button={ { className: cls.sidemenu } } />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,37 @@
|
||||
@import "../theme.scss";
|
||||
|
||||
.navigation
|
||||
{
|
||||
@include flex(row);
|
||||
align-items: center;
|
||||
|
||||
.link
|
||||
{
|
||||
@include body2($fontFamilyBaseAlt);
|
||||
color: inherit;
|
||||
padding: $spacingSNudge $spacingM;
|
||||
|
||||
@include flex(column);
|
||||
|
||||
> i
|
||||
{
|
||||
height: $strokeWidthThick;
|
||||
background-color: $colorNeutralForeground1;
|
||||
border-radius: $borderRadiusSmall;
|
||||
width: 0;
|
||||
|
||||
transition: width $durationNormal $curveEasyEaseMax;
|
||||
}
|
||||
|
||||
&:hover, &:focus-visible
|
||||
{
|
||||
color: $colorNeutralForegroundInverted;
|
||||
|
||||
> i
|
||||
{
|
||||
width: 100%;
|
||||
background-color: $colorNeutralForegroundInverted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import links from "@/_data/links";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import cls from "./NavigationLinks.module.scss";
|
||||
|
||||
const navLinks: { text: string, href: string; }[] =
|
||||
[
|
||||
{ text: "Home", href: "/" },
|
||||
{ text: "My skills", href: "/#skills" },
|
||||
{ text: "Projects", href: "/#projects" },
|
||||
{ text: "About", href: "/#about" },
|
||||
{ text: "Contacts", href: "/#contacts" }
|
||||
];
|
||||
|
||||
const NavigationLinks: React.FC<NavigationLinksProps> = ({ links: linkProps, ...props }) => (
|
||||
<nav role="navigation" { ...props } className={ `${cls.navigation} ${props.className}` }>
|
||||
|
||||
{ navLinks.map((i, index) =>
|
||||
<Link key={ index } { ...linkProps }
|
||||
href={ i.href }
|
||||
className={ `${cls.link} ${linkProps?.className ?? ""}` }>
|
||||
|
||||
{ i.text }
|
||||
<i />
|
||||
</Link>
|
||||
) }
|
||||
|
||||
{ links.blog &&
|
||||
<Link { ...linkProps } className={ `${cls.link} ${linkProps?.className ?? ""}` }
|
||||
href={ links.blog } target="_blank">
|
||||
|
||||
Blog
|
||||
<i />
|
||||
</Link>
|
||||
}
|
||||
|
||||
</nav>
|
||||
);
|
||||
|
||||
export default NavigationLinks;
|
||||
|
||||
export type NavigationLinksProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
links?: Omit<React.HTMLAttributes<HTMLAnchorElement>, "href">;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
@import "../theme.scss";
|
||||
|
||||
.dialog
|
||||
{
|
||||
max-height: unset;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: $spacingNone;
|
||||
margin: $spacingNone;
|
||||
margin-left: auto;
|
||||
background-color: $colorNeutralBackground1;
|
||||
box-shadow: $shadow16;
|
||||
color: unset;
|
||||
border: none;
|
||||
|
||||
&::backdrop
|
||||
{
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
|
||||
// Since colors use variables, and dialog is rendered outside of the regular DOM,
|
||||
// we need to specify them as literals (or add variables to the dialog scope, but that's too complicated).
|
||||
background-color: light-dark(rgba(255, 255, 255, 0.5), rgba(26, 26, 26, 0.5));
|
||||
}
|
||||
|
||||
.wrapper
|
||||
{
|
||||
height: 100%;
|
||||
|
||||
@include flex(column);
|
||||
@include align(flex-end, center);
|
||||
gap: $spacingXXXL;
|
||||
padding: $spacingXXXL;
|
||||
|
||||
> header
|
||||
{
|
||||
@include flex(row);
|
||||
align-items: center;
|
||||
gap: $spacingL;
|
||||
|
||||
> h3
|
||||
{
|
||||
@include subtitle1($fontFamilyBaseAlt);
|
||||
}
|
||||
}
|
||||
|
||||
.navigation
|
||||
{
|
||||
flex-flow: column;
|
||||
align-items: flex-end;
|
||||
|
||||
.link
|
||||
{
|
||||
align-items: end;
|
||||
background-position-x: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transition: right $durationNormal $curveEasyEaseMax;
|
||||
right: -350px;
|
||||
|
||||
&.show
|
||||
{
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
body:has(dialog.menu[open])
|
||||
{
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { Dismiss24Regular, Navigation24Regular } from "@fluentui/react-icons";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import Button, { ButtonProps } from "./Button";
|
||||
import NavigationLinks from "./NavigationLinks";
|
||||
import cls from "./Sidemenu.module.scss";
|
||||
import SocialLinks from "./SocialLinks";
|
||||
import links from "@/_data/links";
|
||||
|
||||
const Sidemenu: React.FC<SidemenuProps> = ({ button, ...panelProps }) =>
|
||||
{
|
||||
const [isOpen, setOpen] = useState<boolean>(false);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
const onCancel: React.ReactEventHandler<HTMLDialogElement> = useCallback((args) =>
|
||||
{
|
||||
args.preventDefault();
|
||||
setOpen(false);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// We use this method to enable user to close the menu by clicking ouside it.
|
||||
const onClick: React.MouseEventHandler<HTMLDialogElement> = useCallback((args) =>
|
||||
{
|
||||
const wrapper = args.currentTarget.childNodes[0];
|
||||
|
||||
// If user clicked outside of the dialog boudaries, or clicked specifically on an anchor, we can close the menu
|
||||
if (!wrapper.contains(args.target as Node) || args.target instanceof HTMLAnchorElement)
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (isOpen)
|
||||
{
|
||||
dialogRef.current?.showModal();
|
||||
}
|
||||
else if (dialogRef.current?.classList.contains(cls.show)) // This check is to prevent a bug when the menu is closed before opening
|
||||
{
|
||||
dialogRef.current?.addEventListener("transitionend", function WaitForClose()
|
||||
{
|
||||
dialogRef.current?.removeEventListener("transitionend", WaitForClose);
|
||||
dialogRef.current?.close();
|
||||
});
|
||||
}
|
||||
|
||||
dialogRef.current?.classList.toggle(cls.show, isOpen);
|
||||
}, [isOpen]);
|
||||
|
||||
return <>
|
||||
<Button { ...button }
|
||||
appearance="secondary"
|
||||
title="Menu"
|
||||
onClick={ () => setOpen(true) }
|
||||
icon={ <Navigation24Regular /> } />
|
||||
|
||||
<dialog { ...panelProps } className={ `${cls.dialog} ${panelProps.className}` } ref={ dialogRef }
|
||||
onCancel={ onCancel } onClick={ onClick }>
|
||||
|
||||
<div className={ cls.wrapper }>
|
||||
|
||||
<header>
|
||||
<h3>Menu</h3>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
title="Close"
|
||||
onClick={ () => setOpen(false) }
|
||||
icon={ <Dismiss24Regular /> } />
|
||||
</header>
|
||||
|
||||
<NavigationLinks className={ cls.navigation } links={ { className: cls.link } } />
|
||||
<SocialLinks />
|
||||
<Button className={ cls.resume } as="next" href={ links.resume }>Download resume</Button>
|
||||
|
||||
</div>
|
||||
</dialog>
|
||||
</>;
|
||||
};
|
||||
|
||||
export default Sidemenu;
|
||||
|
||||
export type SidemenuProps = React.DialogHTMLAttributes<HTMLDialogElement> & {
|
||||
button?: ButtonProps;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
@import "../theme.scss";
|
||||
|
||||
.socials
|
||||
{
|
||||
@include flex(row);
|
||||
align-items: center;
|
||||
gap: $spacingS;
|
||||
|
||||
.link
|
||||
{
|
||||
background-image: none;
|
||||
padding: $spacingNone;
|
||||
border-radius: $borderRadiusCircular;
|
||||
|
||||
--bg-color: var(--network-color);
|
||||
--icon-color: var(--colorNeutralForegroundStaticInverted);
|
||||
|
||||
// Icon
|
||||
g:first-child
|
||||
{
|
||||
fill: var(--icon-color) !important;
|
||||
}
|
||||
|
||||
// Mask
|
||||
g:last-child
|
||||
{
|
||||
fill: var(--bg-color) !important;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible
|
||||
{
|
||||
--icon-color: var(--network-color);
|
||||
--bg-color: transparent;
|
||||
|
||||
&:active
|
||||
{
|
||||
--bg-color: var(--colorNeutralBackground1Pressed);
|
||||
}
|
||||
}
|
||||
|
||||
// Since GitHub has dark brand color, we need to invert it in dark mode
|
||||
&.github
|
||||
{
|
||||
@media (prefers-color-scheme: dark)
|
||||
{
|
||||
--bg-color: var(--colorNeutralForegroundStaticInverted);
|
||||
--icon-color: var(--network-color);
|
||||
|
||||
&:hover,
|
||||
&:focus-visible
|
||||
{
|
||||
--bg-color: transparent;
|
||||
--icon-color: var(--colorNeutralForegroundStaticInverted);
|
||||
|
||||
&:active
|
||||
{
|
||||
--bg-color: var(--colorNeutralBackground1Pressed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import socials from "@/_data/socials";
|
||||
import React from "react";
|
||||
import { SocialIcon, networkFor, social_icons } from "react-social-icons";
|
||||
import cls from "./SocialLinks.module.scss";
|
||||
|
||||
const SocialLinks: React.FC<SocialLinksProps> = ({ size = 50, ...props }) => (
|
||||
<div aria-label="Social links" { ...props } className={ `${cls.socials} ${props.className}` }>
|
||||
{ Object.entries(socials).map(([network, i]) =>
|
||||
|
||||
<SocialIcon
|
||||
key={ network } title={ network }
|
||||
url={ i.href } target="_blank"
|
||||
|
||||
className={ `${cls.link} ${cls[networkFor(i.href)] ?? ""}` }
|
||||
style={ {
|
||||
// @ts-expect-error Inline variables are not supported in TS definition, but it works.
|
||||
"--network-color": social_icons.get(networkFor(i.href))?.color,
|
||||
width: size,
|
||||
height: size
|
||||
} } />
|
||||
|
||||
) }
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SocialLinks;
|
||||
|
||||
export type SocialLinksProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
Reference in New Issue
Block a user