Compare commits

...

10 Commits

Author SHA1 Message Date
e535fadde2 Improved language picker 2026-08-16 21:35:52 +03:00
dd90d250c6 Added og tags 2026-08-16 21:25:12 +03:00
957ae4f828 Fix company link inside paragraph 2026-08-16 21:14:40 +03:00
00c08f6544 Finnish transaltions 2026-08-16 21:07:36 +03:00
d2cd0e37d4 Add allowedHosts 2026-08-10 21:53:59 +03:00
1ce8b64d64 Add start script 2026-08-10 21:52:07 +03:00
7e0d964f35 New deployment script added 2026-08-10 18:10:34 +03:00
de56069158 Gitea link 2026-08-09 21:45:29 +03:00
c984db4ef6 Not found page 2026-08-09 21:33:21 +03:00
fca87f49d9 Add NotFound page with picture
- Create NotFound component that displays not-found.png
- Add SCSS styles for the NotFound page
- Update App.jsx to show NotFound page for invalid routes instead of redirecting

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-08-09 21:29:43 +03:00
22 changed files with 566 additions and 156 deletions

View File

@@ -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>

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

30
scripts/deploy.sh Normal file
View 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."

View File

@@ -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) {
if (!notFound && path !== activeSection) {
setActiveSection(path);
}
} else {
// Invalid path, redirect to home
navigate('/');
}
}, [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 />

View File

@@ -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>

View File

@@ -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}

View File

@@ -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 &amp; 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>
);

View File

@@ -1,38 +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}>
<Link to="/projects" className={styles.btnPrimary}>
See my work
{t.hero.ctaPrimary}
</Link>
<Link to="/contact" className={styles.btnGhost}>
Get in touch
{t.hero.ctaSecondary}
</Link>
</div>
</div>
<div className={styles.badge} aria-hidden="true">
<span>open to work</span>
</div>
</section>
);
}

View 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>
);
}

View File

@@ -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 />

View 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>
);
}

View File

@@ -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';
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 colorMap = {
'.NET': 'tagDotnet',
@@ -38,25 +17,28 @@ const getTagColor = (tag) => {
};
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) => (
{t.projects.projects.map((p) => (
<article key={p.name} className={styles.card}>
<div className={styles.cardTop}>
<span className={styles.folderIcon}>⬡</span>
<span className={styles.folderIcon}>{t.projects.folderIcon}</span>
<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 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>
</div>
@@ -64,15 +46,21 @@ export default function Projects() {
<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} className={styles.tagItem}>
<span className={`${styles.tagPill} ${styles[getTagColor(t)]}`}>{t}</span>
{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>
);

View File

@@ -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>

View File

@@ -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(
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>
</StrictMode>,
</Provider>
</StrictMode>
);
}
createRoot(document.getElementById('root')).render(<Root />);

View File

@@ -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'
);

View File

@@ -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; }
}

View 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;
}
}

View 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;
}

View File

@@ -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 {
font-size: $text-2xl;
color: $color-accent;
@@ -192,3 +179,38 @@
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
View 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;
};

View File

@@ -12,4 +12,7 @@ export default defineConfig({
},
},
},
preview: {
allowedHosts: ['veikko.lintujarvi.fi', 'www.veikko.lintujarvi.fi', 'localhost'],
},
})