stable state
This commit is contained in:
196
app/globals.css
196
app/globals.css
@@ -15,10 +15,15 @@
|
|||||||
--color-progress: #6b5730;
|
--color-progress: #6b5730;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* All page sections sit above the fixed GridCanvas (z-index 0) */
|
/* All page sections sit above the fixed GridCanvas (z-index 0) */
|
||||||
main > section { position: relative; z-index: 5; }
|
main>section {
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
@@ -32,66 +37,117 @@ body {
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
|
||||||
content: "";
|
::-webkit-scrollbar {
|
||||||
position: fixed;
|
width: 4px;
|
||||||
inset: 0;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E");
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 4px; }
|
::-webkit-scrollbar-track {
|
||||||
::-webkit-scrollbar-track { background: #07080a; }
|
background: #07080a;
|
||||||
::-webkit-scrollbar-thumb { background: #1c1f26; border-radius: 2px; }
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #1c1f26;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeUp {
|
@keyframes fadeUp {
|
||||||
from { opacity: 0; transform: translateY(32px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(32px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes slideRight {
|
@keyframes slideRight {
|
||||||
from { transform: scaleX(0); }
|
from {
|
||||||
to { transform: scaleX(1); }
|
transform: scaleX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes pulse-glow {
|
@keyframes pulse-glow {
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(200, 169, 110, 0); }
|
|
||||||
50% { box-shadow: 0 0 20px 4px rgba(200, 169, 110, 0.12); }
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(200, 169, 110, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 4px rgba(200, 169, 110, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes scroll-bounce {
|
@keyframes scroll-bounce {
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(6px); }
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-up {
|
.animate-fade-up {
|
||||||
animation: fadeUp 0.75s ease both;
|
animation: fadeUp 0.75s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delay-1 { animation-delay: 0.15s; }
|
.delay-1 {
|
||||||
.delay-2 { animation-delay: 0.3s; }
|
animation-delay: 0.15s;
|
||||||
.delay-3 { animation-delay: 0.45s; }
|
}
|
||||||
.delay-4 { animation-delay: 0.6s; }
|
|
||||||
.delay-5 { animation-delay: 0.75s; }
|
|
||||||
.delay-6 { animation-delay: 0.9s; }
|
|
||||||
|
|
||||||
.reveal {
|
.delay-2 {
|
||||||
opacity: 0;
|
animation-delay: 0.3s;
|
||||||
transform: translateY(24px);
|
|
||||||
transition: opacity 0.7s ease, transform 0.7s ease;
|
|
||||||
}
|
}
|
||||||
.reveal.visible {
|
|
||||||
opacity: 1;
|
.delay-3 {
|
||||||
transform: translateY(0);
|
animation-delay: 0.45s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delay-4 {
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-5 {
|
||||||
|
animation-delay: 0.75s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delay-6 {
|
||||||
|
animation-delay: 0.9s;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* grid-bg: static CSS grid removed — replaced by GridCanvas (canvas-based distortion) */
|
/* grid-bg: static CSS grid removed — replaced by GridCanvas (canvas-based distortion) */
|
||||||
.grid-bg {}
|
.grid-bg {}
|
||||||
|
|
||||||
@@ -102,6 +158,7 @@ body::before {
|
|||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card::before {
|
.project-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -111,12 +168,16 @@ body::before {
|
|||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-card:hover {
|
.project-card:hover {
|
||||||
border-color: #6b5730;
|
border-color: #6b5730;
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
.project-card:hover::before { opacity: 1; }
|
|
||||||
|
.project-card:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.tech-tag {
|
.tech-tag {
|
||||||
font-family: var(--font-jetbrains), monospace;
|
font-family: var(--font-jetbrains), monospace;
|
||||||
@@ -129,6 +190,7 @@ body::before {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
transition: border-color 0.2s, color 0.2s;
|
transition: border-color 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tech-tag:hover {
|
.tech-tag:hover {
|
||||||
border-color: #6b5730;
|
border-color: #6b5730;
|
||||||
color: #c8a96e;
|
color: #c8a96e;
|
||||||
@@ -143,6 +205,7 @@ body::before {
|
|||||||
transition: color 0.2s;
|
transition: color 0.2s;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link::after {
|
.nav-link::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -153,8 +216,14 @@ body::before {
|
|||||||
background: #c8a96e;
|
background: #c8a96e;
|
||||||
transition: width 0.25s ease;
|
transition: width 0.25s ease;
|
||||||
}
|
}
|
||||||
.nav-link:hover { color: #c8a96e; }
|
|
||||||
.nav-link:hover::after { width: 100%; }
|
.nav-link:hover {
|
||||||
|
color: #c8a96e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.status-done {
|
.status-done {
|
||||||
font-family: var(--font-jetbrains), monospace;
|
font-family: var(--font-jetbrains), monospace;
|
||||||
@@ -165,6 +234,7 @@ body::before {
|
|||||||
color: #3a7a5a;
|
color: #3a7a5a;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-progress {
|
.status-progress {
|
||||||
font-family: var(--font-jetbrains), monospace;
|
font-family: var(--font-jetbrains), monospace;
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
@@ -209,11 +279,17 @@ body::before {
|
|||||||
background: #1c1f26;
|
background: #1c1f26;
|
||||||
border: 1px solid #1c1f26;
|
border: 1px solid #1c1f26;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.hero-bento-grid { grid-template-columns: repeat(2, 1fr); }
|
.hero-bento-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
.hero-bento-grid { grid-template-columns: 1fr; }
|
.hero-bento-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hero header responsive ──────────────────────────────────── */
|
/* ── Hero header responsive ──────────────────────────────────── */
|
||||||
@@ -225,8 +301,12 @@ body::before {
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
.hero-header-strip { flex-direction: column; align-items: flex-start; }
|
.hero-header-strip {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Hero section padding ────────────────────────────────────── */
|
/* ── Hero section padding ────────────────────────────────────── */
|
||||||
@@ -236,6 +316,7 @@ body::before {
|
|||||||
padding-left: 2rem;
|
padding-left: 2rem;
|
||||||
padding-right: 2rem;
|
padding-right: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 599px) {
|
@media (max-width: 599px) {
|
||||||
.hero-section {
|
.hero-section {
|
||||||
padding-top: 76px;
|
padding-top: 76px;
|
||||||
@@ -245,10 +326,31 @@ body::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Headline cursor blink ───────────────────────────────────── */
|
/* ── Headline cursor blink ───────────────────────────────────── */
|
||||||
@keyframes cursor-blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
.mobile-menu-btn {
|
||||||
50% { opacity: 0; }
|
display: flex !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mobile-menu-btn {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cursor-blink {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover .nav-underline { transform: scaleX(1) !important; }
|
||||||
|
|
||||||
.headline-cursor {
|
.headline-cursor {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 3px;
|
width: 3px;
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ export default function RootLayout({
|
|||||||
>
|
>
|
||||||
<body className="min-h-full flex flex-col antialiased" suppressHydrationWarning>
|
<body className="min-h-full flex flex-col antialiased" suppressHydrationWarning>
|
||||||
<GridCanvas />
|
<GridCanvas />
|
||||||
|
{/* Noise texture — real DOM element so pointer-events:none is reliable on Android touch */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
backgroundImage: "url(\"data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='0.04'/%3E%3C/svg%3E\")",
|
||||||
|
pointerEvents: "none",
|
||||||
|
zIndex: 1,
|
||||||
|
opacity: 0.35,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import Skills from "@/components/Skills";
|
|||||||
import Contact from "@/components/Contact";
|
import Contact from "@/components/Contact";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const { about, projects, experiences, education } = portfolioData;
|
const { about, projects, experiences, education, certificates } = portfolioData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -29,7 +29,7 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
<Projects projects={projects} />
|
<Projects projects={projects} />
|
||||||
<Experience experiences={experiences} />
|
<Experience experiences={experiences} />
|
||||||
<Education education={education} />
|
<Education education={education} certificates={certificates} />
|
||||||
<Skills
|
<Skills
|
||||||
skills={about.skills}
|
skills={about.skills}
|
||||||
languages={about.languages}
|
languages={about.languages}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
interface ContactInfo {
|
interface ContactInfo {
|
||||||
@@ -18,21 +17,6 @@ interface ContactProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Contact({ contact, fullName, image }: ContactProps) {
|
export default function Contact({ contact, fullName, image }: ContactProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); }
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
label: "Email",
|
label: "Email",
|
||||||
@@ -82,7 +66,7 @@ export default function Contact({ contact, fullName, image }: ContactProps) {
|
|||||||
return (
|
return (
|
||||||
<section id="contact" style={{ padding: "8rem 2rem" }}>
|
<section id="contact" style={{ padding: "8rem 2rem" }}>
|
||||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
||||||
<div ref={ref} className="reveal">
|
<div>
|
||||||
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Let's Connect</div>
|
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Let's Connect</div>
|
||||||
<h2
|
<h2
|
||||||
className="font-display"
|
className="font-display"
|
||||||
@@ -274,6 +258,7 @@ export default function Contact({ contact, fullName, image }: ContactProps) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
|
suppressHydrationWarning
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-jetbrains), monospace",
|
fontFamily: "var(--font-jetbrains), monospace",
|
||||||
fontSize: "0.62rem",
|
fontSize: "0.62rem",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
interface EducationItem {
|
interface EducationItem {
|
||||||
@@ -11,33 +10,21 @@ interface EducationItem {
|
|||||||
logo?: string;
|
logo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CertificateItem {
|
||||||
|
name: string;
|
||||||
|
issuer: string;
|
||||||
|
date: string;
|
||||||
|
credentialId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface EducationProps {
|
interface EducationProps {
|
||||||
education: EducationItem[];
|
education: EducationItem[];
|
||||||
|
certificates: CertificateItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function EduCard({ item, index }: { item: EducationItem; index: number }) {
|
function EduCard({ item }: { item: EducationItem }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setTimeout(() => el.classList.add("visible"), index * 100);
|
|
||||||
obs.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, [index]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className="reveal"
|
|
||||||
style={{
|
style={{
|
||||||
border: "1px solid #1c1f26",
|
border: "1px solid #1c1f26",
|
||||||
padding: "1.75rem",
|
padding: "1.75rem",
|
||||||
@@ -45,6 +32,9 @@ function EduCard({ item, index }: { item: EducationItem; index: number }) {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
transition: "border-color 0.3s",
|
transition: "border-color 0.3s",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#6b5730")}
|
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#6b5730")}
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#1c1f26")}
|
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#1c1f26")}
|
||||||
@@ -113,26 +103,80 @@ function EduCard({ item, index }: { item: EducationItem; index: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Education({ education }: EducationProps) {
|
function CertCard({ item }: { item: CertificateItem }) {
|
||||||
const headingRef = useRef<HTMLDivElement>(null);
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: "1px solid #1c1f26",
|
||||||
|
padding: "1.75rem",
|
||||||
|
background: "#0e1014",
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "border-color 0.3s",
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#c8a96e")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "#1c1f26")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="font-mono"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-jetbrains), monospace",
|
||||||
|
fontSize: "0.62rem",
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
color: "#c8a96e",
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.date}
|
||||||
|
</div>
|
||||||
|
|
||||||
useEffect(() => {
|
<h3
|
||||||
const el = headingRef.current;
|
className="font-display"
|
||||||
if (!el) return;
|
style={{
|
||||||
const obs = new IntersectionObserver(
|
fontFamily: "var(--font-bebas), sans-serif",
|
||||||
([entry]) => {
|
fontSize: "1.15rem",
|
||||||
if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); }
|
letterSpacing: "0.04em",
|
||||||
},
|
color: "#e2e4e9",
|
||||||
{ threshold: 0.1 }
|
lineHeight: 1.3,
|
||||||
|
marginBottom: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</h3>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-lora), serif",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "#6b7280",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
marginBottom: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.issuer}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
fontFamily: "var(--font-jetbrains), monospace",
|
||||||
|
fontSize: "0.55rem",
|
||||||
|
color: "#4a5060",
|
||||||
|
letterSpacing: "0.12em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
marginTop: "auto"
|
||||||
|
}}>
|
||||||
|
ID: {item.credentialId}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
obs.observe(el);
|
}
|
||||||
return () => obs.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
export default function Education({ education, certificates }: EducationProps) {
|
||||||
return (
|
return (
|
||||||
<section id="education" style={{ padding: "8rem 2rem" }}>
|
<section id="education" style={{ padding: "8rem 2rem" }}>
|
||||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
||||||
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
|
<div style={{ marginBottom: "4rem" }}>
|
||||||
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Academic Background</div>
|
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Academic Background</div>
|
||||||
<h2
|
<h2
|
||||||
className="font-display"
|
className="font-display"
|
||||||
@@ -152,19 +196,56 @@ export default function Education({ education }: EducationProps) {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 340px), 1fr))",
|
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))",
|
||||||
gap: "1px",
|
gap: "1px",
|
||||||
background: "#1c1f26",
|
background: "#1c1f26",
|
||||||
border: "1px solid #1c1f26",
|
border: "1px solid #1c1f26",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{education.map((item, i) => (
|
{education.map((item) => (
|
||||||
<div key={item.degree} style={{ background: "#07080a" }}>
|
<div key={item.degree} style={{ background: "#07080a", display: "flex" }}>
|
||||||
<EduCard item={item} index={i} />
|
<EduCard item={item} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{certificates && certificates.length > 0 && (
|
||||||
|
<div style={{ marginTop: "6rem" }}>
|
||||||
|
<div style={{ marginBottom: "3rem" }}>
|
||||||
|
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Licenses & Certifications</div>
|
||||||
|
<h3
|
||||||
|
className="font-display"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-bebas), sans-serif",
|
||||||
|
fontSize: "clamp(2rem, 4vw, 3.5rem)",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
color: "#e2e4e9",
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Certificates
|
||||||
|
</h3>
|
||||||
|
<div style={{ width: "32px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))",
|
||||||
|
gap: "1px",
|
||||||
|
background: "#1c1f26",
|
||||||
|
border: "1px solid #1c1f26",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{certificates.map((cert) => (
|
||||||
|
<div key={cert.name} style={{ background: "#07080a", display: "flex" }}>
|
||||||
|
<CertCard item={cert} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useState } from "react";
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
interface Experience {
|
interface Experience {
|
||||||
name: string;
|
name: string;
|
||||||
company: string;
|
company: string;
|
||||||
@@ -25,30 +24,10 @@ function formatDate(d: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
|
function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = ref.current;
|
|
||||||
if (!el) return;
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setTimeout(() => el.classList.add("visible"), index * 100);
|
|
||||||
obs.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, [index]);
|
|
||||||
|
|
||||||
const isCurrent = exp.endDate === "current";
|
const isCurrent = exp.endDate === "current";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
|
||||||
className="reveal"
|
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "120px 1px 1fr",
|
gridTemplateColumns: "120px 1px 1fr",
|
||||||
@@ -146,20 +125,7 @@ function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Experience({ experiences }: ExperienceProps) {
|
export default function Experience({ experiences }: ExperienceProps) {
|
||||||
const headingRef = useRef<HTMLDivElement>(null);
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = headingRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); }
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -167,7 +133,7 @@ export default function Experience({ experiences }: ExperienceProps) {
|
|||||||
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
|
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
||||||
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
|
<div style={{ marginBottom: "4rem" }}>
|
||||||
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Career Path</div>
|
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Career Path</div>
|
||||||
<h2
|
<h2
|
||||||
className="font-display"
|
className="font-display"
|
||||||
@@ -185,10 +151,52 @@ export default function Experience({ experiences }: ExperienceProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{experiences.map((exp, i) => (
|
{(showAll ? experiences : experiences.slice(0, 2)).map((exp, i) => (
|
||||||
<ExperienceItem key={exp.name} exp={exp} index={i} />
|
<div
|
||||||
|
key={exp.name}
|
||||||
|
style={{
|
||||||
|
opacity: 1,
|
||||||
|
animation: "fadeUp 0.6s ease both",
|
||||||
|
animationDelay: `${i * 100}ms`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExperienceItem exp={exp} index={i} />
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{experiences.length > 2 && (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center", marginTop: "1rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAll(!showAll)}
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-jetbrains), monospace",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
letterSpacing: "0.15em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
padding: "14px 28px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid #1c1f26",
|
||||||
|
color: "#c8a96e",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "0.5rem"
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "rgba(200, 169, 110, 0.03)";
|
||||||
|
e.currentTarget.style.borderColor = "#c8a96e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
e.currentTarget.style.borderColor = "#1c1f26";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAll ? "— Show Less" : "+ Show More"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,10 +39,18 @@ export default function GridCanvas() {
|
|||||||
window.addEventListener("resize", resize);
|
window.addEventListener("resize", resize);
|
||||||
|
|
||||||
// ── mouse tracking (global so it works across the whole page) ────────────
|
// ── mouse tracking (global so it works across the whole page) ────────────
|
||||||
const onMove = (e: MouseEvent) => { tx = e.clientX; ty = e.clientY; };
|
const onPointerMove = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = e.clientX; ty = e.clientY; } };
|
||||||
const onLeave = () => { tx = -3000; ty = -3000; };
|
const onPointerLeave = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = -3000; ty = -3000; } };
|
||||||
window.addEventListener("mousemove", onMove);
|
window.addEventListener("pointermove", onPointerMove);
|
||||||
document.documentElement.addEventListener("mouseleave", onLeave);
|
document.documentElement.addEventListener("pointerleave", onPointerLeave);
|
||||||
|
|
||||||
|
// ── touch tracking (mobile) ───────────────────────────────────────────────
|
||||||
|
const onTouch = (e: TouchEvent) => {
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (t) { tx = t.clientX; ty = t.clientY; }
|
||||||
|
};
|
||||||
|
window.addEventListener("touchstart", onTouch, { passive: true });
|
||||||
|
window.addEventListener("touchmove", onTouch, { passive: true });
|
||||||
|
|
||||||
// ── displacement: pull a point (px,py) toward the cursor ─────────────────
|
// ── displacement: pull a point (px,py) toward the cursor ─────────────────
|
||||||
const warp = (px: number, py: number): [number, number] => {
|
const warp = (px: number, py: number): [number, number] => {
|
||||||
@@ -159,8 +167,10 @@ export default function GridCanvas() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
window.removeEventListener("resize", resize);
|
window.removeEventListener("resize", resize);
|
||||||
window.removeEventListener("mousemove", onMove);
|
window.removeEventListener("pointermove", onPointerMove);
|
||||||
document.documentElement.removeEventListener("mouseleave", onLeave);
|
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
|
||||||
|
window.removeEventListener("touchstart", onTouch);
|
||||||
|
window.removeEventListener("touchmove", onTouch);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export default function Navigation() {
|
|||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
transition: "background 0.4s ease, border-color 0.4s ease",
|
transition: "background 0.4s ease, border-color 0.4s ease",
|
||||||
background: scrolled ? "rgba(7,8,10,0.94)" : "transparent",
|
background: "rgba(7,8,10,0.94)",
|
||||||
backdropFilter: scrolled ? "blur(16px)" : "none",
|
backdropFilter: "blur(16px)",
|
||||||
borderBottom: scrolled ? "1px solid #1c1f26" : "1px solid transparent",
|
borderBottom: "1px solid #1c1f26",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
@@ -127,16 +127,22 @@ export default function Navigation() {
|
|||||||
|
|
||||||
{/* Mobile toggle */}
|
{/* Mobile toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(!open)}
|
type="button"
|
||||||
className="md:hidden flex flex-col"
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="mobile-menu-btn"
|
||||||
style={{
|
style={{
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
padding: "8px",
|
padding: "12px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
gap: "5px",
|
gap: "5px",
|
||||||
|
touchAction: "manipulation",
|
||||||
}}
|
}}
|
||||||
aria-label="Toggle menu"
|
aria-label="Toggle menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls="mobile-nav-menu"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
@@ -171,13 +177,9 @@ export default function Navigation() {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Hover underline via CSS */}
|
|
||||||
<style>{`
|
|
||||||
nav a:hover .nav-underline { transform: scaleX(1) !important; }
|
|
||||||
`}</style>
|
|
||||||
|
|
||||||
{/* Mobile menu */}
|
{/* Mobile menu */}
|
||||||
<div
|
<div
|
||||||
|
id="mobile-nav-menu"
|
||||||
style={{
|
style={{
|
||||||
background: "rgba(7,8,10,0.98)",
|
background: "rgba(7,8,10,0.98)",
|
||||||
borderTop: "1px solid #1c1f26",
|
borderTop: "1px solid #1c1f26",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import SafeImage from "./SafeImage";
|
import SafeImage from "./SafeImage";
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
@@ -27,26 +27,17 @@ interface ProjectsProps {
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectCard({ project, index }: { project: Project; index: number }) {
|
function ProjectRow({
|
||||||
const [expanded, setExpanded] = useState(false);
|
project,
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
index,
|
||||||
|
isOpen,
|
||||||
useEffect(() => {
|
onToggle
|
||||||
const el = ref.current;
|
}: {
|
||||||
if (!el) return;
|
project: Project;
|
||||||
const obs = new IntersectionObserver(
|
index: number;
|
||||||
([entry]) => {
|
isOpen: boolean;
|
||||||
if (entry.isIntersecting) {
|
onToggle: () => void;
|
||||||
setTimeout(() => el.classList.add("visible"), index * 80);
|
}) {
|
||||||
obs.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, [index]);
|
|
||||||
|
|
||||||
const allTechs = Array.from(
|
const allTechs = Array.from(
|
||||||
new Set(project.tasks.flatMap((t) => t.technologies))
|
new Set(project.tasks.flatMap((t) => t.technologies))
|
||||||
);
|
);
|
||||||
@@ -54,46 +45,117 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
className="project-row"
|
||||||
className="project-card reveal"
|
style={{
|
||||||
style={{ borderRadius: 0, padding: "2rem", flex: 1, display: "flex", flexDirection: "column", position: "relative", zIndex: 1 }}
|
borderBottom: "1px solid #1c1f26",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header bar (always visible) */}
|
||||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.25rem" }}>
|
<button
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
onClick={onToggle}
|
||||||
{project.logo && (
|
style={{
|
||||||
<SafeImage
|
width: "100%",
|
||||||
src={project.logo}
|
display: "flex",
|
||||||
alt={project.name}
|
alignItems: "center",
|
||||||
width={40}
|
justifyContent: "space-between",
|
||||||
height={40}
|
padding: "2rem 0",
|
||||||
fallbackLabel={project.name.slice(0, 2).toUpperCase()}
|
background: "transparent",
|
||||||
style={{ width: "28px", height: "28px", objectFit: "contain", filter: "brightness(0.85)" }}
|
border: "none",
|
||||||
containerStyle={{ background: "#0a0b0e" }}
|
cursor: "pointer",
|
||||||
/>
|
textAlign: "left",
|
||||||
)}
|
transition: "all 0.3s ease",
|
||||||
<div>
|
}}
|
||||||
<div
|
onMouseEnter={(e) => {
|
||||||
className="section-label"
|
(e.currentTarget.querySelector(".project-title") as HTMLElement).style.color = "#c8a96e";
|
||||||
style={{ fontSize: "0.58rem", marginBottom: "0.25rem" }}
|
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.transform = isOpen ? "rotate(180deg) scale(1.1)" : "scale(1.1)";
|
||||||
|
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.color = "#c8a96e";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget.querySelector(".project-title") as HTMLElement).style.color = isOpen ? "#c8a96e" : "#e2e4e9";
|
||||||
|
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.transform = isOpen ? "rotate(180deg) scale(1)" : "scale(1)";
|
||||||
|
(e.currentTarget.querySelector(".toggle-icon") as HTMLElement).style.color = "#4a5060";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: "flex", alignItems: "baseline", gap: "2rem" }}>
|
||||||
|
<span
|
||||||
|
className="font-mono"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-jetbrains), monospace",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
color: "#4a5060",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{String(index + 1).padStart(2, "0")}
|
{String(index + 1).padStart(2, "0")}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<h3
|
<h3
|
||||||
className="font-display"
|
className="project-title font-display"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-bebas), sans-serif",
|
fontFamily: "var(--font-bebas), sans-serif",
|
||||||
fontSize: "1.6rem",
|
fontSize: "clamp(2rem, 5vw, 4rem)",
|
||||||
letterSpacing: "0.04em",
|
letterSpacing: "0.02em",
|
||||||
color: "#e2e4e9",
|
color: isOpen ? "#c8a96e" : "#e2e4e9",
|
||||||
|
transition: "color 0.3s ease",
|
||||||
|
margin: 0,
|
||||||
|
lineHeight: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{project.name}
|
{project.name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", justifyContent: "flex-end" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "2rem" }}>
|
||||||
|
<div
|
||||||
|
className="toggle-icon"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "40px",
|
||||||
|
height: "40px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
border: "1px solid #1c1f26",
|
||||||
|
color: "#4a5060",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
transform: isOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expandable Content */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: isOpen ? "1fr" : "0fr",
|
||||||
|
transition: "grid-template-rows 0.5s cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ overflow: "hidden" }}>
|
||||||
|
<div style={{ paddingBottom: "3rem", paddingTop: "0.5rem" }}>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "3rem" }}>
|
||||||
|
|
||||||
|
{/* Top part: Description & Links & Techs */}
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "3rem", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
|
||||||
|
<div style={{ flex: "1 1 500px" }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "1rem", marginBottom: "1.5rem" }}>
|
||||||
|
{project.logo && (
|
||||||
|
<SafeImage
|
||||||
|
src={project.logo}
|
||||||
|
alt={project.name}
|
||||||
|
width={48}
|
||||||
|
height={48}
|
||||||
|
fallbackLabel={project.name.slice(0, 2).toUpperCase()}
|
||||||
|
style={{ width: "36px", height: "36px", objectFit: "contain", filter: "brightness(0.9)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||||
{project.links?.map((link) => (
|
{project.links?.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.name}
|
key={link.name}
|
||||||
@@ -102,22 +164,24 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-jetbrains), monospace",
|
fontFamily: "var(--font-jetbrains), monospace",
|
||||||
fontSize: "0.6rem",
|
fontSize: "0.65rem",
|
||||||
letterSpacing: "0.12em",
|
letterSpacing: "0.1em",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
padding: "3px 10px",
|
padding: "6px 14px",
|
||||||
border: "1px solid #1c1f26",
|
border: "1px solid #1c1f26",
|
||||||
color: "#4a5060",
|
color: "#c8a96e",
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
transition: "border-color 0.2s, color 0.2s",
|
transition: "all 0.2s",
|
||||||
|
borderRadius: "2px",
|
||||||
|
background: "rgba(200, 169, 110, 0.03)"
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "rgba(200, 169, 110, 0.1)";
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "#c8a96e";
|
(e.currentTarget as HTMLElement).style.borderColor = "#c8a96e";
|
||||||
(e.currentTarget as HTMLElement).style.color = "#c8a96e";
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLElement).style.background = "rgba(200, 169, 110, 0.03)";
|
||||||
(e.currentTarget as HTMLElement).style.borderColor = "#1c1f26";
|
(e.currentTarget as HTMLElement).style.borderColor = "#1c1f26";
|
||||||
(e.currentTarget as HTMLElement).style.color = "#4a5060";
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↗ {link.name}
|
↗ {link.name}
|
||||||
@@ -126,70 +190,55 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-lora), serif",
|
fontFamily: "var(--font-lora), serif",
|
||||||
fontSize: "0.9rem",
|
fontSize: "1.05rem",
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.8,
|
||||||
color: "#6b7280",
|
color: "#9ca3af",
|
||||||
marginBottom: "1.5rem",
|
maxWidth: "700px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{project.description}
|
{project.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Tech tags */}
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "2rem" }}>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
|
{allTechs.map((tech) => (
|
||||||
{allTechs.slice(0, 8).map((tech) => (
|
<span key={tech} className="tech-tag" style={{ border: "1px solid #2a2e38", background: "#0e1014" }}>
|
||||||
<span key={tech} className="tech-tag">{tech}</span>
|
{tech}
|
||||||
))}
|
|
||||||
{allTechs.length > 8 && (
|
|
||||||
<span className="tech-tag" style={{ color: "#c8a96e", borderColor: "#6b5730" }}>
|
|
||||||
+{allTechs.length - 8}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
<div style={{ flex: "0 0 auto", pointerEvents: "none" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "auto", paddingTop: "1.5rem" }}>
|
<div style={{ display: "inline-flex", flexDirection: "column", padding: "1.5rem", background: "#0e1014", border: "1px solid #1c1f26", borderRadius: "8px" }}>
|
||||||
|
<span style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.6rem", color: "#6b7280", textTransform: "uppercase", letterSpacing: "0.15em", marginBottom: "0.5rem" }}>
|
||||||
|
Development Status
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-bebas), sans-serif", fontSize: "2.5rem", color: "#e2e4e9", lineHeight: 1 }}>
|
||||||
|
{doneCount}<span style={{ color: "#4a5060" }}>/{project.tasks.length}</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.7rem", color: "#3a7a5a", marginTop: "0.2rem" }}>
|
||||||
|
Tasks Completed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom part: Tasks Grid */}
|
||||||
|
<div style={{ marginTop: "1rem" }}>
|
||||||
|
<h4 style={{ fontFamily: "var(--font-jetbrains), monospace", fontSize: "0.75rem", letterSpacing: "0.15em", color: "#6b7280", textTransform: "uppercase", marginBottom: "1.5rem", borderBottom: "1px solid #1c1f26", paddingBottom: "1rem" }}>
|
||||||
|
Project Milestones
|
||||||
|
</h4>
|
||||||
<div
|
<div
|
||||||
className="font-mono"
|
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-jetbrains), monospace",
|
display: "grid",
|
||||||
fontSize: "0.62rem",
|
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||||
color: "#3a7a5a",
|
gap: "2rem",
|
||||||
letterSpacing: "0.08em",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{doneCount}/{project.tasks.length} tasks complete
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded(!expanded)}
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-jetbrains), monospace",
|
|
||||||
fontSize: "0.62rem",
|
|
||||||
letterSpacing: "0.12em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#c8a96e",
|
|
||||||
padding: 0,
|
|
||||||
transition: "opacity 0.2s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = "0.7")}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")}
|
|
||||||
>
|
|
||||||
{expanded ? "— Hide tasks" : "+ Show tasks"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tasks */}
|
|
||||||
{expanded && (
|
|
||||||
<div style={{ marginTop: "1.5rem", borderTop: "1px solid #1c1f26", paddingTop: "1.5rem" }}>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
|
||||||
{project.tasks.map((task) => (
|
{project.tasks.map((task) => (
|
||||||
<div
|
<div
|
||||||
key={task.name}
|
key={task.name}
|
||||||
@@ -201,74 +250,57 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: "6px",
|
width: "8px",
|
||||||
height: "6px",
|
height: "8px",
|
||||||
borderRadius: "50%",
|
borderRadius: "50%",
|
||||||
background: task.status === "Done" ? "#3a7a5a" : "#c8954a",
|
background: task.status === "Done" ? "#3a7a5a" : "#c8954a",
|
||||||
marginTop: "6px",
|
marginTop: "6px",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
boxShadow: task.status === "Done" ? "0 0 10px rgba(58,122,90,0.5)" : "0 0 10px rgba(200,149,74,0.3)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div style={{ flex: 1 }}>
|
<div>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontFamily: "var(--font-jetbrains), monospace",
|
fontFamily: "var(--font-jetbrains), monospace",
|
||||||
fontSize: "0.78rem",
|
fontSize: "0.85rem",
|
||||||
color: "#e2e4e9",
|
color: "#e2e4e9",
|
||||||
letterSpacing: "0.04em",
|
letterSpacing: "0.02em",
|
||||||
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{task.name}
|
{task.name}
|
||||||
</span>
|
</span>
|
||||||
<span className={task.status === "Done" ? "status-done" : "status-progress"}>
|
<span className={task.status === "Done" ? "status-done" : "status-progress"} style={{ fontSize: "0.5rem", padding: "3px 6px" }}>
|
||||||
{task.status}
|
{task.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.82rem", color: "#4a5060", lineHeight: 1.6, margin: 0 }}>
|
<p style={{ fontFamily: "var(--font-lora), serif", fontSize: "0.85rem", color: "#6b7280", lineHeight: 1.6, margin: 0 }}>
|
||||||
{task.description}
|
{task.description}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.3rem", marginTop: "0.5rem" }}>
|
|
||||||
{task.technologies.map((tech) => (
|
|
||||||
<span key={tech} className="tech-tag" style={{ fontSize: "0.58rem" }}>
|
|
||||||
{tech}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Projects({ projects }: ProjectsProps) {
|
export default function Projects({ projects }: ProjectsProps) {
|
||||||
const headingRef = useRef<HTMLDivElement>(null);
|
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = headingRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
el.classList.add("visible");
|
|
||||||
obs.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
obs.observe(el);
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="projects" style={{ padding: "8rem 2rem" }}>
|
<section id="projects" style={{ padding: "8rem 0" }}>
|
||||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "0 2rem" }}>
|
||||||
{/* Section header */}
|
{/* Section header */}
|
||||||
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
|
<div style={{ marginBottom: "6rem" }}>
|
||||||
<div className="section-label" style={{ marginBottom: "0.75rem" }}>
|
<div className="section-label" style={{ marginBottom: "0.75rem" }}>
|
||||||
Selected Work
|
Selected Work
|
||||||
</div>
|
</div>
|
||||||
@@ -300,20 +332,16 @@ export default function Projects({ projects }: ProjectsProps) {
|
|||||||
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
|
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* List */}
|
||||||
<div
|
<div style={{ borderTop: "1px solid #1c1f26" }}>
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 520px), 1fr))",
|
|
||||||
gap: "1.5px",
|
|
||||||
background: "#1c1f26",
|
|
||||||
border: "1px solid #1c1f26",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{projects.map((project, i) => (
|
{projects.map((project, i) => (
|
||||||
<div key={project.name} style={{ background: "#07080a", display: "flex", flexDirection: "column" }}>
|
<ProjectRow
|
||||||
<ProjectCard project={project} index={i} />
|
key={project.name}
|
||||||
</div>
|
project={project}
|
||||||
|
index={i}
|
||||||
|
isOpen={openIndex === i}
|
||||||
|
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
interface SkillsProps {
|
interface SkillsProps {
|
||||||
skills: string[];
|
skills: string[];
|
||||||
languages: { name: string; level: string }[];
|
languages: { name: string; level: string }[];
|
||||||
@@ -9,25 +7,6 @@ interface SkillsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Skills({ skills, languages, interests }: SkillsProps) {
|
export default function Skills({ skills, languages, interests }: SkillsProps) {
|
||||||
const headingRef = useRef<HTMLDivElement>(null);
|
|
||||||
const skillsRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const els = [headingRef.current, skillsRef.current].filter(Boolean);
|
|
||||||
const obs = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
entry.target.classList.add("visible");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ threshold: 0.1 }
|
|
||||||
);
|
|
||||||
els.forEach((el) => el && obs.observe(el));
|
|
||||||
return () => obs.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Give skills a pseudo-random "weight" based on their index for visual variety
|
// Give skills a pseudo-random "weight" based on their index for visual variety
|
||||||
const weights = [1.3, 1, 1.5, 1, 1.2, 0.9, 1.4, 1, 1.1, 0.95, 1.3, 1, 1.2, 0.9, 1.4, 1, 1.1, 1.3];
|
const weights = [1.3, 1, 1.5, 1, 1.2, 0.9, 1.4, 1, 1.1, 0.95, 1.3, 1, 1.2, 0.9, 1.4, 1, 1.1, 1.3];
|
||||||
|
|
||||||
@@ -44,7 +23,7 @@ export default function Skills({ skills, languages, interests }: SkillsProps) {
|
|||||||
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
|
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
||||||
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
|
<div style={{ marginBottom: "4rem" }}>
|
||||||
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Expertise</div>
|
<div className="section-label" style={{ marginBottom: "0.75rem" }}>Expertise</div>
|
||||||
<h2
|
<h2
|
||||||
className="font-display"
|
className="font-display"
|
||||||
@@ -69,7 +48,7 @@ export default function Skills({ skills, languages, interests }: SkillsProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Tech skills */}
|
{/* Tech skills */}
|
||||||
<div ref={skillsRef} className="reveal">
|
<div>
|
||||||
<div
|
<div
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -17,6 +17,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tasks": [
|
"tasks": [
|
||||||
|
{
|
||||||
|
"name": "Gazelle Test Management",
|
||||||
|
"description": "Develop a new test management module to orchestrate and track testing campaigns across systems",
|
||||||
|
"status": "In Progress",
|
||||||
|
"technologies": [
|
||||||
|
"Java 17",
|
||||||
|
"Jakarta EE",
|
||||||
|
"Quarkus",
|
||||||
|
"PostgreSQL"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Gazelle Proxy",
|
"name": "Gazelle Proxy",
|
||||||
"description": "Coordinate the renovation the Core Gazelle Proxy to support new protocols and security standards",
|
"description": "Coordinate the renovation the Core Gazelle Proxy to support new protocols and security standards",
|
||||||
@@ -30,17 +41,6 @@
|
|||||||
"Docker"
|
"Docker"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name":"Gazelle Test Management",
|
|
||||||
"description":"Develop a new test management module to orchestrate and track testing campaigns across systems",
|
|
||||||
"status":"In Progress",
|
|
||||||
"technologies":[
|
|
||||||
"Java 17",
|
|
||||||
"Jakarta EE",
|
|
||||||
"Quarkus",
|
|
||||||
"PostgreSQL"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "Validation Service API",
|
"name": "Validation Service API",
|
||||||
"description": "Create a new API to unify all the validation services consumed by Gazelle",
|
"description": "Create a new API to unify all the validation services consumed by Gazelle",
|
||||||
@@ -204,7 +204,7 @@
|
|||||||
"description": "Coordinate the technical team to deliver the best quality software",
|
"description": "Coordinate the technical team to deliver the best quality software",
|
||||||
"startDate": "01-01-2023",
|
"startDate": "01-01-2023",
|
||||||
"endDate": "current",
|
"endDate": "current",
|
||||||
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
|
"logo": "/kereval_logo.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "software-engineer-kereval",
|
"name": "software-engineer-kereval",
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
"description": "Technical Referent for the Gazelle Test Bed tools",
|
"description": "Technical Referent for the Gazelle Test Bed tools",
|
||||||
"startDate": "01-10-2021",
|
"startDate": "01-10-2021",
|
||||||
"endDate": "31-12-2022",
|
"endDate": "31-12-2022",
|
||||||
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
|
"logo": "/kereval_logo.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "intern-kereval",
|
"name": "intern-kereval",
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
"description": "Developed and improved the Gazelle Test Bed tools",
|
"description": "Developed and improved the Gazelle Test Bed tools",
|
||||||
"startDate": "06-04-2021",
|
"startDate": "06-04-2021",
|
||||||
"endDate": "31-09-2021",
|
"endDate": "31-09-2021",
|
||||||
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
|
"logo": "/kereval_logo.jpg"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "intern-veolia",
|
"name": "intern-veolia",
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
"school": "Université de Bretagne Occidentale",
|
"school": "Université de Bretagne Occidentale",
|
||||||
"startDate": "2016",
|
"startDate": "2016",
|
||||||
"endDate": "2020",
|
"endDate": "2020",
|
||||||
"logo": "/ensa.png"
|
"logo": "/ubo.webp"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"degree": "Engineer Degree in Computer Science",
|
"degree": "Engineer Degree in Computer Science",
|
||||||
@@ -257,7 +257,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"about": {
|
"about": {
|
||||||
"title":"Software Engineer",
|
"title": "Techlead Software Engineer",
|
||||||
"FullName": "Achraf Achkari",
|
"FullName": "Achraf Achkari",
|
||||||
"image": "/me.png",
|
"image": "/me.png",
|
||||||
"description": "I am a Technial Lead at Kereval. I have a strong experience in software development and I am passionate about new technologies. I am always looking for new challenges and I am eager to learn new things.",
|
"description": "I am a Technial Lead at Kereval. I have a strong experience in software development and I am passionate about new technologies. I am always looking for new challenges and I am eager to learn new things.",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
allowedDevOrigins: process.env.ALLOWED_DEV_ORIGINS
|
||||||
|
? process.env.ALLOWED_DEV_ORIGINS.split(",")
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -3247,6 +3247,7 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
|
|||||||
BIN
public/kereval_logo.jpg
Normal file
BIN
public/kereval_logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/ubo.webp
Normal file
BIN
public/ubo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user