Compare commits
12 Commits
7d378cb2d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e535fadde2 | |||
| dd90d250c6 | |||
| 957ae4f828 | |||
| 00c08f6544 | |||
| d2cd0e37d4 | |||
| 1ce8b64d64 | |||
| 7e0d964f35 | |||
| de56069158 | |||
| c984db4ef6 | |||
| fca87f49d9 | |||
| 666a53592d | |||
| ae10c8f049 |
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta property="og:title" content="Veikko Lintujärvi" />
|
||||
<meta property="og:description" content="Personal website of Veikko Lintujärvi" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://veikko.lintujarvi.fi" />
|
||||
<meta property="og:image" content="https://veikko.lintujarvi.fi/og-image.jpg" />
|
||||
<meta property="og:site_name" content="Veikko Lintujärvi" />
|
||||
<title>Veikko Lintujärvi</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"start": "vite preview --port 4175 --host"
|
||||
},
|
||||
"dependencies": {
|
||||
"jotai": "^2.20.0",
|
||||
|
||||
BIN
public/og-image.jpg
Normal file
BIN
public/og-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
30
scripts/deploy.sh
Normal file
30
scripts/deploy.sh
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
APP_NAME="kotisivu"
|
||||
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PORT="${1:-${PORT:-3000}}"
|
||||
|
||||
echo "Deploying $APP_NAME from $APP_DIR on port $PORT..."
|
||||
|
||||
cd "$APP_DIR"
|
||||
|
||||
echo "Pulling latest changes..."
|
||||
sudo -E git pull
|
||||
|
||||
echo "Installing dependencies..."
|
||||
sudo bun install
|
||||
|
||||
echo "Building application..."
|
||||
sudo bun run build
|
||||
|
||||
echo "Starting/reloading with pm2..."
|
||||
if sudo pm2 describe "$APP_NAME" > /dev/null 2>&1; then
|
||||
sudo pm2 reload "$APP_NAME" --update-env
|
||||
else
|
||||
sudo pm2 start bun --name "$APP_NAME" --env PORT="$PORT" -- run start
|
||||
fi
|
||||
|
||||
sudo pm2 save
|
||||
|
||||
echo "Deployment complete."
|
||||
28
src/App.jsx
28
src/App.jsx
@@ -7,6 +7,7 @@ import Hero from './components/Hero';
|
||||
import About from './components/About';
|
||||
import Projects from './components/Projects';
|
||||
import Contact from './components/Contact';
|
||||
import NotFound from './components/NotFound';
|
||||
import Footer from './components/Footer';
|
||||
import styles from './App.module.scss';
|
||||
|
||||
@@ -27,18 +28,15 @@ export default function App() {
|
||||
const [displayedSection, setDisplayedSection] = useState(activeSection);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
const path = location.pathname.substring(1) || 'home';
|
||||
const notFound = !Object.keys(SECTIONS).includes(path);
|
||||
|
||||
// Sync URL path with activeSection
|
||||
useEffect(() => {
|
||||
const path = location.pathname.substring(1) || 'home';
|
||||
if (Object.keys(SECTIONS).includes(path)) {
|
||||
if (path !== activeSection) {
|
||||
setActiveSection(path);
|
||||
}
|
||||
} else {
|
||||
// Invalid path, redirect to home
|
||||
navigate('/');
|
||||
if (!notFound && path !== activeSection) {
|
||||
setActiveSection(path);
|
||||
}
|
||||
}, [location.pathname, activeSection, setActiveSection, navigate]);
|
||||
}, [location.pathname, activeSection, setActiveSection, navigate, path, notFound]);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'light') {
|
||||
@@ -59,6 +57,18 @@ export default function App() {
|
||||
const isExiting = activeSection !== displayedSection;
|
||||
const Section = SECTIONS[displayedSection] ?? Hero;
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className={styles.enter}>
|
||||
<NotFound />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/About.module.scss';
|
||||
|
||||
const STACK = ['Azure', '.NET', 'C#', 'JavaScript', 'React', 'Node.js', 'SQL'];
|
||||
|
||||
export default function About() {
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
return (
|
||||
<section id="about" className={styles.about}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.sectionNum}>01.</span>
|
||||
<h2 className={styles.sectionTitle}>About me</h2>
|
||||
<span className={styles.sectionNum}>{t.about.sectionNum}</span>
|
||||
<h2 className={styles.sectionTitle}>{t.about.sectionTitle}</h2>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<div className={styles.text}>
|
||||
<p>
|
||||
I'm a curious software engineer with a passion for building scalable and efficient
|
||||
applications. With deep knowledge in cloud, backend, and frontend development, I enjoy
|
||||
working on projects that challenge me to learn new technologies and improve my skills.
|
||||
{t.about.paragraph1}
|
||||
</p>
|
||||
<p>
|
||||
I tend to build always something that is useful to others, and I find joy in creating tools
|
||||
and applications that solve real-world problems. I believe that technology has the power to
|
||||
make a positive impact on people's lives, and I strive to contribute to that impact through my work.
|
||||
{t.about.paragraph2}
|
||||
</p>
|
||||
<p>
|
||||
Here are some technologies I've been working with recently:
|
||||
{t.about.paragraph3}
|
||||
</p>
|
||||
|
||||
<ul className={styles.stack}>
|
||||
{STACK.map((item) => (
|
||||
{t.about.stack.map((item) => (
|
||||
<li key={item}>
|
||||
<span className={styles.arrow}>▸</span> {item}
|
||||
</li>
|
||||
@@ -43,15 +43,15 @@ export default function About() {
|
||||
</div>
|
||||
<div className={styles.meta}>
|
||||
<p className={styles.metaLine}>
|
||||
<span className={styles.metaKey}>location</span>
|
||||
<span className={styles.metaValue}>Turku Archipelago, Finland</span>
|
||||
<span className={styles.metaKey}>{t.about.meta.location}</span>
|
||||
<span className={styles.metaValue}>{t.about.meta.locationValue}</span>
|
||||
</p>
|
||||
<p className={styles.metaLine}>
|
||||
<span className={styles.metaKey}>favorite book</span>
|
||||
<span className={styles.metaValue}>The Lord of the Rings</span>
|
||||
<span className={styles.metaKey}>{t.about.meta.favoriteBook}</span>
|
||||
<span className={styles.metaValue}>{t.about.meta.favoriteBookValue}</span>
|
||||
</p>
|
||||
<div className={styles.metaLine}>
|
||||
<span className={styles.metaKey}>favorite songs</span>
|
||||
<span className={styles.metaKey}>{t.about.meta.favoriteSongs}</span>
|
||||
<div className={styles.metaLinks}>
|
||||
<a href="https://www.youtube.com/watch?v=EqDcigWxZuU" target="_blank" rel="noreferrer">Rücksturz – Agitation Free</a>
|
||||
<a href="https://www.youtube.com/watch?v=2i2oJJwgLTk" target="_blank" rel="noreferrer">Year of Love – Jenny Hval</a>
|
||||
|
||||
@@ -1,35 +1,48 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/Contact.module.scss';
|
||||
|
||||
export default function Contact() {
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
const SOCIALS = [
|
||||
{ label: t.contact.socials.gitea, href: 'https://gitea.tietokonepaja.fi/' },
|
||||
{ label: t.contact.socials.linkedin, href: 'https://www.linkedin.com/in/veikko-lintuj%C3%A4rvi-5888a6175/' },
|
||||
{ label: t.contact.socials.company, href: 'https://tietokonepaja.fi/' },
|
||||
];
|
||||
|
||||
return (
|
||||
<section id="contact" className={styles.contact}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.sectionNum}>03.</span>
|
||||
<h2 className={styles.sectionTitle}>Get in touch</h2>
|
||||
<span className={styles.sectionNum}>{t.contact.sectionNum}</span>
|
||||
<h2 className={styles.sectionTitle}>{t.contact.sectionTitle}</h2>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
<p className={styles.lede}>
|
||||
I'm currently employed, but my inbox is always open. Whether you have a question or
|
||||
just want to say hi, I'll try my best to get back to you!
|
||||
{t.contact.lede}
|
||||
</p>
|
||||
<p>
|
||||
{t.contact.paragraphBefore}
|
||||
<a href="https://tietokonepaja.fi/" target="_blank" rel="noreferrer">
|
||||
{t.contact.companyName}
|
||||
</a>
|
||||
{t.contact.paragraphAfter}
|
||||
</p>
|
||||
<p>I also have a small company if you are interested in collaboration or services.</p>
|
||||
|
||||
<a
|
||||
href="mailto:veikko@lintujarvi.fi"
|
||||
className={styles.emailBtn}
|
||||
>
|
||||
Say hello ↗
|
||||
{t.contact.emailBtn} ↗
|
||||
</a>
|
||||
|
||||
<ul className={styles.socials}>
|
||||
{[
|
||||
{ label: 'Gitea', href: 'https://gitea.tietokonepaja.fi/' },
|
||||
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/veikko-lintuj%C3%A4rvi-5888a6175/' },
|
||||
{ label: 'Company', href: 'https://www.tietokonepaja.fi/' },
|
||||
].map(({ label, href }) => (
|
||||
{SOCIALS.map(({ label, href }) => (
|
||||
<li key={label}>
|
||||
<a href={href} target="_blank" rel="noreferrer">
|
||||
{label}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/Footer.module.scss';
|
||||
|
||||
export default function Footer() {
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<p>
|
||||
Designed & built by{' '}
|
||||
{t.footer.designedBy}{' '}
|
||||
<a href="#home">Veikko Lintujärvi</a>{' '}
|
||||
<span className={styles.separator}>·</span>{' '}
|
||||
<span className={styles.stack}>React + Vite</span>
|
||||
<span className={styles.separator}>{t.footer.separator}</span>{' '}
|
||||
<span className={styles.stack}>{t.footer.stack}</span>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/Hero.module.scss';
|
||||
|
||||
export default function Hero() {
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
return (
|
||||
<section id="home" className={styles.hero}>
|
||||
<div className={styles.grid} aria-hidden="true" />
|
||||
|
||||
<div className={styles.content}>
|
||||
<p className={styles.greeting}>Hey, I'm</p>
|
||||
<p className={styles.greeting}>{t.hero.greeting}</p>
|
||||
<h1 className={styles.name}>
|
||||
Veikko Lintujärvi<span className={styles.dot}>.</span>
|
||||
{t.hero.name}<span className={styles.dot}>.</span>
|
||||
</h1>
|
||||
<p className={styles.tagline}>
|
||||
Making world a better place —{' '}
|
||||
<span className={styles.highlight}>one line at a time.</span>
|
||||
{t.hero.tagline}
|
||||
</p>
|
||||
<p className={styles.sub}>
|
||||
I'm a curious software engineer from Turku Archipelago, Finland. Right now
|
||||
I'm excited about cloud, Linux and AI.
|
||||
{t.hero.sub}
|
||||
</p>
|
||||
|
||||
<div className={styles.cta}>
|
||||
<a href="/projects" className={styles.btnPrimary}>
|
||||
See my work
|
||||
</a>
|
||||
<a href="/contact" className={styles.btnGhost}>
|
||||
Get in touch
|
||||
</a>
|
||||
<Link to="/projects" className={styles.btnPrimary}>
|
||||
{t.hero.ctaPrimary}
|
||||
</Link>
|
||||
<Link to="/contact" className={styles.btnGhost}>
|
||||
{t.hero.ctaSecondary}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.badge} aria-hidden="true">
|
||||
<span>open to work</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/components/LanguagePicker.jsx
Normal file
30
src/components/LanguagePicker.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/LanguagePicker.module.scss';
|
||||
|
||||
export default function LanguagePicker() {
|
||||
const [language, setLanguage] = useAtom(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
return (
|
||||
<div className={styles.picker}>
|
||||
<button
|
||||
className={styles.flag}
|
||||
onClick={() => setLanguage('fi')}
|
||||
aria-label={`Switch to ${t.languages.fi}`}
|
||||
aria-pressed={language === 'fi'}
|
||||
>
|
||||
<span className={styles.flagIcon}>🇫🇮</span>
|
||||
</button>
|
||||
<button
|
||||
className={styles.flag}
|
||||
onClick={() => setLanguage('en')}
|
||||
aria-label={`Switch to ${t.languages.en}`}
|
||||
aria-pressed={language === 'en'}
|
||||
>
|
||||
<span className={styles.flagIcon}>🇬🇧</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { navOpenState, activeSectionState } from '../state/atoms';
|
||||
import { navOpenState, activeSectionState, languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import LanguagePicker from './LanguagePicker';
|
||||
import styles from '../styles/components/Navbar.module.scss';
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: 'Home', section: 'home' },
|
||||
{ label: 'About', section: 'about' },
|
||||
{ label: 'Projects', section: 'projects' },
|
||||
{ label: 'Contact', section: 'contact' },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const [isOpen, setIsOpen] = useAtom(navOpenState);
|
||||
const activeSection = useAtomValue(activeSectionState);
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
const NAV_LINKS = [
|
||||
{ label: t.nav.home, section: 'home' },
|
||||
{ label: t.nav.about, section: 'about' },
|
||||
{ label: t.nav.projects, section: 'projects' },
|
||||
{ label: t.nav.contact, section: 'contact' },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
@@ -39,12 +43,13 @@ export default function Navbar() {
|
||||
</ul>
|
||||
|
||||
<div className={styles.navActions}>
|
||||
<LanguagePicker />
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
className={`${styles.burger} ${isOpen ? styles.burgerOpen : ''}`}
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
aria-label="Toggle menu"
|
||||
aria-label={t.theme.switchToLight}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span />
|
||||
|
||||
21
src/components/NotFound.jsx
Normal file
21
src/components/NotFound.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/NotFound.module.scss';
|
||||
|
||||
export default function NotFound() {
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
return (
|
||||
<section id="not-found" className={styles.notFound}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.imageContainer}>
|
||||
<img src="/not-found.png" alt="Not found" className={styles.image} />
|
||||
</div>
|
||||
<h1 className={styles.title}>{t.notFound.title}</h1>
|
||||
<p className={styles.message}>{t.notFound.message}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,59 +1,66 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/Projects.module.scss';
|
||||
|
||||
const PROJECTS = [
|
||||
{
|
||||
name: 'Klapi - Unified Control Panel',
|
||||
desc: 'Web interface to view and manage data held by Livonsaaren Tietokonepaja. Easy to customize serving various customer solutions.',
|
||||
tags: ['.NET', 'React', 'SQLite'],
|
||||
href: 'https://klapi.tietokonepaja.fi/',
|
||||
star: false,
|
||||
},
|
||||
{
|
||||
name: 'Hattara - Online office',
|
||||
desc: 'Why to code your own when there is working open source solution to host on your own server? Hattara is a self-hosted cloud office solution for small teams and individuals.',
|
||||
tags: ['Apache2', 'Ubuntu Server', 'NextCloud'],
|
||||
href: 'https://hattara.tietokonepaja.fi/',
|
||||
star: false,
|
||||
},
|
||||
{
|
||||
name: 'Runosaari festival website - Pro bono',
|
||||
desc: "Supporting local culture initiatives have always been close to my heart. I'm proudly helping Runosaari festival with their website and online presence already for the sixth year!",
|
||||
tags: ['React', 'Vite', 'Apache2', 'Ubuntu Server'],
|
||||
href: 'https://www.runosaari.fi/',
|
||||
star: false,
|
||||
},
|
||||
];
|
||||
const getTagColor = (tag) => {
|
||||
const colorMap = {
|
||||
'.NET': 'tagDotnet',
|
||||
React: 'tagReact',
|
||||
SQLite: 'tagSqlite',
|
||||
Apache2: 'tagApache',
|
||||
'Ubuntu Server': 'tagUbuntu',
|
||||
NextCloud: 'tagNextcloud',
|
||||
Vite: 'tagVite',
|
||||
};
|
||||
return colorMap[tag] || 'tagDefault';
|
||||
};
|
||||
|
||||
export default function Projects() {
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
|
||||
return (
|
||||
<section id="projects" className={styles.projects}>
|
||||
<div className={styles.inner}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.sectionNum}>02.</span>
|
||||
<h2 className={styles.sectionTitle}>Projects</h2>
|
||||
<span className={styles.sectionNum}>{t.projects.sectionNum}</span>
|
||||
<h2 className={styles.sectionTitle}>{t.projects.sectionTitle}</h2>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
|
||||
<div className={styles.grid}>
|
||||
{PROJECTS.map((p) => (
|
||||
<article key={p.name} className={`${styles.card} ${p.star ? styles.featured : ''}`}>
|
||||
{p.star && <span className={styles.featuredBadge}>Featured</span>}
|
||||
{t.projects.projects.map((p) => (
|
||||
<article key={p.name} className={styles.card}>
|
||||
<div className={styles.cardTop}>
|
||||
<span className={styles.folderIcon}>⬡</span>
|
||||
<a href={p.href} className={styles.externalLink} aria-label={`Open ${p.name}`}>
|
||||
↗
|
||||
</a>
|
||||
<span className={styles.folderIcon}>{t.projects.folderIcon}</span>
|
||||
<div className={styles.cardLinks}>
|
||||
<a href={p.href} className={styles.externalLink} aria-label={`${t.projects.externalLinkAria} ${p.name}`}>
|
||||
↗
|
||||
</a>
|
||||
<a href={p.sourceHref} className={styles.sourceLink} aria-label={`${t.projects.sourceLinkAria} ${p.name}`}>
|
||||
📦
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className={styles.cardName}>{p.name}</h3>
|
||||
<p className={styles.cardDesc}>{p.desc}</p>
|
||||
<ul className={styles.tags}>
|
||||
{p.tags.map((t) => (
|
||||
<li key={t}>{t}</li>
|
||||
{p.tags.map((tag) => (
|
||||
<li key={tag} className={styles.tagItem}>
|
||||
<span className={`${styles.tagPill} ${styles[getTagColor(tag)]}`}>{tag}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.giteaLink}>
|
||||
<p className={styles.giteaText}>{t.projects.giteaText}</p>
|
||||
<a href="https://gitea.tietokonepaja.fi/" className={styles.giteaBtn} target="_blank" rel="noreferrer">
|
||||
{t.projects.giteaBtn}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useAtom } from 'jotai';
|
||||
import { themeState } from '../state/atoms';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { themeState, languageState } from '../state/atoms';
|
||||
import { getTranslations } from '../translations';
|
||||
import styles from '../styles/components/ThemeToggle.module.scss';
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [theme, setTheme] = useAtom(themeState);
|
||||
const language = useAtomValue(languageState);
|
||||
const t = getTranslations(language);
|
||||
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'}
|
||||
aria-label={isDark ? t.theme.switchToLight : t.theme.switchToDark}
|
||||
>
|
||||
{isDark ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
|
||||
36
src/main.jsx
36
src/main.jsx
@@ -1,13 +1,33 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { StrictMode, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'jotai';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { languageState } from './state/atoms';
|
||||
import './styles/main.scss';
|
||||
import App from './App.jsx';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</StrictMode>,
|
||||
);
|
||||
export function LanguageSync() {
|
||||
const language = useAtomValue(languageState);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = language;
|
||||
}, [language]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Root() {
|
||||
return (
|
||||
<StrictMode>
|
||||
<Provider>
|
||||
<LanguageSync />
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</Provider>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<Root />);
|
||||
|
||||
@@ -5,3 +5,7 @@ export const navOpenState = atom(false);
|
||||
export const activeSectionState = atom('home');
|
||||
|
||||
export const themeState = atom('dark');
|
||||
|
||||
export const languageState = atom(
|
||||
typeof navigator !== 'undefined' && navigator.language?.startsWith('fi') ? 'fi' : 'en'
|
||||
);
|
||||
|
||||
@@ -127,39 +127,3 @@
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
// ── "Open to work" badge ───────────────────────────────────────
|
||||
.badge {
|
||||
position: absolute;
|
||||
bottom: $space-8;
|
||||
right: $space-6;
|
||||
background: rgb(var(--color-bg-card) / 0.9);
|
||||
border: 1px solid rgba(34, 197, 94, 0.35);
|
||||
border-radius: 100px;
|
||||
padding: $space-2 $space-4;
|
||||
font-size: $text-xs;
|
||||
font-family: $font-mono;
|
||||
letter-spacing: 0.06em;
|
||||
color: #4ade80;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $space-2;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: pulse 2s ease infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.badge { display: none; }
|
||||
}
|
||||
|
||||
45
src/styles/components/LanguagePicker.module.scss
Normal file
45
src/styles/components/LanguagePicker.module.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
@use '../variables' as *;
|
||||
|
||||
.picker {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 0.15rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flag {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem;
|
||||
border-radius: 2px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&[aria-pressed='false'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flagIcon {
|
||||
font-size: 1.1rem;
|
||||
display: inline-block;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
39
src/styles/components/NotFound.module.scss
Normal file
39
src/styles/components/NotFound.module.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
@use '../variables' as *;
|
||||
|
||||
.notFound {
|
||||
padding: $space-24 $space-6;
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.inner {
|
||||
max-width: $max-width;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
margin-bottom: $space-8;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: 0 0 30px rgb(var(--color-primary-mid) / 0.2);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: $text-4xl;
|
||||
font-weight: 700;
|
||||
color: $color-text;
|
||||
margin-bottom: $space-4;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: $text-lg;
|
||||
color: $color-text-muted;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -66,19 +66,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.featuredBadge {
|
||||
position: absolute;
|
||||
top: $space-4;
|
||||
right: $space-4;
|
||||
font-size: $text-xs;
|
||||
font-family: $font-mono;
|
||||
color: $color-accent-warm;
|
||||
background: rgb(var(--color-accent-warm) / 0.1);
|
||||
border: 1px solid rgb(var(--color-accent-warm) / 0.25);
|
||||
border-radius: 100px;
|
||||
padding: 2px $space-3;
|
||||
}
|
||||
|
||||
.cardTop {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -86,6 +73,22 @@
|
||||
margin-bottom: $space-2;
|
||||
}
|
||||
|
||||
.cardLinks {
|
||||
display: flex;
|
||||
gap: $space-2;
|
||||
}
|
||||
|
||||
.sourceLink {
|
||||
font-size: $text-xl;
|
||||
color: $color-text-muted;
|
||||
transition: color $transition-fast, transform $transition-fast;
|
||||
|
||||
&:hover {
|
||||
color: $color-accent;
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
}
|
||||
|
||||
.folderIcon {
|
||||
font-size: $text-2xl;
|
||||
color: $color-accent;
|
||||
@@ -121,13 +124,93 @@
|
||||
flex-wrap: wrap;
|
||||
gap: $space-2;
|
||||
margin-top: $space-2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
font-family: $font-mono;
|
||||
font-size: $text-xs;
|
||||
color: $color-text-dim;
|
||||
background: rgb(var(--color-primary) / 0.15);
|
||||
border-radius: $radius-sm;
|
||||
padding: 2px $space-3;
|
||||
.tagItem {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tagPill {
|
||||
font-family: $font-mono;
|
||||
font-size: $text-xs;
|
||||
border-radius: $radius-sm;
|
||||
padding: 2px $space-3;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
// Tag color variants
|
||||
.tagReact {
|
||||
background: rgb(97, 218, 251);
|
||||
color: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.tagDotnet {
|
||||
background: rgb(102, 51, 153);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.tagSqlite {
|
||||
background: rgb(70, 130, 180);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.tagApache {
|
||||
background: rgb(204, 102, 51);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.tagUbuntu {
|
||||
background: rgb(220, 100, 40);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.tagNextcloud {
|
||||
background: rgb(0, 150, 200);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.tagVite {
|
||||
background: rgb(150, 50, 200);
|
||||
color: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
.tagDefault {
|
||||
background: rgb(var(--color-primary) / 0.15);
|
||||
color: $color-text-dim;
|
||||
}
|
||||
|
||||
// ── Gitea Link ──────────────────────────────────────────────────
|
||||
.giteaLink {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $space-4;
|
||||
margin-top: $space-12;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.giteaText {
|
||||
font-size: $text-lg;
|
||||
color: $color-text-muted;
|
||||
}
|
||||
|
||||
.giteaBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: $space-4 $space-12;
|
||||
border: 1px solid $color-accent;
|
||||
border-radius: $radius-md;
|
||||
font-size: $text-base;
|
||||
font-weight: 600;
|
||||
color: $color-accent;
|
||||
transition: all $transition-base;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
&:hover {
|
||||
background: rgb(var(--color-accent) / 0.08);
|
||||
color: $color-accent;
|
||||
box-shadow: 0 0 20px rgb(var(--color-accent) / 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
200
src/translations.js
Normal file
200
src/translations.js
Normal file
@@ -0,0 +1,200 @@
|
||||
export const translations = {
|
||||
en: {
|
||||
// Navbar
|
||||
nav: {
|
||||
home: 'Home',
|
||||
about: 'About',
|
||||
projects: 'Projects',
|
||||
contact: 'Contact',
|
||||
},
|
||||
// Hero
|
||||
hero: {
|
||||
greeting: 'Hey, I\'m',
|
||||
name: 'Veikko Lintujärvi',
|
||||
tagline: 'Making world a better place — one line at a time.',
|
||||
sub: 'I\'m a curious software engineer from Turku Archipelago, Finland. Right now I\'m excited about cloud, Linux and AI.',
|
||||
ctaPrimary: 'See my work',
|
||||
ctaSecondary: 'Get in touch',
|
||||
},
|
||||
// About
|
||||
about: {
|
||||
sectionNum: '01.',
|
||||
sectionTitle: 'About me',
|
||||
paragraph1: 'I\'m a curious software engineer with a passion for building scalable and efficient applications. With deep knowledge in cloud, backend, and frontend development, I enjoy working on projects that challenge me to learn new technologies and improve my skills.',
|
||||
paragraph2: 'I tend to build always something that is useful to others, and I find joy in creating tools and applications that solve real-world problems. I believe that technology has the power to make a positive impact on people\'s lives, and I strive to contribute to that impact through my work.',
|
||||
paragraph3: 'Here are some technologies I\'ve been working with recently:',
|
||||
stack: ['Azure', '.NET', 'C#', 'JavaScript', 'React', 'Node.js', 'SQL'],
|
||||
meta: {
|
||||
location: 'location',
|
||||
locationValue: 'Turku Archipelago, Finland',
|
||||
favoriteBook: 'favorite book',
|
||||
favoriteBookValue: 'The Name of the Rose',
|
||||
favoriteSongs: 'favorite songs',
|
||||
},
|
||||
},
|
||||
// Projects
|
||||
projects: {
|
||||
sectionNum: '02.',
|
||||
sectionTitle: 'Projects',
|
||||
projects: [
|
||||
{
|
||||
name: 'Klapi - Unified content management panel',
|
||||
desc: 'Web interface to view and manage data held by Livonsaaren Tietokonepaja. Easy to customize serving various customer solutions.',
|
||||
tags: ['.NET', 'React', 'SQLite'],
|
||||
},
|
||||
{
|
||||
name: 'Hattara - Shared online office',
|
||||
desc: 'Why to code your own when there is working open source solution to host on your own server? Hattara is a self-hosted cloud office solution for small teams and individuals.',
|
||||
tags: ['Apache2', 'Ubuntu Server', 'NextCloud'],
|
||||
},
|
||||
{
|
||||
name: 'Runosaari festival website - Pro bono project',
|
||||
desc: "Supporting local culture initiatives have always been close to my heart. I'm proudly helping Runosaari festival with their website and online presence already for the sixth year!",
|
||||
tags: ['React', 'Vite', 'Apache2', 'Ubuntu Server'],
|
||||
},
|
||||
],
|
||||
giteaText: 'Check out here the rest of my code',
|
||||
giteaBtn: 'View on Gitea',
|
||||
folderIcon: '⬡',
|
||||
externalLinkAria: 'Open',
|
||||
sourceLinkAria: 'View source code for',
|
||||
},
|
||||
// Contact
|
||||
contact: {
|
||||
sectionNum: '03.',
|
||||
sectionTitle: 'Get in touch',
|
||||
lede: "I'm currently employed, but my inbox is always open. Whether you have a question or just want to say hi, I'll try my best to get back to you!",
|
||||
paragraphBefore: 'I also have a small company ',
|
||||
companyName: 'Livonsaaren Tietokonepaja',
|
||||
paragraphAfter: ' if you are interested in collaboration or services.',
|
||||
emailBtn: 'Say hello',
|
||||
socials: {
|
||||
gitea: 'Gitea',
|
||||
linkedin: 'LinkedIn',
|
||||
company: 'Company',
|
||||
},
|
||||
},
|
||||
// Footer
|
||||
footer: {
|
||||
designedBy: 'Designed & built by',
|
||||
separator: '·',
|
||||
stack: 'React + Vite',
|
||||
},
|
||||
// NotFound
|
||||
notFound: {
|
||||
title: 'Page Not Found',
|
||||
message: "I didn't find what you were looking for :(",
|
||||
},
|
||||
// ThemeToggle
|
||||
theme: {
|
||||
switchToLight: 'Switch to light mode',
|
||||
switchToDark: 'Switch to dark mode',
|
||||
},
|
||||
// Language names
|
||||
languages: {
|
||||
en: 'English',
|
||||
fi: 'Suomi',
|
||||
},
|
||||
},
|
||||
fi: {
|
||||
// Navbar
|
||||
nav: {
|
||||
home: 'Etusivu',
|
||||
about: 'Minä',
|
||||
projects: 'Projektit',
|
||||
contact: 'Yhteystiedot',
|
||||
},
|
||||
// Hero
|
||||
hero: {
|
||||
greeting: 'Hei, olen',
|
||||
name: 'Veikko Lintujärvi',
|
||||
tagline: 'Tehdään maailmasta parempi paikka kaikille — koodirivi kerrallaan.',
|
||||
sub: 'Olen utelias insinööri Turun saaristosta. Tällä hetkellä inspiroidun pilvipalveluista, Linuxista ja tekoälystä.',
|
||||
ctaPrimary: 'Töitäni',
|
||||
ctaSecondary: 'Ota yhteyttä',
|
||||
},
|
||||
// About
|
||||
about: {
|
||||
sectionNum: '01.',
|
||||
sectionTitle: 'Minä',
|
||||
paragraph1: 'Intohimonani on rakentaa tehokasta ja skaalautuvaa internettiä. Nautin haastavista projekteista, joissa pääsen syventämään osaamistani pilvi-, fullstack- ja tekoälykehityksessä. Haluan jatkuvasti oppia uusia teknologioita ja sovelluskehityksen toimintatapoja tekemiseni ohessa.',
|
||||
paragraph2: 'Pyrin aina rakentamaan jotain, mistä on hyötyä muille. Innostun kun saan rakentaa työkaluja, jotka ratkovat aitoja ongelmia. Uskon, että tekniikalla on kyky vaikuttaa positiivisesti ihmisiin ja yhteiskuntaan.',
|
||||
paragraph3: 'Tässä muutamia teknologioita, joiden parissa olen viime aikoina työskennellyt:',
|
||||
stack: ['Azure', '.NET', 'C#', 'Mistral AI', 'JavaScript', 'React', 'Node.js', 'SQL'],
|
||||
meta: {
|
||||
location: 'sijainti',
|
||||
locationValue: 'Turun saaristo, Suomi',
|
||||
favoriteBook: 'lempi kirja',
|
||||
favoriteBookValue: 'Ruusun nimi',
|
||||
favoriteSongs: 'lempi biisit',
|
||||
},
|
||||
},
|
||||
// Projects
|
||||
projects: {
|
||||
sectionNum: '02.',
|
||||
sectionTitle: 'Projektit',
|
||||
projects: [
|
||||
{
|
||||
name: 'Klapi - Yhtenäinen sisällönhallintapaneeli',
|
||||
desc: 'Verkkokäyttöliittymä Livonsaaren Tietokonepajan datan esittämiseen ja hallintaan. Helppo mukauttaa eri asiakasratkaisuihin.',
|
||||
tags: ['.NET', 'React', 'SQLite'],
|
||||
},
|
||||
{
|
||||
name: 'Hattara - Jaettu pilvitoimisto',
|
||||
desc: 'Miksi koodata omaa, kun on olemassa toimiva avoimen lähdekoodin ratkaisu, jonka voi hostata omalla palvelimella? Hattara on itsenäinen pilvitoimistoratkaisu pienille tiimeille ja yksityishenkilöille.',
|
||||
tags: ['Apache2', 'Ubuntu Server', 'NextCloud'],
|
||||
},
|
||||
{
|
||||
name: 'Runosaaren festivaalin verkkosivut - Pro bono -projekti',
|
||||
desc: 'Paikallisten kulttuurihankkeiden tukeminen on aina ollut minulle tärkeää. Olen ollut mukana auttamassa Runosaaren festivaalia heidän verkkosivuissaan ja verkkonäkyvyydessään jo kuudennetta vuotta!',
|
||||
tags: ['React', 'Vite', 'Apache2', 'Ubuntu Server'],
|
||||
},
|
||||
],
|
||||
giteaText: 'Tutustu täältä muihin projekteihini',
|
||||
giteaBtn: 'Katso Giteassa',
|
||||
folderIcon: '⬡',
|
||||
externalLinkAria: 'Avaa',
|
||||
sourceLinkAria: 'Näytä lähdekoodi',
|
||||
},
|
||||
// Contact
|
||||
contact: {
|
||||
sectionNum: '03.',
|
||||
sectionTitle: 'Ota yhteyttä',
|
||||
lede: 'Olen tällä hetkellä työsuhteessa, mutta luen mielenkiinnolla kaikki yhteydenotot. Olipa sinulla kysymys, ehdotus tai tervehdys, yritän parhaani mukaan vastata!',
|
||||
paragraphBefore: 'Minulla on myös yritys: ',
|
||||
companyName: 'Livonsaaren Tietokonepaja',
|
||||
paragraphAfter: '. Jos olet kiinnostunut yhteistyöstä tai palveluistamme, ota rohkeasti yhteyttä.',
|
||||
emailBtn: 'Laita mailia',
|
||||
socials: {
|
||||
gitea: 'Gitea',
|
||||
linkedin: 'LinkedIn',
|
||||
company: 'Yritykseni',
|
||||
},
|
||||
},
|
||||
// Footer
|
||||
footer: {
|
||||
designedBy: 'Suunnitellut & toteuttanut',
|
||||
separator: '·',
|
||||
stack: 'React + Vite',
|
||||
},
|
||||
// NotFound
|
||||
notFound: {
|
||||
title: 'Sivua ei löydy',
|
||||
message: 'En löytänyt etsimääsi sivua :(',
|
||||
},
|
||||
// ThemeToggle
|
||||
theme: {
|
||||
switchToLight: 'Vaihda vaaleaan teemaan',
|
||||
switchToDark: 'Vaihda tummaan teemaan',
|
||||
},
|
||||
// Language names
|
||||
languages: {
|
||||
en: 'English',
|
||||
fi: 'Suomi',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getTranslations = (lang) => {
|
||||
return translations[lang] || translations.en;
|
||||
};
|
||||
@@ -12,4 +12,7 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: ['veikko.lintujarvi.fi', 'www.veikko.lintujarvi.fi', 'localhost'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user