chore: delete localhost_2025-09-27_00-35-13.report.html file

- Removed large Lighthouse report file from the repository.
This commit is contained in:
2025-09-27 07:13:51 +02:00
parent d30266248c
commit 3e2f561768
32 changed files with 1787 additions and 48648 deletions

View File

@@ -1,16 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npm run typecheck:*)",
"Bash(npm run build:*)",
"Bash(timeout 10s npm run dev)",
"Bash(npm run format:*)",
"Bash(npm run analyze:*)",
"Bash(npx eslint:*)",
"Bash(git add:*)",
"Bash(curl:*)"
],
"deny": [],
"ask": []
}
}

View File

@@ -0,0 +1,15 @@
---
description: Create a new UI component
aliases: [comp, ui]
parameters: [name, type]
---
Create a new {{type}} component named {{name}} that:
1. Follows project component patterns
2. Includes proper TypeScript types
3. Has responsive design considerations
4. Includes basic styling structure
5. Has proper prop validation
Make it reusable and well-documented.

View File

@@ -0,0 +1,15 @@
---
description: Refactor JavaScript/TypeScript code
aliases: [refactor-js, clean]
parameters: [target]
---
Refactor {{target}} to improve:
1. Code structure and organization
2. Modern ES6+ syntax usage
3. Performance optimizations
4. Type safety (for TypeScript)
5. Reusability and maintainability
Follow current project conventions and patterns.

View File

@@ -0,0 +1,15 @@
---
description: Review code and suggest improvements
aliases: [code-review, cr]
parameters: [files]
---
Review the code in {{files}} and provide detailed feedback on:
1. Code quality and best practices
2. Potential bugs or issues
3. Performance considerations
4. Readability and maintainability
5. Security concerns
Provide specific, actionable suggestions for improvement.

View File

@@ -0,0 +1,17 @@
---
description: Generate comprehensive unit tests
aliases: [unittest, test-gen]
parameters: [filename]
---
Generate comprehensive unit tests for {{filename}}.
Consider:
1. Test all public functions and methods
2. Include edge cases and error scenarios
3. Use appropriate mocking where needed
4. Follow existing test framework conventions
5. Ensure good test coverage
If no filename provided, suggest which files need tests.

117
AGENTS.md Normal file
View File

@@ -0,0 +1,117 @@
# AGENTS.md
AI coding agent instructions for **nachtigall.dev**
## Project Overview
A Vue 3 + Vuetify playground for prototyping component ideas and documenting reusable UI patterns.
**Project Type:** Vue.js Web Application
**Primary Language:** TypeScript (61% of codebase)
**Secondary Languages:** Vue (37%)
## Architecture
**Key Frameworks & Libraries:**
- Vue.js (^3.5.22) - web
- Rollup (^6.0.3) - build
- Vite (^7.1.7) - build
- Vitest (^3.2.4) - testing
**Project Structure:**
- `public/` - Public assets
- `src/` - Source code
- `src/assets/` - Static assets
- `src/components/` - React/UI components
- `src/composables/` - Project files
- `src/directives/` - Project files
- `src/layouts/` - Project files
- `src/locales/` - Project files
- `src/pages/` - Page components
- `src/plugins/` - Project files
- `src/router/` - Project files
- `src/services/` - Service layer
- `src/stores/` - Project files
- `src/styles/` - Project files
- `src/utils/` - Utility functions
- `src/components/portfolio/` - Project files
- `src/pages/contact/` - Project files
- `src/pages/experience/` - Project files
- `src/pages/projects/` - Project files
- `src/services/portfolio-data/` - Project files
## Key Files
**Configuration:**
- `package.json` - Node.js dependencies and scripts
- `yarn.lock` - Configuration file
**Documentation:**
- `README.md`
- `src/components/README.md`
- `src/layouts/README.md`
## Development Commands
**Build:**
```bash
npm run build
```
**Test:**
```bash
npm run test
```
**Development:**
```bash
npm run dev
```
**Lint:**
```bash
npm run lint
```
## Code Style Guidelines
- Use camelCase for variables and functions
- Use PascalCase for classes and components
- Prefer const/let over var
- Use async/await over callbacks when possible
## Testing
**Testing Frameworks:** Vitest
**Run Tests:**
```bash
npm run test
```
**Test Files:**
- `vitest.config.ts`
## AI Coding Assistance Notes
**Important Considerations:**
- Check package.json for available scripts before running commands
- Be aware of Node.js version requirements
- Consider impact on bundle size when adding dependencies
- Project has 78 files across 24 directories
- Check build configuration files before making structural changes
---
_This AGENTS.md file was generated by Nanocoder. Update it as your project evolves._

View File

