feat(Portfolio): implementation of Portfolio page

This commit is contained in:
2026-08-06 22:19:04 +02:00
parent 030ca4009c
commit fec4ed568c
3 changed files with 253 additions and 0 deletions
+4
View File
@@ -80,3 +80,7 @@
color: var(--shiki-dark);
background-color: var(--shiki-dark-bg);
}
.macchiato .icon-invert {
filter: invert(1);
}
+5
View File
@@ -5,6 +5,7 @@ import Layout from "./components/Layout";
import Home from "./pages/Home";
import Stream from "./pages/Stream";
import Contact from "./pages/Contact";
import Portfolio from "./pages/Portfolio";
export default function App(): ReactElement {
const path = window.location.pathname.toLowerCase();
@@ -18,5 +19,9 @@ export default function App(): ReactElement {
content = <Contact />;
}
if (path === "/portfolio") {
content = <Portfolio />;
}
return <Layout>{content}</Layout>;
}
+244
View File
@@ -0,0 +1,244 @@
import type { ReactElement } from "react";
import PortfolioSection from "../components/portfolio/PortfolioSection";
import TimelineEntry from "../components/portfolio/TimelineEntry";
import Skills from "../components/portfolio/Skills";
import portfolio from "../content/portfolio.json";
import forgejo from "../assets/images/forgejo.svg";
import gitea from "../assets/images/gitea.svg";
const LOGOS: Record<string, string> = { forgejo, gitea };
interface Profile {
name: string;
headline: string;
region: string;
location: string;
links: { label: string; svg: string; url: string }[];
summary: string;
}
interface TimelineItem {
role?: string;
company?: string;
companyUrl?: string;
degree?: string;
school?: string;
schoolUrl?: string;
start: string;
end: string;
current: boolean;
highlights: string[];
}
interface Experience extends TimelineItem {
role: string;
company: string;
companyUrl?: string;
}
interface Education extends TimelineItem {
degree: string;
school: string;
schoolUrl?: string;
}
interface SkillGroup {
category: string;
items: string[];
}
interface Language {
name: string;
proficiency: string;
}
interface Project {
name: string;
url: string;
description: string;
tags: string[];
}
interface PortfolioData {
profile: Profile;
experience: Experience[];
education: Education[];
skills: SkillGroup[];
languages: Language[];
projects: Project[];
}
const data = portfolio as PortfolioData;
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const formatDate = (value: string): string => {
if (value.toLowerCase() === "present") return "Present";
const [year, month] = value.split("-");
return month ? `${MONTHS[Number(month) - 1]} ${year}` : year;
};
const formatRange = (start: string, end: string): string =>
`${formatDate(start)} ${formatDate(end)}`;
export default function Portfolio(): ReactElement {
const { profile, experience, education, skills, languages, projects } =
data;
return (
<div className="text-ctp-text flex flex-col min-h-screen px-4 py-6">
<div className="w-full max-w-360 min-w-120 mx-auto flex flex-col flex-1">
<h1 className="text-4xl text-center font-bold text-ctp-mauve mb-6">
Portfolio
</h1>
<div className="grid md:grid-cols-[2fr_1fr] gap-8 items-start">
<div className="space-y-2">
<PortfolioSection title="Projects & Contributions">
{projects.map((proj) => (
<a
key={proj.name}
href={proj.url}
target="_blank"
rel="noreferrer"
className="block bg-ctp-mantle border border-ctp-lavender-800 rounded-xl p-4 mb-4 hover:border-ctp-green transition-colors"
>
<h3 className="font-semibold text-ctp-mauve hover:underline">
{proj.name}
</h3>
<p className="text-sm text-ctp-text mt-1 whitespace-pre-line">
{proj.description}
</p>
<div className="flex flex-wrap gap-2 mt-3">
{proj.tags.map((tag) => (
<Skills key={tag} label={tag} />
))}
</div>
</a>
))}
</PortfolioSection>
<PortfolioSection title="Skills">
{skills.map((group) => (
<div key={group.category} className="mb-4">
<h3 className="text-ctp-lavender font-semibold mb-2">
{group.category}
</h3>
<div className="flex flex-wrap gap-2">
{group.items.map((skill) => (
<Skills key={skill} label={skill} />
))}
</div>
</div>
))}
</PortfolioSection>
<PortfolioSection title="Education">
{education.map((edu) => (
<TimelineEntry
key={edu.degree}
title={edu.degree}
subtitle={edu.school}
subtitleUrl={edu.schoolUrl}
date={formatRange(edu.start, edu.end)}
current={edu.current}
highlights={edu.highlights}
/>
))}
</PortfolioSection>
<PortfolioSection title="Experience">
{experience.map((exp) => (
<TimelineEntry
key={exp.role}
title={exp.role}
subtitle={exp.company}
subtitleUrl={exp.companyUrl}
date={formatRange(exp.start, exp.end)}
current={exp.current}
highlights={exp.highlights}
/>
))}
</PortfolioSection>
</div>
<div className="space-y-6">
<div className="bg-ctp-mantle border border-ctp-lavender-800 rounded-xl p-6">
<div className="size-20 rounded-full bg-ctp-surface0 border border-ctp-text flex items-center justify-center text-2xl font-bold text-ctp-mauve mb-4">
{profile.name[0]}
</div>
<h2 className="text-3xl font-bold text-ctp-mauve">
{profile.name}
</h2>
<p className="text-ctp-green mt-1">
{profile.headline}
</p>
<p className="text-sm text-ctp-subtext0 mt-3">
{profile.location}
</p>
<p className="text-sm text-ctp-subtext0">
{profile.region}
</p>
<div className="flex flex-wrap gap-2 mt-4">
{profile.links.map((link) => (
<a
key={link.label}
href={link.url}
target="_blank"
rel="noreferrer"
aria-label={link.label}
title={link.label}
className="p-2 cursor-pointer hover:opacity-80 transition-opacity"
>
<img
src={LOGOS[link.svg]}
alt={link.label}
className={`w-7 h-7 ${
link.svg === "github"
? "icon-invert"
: ""
}`}
/>
</a>
))}
</div>
</div>
<PortfolioSection title="Summary">
<p className="text-sm leading-relaxed whitespace-pre-line">
{profile.summary}
</p>
</PortfolioSection>
<PortfolioSection title="Languages">
<ul className="space-y-2">
{languages.map((lang) => (
<li
key={lang.name}
className="flex justify-between text-sm border-b border-ctp-surface0 pb-2"
>
<span>{lang.name}</span>
<span className="text-ctp-subtext0">
{lang.proficiency}
</span>
</li>
))}
</ul>
</PortfolioSection>
</div>
</div>
</div>
</div>
);
}