Merge pull request #5 from KuchtaVR6/work-experience-revamp
Work experience revamp
This commit is contained in:
Binary file not shown.
BIN
public/portfolio/cities/Edinburgh.png
Normal file
BIN
public/portfolio/cities/Edinburgh.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
public/portfolio/cities/Greater London.png
Normal file
BIN
public/portfolio/cities/Greater London.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
public/portfolio/cities/Manchester.png
Normal file
BIN
public/portfolio/cities/Manchester.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
BIN
public/portfolio/cities/Warsaw.png
Normal file
BIN
public/portfolio/cities/Warsaw.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
@@ -7,7 +7,8 @@ const workExperienceData : WorkExperienceArgs[] = [
|
||||
company: "Softwire",
|
||||
city: "Manchester",
|
||||
country: "United Kingdom",
|
||||
startDate: "November 2025"
|
||||
startDate: "November 2025",
|
||||
isImportant: true
|
||||
},
|
||||
{
|
||||
industry: "Software & AI",
|
||||
@@ -16,15 +17,18 @@ const workExperienceData : WorkExperienceArgs[] = [
|
||||
city: "Manchester",
|
||||
country: "United Kingdom",
|
||||
startDate: "November 2024",
|
||||
endDate: "November 2025"
|
||||
endDate: "November 2025",
|
||||
isImportant: true
|
||||
},
|
||||
{
|
||||
industry: "Artificial Intelligence",
|
||||
title: "Programming Data Annotator",
|
||||
company: "DataAnnotation Tech",
|
||||
city: "New York",
|
||||
country: "USA",
|
||||
startDate: "January 2024"
|
||||
city: "Edinburgh",
|
||||
country: "United Kingdom",
|
||||
displayLocation: "New York, United States (Remote)",
|
||||
startDate: "January 2024",
|
||||
isImportant: true
|
||||
},
|
||||
{
|
||||
industry: "Education",
|
||||
@@ -33,7 +37,8 @@ const workExperienceData : WorkExperienceArgs[] = [
|
||||
city: "London",
|
||||
country: "United Kingdom",
|
||||
startDate: "September 2021",
|
||||
endDate: "April 2024"
|
||||
endDate: "April 2024",
|
||||
isImportant: true
|
||||
},
|
||||
{
|
||||
industry: "Software",
|
||||
@@ -42,7 +47,8 @@ const workExperienceData : WorkExperienceArgs[] = [
|
||||
city: "London",
|
||||
country: "United Kingdom",
|
||||
startDate: "June 2023",
|
||||
endDate: "August 2023"
|
||||
endDate: "August 2023",
|
||||
isImportant: true
|
||||
},
|
||||
{
|
||||
industry: "Education",
|
||||
@@ -105,10 +111,15 @@ const workExperienceData : WorkExperienceArgs[] = [
|
||||
city: "Warsaw",
|
||||
country: "Poland",
|
||||
startDate: "July 2018",
|
||||
endDate: "August 2018"
|
||||
endDate: "August 2018",
|
||||
isImportant: true
|
||||
}
|
||||
];
|
||||
|
||||
export const workExperienceIntro =
|
||||
"Growing up in Warsaw, I moved to London at 18. Since then, my career and education has taken me through " +
|
||||
"Edinburgh and Manchester, shaping my journey across the UK.";
|
||||
|
||||
export const workExperienceParagraph =
|
||||
"Since the age of 16, I have actively engaged in various professional roles across multiple industries, " +
|
||||
"including Artificial Intelligence, Software, Education, and Hospitality. My journey began in the hospitality " +
|
||||
|
||||
@@ -8,7 +8,9 @@ export type WorkExperienceArgs = {
|
||||
title : string,
|
||||
city : string,
|
||||
country : string,
|
||||
endDate? : string
|
||||
endDate? : string,
|
||||
displayLocation? : string,
|
||||
isImportant? : boolean
|
||||
}
|
||||
|
||||
const WorkExperience : FC<WorkExperienceArgs> = (props) => {
|
||||
|
||||
@@ -1,54 +1,20 @@
|
||||
import { Splide, SplideSlide } from "@splidejs/react-splide";
|
||||
import { useEffect, useState } from "react";
|
||||
import styles from "../styling/experience.module.scss";
|
||||
import WorkExperience from "@/src/portfolio/helpers/WorkExperience";
|
||||
import workExperienceData, { workExperienceParagraph } from "@/src/portfolio/data/workExperienceData";
|
||||
import { getCityGroups } from "./Experience/helpers";
|
||||
import CityGroup from "./Experience/CityGroup";
|
||||
import { workExperienceIntro } from "@/src/portfolio/data/workExperienceData";
|
||||
|
||||
const Experience = (): JSX.Element => {
|
||||
const calcPagesOnWidth = (width : number): number => {
|
||||
return Math.floor(width / 800 + 1);
|
||||
};
|
||||
|
||||
const [pages, setPages] = useState(calcPagesOnWidth(1000));
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
setPages(calcPagesOnWidth(window.innerWidth));
|
||||
const handleResize = (): void => {
|
||||
setPages(calcPagesOnWidth(window.innerWidth));
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
const cityGroups = getCityGroups();
|
||||
|
||||
return (
|
||||
<div className={styles.section} id={"experience"}>
|
||||
<h2>Work Experience</h2>
|
||||
<p data-aos={"fade-right"}>
|
||||
{workExperienceParagraph}
|
||||
</p>
|
||||
<Splide
|
||||
options={{
|
||||
rewind: true,
|
||||
perPage: pages,
|
||||
focus: "center",
|
||||
start: 0
|
||||
}}
|
||||
>
|
||||
{
|
||||
workExperienceData.map((entry, index) =>
|
||||
<SplideSlide key={"outer"+index}>
|
||||
<WorkExperience
|
||||
{...entry} key={"inner"+index}
|
||||
/>
|
||||
</SplideSlide>
|
||||
)
|
||||
}
|
||||
</Splide>
|
||||
<p className={styles.intro}>{workExperienceIntro}</p>
|
||||
<div className={styles.cityGroupsContainer}>
|
||||
{cityGroups.map((group, index) => (
|
||||
<CityGroup key={index} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
57
src/portfolio/sections/Experience/CityGroup.tsx
Normal file
57
src/portfolio/sections/Experience/CityGroup.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { FC, useState } from "react";
|
||||
import { ICityGroup } from "./helpers";
|
||||
import styles from "@/src/portfolio/styling/experience.module.scss";
|
||||
import { FiChevronDown, FiChevronUp } from "react-icons/fi";
|
||||
|
||||
interface ICityGroupProps {
|
||||
group: ICityGroup;
|
||||
}
|
||||
|
||||
const CityGroup: FC<ICityGroupProps> = ({ group }) => {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const displayedJobs = showAll ? group.jobs : group.visibleJobs;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.cityGroup} ${group.offsetDirection === "left" ? styles.offsetLeft : styles.offsetRight}`}
|
||||
style={{
|
||||
backgroundImage: group.imageUrl ? `url(${group.imageUrl})` : undefined
|
||||
}}
|
||||
>
|
||||
<div className={styles.overlay}></div>
|
||||
<h3 className={styles.cityTitle}>{group.city}</h3>
|
||||
<div className={styles.jobsContainer}>
|
||||
{displayedJobs.map((job, index) => (
|
||||
<div key={index} className={styles.job} data-aos="fade-left">
|
||||
<span className={styles.largerText}>
|
||||
<b>{job.title}</b><br/>
|
||||
at <span className={styles.redText}>{job.company}</span><br/>
|
||||
</span>
|
||||
in <b>{job.displayLocation || `${job.city}, ${job.country}`}.</b>
|
||||
<div className={styles.industry}>{job.industry}</div>
|
||||
<hr/>
|
||||
{job.endDate ? `${job.startDate} - ${job.endDate}` : `Since ${job.startDate}`}
|
||||
</div>
|
||||
))}
|
||||
{group.hasMore && (
|
||||
<button className={styles.showMoreBtn} onClick={() => setShowAll(!showAll)}>
|
||||
{showAll ? (
|
||||
<>
|
||||
Show Less <FiChevronUp />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show {group.hiddenCount} More {group.hiddenCount === 1 ? "Job" : "Jobs"} <FiChevronDown />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.locationMarker}>
|
||||
{group.city}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CityGroup;
|
||||
96
src/portfolio/sections/Experience/helpers.ts
Normal file
96
src/portfolio/sections/Experience/helpers.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import workExperienceData from "@/src/portfolio/data/workExperienceData";
|
||||
import { WorkExperienceArgs } from "@/src/portfolio/helpers/WorkExperience";
|
||||
|
||||
export interface ICityGroup {
|
||||
city: string;
|
||||
jobs: WorkExperienceArgs[];
|
||||
visibleJobs: WorkExperienceArgs[];
|
||||
hasMore: boolean;
|
||||
hiddenCount: number;
|
||||
imageUrl: string;
|
||||
offsetDirection: "left" | "right";
|
||||
}
|
||||
|
||||
const CITY_IMAGES: Record<string, string> = {
|
||||
"Manchester": "/portfolio/cities/Manchester.png",
|
||||
"Edinburgh": "/portfolio/cities/Edinburgh.png",
|
||||
"Greater London": "/portfolio/cities/Greater London.png",
|
||||
"Warsaw": "/portfolio/cities/Warsaw.png"
|
||||
};
|
||||
|
||||
const CITY_ALIASES: Record<string, string> = {
|
||||
"London": "Greater London",
|
||||
"Upminster": "Greater London"
|
||||
};
|
||||
|
||||
const createCityGroup = (
|
||||
city: string,
|
||||
jobs: WorkExperienceArgs[],
|
||||
offset: "left" | "right"
|
||||
): ICityGroup => {
|
||||
const importantJobs = jobs.filter(job => job.isImportant);
|
||||
const nonImportantJobs = jobs.filter(job => !job.isImportant);
|
||||
|
||||
const needToFill = Math.max(0, 2 - importantJobs.length);
|
||||
const fillerJobs = nonImportantJobs.slice(0, needToFill);
|
||||
const remainingNonImportant = nonImportantJobs.slice(needToFill);
|
||||
|
||||
return {
|
||||
city,
|
||||
jobs,
|
||||
visibleJobs: [...importantJobs, ...fillerJobs],
|
||||
hasMore: remainingNonImportant.length > 0,
|
||||
hiddenCount: remainingNonImportant.length,
|
||||
imageUrl: CITY_IMAGES[city] || "",
|
||||
offsetDirection: offset
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCityName = (city: string): string => {
|
||||
return CITY_ALIASES[city] || city;
|
||||
};
|
||||
|
||||
const parseDate = (dateString: string): Date => {
|
||||
return new Date(dateString);
|
||||
};
|
||||
|
||||
const getEarliestJobDate = (jobs: WorkExperienceArgs[]): Date => {
|
||||
return jobs.reduce((earliest, job) => {
|
||||
const jobDate = parseDate(job.startDate);
|
||||
return jobDate < earliest ? jobDate : earliest;
|
||||
}, parseDate(jobs[0].startDate));
|
||||
};
|
||||
|
||||
export const getCityGroups = (): ICityGroup[] => {
|
||||
const cityJobsMap = new Map<string, WorkExperienceArgs[]>();
|
||||
|
||||
workExperienceData.forEach(job => {
|
||||
const normalizedCity = normalizeCityName(job.city);
|
||||
if (!cityJobsMap.has(normalizedCity)) {
|
||||
cityJobsMap.set(normalizedCity, []);
|
||||
}
|
||||
const cityJobs = cityJobsMap.get(normalizedCity);
|
||||
if (cityJobs) {
|
||||
cityJobs.push(job);
|
||||
}
|
||||
});
|
||||
|
||||
const cityOrder = Array.from(cityJobsMap.entries())
|
||||
.map(([city, jobs]) => ({ city, earliestDate: getEarliestJobDate(jobs) }))
|
||||
.sort((a, b) => b.earliestDate.getTime() - a.earliestDate.getTime())
|
||||
.map(entry => entry.city);
|
||||
|
||||
const offsetPattern: ("left" | "right")[] = ["right", "left", "right", "left"];
|
||||
|
||||
return cityOrder
|
||||
.map((city, index) => {
|
||||
const jobs = cityJobsMap.get(city);
|
||||
if (!jobs) return null;
|
||||
return createCityGroup(
|
||||
city,
|
||||
jobs,
|
||||
offsetPattern[index % offsetPattern.length]
|
||||
);
|
||||
})
|
||||
.filter((group): group is ICityGroup => group !== null);
|
||||
};
|
||||
@@ -20,21 +20,176 @@
|
||||
}
|
||||
}
|
||||
|
||||
.cityGroupsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin-top: 3rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cityGroup {
|
||||
position: relative;
|
||||
padding: 3rem 2rem;
|
||||
border-radius: 8px;
|
||||
min-height: 400px;
|
||||
width: 65%;
|
||||
margin: 0 auto 6rem auto;
|
||||
background-size: contain !important;
|
||||
background-repeat: no-repeat !important;
|
||||
background-position: bottom !important;
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -3rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 60%;
|
||||
height: 2px;
|
||||
background: linear-gradient(to right, transparent, $accent_colour, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 90%;
|
||||
padding: 2rem 1rem;
|
||||
margin-bottom: 4rem;
|
||||
|
||||
&::after {
|
||||
bottom: -2rem;
|
||||
width: 80%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6));
|
||||
z-index: -1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.cityTitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cityDescription {
|
||||
font-size: 1rem;
|
||||
color: rgba($white, 0.9);
|
||||
margin-bottom: 2rem;
|
||||
font-style: italic;
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.8);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.jobsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.offsetLeft .jobsContainer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.offsetRight .jobsContainer {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.locationMarker {
|
||||
position: absolute;
|
||||
bottom: -3rem;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
background: $accent_colour;
|
||||
color: $white;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
bottom: -2rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
.job {
|
||||
@include lightSection;
|
||||
width: 100%;
|
||||
padding: 1em;
|
||||
margin: auto 1em;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
width: 45%;
|
||||
padding: 0.75em;
|
||||
margin: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
|
||||
.largerText {
|
||||
font-size: 1.3em;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.redText {
|
||||
color: $accent_colour;
|
||||
}
|
||||
|
||||
.industry {
|
||||
margin-top: 12px;
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 85%;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 95%;
|
||||
}
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(8px);
|
||||
color: $white;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid rgba($white, 0.4);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: $white;
|
||||
border-color: rgba($white, 0.6);
|
||||
}
|
||||
|
||||
svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,14 @@
|
||||
flex-basis: calc(100% / 2);
|
||||
padding: 2em 2em;
|
||||
|
||||
h2 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1.05em;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.links {
|
||||
|
||||
|
||||
@@ -98,6 +98,17 @@
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
span {
|
||||
float: none !important;
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user