Finnish transaltions
This commit is contained in:
@@ -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';
|
import styles from '../styles/components/About.module.scss';
|
||||||
|
|
||||||
const STACK = ['Azure', '.NET', 'C#', 'JavaScript', 'React', 'Node.js', 'SQL'];
|
|
||||||
|
|
||||||
export default function About() {
|
export default function About() {
|
||||||
|
const language = useAtomValue(languageState);
|
||||||
|
const t = getTranslations(language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="about" className={styles.about}>
|
<section id="about" className={styles.about}>
|
||||||
<div className={styles.inner}>
|
<div className={styles.inner}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.sectionNum}>01.</span>
|
<span className={styles.sectionNum}>{t.about.sectionNum}</span>
|
||||||
<h2 className={styles.sectionTitle}>About me</h2>
|
<h2 className={styles.sectionTitle}>{t.about.sectionTitle}</h2>
|
||||||
<div className={styles.line} />
|
<div className={styles.line} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<p>
|
<p>
|
||||||
I'm a curious software engineer with a passion for building scalable and efficient
|
{t.about.paragraph1}
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
I tend to build always something that is useful to others, and I find joy in creating tools
|
{t.about.paragraph2}
|
||||||
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.
|
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Here are some technologies I've been working with recently:
|
{t.about.paragraph3}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className={styles.stack}>
|
<ul className={styles.stack}>
|
||||||
{STACK.map((item) => (
|
{t.about.stack.map((item) => (
|
||||||
<li key={item}>
|
<li key={item}>
|
||||||
<span className={styles.arrow}>▸</span> {item}
|
<span className={styles.arrow}>▸</span> {item}
|
||||||
</li>
|
</li>
|
||||||
@@ -43,15 +43,15 @@ export default function About() {
|
|||||||
</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}>{t.about.meta.location}</span>
|
||||||
<span className={styles.metaValue}>Turku Archipelago, Finland</span>
|
<span className={styles.metaValue}>{t.about.meta.locationValue}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.metaLine}>
|
<p className={styles.metaLine}>
|
||||||
<span className={styles.metaKey}>favorite book</span>
|
<span className={styles.metaKey}>{t.about.meta.favoriteBook}</span>
|
||||||
<span className={styles.metaValue}>The Lord of the Rings</span>
|
<span className={styles.metaValue}>{t.about.meta.favoriteBookValue}</span>
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.metaLine}>
|
<div className={styles.metaLine}>
|
||||||
<span className={styles.metaKey}>favorite songs</span>
|
<span className={styles.metaKey}>{t.about.meta.favoriteSongs}</span>
|
||||||
<div className={styles.metaLinks}>
|
<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=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>
|
<a href="https://www.youtube.com/watch?v=2i2oJJwgLTk" target="_blank" rel="noreferrer">Year of Love – Jenny Hval</a>
|
||||||
|
|||||||
@@ -1,35 +1,42 @@
|
|||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { languageState } from '../state/atoms';
|
||||||
|
import { getTranslations } from '../translations';
|
||||||
import styles from '../styles/components/Contact.module.scss';
|
import styles from '../styles/components/Contact.module.scss';
|
||||||
|
|
||||||
export default function Contact() {
|
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 (
|
return (
|
||||||
<section id="contact" className={styles.contact}>
|
<section id="contact" className={styles.contact}>
|
||||||
<div className={styles.inner}>
|
<div className={styles.inner}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.sectionNum}>03.</span>
|
<span className={styles.sectionNum}>{t.contact.sectionNum}</span>
|
||||||
<h2 className={styles.sectionTitle}>Get in touch</h2>
|
<h2 className={styles.sectionTitle}>{t.contact.sectionTitle}</h2>
|
||||||
<div className={styles.line} />
|
<div className={styles.line} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<p className={styles.lede}>
|
<p className={styles.lede}>
|
||||||
I'm currently employed, but my inbox is always open. Whether you have a question or
|
{t.contact.lede}
|
||||||
just want to say hi, I'll try my best to get back to you!
|
|
||||||
</p>
|
</p>
|
||||||
<p>I also have a small company if you are interested in collaboration or services.</p>
|
<p dangerouslySetInnerHTML={{ __html: t.contact.paragraph }} />
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="mailto:veikko@lintujarvi.fi"
|
href="mailto:veikko@lintujarvi.fi"
|
||||||
className={styles.emailBtn}
|
className={styles.emailBtn}
|
||||||
>
|
>
|
||||||
Say hello ↗
|
{t.contact.emailBtn} ↗
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul className={styles.socials}>
|
<ul className={styles.socials}>
|
||||||
{[
|
{SOCIALS.map(({ label, href }) => (
|
||||||
{ 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 }) => (
|
|
||||||
<li key={label}>
|
<li key={label}>
|
||||||
<a href={href} target="_blank" rel="noreferrer">
|
<a href={href} target="_blank" rel="noreferrer">
|
||||||
{label}
|
{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';
|
import styles from '../styles/components/Footer.module.scss';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
|
const language = useAtomValue(languageState);
|
||||||
|
const t = getTranslations(language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className={styles.footer}>
|
<footer className={styles.footer}>
|
||||||
<p>
|
<p>
|
||||||
Designed & built by{' '}
|
{t.footer.designedBy}{' '}
|
||||||
<a href="#home">Veikko Lintujärvi</a>{' '}
|
<a href="#home">Veikko Lintujärvi</a>{' '}
|
||||||
<span className={styles.separator}>·</span>{' '}
|
<span className={styles.separator}>{t.footer.separator}</span>{' '}
|
||||||
<span className={styles.stack}>React + Vite</span>
|
<span className={styles.stack}>{t.footer.stack}</span>
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
import { Link } from 'react-router-dom';
|
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';
|
import styles from '../styles/components/Hero.module.scss';
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
|
const language = useAtomValue(languageState);
|
||||||
|
const t = getTranslations(language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="home" className={styles.hero}>
|
<section id="home" className={styles.hero}>
|
||||||
<div className={styles.grid} aria-hidden="true" />
|
<div className={styles.grid} aria-hidden="true" />
|
||||||
|
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<p className={styles.greeting}>Hey, I'm</p>
|
<p className={styles.greeting}>{t.hero.greeting}</p>
|
||||||
<h1 className={styles.name}>
|
<h1 className={styles.name}>
|
||||||
Veikko Lintujärvi<span className={styles.dot}>.</span>
|
{t.hero.name}<span className={styles.dot}>.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className={styles.tagline}>
|
<p className={styles.tagline}>
|
||||||
Making world a better place —{' '}
|
{t.hero.tagline}
|
||||||
<span className={styles.highlight}>one line at a time.</span>
|
|
||||||
</p>
|
</p>
|
||||||
<p className={styles.sub}>
|
<p className={styles.sub}>
|
||||||
I'm a curious software engineer from Turku Archipelago, Finland. Right now
|
{t.hero.sub}
|
||||||
I'm excited about cloud, Linux and AI.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={styles.cta}>
|
<div className={styles.cta}>
|
||||||
<Link to="/projects" className={styles.btnPrimary}>
|
<Link to="/projects" className={styles.btnPrimary}>
|
||||||
See my work
|
{t.hero.ctaPrimary}
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/contact" className={styles.btnGhost}>
|
<Link to="/contact" className={styles.btnGhost}>
|
||||||
Get in touch
|
{t.hero.ctaSecondary}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.badge} aria-hidden="true">
|
|
||||||
<span>open to work</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/components/LanguagePicker.jsx
Normal file
23
src/components/LanguagePicker.jsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
const toggleLanguage = () => {
|
||||||
|
setLanguage(language === 'en' ? 'fi' : 'en');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={styles.picker}
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
aria-label={`Switch to ${language === 'en' ? t.languages.fi : t.languages.en}`}
|
||||||
|
>
|
||||||
|
<span className={styles.label}>{language === 'en' ? 'FI' : 'EN'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,23 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
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 ThemeToggle from './ThemeToggle';
|
||||||
|
import LanguagePicker from './LanguagePicker';
|
||||||
import styles from '../styles/components/Navbar.module.scss';
|
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() {
|
export default function Navbar() {
|
||||||
const [isOpen, setIsOpen] = useAtom(navOpenState);
|
const [isOpen, setIsOpen] = useAtom(navOpenState);
|
||||||
const activeSection = useAtomValue(activeSectionState);
|
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 (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
@@ -39,12 +43,13 @@ export default function Navbar() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className={styles.navActions}>
|
<div className={styles.navActions}>
|
||||||
|
<LanguagePicker />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className={`${styles.burger} ${isOpen ? styles.burgerOpen : ''}`}
|
className={`${styles.burger} ${isOpen ? styles.burgerOpen : ''}`}
|
||||||
onClick={() => setIsOpen((v) => !v)}
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
aria-label="Toggle menu"
|
aria-label={t.theme.switchToLight}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<span />
|
<span />
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { languageState } from '../state/atoms';
|
||||||
|
import { getTranslations } from '../translations';
|
||||||
import styles from '../styles/components/NotFound.module.scss';
|
import styles from '../styles/components/NotFound.module.scss';
|
||||||
|
|
||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
|
const language = useAtomValue(languageState);
|
||||||
|
const t = getTranslations(language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="not-found" className={styles.notFound}>
|
<section id="not-found" className={styles.notFound}>
|
||||||
<div className={styles.inner}>
|
<div className={styles.inner}>
|
||||||
<div className={styles.imageContainer}>
|
<div className={styles.imageContainer}>
|
||||||
<img src="/not-found.png" alt="Not found" className={styles.image} />
|
<img src="/not-found.png" alt="Not found" className={styles.image} />
|
||||||
</div>
|
</div>
|
||||||
<h1 className={styles.title}>Page Not Found</h1>
|
<h1 className={styles.title}>{t.notFound.title}</h1>
|
||||||
<p className={styles.message}>I didn't find what you were looking for :{'('}</p>
|
<p className={styles.message}>{t.notFound.message}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,29 +1,8 @@
|
|||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { languageState } from '../state/atoms';
|
||||||
|
import { getTranslations } from '../translations';
|
||||||
import styles from '../styles/components/Projects.module.scss';
|
import styles from '../styles/components/Projects.module.scss';
|
||||||
|
|
||||||
const 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'],
|
|
||||||
href: 'https://klapi.tietokonepaja.fi/',
|
|
||||||
sourceHref: 'https://github.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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'],
|
|
||||||
href: 'https://hattara.tietokonepaja.fi/',
|
|
||||||
sourceHref: 'https://github.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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'],
|
|
||||||
href: 'https://www.runosaari.fi/',
|
|
||||||
sourceHref: 'https://github.com',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const getTagColor = (tag) => {
|
const getTagColor = (tag) => {
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
'.NET': 'tagDotnet',
|
'.NET': 'tagDotnet',
|
||||||
@@ -38,25 +17,28 @@ const getTagColor = (tag) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects() {
|
||||||
|
const language = useAtomValue(languageState);
|
||||||
|
const t = getTranslations(language);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="projects" className={styles.projects}>
|
<section id="projects" className={styles.projects}>
|
||||||
<div className={styles.inner}>
|
<div className={styles.inner}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<span className={styles.sectionNum}>02.</span>
|
<span className={styles.sectionNum}>{t.projects.sectionNum}</span>
|
||||||
<h2 className={styles.sectionTitle}>Projects</h2>
|
<h2 className={styles.sectionTitle}>{t.projects.sectionTitle}</h2>
|
||||||
<div className={styles.line} />
|
<div className={styles.line} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
{PROJECTS.map((p) => (
|
{t.projects.projects.map((p) => (
|
||||||
<article key={p.name} className={styles.card}>
|
<article key={p.name} className={styles.card}>
|
||||||
<div className={styles.cardTop}>
|
<div className={styles.cardTop}>
|
||||||
<span className={styles.folderIcon}>⬡</span>
|
<span className={styles.folderIcon}>{t.projects.folderIcon}</span>
|
||||||
<div className={styles.cardLinks}>
|
<div className={styles.cardLinks}>
|
||||||
<a href={p.href} className={styles.externalLink} aria-label={`Open ${p.name}`}>
|
<a href={p.href} className={styles.externalLink} aria-label={`${t.projects.externalLinkAria} ${p.name}`}>
|
||||||
↗
|
↗
|
||||||
</a>
|
</a>
|
||||||
<a href={p.sourceHref} className={styles.sourceLink} aria-label={`View source code for ${p.name}`}>
|
<a href={p.sourceHref} className={styles.sourceLink} aria-label={`${t.projects.sourceLinkAria} ${p.name}`}>
|
||||||
📦
|
📦
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,9 +46,9 @@ export default function Projects() {
|
|||||||
<h3 className={styles.cardName}>{p.name}</h3>
|
<h3 className={styles.cardName}>{p.name}</h3>
|
||||||
<p className={styles.cardDesc}>{p.desc}</p>
|
<p className={styles.cardDesc}>{p.desc}</p>
|
||||||
<ul className={styles.tags}>
|
<ul className={styles.tags}>
|
||||||
{p.tags.map((t) => (
|
{p.tags.map((tag) => (
|
||||||
<li key={t} className={styles.tagItem}>
|
<li key={tag} className={styles.tagItem}>
|
||||||
<span className={`${styles.tagPill} ${styles[getTagColor(t)]}`}>{t}</span>
|
<span className={`${styles.tagPill} ${styles[getTagColor(tag)]}`}>{tag}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -74,9 +56,9 @@ export default function Projects() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.giteaLink}>
|
<div className={styles.giteaLink}>
|
||||||
<p className={styles.giteaText}>Check out here the rest of my code</p>
|
<p className={styles.giteaText}>{t.projects.giteaText}</p>
|
||||||
<a href="https://gitea.tietokonepaja.fi/" className={styles.giteaBtn} target="_blank" rel="noreferrer">
|
<a href="https://gitea.tietokonepaja.fi/" className={styles.giteaBtn} target="_blank" rel="noreferrer">
|
||||||
View on Gitea
|
{t.projects.giteaBtn}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useAtom } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { themeState } from '../state/atoms';
|
import { themeState, languageState } from '../state/atoms';
|
||||||
|
import { getTranslations } from '../translations';
|
||||||
import styles from '../styles/components/ThemeToggle.module.scss';
|
import styles from '../styles/components/ThemeToggle.module.scss';
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
export default function ThemeToggle() {
|
||||||
const [theme, setTheme] = useAtom(themeState);
|
const [theme, setTheme] = useAtom(themeState);
|
||||||
|
const language = useAtomValue(languageState);
|
||||||
|
const t = getTranslations(language);
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={styles.toggle}
|
className={styles.toggle}
|
||||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
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 />}
|
{isDark ? <SunIcon /> : <MoonIcon />}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
26
src/main.jsx
26
src/main.jsx
@@ -1,13 +1,33 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode, useEffect } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { Provider } from 'jotai';
|
||||||
import { BrowserRouter as Router } from 'react-router-dom';
|
import { BrowserRouter as Router } from 'react-router-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { languageState } from './state/atoms';
|
||||||
import './styles/main.scss';
|
import './styles/main.scss';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
export function LanguageSync() {
|
||||||
|
const language = useAtomValue(languageState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
}, [language]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Root() {
|
||||||
|
return (
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
|
<Provider>
|
||||||
|
<LanguageSync />
|
||||||
<Router>
|
<Router>
|
||||||
<App />
|
<App />
|
||||||
</Router>
|
</Router>
|
||||||
</StrictMode>,
|
</Provider>
|
||||||
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<Root />);
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ export const navOpenState = atom(false);
|
|||||||
export const activeSectionState = atom('home');
|
export const activeSectionState = atom('home');
|
||||||
|
|
||||||
export const themeState = atom('dark');
|
export const themeState = atom('dark');
|
||||||
|
|
||||||
|
export const languageState = atom('en');
|
||||||
|
|||||||
@@ -127,39 +127,3 @@
|
|||||||
transform: translateY(-2px);
|
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; }
|
|
||||||
}
|
|
||||||
|
|||||||
27
src/styles/components/LanguagePicker.module.scss
Normal file
27
src/styles/components/LanguagePicker.module.scss
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@use '../variables' as *;
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--text-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
@@ -89,19 +89,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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folderIcon {
|
.folderIcon {
|
||||||
font-size: $text-2xl;
|
font-size: $text-2xl;
|
||||||
color: $color-accent;
|
color: $color-accent;
|
||||||
|
|||||||
196
src/translations.js
Normal file
196
src/translations.js
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
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!",
|
||||||
|
paragraph: 'I also have a small company 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!',
|
||||||
|
paragraph: 'Minulla on myös yritys: <a href="https://tietokonepaja.fi/" target="_blank" rel="noreferrer">Livonsaaren Tietokonepaja</a>. 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;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user