@@ -4,7 +4,7 @@
{
"name": "Ollama",
"baseUrl": "http://localhost:11434/v1",
"models": ["qwen3-coder:latest", "gemma3n:e4b"]
"models": ["gpt-oss:20b", "qwen3-coder:latest", "gemma3n:e4b"]
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,8 @@
<template>
<div>
<a class="skip-link" href="#main-content">Skip to main content</a>
<a class="skip-link" href="#main-content">
{{ t('common.skipToContent') }}
</a>
<v-app>
<v-main id="main-content">
@@ -13,5 +15,7 @@
</template>
<script lang="ts" setup>
//
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

View File

@@ -6,20 +6,19 @@
<v-card class="pa-6">
<v-card-title class="text-h5 mb-4">
<v-icon class="me-2" color="error"> mdi-alert-circle </v-icon>
Something went wrong
{{ t('error.title') }}
</v-card-title>
<v-card-text>
<p class="mb-4">
An unexpected error occurred. We apologize for the
inconvenience.
{{ t('error.message') }}
</p>
<v-expansion-panels v-if="errorDetails" variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon class="me-2"> mdi-bug </v-icon>
Error Details
{{ t('error.details') }}
</v-expansion-panel-title>
<v-expansion-panel-text>
<pre class="error-details">{{ errorDetails }}</pre>
@@ -31,12 +30,12 @@
<v-card-actions>
<v-btn color="primary" @click="retry">
<v-icon start> mdi-refresh </v-icon>
Try Again
{{ t('error.tryAgain') }}
</v-btn>
<v-btn variant="text" @click="goHome">
<v-icon start> mdi-home </v-icon>
Go Home
{{ t('error.goHome') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -49,6 +48,7 @@
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { logger } from '@/utils/logger'
@@ -79,6 +79,7 @@ const normalizeError = (input: unknown): Error => {
}
const router = useRouter()
const { t } = useI18n()
const hasError = ref(false)
const errorDetails = ref<string>('')

View File

@@ -6,13 +6,13 @@
&copy; {{ new Date().getFullYear() }} |
<span class="d-none d-sm-inline-block">nachtigall.dev</span> |
<a
aria-label="Christian Nachtigall on GitHub"
:aria-label="t('footer.githubAria')"
class="text-decoration-none text-caption text-medium-emphasis footer-link"
href="https://github.com/cnachtigall"
rel="noopener noreferrer"
target="_blank"
>
<span class="visually-hidden">GitHub profile opens in a new tab</span>
<span class="visually-hidden">{{ t('footer.githubHint') }}</span>
<v-icon
aria-hidden="true"
class="app-icon"
@@ -25,7 +25,11 @@
</v-footer>
</template>
<script lang="ts" setup></script>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<style lang="sass" scoped>
.footer-content

View File

@@ -1,6 +1,8 @@
<template>
<!-- Skip to main content link -->
<a class="skip-link" href="#main-content"> Skip to main content </a>
<a class="skip-link" href="#main-content">
{{ t('common.skipToContent') }}
</a>
<!-- Floating Navigation -->
<nav
@@ -9,8 +11,9 @@
'floating-nav--visible': showFloatingNav,
'floating-nav--scrolled': isScrolled,
'floating-nav--home': isHomeRoute,
'floating-nav--locale-switch': localeAnimationActive,
}"
aria-label="Main navigation"
:aria-label="t('nav.ariaMain')"
class="floating-nav"
role="navigation"
>
@@ -18,7 +21,7 @@
<!-- Logo/Brand -->
<div class="floating-nav__brand">
<RouterLink
aria-label="Christian Nachtigall - Home"
:aria-label="t('nav.brandAria', { name: 'Christian Nachtigall' })"
class="brand-link"
to="/"
>
@@ -27,7 +30,9 @@
</div>
<div v-if="!mobile" class="brand-text">
<div class="brand-name">Christian</div>
<div class="brand-title">Front-end Engineer</div>
<div class="brand-title">
{{ t('nav.brandTitle') }}
</div>
</div>
</RouterLink>
</div>
@@ -46,16 +51,48 @@
:to="resolveNavTarget(routeMeta)"
@click="onNavClick($event, routeMeta)"
>
<span class="nav-link__text">{{ routeMeta.displayLabel }}</span>
<Transition name="locale-fade" mode="out-in">
<span
:key="`${routeMeta.key}-${currentLocale}`"
class="nav-link__text"
>
{{ routeMeta.displayLabel }}
</span>
</Transition>
<div class="nav-link__indicator" />
</RouterLink>
</div>
</div>
<!-- CTA Button -->
<div v-if="!mobile" class="floating-nav__cta">
<!-- Actions -->
<div v-if="!mobile" class="floating-nav__actions">
<div
v-if="showLocaleSwitch"
class="floating-nav__locale"
role="group"
:aria-label="t('nav.localeLabel')"
>
<button
v-for="option in localeOptions"
:key="option.code"
type="button"
:aria-label="t('nav.localeAria', { locale: option.name })"
:aria-pressed="option.code === currentLocale"
:class="[
'locale-pill',
{ 'locale-pill--active': option.code === currentLocale },
]"
:title="option.name"
@click="changeLocale(option.code)"
>
{{ option.short }}
</button>
</div>
<RouterLink class="cta-button" to="/contact">
<span>Let's Connect</span>
<Transition name="locale-fade" mode="out-in">
<span :key="`cta-${currentLocale}`">{{ t('nav.cta') }}</span>
</Transition>
<v-icon icon="mdi-arrow-right" size="16" />
</RouterLink>
</div>
@@ -66,7 +103,7 @@
:class="{ 'mobile-menu-button--active': drawer }"
:aria-expanded="drawer ? 'true' : 'false'"
aria-controls="mobile-navigation"
aria-label="Toggle navigation menu"
:aria-label="t('nav.toggle')"
class="mobile-menu-button"
@click="toggleMobileMenu"
>
@@ -86,8 +123,11 @@
>
<aside
id="mobile-navigation"
aria-label="Mobile navigation"
class="mobile-sidebar"
:aria-label="t('nav.ariaMobile')"
:class="[
'mobile-sidebar',
{ 'mobile-sidebar--locale-switch': localeAnimationActive },
]"
role="navigation"
@click.stop
>
@@ -99,11 +139,13 @@
</div>
<div class="brand-text">
<div class="brand-name">Christian</div>
<div class="brand-title">Front-end Engineer</div>
<div class="brand-title">
{{ t('nav.brandTitle') }}
</div>
</div>
</RouterLink>
<button
aria-label="Close navigation"
:aria-label="t('nav.close')"
class="sidebar-close"
@click="closeMobileMenu"
>
@@ -125,12 +167,23 @@
@click="onNavClick($event, routeMeta)"
>
<div class="sidebar-nav-link__content">
<span class="sidebar-nav-link__text">{{
routeMeta.displayLabel
}}</span>
<span class="sidebar-nav-link__description">
{{ getNavDescription(routeMeta.key) }}
</span>
<Transition name="locale-fade" mode="out-in">
<span
:key="`sidebar-${routeMeta.key}-${currentLocale}`"
class="sidebar-nav-link__text"
>
{{ routeMeta.displayLabel }}
</span>
</Transition>
<Transition name="locale-fade" mode="out-in">
<span
v-if="getNavDescription(routeMeta.key)"
:key="`sidebar-desc-${routeMeta.key}-${currentLocale}`"
class="sidebar-nav-link__description"
>
{{ getNavDescription(routeMeta.key) }}
</span>
</Transition>
</div>
<v-icon
class="sidebar-nav-link__icon"
@@ -142,20 +195,49 @@
<!-- Sidebar Footer -->
<div class="mobile-sidebar__footer">
<div v-if="showLocaleSwitch" class="mobile-sidebar__locale">
<span class="mobile-locale__label">{{ t('nav.localeLabel') }}</span>
<div
class="mobile-locale__options"
role="group"
:aria-label="t('nav.localeLabel')"
>
<button
v-for="option in localeOptions"
:key="`mobile-locale-${option.code}`"
type="button"
:aria-label="t('nav.localeAria', { locale: option.name })"
:aria-pressed="option.code === currentLocale"
:class="[
'locale-pill',
{ 'locale-pill--active': option.code === currentLocale },
]"
:title="option.name"
@click="changeLocale(option.code)"
>
{{ option.short }}
</button>
</div>
</div>
<RouterLink
class="sidebar-cta"
to="/contact"
@click="closeMobileMenu"
>
<v-icon icon="mdi-send" size="16" />
<span>Let's Connect</span>
<Transition name="locale-fade" mode="out-in">
<span :key="`sidebar-cta-${currentLocale}`">
{{ t('nav.cta') }}
</span>
</Transition>
</RouterLink>
<div class="sidebar-social">
<a
v-for="social in socialLinks"
:key="social.label"
:aria-label="`Open ${social.label}`"
:aria-label="t('common.openExternal', { label: social.label })"
:href="social.url"
class="sidebar-social-link"
rel="noopener"
@@ -167,7 +249,7 @@
<div class="sidebar-status">
<div class="status-indicator" />
<span>Available for projects</span>
<span>{{ t('common.availabilityStatus') }}</span>
</div>
</div>
</aside>
@@ -179,19 +261,25 @@
import type { ComponentPublicInstance } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { usePortfolio } from '@/composables/usePortfolio'
import {
availableLocales,
persistLocalePreference,
type AppLocale,
} from '@/plugins/i18n'
import { mainNavigation } from '@/services/navigation'
import { socialLinks } from '@/services/portfolio'
import type { NavItem } from '@/services/navigation'
const { mobile } = useDisplay()
const route = useRoute()
const { t, locale } = useI18n()
const portfolio = usePortfolio()
interface NavigationLinkMeta {
interface NavigationLinkMeta extends NavItem {
readonly key: string
readonly label: string
readonly path: string
readonly sectionId?: string
readonly displayLabel: string
}
@@ -200,13 +288,60 @@ const floatingNavRef = ref<HTMLElement | null>(null)
const showFloatingNav = ref(true)
const lastScrollY = ref(0)
const navigationLinks = computed<NavigationLinkMeta[]>(() =>
mainNavigation.map(item => ({
const navigationLinks = computed<NavigationLinkMeta[]>(() => {
const localeKey = locale.value
void localeKey
return mainNavigation.map(item => ({
...item,
displayLabel: item.label,
displayLabel: t(item.labelKey),
key: `path:${item.path}`,
}))
)
})
const localeOptions = computed(() => {
const localeKey = locale.value
void localeKey
return availableLocales.map(code => ({
code: code as AppLocale,
short: t(`nav.localeShort.${code}`),
name: t(`nav.localeNames.${code}`),
}))
})
const showLocaleSwitch = computed(() => localeOptions.value.length > 1)
const currentLocale = computed(() => locale.value as AppLocale)
const localeAnimationActive = ref(false)
let localeAnimationTimer: ReturnType<typeof setTimeout> | null = null
const triggerLocaleAnimation = () => {
if (typeof window === 'undefined') {
localeAnimationActive.value = false
return
}
if (localeAnimationTimer) {
window.clearTimeout(localeAnimationTimer)
}
localeAnimationActive.value = true
localeAnimationTimer = window.setTimeout(() => {
localeAnimationActive.value = false
localeAnimationTimer = null
}, 450)
}
const changeLocale = (target: AppLocale) => {
if (currentLocale.value === target) {
return
}
locale.value = target
}
const socialLinks = computed(() => portfolio.socials)
const drawer = ref(false)
const isScrolled = ref(false)
@@ -300,13 +435,9 @@ const closeMobileMenu = () => {
}
const getNavDescription = (key: string): string => {
const descriptions: Record<string, string> = {
'path:/': 'About me & expertise',
'path:/projects': 'Featured work & case studies',
'path:/experience': 'Career & achievements',
'path:/contact': 'Get in touch',
}
return descriptions[key] || ''
const translationKey = `nav.descriptions.${key}`
const description = t(translationKey)
return description === translationKey ? '' : description
}
const onNavClick = (_event: MouseEvent, _item: NavigationLinkMeta) => {
@@ -328,8 +459,20 @@ onMounted(async () => {
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateProgress)
window.removeEventListener('resize', handleResize)
if (localeAnimationTimer) {
window.clearTimeout(localeAnimationTimer)
localeAnimationTimer = null
}
})
watch(
() => locale.value,
() => {
persistLocalePreference(locale.value as AppLocale)
triggerLocaleAnimation()
}
)
watch(
() => route.fullPath,
() => {
@@ -416,11 +559,22 @@ watch(
min-height: 64px
transition: all 0.3s ease
overflow: hidden
position: relative
.floating-nav--scrolled .floating-nav__container
background: rgba(18, 18, 18, 0.95)
border-color: rgba(255, 255, 255, 0.15)
.floating-nav--locale-switch .floating-nav__container::after
content: ''
position: absolute
inset: -1px
border-radius: inherit
background: radial-gradient(circle at 20% 20%, rgba(255, 20, 225, 0.35), transparent 65%), radial-gradient(circle at 80% 20%, rgba(20, 67, 255, 0.3), transparent 70%)
opacity: 0
pointer-events: none
animation: localePulse 0.6s ease forwards
// Brand Section
.floating-nav__brand
display: flex
@@ -525,9 +679,54 @@ watch(
.nav-link--active .nav-link__indicator
width: 60%
// CTA Button
.floating-nav__cta
// Actions & Locale Switcher
.floating-nav__actions
margin-left: auto
display: flex
align-items: center
gap: 12px
.floating-nav__locale
display: inline-flex
align-items: center
gap: 6px
padding: 4px
border-radius: 999px
border: 1px solid rgba(255, 255, 255, 0.08)
background: rgba(17, 18, 24, 0.68)
backdrop-filter: blur(12px)
.locale-pill
display: inline-flex
align-items: center
justify-content: center
min-width: 40px
padding: 6px 12px
border-radius: 999px
border: 1px solid rgba(255, 255, 255, 0.18)
background: transparent
color: rgba(255, 255, 255, 0.8)
font-size: 0.75rem
font-weight: 600
text-transform: uppercase
letter-spacing: 0.08em
cursor: pointer
transition: all 0.2s ease
.locale-pill:hover
border-color: rgba(255, 255, 255, 0.4)
color: #ffffff
.locale-pill--active
background: var(--portfolio-accent-gradient)
border-color: transparent
color: #0f1015
box-shadow: 0 6px 18px rgba(255, 20, 225, 0.35)
cursor: default
animation: localePillPop 0.3s ease
.locale-pill--active:hover
color: #0f1015
.cta-button
display: flex
@@ -603,6 +802,10 @@ watch(
transform: translateX(-100%)
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)
.mobile-sidebar--locale-switch
animation: localeGlow 0.6s ease
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 20, 225, 0.2)
.mobile-sidebar-overlay .mobile-sidebar
transform: translateX(0)
@@ -699,6 +902,22 @@ watch(
flex-direction: column
gap: 20px
.mobile-sidebar__locale
display: flex
flex-direction: column
gap: 10px
.mobile-locale__label
font-size: 0.75rem
text-transform: uppercase
letter-spacing: 0.08em
color: rgba(255, 255, 255, 0.65)
.mobile-locale__options
display: flex
flex-wrap: wrap
gap: 8px
.sidebar-cta
display: flex
align-items: center
@@ -754,6 +973,33 @@ watch(
background: #4ade80
animation: pulse 2s infinite
@keyframes localePulse
0%
opacity: 0
transform: scale(0.88)
45%
opacity: 0.6
transform: scale(1.05)
100%
opacity: 0
transform: scale(1.12)
@keyframes localeGlow
0%
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3), 0 0 0 0 rgba(255, 20, 225, 0.3)
60%
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3), 0 0 0 8px rgba(255, 20, 225, 0)
100%
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3), 0 0 0 0 rgba(255, 20, 225, 0)
@keyframes localePillPop
0%
transform: scale(0.9)
60%
transform: scale(1.05)
100%
transform: scale(1)
@keyframes pulse
0%, 100%
opacity: 1
@@ -767,6 +1013,15 @@ watch(
transform: translateX(0)
// Transitions
.locale-fade-enter-active,
.locale-fade-leave-active
transition: opacity 0.25s ease, transform 0.25s ease
.locale-fade-enter-from,
.locale-fade-leave-to
opacity: 0
transform: translateY(6px)
.mobile-sidebar-enter-active,
.mobile-sidebar-leave-active
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
@@ -824,4 +1079,9 @@ watch(
animation: none
opacity: 1
transform: none
.floating-nav--locale-switch .floating-nav__container::after,
.mobile-sidebar--locale-switch,
.locale-pill--active
animation: none !important
</style>

View File

@@ -26,7 +26,7 @@
target="_blank"
rel="noopener"
>
Connect
{{ t('common.connect') }}
</v-btn>
</v-card>
</v-col>
@@ -34,6 +34,8 @@
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import type { ContactChannel } from '@/services/portfolio'
interface ContactGridProps {
@@ -41,4 +43,6 @@ interface ContactGridProps {
}
defineProps<ContactGridProps>()
const { t } = useI18n()
</script>

View File

@@ -65,7 +65,9 @@
</p>
<div class="experience-achievements">
<h4 class="achievements-title">Key Achievements</h4>
<h4 class="achievements-title">
{{ t('experienceTimeline.achievements') }}
</h4>
<ul class="achievements-list">
<li
v-for="achievement in entry.achievements"
@@ -77,7 +79,9 @@
</div>
<div class="experience-tech">
<h4 class="tech-title">Technologies</h4>
<h4 class="tech-title">
{{ t('experienceTimeline.technologies') }}
</h4>
<div class="tech-stack">
<v-chip
v-for="tech in entry.tech"
@@ -102,6 +106,8 @@
</template>
<script lang="ts" setup>
import { useI18n } from 'vue-i18n'
import type { Experience } from '@/services/portfolio'
interface ExperienceTimelineProps {
@@ -109,6 +115,8 @@ interface ExperienceTimelineProps {
}
defineProps<ExperienceTimelineProps>()
const { t } = useI18n()
</script>
<style scoped lang="sass">

View File

@@ -19,7 +19,7 @@
<template v-if="emphasisWords.length">
<span class="accent-text">{{ emphasisWords[0] }}</span>
<template v-if="emphasisWords.length > 1">
&nbsp;and&nbsp;
&nbsp;{{ t('common.and') }}&nbsp;
<span class="accent-text">{{ emphasisWords[1] }}</span>
</template>
</template>
@@ -70,12 +70,14 @@
v-reveal="140"
class="d-flex flex-wrap align-center hero-social-row"
>
<div class="text-caption text-medium-emphasis">Connect with me</div>
<div class="text-caption text-medium-emphasis">
{{ t('hero.connect') }}
</div>
<div class="d-flex align-center gap-3">
<v-btn
v-for="social in socials"
:key="social.label"
:aria-label="`Open ${social.label}`"
:aria-label="t('common.openExternal', { label: social.label })"
:href="social.url"
class="text-none hero-social"
color="primary"
@@ -128,6 +130,7 @@
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import { useI18n } from 'vue-i18n'
import type { IntroContent, SocialLink } from '@/services/portfolio'
@@ -139,6 +142,8 @@ interface HeroSectionProps {
const props = defineProps<HeroSectionProps>()
const { intro, socials } = toRefs(props)
const { t } = useI18n()
const emphasisWords = computed(
() => intro.value.valueEmphasis?.filter(Boolean) ?? []
)

View File

@@ -22,7 +22,7 @@
</span>
<div v-if="project.featured" class="project-card__featured-badge">
<v-icon icon="mdi-star" size="12" />
<span>Featured</span>
<span>{{ t('projectCard.featured') }}</span>
</div>
</div>
</div>
@@ -48,7 +48,7 @@
<h4
class="text-caption text-uppercase text-medium-emphasis mb-3 font-weight-bold tracking-wider"
>
Key Impact
{{ t('projectCard.keyImpact') }}
</h4>
<ul class="project-card__metrics">
<li v-for="metric in project.metrics.slice(0, 3)" :key="metric">
@@ -62,7 +62,7 @@
<h4
class="text-caption text-uppercase text-medium-emphasis mb-3 font-weight-bold tracking-wider"
>
Tech Stack
{{ t('projectCard.techStack') }}
</h4>
<div class="d-flex flex-wrap gap-2">
<v-chip
@@ -86,7 +86,7 @@
variant="outlined"
size="small"
>
+{{ project.tech.length - 4 }} more
{{ t('projectCard.more', { count: project.tech.length - 4 }) }}
</v-chip>
</div>
</div>
@@ -115,6 +115,7 @@
<script lang="ts" setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Project, ProjectLink } from '@/services/portfolio'
@@ -124,6 +125,8 @@ interface ProjectCardProps {
const props = defineProps<ProjectCardProps>()
const { t } = useI18n()
const preferredOrder: ProjectLink['type'][] = ['demo', 'case-study', 'code']
const primaryLinks = computed(() => {

View File

@@ -1,36 +1,63 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
contactChannels,
experience,
DEFAULT_PORTFOLIO_LOCALE,
getFeaturedProjects,
introContent,
projects,
socialLinks,
type Experience as ExperienceEntry,
getPortfolioContent,
resolvePortfolioLocale,
type PortfolioContent,
type Project,
type Experience,
type ContactChannel,
type SocialLink,
} from '@/services/portfolio'
interface PortfolioData {
readonly intro: typeof introContent
readonly intro: PortfolioContent['intro']
readonly projects: readonly Project[]
readonly featuredProjects: readonly Project[]
readonly timeline: readonly ExperienceEntry[]
readonly timeline: readonly Experience[]
readonly contact: readonly ContactChannel[]
readonly socials: readonly SocialLink[]
readonly findProject: (slug: string) => Project | undefined
}
export function usePortfolio(): PortfolioData {
const projectMap = new Map(projects.map(project => [project.slug, project]))
const { locale } = useI18n()
const resolvedLocale = computed(() =>
resolvePortfolioLocale(locale.value ?? DEFAULT_PORTFOLIO_LOCALE)
)
const content = computed(() => getPortfolioContent(resolvedLocale.value))
const featuredProjects = computed(() => getFeaturedProjects(content.value))
const projectMap = computed(
() =>
new Map(content.value.projects.map(project => [project.slug, project]))
)
return {
intro: introContent,
projects,
featuredProjects: getFeaturedProjects(),
timeline: experience,
contact: contactChannels,
socials: socialLinks,
findProject: (slug: string) => projectMap.get(slug),
get intro() {
return content.value.intro
},
get projects() {
return content.value.projects
},
get featuredProjects() {
return featuredProjects.value
},
get timeline() {
return content.value.experience
},
get contact() {
return content.value.contact
},
get socials() {
return content.value.socials
},
findProject: (slug: string) => projectMap.value.get(slug),
}
}

148
src/locales/de.ts Normal file
View File

@@ -0,0 +1,148 @@
const de = {
common: {
skipToContent: 'Zum Hauptinhalt springen',
openExternal: '{label} oeffnen',
connect: 'Verbinden',
and: 'und',
availabilityStatus: 'Verfuegbar fuer Projekte',
getInTouch: 'Kontakt aufnehmen',
viewAll: 'Alle anzeigen',
viewTimeline: 'Zeitleiste ansehen',
backToProjects: 'Zurueck zu den Projekten',
},
nav: {
ariaMain: 'Hauptnavigation',
ariaMobile: 'Mobile Navigation',
brandAria: '{name} - Startseite',
brandTitle: 'Frontend-Engineer',
cta: 'Kontakt aufnehmen',
toggle: 'Navigationsmenue umschalten',
close: 'Navigation schliessen',
localeLabel: 'Sprache',
localeAria: 'Zu {locale} wechseln',
links: {
home: 'Start',
projects: 'Projekte',
experience: 'Erfahrung',
contact: 'Kontakt',
},
descriptions: {
'path:/': 'Ueber mich & Expertise',
'path:/projects': 'Ausgewaehlte Arbeiten & Fallstudien',
'path:/experience': 'Karriere & Erfolge',
'path:/contact': 'Kontakt aufnehmen',
},
localeNames: {
en: 'Englisch',
de: 'Deutsch',
},
localeShort: {
en: 'EN',
de: 'DE',
},
},
hero: {
connect: 'Vernetze dich mit mir',
},
footer: {
githubAria: 'Christian Nachtigall auf GitHub',
githubHint: 'GitHub-Profil oeffnet in neuem Tab',
},
error: {
title: 'Etwas ist schiefgelaufen',
message:
'Ein unerwarteter Fehler ist aufgetreten. Entschuldige die Umstaende.',
details: 'Fehlerdetails',
tryAgain: 'Erneut versuchen',
goHome: 'Zur Startseite',
},
projectCard: {
featured: 'Hervorgehoben',
keyImpact: 'Zentrale Wirkung',
techStack: 'Tech-Stack',
more: '+{count} mehr',
},
experienceTimeline: {
achievements: 'Wesentliche Erfolge',
technologies: 'Technologien',
},
home: {
projectsLabel: 'Ausgewaehlte Arbeiten',
projectsTitle: 'Hervorgehobene Projekte',
experienceLabel: 'Erfahrung',
experienceTitle: 'Aktuelle Rollen',
calloutTitle: 'Bereit fuer das naechste Erlebnis?',
calloutBody:
'Lass uns ein Produkt bauen, das sich schnell, inklusiv und einfach pflegen laesst.',
},
projectsPage: {
kicker: 'Portfolio',
titleLead: 'Projekte mit Feinschliff und',
titleHighlight: 'messbarem Impact',
subtitle:
'Jedes Engagement setzte auf skalierbare UI-Fundamente, teamuebergreifende Zusammenarbeit und messbare Produktmetriken.',
stats: {
projects: 'Projekte',
featured: 'Hervorgehoben',
technologies: 'Technologien',
},
filters: {
all: 'Alle Projekte',
featured: 'Hervorgehoben',
},
empty: {
title: 'Keine Projekte gefunden',
description: 'Passe deine Filter an.',
},
},
projectDetails: {
roleTimeframe: 'Rolle & Zeitraum',
mission: 'Mission',
highlights: 'Highlights',
emptyTitle: 'Dieses Projekt erkundet gerade neue Ideen',
emptyBody:
'Der aufgerufene Link ist moeglicherweise veraltet. Kehre zur Projektuebersicht zurueck.',
},
experiencePage: {
kicker: 'Erfahrung',
title: 'Arbeiten fuer designorientierte High-Impact-Teams',
subtitle:
'Von Fintech-Scale-ups bis Gesundheitsplattformen uebersetze ich komplexe Anforderungen in zugaengliche, performante Benutzeroberflaechen.',
stats: {
companies: 'Unternehmen',
years: 'Jahre',
technologies: 'Technologien',
},
ctaTitle: 'Bereit fuer dein naechstes Projekt?',
ctaBody: 'Ich freue mich ueber neue Chancen und anspruchsvolle Probleme.',
ctaButton: 'Kontakt aufnehmen',
},
contactPage: {
kicker: 'Kontakt',
titleLead: 'Bereit, etwas',
titleHighlight: 'einpraegendes',
titleTrail: 'zu bauen?',
subtitle:
'Ich arbeite mit Teams an Designsystemen, datenreichen Oberflaechen und polierten Marketing-Erlebnissen. Schreibe mir und ich melde mich meist innerhalb eines Werktags.',
stats: {
responseTime: 'Antwortzeit',
remoteReady: 'Remote bereit',
timeZones: 'Zeitzonen',
},
ctaTitle: 'Moechtest du meine aktuellen Arbeiten sehen?',
ctaBody:
'Gerne teile ich individuelle Code-Walkthroughs, bespreche konkrete Herausforderungen oder vereinbare eine Live-Session.',
ctaButton: 'Gespraech starten',
collaboration: {
title: 'Details zur Zusammenarbeit',
availabilityTitle: 'Verfuegbarkeit',
availabilityBody:
'Aktuell offen fuer neue Projekte. Beste Zeiten fuer Calls: 9 - 18 Uhr CET',
workStyleTitle: 'Arbeitsweise',
workStyleBody:
'Kollaboratives Remote-Arbeiten mit regelmaessigen Check-ins. Sicher im asynchronen Austausch und Pair Programming.',
},
},
}
export default de

149
src/locales/en.ts Normal file
View File

@@ -0,0 +1,149 @@
const en = {
common: {
skipToContent: 'Skip to main content',
openExternal: 'Open {label}',
connect: 'Connect',
and: 'and',
availabilityStatus: 'Available for projects',
getInTouch: 'Get in touch',
viewAll: 'View all',
viewTimeline: 'View timeline',
backToProjects: 'Back to projects',
},
nav: {
ariaMain: 'Main navigation',
ariaMobile: 'Mobile navigation',
brandAria: '{name} - Home',
brandTitle: 'Front-end Engineer',
cta: "Let's Connect",
toggle: 'Toggle navigation menu',
close: 'Close navigation',
localeLabel: 'Language',
localeAria: 'Switch to {locale}',
links: {
home: 'Home',
projects: 'Projects',
experience: 'Experience',
contact: 'Contact',
},
descriptions: {
'path:/': 'About me & expertise',
'path:/projects': 'Featured work & case studies',
'path:/experience': 'Career & achievements',
'path:/contact': 'Get in touch',
},
localeNames: {
en: 'English',
de: 'German',
},
localeShort: {
en: 'EN',
de: 'DE',
},
},
hero: {
connect: 'Connect with me',
},
footer: {
githubAria: 'Christian Nachtigall on GitHub',
githubHint: 'GitHub profile opens in a new tab',
},
error: {
title: 'Something went wrong',
message:
'An unexpected error occurred. We apologize for the inconvenience.',
details: 'Error Details',
tryAgain: 'Try Again',
goHome: 'Go Home',
},
projectCard: {
featured: 'Featured',
keyImpact: 'Key Impact',
techStack: 'Tech Stack',
more: '+{count} more',
},
experienceTimeline: {
achievements: 'Key Achievements',
technologies: 'Technologies',
},
home: {
projectsLabel: 'Selected Work',
projectsTitle: 'Featured projects',
experienceLabel: 'Experience',
experienceTitle: 'Recent positions',
calloutTitle: 'Ready to ship your next experience?',
calloutBody:
"Let's collaborate on a product that feels fast, inclusive, and effortless to maintain.",
},
projectsPage: {
kicker: 'Portfolio',
titleLead: 'Projects that combine polish and',
titleHighlight: 'measurable impact',
subtitle:
'Each engagement focused on designing scalable UI foundations, cross-team collaboration, and measurable product metrics.',
stats: {
projects: 'Projects',
featured: 'Featured',
technologies: 'Technologies',
},
filters: {
all: 'All Projects',
featured: 'Featured',
},
empty: {
title: 'No projects found',
description: 'Try adjusting your filter selection.',
},
},
projectDetails: {
roleTimeframe: 'Role & timeframe',
mission: 'Mission',
highlights: 'Highlights',
emptyTitle: 'This project is off exploring new ideas',
emptyBody:
'The link you followed might be out of date. Head back to the projects overview to keep exploring.',
},
experiencePage: {
kicker: 'Experience',
title: 'Building for design-forward, high-impact teams',
subtitle:
'From fintech scale-ups to healthcare platforms, I translate complex requirements into approachable, performant user interfaces.',
stats: {
companies: 'Companies',
years: 'Years',
technologies: 'Technologies',
},
ctaTitle: 'Ready to discuss your next project?',
ctaBody:
"I'm always interested in hearing about new opportunities and challenging problems to solve.",
ctaButton: "Let's Connect",
},
contactPage: {
kicker: 'Contact',
titleLead: 'Ready to build something',
titleHighlight: 'memorable',
titleTrail: 'together?',
subtitle:
'I partner with teams on design systems, data-rich interfaces, and polished marketing experiences. Drop a note and I typically reply within one business day.',
stats: {
responseTime: 'Response Time',
remoteReady: 'Remote Ready',
timeZones: 'Time Zones',
},
ctaTitle: 'Want to review my latest work samples?',
ctaBody:
"I'm happy to share tailored code walkthroughs, discuss specific challenges, or schedule a live pairing session to explore solutions together.",
ctaButton: 'Start a conversation',
collaboration: {
title: 'Collaboration Details',
availabilityTitle: 'Availability',
availabilityBody:
'Currently accepting new projects. Best availability for calls: 9 AM - 6 PM CET',
workStyleTitle: 'Work Style',
workStyleBody:
'Collaborative remote work with regular check-ins. Comfortable with async communication and pair programming.',
},
},
}
export default en

View File

@@ -14,16 +14,15 @@
<p
class="text-caption text-uppercase text-medium-emphasis mb-2 font-weight-bold tracking-widest"
>
Contact
{{ t('contactPage.kicker') }}
</p>
<h1 class="contact-title text-h2 text-md-h1 font-weight-bold mb-4">
Ready to build something
<span class="accent-text">memorable</span> together?
{{ t('contactPage.titleLead') }}
<span class="accent-text">{{ t('contactPage.titleHighlight') }}</span>
{{ t('contactPage.titleTrail') }}
</h1>
<p class="contact-subtitle text-h6 text-medium-emphasis mx-auto">
I partner with teams on design systems, data-rich interfaces, and
polished marketing experiences. Drop a note and I typically reply
within one business day.
{{ t('contactPage.subtitle') }}
</p>
</header>
@@ -32,19 +31,25 @@
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">&lt;24h</div>
<div class="stat-label">Response Time</div>
<div class="stat-label">
{{ t('contactPage.stats.responseTime') }}
</div>
</div>
</v-col>
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">100%</div>
<div class="stat-label">Remote Ready</div>
<div class="stat-label">
{{ t('contactPage.stats.remoteReady') }}
</div>
</div>
</v-col>
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">3</div>
<div class="stat-label">Time Zones</div>
<div class="stat-label">
{{ t('contactPage.stats.timeZones') }}
</div>
</div>
</v-col>
</v-row>
@@ -59,12 +64,10 @@
<v-row align="center" justify="space-between">
<v-col cols="12" md="8">
<h2 class="text-h4 font-weight-bold mb-3">
Want to review my latest work samples?
{{ t('contactPage.ctaTitle') }}
</h2>
<p class="text-body-1 text-medium-emphasis mb-0">
I'm happy to share tailored code walkthroughs, discuss specific
challenges, or schedule a live pairing session to explore
solutions together.
{{ t('contactPage.ctaBody') }}
</p>
</v-col>
<v-col cols="12" md="4" class="text-center text-md-end">
@@ -75,7 +78,7 @@
rounded="pill"
size="large"
>
Start a conversation
{{ t('contactPage.ctaButton') }}
<v-icon icon="mdi-send" size="18" class="ms-2" />
</v-btn>
</v-col>
@@ -88,7 +91,7 @@
<v-col cols="12" md="8">
<div class="additional-info">
<h3 class="text-h5 font-weight-bold mb-4 text-center">
Collaboration Details
{{ t('contactPage.collaboration.title') }}
</h3>
<v-row>
<v-col cols="12" sm="6">
@@ -98,10 +101,11 @@
size="24"
class="info-icon mb-3"
/>
<h4 class="info-title">Availability</h4>
<h4 class="info-title">
{{ t('contactPage.collaboration.availabilityTitle') }}
</h4>
<p class="info-text">
Currently accepting new projects. Best availability for
calls: 9 AM - 6 PM CET
{{ t('contactPage.collaboration.availabilityBody') }}
</p>
</div>
</v-col>
@@ -112,10 +116,11 @@
size="24"
class="info-icon mb-3"
/>
<h4 class="info-title">Work Style</h4>
<h4 class="info-title">
{{ t('contactPage.collaboration.workStyleTitle') }}
</h4>
<p class="info-text">
Collaborative remote work with regular check-ins.
Comfortable with async communication and pair programming.
{{ t('contactPage.collaboration.workStyleBody') }}
</p>
</div>
</v-col>
@@ -131,8 +136,10 @@
<script lang="ts" setup>
import ContactGrid from '@/components/portfolio/ContactGrid.vue'
import { usePortfolio } from '@/composables/usePortfolio'
import { useI18n } from 'vue-i18n'
const portfolio = usePortfolio()
const { t } = useI18n()
</script>
<style scoped lang="sass">

View File

@@ -14,15 +14,13 @@
<p
class="text-caption text-uppercase text-medium-emphasis mb-2 font-weight-bold tracking-widest"
>
Experience
{{ t('experiencePage.kicker') }}
</p>
<h1 class="text-h2 text-md-h1 font-weight-bold mb-4 experience-title">
Building for <span class="accent-text">design-forward</span>,
high-impact teams
{{ t('experiencePage.title') }}
</h1>
<p class="text-h6 text-medium-emphasis experience-subtitle mx-auto">
From fintech scale-ups to healthcare platforms, I translate complex
requirements into approachable, performant user interfaces.
{{ t('experiencePage.subtitle') }}
</p>
</header>
@@ -31,13 +29,17 @@
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">{{ portfolio.timeline.length }}+</div>
<div class="stat-label">Companies</div>
<div class="stat-label">
{{ t('experiencePage.stats.companies') }}
</div>
</div>
</v-col>
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">{{ yearsOfExperience }}+</div>
<div class="stat-label">Years</div>
<div class="stat-label">
{{ t('experiencePage.stats.years') }}
</div>
</div>
</v-col>
<v-col cols="auto">
@@ -45,7 +47,9 @@
<div class="stat-number">
{{ uniqueTechCount }}
</div>
<div class="stat-label">Technologies</div>
<div class="stat-label">
{{ t('experiencePage.stats.technologies') }}
</div>
</div>
</v-col>
</v-row>
@@ -59,11 +63,10 @@
<div class="experience-cta mt-16 text-center">
<v-card class="pa-8 cta-card" rounded="2xl" variant="outlined">
<h2 class="text-h4 font-weight-bold mb-3">
Ready to discuss your next project?
{{ t('experiencePage.ctaTitle') }}
</h2>
<p class="text-body-1 text-medium-emphasis mb-6">
I'm always interested in hearing about new opportunities and
challenging problems to solve.
{{ t('experiencePage.ctaBody') }}
</p>
<v-btn
class="text-none font-weight-bold cta-button"
@@ -72,7 +75,7 @@
size="large"
to="/contact"
>
Let's Connect
{{ t('experiencePage.ctaButton') }}
<v-icon icon="mdi-arrow-right" size="18" class="ms-2" />
</v-btn>
</v-card>
@@ -85,8 +88,10 @@
import { computed } from 'vue'
import ExperienceTimeline from '@/components/portfolio/ExperienceTimeline.vue'
import { usePortfolio } from '@/composables/usePortfolio'
import { useI18n } from 'vue-i18n'
const portfolio = usePortfolio()
const { t } = useI18n()
const yearsOfExperience = computed(() => {
const currentYear = new Date().getFullYear()

View File

@@ -19,9 +19,11 @@
<div class="section-heading mb-8">
<div class="section-header">
<p class="text-caption text-uppercase text-medium-emphasis mb-1">
Selected Work
{{ t('home.projectsLabel') }}
</p>
<h2 class="text-h4 font-weight-bold mb-3">Featured projects</h2>
<h2 class="text-h4 font-weight-bold mb-3">
{{ t('home.projectsTitle') }}
</h2>
</div>
<v-btn
class="text-none font-weight-bold accent-hover"
@@ -31,7 +33,7 @@
variant="tonal"
to="/projects"
>
View all
{{ t('common.viewAll') }}
</v-btn>
</div>
@@ -60,9 +62,11 @@
<div class="section-heading mb-8">
<div class="section-header">
<p class="text-caption text-uppercase text-medium-emphasis mb-1">
Experience
{{ t('home.experienceLabel') }}
</p>
<h2 class="text-h4 font-weight-bold mb-3">Recent positions</h2>
<h2 class="text-h4 font-weight-bold mb-3">
{{ t('home.experienceTitle') }}
</h2>
</div>
<v-btn
class="text-none font-weight-bold accent-hover"
@@ -72,7 +76,7 @@
variant="tonal"
to="/experience"
>
View timeline
{{ t('common.viewTimeline') }}
</v-btn>
</div>
@@ -120,11 +124,10 @@
<v-row align="center" justify="space-between">
<v-col cols="12" md="8">
<h2 class="text-h4 font-weight-bold mb-2 text-white">
Ready to ship your next experience?
{{ t('home.calloutTitle') }}
</h2>
<p class="text-body-1 text-white text-opacity-75 mb-0">
Lets collaborate on a product that feels fast, inclusive, and
effortless to maintain.
{{ t('home.calloutBody') }}
</p>
</v-col>
<v-col cols="12" md="4" class="text-center text-md-end">
@@ -135,7 +138,7 @@
size="large"
to="/contact"
>
Get in touch
{{ t('common.getInTouch') }}
</v-btn>
</v-col>
</v-row>
@@ -147,8 +150,10 @@
import HeroSection from '@/components/portfolio/HeroSection.vue'
import ProjectCard from '@/components/portfolio/ProjectCard.vue'
import { usePortfolio } from '@/composables/usePortfolio'
import { useI18n } from 'vue-i18n'
const portfolio = usePortfolio()
const { t } = useI18n()
</script>
<style scoped lang="sass">

View File

@@ -49,12 +49,16 @@
<v-row>
<v-col cols="12" md="7">
<h2 class="text-h5 font-weight-bold mb-3">Role & timeframe</h2>
<h2 class="text-h5 font-weight-bold mb-3">
{{ t('projectDetails.roleTimeframe') }}
</h2>
<p class="text-body-2 text-medium-emphasis mb-6">
{{ project.role }} · {{ project.timeframe }}
</p>
<h2 class="text-h5 font-weight-bold mb-3">Mission</h2>
<h2 class="text-h5 font-weight-bold mb-3">
{{ t('projectDetails.mission') }}
</h2>
<p class="text-body-1 text-medium-emphasis">
{{ project.description }}
</p>
@@ -62,7 +66,9 @@
<v-col cols="12" md="5">
<v-card class="pa-6" rounded="xl" variant="outlined">
<h2 class="text-h6 font-weight-bold mb-4">Highlights</h2>
<h2 class="text-h6 font-weight-bold mb-4">
{{ t('projectDetails.highlights') }}
</h2>
<ul class="text-body-2 text-medium-emphasis highlight-list">
<li
v-for="highlight in project.highlights"
@@ -108,11 +114,10 @@
icon="mdi-emoticon-sad-outline"
/>
<h1 class="text-h4 font-weight-bold mb-3">
This project is off exploring new ideas
{{ t('projectDetails.emptyTitle') }}
</h1>
<p class="text-body-1 text-medium-emphasis mb-6">
The link you followed might be out of date. Head back to the
projects overview to keep exploring.
{{ t('projectDetails.emptyBody') }}
</p>
<v-btn
class="text-none font-weight-bold accent-hover"
@@ -121,7 +126,7 @@
variant="flat"
to="/projects"
>
Back to projects
{{ t('common.backToProjects') }}
</v-btn>
</v-card>
</v-col>
@@ -133,9 +138,11 @@
import { useRoute } from 'vue-router/auto'
import { usePortfolio } from '@/composables/usePortfolio'
import { useI18n } from 'vue-i18n'
const portfolio = usePortfolio()
const route = useRoute<'/projects/[slug]'>()
const { t } = useI18n()
const slug = computed(() => route.params.slug)

View File

@@ -14,15 +14,16 @@
<p
class="text-caption text-uppercase text-medium-emphasis mb-2 font-weight-bold tracking-widest"
>
Portfolio
{{ t('projectsPage.kicker') }}
</p>
<h1 class="text-h2 text-md-h1 font-weight-bold mb-4 projects-title">
Projects that combine polish and
<span class="accent-text">measurable impact</span>
{{ t('projectsPage.titleLead') }}
<span class="accent-text">{{
t('projectsPage.titleHighlight')
}}</span>
</h1>
<p class="text-h6 text-medium-emphasis projects-subtitle mx-auto">
Each engagement focused on designing scalable UI foundations,
cross-team collaboration, and measurable product metrics.
{{ t('projectsPage.subtitle') }}
</p>
</header>
@@ -33,7 +34,9 @@
<div class="stat-number">
{{ portfolio.projects.length }}
</div>
<div class="stat-label">Projects</div>
<div class="stat-label">
{{ t('projectsPage.stats.projects') }}
</div>
</div>
</v-col>
<v-col cols="auto">
@@ -41,7 +44,9 @@
<div class="stat-number">
{{ portfolio.featuredProjects.length }}
</div>
<div class="stat-label">Featured</div>
<div class="stat-label">
{{ t('projectsPage.stats.featured') }}
</div>
</div>
</v-col>
<v-col cols="auto">
@@ -49,7 +54,9 @@
<div class="stat-number">
{{ uniqueTechCount }}
</div>
<div class="stat-label">Technologies</div>
<div class="stat-label">
{{ t('projectsPage.stats.technologies') }}
</div>
</div>
</v-col>
</v-row>
@@ -65,7 +72,7 @@
variant="outlined"
@click="selectedFilter = 'all'"
>
All Projects
{{ t('projectsPage.filters.all') }}
</v-btn>
<v-btn
:class="{ 'filter-active': selectedFilter === 'featured' }"
@@ -76,7 +83,7 @@
@click="selectedFilter = 'featured'"
>
<v-icon class="me-2" icon="mdi-star" size="16" />
Featured
{{ t('projectsPage.filters.featured') }}
</v-btn>
</div>
</div>
@@ -103,9 +110,11 @@
icon="mdi-folder-open-outline"
size="64"
/>
<h3 class="text-h5 mb-2">No projects found</h3>
<h3 class="text-h5 mb-2">
{{ t('projectsPage.empty.title') }}
</h3>
<p class="text-body-1 text-medium-emphasis">
Try adjusting your filter selection.
{{ t('projectsPage.empty.description') }}
</p>
</div>
</div>
@@ -116,9 +125,11 @@
import { computed, ref } from 'vue'
import ProjectCard from '@/components/portfolio/ProjectCard.vue'
import { usePortfolio } from '@/composables/usePortfolio'
import { useI18n } from 'vue-i18n'
const portfolio = usePortfolio()
const selectedFilter = ref<'all' | 'featured'>('all')
const { t } = useI18n()
const filteredProjects = computed(() => {
if (selectedFilter.value === 'featured') {

73
src/plugins/i18n.ts Normal file
View File

@@ -0,0 +1,73 @@
import { createI18n } from 'vue-i18n'
import de from '@/locales/de'
import en from '@/locales/en'
export const FALLBACK_LOCALE = 'en'
export const USER_LOCALE_STORAGE_KEY = 'portfolio-locale'
const messages = {
en,
de,
} as const
type SupportedLocale = keyof typeof messages
const supportedLocales = Object.keys(messages) as SupportedLocale[]
const defaultLocale = FALLBACK_LOCALE as SupportedLocale
const isBrowser = typeof window !== 'undefined'
const readStoredLocale = (): SupportedLocale | undefined => {
if (!isBrowser) {
return undefined
}
const stored = window.localStorage.getItem(USER_LOCALE_STORAGE_KEY)
return supportedLocales.includes(stored as SupportedLocale)
? (stored as SupportedLocale)
: undefined
}
const detectNavigatorLocale = (): SupportedLocale | undefined => {
if (typeof navigator === 'undefined' || !navigator.language) {
return undefined
}
const candidate = navigator.language.toLowerCase().split('-')[0]
return supportedLocales.includes(candidate as SupportedLocale)
? (candidate as SupportedLocale)
: undefined
}
const detectInitialLocale = (): SupportedLocale =>
readStoredLocale() ?? detectNavigatorLocale() ?? defaultLocale
export const setDocumentLangAttribute = (locale: string) => {
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('lang', locale)
}
}
export const persistLocalePreference = (locale: SupportedLocale) => {
if (isBrowser) {
window.localStorage.setItem(USER_LOCALE_STORAGE_KEY, locale)
}
setDocumentLangAttribute(locale)
}
const initialLocale = detectInitialLocale()
export const i18n = createI18n({
legacy: false,
locale: initialLocale,
fallbackLocale: FALLBACK_LOCALE,
messages,
})
persistLocalePreference(initialLocale)
export type AppLocale = SupportedLocale
export const availableLocales = supportedLocales
export default i18n

View File

@@ -12,11 +12,12 @@ import pinia from '../stores'
import vuetify from './vuetify'
import reveal from '@/directives/reveal'
import { i18n } from './i18n'
// Types
export function registerPlugins(app: App) {
app.use(vuetify).use(router).use(pinia)
app.use(vuetify).use(router).use(pinia).use(i18n)
app.directive('reveal', reveal)
}

View File

@@ -1,12 +1,12 @@
export interface NavItem {
readonly label: string
readonly labelKey: string
readonly path: string
readonly sectionId?: string
}
export const mainNavigation: readonly NavItem[] = [
{ label: 'Home', path: '/' },
{ label: 'Projects', path: '/projects' },
{ label: 'Experience', path: '/experience' },
{ label: 'Contact', path: '/contact' },
{ labelKey: 'nav.links.home', path: '/' },
{ labelKey: 'nav.links.projects', path: '/projects' },
{ labelKey: 'nav.links.experience', path: '/experience' },
{ labelKey: 'nav.links.contact', path: '/contact' },
]

View File

@@ -0,0 +1,306 @@
import type { PortfolioContent } from '../portfolio.types'
const introContent: PortfolioContent['intro'] = {
name: 'Christian Nachtigall',
title: 'Frontend-Engineer',
location: 'Berlin, Deutschland',
valueLead: 'Ich schaffe schnelle, barrierefreie Web-Erlebnisse, die Menschen',
valueEmphasis: ['vertrauen', 'in Erinnerung behalten'],
valueTail: '.',
narrative:
'Ich arbeite mit Produktteams zusammen, um performante Komponenten-Systeme, polierte Dashboards und inklusive Nutzerreisen zu launchen - ohne Geschwindigkeit gegen Wartbarkeit zu tauschen.',
specialties: [
'Architektur mit Vue 3 + Vuetify',
'Audits und Behebung von Barrieren',
'Designsysteme mit Performance-Fokus',
],
primaryAction: {
label: 'Projekte ansehen',
to: '/projects',
},
secondaryAction: {
label: 'Kontakt aufnehmen',
to: '/contact',
},
}
const socialLinks: PortfolioContent['socials'] = [
{
label: 'GitHub',
icon: 'mdi-github',
url: 'https://github.com/cnachtigall',
},
{
label: 'LinkedIn',
icon: 'mdi-linkedin',
url: 'https://www.linkedin.com/in/cnachtigall',
},
{ label: 'Email', icon: 'mdi-email', url: 'mailto:hello@nachtigall.dev' },
]
const projects: PortfolioContent['projects'] = [
{
slug: 'optimus-ui',
name: 'Optimus UI Library',
tagline: 'Designsystem fuer ein Fintech Scale-up',
summary:
'Skalierte eine Vue-Komponentenbibliothek fuer fuenf Produktteams und verkuerzte die Lieferzeit neuer Features um 35%.',
description:
'Verantwortete die Architektur eines wiederverwendbaren Komponenten-Systems, das Nutzererlebnisse ueber Web- und Mobile-Banking hinweg vereinheitlichte. Fokus auf Barrierefreiheit, tokenbasiertes Theming und automatisierte visuelle Regressionstests.',
role: 'Lead Frontend-Engineer',
timeframe: '2023 - 2024',
heroImage:
'https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue 3', 'Vuetify', 'TypeScript', 'Storybook', 'Playwright'],
metrics: [
'35% schnellere Auslieferung von Features',
'98 Punkte im Lighthouse-Barrierefreiheits-Score',
'Eingesetzt in 5 Produktteams',
],
highlights: [
{
title: 'Design-Token-Pipeline',
description:
'Automatisierte Token-Exporte fuer Design, Doku und Produktion sorgten fuer pixelgenaue Paritaet auf allen Kanaelen.',
},
{
title: 'Barrierefreiheits-Reviews',
description:
'Lieferte AA-konforme Komponenten mit axe-core-CI-Checks aus und ermoeglichte inklusive Erlebnisse out of the box.',
},
],
links: [
{
label: 'Fallstudie',
url: 'https://nachtigall.dev/projects/optimus-ui',
type: 'case-study',
},
{
label: 'Live-Storybook',
url: 'https://storybook.nachtigall.dev',
type: 'demo',
},
{
label: 'Design-Token-Repository',
url: 'https://github.com/cnachtigall/optimus-design-tokens',
type: 'code',
},
],
featured: true,
},
{
slug: 'aurora-analytics',
name: 'Aurora Analytics',
tagline: 'Insights-Dashboard mit kollaborativen Kommentaren',
summary:
'Lieferte eine Data-Storytelling-Oberflaeche mit Inline-Kommentaren, die Analystinnen und Analysten doppelt so schnelle Feedback-Schleifen ermoeglichte.',
description:
'Implementierte ein modulares Dashboard, das Echtzeit-Metriken, gespeicherte Analysen und kollaborative Kommentare verbindet. Schwerpunkt auf sanften Uebergaengen, optimistischem UI und robusten API-Integrationen.',
role: 'Senior Frontend-Engineer',
timeframe: '2022 - 2023',
heroImage:
'https://images.unsplash.com/photo-1556157382-97eda2d62296?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue 3', 'Pinia', 'Apollo GraphQL', 'D3.js', 'Vitest'],
metrics: [
'2x schnellere Feedback-Schleifen fuer Analysten',
'Interaktionen im Dashboard unter einer Sekunde',
'Kollaborative Features von ueber 180 Nutzenden genutzt',
],
highlights: [
{
title: 'Echtzeit-Praesenz',
description:
'Implementierte WebSocket-basierte Praesenz-Indikatoren und Live-Cursor, um gleichzeitige Betrachtende und Bearbeitungen sichtbar zu machen.',
},
{
title: 'Narrative Exporte',
description:
'Rendert Dashboards als gebrandete PDF-Stories mit Kontext-Anmerkungen und Management-Zusammenfassungen.',
},
],
links: [
{
label: 'Live-Demo ansehen',
url: 'https://aurora.nachtigall.dev',
type: 'demo',
},
{
label: 'Code ansehen',
url: 'https://github.com/cnachtigall/aurora-analytics',
type: 'code',
},
{
label: 'Fallstudie',
url: 'https://nachtigall.dev/projects/aurora-analytics',
type: 'case-study',
},
],
featured: true,
},
{
slug: 'pulse-health',
name: 'Pulse Health Platform',
tagline: 'Patientenportal fuer Kliniken',
summary:
'Fuehrte virtuelle Wartezimmer und asynchrone Triage ein und senkte das Telefonaufkommen um 40%.',
description:
'Arbeitete mit Klinikteams zusammen, um ein responsives Gesundheitsportal mit Terminbuchung, Symptom-Triage und sicherer Kommunikation zu entwickeln. Vereinte strenge Compliance-Vorgaben mit empathischer Nutzererfahrung.',
role: 'Frontend-Engineer',
timeframe: '2021 - 2022',
heroImage:
'https://images.unsplash.com/photo-1521790797524-b2497295b8a0?auto=format&fit=crop&w=1600&q=80',
tech: ['Nuxt', 'Vuetify', 'TypeScript', 'Cypress', 'i18n'],
metrics: [
'40% weniger Telefonaufkommen',
'22% weniger Abbrueche bei Terminbuchungen',
'Ausrollung in 12 Kliniken in 3 Laendern',
],
highlights: [
{
title: 'Virtuelle Triage',
description:
'Entwarf einen Symptom-Checker, der dringende Faelle priorisiert und sofort Versorgungshinweise liefert.',
},
{
title: 'Offline-Resilienz',
description:
'Implementierte offline-faehige Formulare mit Warteschlangen-Sync fuer Regionen mit instabiler Anbindung.',
},
],
links: [
{
label: 'Produkt-Tour',
url: 'https://pulse.nachtigall.dev',
type: 'demo',
},
{
label: 'Fallstudie',
url: 'https://nachtigall.dev/projects/pulse-health',
type: 'case-study',
},
],
},
{
slug: 'lumen-labs',
name: 'Lumen Labs Microsite',
tagline: 'Interaktive Marketing-Microsite fuer ein VR-Studio',
summary:
'Launchte eine cineastische Landing Experience mit scrollgesteuertem Storytelling in weniger als vier Wochen.',
description:
'Arbeitete mit einer Kreativagentur zusammen, um eine immersive Microsite fuer kommende VR-Titel zu konzipieren und auszuliefern. Fokus auf fluessige Animationen, 3D-Asset-Integration und SEO-Tauglichkeit.',
role: 'Creative Developer',
timeframe: '2020 - 2021',
heroImage:
'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue', 'GSAP', 'Three.js', 'Netlify'],
metrics: [
'Launch in 4 Wochen',
'Verweildauer +63%',
'Individuelle Shader-Pipeline unter 60kb',
],
highlights: [
{
title: 'Scroll-Choreografie',
description:
'Inszenierte narrative Abschnitte, die mit der Scrolltiefe synchronisiert sind und GPU-freundliche Shader fuer Lichteffekte nutzen.',
},
{
title: 'CMS-Uebergabe',
description:
'Integrierte ein Headless-CMS, damit das Studio Texte und Assets ohne Entwicklerunterstuetzung pflegen kann.',
},
],
links: [
{
label: 'Website besuchen',
url: 'https://lumenlabs.example.com',
type: 'demo',
},
{
label: 'Animations-Snippets',
url: 'https://github.com/cnachtigall/lumen-labs-microsite',
type: 'code',
},
],
},
]
const experience: PortfolioContent['experience'] = [
{
company: 'Finwave',
role: 'Lead Frontend-Engineer',
timeframe: '2023 - heute',
location: 'Berlin (Hybrid)',
description:
'Leitet die Experience-Engineering-Gilde fuer Designsystem, Barrierefreiheit und gemeinsame Tooling-Stacks ueber fuenf Produktbereiche hinweg.',
achievements: [
'Skalierte das Designsystem auf ueber 120 Beitragende mit automatisierten Release Notes.',
'Erhoehte den Median der Core Web Vitals innerhalb eines Quartals um 18%.',
'Coachte ein verteiltes Team von acht Frontend-Engineers.',
],
tech: ['Vue 3', 'Vite', 'Vuetify', 'TypeScript', 'Storybook'],
image:
'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70?auto=format&fit=crop&w=1200&q=80',
},
{
company: 'DataForge',
role: 'Senior Frontend-Engineer',
timeframe: '2021 - 2023',
location: 'Remote (EU)',
description:
'Lieferte Analytics-Workflows und kollaborative Features fuer Enterprise-Kunden auf dem Weg weg von Legacy-BI-Loesungen.',
achievements: [
'Rollte kollaborative Dashboards mit optimistischem UI und Echtzeit-Praesenz aus.',
'Reduzierte die Bundle-Groesse durch Module Federation und Code-Splitting um 28%.',
'Schob gemeinsam mit Design und Data Science sechs neue Kund*innen-Features pro Quartal an.',
],
tech: ['Vue 3', 'Apollo', 'GraphQL', 'Pinia'],
image:
'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=1200&q=80',
},
{
company: 'BrightHealth',
role: 'Frontend-Engineer',
timeframe: '2019 - 2021',
location: 'Hamburg (Vor Ort)',
description:
'Entwickelte Patienten-Engagement-Loesungen mit Fokus auf barrierefreie Workflows und Vertrauen der Klinikteams.',
achievements: [
'Fuehrte asynchrone Triage ein und sparte Klinikteams rund vier Stunden pro Schicht.',
'Lokalisierte das Portal fuer fuenf Sprachen inklusive vollstaendiger RTL-Unterstuetzung.',
'Begleitete die Barrierefreiheits-Remediation hin zu vollstaendiger WCAG-AA-Compliance.',
],
tech: ['Nuxt', 'Vuex', 'Cypress', 'i18n'],
image:
'https://images.unsplash.com/photo-1472289065668-ce650ac443d2?auto=format&fit=crop&w=1200&q=80',
},
]
const contactChannels: PortfolioContent['contact'] = [
{
label: 'E-Mail',
value: 'hello@nachtigall.dev',
icon: 'mdi-email-outline',
href: 'mailto:hello@nachtigall.dev',
},
{
label: 'LinkedIn',
value: 'linkedin.com/in/cnachtigall',
icon: 'mdi-linkedin',
href: 'https://www.linkedin.com/in/cnachtigall',
},
{
label: 'Calendly',
value: '30-minuetiges Gespraech buchen',
icon: 'mdi-calendar-clock',
href: 'https://calendly.com/cnachtigall/coffee',
},
]
export const portfolioDe: PortfolioContent = {
intro: introContent,
socials: socialLinks,
projects,
experience,
contact: contactChannels,
}

View File

@@ -0,0 +1,306 @@
import type { PortfolioContent } from '../portfolio.types'
const introContent: PortfolioContent['intro'] = {
name: 'Christian Nachtigall',
title: 'Front-end Engineer',
location: 'Berlin, Germany',
valueLead: 'I build fast, accessible web experiences that users',
valueEmphasis: ['trust', 'remember'],
valueTail: '.',
narrative:
'Partnering with product teams to launch performant component systems, polished dashboards, and inclusive user journeys - without trading speed for maintainability.',
specialties: [
'Vue 3 + Vuetify architecture',
'Accessibility audits & remediation',
'Performance-first design systems',
],
primaryAction: {
label: 'Explore My Work',
to: '/projects',
},
secondaryAction: {
label: "Let's Collaborate",
to: '/contact',
},
}
const socialLinks: PortfolioContent['socials'] = [
{
label: 'GitHub',
icon: 'mdi-github',
url: 'https://github.com/cnachtigall',
},
{
label: 'LinkedIn',
icon: 'mdi-linkedin',
url: 'https://www.linkedin.com/in/cnachtigall',
},
{ label: 'Email', icon: 'mdi-email', url: 'mailto:hello@nachtigall.dev' },
]
const projects: PortfolioContent['projects'] = [
{
slug: 'optimus-ui',
name: 'Optimus UI Library',
tagline: 'Design system for a fintech scale-up',
summary:
'Scaled a Vue component library across five product teams, cutting feature delivery time by 35%.',
description:
'Led the architecture of a reusable component system that unified product experiences across web and mobile banking surfaces. Focused on accessibility, token-driven theming, and automated visual regression coverage.',
role: 'Lead Front-end Engineer',
timeframe: '2023 - 2024',
heroImage:
'https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue 3', 'Vuetify', 'TypeScript', 'Storybook', 'Playwright'],
metrics: [
'35% faster feature delivery',
'98 Lighthouse accessibility score',
'Shared across 5 product teams',
],
highlights: [
{
title: 'Design-token pipeline',
description:
'Automated token exports for design, docs, and production builds, guaranteeing pixel-perfect parity across channels.',
},
{
title: 'Accessibility reviews',
description:
'Shipped AA-compliant components with axe-core CI checks, ensuring inclusive experiences out of the box.',
},
],
links: [
{
label: 'Case study',
url: 'https://nachtigall.dev/projects/optimus-ui',
type: 'case-study',
},
{
label: 'Live Storybook',
url: 'https://storybook.nachtigall.dev',
type: 'demo',
},
{
label: 'Design tokens repo',
url: 'https://github.com/cnachtigall/optimus-design-tokens',
type: 'code',
},
],
featured: true,
},
{
slug: 'aurora-analytics',
name: 'Aurora Analytics',
tagline: 'Insights dashboard with collaborative commenting',
summary:
'Delivered a data storytelling surface with inline annotations that helped analysts close feedback loops 2x faster.',
description:
'Implemented a modular dashboard that blends real-time metrics, saved explorations, and collaborative commenting. Emphasis on smooth transitions, optimistic UI, and resilient API integrations.',
role: 'Senior Front-end Engineer',
timeframe: '2022 - 2023',
heroImage:
'https://images.unsplash.com/photo-1556157382-97eda2d62296?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue 3', 'Pinia', 'Apollo GraphQL', 'D3.js', 'Vitest'],
metrics: [
'2x faster analyst feedback loops',
'Sub-1s dashboard interactions',
'Collaborative features adopted by 180+ users',
],
highlights: [
{
title: 'Realtime presence',
description:
'Built WebSocket-powered presence indicators and live cursors to highlight concurrent viewers and edits.',
},
{
title: 'Narrative exports',
description:
'Rendered dashboards to branded PDF stories with contextual annotations and executive summaries.',
},
],
links: [
{
label: 'See Live Demo',
url: 'https://aurora.nachtigall.dev',
type: 'demo',
},
{
label: 'View Code',
url: 'https://github.com/cnachtigall/aurora-analytics',
type: 'code',
},
{
label: 'Case study',
url: 'https://nachtigall.dev/projects/aurora-analytics',
type: 'case-study',
},
],
featured: true,
},
{
slug: 'pulse-health',
name: 'Pulse Health Platform',
tagline: 'Patient engagement portal for clinics',
summary:
'Introduced virtual waiting rooms and asynchronous triage, cutting phone volume by 40%.',
description:
'Collaborated with clinicians to ship a responsive health portal with appointment bookings, symptom triage, and secure messaging. Balanced strict compliance requirements with empathetic UX.',
role: 'Front-end Engineer',
timeframe: '2021 - 2022',
heroImage:
'https://images.unsplash.com/photo-1521790797524-b2497295b8a0?auto=format&fit=crop&w=1600&q=80',
tech: ['Nuxt', 'Vuetify', 'TypeScript', 'Cypress', 'i18n'],
metrics: [
'40% reduction in call volume',
'Appointment drop-offs down 22%',
'Deployed to 12 clinics in 3 countries',
],
highlights: [
{
title: 'Virtual triage',
description:
'Designed a symptom checker that prioritised urgent cases and surfaced care guidance instantly.',
},
{
title: 'Offline resilience',
description:
'Implemented offline-ready forms with queue syncing for communities with spotty connectivity.',
},
],
links: [
{
label: 'Product Tour',
url: 'https://pulse.nachtigall.dev',
type: 'demo',
},
{
label: 'Case study',
url: 'https://nachtigall.dev/projects/pulse-health',
type: 'case-study',
},
],
},
{
slug: 'lumen-labs',
name: 'Lumen Labs Microsite',
tagline: 'Interactive marketing microsite for a VR studio',
summary:
'Launched a cinematic landing experience with scroll-driven storytelling in under four weeks.',
description:
'Partnered with a creative agency to prototype and deliver an immersive microsite showcasing upcoming VR titles. Focused on buttery animations, 3D asset integration, and SEO-readiness.',
role: 'Creative Developer',
timeframe: '2020 - 2021',
heroImage:
'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue', 'GSAP', 'Three.js', 'Netlify'],
metrics: [
'Launched in 4 weeks',
'Time on page +63%',
'Custom shader pipeline under 60kb',
],
highlights: [
{
title: 'Scroll choreography',
description:
'Crafted narrative beats synced to scroll depth, using GPU-friendly shaders for lighting effects.',
},
{
title: 'CMS handoff',
description:
'Integrated a headless CMS so the studio could update copy and assets without developer support.',
},
],
links: [
{
label: 'Visit Site',
url: 'https://lumenlabs.example.com',
type: 'demo',
},
{
label: 'Animation snippets',
url: 'https://github.com/cnachtigall/lumen-labs-microsite',
type: 'code',
},
],
},
]
const experience: PortfolioContent['experience'] = [
{
company: 'Finwave',
role: 'Lead Front-end Engineer',
timeframe: '2023 - Present',
location: 'Berlin (Hybrid)',
description:
'Heading the experience engineering guild responsible for the design system, accessibility, and shared tooling across five product pillars.',
achievements: [
'Scaled design system to 120+ contributors with automated release notes.',
'Improved Core Web Vitals median score by 18% in one quarter.',
'Mentored and coached a distributed team of eight front-end engineers.',
],
tech: ['Vue 3', 'Vite', 'Vuetify', 'TypeScript', 'Storybook'],
image:
'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70?auto=format&fit=crop&w=1200&q=80',
},
{
company: 'DataForge',
role: 'Senior Front-end Engineer',
timeframe: '2021 - 2023',
location: 'Remote (EU)',
description:
'Delivered analytics workflows and collaboration features for enterprise customers transitioning off legacy BI suites.',
achievements: [
'Rolled out collaborative dashboards with optimistic UI and real-time presence.',
'Reduced bundle size by 28% through module federation and code splitting.',
'Partnered with design and data science to ship 6 customer-facing features per quarter.',
],
tech: ['Vue 3', 'Apollo', 'GraphQL', 'Pinia'],
image:
'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=1200&q=80',
},
{
company: 'BrightHealth',
role: 'Front-end Engineer',
timeframe: '2019 - 2021',
location: 'Hamburg (On-site)',
description:
'Built patient engagement tools with a focus on accessible workflows and clinician trust.',
achievements: [
'Launched asynchronous triage saving clinicians ~4 hours per shift.',
'Localised the portal for five languages, including full RTL support.',
'Co-led accessibility remediation to achieve WCAG AA compliance.',
],
tech: ['Nuxt', 'Vuex', 'Cypress', 'i18n'],
image:
'https://images.unsplash.com/photo-1472289065668-ce650ac443d2?auto=format&fit=crop&w=1200&q=80',
},
]
const contactChannels: PortfolioContent['contact'] = [
{
label: 'Email',
value: 'hello@nachtigall.dev',
icon: 'mdi-email-outline',
href: 'mailto:hello@nachtigall.dev',
},
{
label: 'LinkedIn',
value: 'linkedin.com/in/cnachtigall',
icon: 'mdi-linkedin',
href: 'https://www.linkedin.com/in/cnachtigall',
},
{
label: 'Calendly',
value: 'Book a 30 min chat',
icon: 'mdi-calendar-clock',
href: 'https://calendly.com/cnachtigall/coffee',
},
]
export const portfolioEn: PortfolioContent = {
intro: introContent,
socials: socialLinks,
projects,
experience,
contact: contactChannels,
}

View File

@@ -1,376 +1,57 @@
export interface SocialLink {
readonly label: string
readonly icon: string
readonly url: string
import {
PORTFOLIO_LOCALES,
type PortfolioContent,
type PortfolioLocale,
type Project,
} from './portfolio.types'
import { portfolioEn } from './portfolio-data/en'
import { portfolioDe } from './portfolio-data/de'
export type {
PortfolioContent,
PortfolioLocale,
Project,
ProjectLink,
ProjectLinkType,
ProjectHighlight,
SocialLink,
Experience,
IntroContent,
ContactChannel,
} from './portfolio.types'
export const DEFAULT_PORTFOLIO_LOCALE: PortfolioLocale = 'en'
const portfolioByLocale: Record<PortfolioLocale, PortfolioContent> = {
en: portfolioEn,
de: portfolioDe,
}
export type ProjectLinkType = 'demo' | 'code' | 'case-study' | 'article'
export const SUPPORTED_PORTFOLIO_LOCALES = [...PORTFOLIO_LOCALES]
export interface ProjectLink {
readonly label: string
readonly url: string
readonly type?: ProjectLinkType
export function isPortfolioLocale(value: string): value is PortfolioLocale {
return PORTFOLIO_LOCALES.includes(value as PortfolioLocale)
}
export interface ProjectHighlight {
readonly title: string
readonly description: string
}
export interface Project {
readonly slug: string
readonly name: string
readonly tagline: string
readonly summary: string
readonly description: string
readonly role: string
readonly timeframe: string
readonly heroImage: string
readonly tech: readonly string[]
readonly metrics: readonly string[]
readonly highlights: readonly ProjectHighlight[]
readonly links: readonly ProjectLink[]
readonly featured?: boolean
}
export interface Experience {
readonly company: string
readonly role: string
readonly timeframe: string
readonly location: string
readonly description: string
readonly achievements: readonly string[]
readonly tech: readonly string[]
readonly image: string
}
export interface IntroContent {
readonly name: string
readonly title: string
readonly location: string
readonly valueLead: string
readonly valueEmphasis: readonly string[]
readonly valueTail: string
readonly narrative: string
readonly specialties: readonly string[]
readonly primaryAction: {
readonly label: string
readonly to: string
}
readonly secondaryAction: {
readonly label: string
readonly to: string
export function resolvePortfolioLocale(
locale: string | undefined
): PortfolioLocale {
if (!locale) {
return DEFAULT_PORTFOLIO_LOCALE
}
const normalized = locale.toLowerCase().split('-')[0]
return isPortfolioLocale(normalized) ? normalized : DEFAULT_PORTFOLIO_LOCALE
}
export interface ContactChannel {
readonly label: string
readonly value: string
readonly icon: string
readonly href: string
export function getPortfolioContent(locale: PortfolioLocale): PortfolioContent {
return (
portfolioByLocale[locale] ?? portfolioByLocale[DEFAULT_PORTFOLIO_LOCALE]
)
}
export const introContent: IntroContent = {
name: 'Christian Nachtigall',
title: 'Front-end Engineer',
location: 'Berlin, Germany',
valueLead: 'I build fast, accessible web experiences that users',
valueEmphasis: ['trust', 'remember'],
valueTail: '.',
narrative:
'Partnering with product teams to launch performant component systems, polished dashboards, and inclusive user journeys—without trading speed for maintainability.',
specialties: [
'Vue 3 + Vuetify architecture',
'Accessibility audits & remediation',
'Performance-first design systems',
],
primaryAction: {
label: 'Explore My Work',
to: '/projects',
},
secondaryAction: {
label: 'Lets Collaborate',
to: '/contact',
},
}
export const socialLinks: readonly SocialLink[] = [
{
label: 'GitHub',
icon: 'mdi-github',
url: 'https://github.com/cnachtigall',
},
{
label: 'LinkedIn',
icon: 'mdi-linkedin',
url: 'https://www.linkedin.com/in/cnachtigall',
},
{ label: 'Email', icon: 'mdi-email', url: 'mailto:hello@nachtigall.dev' },
]
export const projects: readonly Project[] = [
{
slug: 'optimus-ui',
name: 'Optimus UI Library',
tagline: 'Design system for a fintech scale-up',
summary:
'Scaled a Vue component library across five product teams, cutting feature delivery time by 35%.',
description:
'Led the architecture of a reusable component system that unified product experiences across web and mobile banking surfaces. Focused on accessibility, token-driven theming, and automated visual regression coverage.',
role: 'Lead Front-end Engineer',
timeframe: '2023 2024',
heroImage:
'https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue 3', 'Vuetify', 'TypeScript', 'Storybook', 'Playwright'],
metrics: [
'35% faster feature delivery',
'98 Lighthouse accessibility score',
'Shared across 5 product teams',
],
highlights: [
{
title: 'Design-token pipeline',
description:
'Automated token exports for design, docs, and production builds, guaranteeing pixel-perfect parity across channels.',
},
{
title: 'Accessibility reviews',
description:
'Shipped AA-compliant components with axe-core CI checks, ensuring inclusive experiences out of the box.',
},
],
links: [
{
label: 'Case study',
url: 'https://nachtigall.dev/projects/optimus-ui',
type: 'case-study',
},
{
label: 'Live Storybook',
url: 'https://storybook.nachtigall.dev',
type: 'demo',
},
{
label: 'Design tokens repo',
url: 'https://github.com/cnachtigall/optimus-design-tokens',
type: 'code',
},
],
featured: true,
},
{
slug: 'aurora-analytics',
name: 'Aurora Analytics',
tagline: 'Insights dashboard with collaborative commenting',
summary:
'Delivered a data storytelling surface with inline annotations that helped analysts close feedback loops 2× faster.',
description:
'Implemented a modular dashboard that blends real-time metrics, saved explorations, and collaborative commenting. Emphasis on smooth transitions, optimistic UI, and resilient API integrations.',
role: 'Senior Front-end Engineer',
timeframe: '2022 2023',
heroImage:
'https://images.unsplash.com/photo-1556157382-97eda2d62296?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue 3', 'Pinia', 'Apollo GraphQL', 'D3.js', 'Vitest'],
metrics: [
'2× faster analyst feedback loops',
'Sub-1s dashboard interactions',
'Collaborative features adopted by 180+ users',
],
highlights: [
{
title: 'Realtime presence',
description:
'Built WebSocket-powered presence indicators and live cursors to highlight concurrent viewers and edits.',
},
{
title: 'Narrative exports',
description:
'Rendered dashboards to branded PDF stories with contextual annotations and executive summaries.',
},
],
links: [
{
label: 'See Live Demo',
url: 'https://aurora.nachtigall.dev',
type: 'demo',
},
{
label: 'View Code',
url: 'https://github.com/cnachtigall/aurora-analytics',
type: 'code',
},
{
label: 'Case study',
url: 'https://nachtigall.dev/projects/aurora-analytics',
type: 'case-study',
},
],
featured: true,
},
{
slug: 'pulse-health',
name: 'Pulse Health Platform',
tagline: 'Patient engagement portal for clinics',
summary:
'Introduced virtual waiting rooms and asynchronous triage, cutting phone volume by 40%.',
description:
'Collaborated with clinicians to ship a responsive health portal with appointment bookings, symptom triage, and secure messaging. Balanced strict compliance requirements with empathetic UX.',
role: 'Front-end Engineer',
timeframe: '2021 2022',
heroImage:
'https://images.unsplash.com/photo-1521790797524-b2497295b8a0?auto=format&fit=crop&w=1600&q=80',
tech: ['Nuxt', 'Vuetify', 'TypeScript', 'Cypress', 'i18n'],
metrics: [
'40% reduction in call volume',
'Appointment drop-offs down 22%',
'Deployed to 12 clinics in 3 countries',
],
highlights: [
{
title: 'Virtual triage',
description:
'Designed a symptom checker that prioritised urgent cases and surfaced care guidance instantly.',
},
{
title: 'Offline resilience',
description:
'Implemented offline-ready forms with queue syncing for communities with spotty connectivity.',
},
],
links: [
{
label: 'Product Tour',
url: 'https://pulse.nachtigall.dev',
type: 'demo',
},
{
label: 'Case study',
url: 'https://nachtigall.dev/projects/pulse-health',
type: 'case-study',
},
],
},
{
slug: 'lumen-labs',
name: 'Lumen Labs Microsite',
tagline: 'Interactive marketing microsite for a VR studio',
summary:
'Launched a cinematic landing experience with scroll-driven storytelling in under four weeks.',
description:
'Partnered with a creative agency to prototype and deliver an immersive microsite showcasing upcoming VR titles. Focused on buttery animations, 3D asset integration, and SEO-readiness.',
role: 'Creative Developer',
timeframe: '2020 2021',
heroImage:
'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1600&q=80',
tech: ['Vue', 'GSAP', 'Three.js', 'Netlify'],
metrics: [
'Launched in 4 weeks',
'Time on page +63%',
'Custom shader pipeline under 60kb',
],
highlights: [
{
title: 'Scroll choreography',
description:
'Crafted narrative beats synced to scroll depth, using GPU-friendly shaders for lighting effects.',
},
{
title: 'CMS handoff',
description:
'Integrated a headless CMS so the studio could update copy and assets without developer support.',
},
],
links: [
{
label: 'Visit Site',
url: 'https://lumenlabs.example.com',
type: 'demo',
},
{
label: 'Animation snippets',
url: 'https://github.com/cnachtigall/lumen-labs-microsite',
type: 'code',
},
],
},
]
export const experience: readonly Experience[] = [
{
company: 'Finwave',
role: 'Lead Front-end Engineer',
timeframe: '2023 Present',
location: 'Berlin (Hybrid)',
description:
'Heading the experience engineering guild responsible for the design system, accessibility, and shared tooling across five product pillars.',
achievements: [
'Scaled design system to 120+ contributors with automated release notes.',
'Improved Core Web Vitals median score by 18% in one quarter.',
'Mentored and coached a distributed team of eight front-end engineers.',
],
tech: ['Vue 3', 'Vite', 'Vuetify', 'TypeScript', 'Storybook'],
image:
'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70?auto=format&fit=crop&w=1200&q=80',
},
{
company: 'DataForge',
role: 'Senior Front-end Engineer',
timeframe: '2021 2023',
location: 'Remote (EU)',
description:
'Delivered analytics workflows and collaboration features for enterprise customers transitioning off legacy BI suites.',
achievements: [
'Rolled out collaborative dashboards with optimistic UI and real-time presence.',
'Reduced bundle size by 28% through module federation and code splitting.',
'Partnered with design and data science to ship 6 customer-facing features per quarter.',
],
tech: ['Vue 3', 'Apollo', 'GraphQL', 'Pinia'],
image:
'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=1200&q=80',
},
{
company: 'BrightHealth',
role: 'Front-end Engineer',
timeframe: '2019 2021',
location: 'Hamburg (On-site)',
description:
'Built patient engagement tools with a focus on accessible workflows and clinician trust.',
achievements: [
'Launched asynchronous triage saving clinicians ~4 hours per shift.',
'Localised the portal for five languages, including full RTL support.',
'Co-led accessibility remediation to achieve WCAG AA compliance.',
],
tech: ['Nuxt', 'Vuex', 'Cypress', 'i18n'],
image:
'https://images.unsplash.com/photo-1472289065668-ce650ac443d2?auto=format&fit=crop&w=1200&q=80',
},
]
export const contactChannels: readonly ContactChannel[] = [
{
label: 'Email',
value: 'hello@nachtigall.dev',
icon: 'mdi-email-outline',
href: 'mailto:hello@nachtigall.dev',
},
{
label: 'LinkedIn',
value: 'linkedin.com/in/cnachtigall',
icon: 'mdi-linkedin',
href: 'https://www.linkedin.com/in/cnachtigall',
},
{
label: 'Calendly',
value: 'Book a 30 min chat',
icon: 'mdi-calendar-clock',
href: 'https://calendly.com/cnachtigall/coffee',
},
]
export function getProjectBySlug(slug: string): Project | undefined {
return projects.find(project => project.slug === slug)
}
export function getFeaturedProjects(): readonly Project[] {
return projects.filter(project => project.featured)
export function getFeaturedProjects(
content: PortfolioContent
): readonly Project[] {
return content.projects.filter(project => project.featured)
}

View File

@@ -0,0 +1,82 @@
export const PORTFOLIO_LOCALES = ['en', 'de'] as const
export type PortfolioLocale = (typeof PORTFOLIO_LOCALES)[number]
export interface SocialLink {
readonly label: string
readonly icon: string
readonly url: string
}
export type ProjectLinkType = 'demo' | 'code' | 'case-study' | 'article'
export interface ProjectLink {
readonly label: string
readonly url: string
readonly type?: ProjectLinkType
}
export interface ProjectHighlight {
readonly title: string
readonly description: string
}
export interface Project {
readonly slug: string
readonly name: string
readonly tagline: string
readonly summary: string
readonly description: string
readonly role: string
readonly timeframe: string
readonly heroImage: string
readonly tech: readonly string[]
readonly metrics: readonly string[]
readonly highlights: readonly ProjectHighlight[]
readonly links: readonly ProjectLink[]
readonly featured?: boolean
}
export interface Experience {
readonly company: string
readonly role: string
readonly timeframe: string
readonly location: string
readonly description: string
readonly achievements: readonly string[]
readonly tech: readonly string[]
readonly image: string
}
export interface IntroContent {
readonly name: string
readonly title: string
readonly location: string
readonly valueLead: string
readonly valueEmphasis: readonly string[]
readonly valueTail: string
readonly narrative: string
readonly specialties: readonly string[]
readonly primaryAction: {
readonly label: string
readonly to: string
}
readonly secondaryAction: {
readonly label: string
readonly to: string
}
}
export interface ContactChannel {
readonly label: string
readonly value: string
readonly icon: string
readonly href: string
}
export interface PortfolioContent {
readonly intro: IntroContent
readonly socials: readonly SocialLink[]
readonly projects: readonly Project[]
readonly experience: readonly Experience[]
readonly contact: readonly ContactChannel[]
}