Compare commits
3 Commits
0da09abeda
...
master
| Author | SHA256 | Date | |
|---|---|---|---|
| 30f5616deb | |||
| 9f8b81fd0c | |||
| db92ad2f88 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/dist
|
||||
/node_modules
|
||||
package-lock.json
|
||||
BIN
assets/bg.png
Normal file
BIN
assets/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
assets/capi.mp3
Normal file
BIN
assets/capi.mp3
Normal file
Binary file not shown.
BIN
assets/moan.mp3
Normal file
BIN
assets/moan.mp3
Normal file
Binary file not shown.
24
index.html
Normal file
24
index.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/media/bg.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="depoliticized. - personal identity & portfolio"
|
||||
/>
|
||||
<meta name="theme-color" content="#1A1A1A" />
|
||||
<title>depoliticized.</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
package.json
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "depoliticized-portfolio",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"gsap": "^3.12.5",
|
||||
"lenis": "^1.1.13",
|
||||
"lucide-react": "^0.460.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
13
pub-pgp.txt
Normal file
13
pub-pgp.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEZzigpRYJKwYBBAHaRw8BAQdAt3o7m1YIEwDC+wStOTX4FRbkjEFTJ3KiSEC9
|
||||
YLcUk+20HWV4Y3VzM3MgPGNvbnRhY3RAc3RlYWxlci53dGY+iJkEExYKAEEWIQRv
|
||||
xuv7mSWfs1bTXy2ARacLZTIVdwUCZzigpQIbAwUJBaN1CwULCQgHAgIiAgYVCgkI
|
||||
CwIEFgIDAQIeBwIXgAAKCRCARacLZTIVd5PjAP49C0bBm5Utku41cNmWJ4H+JcJP
|
||||
FJyO6VCkPc6RlSpn5gEA7mGcOMkAym9ASFShJ1/o7ZZGSBhnFJ2HQx7H0lDEzAq4
|
||||
OARnOKClEgorBgEEAZdVAQUBAQdADUszFmROJl5SKOWrEuth432zT4AXav+lqS2q
|
||||
fPaSfjsDAQgHiH4EGBYKACYWIQRvxuv7mSWfs1bTXy2ARacLZTIVdwUCZzigpQIb
|
||||
DAUJBaN1CwAKCRCARacLZTIVd8yWAP4nq3mnf/QKmfAouqTjV6hxaPDjAH3x9axk
|
||||
hGSQOOnhGgD/Zfoc+wr9YA7CfNIcZMsIalsH4RzKwAXtjyIJRea4OAA=
|
||||
=LFH8
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
BIN
public/media/bg.png
Normal file
BIN
public/media/bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
BIN
public/media/capi.mp3
Normal file
BIN
public/media/capi.mp3
Normal file
Binary file not shown.
BIN
public/media/moan.mp3
Normal file
BIN
public/media/moan.mp3
Normal file
Binary file not shown.
13
public/pub-pgp.txt
Normal file
13
public/pub-pgp.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mDMEZzigpRYJKwYBBAHaRw8BAQdAt3o7m1YIEwDC+wStOTX4FRbkjEFTJ3KiSEC9
|
||||
YLcUk+20HWV4Y3VzM3MgPGNvbnRhY3RAc3RlYWxlci53dGY+iJkEExYKAEEWIQRv
|
||||
xuv7mSWfs1bTXy2ARacLZTIVdwUCZzigpQIbAwUJBaN1CwULCQgHAgIiAgYVCgkI
|
||||
CwIEFgIDAQIeBwIXgAAKCRCARacLZTIVd5PjAP49C0bBm5Utku41cNmWJ4H+JcJP
|
||||
FJyO6VCkPc6RlSpn5gEA7mGcOMkAym9ASFShJ1/o7ZZGSBhnFJ2HQx7H0lDEzAq4
|
||||
OARnOKClEgorBgEEAZdVAQUBAQdADUszFmROJl5SKOWrEuth432zT4AXav+lqS2q
|
||||
fPaSfjsDAQgHiH4EGBYKACYWIQRvxuv7mSWfs1bTXy2ARacLZTIVdwUCZzigpQIb
|
||||
DAUJBaN1CwAKCRCARacLZTIVd8yWAP4nq3mnf/QKmfAouqTjV6hxaPDjAH3x9axk
|
||||
hGSQOOnhGgD/Zfoc+wr9YA7CfNIcZMsIalsH4RzKwAXtjyIJRea4OAA=
|
||||
=LFH8
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
111
src/App.tsx
Normal file
111
src/App.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import Lenis from 'lenis'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
|
||||
import LoadingScreen from './components/LoadingScreen'
|
||||
import Navigation from './components/Navigation'
|
||||
import HeroSection from './components/HeroSection'
|
||||
import AboutSection from './components/AboutSection'
|
||||
import InterestsSection from './components/InterestsSection'
|
||||
import ProjectsSection from './components/ProjectsSection'
|
||||
import ExperienceSection from './components/ExperienceSection'
|
||||
import TopicsSection from './components/TopicsSection'
|
||||
import ContactSection from './components/ContactSection'
|
||||
import Footer from './components/Footer'
|
||||
import FloatingParticles from './components/FloatingParticles'
|
||||
import ScrollProgress from './components/ScrollProgress'
|
||||
import NoiseOverlay from './components/NoiseOverlay'
|
||||
import { useReducedMotion } from './hooks/useReducedMotion'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
export default function App() {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [showContent, setShowContent] = useState(false)
|
||||
const lenisRef = useRef<Lenis | null>(null)
|
||||
const mainRef = useRef<HTMLElement>(null)
|
||||
const capiAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
capiAudioRef.current = new Audio('./media/capi.mp3')
|
||||
capiAudioRef.current.preload = 'auto'
|
||||
capiAudioRef.current.loop = true
|
||||
capiAudioRef.current.volume = 0.25
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
})
|
||||
lenisRef.current = lenis
|
||||
|
||||
lenis.on('scroll', ScrollTrigger.update)
|
||||
|
||||
const rafCallback = (time: number) => {
|
||||
lenis.raf(time * 1000)
|
||||
}
|
||||
gsap.ticker.add(rafCallback)
|
||||
gsap.ticker.lagSmoothing(0)
|
||||
|
||||
return () => {
|
||||
gsap.ticker.remove(rafCallback)
|
||||
lenis.destroy()
|
||||
}
|
||||
}, [loaded])
|
||||
|
||||
const handleLoadComplete = useCallback(() => {
|
||||
setLoaded(true)
|
||||
// Small delay before showing content for transition
|
||||
setTimeout(() => {
|
||||
setShowContent(true)
|
||||
}, 100)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showContent) {
|
||||
// Start ambient capi.mp3 loop
|
||||
capiAudioRef.current?.play().catch(() => {})
|
||||
}
|
||||
}, [showContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (showContent && mainRef.current && !reducedMotion) {
|
||||
gsap.fromTo(
|
||||
mainRef.current,
|
||||
{ opacity: 0, y: 40 },
|
||||
{ opacity: 1, y: 0, duration: 1.2, ease: 'expo.out' }
|
||||
)
|
||||
}
|
||||
}, [showContent, reducedMotion])
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingScreen onLoadComplete={handleLoadComplete} />
|
||||
|
||||
{showContent && (
|
||||
<>
|
||||
<ScrollProgress />
|
||||
<NoiseOverlay />
|
||||
<FloatingParticles />
|
||||
<Navigation />
|
||||
<main ref={mainRef} className="relative" style={{ opacity: reducedMotion ? 1 : 0 }}>
|
||||
<HeroSection />
|
||||
<AboutSection />
|
||||
<InterestsSection />
|
||||
<ProjectsSection />
|
||||
<ExperienceSection />
|
||||
<TopicsSection />
|
||||
<ContactSection />
|
||||
<Footer />
|
||||
</main>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
128
src/components/AboutSection.tsx
Normal file
128
src/components/AboutSection.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { MapPin, BookOpen, User } from "lucide-react";
|
||||
import { useReducedMotion } from "../hooks/useReducedMotion";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
export default function AboutSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || !sectionRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
".about-reveal",
|
||||
{ y: 50, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 1,
|
||||
stagger: 0.15,
|
||||
ease: "expo.out",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top 70%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
},
|
||||
);
|
||||
}, sectionRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="about"
|
||||
ref={sectionRef}
|
||||
className="relative py-32 md:py-40 px-6"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Section label */}
|
||||
<div className="about-reveal flex items-center gap-4 mb-16">
|
||||
<span className="text-pink-brand font-mono text-xs tracking-[0.3em] uppercase">
|
||||
001
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-pink-brand/20" />
|
||||
<span className="text-grey-500 font-display text-xs tracking-[0.2em] uppercase">
|
||||
Identity
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main heading */}
|
||||
<h2 className="about-reveal font-display text-4xl md:text-6xl lg:text-7xl font-light text-grey-900 mb-16 leading-tight">
|
||||
A young adult from
|
||||
<br />
|
||||
<span className="text-gradient">Europe</span>
|
||||
</h2>
|
||||
|
||||
{/* Content grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-12">
|
||||
{/* Left column - main text */}
|
||||
<div className="md:col-span-7 space-y-8">
|
||||
<p className="about-reveal text-grey-600 text-lg md:text-xl leading-relaxed font-body">
|
||||
I am a curious mind navigating the intersection of technology,
|
||||
psychology, and philosophy. My work spans from low-level systems
|
||||
to sophisticated web platforms, always driven by a desire to
|
||||
understand how things work beneath the surface.
|
||||
</p>
|
||||
<p className="about-reveal text-grey-500 text-base leading-relaxed font-body">
|
||||
Reading is a core part of who I am - it shapes how I think about
|
||||
problems and approach solutions. I believe in building things that
|
||||
are both technically sound and thoughtfully designed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right column - details */}
|
||||
<div className="md:col-span-5 space-y-6">
|
||||
<div className="about-reveal group p-6 rounded-2xl bg-skin-100/50 border border-pink-brand/5 hover:border-pink-brand/15 transition-all duration-300 hover:shadow-lg hover:shadow-pink-brand/5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-xl bg-pink-brand/10 text-pink-brand group-hover:bg-pink-brand group-hover:text-white transition-all duration-300">
|
||||
<MapPin className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display font-medium text-grey-900 mb-1">
|
||||
Location
|
||||
</h3>
|
||||
<p className="text-grey-500 text-sm font-body">Europe</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-reveal group p-6 rounded-2xl bg-skin-100/50 border border-pink-brand/5 hover:border-pink-brand/15 transition-all duration-300 hover:shadow-lg hover:shadow-pink-brand/5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-xl bg-pink-brand/10 text-pink-brand group-hover:bg-pink-brand group-hover:text-white transition-all duration-300">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display font-medium text-grey-900 mb-1">
|
||||
Age
|
||||
</h3>
|
||||
<p className="text-grey-500 text-sm font-body">Young adult</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-reveal group p-6 rounded-2xl bg-skin-100/50 border border-pink-brand/5 hover:border-pink-brand/15 transition-all duration-300 hover:shadow-lg hover:shadow-pink-brand/5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-xl bg-pink-brand/10 text-pink-brand group-hover:bg-pink-brand group-hover:text-white transition-all duration-300">
|
||||
<BookOpen className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-display font-medium text-grey-900 mb-1">
|
||||
Passion
|
||||
</h3>
|
||||
<p className="text-grey-500 text-sm font-body">Avid reader</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
258
src/components/ContactSection.tsx
Normal file
258
src/components/ContactSection.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import {
|
||||
MessageSquare,
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
Key,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useReducedMotion } from "../hooks/useReducedMotion";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
interface ContactItem {
|
||||
label: string;
|
||||
value: string;
|
||||
href?: string;
|
||||
isLink?: boolean;
|
||||
unavailable?: boolean;
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
interface ContactGroup {
|
||||
category: string;
|
||||
items: ContactItem[];
|
||||
}
|
||||
|
||||
const contacts: ContactGroup[] = [
|
||||
{
|
||||
category: "Messaging",
|
||||
items: [
|
||||
{
|
||||
label: "Simplex",
|
||||
value: "Open chat",
|
||||
href: "https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2F3_XOiEnk2Ebup7DqgOwGM5kXlIZX-uJo%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAPJz5X-WHhJk_y1MLRxy7NTfspD-vKF0pmmi_GAJpExY%253D%26q%3Dc%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion",
|
||||
isLink: true,
|
||||
},
|
||||
{
|
||||
label: "Discord",
|
||||
value:
|
||||
"@sysrand [1408202642588438589] / @forgecadrape [1452113626365169958]",
|
||||
},
|
||||
{ label: "Telegram", value: "@forgecadrape (main) / @xorededx" },
|
||||
{ label: "XMPP", value: "sw@anoxinon.me (main) / me0w@xmpp.jp" },
|
||||
{
|
||||
label: "Session",
|
||||
value:
|
||||
"056834db96cedde6012da9d5402c683c0eb260ed866da145a8f86e7f329bf77222",
|
||||
},
|
||||
{
|
||||
label: "Tox",
|
||||
value:
|
||||
"5622A2D2218AF235E39E356792270413BBE7CAEEAA531A20EEF11BF2FD10576E5050F0A55970",
|
||||
},
|
||||
{ label: "Potato Chat", value: "@depoliticized" },
|
||||
{
|
||||
label: "Element",
|
||||
value: "Open profile",
|
||||
href: "https://matrix.to/#/@depoliticized:tchncs.de",
|
||||
isLink: true,
|
||||
},
|
||||
{ label: "Briar", value: "temporary unavailable", unavailable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Email",
|
||||
items: [
|
||||
{
|
||||
label: "Primary",
|
||||
value: "contact@stealer.wtf (temporary unavailable)",
|
||||
unavailable: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Git",
|
||||
items: [
|
||||
{
|
||||
label: "serpent256",
|
||||
value: "View profile",
|
||||
href: "https://git.fingeri.ng/serpent256",
|
||||
isLink: true,
|
||||
},
|
||||
{
|
||||
label: "raped.cc",
|
||||
value: "View profile (main)",
|
||||
href: "https://git.fingeri.ng/raped.cc",
|
||||
isLink: true,
|
||||
},
|
||||
{
|
||||
label: "whiskers",
|
||||
value: "View org",
|
||||
href: "https://git.fingeri.ng/whiskers",
|
||||
isLink: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "PGP",
|
||||
items: [
|
||||
{
|
||||
label: "Public Key",
|
||||
value: "Download /pub-pgp.txt",
|
||||
href: "./pub-pgp.txt",
|
||||
isLink: true,
|
||||
icon: Key,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded-md text-grey-400 hover:text-pink-brand hover:bg-pink-brand/10 transition-all"
|
||||
aria-label="Copy to clipboard"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || !sectionRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
".contact-reveal",
|
||||
{ y: 40, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 0.8,
|
||||
stagger: 0.1,
|
||||
ease: "expo.out",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top 70%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
},
|
||||
);
|
||||
}, sectionRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="contact"
|
||||
ref={sectionRef}
|
||||
className="relative py-32 md:py-40 px-6 overflow-hidden"
|
||||
>
|
||||
{!reducedMotion && (
|
||||
<div className="absolute bottom-0 left-1/2 w-[800px] h-[400px] -translate-x-1/2 translate-y-1/2 rounded-full bg-pink-brand/3 blur-[120px] pointer-events-none" />
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto relative z-10">
|
||||
{/* Section label */}
|
||||
<div className="contact-reveal flex items-center gap-4 mb-16">
|
||||
<span className="text-pink-brand font-mono text-xs tracking-[0.3em] uppercase">
|
||||
006
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-pink-brand/20" />
|
||||
<span className="text-grey-500 font-display text-xs tracking-[0.2em] uppercase">
|
||||
Contact
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="contact-reveal mb-16">
|
||||
<h2 className="font-display text-4xl md:text-6xl lg:text-7xl font-light text-grey-900 mb-4">
|
||||
Get in <span className="text-gradient">touch</span>
|
||||
</h2>
|
||||
<p className="text-grey-500 text-lg font-body max-w-xl">
|
||||
Reach out through any of these channels. I prefer encrypted
|
||||
messaging when possible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{contacts.map((group) => (
|
||||
<div key={group.category} className="contact-reveal">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MessageSquare className="w-4 h-4 text-pink-brand" />
|
||||
<h3 className="font-display text-sm font-medium text-grey-900 uppercase tracking-widest">
|
||||
{group.category}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{group.items.map((item) => (
|
||||
<div
|
||||
key={item.label + item.value}
|
||||
className="group flex items-center justify-between p-4 rounded-xl bg-skin-100/40 border border-pink-brand/5 hover:border-pink-brand/15 hover:bg-skin-100/70 transition-all duration-300"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-xs font-mono text-grey-400 block mb-0.5">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.isLink && item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-grey-700 hover:text-pink-brand transition-colors font-body"
|
||||
>
|
||||
{item.icon && <item.icon className="w-3.5 h-3.5" />}
|
||||
{item.value}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
) : item.unavailable ? (
|
||||
<span className="text-sm italic text-red-400 font-body">
|
||||
{item.value}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-grey-700 font-body break-all">
|
||||
{item.value}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!item.isLink && !item.unavailable && (
|
||||
<CopyButton text={item.value} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
135
src/components/ExperienceSection.tsx
Normal file
135
src/components/ExperienceSection.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { Code2, Server, Shield, Cpu, GitBranch, Bot, Cloud, Layers } from 'lucide-react'
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
const skills = [
|
||||
{ icon: Code2, label: 'Languages', items: 'Rust, V, D, Go, TypeScript, Python, PHP, Elixir' },
|
||||
{ icon: Layers, label: 'Frontend', items: 'Astro, Svelte 5, lightweight & modern UIs' },
|
||||
{ icon: Server, label: 'Backend & APIs', items: 'REST API design, complex infrastructure' },
|
||||
{ icon: Cpu, label: 'Low Level', items: 'Systems programming, kernel research' },
|
||||
{ icon: Shield, label: 'Security Research', items: 'Evasion, exfiltration, cryptography' },
|
||||
{ icon: GitBranch, label: 'DevOps', items: 'Self-hosting, Docker, monorepo architecture' },
|
||||
{ icon: Bot, label: 'Automation', items: 'Telegram / Discord bots & scripts' },
|
||||
{ icon: Cloud, label: 'Networks', items: 'Secure communications, protocol design' },
|
||||
]
|
||||
|
||||
const earlyProjects = [
|
||||
{ name: 'Rose-Stealer_old', url: 'https://github.com/0xRose/Rose-Stealer_old', desc: 'Professional & efficient credential stealer written in python' },
|
||||
{ name: 'Rose-RAT', url: 'https://github.com/0xRose/Rose-RAT', desc: 'Remote Administration Toolkit Extension to Rose-Stealer with web-host and client controller' },
|
||||
{ name: 'Rose-Stealer', url: 'https://github.com/0xRose/Rose-Stealer', desc: 'Slightly refined & more modern version of Rosev1' },
|
||||
{ name: 'Rose-Obf', url: 'https://github.com/gumbobrot/Rose-Obf', desc: 'Rose Python obfuscator & encryptor' },
|
||||
{ name: 'RoseGuardian', url: 'https://github.com/gumbobrot/RoseGuardian', desc: 'Prior experimental version of Rose obf' },
|
||||
{ name: 'PyAnalyzer', url: 'https://github.com/gumbobrot/PyAnalyzer', desc: 'Python script utilizing pycdc and pyinstxtractor to decompile pyinstaller packed executables' },
|
||||
{ name: 'Knight-RAT', url: 'https://github.com/gumbobrot/Knight-RAT', desc: 'Discord-bot managed Remote Access Trojan' },
|
||||
]
|
||||
|
||||
export default function ExperienceSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || !sectionRef.current) return
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
'.exp-reveal',
|
||||
{ y: 40, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 0.8,
|
||||
stagger: 0.08,
|
||||
ease: 'expo.out',
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: 'top 70%',
|
||||
toggleActions: 'play none none none',
|
||||
},
|
||||
}
|
||||
)
|
||||
}, sectionRef)
|
||||
|
||||
return () => ctx.revert()
|
||||
}, [reducedMotion])
|
||||
|
||||
return (
|
||||
<section
|
||||
id="experience"
|
||||
ref={sectionRef}
|
||||
className="relative py-32 md:py-40 px-6 overflow-hidden"
|
||||
>
|
||||
{!reducedMotion && (
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] rounded-full bg-pink-brand/3 blur-[100px] pointer-events-none translate-x-1/2 -translate-y-1/2" />
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto relative z-10">
|
||||
{/* Section label */}
|
||||
<div className="exp-reveal flex items-center gap-4 mb-16">
|
||||
<span className="text-pink-brand font-mono text-xs tracking-[0.3em] uppercase">
|
||||
004
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-pink-brand/20" />
|
||||
<span className="text-grey-500 font-display text-xs tracking-[0.2em] uppercase">
|
||||
Experience
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skills grid */}
|
||||
<h3 className="exp-reveal font-display text-2xl md:text-3xl font-light text-grey-900 mb-10">
|
||||
Capabilities
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-24">
|
||||
{skills.map((skill) => (
|
||||
<div
|
||||
key={skill.label}
|
||||
className="exp-reveal group p-5 rounded-2xl bg-skin-100/40 border border-pink-brand/5 hover:border-pink-brand/15 hover:bg-skin-100/70 transition-all duration-300"
|
||||
>
|
||||
<skill.icon className="w-5 h-5 text-pink-brand mb-3 group-hover:scale-110 transition-transform" />
|
||||
<h4 className="font-display text-sm font-medium text-grey-900 mb-1">{skill.label}</h4>
|
||||
<p className="text-grey-500 text-xs leading-relaxed font-body">{skill.items}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Early history */}
|
||||
<h3 className="exp-reveal font-display text-2xl md:text-3xl font-light text-grey-900 mb-4">
|
||||
Early History
|
||||
</h3>
|
||||
<p className="exp-reveal text-grey-500 text-base font-body mb-10 max-w-2xl">
|
||||
Projects from 2+ years ago that shaped my foundation. These represent my first
|
||||
serious forays into software development and security research.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{earlyProjects.map((project) => (
|
||||
<a
|
||||
key={project.name}
|
||||
href={project.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="exp-reveal group flex items-center justify-between p-4 rounded-xl bg-skin-100/30 border border-pink-brand/5 hover:border-pink-brand/15 hover:bg-skin-100/60 transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<GitBranch className="w-4 h-4 text-grey-400 group-hover:text-pink-brand transition-colors" />
|
||||
<div>
|
||||
<span className="font-display text-sm font-medium text-grey-900 group-hover:text-pink-brand transition-colors">
|
||||
{project.name}
|
||||
</span>
|
||||
<p className="text-grey-500 text-xs font-body hidden sm:block">{project.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-grey-400 group-hover:text-pink-brand transition-colors">
|
||||
github
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
94
src/components/FloatingParticles.tsx
Normal file
94
src/components/FloatingParticles.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion'
|
||||
|
||||
interface Particle {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
speedX: number
|
||||
speedY: number
|
||||
opacity: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export default function FloatingParticles() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion) return
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let animationId: number
|
||||
let particles: Particle[] = []
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
const colors = ['rgba(255, 45, 122, 0.15)', 'rgba(255, 107, 157, 0.12)', 'rgba(255, 182, 193, 0.1)']
|
||||
|
||||
const createParticles = () => {
|
||||
particles = []
|
||||
const count = Math.min(Math.floor(window.innerWidth / 15), 80)
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: Math.random() * 3 + 1,
|
||||
speedX: (Math.random() - 0.5) * 0.3,
|
||||
speedY: (Math.random() - 0.5) * 0.3,
|
||||
opacity: Math.random() * 0.5 + 0.1,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
})
|
||||
}
|
||||
}
|
||||
createParticles()
|
||||
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
particles.forEach((p) => {
|
||||
p.x += p.speedX
|
||||
p.y += p.speedY
|
||||
|
||||
if (p.x < 0) p.x = canvas.width
|
||||
if (p.x > canvas.width) p.x = 0
|
||||
if (p.y < 0) p.y = canvas.height
|
||||
if (p.y > canvas.height) p.y = 0
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = p.color
|
||||
ctx.fill()
|
||||
})
|
||||
|
||||
animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId)
|
||||
window.removeEventListener('resize', resize)
|
||||
}
|
||||
}, [reducedMotion])
|
||||
|
||||
if (reducedMotion) return null
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="fixed inset-0 z-0 pointer-events-none"
|
||||
style={{ opacity: 0.6 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
23
src/components/Footer.tsx
Normal file
23
src/components/Footer.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Heart } from 'lucide-react'
|
||||
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="relative py-16 px-6 border-t border-pink-brand/5">
|
||||
<div className="max-w-6xl mx-auto flex flex-col items-center justify-center text-center gap-4">
|
||||
<p className="font-mono text-sm text-grey-400 tracking-[0.3em]">
|
||||
depoliticized.
|
||||
</p>
|
||||
|
||||
<p className="flex items-center gap-1.5 text-xs text-grey-500 font-body">
|
||||
Built with <Heart className="w-3 h-3 text-pink-brand fill-pink-brand" /> in Europe
|
||||
</p>
|
||||
|
||||
<p className="font-mono text-xs text-grey-400">
|
||||
{year}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
101
src/components/HeroSection.tsx
Normal file
101
src/components/HeroSection.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { gsap } from 'gsap'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion'
|
||||
|
||||
export default function HeroSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const titleRef = useRef<HTMLHeadingElement>(null)
|
||||
const subtitleRef = useRef<HTMLParagraphElement>(null)
|
||||
const reducedMotion = useReducedMotion()
|
||||
const [subtitleText, setSubtitleText] = useState('')
|
||||
const fullSubtitle = 'identity · research · code'
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion) {
|
||||
setSubtitleText(fullSubtitle)
|
||||
return
|
||||
}
|
||||
|
||||
// Title entrance animation
|
||||
if (titleRef.current) {
|
||||
gsap.fromTo(
|
||||
titleRef.current,
|
||||
{ y: 60, opacity: 0, scale: 0.95 },
|
||||
{ y: 0, opacity: 1, scale: 1, duration: 1.4, ease: 'expo.out', delay: 0.3 }
|
||||
)
|
||||
}
|
||||
|
||||
// Typing effect for subtitle
|
||||
let index = 0
|
||||
const interval = setInterval(() => {
|
||||
if (index <= fullSubtitle.length) {
|
||||
setSubtitleText(fullSubtitle.slice(0, index))
|
||||
index++
|
||||
} else {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, 80)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [reducedMotion])
|
||||
|
||||
const handleScrollDown = () => {
|
||||
const about = document.querySelector('#about')
|
||||
if (about) about.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={sectionRef}
|
||||
className="relative min-h-screen flex flex-col items-center justify-center overflow-hidden"
|
||||
>
|
||||
{/* Ambient shapes */}
|
||||
{!reducedMotion && (
|
||||
<>
|
||||
<div className="absolute top-1/5 left-1/5 w-[500px] h-[500px] rounded-full bg-pink-brand/5 blur-[100px] ambient-shape-1 pointer-events-none" />
|
||||
<div className="absolute bottom-1/4 right-1/5 w-[400px] h-[400px] rounded-full bg-pink-soft/5 blur-[80px] ambient-shape-2 pointer-events-none" />
|
||||
<div className="absolute top-1/3 right-1/4 w-2 h-2 rounded-full bg-pink-brand/30 ambient-shape-3 pointer-events-none" />
|
||||
<div className="absolute bottom-1/3 left-1/3 w-3 h-3 rounded-full bg-pink-soft/20 ambient-shape-1 pointer-events-none" style={{ animationDelay: '-5s' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main content */}
|
||||
<div className="relative z-10 text-center px-6">
|
||||
<h1
|
||||
ref={titleRef}
|
||||
className="font-mono font-light text-grey-900 select-none text-[clamp(1.75rem,7vw,10rem)] tracking-[0.05em] md:tracking-[0.15em] leading-[1.1]"
|
||||
>
|
||||
depoliticized.
|
||||
</h1>
|
||||
|
||||
<p
|
||||
ref={subtitleRef}
|
||||
className="mt-6 font-display text-grey-500 text-sm md:text-base tracking-[0.3em] uppercase typing-cursor"
|
||||
>
|
||||
{subtitleText}
|
||||
</p>
|
||||
|
||||
<div className="mt-12 flex justify-center">
|
||||
<div className="w-px h-16 bg-pink-brand/20 relative overflow-hidden">
|
||||
{!reducedMotion && (
|
||||
<div className="absolute top-0 left-0 w-full h-1/2 bg-pink-brand/60 animate-pulse-slow" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<button
|
||||
onClick={handleScrollDown}
|
||||
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-grey-500 hover:text-pink-brand transition-colors animate-float"
|
||||
aria-label="Scroll down"
|
||||
>
|
||||
<ChevronDown className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
{/* Edge gradient fade */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-skin-50" style={{ maskImage: 'linear-gradient(to bottom, transparent, black)' }} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
160
src/components/InterestsSection.tsx
Normal file
160
src/components/InterestsSection.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { Music, Brain, Sparkles } from "lucide-react";
|
||||
import { useReducedMotion } from "../hooks/useReducedMotion";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const musicGenres = [
|
||||
"Nu-Metal",
|
||||
"Alt-Metal",
|
||||
"Scenecore",
|
||||
"Nightcore",
|
||||
"Russian Hardtekk",
|
||||
"EDM",
|
||||
"90's Music",
|
||||
];
|
||||
|
||||
export default function InterestsSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || !sectionRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
".interest-reveal",
|
||||
{ y: 40, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 0.8,
|
||||
stagger: 0.1,
|
||||
ease: "expo.out",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top 70%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
gsap.fromTo(
|
||||
".genre-tag",
|
||||
{ scale: 0.8, opacity: 0 },
|
||||
{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
duration: 0.6,
|
||||
stagger: 0.06,
|
||||
ease: "back.out(1.7)",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top 60%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
},
|
||||
);
|
||||
}, sectionRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="interests"
|
||||
ref={sectionRef}
|
||||
className="relative py-32 md:py-40 px-6 overflow-hidden"
|
||||
>
|
||||
{/* Background ambient */}
|
||||
{!reducedMotion && (
|
||||
<div className="absolute top-1/2 left-0 w-[600px] h-[600px] -translate-y-1/2 -translate-x-1/2 rounded-full bg-pink-brand/3 blur-[120px] pointer-events-none" />
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto relative z-10">
|
||||
{/* Section label */}
|
||||
<div className="interest-reveal flex items-center gap-4 mb-16">
|
||||
<span className="text-pink-brand font-mono text-xs tracking-[0.3em] uppercase">
|
||||
002
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-pink-brand/20" />
|
||||
<span className="text-grey-500 font-display text-xs tracking-[0.2em] uppercase">
|
||||
Interests
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-20">
|
||||
{/* Music */}
|
||||
<div>
|
||||
<div className="interest-reveal flex items-center gap-3 mb-8">
|
||||
<div className="p-3 rounded-xl bg-pink-brand/10 text-pink-brand">
|
||||
<Music className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-display text-2xl md:text-3xl font-light text-grey-900">
|
||||
Music
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="interest-reveal text-grey-500 text-base leading-relaxed font-body mb-8">
|
||||
Music is essential to my rhythm. From heavy riffs to high-speed
|
||||
electronic beats, these are the sounds that fuel my focus and
|
||||
creative energy.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{musicGenres.map((genre) => (
|
||||
<span
|
||||
key={genre}
|
||||
className="genre-tag px-4 py-2 rounded-full text-sm font-display text-grey-700 bg-skin-100 border border-pink-brand/10 hover:border-pink-brand/30 hover:bg-pink-brand/5 hover:text-pink-brand transition-all duration-300 cursor-default"
|
||||
>
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mind */}
|
||||
<div>
|
||||
<div className="interest-reveal flex items-center gap-3 mb-8">
|
||||
<div className="p-3 rounded-xl bg-pink-brand/10 text-pink-brand">
|
||||
<Brain className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="font-display text-2xl md:text-3xl font-light text-grey-900">
|
||||
The Mind
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="interest-reveal text-grey-500 text-base leading-relaxed font-body mb-8">
|
||||
Beyond code, I am deeply fascinated by how humans think, feel, and
|
||||
perceive reality. Psychology and philosophy provide the frameworks
|
||||
I use to understand complex systems - both technical and human.
|
||||
</p>
|
||||
|
||||
<div className="interest-reveal grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-skin-100/50 border border-pink-brand/5 hover:border-pink-brand/15 transition-all duration-300 group">
|
||||
<Sparkles className="w-5 h-5 text-pink-brand mb-3 group-hover:scale-110 transition-transform" />
|
||||
<h4 className="font-display font-medium text-grey-900 text-sm mb-1">
|
||||
Psychology
|
||||
</h4>
|
||||
<p className="text-grey-500 text-xs font-body">
|
||||
Cognitive patterns & behavior
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-skin-100/50 border border-pink-brand/5 hover:border-pink-brand/15 transition-all duration-300 group">
|
||||
<Sparkles className="w-5 h-5 text-pink-brand mb-3 group-hover:scale-110 transition-transform" />
|
||||
<h4 className="font-display font-medium text-grey-900 text-sm mb-1">
|
||||
Philosophy
|
||||
</h4>
|
||||
<p className="text-grey-500 text-xs font-body">
|
||||
Ethics, logic & existence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
187
src/components/LoadingScreen.tsx
Normal file
187
src/components/LoadingScreen.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { gsap } from 'gsap'
|
||||
import { Volume2, VolumeX, AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion'
|
||||
|
||||
interface LoadingScreenProps {
|
||||
onLoadComplete: () => void
|
||||
}
|
||||
|
||||
export default function LoadingScreen({ onLoadComplete }: LoadingScreenProps) {
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [audioEnabled, setAudioEnabled] = useState(true)
|
||||
const [exiting, setExiting] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
audioRef.current = new Audio('./media/moan.mp3')
|
||||
audioRef.current.preload = 'auto'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || reducedMotion) return
|
||||
gsap.fromTo(
|
||||
containerRef.current.querySelectorAll('.load-item'),
|
||||
{ y: 30, opacity: 0 },
|
||||
{ y: 0, opacity: 1, duration: 0.8, stagger: 0.12, ease: 'expo.out', delay: 0.2 }
|
||||
)
|
||||
}, [reducedMotion])
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
if (!accepted || loading) return
|
||||
setLoading(true)
|
||||
|
||||
// Play moan audio during loading
|
||||
if (audioEnabled && audioRef.current) {
|
||||
audioRef.current.volume = 0.6
|
||||
audioRef.current.play().catch(() => {
|
||||
// Audio blocked by browser policy, continue anyway
|
||||
})
|
||||
}
|
||||
|
||||
// Simulate loading progress
|
||||
const startTime = Date.now()
|
||||
const duration = 2500 // 2.5s minimum load time
|
||||
|
||||
const updateProgress = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const p = Math.min((elapsed / duration) * 100, 100)
|
||||
setProgress(p)
|
||||
|
||||
if (p < 100) {
|
||||
requestAnimationFrame(updateProgress)
|
||||
} else {
|
||||
// Finish loading
|
||||
setTimeout(() => {
|
||||
// Stop moan.mp3 before transitioning
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.currentTime = 0
|
||||
}
|
||||
setExiting(true)
|
||||
setTimeout(() => {
|
||||
onLoadComplete()
|
||||
}, 800)
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(updateProgress)
|
||||
}, [accepted, loading, audioEnabled, onLoadComplete])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`fixed inset-0 z-[100] flex flex-col items-center justify-center bg-grey-900 transition-all duration-700 ${
|
||||
exiting ? 'opacity-0 scale-105' : 'opacity-100 scale-100'
|
||||
}`}
|
||||
style={{ pointerEvents: exiting ? 'none' : 'auto' }}
|
||||
>
|
||||
{/* Ambient background shapes */}
|
||||
{!reducedMotion && (
|
||||
<>
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 rounded-full bg-pink-brand/5 blur-3xl ambient-shape-1" />
|
||||
<div className="absolute bottom-1/3 right-1/4 w-96 h-96 rounded-full bg-pink-soft/5 blur-3xl ambient-shape-2" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center max-w-md w-full px-6">
|
||||
{/* Banner image */}
|
||||
<div className="load-item mb-8 relative">
|
||||
<div className="overflow-hidden rounded-lg shadow-2xl shadow-pink-brand/10">
|
||||
<img
|
||||
src="./media/bg.png"
|
||||
alt="Banner"
|
||||
className="w-48 h-auto object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
{!reducedMotion && (
|
||||
<div className="absolute -inset-1 rounded-lg bg-pink-brand/20 blur-xl -z-10 animate-pulse-slow" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Load text */}
|
||||
<h1 className="load-item text-4xl md:text-5xl font-mono font-light text-skin-100 tracking-widest mb-2">
|
||||
Load~
|
||||
</h1>
|
||||
|
||||
<p className="load-item text-grey-500 text-sm mb-8 font-body">
|
||||
depoliticized.
|
||||
</p>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="load-item w-full mb-6">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accepted}
|
||||
onChange={(e) => setAccepted(e.target.checked)}
|
||||
className="mt-1 w-4 h-4 rounded border-yellow-500/30 bg-transparent accent-pink-brand focus:ring-pink-brand/50 focus:ring-2"
|
||||
/>
|
||||
<span className="text-yellow-200/80 text-xs leading-relaxed font-body">
|
||||
I acknowledge that the creator is not responsible for any content shown on this page. All links, features, contacts, projects, etc. are entirely for research and personal educational purposes, not meant to be deployed or actually harm anyone.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audio toggle */}
|
||||
<div className="load-item flex items-center gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setAudioEnabled(!audioEnabled)}
|
||||
className="flex items-center gap-2 text-xs text-grey-500 hover:text-pink-soft transition-colors"
|
||||
>
|
||||
{audioEnabled ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" />}
|
||||
<span>{audioEnabled ? 'Sound on' : 'Sound off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Load button */}
|
||||
<button
|
||||
onClick={handleLoad}
|
||||
disabled={!accepted || loading}
|
||||
className={`load-item group relative px-12 py-4 rounded-full font-display font-medium text-sm tracking-wider uppercase transition-all duration-300 overflow-hidden ${
|
||||
accepted && !loading
|
||||
? 'bg-pink-brand text-white hover:bg-pink-bright hover:shadow-lg hover:shadow-pink-brand/30 hover:scale-105'
|
||||
: 'bg-grey-700 text-grey-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative z-10">Load</span>
|
||||
)}
|
||||
{!loading && accepted && !reducedMotion && (
|
||||
<span className="absolute inset-0 bg-pink-bright translate-y-full group-hover:translate-y-0 transition-transform duration-300 ease-out" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Progress bar */}
|
||||
{loading && (
|
||||
<div className="load-item w-full mt-8">
|
||||
<div className="h-1 w-full bg-grey-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-pink-brand rounded-full transition-all duration-100 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-grey-500 text-xs mt-2 font-mono">
|
||||
{Math.round(progress)}%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
133
src/components/Navigation.tsx
Normal file
133
src/components/Navigation.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'About', href: '#about' },
|
||||
{ label: 'Interests', href: '#interests' },
|
||||
{ label: 'Projects', href: '#projects' },
|
||||
{ label: 'Experience', href: '#experience' },
|
||||
{ label: 'Topics', href: '#topics' },
|
||||
{ label: 'Contact', href: '#contact' },
|
||||
]
|
||||
|
||||
export default function Navigation() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setVisible(window.scrollY > window.innerHeight * 0.6)
|
||||
}
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveSection(entry.target.id)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ rootMargin: '-40% 0px -40% 0px' }
|
||||
)
|
||||
|
||||
navItems.forEach((item) => {
|
||||
const el = document.querySelector(item.href)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
e.preventDefault()
|
||||
const el = document.querySelector(href)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth' })
|
||||
setMobileOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-500 ${
|
||||
visible ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="glass">
|
||||
<div className="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}}
|
||||
className="font-mono text-sm text-grey-900 hover:text-pink-brand transition-colors tracking-widest"
|
||||
>
|
||||
depoliticized.
|
||||
</a>
|
||||
|
||||
{/* Desktop nav */}
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => handleClick(e, item.href)}
|
||||
className={`text-xs font-display uppercase tracking-widest transition-colors relative ${
|
||||
activeSection === item.href.slice(1)
|
||||
? 'text-pink-brand'
|
||||
: 'text-grey-600 hover:text-grey-900'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
{activeSection === item.href.slice(1) && (
|
||||
<span className="absolute -bottom-1 left-0 right-0 h-px bg-pink-brand" />
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
className="md:hidden text-grey-900"
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
<div
|
||||
className={`md:hidden glass overflow-hidden transition-all duration-300 ${
|
||||
mobileOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}
|
||||
>
|
||||
<div className="px-6 py-4 flex flex-col gap-4">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={(e) => handleClick(e, item.href)}
|
||||
className={`text-sm font-display uppercase tracking-widest transition-colors ${
|
||||
activeSection === item.href.slice(1)
|
||||
? 'text-pink-brand'
|
||||
: 'text-grey-600 hover:text-grey-900'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
src/components/NoiseOverlay.tsx
Normal file
17
src/components/NoiseOverlay.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion'
|
||||
|
||||
export default function NoiseOverlay() {
|
||||
const reducedMotion = useReducedMotion()
|
||||
if (reducedMotion) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[55] pointer-events-none opacity-[0.015]"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
|
||||
backgroundRepeat: 'repeat',
|
||||
backgroundSize: '128px 128px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
243
src/components/ProjectsSection.tsx
Normal file
243
src/components/ProjectsSection.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import {
|
||||
Activity,
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Terminal,
|
||||
Globe,
|
||||
Lock,
|
||||
Database,
|
||||
} from "lucide-react";
|
||||
import { useReducedMotion } from "../hooks/useReducedMotion";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const activeProjects = [
|
||||
{
|
||||
title: "Seraph C2",
|
||||
description:
|
||||
"Full fledged, interactive, post-exploitation C2 system with AI-support, E2EE, evasion, cross-platform featuring and large scaled exfiltration features.",
|
||||
icon: Terminal,
|
||||
tags: ["Security Research", "AI", "E2EE"],
|
||||
status: "LIVE",
|
||||
},
|
||||
{
|
||||
title: "AnonPay (tocador.app-style)",
|
||||
description:
|
||||
"Payment gateway that allows paying in hundreds of different cryptocurrencies whilst still receiving only one by utilizing multiple swap aggregators and joining different, rotating wallet pools.",
|
||||
icon: Globe,
|
||||
tags: ["Crypto", "Finance", "Payments"],
|
||||
status: "LIVE",
|
||||
},
|
||||
{
|
||||
title: "kippenkartell.cc",
|
||||
description:
|
||||
"Dockerized Premium SaaS marketplace platform with a clean monorepo architecture: SvelteKit frontend, Rust REST API, MySQL database.",
|
||||
icon: Database,
|
||||
tags: ["SaaS", "SvelteKit", "Rust"],
|
||||
status: "N/A",
|
||||
},
|
||||
{
|
||||
title: "spying.su",
|
||||
description:
|
||||
"Discord, Social Media & Breach Data Aggregation and Web Intelligence Platform.",
|
||||
icon: Lock,
|
||||
tags: ["OSINT", "Data", "Intelligence"],
|
||||
status: "N/A",
|
||||
},
|
||||
];
|
||||
|
||||
const plannedProjects = [
|
||||
{
|
||||
title: "webforum",
|
||||
description:
|
||||
"Suitable & simple breachforum's & WPD-style webforum for anonymous media submission, role assignment systems, localized restriction control & community/topic merging, additionally private chats & E2EE secured chats.",
|
||||
},
|
||||
{
|
||||
title: "porn page",
|
||||
description:
|
||||
"A webforum/archive & user-control system for manageable, quick, fast & secure access/uploading to/of pornography media.",
|
||||
},
|
||||
{
|
||||
title: "vpn-protocol",
|
||||
description:
|
||||
"Low-level interface VPN protocol featuring extremely tiny binary & lightweight byte transmitting using NDISAPI (curve25519 -> serpent(threefish512)).",
|
||||
},
|
||||
];
|
||||
|
||||
const completedProjects = [
|
||||
{
|
||||
title: "Cryptography Library",
|
||||
description:
|
||||
"A threefish512 (customized padding & improved security) implementation in D based off a russian barebones cryptographic algorithm & a ported shamir's secret sharing implementation in python with a bridger written in C.",
|
||||
link: "https://git.fingeri.ng/whiskers/cryptography",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProjectsSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || !sectionRef.current) return;
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
".project-reveal",
|
||||
{ y: 50, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 0.9,
|
||||
stagger: 0.12,
|
||||
ease: "expo.out",
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: "top 70%",
|
||||
toggleActions: "play none none none",
|
||||
},
|
||||
},
|
||||
);
|
||||
}, sectionRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [reducedMotion]);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="projects"
|
||||
ref={sectionRef}
|
||||
className="relative py-32 md:py-40 px-6"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Section label */}
|
||||
<div className="project-reveal flex items-center gap-4 mb-16">
|
||||
<span className="text-pink-brand font-mono text-xs tracking-[0.3em] uppercase">
|
||||
003
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-pink-brand/20" />
|
||||
<span className="text-grey-500 font-display text-xs tracking-[0.2em] uppercase">
|
||||
Projects
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Active Projects */}
|
||||
<div className="mb-24">
|
||||
<div className="project-reveal flex items-center gap-3 mb-10">
|
||||
<Activity className="w-5 h-5 text-pink-brand" />
|
||||
<h3 className="font-display text-xl md:text-2xl font-medium text-grey-900">
|
||||
Active
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 rounded-full bg-pink-brand/10 text-pink-brand text-xs font-mono">
|
||||
{activeProjects.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{activeProjects.map((project) => (
|
||||
<div
|
||||
key={project.title}
|
||||
className="project-reveal group p-6 md:p-8 rounded-2xl bg-skin-100/40 border border-pink-brand/5 hover:border-pink-brand/20 hover:bg-skin-100/80 transition-all duration-500 hover:shadow-xl hover:shadow-pink-brand/5"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="p-3 rounded-xl bg-pink-brand/10 text-pink-brand group-hover:bg-pink-brand group-hover:text-white transition-all duration-300">
|
||||
<project.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5 text-xs font-mono text-pink-brand">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-pink-brand animate-pulse" />
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="font-display text-lg font-medium text-grey-900 mb-3 group-hover:text-pink-brand transition-colors">
|
||||
{project.title}
|
||||
</h4>
|
||||
<p className="text-grey-500 text-sm leading-relaxed font-body mb-4">
|
||||
{project.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 rounded-md text-xs font-mono text-grey-600 bg-skin-200/50"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Planned Projects */}
|
||||
<div className="mb-24">
|
||||
<div className="project-reveal flex items-center gap-3 mb-10">
|
||||
<Rocket className="w-5 h-5 text-pink-soft" />
|
||||
<h3 className="font-display text-xl md:text-2xl font-medium text-grey-900">
|
||||
Planned
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 rounded-full bg-pink-soft/10 text-pink-soft text-xs font-mono">
|
||||
{plannedProjects.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{plannedProjects.map((project, i) => (
|
||||
<div
|
||||
key={project.title}
|
||||
className="project-reveal group flex gap-6 p-6 rounded-2xl bg-skin-100/30 border border-pink-brand/5 hover:border-pink-brand/15 hover:bg-skin-100/60 transition-all duration-300"
|
||||
>
|
||||
<span className="font-mono text-2xl text-pink-brand/30 group-hover:text-pink-brand/60 transition-colors">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-display text-base font-medium text-grey-900 mb-2 group-hover:text-pink-brand transition-colors">
|
||||
{project.title}
|
||||
</h4>
|
||||
<p className="text-grey-500 text-sm leading-relaxed font-body">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completed Projects */}
|
||||
<div>
|
||||
<div className="project-reveal flex items-center gap-3 mb-10">
|
||||
<CheckCircle2 className="w-5 h-5 text-grey-500" />
|
||||
<h3 className="font-display text-xl md:text-2xl font-medium text-grey-900">
|
||||
Completed
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{completedProjects.map((project) => (
|
||||
<a
|
||||
key={project.title}
|
||||
href={project.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="project-reveal group flex items-start justify-between gap-4 p-6 rounded-2xl bg-skin-100/30 border border-pink-brand/5 hover:border-pink-brand/20 hover:bg-skin-100/60 transition-all duration-300"
|
||||
>
|
||||
<div>
|
||||
<h4 className="font-display text-base font-medium text-grey-900 mb-2 group-hover:text-pink-brand transition-colors">
|
||||
{project.title}
|
||||
</h4>
|
||||
<p className="text-grey-500 text-sm leading-relaxed font-body">
|
||||
{project.description}
|
||||
</p>
|
||||
</div>
|
||||
<ExternalLink className="w-5 h-5 text-grey-400 group-hover:text-pink-brand flex-shrink-0 transition-colors" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
src/components/ScrollProgress.tsx
Normal file
32
src/components/ScrollProgress.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion'
|
||||
|
||||
export default function ScrollProgress() {
|
||||
const [progress, setProgress] = useState(0)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight
|
||||
const scrollPercent = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0
|
||||
setProgress(scrollPercent)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [reducedMotion])
|
||||
|
||||
if (reducedMotion) return null
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 right-0 z-[60] h-[2px] bg-transparent">
|
||||
<div
|
||||
className="h-full bg-pink-brand transition-all duration-100 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
src/components/TopicsSection.tsx
Normal file
137
src/components/TopicsSection.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { gsap } from 'gsap'
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||
import { Telescope, ArrowUpRight } from 'lucide-react'
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
const topics = [
|
||||
{
|
||||
title: 'OpenClaw / MCP Servers',
|
||||
description: 'Exploring modular control plane architectures and automated orchestration systems.',
|
||||
},
|
||||
{
|
||||
title: 'ML / AI Training',
|
||||
description: 'Deep diving into model training pipelines, fine-tuning strategies, and deployment patterns.',
|
||||
},
|
||||
{
|
||||
title: 'LLM Definition & Background',
|
||||
description: 'Understanding the theoretical foundations and architectural evolution of large language models.',
|
||||
},
|
||||
{
|
||||
title: 'Low Level & Exploit Development',
|
||||
description: 'Memory corruption, binary exploitation, and vulnerability research at the systems level.',
|
||||
},
|
||||
{
|
||||
title: 'Game Security Research',
|
||||
description: 'External / internal cheat development & kernel level anti-cheat exploitation research.',
|
||||
},
|
||||
{
|
||||
title: 'Database Engineering',
|
||||
description: 'Efficiency, management & scaling strategies for high-throughput data systems.',
|
||||
},
|
||||
{
|
||||
title: 'Stability & Concurrency',
|
||||
description: 'Improved async handling, fault tolerance, and concurrent system design patterns.',
|
||||
},
|
||||
]
|
||||
|
||||
export default function TopicsSection() {
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion || !sectionRef.current) return
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
'.topic-reveal',
|
||||
{ y: 30, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 0.7,
|
||||
stagger: 0.1,
|
||||
ease: 'expo.out',
|
||||
scrollTrigger: {
|
||||
trigger: sectionRef.current,
|
||||
start: 'top 70%',
|
||||
toggleActions: 'play none none none',
|
||||
},
|
||||
}
|
||||
)
|
||||
}, sectionRef)
|
||||
|
||||
return () => ctx.revert()
|
||||
}, [reducedMotion])
|
||||
|
||||
return (
|
||||
<section
|
||||
id="topics"
|
||||
ref={sectionRef}
|
||||
className="relative py-32 md:py-40 px-6"
|
||||
>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Section label */}
|
||||
<div className="topic-reveal flex items-center gap-4 mb-16">
|
||||
<span className="text-pink-brand font-mono text-xs tracking-[0.3em] uppercase">
|
||||
005
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-pink-brand/20" />
|
||||
<span className="text-grey-500 font-display text-xs tracking-[0.2em] uppercase">
|
||||
Research
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
|
||||
{/* Left sticky heading */}
|
||||
<div className="lg:col-span-4">
|
||||
<div className="lg:sticky lg:top-32">
|
||||
<div className="topic-reveal flex items-center gap-3 mb-6">
|
||||
<div className="p-3 rounded-xl bg-pink-brand/10 text-pink-brand">
|
||||
<Telescope className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="topic-reveal font-display text-3xl md:text-4xl lg:text-5xl font-light text-grey-900 leading-tight mb-6">
|
||||
Topics to<br />
|
||||
<span className="text-gradient">explore</span>
|
||||
</h2>
|
||||
<p className="topic-reveal text-grey-500 text-base font-body leading-relaxed">
|
||||
A living list of research directions and technical frontiers
|
||||
I am actively looking into or plan to investigate.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right topic list */}
|
||||
<div className="lg:col-span-8 space-y-4">
|
||||
{topics.map((topic, i) => (
|
||||
<div
|
||||
key={topic.title}
|
||||
className="topic-reveal group p-6 rounded-2xl bg-skin-100/40 border border-pink-brand/5 hover:border-pink-brand/20 hover:bg-skin-100/70 transition-all duration-300 cursor-default"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<span className="font-mono text-sm text-pink-brand/40 group-hover:text-pink-brand/70 transition-colors mt-1">
|
||||
{String(i + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-display text-base font-medium text-grey-900 mb-1 group-hover:text-pink-brand transition-colors">
|
||||
{topic.title}
|
||||
</h4>
|
||||
<p className="text-grey-500 text-sm font-body leading-relaxed">
|
||||
{topic.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowUpRight className="w-4 h-4 text-grey-300 group-hover:text-pink-brand flex-shrink-0 mt-1 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
16
src/hooks/useReducedMotion.ts
Normal file
16
src/hooks/useReducedMotion.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reducedMotion, setReducedMotion] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReducedMotion(mq.matches)
|
||||
|
||||
const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
return reducedMotion
|
||||
}
|
||||
53
src/hooks/useTypingEffect.ts
Normal file
53
src/hooks/useTypingEffect.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useReducedMotion } from './useReducedMotion'
|
||||
|
||||
interface UseTypingEffectOptions {
|
||||
text: string
|
||||
speed?: number
|
||||
delay?: number
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
export function useTypingEffect({ text, speed = 50, delay = 0, onComplete }: UseTypingEffectOptions) {
|
||||
const [displayed, setDisplayed] = useState('')
|
||||
const [started, setStarted] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const reducedMotion = useReducedMotion()
|
||||
const indexRef = useRef(0)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (started) return
|
||||
setStarted(true)
|
||||
}, [started])
|
||||
|
||||
useEffect(() => {
|
||||
if (!started) return
|
||||
|
||||
if (reducedMotion) {
|
||||
setDisplayed(text)
|
||||
setDone(true)
|
||||
onComplete?.()
|
||||
return
|
||||
}
|
||||
|
||||
const type = () => {
|
||||
if (indexRef.current < text.length) {
|
||||
setDisplayed(text.slice(0, indexRef.current + 1))
|
||||
indexRef.current++
|
||||
timeoutRef.current = setTimeout(type, speed)
|
||||
} else {
|
||||
setDone(true)
|
||||
onComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(type, delay)
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}, [started, text, speed, delay, reducedMotion, onComplete])
|
||||
|
||||
return { displayed, done, start }
|
||||
}
|
||||
148
src/index.css
Normal file
148
src/index.css
Normal file
@@ -0,0 +1,148 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-skin-50: #FDF8F3;
|
||||
--color-skin-100: #FAF0E6;
|
||||
--color-skin-200: #F5E1D0;
|
||||
--color-skin-300: #EBCDB0;
|
||||
--color-pink-brand: #FF2D7A;
|
||||
--color-pink-bright: #FF1B8D;
|
||||
--color-pink-soft: #FF6B9D;
|
||||
--color-pink-pale: #FFB6C1;
|
||||
--color-grey-900: #1A1A1A;
|
||||
--color-grey-800: #2A2A2A;
|
||||
--color-grey-700: #3A3A3A;
|
||||
--color-grey-600: #555555;
|
||||
--color-grey-500: #777777;
|
||||
|
||||
--font-mono: 'Roboto Mono', monospace;
|
||||
--font-display: 'Space Grotesk', sans-serif;
|
||||
--font-body: 'DM Sans', sans-serif;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in-out-circ: cubic-bezier(0.85, 0, 0.15, 1);
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--color-skin-50);
|
||||
color: var(--color-grey-900);
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--color-pink-brand);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-skin-100);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-pink-soft);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-pink-brand);
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--color-pink-brand) 0%, var(--color-pink-bright) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(253, 248, 243, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 45, 122, 0.1);
|
||||
}
|
||||
|
||||
/* Ambient floating shapes */
|
||||
@keyframes ambient-float-1 {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
33% { transform: translate(30px, -50px) rotate(120deg); }
|
||||
66% { transform: translate(-20px, -30px) rotate(240deg); }
|
||||
}
|
||||
|
||||
@keyframes ambient-float-2 {
|
||||
0%, 100% { transform: translate(0, 0) rotate(0deg); }
|
||||
33% { transform: translate(-40px, 30px) rotate(-120deg); }
|
||||
66% { transform: translate(20px, 50px) rotate(-240deg); }
|
||||
}
|
||||
|
||||
@keyframes ambient-float-3 {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
50% { transform: translate(15px, -25px) scale(1.05); }
|
||||
}
|
||||
|
||||
.ambient-shape-1 {
|
||||
animation: ambient-float-1 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-shape-2 {
|
||||
animation: ambient-float-2 15s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ambient-shape-3 {
|
||||
animation: ambient-float-3 8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Typing cursor */
|
||||
.typing-cursor::after {
|
||||
content: '|';
|
||||
animation: blink 1s step-end infinite;
|
||||
color: var(--color-pink-brand);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Line reveal */
|
||||
.line-reveal {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-reveal > span {
|
||||
display: inline-block;
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
transition: transform 0.8s var(--ease-out-expo), opacity 0.8s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
.line-reveal.revealed > span {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
53
tailwind.config.js
Normal file
53
tailwind.config.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
skin: {
|
||||
50: '#FDF8F3',
|
||||
100: '#FAF0E6',
|
||||
200: '#F5E1D0',
|
||||
300: '#EBCDB0',
|
||||
},
|
||||
pink: {
|
||||
brand: '#FF2D7A',
|
||||
bright: '#FF1B8D',
|
||||
soft: '#FF6B9D',
|
||||
pale: '#FFB6C1',
|
||||
},
|
||||
grey: {
|
||||
900: '#1A1A1A',
|
||||
800: '#2A2A2A',
|
||||
700: '#3A3A3A',
|
||||
600: '#555555',
|
||||
500: '#777777',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['"Roboto Mono"', 'monospace'],
|
||||
display: ['"Space Grotesk"', 'sans-serif'],
|
||||
body: ['"DM Sans"', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'float': 'float 6s ease-in-out infinite',
|
||||
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'drift': 'drift 20s linear infinite',
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-20px)' },
|
||||
},
|
||||
drift: {
|
||||
'0%': { transform: 'translateX(-100%) translateY(0)' },
|
||||
'100%': { transform: 'translateX(100vw) translateY(-40px)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: './',
|
||||
})
|
||||
Reference in New Issue
Block a user