Compare commits
10 Commits
666a53592d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e535fadde2 | |||
| dd90d250c6 | |||
| 957ae4f828 | |||
| 00c08f6544 | |||
| d2cd0e37d4 | |||
| 1ce8b64d64 | |||
| 7e0d964f35 | |||
| de56069158 | |||
| c984db4ef6 | |||
| fca87f49d9 |
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>Veikko Lintujärvi</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"start": "vite preview --port 4175 --host"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jotai": "^2.20.0",
|
"jotai": "^2.20.0",
|
||||||
@@ -27,4 +28,4 @@
|
|||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 About from './components/About';
|
||||||
import Projects from './components/Projects';
|
import Projects from './components/Projects';
|
||||||
import Contact from './components/Contact';
|
import Contact from './components/Contact';
|
||||||
|
import NotFound from './components/NotFound';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
import styles from './App.module.scss';
|
import styles from './App.module.scss';
|
||||||
|
|
||||||
@@ -27,18 +28,15 @@ export default function App() {
|
|||||||
const [displayedSection, setDisplayedSection] = useState(activeSection);
|
const [displayedSection, setDisplayedSection] = useState(activeSection);
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const path = location.pathname.substring(1) || 'home';
|
||||||
|
const notFound = !Object.keys(SECTIONS).includes(path);
|
||||||
|
|
||||||
// Sync URL path with activeSection
|
// Sync URL path with activeSection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const path = location.pathname.substring(1) || 'home';
|
if (!notFound && path !== activeSection) {
|
||||||
if (Object.keys(SECTIONS).includes(path)) {
|
setActiveSection(path);
|
||||||
if (path !== activeSection) {
|
|
||||||
setActiveSection(path);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Invalid path, redirect to home
|
|
||||||
navigate('/');
|
|
||||||
}
|
}
|
||||||
}, [location.pathname, activeSection, setActiveSection, navigate]);
|
}, [location.pathname, activeSection, setActiveSection, navigate, path, notFound]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme === 'light') {
|
if (theme === 'light') {
|
||||||
@@ -59,6 +57,18 @@ export default function App() {
|
|||||||
const isExiting = activeSection !== displayedSection;
|
const isExiting = activeSection !== displayedSection;
|
||||||
const Section = SECTIONS[displayedSection] ?? Hero;
|
const Section = SECTIONS[displayedSection] ?? Hero;
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Navbar />
|
||||||
|
<main className={styles.enter}>
|
||||||
|
<NotFound />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Navbar />
|
<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';
|
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,48 @@
|
|||||||
|
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>
|
||||||
|
{t.contact.paragraphBefore}
|
||||||
|
<a href="https://tietokonepaja.fi/" target="_blank" rel="noreferrer">
|
||||||
|
{t.contact.companyName}
|
||||||
|
</a>
|
||||||
|
{t.contact.paragraphAfter}
|
||||||
</p>
|
</p>
|
||||||
<p>I also have a small company if you are interested in collaboration or services.</p>
|
|
||||||
|
|
||||||
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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 />
|
||||||
|
|||||||
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,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,15 +46,21 @@ 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>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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 { 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() {
|
||||||
<StrictMode>
|
const language = useAtomValue(languageState);
|
||||||
<Router>
|
|
||||||
<App />
|
useEffect(() => {
|
||||||
</Router>
|
document.documentElement.lang = language;
|
||||||
</StrictMode>,
|
}, [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 activeSectionState = atom('home');
|
||||||
|
|
||||||
export const themeState = atom('dark');
|
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);
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -192,3 +179,38 @@
|
|||||||
background: rgb(var(--color-primary) / 0.15);
|
background: rgb(var(--color-primary) / 0.15);
|
||||||
color: $color-text-dim;
|
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