1
0
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:
2024-08-19 23:08:50 +00:00
commit 3ec7d9a722
134 changed files with 17088 additions and 0 deletions
@@ -0,0 +1,26 @@
@import "../theme.scss";
.section
{
@include centerTwo;
@include body2($fontFamilyBaseAlt);
color: $colorNeutralForeground2;
align-items: center;
> div:first-child
{
@include flex(column);
gap: $spacingM;
}
> img
{
height: auto;
width: 100%;
max-width: 400px;
justify-self: center;
border-radius: $borderRadiusMedium;
box-shadow: $shadow2;
}
}
+21
View File
@@ -0,0 +1,21 @@
import { aboutPicture } from "@/_assets/illustrations";
import bio from "@/_data/bio";
import Image from "next/image";
import React from "react";
import cls from "./AboutSection.module.scss";
const AboutSection: React.FC = () => (
<section id="about" className={ cls.section }>
<div>
<h2>About me</h2>
{ bio.map((i, index) =>
<p key={ index }>{ i }</p>
) }
</div>
<Image src={ aboutPicture.src } alt={ aboutPicture.alt } />
</section>
);
export default AboutSection;
@@ -0,0 +1,91 @@
@import "../theme.scss";
.section
{
@include flex(column);
gap: $spacingXXXL;
@include body1;
h2
{
text-align: center;
}
.content
{
@include centerTwo;
.container
{
@include flex(column);
gap: $spacingM;
}
.contacts
{
align-items: flex-end;
text-align: right;
}
.textarea
{
min-width: 100%;
min-height: 160px;
max-width: 60vw;
resize: both;
}
.formToolbar
{
@include flex(row);
justify-content: flex-end;
@media screen and (max-width: 460px)
{
flex-flow: column;
row-gap: $spacingM;
align-items: flex-end;
.status > span
{
@include body1($fontFamilyBaseAlt);
}
}
.status
{
@include flex(row);
@include align(center, flex-end);
@include body2($fontFamilyBaseAlt);
height: 40px;
width: 0;
overflow: hidden;
> span
{
margin: $spacingS $spacingM;
text-wrap: nowrap;
}
color: $colorNeutralForegroundStaticInverted;
background-color: $colorStatusDangerBackground3;
transition-property: width;
transition-duration: $durationNormal;
transition-timing-function: $curveEasyEaseMax;
&:is(.error, .success)
{
width: 100%;
}
&.success
{
background-color: $colorStatusSuccessBackground3;
}
}
}
}
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import Button from "@/_components/Button";
import contacts from "@/_data/contacts";
import FormStatusTracker from "@/_utils/FormStatusTracker";
import React, { InputHTMLAttributes, useMemo, useState } from "react";
import { useFormState } from "react-dom";
import sendInquiry, { FormStatus } from "../_utils/sendInquiry";
import cls from "./ContactSection.module.scss";
const defaultState: FormStatus = { status: "idle" };
const ContactSection: React.FC = () =>
{
const [pending, setPending] = useState<boolean>(false);
const [{ status, message }, formAction] = useFormState<FormStatus, FormData>(sendInquiry, defaultState);
const { telephone: phone, email, socials } = contacts;
const sharedProps: InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> = useMemo(() => ({
required: true,
disabled: pending,
readOnly: status === "success"
}), [status, pending]);
return (
<section id="contacts" className={ cls.section }>
<h2>Let's get in touch</h2>
<div className={ cls.content }>
<div className={ cls.container }>
<h3>Inquiries, requests or proposals</h3>
<form className={ cls.container } action={ formAction }>
<FormStatusTracker onPendingChanged={ setPending } />
<input name="email" type="email" { ...sharedProps }
autoComplete="email" spellCheck="false"
maxLength={ 60 }
placeholder="Email" />
<input name="subject" type="text" { ...sharedProps }
autoComplete="off" spellCheck="true"
maxLength={ 120 }
placeholder="Subject" />
<textarea name="message" { ...sharedProps } className={ cls.textarea }
autoComplete="off" spellCheck="true"
minLength={ 100 } maxLength={ 2000 }
placeholder="Message (min 100 characters)" />
<input name="timezone" type="hidden" readOnly
value={ Intl.DateTimeFormat().resolvedOptions().timeZone } />
<div className={ cls.formToolbar }>
<div className={ `${cls.status} ${pending ? "" : cls[status]}` }>
{ pending &&
<span role="alert" aria-live="assertive">
Sending
</span>
}
{ !pending && status === "success" &&
<span role="alert" aria-live="assertive">
Message successfully sent
</span>
}
{ !pending && status === "error" &&
<span role="alert" aria-live="assertive">
{ message ?? "Something went wrong" }
</span>
}
</div>
{ status !== "success" &&
<Button type="submit" disabled={ pending }>
Submit
</Button>
}
</div>
</form>
</div>
<div className={ `${cls.container} ${cls.contacts}` }>
<h3>Direct contacts</h3>
<p>
{ Object.entries(socials).map(([name, i]) =>
<span key={ name }>
<span aria-hidden>{ name + ": " }</span>
<a aria-label={ `${name}: ${i.username}` } href={ i.href } target="_blank">
{ i.username }
</a>
<br aria-hidden />
</span>
) }
{ phone &&
<span>
<span aria-hidden>Telephone: </span>
<a aria-label={ `Telephone: ${phone.text} (${phone.country})` } href={ phone.href }>
{ phone.text }
</a>
<span aria-hidden> ({ phone.country })</span>
</span>
}
</p>
<Button href={ email.href } target="_blank">
{ email.text }
</Button>
</div>
</div>
</section>
);
};
export default ContactSection;
@@ -0,0 +1,242 @@
@import "../theme.scss";
.section
{
@include flex(column);
gap: $spacingXXXL;
h2
{
text-align: center;
}
// Properties shared between horizontal and vertical timelines
.timeline
{
@include maxCenter(1600px);
display: grid;
position: relative;
.line
{
z-index: -1;
position: absolute;
display: grid;
> *
{
grid-row: 1;
grid-column: 1;
}
.strip
{
border-radius: $borderRadiusMedium;
}
.trailHorizontal
{
width: 100%;
height: auto;
}
.trailVertical
{
height: 100%;
width: auto;
}
}
.item
{
display: grid;
.year
{
@include subtitle1($fontFamilyBaseAlt);
}
> i
{
display: block;
height: 32px;
width: 32px;
border: $strokeWidthThickest solid $colorNeutralForeground1;
border-radius: $borderRadiusCircular;
background-color: $colorNeutralForeground1;
box-shadow: inset 0 0 0 16px $colorNeutralBackground1;
}
.description
{
p
{
@include body2($fontFamilyBaseAlt);
color: $colorNeutralForeground3;
}
.title
{
@include subtitle1($fontFamilyBaseAlt);
}
}
}
// Vertical timeline
@media screen and (max-width: 860px)
{
gap: $spacingXXXL;
.line
{
height: 100%;
width: 8px;
left: 88px;
justify-content: center;
justify-items: center;
.strip
{
width: 8px;
height: 100%;
background-image:
repeating-linear-gradient($colorNeutralForeground1 0 $spacingM, transparent 0 $spacingXXL);
}
.trailHorizontal
{
display: none;
}
}
.item
{
grid-template-columns: 64px auto 1fr;
padding: $spacingXXXL $spacingNone;
gap: $spacingM;
align-items: center;
}
}
// Horizontal timeline
@media screen and (min-width: 861px)
{
grid-auto-flow: column;
.line
{
bottom: 72px;
width: 100%;
height: 8px;
align-content: center;
align-items: center;
.strip
{
height: 8px;
background-image:
repeating-linear-gradient(90deg, $colorNeutralForeground1 0 $spacingM, transparent 0 $spacingXXL);
}
.trailVertical
{
display: none;
}
}
.item
{
grid-template-rows: 128px auto 48px;
padding: $spacingNone $spacingM;
row-gap: $spacingM;
.year
{
grid-row: 3/3;
}
> i
{
grid-row: 2/2;
transition: box-shadow $durationNormal $curveEasyEaseMax;
}
h3,
p
{
transition-property: font-size, line-height, opacity;
transition-duration: $durationNormal;
transition-timing-function: $curveEasyEaseMax;
}
.description
{
grid-row: 1/1;
align-self: self-end;
p
{
font-size: 0;
opacity: 0;
}
@media screen and (max-width: 1200px)
{
.title
{
opacity: 0;
font-size: 0;
}
}
}
}
// When there's something hovered or focused inside the timeline
&:has(:hover, :focus-visible, :focus-within) .item
{
// Item that is being hovered or focused
&:is(:hover, :focus-visible, :focus-within)
{
.year,
.title
{
@include title1($fontFamilyBaseAlt);
opacity: 1;
}
> i
{
box-shadow: inset 0 0 0 0 $colorNeutralBackground1;
}
.description > p
{
@include subtitle2($fontFamilyBaseAlt);
opacity: 1;
}
}
// Other not focused items
&:not(:hover, :focus-visible, :focus-within)
{
.year,
.title
{
@include body1($fontFamilyBaseAlt);
color: $colorNeutralForeground3;
@media screen and (max-width: 1200px)
{
opacity: 0;
font-size: 0;
}
}
}
}
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import { experienceBgHorizontal, experienceBgVertical } from "@/_assets/decorations";
import experience, { WorkplaceEntry } from "@/_data/experience";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import cls from "./ExperienceSection.module.scss";
const ExperienceSection: React.FC = () => (
<section id="experience" className={ cls.section }>
<h2>My work experience</h2>
<div className={ cls.timeline } role="list" aria-label="My work experience">
<div aria-hidden className={ cls.line }>
<Image className={ cls.trailHorizontal }
alt={ experienceBgHorizontal.alt } src={ experienceBgHorizontal.src } />
<Image className={ cls.trailVertical }
alt={ experienceBgVertical.alt } src={ experienceBgVertical.src } />
<i className={ cls.strip } />
</div>
{ experience.map((i, index) =>
<div className={ cls.item } key={ index }
tabIndex={ 0 } role="listitem" aria-label={ getAriaLabel(i) }>
<p aria-hidden className={ cls.year }>{ i.year }</p>
<i />
<div className={ cls.description }>
<p aria-hidden>{ i.place }</p>
<h3 aria-hidden className={ cls.title }>{ i.title }</h3>
<p aria-hidden={ !!i.tech }>{ i.tech ?? <Link href="#contacts">Contact me</Link> }</p>
</div>
</div>
) }
</div>
</section>
);
function getAriaLabel(item: WorkplaceEntry): string
{
let str: string[] = [];
if (item.year)
str.push(`${item.year} -`);
str.push(item.title);
if (item.place)
str.push(`at ${item.place}`);
if (item.tech)
return str.join(" ") + `. ${item.tech}`;
else
return str.join(" ");
}
export default ExperienceSection;
@@ -0,0 +1,98 @@
@import "../theme.scss";
.section
{
@include centerTwo;
.listItem
{
background-position: right;
}
.descriptions
{
@include body2($fontFamilyBaseAlt);
display: grid;
overflow-x: visible;
min-height: 760px;
@media screen and (max-width: 860px)
{
min-height: unset;
padding-top: calc(56px + $spacingXL);
}
img
{
width: 100%;
height: auto;
}
.defaultImg
{
@include slideIn;
align-self: center;
}
.projectItem
{
@include flex(column);
max-width: 600px;
justify-self: center;
gap: $spacingM;
@include slideIn;
@media (prefers-color-scheme: light)
{
> img[data-theme=dark]
{
display: none;
}
}
@media (prefers-color-scheme: dark)
{
> img[data-theme=light]
{
display: none;
}
}
p
{
color: $colorNeutralForeground2;
}
> h4
{
@include subtitle1($fontFamilyBaseAlt);
}
.stack
{
@include flex(row, wrap);
gap: $spacingL;
@include body1;
.item
{
@include flex(row);
align-items: center;
gap: $spacingSNudge;
> svg
{
width: 24px;
height: 24px;
}
}
}
}
}
.cta
{
align-self: flex-end;
}
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { projectsImg } from "@/_assets/illustrations";
import Button from "@/_components/Button";
import links from "@/_data/links";
import projects from "@/_data/projects";
import shared from "@/_styles/gallery.module.scss";
import { ArrowRight24Regular } from "@fluentui/react-icons";
import Image from "next/image";
import React, { useState } from "react";
import { networkFor } from "react-social-icons";
import cls from "./ProjectsSection.module.scss";
const ProjectsSection: React.FC = () =>
{
const [selection, setSelection] = useState<number | undefined>(undefined);
return (
<section id="projects" className={ cls.section }>
<div className={ shared.list }>
<h2>My pet projects</h2>
{ projects.map((project, index) =>
<Button key={ index } type="button"
className={ `${shared.listItem} ${cls.listItem}` }
appearance={ selection === index ? "primary" : "secondary" }
data-selected={ selection === index }
onClick={ () => setSelection(selection == index ? undefined : index) }
aria-label={ `"${project.title}". ${project.subtitle}` }>
<div className={ shared.content }>
<span className={ shared.title }>{ project.title }</span>
<span>{ project.subtitle }</span>
</div>
</Button>
) }
<Button className={ cls.cta } appearance="secondary" href={ links.github } target="_blank"
iconAfter={ <ArrowRight24Regular /> }>
View GitHub profile
</Button>
</div>
<div className={ cls.descriptions } aria-live="polite" aria-atomic>
{ projects.map((project, index) =>
<div key={ index } className={ cls.projectItem } hidden={ selection !== index }>
<Image src={ project.image } alt={ project.title } data-theme={ project.imageDark ? "light" : "both" } />
{/* This is a workaround since not all images can be theme-adaptive */ }
{ project.imageDark &&
<Image src={ project.imageDark } alt="" data-theme="dark" />
}
<h3>{ project.title }</h3>
{ project.description?.map((i, index) =>
<p key={ index }>{ i }</p>
) }
<h4>Stack</h4>
<div className={ cls.stack }>
{ project.stack.map((i, index) =>
<div key={ index } className={ cls.item }>
<i.icon /> { i.text }
</div>
) }
</div>
<Button className={ cls.cta } appearance="secondary" href={ project.link } target="_blank"
iconAfter={ <ArrowRight24Regular /> }>
{ networkFor(project.link) === "github" ? "View on GitHub" : "Visit project page" }
</Button>
</div>
) }
<Image className={ cls.defaultImg } hidden={ selection !== undefined }
src={ projectsImg.src } alt={ projectsImg.alt } />
</div>
</section>
);
};
export default ProjectsSection;
@@ -0,0 +1,51 @@
@import "../theme.scss";
.section
{
@include centerTwo;
align-items: center;
.illustrations
{
justify-self: center;
position: relative;
img
{
width: 100%;
max-height: 600px;
@include slideIn;
}
// [SPECIAL]
.whatsThis
{
position: absolute;
cursor: pointer;
bottom: calc(50% - 20px + 13.5%);
left: 40%;
width: 20%;
height: 40px;
@media screen and (max-width: 1400px)
{
bottom: calc(50% - 20px + 6vw)
}
}
}
.list
{
.cta
{
align-self: flex-end;
}
@media screen and (max-width: 860px)
{
grid-row: 1;
}
}
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import Button from "@/_components/Button";
import skills from "@/_data/skills";
import shared from "@/_styles/gallery.module.scss";
import { ArrowDownload24Regular } from "@fluentui/react-icons";
import Image from "next/image";
import React, { useId, useState } from "react";
import cls from "./SkillsSection.module.scss";
import links from "@/_data/links";
const SkillsSection: React.FC = () =>
{
const [selection, setSelection] = useState<number>(0);
const illustrations = useId();
return (
<section id="skills" className={ cls.section }>
<div id={ illustrations } className={ cls.illustrations } aria-live="polite" aria-atomic>
{ skills.map((i, index) =>
<Image key={ index }
src={ i.image.src } alt={ i.image.alt }
hidden={ selection !== index } />
) }
{ selection === 4 &&
// [SPECIAL] It's a surprize tool that will help us later
<div role="button" aria-label="Click me" className={ cls.whatsThis } onClick={ () => window.open("https://www.youtube.com/watch?v=dQw4w9WgXcQ") } />
}
</div>
<div className={ `${shared.list} ${cls.list}` }>
<h2>My skillset</h2>
{ skills.map((skill, index) =>
<Button key={ index } type="button" aria-current={ selection === index } aria-controls={ illustrations }
className={ shared.listItem } appearance={ selection === index ? "primary" : "secondary" }
data-selected={ selection === index }
onClick={ () => setSelection(index) }
aria-label={ `${skill.title} skills. Associated stack: ${skill.description}` }
icon={ <skill.icon /> } >
<div className={ shared.content }>
<span className={ shared.title }>{ skill.title }</span>
<span>{ skill.description }</span>
</div>
</Button>
) }
<Button appearance="secondary" className={ cls.cta }
as="next" href={ links.resume }
icon={ <ArrowDownload24Regular /> }>
Download resume
</Button>
</div>
</section>
);
};
export default SkillsSection;