Some basic improvements
This commit is contained in:
49
src/App.jsx
49
src/App.jsx
@@ -1,19 +1,58 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { activeSectionState, themeState } from './state/atoms';
|
||||||
import Navbar from './components/Navbar/Navbar';
|
import Navbar from './components/Navbar/Navbar';
|
||||||
import Hero from './components/Hero/Hero';
|
import Hero from './components/Hero/Hero';
|
||||||
import About from './components/About/About';
|
import About from './components/About/About';
|
||||||
import Projects from './components/Projects/Projects';
|
import Projects from './components/Projects/Projects';
|
||||||
import Contact from './components/Contact/Contact';
|
import Contact from './components/Contact/Contact';
|
||||||
import Footer from './components/Footer/Footer';
|
import Footer from './components/Footer/Footer';
|
||||||
|
import styles from './App.module.scss';
|
||||||
|
|
||||||
|
const SECTIONS = {
|
||||||
|
home: Hero,
|
||||||
|
about: About,
|
||||||
|
projects: Projects,
|
||||||
|
contact: Contact,
|
||||||
|
};
|
||||||
|
|
||||||
|
const EXIT_DURATION = 200;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const activeSection = useAtomValue(activeSectionState);
|
||||||
|
const theme = useAtomValue(themeState);
|
||||||
|
const [displayedSection, setDisplayedSection] = useState(activeSection);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme === 'light') {
|
||||||
|
document.documentElement.dataset.theme = 'light';
|
||||||
|
} else {
|
||||||
|
delete document.documentElement.dataset.theme;
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeSection === displayedSection) return;
|
||||||
|
setIsExiting(true);
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
setDisplayedSection(activeSection);
|
||||||
|
setIsExiting(false);
|
||||||
|
}, EXIT_DURATION);
|
||||||
|
return () => clearTimeout(timerRef.current);
|
||||||
|
}, [activeSection]);
|
||||||
|
|
||||||
|
const Section = SECTIONS[displayedSection] ?? Hero;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main>
|
<main
|
||||||
<Hero />
|
key={displayedSection}
|
||||||
<About />
|
className={isExiting ? styles.exit : styles.enter}
|
||||||
<Projects />
|
>
|
||||||
<Contact />
|
<Section />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
32
src/App.module.scss
Normal file
32
src/App.module.scss
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
@use './styles/variables' as *;
|
||||||
|
|
||||||
|
.enter {
|
||||||
|
animation: sectionEnter 0.25s ease both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exit {
|
||||||
|
animation: sectionExit 0.2s ease both;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sectionEnter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sectionExit {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import styles from './About.module.scss';
|
import styles from './About.module.scss';
|
||||||
|
|
||||||
const STACK = ['React', 'TypeScript', 'Node.js', 'PostgreSQL', 'Docker', 'Go'];
|
const STACK = ['Technology 1', 'Technology 2', 'Technology 3', 'Technology 4', 'Technology 5', 'Technology 6'];
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
return (
|
return (
|
||||||
@@ -15,14 +15,13 @@ export default function About() {
|
|||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<p>
|
<p>
|
||||||
I'm a developer who cares deeply about the craft. Whether it's
|
A paragraph about yourself and your background. What kind of work
|
||||||
shaving 200ms off a render or designing a data model that still
|
do you do and what drives you? Keep it personal and authentic.
|
||||||
makes sense six months later — the details matter.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
When I'm not staring at a terminal, I'm probably hiking somewhere
|
A second paragraph with more details — hobbies, interests, or
|
||||||
with bad cell coverage, reading about distributed systems, or
|
anything else that gives visitors a sense of who you are beyond
|
||||||
losing at chess online.
|
your job title.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Here are some technologies I've been working with recently:
|
Here are some technologies I've been working with recently:
|
||||||
@@ -39,20 +38,20 @@ export default function About() {
|
|||||||
|
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.avatar}>
|
<div className={styles.avatar}>
|
||||||
<span className={styles.avatarInner}>VK</span>
|
<span className={styles.avatarInner}>YN</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.meta}>
|
<div className={styles.meta}>
|
||||||
<p className={styles.metaLine}>
|
<p className={styles.metaLine}>
|
||||||
<span className={styles.metaKey}>location</span>
|
<span className={styles.metaKey}>location</span>
|
||||||
<span>Helsinki, Finland</span>
|
<span>City, Country</span>
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.metaLine}>
|
<p className={styles.metaLine}>
|
||||||
<span className={styles.metaKey}>status</span>
|
<span className={styles.metaKey}>status</span>
|
||||||
<span className={styles.green}>Available</span>
|
<span className={styles.green}>Available</span>
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.metaLine}>
|
<p className={styles.metaLine}>
|
||||||
<span className={styles.metaKey}>coffee</span>
|
<span className={styles.metaKey}>focus</span>
|
||||||
<span>v∞.0.0</span>
|
<span>Your focus area</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 0 30px rgba($color-primary-mid, 0.3);
|
box-shadow: 0 0 30px rgb(var(--color-primary-mid) / 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatarInner {
|
.avatarInner {
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ export default function Contact() {
|
|||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<p className={styles.lede}>
|
<p className={styles.lede}>
|
||||||
Whether you have a project in mind, a question, or just want to talk
|
A short message inviting visitors to reach out. Let them know
|
||||||
shop — my inbox is open. I'm reasonably fast at responding, unless
|
you're open to new opportunities, collaborations, or just a chat.
|
||||||
it's a Monday.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="mailto:hello@example.com"
|
href="mailto:your@email.com"
|
||||||
className={styles.emailBtn}
|
className={styles.emailBtn}
|
||||||
>
|
>
|
||||||
Say hello ↗
|
Say hello ↗
|
||||||
@@ -26,9 +25,9 @@ export default function Contact() {
|
|||||||
|
|
||||||
<ul className={styles.socials}>
|
<ul className={styles.socials}>
|
||||||
{[
|
{[
|
||||||
{ label: 'GitHub', href: 'https://github.com' },
|
{ label: 'GitHub', href: '#' },
|
||||||
{ label: 'LinkedIn', href: 'https://linkedin.com' },
|
{ label: 'LinkedIn', href: '#' },
|
||||||
{ label: 'Twitter / X', href: 'https://x.com' },
|
{ label: 'Platform', href: '#' },
|
||||||
].map(({ label, href }) => (
|
].map(({ label, href }) => (
|
||||||
<li key={label}>
|
<li key={label}>
|
||||||
<a href={href} target="_blank" rel="noreferrer">
|
<a href={href} target="_blank" rel="noreferrer">
|
||||||
|
|||||||
@@ -65,9 +65,9 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba($color-accent, 0.08);
|
background: rgb(var(--color-accent) / 0.08);
|
||||||
color: $color-accent;
|
color: $color-accent;
|
||||||
box-shadow: 0 0 20px rgba($color-accent, 0.15);
|
box-shadow: 0 0 20px rgb(var(--color-accent) / 0.15);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ export default function Footer() {
|
|||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<p>
|
<p>
|
||||||
Designed & built by{' '}
|
Designed & built by{' '}
|
||||||
<a href="#home">Veikko</a>{' '}
|
<a href="#home">Your Name</a>{' '}
|
||||||
<span className={styles.separator}>·</span>{' '}
|
<span className={styles.separator}>·</span>{' '}
|
||||||
<span className={styles.stack}>React + Vite + Bun</span>
|
<span className={styles.stack}>React + Vite</span>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ export default function Hero() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<p className={styles.greeting}>Hey, I'm</p>
|
<p className={styles.greeting}>Hey, I'm</p>
|
||||||
<h1 className={styles.name}>
|
<h1 className={styles.name}>
|
||||||
Veikko<span className={styles.dot}>.</span>
|
Your Name<span className={styles.dot}>.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.tagline}>
|
<p className={styles.tagline}>
|
||||||
I build things for the web —{' '}
|
Your tagline goes here —{' '}
|
||||||
<span className={styles.highlight}>sometimes they even work.</span>
|
<span className={styles.highlight}>make it memorable.</span>
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.sub}>
|
<p className={styles.sub}>
|
||||||
Full-stack developer with a soft spot for clean architecture,
|
A short bio or description about yourself. What do you do, what do
|
||||||
dark mode interfaces, and coffee that's slightly too strong.
|
you care about, and what makes you interesting?
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={styles.cta}>
|
<div className={styles.cta}>
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(rgba($color-primary, 0.08) 1px, transparent 1px),
|
linear-gradient(rgb(var(--color-primary) / 0.08) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba($color-primary, 0.08) 1px, transparent 1px);
|
linear-gradient(90deg, rgb(var(--color-primary) / 0.08) 1px, transparent 1px);
|
||||||
background-size: 40px 40px;
|
background-size: 40px 40px;
|
||||||
mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 40%, transparent 100%);
|
mask-image: radial-gradient(ellipse 80% 60% at 50% 0%, black 40%, transparent 100%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 700px;
|
width: 700px;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
background: radial-gradient(ellipse, rgba($color-primary-mid, 0.18) 0%, transparent 70%);
|
background: radial-gradient(ellipse, rgb(var(--color-primary-mid) / 0.18) 0%, transparent 70%);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: $color-primary-glow;
|
background: $color-primary-glow;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 0 24px rgba($color-primary-glow, 0.4);
|
box-shadow: 0 0 24px rgb(var(--color-primary-glow) / 0.4);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: $space-8;
|
bottom: $space-8;
|
||||||
right: $space-6;
|
right: $space-6;
|
||||||
background: rgba($color-bg-card, 0.9);
|
background: rgb(var(--color-bg-card) / 0.9);
|
||||||
border: 1px solid rgba(34, 197, 94, 0.35);
|
border: 1px solid rgba(34, 197, 94, 0.35);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
padding: $space-2 $space-4;
|
padding: $space-2 $space-4;
|
||||||
|
|||||||
@@ -1,36 +1,48 @@
|
|||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { navOpenState } from '../../state/atoms';
|
import { navOpenState, activeSectionState } from '../../state/atoms';
|
||||||
|
import ThemeToggle from '../ThemeToggle/ThemeToggle';
|
||||||
import styles from './Navbar.module.scss';
|
import styles from './Navbar.module.scss';
|
||||||
|
|
||||||
const NAV_LINKS = [
|
const NAV_LINKS = [
|
||||||
{ label: 'Home', href: '#home' },
|
{ label: 'Home', section: 'home' },
|
||||||
{ label: 'About', href: '#about' },
|
{ label: 'About', section: 'about' },
|
||||||
{ label: 'Projects', href: '#projects' },
|
{ label: 'Projects', section: 'projects' },
|
||||||
{ label: 'Contact', href: '#contact' },
|
{ label: 'Contact', section: 'contact' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const [isOpen, setIsOpen] = useAtom(navOpenState);
|
const [isOpen, setIsOpen] = useAtom(navOpenState);
|
||||||
|
const [activeSection, setActiveSection] = useAtom(activeSectionState);
|
||||||
|
|
||||||
|
function handleNav(section) {
|
||||||
|
setActiveSection(section);
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<a href="#home" className={styles.logo}>
|
<button className={styles.logo} onClick={() => handleNav('home')}>
|
||||||
<span className={styles.logoBracket}><</span>
|
<span className={styles.logoBracket}><</span>
|
||||||
vk
|
vk
|
||||||
<span className={styles.logoBracket}>/></span>
|
<span className={styles.logoBracket}>/></span>
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<ul className={`${styles.links} ${isOpen ? styles.open : ''}`}>
|
<ul className={`${styles.links} ${isOpen ? styles.open : ''}`}>
|
||||||
{NAV_LINKS.map(({ label, href }) => (
|
{NAV_LINKS.map(({ label, section }) => (
|
||||||
<li key={href}>
|
<li key={section}>
|
||||||
<a href={href} onClick={() => setIsOpen(false)}>
|
<button
|
||||||
|
onClick={() => handleNav(section)}
|
||||||
|
className={activeSection === section ? styles.active : undefined}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`${styles.burger} ${isOpen ? styles.burgerOpen : ''}`}
|
className={`${styles.burger} ${isOpen ? styles.burgerOpen : ''}`}
|
||||||
onClick={() => setIsOpen((v) => !v)}
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 0 auto 0;
|
inset: 0 0 auto 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: rgba($color-bg, 0.85);
|
background: rgb(var(--color-bg) / 0.85);
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
border-bottom: 1px solid $color-border;
|
border-bottom: 1px solid $color-border;
|
||||||
transition: background $transition-base;
|
transition: background $transition-base;
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
@@ -40,13 +44,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: $space-8;
|
gap: $space-8;
|
||||||
|
|
||||||
a {
|
button {
|
||||||
|
font-family: inherit;
|
||||||
font-size: $text-sm;
|
font-size: $text-sm;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: $color-text-muted;
|
color: $color-text-muted;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
transition: color $transition-fast;
|
transition: color $transition-fast;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
@@ -67,6 +76,14 @@
|
|||||||
transform: scaleX(1);
|
transform: scaleX(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $color-text;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ import styles from './Projects.module.scss';
|
|||||||
|
|
||||||
const PROJECTS = [
|
const PROJECTS = [
|
||||||
{
|
{
|
||||||
name: 'Lumina',
|
name: 'Project Alpha',
|
||||||
desc: 'Real-time collaborative whiteboard built with WebSockets and Canvas API. Because sometimes Figma is overkill.',
|
desc: 'A short description of your featured project. What problem does it solve and what technologies make it interesting?',
|
||||||
tags: ['React', 'Node.js', 'WebSockets'],
|
tags: ['Tag 1', 'Tag 2', 'Tag 3'],
|
||||||
href: '#',
|
href: '#',
|
||||||
star: true,
|
star: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Hallberg',
|
name: 'Project Beta',
|
||||||
desc: 'Personal finance tracker that actually respects your privacy — no cloud, no telemetry, just SQLite and honesty.',
|
desc: 'A short description of your second project. Highlight the key challenge or the most exciting part of building it.',
|
||||||
tags: ['Go', 'SQLite', 'HTMX'],
|
tags: ['Tag 1', 'Tag 2', 'Tag 3'],
|
||||||
href: '#',
|
href: '#',
|
||||||
star: false,
|
star: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Pikkubot',
|
name: 'Project Gamma',
|
||||||
desc: 'Tiny Telegram bot for generating daily Finnish vocabulary cards. My Finnish is still terrible, but at least it\'s automated.',
|
desc: 'A short description of your third project. What did you learn, or what are you most proud of about it?',
|
||||||
tags: ['Python', 'Telegram API'],
|
tags: ['Tag 1', 'Tag 2'],
|
||||||
href: '#',
|
href: '#',
|
||||||
star: false,
|
star: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,12 +57,12 @@
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
border-color: rgba($color-primary-glow, 0.4);
|
border-color: rgb(var(--color-primary-glow) / 0.4);
|
||||||
box-shadow: 0 8px 32px rgba($color-primary, 0.2);
|
box-shadow: 0 8px 32px rgb(var(--color-primary) / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.featured {
|
&.featured {
|
||||||
border-color: rgba($color-accent, 0.25);
|
border-color: rgb(var(--color-accent) / 0.25);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +73,8 @@
|
|||||||
font-size: $text-xs;
|
font-size: $text-xs;
|
||||||
font-family: $font-mono;
|
font-family: $font-mono;
|
||||||
color: $color-accent-warm;
|
color: $color-accent-warm;
|
||||||
background: rgba($color-accent-warm, 0.1);
|
background: rgb(var(--color-accent-warm) / 0.1);
|
||||||
border: 1px solid rgba($color-accent-warm, 0.25);
|
border: 1px solid rgb(var(--color-accent-warm) / 0.25);
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
padding: 2px $space-3;
|
padding: 2px $space-3;
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
font-family: $font-mono;
|
font-family: $font-mono;
|
||||||
font-size: $text-xs;
|
font-size: $text-xs;
|
||||||
color: $color-text-dim;
|
color: $color-text-dim;
|
||||||
background: rgba($color-primary, 0.15);
|
background: rgb(var(--color-primary) / 0.15);
|
||||||
border-radius: $radius-sm;
|
border-radius: $radius-sm;
|
||||||
padding: 2px $space-3;
|
padding: 2px $space-3;
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/ThemeToggle/ThemeToggle.jsx
Normal file
42
src/components/ThemeToggle/ThemeToggle.jsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { themeState } from '../../state/atoms';
|
||||||
|
import styles from './ThemeToggle.module.scss';
|
||||||
|
|
||||||
|
export default function ThemeToggle() {
|
||||||
|
const [theme, setTheme] = useAtom(themeState);
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.toggle}
|
||||||
|
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
||||||
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{isDark ? <SunIcon /> : <MoonIcon />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SunIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<line x1="12" y1="1" x2="12" y2="3" />
|
||||||
|
<line x1="12" y1="21" x2="12" y2="23" />
|
||||||
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||||
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||||
|
<line x1="1" y1="12" x2="3" y2="12" />
|
||||||
|
<line x1="21" y1="12" x2="23" y2="12" />
|
||||||
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||||
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MoonIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/components/ThemeToggle/ThemeToggle.module.scss
Normal file
19
src/components/ThemeToggle/ThemeToggle.module.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@use '../../styles/variables' as *;
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $color-text-muted;
|
||||||
|
padding: $space-2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $radius-sm;
|
||||||
|
transition: color $transition-fast, background-color $transition-fast;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-text;
|
||||||
|
background-color: $color-bg-elevated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,5 @@ import { atom } from 'jotai';
|
|||||||
export const navOpenState = atom(false);
|
export const navOpenState = atom(false);
|
||||||
|
|
||||||
export const activeSectionState = atom('home');
|
export const activeSectionState = atom('home');
|
||||||
|
|
||||||
|
export const themeState = atom('dark');
|
||||||
|
|||||||
@@ -1,5 +1,36 @@
|
|||||||
@use 'variables' as *;
|
@use 'variables' as *;
|
||||||
|
|
||||||
|
// ── Theme tokens ───────────────────────────────────────────────
|
||||||
|
:root {
|
||||||
|
--color-bg: 10 14 26;
|
||||||
|
--color-bg-elevated: 17 24 39;
|
||||||
|
--color-bg-card: 19 29 53;
|
||||||
|
--color-border: 30 45 77;
|
||||||
|
--color-primary: 30 58 138;
|
||||||
|
--color-primary-mid: 37 99 235;
|
||||||
|
--color-primary-glow: 59 130 246;
|
||||||
|
--color-accent: 96 165 250;
|
||||||
|
--color-accent-warm: 245 158 11;
|
||||||
|
--color-text: 226 232 240;
|
||||||
|
--color-text-muted: 148 163 184;
|
||||||
|
--color-text-dim: 71 85 105;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] {
|
||||||
|
--color-bg: 248 250 252;
|
||||||
|
--color-bg-elevated: 241 245 249;
|
||||||
|
--color-bg-card: 255 255 255;
|
||||||
|
--color-border: 203 213 225;
|
||||||
|
--color-primary: 30 58 138;
|
||||||
|
--color-primary-mid: 37 99 235;
|
||||||
|
--color-primary-glow: 59 130 246;
|
||||||
|
--color-accent: 37 99 235;
|
||||||
|
--color-accent-warm: 217 119 6;
|
||||||
|
--color-text: 15 23 42;
|
||||||
|
--color-text-muted: 71 85 105;
|
||||||
|
--color-text-dim: 148 163 184;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reset & base ───────────────────────────────────────────────
|
// ── Reset & base ───────────────────────────────────────────────
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
@@ -21,6 +52,7 @@ body {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
transition: background-color $transition-base, color $transition-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
// ── Palette ────────────────────────────────────────────────────
|
// ── Palette ────────────────────────────────────────────────────
|
||||||
$color-bg: #0a0e1a;
|
$color-bg: rgb(var(--color-bg));
|
||||||
$color-bg-elevated: #111827;
|
$color-bg-elevated: rgb(var(--color-bg-elevated));
|
||||||
$color-bg-card: #131d35;
|
$color-bg-card: rgb(var(--color-bg-card));
|
||||||
$color-border: #1e2d4d;
|
$color-border: rgb(var(--color-border));
|
||||||
|
|
||||||
$color-primary: #1e3a8a; // dark blue
|
$color-primary: rgb(var(--color-primary));
|
||||||
$color-primary-mid: #2563eb;
|
$color-primary-mid: rgb(var(--color-primary-mid));
|
||||||
$color-primary-glow:#3b82f6;
|
$color-primary-glow:rgb(var(--color-primary-glow));
|
||||||
$color-accent: #60a5fa; // light blue for highlights
|
$color-accent: rgb(var(--color-accent));
|
||||||
$color-accent-warm: #f59e0b; // amber – a touch of personality
|
$color-accent-warm: rgb(var(--color-accent-warm));
|
||||||
|
|
||||||
$color-text: #e2e8f0;
|
$color-text: rgb(var(--color-text));
|
||||||
$color-text-muted: #94a3b8;
|
$color-text-muted: rgb(var(--color-text-muted));
|
||||||
$color-text-dim: #475569;
|
$color-text-dim: rgb(var(--color-text-dim));
|
||||||
|
|
||||||
// ── Typography ─────────────────────────────────────────────────
|
// ── Typography ─────────────────────────────────────────────────
|
||||||
$font-sans: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
$font-sans: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
|||||||
Reference in New Issue
Block a user