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:
@@ -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": []
|
||||
}
|
||||
}
|
||||
15
.nanocoder/commands/component.md
Normal file
15
.nanocoder/commands/component.md
Normal 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.
|
||||
15
.nanocoder/commands/refactor.md
Normal file
15
.nanocoder/commands/refactor.md
Normal 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.
|
||||
15
.nanocoder/commands/review.md
Normal file
15
.nanocoder/commands/review.md
Normal 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.
|
||||
17
.nanocoder/commands/test.md
Normal file
17
.nanocoder/commands/test.md
Normal 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
117
AGENTS.md
Normal 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._
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>('')
|
||||
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
© {{ 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<template v-if="emphasisWords.length">
|
||||
<span class="accent-text">{{ emphasisWords[0] }}</span>
|
||||
<template v-if="emphasisWords.length > 1">
|
||||
and
|
||||
{{ t('common.and') }}
|
||||
<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) ?? []
|
||||
)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
148
src/locales/de.ts
Normal 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
149
src/locales/en.ts
Normal 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
|
||||
@@ -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"><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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
Let’s 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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
73
src/plugins/i18n.ts
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
306
src/services/portfolio-data/de.ts
Normal file
306
src/services/portfolio-data/de.ts
Normal 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,
|
||||
}
|
||||
306
src/services/portfolio-data/en.ts
Normal file
306
src/services/portfolio-data/en.ts
Normal 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,
|
||||
}
|
||||
@@ -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: 'Let’s 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)
|
||||
}
|
||||
|
||||
82
src/services/portfolio.types.ts
Normal file
82
src/services/portfolio.types.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user