stable state
This commit is contained in:
200
app/globals.css
200
app/globals.css
@@ -15,10 +15,15 @@
|
||||
--color-progress: #6b5730;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
scroll-behavior: smooth;
|
||||
@@ -32,66 +37,117 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
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 { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: #07080a; }
|
||||
::-webkit-scrollbar-thumb { background: #1c1f26; border-radius: 2px; }
|
||||
::-webkit-scrollbar-track {
|
||||
background: #07080a;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #1c1f26;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(32px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideRight {
|
||||
from { transform: scaleX(0); }
|
||||
to { transform: scaleX(1); }
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scroll-bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(6px); }
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(6px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fadeUp 0.75s ease both;
|
||||
}
|
||||
|
||||
.delay-1 { animation-delay: 0.15s; }
|
||||
.delay-2 { animation-delay: 0.3s; }
|
||||
.delay-3 { animation-delay: 0.45s; }
|
||||
.delay-4 { animation-delay: 0.6s; }
|
||||
.delay-5 { animation-delay: 0.75s; }
|
||||
.delay-6 { animation-delay: 0.9s; }
|
||||
.delay-1 {
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(24px);
|
||||
transition: opacity 0.7s ease, transform 0.7s ease;
|
||||
.delay-2 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
.reveal.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
|
||||
.delay-3 {
|
||||
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 {}
|
||||
|
||||
@@ -102,21 +158,26 @@ body::before {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, rgba(200,169,110,0.05) 0%, transparent 60%);
|
||||
background: linear-gradient(135deg, rgba(200, 169, 110, 0.05) 0%, transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
border-color: #6b5730;
|
||||
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 {
|
||||
font-family: var(--font-jetbrains), monospace;
|
||||
@@ -129,6 +190,7 @@ body::before {
|
||||
display: inline-block;
|
||||
transition: border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.tech-tag:hover {
|
||||
border-color: #6b5730;
|
||||
color: #c8a96e;
|
||||
@@ -143,6 +205,7 @@ body::before {
|
||||
transition: color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -153,8 +216,14 @@ body::before {
|
||||
background: #c8a96e;
|
||||
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 {
|
||||
font-family: var(--font-jetbrains), monospace;
|
||||
@@ -165,6 +234,7 @@ body::before {
|
||||
color: #3a7a5a;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
font-family: var(--font-jetbrains), monospace;
|
||||
font-size: 0.58rem;
|
||||
@@ -209,11 +279,17 @@ body::before {
|
||||
background: #1c1f26;
|
||||
border: 1px solid #1c1f26;
|
||||
}
|
||||
|
||||
@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) {
|
||||
.hero-bento-grid { grid-template-columns: 1fr; }
|
||||
.hero-bento-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Hero header responsive ──────────────────────────────────── */
|
||||
@@ -225,8 +301,12 @@ body::before {
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
@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 ────────────────────────────────────── */
|
||||
@@ -236,6 +316,7 @@ body::before {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.hero-section {
|
||||
padding-top: 76px;
|
||||
@@ -245,10 +326,31 @@ body::before {
|
||||
}
|
||||
|
||||
/* ── Headline cursor blink ───────────────────────────────────── */
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
|
||||
.mobile-menu-btn {
|
||||
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 {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
|
||||
@@ -37,7 +37,19 @@ export default function RootLayout({
|
||||
>
|
||||
<body className="min-h-full flex flex-col antialiased" suppressHydrationWarning>
|
||||
<GridCanvas />
|
||||
{children}
|
||||
{/* 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}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import Skills from "@/components/Skills";
|
||||
import Contact from "@/components/Contact";
|
||||
|
||||
export default function Home() {
|
||||
const { about, projects, experiences, education } = portfolioData;
|
||||
const { about, projects, experiences, education, certificates } = portfolioData;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -29,7 +29,7 @@ export default function Home() {
|
||||
/>
|
||||
<Projects projects={projects} />
|
||||
<Experience experiences={experiences} />
|
||||
<Education education={education} />
|
||||
<Education education={education} certificates={certificates} />
|
||||
<Skills
|
||||
skills={about.skills}
|
||||
languages={about.languages}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import SafeImage from "./SafeImage";
|
||||
|
||||
interface ContactInfo {
|
||||
@@ -18,21 +17,6 @@ interface 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 = [
|
||||
{
|
||||
label: "Email",
|
||||
@@ -82,7 +66,7 @@ export default function Contact({ contact, fullName, image }: ContactProps) {
|
||||
return (
|
||||
<section id="contact" style={{ padding: "8rem 2rem" }}>
|
||||
<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>
|
||||
<h2
|
||||
className="font-display"
|
||||
@@ -274,6 +258,7 @@ export default function Contact({ contact, fullName, image }: ContactProps) {
|
||||
>
|
||||
<div
|
||||
className="font-mono"
|
||||
suppressHydrationWarning
|
||||
style={{
|
||||
fontFamily: "var(--font-jetbrains), monospace",
|
||||
fontSize: "0.62rem",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import SafeImage from "./SafeImage";
|
||||
|
||||
interface EducationItem {
|
||||
@@ -11,33 +10,21 @@ interface EducationItem {
|
||||
logo?: string;
|
||||
}
|
||||
|
||||
interface EducationProps {
|
||||
education: EducationItem[];
|
||||
interface CertificateItem {
|
||||
name: string;
|
||||
issuer: string;
|
||||
date: string;
|
||||
credentialId: string;
|
||||
}
|
||||
|
||||
function EduCard({ item, index }: { item: EducationItem; 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]);
|
||||
interface EducationProps {
|
||||
education: EducationItem[];
|
||||
certificates: CertificateItem[];
|
||||
}
|
||||
|
||||
function EduCard({ item }: { item: EducationItem }) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="reveal"
|
||||
style={{
|
||||
border: "1px solid #1c1f26",
|
||||
padding: "1.75rem",
|
||||
@@ -45,6 +32,9 @@ function EduCard({ item, index }: { item: EducationItem; index: number }) {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
transition: "border-color 0.3s",
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "#6b5730")}
|
||||
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) {
|
||||
const headingRef = useRef<HTMLDivElement>(null);
|
||||
function CertCard({ item }: { item: CertificateItem }) {
|
||||
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(() => {
|
||||
const el = headingRef.current;
|
||||
if (!el) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) { el.classList.add("visible"); obs.disconnect(); }
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
<h3
|
||||
className="font-display"
|
||||
style={{
|
||||
fontFamily: "var(--font-bebas), sans-serif",
|
||||
fontSize: "1.15rem",
|
||||
letterSpacing: "0.04em",
|
||||
color: "#e2e4e9",
|
||||
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 (
|
||||
<section id="education" style={{ padding: "8rem 2rem" }}>
|
||||
<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>
|
||||
<h2
|
||||
className="font-display"
|
||||
@@ -152,19 +196,56 @@ export default function Education({ education }: EducationProps) {
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 340px), 1fr))",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 340px), 1fr))",
|
||||
gap: "1px",
|
||||
background: "#1c1f26",
|
||||
border: "1px solid #1c1f26",
|
||||
}}
|
||||
>
|
||||
{education.map((item, i) => (
|
||||
<div key={item.degree} style={{ background: "#07080a" }}>
|
||||
<EduCard item={item} index={i} />
|
||||
{education.map((item) => (
|
||||
<div key={item.degree} style={{ background: "#07080a", display: "flex" }}>
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
import SafeImage from "./SafeImage";
|
||||
|
||||
interface Experience {
|
||||
name: string;
|
||||
company: string;
|
||||
@@ -25,30 +24,10 @@ function formatDate(d: string) {
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="reveal"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "120px 1px 1fr",
|
||||
@@ -146,20 +125,7 @@ function ExperienceItem({ exp, index }: { exp: Experience; index: number }) {
|
||||
}
|
||||
|
||||
export default function Experience({ experiences }: ExperienceProps) {
|
||||
const headingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -167,7 +133,7 @@ export default function Experience({ experiences }: ExperienceProps) {
|
||||
style={{ padding: "8rem 2rem", background: "#0a0b0e" }}
|
||||
>
|
||||
<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>
|
||||
<h2
|
||||
className="font-display"
|
||||
@@ -185,10 +151,52 @@ export default function Experience({ experiences }: ExperienceProps) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{experiences.map((exp, i) => (
|
||||
<ExperienceItem key={exp.name} exp={exp} index={i} />
|
||||
{(showAll ? experiences : experiences.slice(0, 2)).map((exp, 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>
|
||||
|
||||
{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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -39,10 +39,18 @@ export default function GridCanvas() {
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
// ── mouse tracking (global so it works across the whole page) ────────────
|
||||
const onMove = (e: MouseEvent) => { tx = e.clientX; ty = e.clientY; };
|
||||
const onLeave = () => { tx = -3000; ty = -3000; };
|
||||
window.addEventListener("mousemove", onMove);
|
||||
document.documentElement.addEventListener("mouseleave", onLeave);
|
||||
const onPointerMove = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = e.clientX; ty = e.clientY; } };
|
||||
const onPointerLeave = (e: PointerEvent) => { if (e.pointerType === "mouse") { tx = -3000; ty = -3000; } };
|
||||
window.addEventListener("pointermove", onPointerMove);
|
||||
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 ─────────────────
|
||||
const warp = (px: number, py: number): [number, number] => {
|
||||
@@ -159,8 +167,10 @@ export default function GridCanvas() {
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
document.documentElement.removeEventListener("mouseleave", onLeave);
|
||||
window.removeEventListener("pointermove", onPointerMove);
|
||||
document.documentElement.removeEventListener("pointerleave", onPointerLeave);
|
||||
window.removeEventListener("touchstart", onTouch);
|
||||
window.removeEventListener("touchmove", onTouch);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -30,9 +30,9 @@ export default function Navigation() {
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
transition: "background 0.4s ease, border-color 0.4s ease",
|
||||
background: scrolled ? "rgba(7,8,10,0.94)" : "transparent",
|
||||
backdropFilter: scrolled ? "blur(16px)" : "none",
|
||||
borderBottom: scrolled ? "1px solid #1c1f26" : "1px solid transparent",
|
||||
background: "rgba(7,8,10,0.94)",
|
||||
backdropFilter: "blur(16px)",
|
||||
borderBottom: "1px solid #1c1f26",
|
||||
}}
|
||||
>
|
||||
<nav
|
||||
@@ -127,16 +127,22 @@ export default function Navigation() {
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="md:hidden flex flex-col"
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="mobile-menu-btn"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
padding: "8px",
|
||||
padding: "12px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "5px",
|
||||
touchAction: "manipulation",
|
||||
}}
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={open}
|
||||
aria-controls="mobile-nav-menu"
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
@@ -171,13 +177,9 @@ export default function Navigation() {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Hover underline via CSS */}
|
||||
<style>{`
|
||||
nav a:hover .nav-underline { transform: scaleX(1) !important; }
|
||||
`}</style>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div
|
||||
id="mobile-nav-menu"
|
||||
style={{
|
||||
background: "rgba(7,8,10,0.98)",
|
||||
borderTop: "1px solid #1c1f26",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import SafeImage from "./SafeImage";
|
||||
|
||||
interface Task {
|
||||
@@ -27,26 +27,17 @@ interface ProjectsProps {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
function ProjectCard({ project, index }: { project: Project; index: number }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
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 * 80);
|
||||
obs.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [index]);
|
||||
|
||||
function ProjectRow({
|
||||
project,
|
||||
index,
|
||||
isOpen,
|
||||
onToggle
|
||||
}: {
|
||||
project: Project;
|
||||
index: number;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const allTechs = Array.from(
|
||||
new Set(project.tasks.flatMap((t) => t.technologies))
|
||||
);
|
||||
@@ -54,46 +45,117 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="project-card reveal"
|
||||
style={{ borderRadius: 0, padding: "2rem", flex: 1, display: "flex", flexDirection: "column", position: "relative", zIndex: 1 }}
|
||||
className="project-row"
|
||||
style={{
|
||||
borderBottom: "1px solid #1c1f26",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.25rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
|
||||
{project.logo && (
|
||||
<SafeImage
|
||||
src={project.logo}
|
||||
alt={project.name}
|
||||
width={40}
|
||||
height={40}
|
||||
fallbackLabel={project.name.slice(0, 2).toUpperCase()}
|
||||
style={{ width: "28px", height: "28px", objectFit: "contain", filter: "brightness(0.85)" }}
|
||||
containerStyle={{ background: "#0a0b0e" }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div
|
||||
className="section-label"
|
||||
style={{ fontSize: "0.58rem", marginBottom: "0.25rem" }}
|
||||
{/* Header bar (always visible) */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "2rem 0",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "all 0.3s ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget.querySelector(".project-title") as HTMLElement).style.color = "#c8a96e";
|
||||
(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")}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<h3
|
||||
className="font-display"
|
||||
className="project-title font-display"
|
||||
style={{
|
||||
fontFamily: "var(--font-bebas), sans-serif",
|
||||
fontSize: "1.6rem",
|
||||
letterSpacing: "0.04em",
|
||||
color: "#e2e4e9",
|
||||
fontSize: "clamp(2rem, 5vw, 4rem)",
|
||||
letterSpacing: "0.02em",
|
||||
color: isOpen ? "#c8a96e" : "#e2e4e9",
|
||||
transition: "color 0.3s ease",
|
||||
margin: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{project.name}
|
||||
</h3>
|
||||
</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) => (
|
||||
<a
|
||||
key={link.name}
|
||||
@@ -102,22 +164,24 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontFamily: "var(--font-jetbrains), monospace",
|
||||
fontSize: "0.6rem",
|
||||
letterSpacing: "0.12em",
|
||||
fontSize: "0.65rem",
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
padding: "3px 10px",
|
||||
padding: "6px 14px",
|
||||
border: "1px solid #1c1f26",
|
||||
color: "#4a5060",
|
||||
color: "#c8a96e",
|
||||
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) => {
|
||||
(e.currentTarget as HTMLElement).style.background = "rgba(200, 169, 110, 0.1)";
|
||||
(e.currentTarget as HTMLElement).style.borderColor = "#c8a96e";
|
||||
(e.currentTarget as HTMLElement).style.color = "#c8a96e";
|
||||
}}
|
||||
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.color = "#4a5060";
|
||||
}}
|
||||
>
|
||||
↗ {link.name}
|
||||
@@ -126,70 +190,55 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "var(--font-lora), serif",
|
||||
fontSize: "0.9rem",
|
||||
lineHeight: 1.7,
|
||||
color: "#6b7280",
|
||||
marginBottom: "1.5rem",
|
||||
fontSize: "1.05rem",
|
||||
lineHeight: 1.8,
|
||||
color: "#9ca3af",
|
||||
maxWidth: "700px",
|
||||
}}
|
||||
>
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* Tech tags */}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
|
||||
{allTechs.slice(0, 8).map((tech) => (
|
||||
<span key={tech} className="tech-tag">{tech}</span>
|
||||
))}
|
||||
{allTechs.length > 8 && (
|
||||
<span className="tech-tag" style={{ color: "#c8a96e", borderColor: "#6b5730" }}>
|
||||
+{allTechs.length - 8}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginTop: "2rem" }}>
|
||||
{allTechs.map((tech) => (
|
||||
<span key={tech} className="tech-tag" style={{ border: "1px solid #2a2e38", background: "#0e1014" }}>
|
||||
{tech}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginTop: "auto", paddingTop: "1.5rem" }}>
|
||||
<div style={{ flex: "0 0 auto", pointerEvents: "none" }}>
|
||||
<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
|
||||
className="font-mono"
|
||||
style={{
|
||||
fontFamily: "var(--font-jetbrains), monospace",
|
||||
fontSize: "0.62rem",
|
||||
color: "#3a7a5a",
|
||||
letterSpacing: "0.08em",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
||||
gap: "2rem",
|
||||
}}
|
||||
>
|
||||
{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) => (
|
||||
<div
|
||||
key={task.name}
|
||||
@@ -201,74 +250,57 @@ function ProjectCard({ project, index }: { project: Project; index: number }) {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
borderRadius: "50%",
|
||||
background: task.status === "Done" ? "#3a7a5a" : "#c8954a",
|
||||
marginTop: "6px",
|
||||
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 style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.25rem" }}>
|
||||
<div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem", marginBottom: "0.5rem" }}>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-jetbrains), monospace",
|
||||
fontSize: "0.78rem",
|
||||
fontSize: "0.85rem",
|
||||
color: "#e2e4e9",
|
||||
letterSpacing: "0.04em",
|
||||
letterSpacing: "0.02em",
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{task.name}
|
||||
</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}
|
||||
</span>
|
||||
</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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Projects({ projects }: ProjectsProps) {
|
||||
const headingRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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();
|
||||
}, []);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<section id="projects" style={{ padding: "8rem 2rem" }}>
|
||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
||||
<section id="projects" style={{ padding: "8rem 0" }}>
|
||||
<div style={{ maxWidth: "1200px", margin: "0 auto", padding: "0 2rem" }}>
|
||||
{/* Section header */}
|
||||
<div ref={headingRef} className="reveal" style={{ marginBottom: "4rem" }}>
|
||||
<div style={{ marginBottom: "6rem" }}>
|
||||
<div className="section-label" style={{ marginBottom: "0.75rem" }}>
|
||||
Selected Work
|
||||
</div>
|
||||
@@ -300,20 +332,16 @@ export default function Projects({ projects }: ProjectsProps) {
|
||||
<div style={{ width: "48px", height: "1px", background: "#c8a96e", marginTop: "1rem" }} />
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 520px), 1fr))",
|
||||
gap: "1.5px",
|
||||
background: "#1c1f26",
|
||||
border: "1px solid #1c1f26",
|
||||
}}
|
||||
>
|
||||
{/* List */}
|
||||
<div style={{ borderTop: "1px solid #1c1f26" }}>
|
||||
{projects.map((project, i) => (
|
||||
<div key={project.name} style={{ background: "#07080a", display: "flex", flexDirection: "column" }}>
|
||||
<ProjectCard project={project} index={i} />
|
||||
</div>
|
||||
<ProjectRow
|
||||
key={project.name}
|
||||
project={project}
|
||||
index={i}
|
||||
isOpen={openIndex === i}
|
||||
onToggle={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface SkillsProps {
|
||||
skills: string[];
|
||||
languages: { name: string; level: string }[];
|
||||
@@ -9,25 +7,6 @@ interface 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
|
||||
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" }}
|
||||
>
|
||||
<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>
|
||||
<h2
|
||||
className="font-display"
|
||||
@@ -69,7 +48,7 @@ export default function Skills({ skills, languages, interests }: SkillsProps) {
|
||||
}}
|
||||
>
|
||||
{/* Tech skills */}
|
||||
<div ref={skillsRef} className="reveal">
|
||||
<div>
|
||||
<div
|
||||
className="font-mono"
|
||||
style={{
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
{
|
||||
"name":"Achraf",
|
||||
"lastName":"Achkari",
|
||||
"projects":[
|
||||
"name": "Achraf",
|
||||
"lastName": "Achkari",
|
||||
"projects": [
|
||||
{
|
||||
"name":"Gazelle",
|
||||
"description":"Gazelle Test Bed",
|
||||
"name": "Gazelle",
|
||||
"description": "Gazelle Test Bed",
|
||||
"logo": "https://gazelle.ihe.net/files/LOGO_0.png",
|
||||
"links":[
|
||||
"links": [
|
||||
{
|
||||
"name":"Deployed",
|
||||
"url":"https://gazelle.ihe.net/"
|
||||
"name": "Deployed",
|
||||
"url": "https://gazelle.ihe.net/"
|
||||
},
|
||||
{
|
||||
"name":"Github",
|
||||
"url":"https://gitlab.inria.fr/gazelle/"
|
||||
"name": "Github",
|
||||
"url": "https://gitlab.inria.fr/gazelle/"
|
||||
}
|
||||
],
|
||||
"tasks":[
|
||||
"tasks": [
|
||||
{
|
||||
"name":"Gazelle Proxy",
|
||||
"description":"Coordinate the renovation the Core Gazelle Proxy to support new protocols and security standards",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"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",
|
||||
"description": "Coordinate the renovation the Core Gazelle Proxy to support new protocols and security standards",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Java 17/21",
|
||||
"Jakarta EE",
|
||||
"Quarkus",
|
||||
@@ -31,21 +42,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"description":"Create a new API to unify all the validation services consumed by Gazelle",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Validation Service API",
|
||||
"description": "Create a new API to unify all the validation services consumed by Gazelle",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Java 17",
|
||||
"MicroProfile",
|
||||
"JAX-RS",
|
||||
@@ -53,10 +53,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Gazelle Objects Checker",
|
||||
"description":"Renovate the validator generator engine to recent JDK and enhance the validation rules",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Gazelle Objects Checker",
|
||||
"description": "Renovate the validator generator engine to recent JDK and enhance the validation rules",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Java",
|
||||
"OCL",
|
||||
"XML",
|
||||
@@ -67,25 +67,25 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Typeson",
|
||||
"description":"Polymorphic JSON serialization and deserialization library",
|
||||
"name": "Typeson",
|
||||
"description": "Polymorphic JSON serialization and deserialization library",
|
||||
"logo": "/typesonlogo-light.png",
|
||||
"links":[
|
||||
"links": [
|
||||
{
|
||||
"name":"Github",
|
||||
"url":"https://github.com/achachraf/Typeson"
|
||||
"name": "Github",
|
||||
"url": "https://github.com/achachraf/Typeson"
|
||||
},
|
||||
{
|
||||
"name":"Maven",
|
||||
"url":"https://mvnrepository.com/artifact/io.github.achachraf/typeson/1.0.1"
|
||||
"name": "Maven",
|
||||
"url": "https://mvnrepository.com/artifact/io.github.achachraf/typeson/1.0.1"
|
||||
}
|
||||
],
|
||||
"tasks":[
|
||||
"tasks": [
|
||||
{
|
||||
"name":"JSON Object Mapper",
|
||||
"description":"Develop a new JSON object mapper to support polymorphic serialization and deserialization",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "JSON Object Mapper",
|
||||
"description": "Develop a new JSON object mapper to support polymorphic serialization and deserialization",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Java 17",
|
||||
"GraphScan",
|
||||
"Reflection",
|
||||
@@ -93,10 +93,10 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Integration with Jackson",
|
||||
"description":"Integrate Typeson with Jackson to support polymorphic serialization and deserialization",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Integration with Jackson",
|
||||
"description": "Integrate Typeson with Jackson to support polymorphic serialization and deserialization",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Java 17",
|
||||
"Jackson"
|
||||
]
|
||||
@@ -104,29 +104,29 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"FOON",
|
||||
"description":"TypeScript SDK for semantic JSON transformation using LLMs — turn messy JSON into your schema, safely",
|
||||
"name": "FOON",
|
||||
"description": "TypeScript SDK for semantic JSON transformation using LLMs — turn messy JSON into your schema, safely",
|
||||
"logo": "/foon-logo.svg",
|
||||
"links":[
|
||||
"links": [
|
||||
{
|
||||
"name":"Website",
|
||||
"url":"https://foon.ink"
|
||||
"name": "Website",
|
||||
"url": "https://foon.ink"
|
||||
},
|
||||
{
|
||||
"name":"Github",
|
||||
"url":"https://github.com/achachraf/foon-sdk"
|
||||
"name": "Github",
|
||||
"url": "https://github.com/achachraf/foon-sdk"
|
||||
},
|
||||
{
|
||||
"name":"NPM",
|
||||
"url":"https://www.npmjs.com/package/foon-sdk"
|
||||
"name": "NPM",
|
||||
"url": "https://www.npmjs.com/package/foon-sdk"
|
||||
}
|
||||
],
|
||||
"tasks":[
|
||||
"tasks": [
|
||||
{
|
||||
"name":"Mapping Engine",
|
||||
"description":"Core engine that uses LLM providers to generate semantic mapping plans, then deterministically executes them against a JSON Schema with confidence scoring",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Mapping Engine",
|
||||
"description": "Core engine that uses LLM providers to generate semantic mapping plans, then deterministically executes them against a JSON Schema with confidence scoring",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"TypeScript",
|
||||
"AJV",
|
||||
"JSONPath",
|
||||
@@ -134,29 +134,29 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Security Layer",
|
||||
"description":"Input validation, prompt injection protection, sensitive field redaction, and prototype pollution detection",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Security Layer",
|
||||
"description": "Input validation, prompt injection protection, sensitive field redaction, and prototype pollution detection",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"TypeScript",
|
||||
"Schema Validation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Express Middleware",
|
||||
"description":"Drop-in Express integration with dual-route transformation and configurable error handling",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Express Middleware",
|
||||
"description": "Drop-in Express integration with dual-route transformation and configurable error handling",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"TypeScript",
|
||||
"Express",
|
||||
"REST"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Marketing Site & Docs",
|
||||
"description":"Interactive demo website with live transformation showcase, MDX documentation, and waitlist backend",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Marketing Site & Docs",
|
||||
"description": "Interactive demo website with live transformation showcase, MDX documentation, and waitlist backend",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Next.js",
|
||||
"React 19",
|
||||
"Tailwind CSS",
|
||||
@@ -167,25 +167,25 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"CLI Portfolio",
|
||||
"description":"Create a CLI portfolio to display my resume",
|
||||
"name": "CLI Portfolio",
|
||||
"description": "Create a CLI portfolio to display my resume",
|
||||
"logo": "/CLIPortfolioLogo.png",
|
||||
"tasks":[
|
||||
"tasks": [
|
||||
{
|
||||
"name":"Main CLI",
|
||||
"description":"Create the main CLI with basic commands handling",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Main CLI",
|
||||
"description": "Create the main CLI with basic commands handling",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Next.js",
|
||||
"SSR",
|
||||
"Typescript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name":"Enhanced terminal",
|
||||
"description":"Enhance the terminal to support graphical content",
|
||||
"status":"Done",
|
||||
"technologies":[
|
||||
"name": "Enhanced terminal",
|
||||
"description": "Enhance the terminal to support graphical content",
|
||||
"status": "Done",
|
||||
"technologies": [
|
||||
"Next.js",
|
||||
"Next API",
|
||||
"SSR",
|
||||
@@ -198,70 +198,70 @@
|
||||
],
|
||||
"experiences": [
|
||||
{
|
||||
"name":"techlead-kereval",
|
||||
"company":"Kereval",
|
||||
"position":"TechLead",
|
||||
"description":"Coordinate the technical team to deliver the best quality software",
|
||||
"startDate":"01-01-2023",
|
||||
"endDate":"current",
|
||||
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
|
||||
"name": "techlead-kereval",
|
||||
"company": "Kereval",
|
||||
"position": "TechLead",
|
||||
"description": "Coordinate the technical team to deliver the best quality software",
|
||||
"startDate": "01-01-2023",
|
||||
"endDate": "current",
|
||||
"logo": "/kereval_logo.jpg"
|
||||
},
|
||||
{
|
||||
"name":"software-engineer-kereval",
|
||||
"company":"Kereval",
|
||||
"position":"Software Engineer",
|
||||
"description":"Technical Referent for the Gazelle Test Bed tools",
|
||||
"startDate":"01-10-2021",
|
||||
"endDate":"31-12-2022",
|
||||
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
|
||||
"name": "software-engineer-kereval",
|
||||
"company": "Kereval",
|
||||
"position": "Software Engineer",
|
||||
"description": "Technical Referent for the Gazelle Test Bed tools",
|
||||
"startDate": "01-10-2021",
|
||||
"endDate": "31-12-2022",
|
||||
"logo": "/kereval_logo.jpg"
|
||||
},
|
||||
{
|
||||
"name":"intern-kereval",
|
||||
"company":"Kereval",
|
||||
"position":"Intern",
|
||||
"description":"Developed and improved the Gazelle Test Bed tools",
|
||||
"startDate":"06-04-2021",
|
||||
"endDate":"31-09-2021",
|
||||
"logo": "https://www.kereval.com/wp-content/uploads/2021/06/Logo_Kereval_WR.png"
|
||||
"name": "intern-kereval",
|
||||
"company": "Kereval",
|
||||
"position": "Intern",
|
||||
"description": "Developed and improved the Gazelle Test Bed tools",
|
||||
"startDate": "06-04-2021",
|
||||
"endDate": "31-09-2021",
|
||||
"logo": "/kereval_logo.jpg"
|
||||
},
|
||||
{
|
||||
"name":"intern-veolia",
|
||||
"company":"Veolia",
|
||||
"position":"Intern",
|
||||
"description":"Developed Ticketing System for the company's employees",
|
||||
"startDate":"01-07-2019",
|
||||
"endDate":"01-08-2019",
|
||||
"name": "intern-veolia",
|
||||
"company": "Veolia",
|
||||
"position": "Intern",
|
||||
"description": "Developed Ticketing System for the company's employees",
|
||||
"startDate": "01-07-2019",
|
||||
"endDate": "01-08-2019",
|
||||
"logo": "/veolia_logo.png"
|
||||
}
|
||||
],
|
||||
"education":[
|
||||
"education": [
|
||||
{
|
||||
"degree":"Master Degree in Computer Science",
|
||||
"school":"Université de Bretagne Occidentale",
|
||||
"startDate":"2016",
|
||||
"endDate":"2020",
|
||||
"degree": "Master Degree in Computer Science",
|
||||
"school": "Université de Bretagne Occidentale",
|
||||
"startDate": "2016",
|
||||
"endDate": "2020",
|
||||
"logo": "/ubo.webp"
|
||||
},
|
||||
{
|
||||
"degree": "Engineer Degree in Computer Science",
|
||||
"school": "Ecole Nationale des Sciences Appliquées de Tanger",
|
||||
"startDate": "2016",
|
||||
"endDate": "2020",
|
||||
"logo": "/ensa.png"
|
||||
},
|
||||
{
|
||||
"degree":"Engineer Degree in Computer Science",
|
||||
"school":"Ecole Nationale des Sciences Appliquées de Tanger",
|
||||
"startDate":"2016",
|
||||
"endDate":"2020",
|
||||
"logo": "/ensa.png"
|
||||
},
|
||||
{
|
||||
"degree":"Baccalauréat",
|
||||
"school":"Lycée Ibn Batouta",
|
||||
"startDate":"2015",
|
||||
"endDate":"2016"
|
||||
"degree": "Baccalauréat",
|
||||
"school": "Lycée Ibn Batouta",
|
||||
"startDate": "2015",
|
||||
"endDate": "2016"
|
||||
}
|
||||
],
|
||||
"about":{
|
||||
"title":"Software Engineer",
|
||||
"FullName":"Achraf Achkari",
|
||||
"about": {
|
||||
"title": "Techlead Software Engineer",
|
||||
"FullName": "Achraf Achkari",
|
||||
"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.",
|
||||
"skills":[
|
||||
"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.",
|
||||
"skills": [
|
||||
"Java",
|
||||
"Jakarta EE",
|
||||
"MicroProfile",
|
||||
@@ -279,28 +279,28 @@
|
||||
"Typescript",
|
||||
"Jest"
|
||||
],
|
||||
"contact":{
|
||||
"email":"achrafachkari@gmail.com",
|
||||
"phone":"+33 7 82 92 98 79",
|
||||
"linkedin":"https://www.linkedin.com/in/achraf-achkari/",
|
||||
"github":"https://github.com/achachraf/",
|
||||
"Address":"Toulouse, France"
|
||||
"contact": {
|
||||
"email": "achrafachkari@gmail.com",
|
||||
"phone": "+33 7 82 92 98 79",
|
||||
"linkedin": "https://www.linkedin.com/in/achraf-achkari/",
|
||||
"github": "https://github.com/achachraf/",
|
||||
"Address": "Toulouse, France"
|
||||
},
|
||||
"languages":[
|
||||
"languages": [
|
||||
{
|
||||
"name":"French",
|
||||
"level":"C1"
|
||||
"name": "French",
|
||||
"level": "C1"
|
||||
},
|
||||
{
|
||||
"name":"English",
|
||||
"level":"C2"
|
||||
"name": "English",
|
||||
"level": "C2"
|
||||
},
|
||||
{
|
||||
"name":"Arabic",
|
||||
"level":"Native"
|
||||
"name": "Arabic",
|
||||
"level": "Native"
|
||||
}
|
||||
],
|
||||
"interests":[
|
||||
"interests": [
|
||||
"Mathematics",
|
||||
"Piano",
|
||||
"Writing",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
allowedDevOrigins: process.env.ALLOWED_DEV_ORIGINS
|
||||
? process.env.ALLOWED_DEV_ORIGINS.split(",")
|
||||
: [],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -3247,6 +3247,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"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