chore: remove unused components, utilities, and dependencies

- Removed unused components (`components.vue`, `Markup.vue`, `index.vue`, etc.) and related styles.
- Deleted obsolete utilities (`api.ts`, `componentCatalog.ts`).
- Cleared out dependencies in `package-lock.json` related to unused functionalities.
This commit is contained in:
2025-09-27 01:03:01 +02:00
parent 7c6cddd742
commit d30266248c
42 changed files with 53701 additions and 11525 deletions

View File

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

View File

@@ -4,31 +4,31 @@ A Vue 3 + Vuetify playground for prototyping component ideas and documenting reu
## Getting Started
- Install dependencies with `npm install`.
- Start the development server with `npm run dev` (Vite).
- Run the Vitest-powered unit suite with `npm run test:unit`.
- Trigger the placeholder end-to-end command with `npm run test:e2e` until the real harness lands.
- Lint the codebase with `npm run lint`.
- Type-check and build the production bundle with `npm run build`.
- Install dependencies with `yarn install`.
- Start the development server with `yarn dev` (Vite).
- Run the Vitest-powered unit suite with `yarn test:unit`.
- Trigger the placeholder end-to-end command with `yarn test:e2e` until a real harness lands.
- Lint the codebase with `yarn lint`.
- Type-check and build the production bundle with `yarn build`.
## Project Layout
- `src/main.ts` bootstraps the Vue application and registers plugins.
- `src/layouts/` contains layout shells that wrap routed pages.
- `src/pages/` defines the routed views, generated automatically by `unplugin-vue-router`.
- `src/components/` holds global UI components, including the reusable `app/Markup` code viewer.
- `src/composables/` exposes Composition API helpers, such as the component catalog utilities.
- `src/services/` centralises domain logic (navigation metadata, Prism highlighting helpers, etc.).
- `src/pages/` defines the routed views, including the new projects, experience, and contact flows generated automatically by `unplugin-vue-router`.
- `src/components/` holds global UI components as well as the portfolio building blocks in `portfolio/`.
- `src/composables/` exposes Composition API helpers, such as the portfolio data accessors.
- `src/services/` centralises domain logic and data sets, including `portfolio.ts`.
- `src/stores/` defines Pinia stores.
- `src/types/` contains ambient TypeScript declarations.
## Component Catalog
## Portfolio Content
The component catalog data and navigation structure live in `src/services/componentCatalog.ts` and are consumed via the `useComponentCatalog` composable. This keeps page views and navigation drawers in sync while supporting lazy loading.
The home, projects, experience, and contact screens are populated from `src/services/portfolio.ts` via the `usePortfolio` composable. Update those data sets to surface new case studies, achievements, or contact options.
## Styling Guidelines
- Prefer Vuetify utility classes for spacing, colour and typography.
- Prefer Vuetify utility classes for spacing, colour, and typography.
- Where custom rules are required, add them in the scoped `<style>` blocks of the relevant components.
- Inline styles are intentionally avoided.

View File

@@ -2,9 +2,41 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link href="#" rel="icon" />
<link href="/favicon.ico" rel="icon" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>nachtigall.dev</title>
<meta
name="description"
content="Portfolio of Christian Nachtigall, a front-end engineer crafting accessible Vue & Vuetify experiences with performance-first thinking."
/>
<meta name="theme-color" content="#7c5cff" />
<meta
property="og:title"
content="Christian Nachtigall · Front-end Engineer"
/>
<meta
property="og:description"
content="Explore projects, experience, and accessibility-driven front-end work by Christian Nachtigall."
/>
<meta property="og:type" content="website" />
<meta property="og:url" content="https://nachtigall.dev" />
<meta
property="og:image"
content="https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1200&q=80"
/>
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content="Christian Nachtigall · Front-end Engineer"
/>
<meta
name="twitter:description"
content="Portfolio for accessibility-focused Vue developer Christian Nachtigall."
/>
<meta
name="twitter:image"
content="https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1200&q=80"
/>
<title>Christian Nachtigall · Front-end Engineer</title>
<link
crossorigin="anonymous"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

10391
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"typecheck": "vue-tsc --noEmit",
"analyze": "ANALYZE=true npm run build",
"analyze": "ANALYZE=true yarn build",
"prepare": "husky"
},
"lint-staged": {
@@ -30,10 +30,9 @@
"@mdi/font": "^7.4.47",
"core-js": "^3.45.1",
"prettier": "^3.6.2",
"prism-theme-vars": "^0.2.5",
"prismjs": "^1.30.0",
"roboto-fontface": "*",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12",
"vuetify": "^3.10.3"
},
"devDependencies": {
@@ -41,11 +40,11 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.36.0",
"@types/node": "^24.5.2",
"@types/prismjs": "^1.26.5",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/ui": "^3.2.4",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"eslint": "^9.36.0",
"eslint-config-standard": "^17.1.0",
"eslint-config-vuetify": "^4.2.0",
@@ -57,11 +56,14 @@
"globals": "^16.4.0",
"husky": "^9.1.7",
"jsdom": "^27.0.0",
"lighthouse": "^12.8.2",
"lint-staged": "^16.2.1",
"npm-check-updates": "^18.3.0",
"pinia": "^3.0.3",
"postcss": "^8.5.6",
"rollup-plugin-visualizer": "^6.0.3",
"sass": "1.93.2",
"tailwindcss": "3",
"typescript": "^5.9.2",
"unplugin-auto-import": "^20.2.0",
"unplugin-fonts": "^1.4.0",

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,11 +1,15 @@
<template>
<v-app>
<v-main>
<ErrorBoundary>
<router-view />
</ErrorBoundary>
</v-main>
</v-app>
<div>
<a class="skip-link" href="#main-content">Skip to main content</a>
<v-app>
<v-main id="main-content">
<ErrorBoundary>
<router-view />
</ErrorBoundary>
</v-main>
</v-app>
</div>
</template>
<script lang="ts" setup>

5
src/components.d.ts vendored
View File

@@ -8,10 +8,13 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ContactGrid: typeof import('./components/portfolio/ContactGrid.vue')['default']
ErrorBoundary: typeof import('./components/ErrorBoundary.vue')['default']
ExperienceTimeline: typeof import('./components/portfolio/ExperienceTimeline.vue')['default']
Footer: typeof import('./components/Footer.vue')['default']
Markup: typeof import('./components/app/Markup.vue')['default']
HeroSection: typeof import('./components/portfolio/HeroSection.vue')['default']
Nav: typeof import('./components/Nav.vue')['default']
ProjectCard: typeof import('./components/portfolio/ProjectCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}

View File

@@ -53,16 +53,37 @@ import { useRouter } from 'vue-router'
import { logger } from '@/utils/logger'
interface ErrorInfo {
message: string
stack?: string
componentStack?: string
readonly message: string
readonly stack?: string
readonly componentStack?: string
}
const normalizeError = (input: unknown): Error => {
if (input instanceof Error) {
return input
}
if (typeof input === 'string') {
return new Error(input)
}
if (input && typeof input === 'object') {
const message =
'message' in input && typeof input.message === 'string'
? input.message
: 'Unknown error'
return new Error(message)
}
return new Error('Unknown error')
}
const router = useRouter()
const hasError = ref(false)
const errorDetails = ref<string>('')
const handleError = (error: Error, errorInfo?: ErrorInfo) => {
const handleError = (errorInput: unknown, errorInfo?: ErrorInfo) => {
const error = normalizeError(errorInput)
hasError.value = true
const details = {
@@ -110,11 +131,11 @@ onErrorCaptured((error, instance, info) => {
// Global error handler
onMounted(() => {
window.addEventListener('error', event => {
handleError(event.error)
handleError(event.error ?? event.message)
})
window.addEventListener('unhandledrejection', event => {
handleError(new Error(event.reason))
handleError(event.reason)
})
})
</script>

View File

@@ -6,12 +6,20 @@
&copy; {{ new Date().getFullYear() }} |
<span class="d-none d-sm-inline-block">nachtigall.dev</span> |
<a
class="text-decoration-none text-caption text-disabled"
href="https://somegit.dev/nachtigall.dev/nachtigall.dev"
aria-label="Christian Nachtigall on GitHub"
class="text-decoration-none text-caption text-medium-emphasis footer-link"
href="https://github.com/cnachtigall"
rel="noopener noreferrer"
target="_blank"
>
<i aria-hidden="true" class="fa fa-gitea app-icon" />
<span class="visually-hidden">GitHub profile opens in a new tab</span>
<v-icon
aria-hidden="true"
class="app-icon"
color="primary"
icon="mdi-github"
size="20"
/>
</a>
</div>
</v-footer>
@@ -23,4 +31,14 @@
.footer-content
gap: 5px
font-size: 0.8rem
.footer-link
display: inline-flex
align-items: center
gap: 6px
transition: color 0.2s ease
.footer-link:focus-visible,
.footer-link:hover
color: var(--portfolio-accent) !important
</style>

View File

@@ -1,37 +1,827 @@
<template>
<v-app-bar :elevation="5" scroll-behavior="hide">
<template v-if="mobile" #prepend>
<v-app-bar-nav-icon />
</template>
<!-- Skip to main content link -->
<a class="skip-link" href="#main-content"> Skip to main content </a>
<v-list id="top-nav" class="d-flex ms-2" nav>
<v-list-item
v-for="(route, index) in navigationLinks"
:key="index"
class="mx-n1"
>
<!-- Floating Navigation -->
<nav
ref="floatingNavRef"
:class="{
'floating-nav--visible': showFloatingNav,
'floating-nav--scrolled': isScrolled,
'floating-nav--home': isHomeRoute,
}"
aria-label="Main navigation"
class="floating-nav"
role="navigation"
>
<div class="floating-nav__container">
<!-- Logo/Brand -->
<div class="floating-nav__brand">
<RouterLink
class="text-white text-decoration-none font-weight-black text-uppercase app-link"
:to="{ path: route.path }"
aria-label="Christian Nachtigall - Home"
class="brand-link"
to="/"
>
{{ route.displayLabel }}
<div class="brand-avatar">
<span class="brand-initial">C</span>
</div>
<div v-if="!mobile" class="brand-text">
<div class="brand-name">Christian</div>
<div class="brand-title">Front-end Engineer</div>
</div>
</RouterLink>
</v-list-item>
</v-list>
</v-app-bar>
</div>
<!-- Desktop Navigation -->
<div v-if="!mobile" ref="navLinksRef" class="nav-links">
<div class="nav-links__list">
<RouterLink
v-for="routeMeta in navigationLinks"
:key="routeMeta.key"
:ref="getNavLinkRefSetter(routeMeta.key)"
:class="[
'nav-link',
{ 'nav-link--active': isNavItemActive(routeMeta) },
]"
:to="resolveNavTarget(routeMeta)"
@click="onNavClick($event, routeMeta)"
>
<span class="nav-link__text">{{ routeMeta.displayLabel }}</span>
<div class="nav-link__indicator" />
</RouterLink>
</div>
</div>
<!-- CTA Button -->
<div v-if="!mobile" class="floating-nav__cta">
<RouterLink class="cta-button" to="/contact">
<span>Let's Connect</span>
<v-icon icon="mdi-arrow-right" size="16" />
</RouterLink>
</div>
<!-- Mobile Menu Button -->
<button
v-if="mobile"
:class="{ 'mobile-menu-button--active': drawer }"
:aria-expanded="drawer ? 'true' : 'false'"
aria-controls="mobile-navigation"
aria-label="Toggle navigation menu"
class="mobile-menu-button"
@click="toggleMobileMenu"
>
<span class="mobile-menu-button__line" />
<span class="mobile-menu-button__line" />
<span class="mobile-menu-button__line" />
</button>
</div>
</nav>
<!-- Mobile Sidebar Navigation -->
<Transition name="mobile-sidebar">
<div
v-if="mobile && drawer"
class="mobile-sidebar-overlay"
@click="closeMobileMenu"
>
<aside
id="mobile-navigation"
aria-label="Mobile navigation"
class="mobile-sidebar"
role="navigation"
@click.stop
>
<!-- Sidebar Header -->
<div class="mobile-sidebar__header">
<RouterLink class="sidebar-brand" to="/" @click="closeMobileMenu">
<div class="brand-avatar brand-avatar--small">
<span class="brand-initial">C</span>
</div>
<div class="brand-text">
<div class="brand-name">Christian</div>
<div class="brand-title">Front-end Engineer</div>
</div>
</RouterLink>
<button
aria-label="Close navigation"
class="sidebar-close"
@click="closeMobileMenu"
>
<v-icon icon="mdi-close" size="20" />
</button>
</div>
<!-- Navigation Links -->
<nav class="mobile-sidebar__nav">
<RouterLink
v-for="(routeMeta, index) in navigationLinks"
:key="`sidebar-${routeMeta.key}`"
:class="[
'sidebar-nav-link',
{ 'sidebar-nav-link--active': isNavItemActive(routeMeta) },
]"
:style="{ '--delay': `${index * 80}ms` }"
:to="resolveNavTarget(routeMeta)"
@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>
</div>
<v-icon
class="sidebar-nav-link__icon"
icon="mdi-chevron-right"
size="18"
/>
</RouterLink>
</nav>
<!-- Sidebar Footer -->
<div class="mobile-sidebar__footer">
<RouterLink
class="sidebar-cta"
to="/contact"
@click="closeMobileMenu"
>
<v-icon icon="mdi-send" size="16" />
<span>Let's Connect</span>
</RouterLink>
<div class="sidebar-social">
<a
v-for="social in socialLinks"
:key="social.label"
:aria-label="`Open ${social.label}`"
:href="social.url"
class="sidebar-social-link"
rel="noopener"
target="_blank"
>
<v-icon :icon="social.icon" size="18" />
</a>
</div>
<div class="sidebar-status">
<div class="status-indicator" />
<span>Available for projects</span>
</div>
</div>
</aside>
</div>
</Transition>
</template>
<script lang="ts" setup>
import type { ComponentPublicInstance } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useDisplay } from 'vuetify'
import { mainNavigation } from '@/services/navigation'
import { socialLinks } from '@/services/portfolio'
const { mobile } = useDisplay()
const route = useRoute()
const navigationLinks = computed(() =>
interface NavigationLinkMeta {
readonly key: string
readonly label: string
readonly path: string
readonly sectionId?: string
readonly displayLabel: string
}
const HOME_PATH = '/'
const floatingNavRef = ref<HTMLElement | null>(null)
const showFloatingNav = ref(true)
const lastScrollY = ref(0)
const navigationLinks = computed<NavigationLinkMeta[]>(() =>
mainNavigation.map(item => ({
...item,
displayLabel: item.path === '/' ? item.path : item.path.replace('/', ''),
displayLabel: item.label,
key: `path:${item.path}`,
}))
)
const drawer = ref(false)
const isScrolled = ref(false)
const navLinksRef = ref<HTMLElement | null>(null)
const indicatorRefresh = ref(0)
const isHomeRoute = computed(() => route.path === HOME_PATH)
const linkRefs = new Map<string, HTMLElement>()
const linkRefHandlers = new Map<
string,
(el: Element | ComponentPublicInstance | null) => void
>()
const resolveHTMLElement = (
el: Element | ComponentPublicInstance | null
): HTMLElement | null => {
if (!el) {
return null
}
if (el instanceof HTMLElement) {
return el
}
const possible = (el as ComponentPublicInstance).$el
return possible instanceof HTMLElement ? possible : null
}
const updateProgress = () => {
const scrollTop = window.scrollY
// Update scroll state
isScrolled.value = scrollTop > 50
// Navigation visibility logic
const scrollingDown = scrollTop > lastScrollY.value && scrollTop > 100
const scrollingUp = scrollTop < lastScrollY.value
const atTop = scrollTop < 50
if (atTop) {
showFloatingNav.value = true
} else if (scrollingDown && !mobile.value) {
showFloatingNav.value = false
} else if (scrollingUp) {
showFloatingNav.value = true
}
lastScrollY.value = scrollTop
}
const getNavLinkRefSetter = (key: string) => {
if (!linkRefHandlers.has(key)) {
linkRefHandlers.set(key, el => {
const element = resolveHTMLElement(el)
const current = linkRefs.get(key)
if (element === current) {
return
}
if (element) {
linkRefs.set(key, element)
} else {
linkRefs.delete(key)
}
indicatorRefresh.value++
})
}
return linkRefHandlers.get(key)!
}
const resolveNavTarget = (item: NavigationLinkMeta) => item.path
const isNavItemActive = (item: NavigationLinkMeta) => {
if (item.path === HOME_PATH) {
return route.path === HOME_PATH
}
return route.path.startsWith(item.path)
}
const toggleMobileMenu = () => {
drawer.value = !drawer.value
}
const closeMobileMenu = () => {
drawer.value = false
}
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 onNavClick = (_event: MouseEvent, _item: NavigationLinkMeta) => {
// Simply close the mobile menu when navigating
closeMobileMenu()
}
const handleResize = () => {
indicatorRefresh.value++
}
onMounted(async () => {
await nextTick()
updateProgress()
window.addEventListener('scroll', updateProgress, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', updateProgress)
window.removeEventListener('resize', handleResize)
})
watch(
() => route.fullPath,
() => {
drawer.value = false
}
)
watch(
() => route.path,
async () => {
indicatorRefresh.value++
await nextTick()
}
)
watch(
() => mobile.value,
value => {
if (!value) {
drawer.value = false
}
}
)
</script>
<style lang="sass" scoped>
// Skip link styles
.skip-link
position: absolute
top: -40px
left: 16px
z-index: 2000
padding: 0.5rem 1rem
border-radius: 999px
background: var(--portfolio-accent-gradient)
color: #0f1015
font-weight: 600
text-decoration: none
transition: top 0.2s ease
.skip-link:focus,
.skip-link:focus-visible
top: 16px
// Floating Navigation
.floating-nav
position: fixed
top: 20px
left: 50%
transform: translateX(-50%) translateY(-100px)
z-index: 1000
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1)
opacity: 0
pointer-events: none
overflow: hidden
border-radius: 60px
.floating-nav--visible
transform: translateX(-50%) translateY(0)
opacity: 1
pointer-events: auto
overflow: hidden
border-radius: 60px
.floating-nav--scrolled
backdrop-filter: blur(20px)
box-shadow: 0 20px 40px rgba(8, 10, 20, 0.4), 0 8px 32px rgba(255, 20, 225, 0.15)
overflow: hidden
border-radius: 60px
.floating-nav--home
overflow: hidden
border-radius: 60px
.floating-nav__container
display: flex
align-items: center
gap: 2rem
padding: 12px 20px
background: rgba(18, 18, 18, 0.85)
border: 1px solid rgba(255, 255, 255, 0.1)
border-radius: 60px
backdrop-filter: blur(20px)
min-height: 64px
transition: all 0.3s ease
overflow: hidden
.floating-nav--scrolled .floating-nav__container
background: rgba(18, 18, 18, 0.95)
border-color: rgba(255, 255, 255, 0.15)
// Brand Section
.floating-nav__brand
display: flex
align-items: center
.brand-link
display: flex
align-items: center
gap: 12px
text-decoration: none
color: inherit
transition: transform 0.2s ease
.brand-link:hover
transform: scale(1.02)
.brand-avatar
width: 40px
height: 40px
border-radius: 50%
background: var(--portfolio-accent-gradient)
display: flex
align-items: center
justify-content: center
box-shadow: 0 4px 12px rgba(255, 20, 225, 0.3)
transition: all 0.3s ease
.brand-avatar--small
width: 32px
height: 32px
.brand-avatar:hover
transform: scale(1.05)
box-shadow: 0 6px 16px rgba(255, 20, 225, 0.4)
.brand-initial
font-weight: 800
font-size: 1.1rem
color: white
.brand-text
display: flex
flex-direction: column
gap: 2px
.brand-name
font-weight: 700
font-size: 0.9rem
color: #ffffff
line-height: 1
.brand-title
font-weight: 500
font-size: 0.75rem
color: var(--color-text-muted)
line-height: 1
// Navigation Links
.nav-links__list
display: flex
align-items: center
gap: 8px
.nav-link
position: relative
display: flex
align-items: center
padding: 8px 16px
border-radius: 32px
text-decoration: none
color: #e0e0e0
font-weight: 500
font-size: 0.9rem
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
overflow: hidden
.nav-link:hover
color: #ffffff
background: rgba(255, 255, 255, 0.08)
transform: translateY(-1px)
.nav-link--active
color: var(--portfolio-accent)
background: rgba(255, 20, 225, 0.12)
.nav-link__text
position: relative
z-index: 2
.nav-link__indicator
position: absolute
bottom: 0
left: 50%
width: 0
height: 2px
background: var(--portfolio-accent-gradient)
border-radius: 999px
transform: translateX(-50%)
transition: width 0.3s ease
.nav-link:hover .nav-link__indicator,
.nav-link--active .nav-link__indicator
width: 60%
// CTA Button
.floating-nav__cta
margin-left: auto
.cta-button
display: flex
align-items: center
gap: 8px
padding: 10px 20px
background: var(--portfolio-accent-gradient)
color: white !important
text-decoration: none
border-radius: 32px
font-weight: 600
font-size: 0.9rem
transition: all 0.3s ease
box-shadow: 0 4px 12px rgba(255, 20, 225, 0.3)
.cta-button:hover
transform: translateY(-2px)
box-shadow: 0 8px 20px rgba(255, 20, 225, 0.4)
color: white !important
// Mobile Menu Button
.mobile-menu-button
display: flex
flex-direction: column
justify-content: center
align-items: center
width: 40px
height: 40px
background: none
border: none
cursor: pointer
padding: 0
gap: 4px
.mobile-menu-button__line
width: 20px
height: 2px
background: #ffffff
border-radius: 1px
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
transform-origin: center
.mobile-menu-button--active .mobile-menu-button__line:nth-child(1)
transform: translateY(6px) rotate(45deg)
.mobile-menu-button--active .mobile-menu-button__line:nth-child(2)
opacity: 0
.mobile-menu-button--active .mobile-menu-button__line:nth-child(3)
transform: translateY(-6px) rotate(-45deg)
// Mobile Sidebar Navigation
.mobile-sidebar-overlay
position: fixed
inset: 0
background: rgba(8, 10, 20, 0.8)
backdrop-filter: blur(4px)
z-index: 1100
.mobile-sidebar
position: fixed
top: 0
left: 0
height: 100vh
width: 320px
background: rgba(18, 18, 18, 0.98)
backdrop-filter: blur(20px)
border-right: 1px solid rgba(255, 255, 255, 0.1)
display: flex
flex-direction: column
padding: 24px 0
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.3)
transform: translateX(-100%)
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)
.mobile-sidebar-overlay .mobile-sidebar
transform: translateX(0)
// Sidebar Header
.mobile-sidebar__header
padding: 0 24px 24px
border-bottom: 1px solid rgba(255, 255, 255, 0.08)
margin-bottom: 32px
display: flex
align-items: center
justify-content: space-between
.sidebar-brand
display: flex
align-items: center
gap: 12px
text-decoration: none
color: inherit
.sidebar-close
background: none
border: none
color: #ffffff
cursor: pointer
padding: 8px
border-radius: 8px
transition: all 0.2s ease
.sidebar-close:hover
background: rgba(255, 255, 255, 0.08)
// Navigation Links
.mobile-sidebar__nav
flex: 1
padding: 0 12px
display: flex
flex-direction: column
gap: 4px
.sidebar-nav-link
display: flex
align-items: center
justify-content: space-between
padding: 16px 16px
border-radius: 12px
text-decoration: none
color: #e0e0e0
transition: all 0.3s ease
opacity: 0
transform: translateX(-20px)
animation: slideInSidebar 0.4s ease forwards
animation-delay: var(--delay, 0ms)
.sidebar-nav-link:hover
background: rgba(255, 255, 255, 0.05)
transform: translateX(4px)
.sidebar-nav-link--active
background: rgba(255, 20, 225, 0.12)
border-left: 3px solid var(--portfolio-accent)
.sidebar-nav-link__content
display: flex
flex-direction: column
gap: 2px
.sidebar-nav-link__text
font-weight: 600
font-size: 1rem
color: #ffffff
.sidebar-nav-link--active .sidebar-nav-link__text
color: var(--portfolio-accent)
.sidebar-nav-link__description
font-size: 0.8rem
color: var(--color-text-muted)
font-weight: 400
.sidebar-nav-link__icon
opacity: 0.4
transition: all 0.3s ease
.sidebar-nav-link:hover .sidebar-nav-link__icon,
.sidebar-nav-link--active .sidebar-nav-link__icon
opacity: 1
color: var(--portfolio-accent)
// Sidebar Footer
.mobile-sidebar__footer
padding: 24px 24px 0
border-top: 1px solid rgba(255, 255, 255, 0.08)
display: flex
flex-direction: column
gap: 20px
.sidebar-cta
display: flex
align-items: center
justify-content: center
gap: 8px
padding: 12px 20px
background: var(--portfolio-accent-gradient)
color: white
text-decoration: none
border-radius: 12px
font-weight: 600
font-size: 0.9rem
transition: all 0.3s ease
.sidebar-cta:hover
transform: translateY(-1px)
box-shadow: 0 6px 16px rgba(255, 20, 225, 0.3)
color: white
.sidebar-social
display: flex
justify-content: center
gap: 12px
.sidebar-social-link
display: flex
align-items: center
justify-content: center
width: 36px
height: 36px
border-radius: 8px
background: rgba(255, 255, 255, 0.05)
color: #e0e0e0
text-decoration: none
transition: all 0.3s ease
.sidebar-social-link:hover
background: rgba(255, 20, 225, 0.15)
color: var(--portfolio-accent)
.sidebar-status
display: flex
align-items: center
justify-content: center
gap: 8px
font-size: 0.8rem
color: var(--color-text-muted)
.status-indicator
width: 8px
height: 8px
border-radius: 50%
background: #4ade80
animation: pulse 2s infinite
@keyframes pulse
0%, 100%
opacity: 1
50%
opacity: 0.6
// Animations
@keyframes slideInSidebar
to
opacity: 1
transform: translateX(0)
// Transitions
.mobile-sidebar-enter-active,
.mobile-sidebar-leave-active
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
.mobile-sidebar-enter-from,
.mobile-sidebar-leave-to
opacity: 0
.mobile-sidebar-enter-from .mobile-sidebar,
.mobile-sidebar-leave-to .mobile-sidebar
transform: translateX(-100%)
// Responsive Design
@media (max-width: 768px)
.floating-nav
top: 10px
left: 10px
right: 10px
transform: none
.floating-nav--visible
transform: none
.floating-nav__container
gap: 1rem
padding: 10px 16px
justify-content: space-between
.brand-text
display: none
@media (min-width: 769px)
.mobile-menu-button
display: none
.nav-links
display: flex
@media (max-width: 1200px)
.floating-nav__container
gap: 1.5rem
.nav-link
padding: 6px 12px
font-size: 0.85rem
@media (prefers-reduced-motion: reduce)
.floating-nav,
.nav-link,
.brand-avatar,
.mobile-nav-link
transition: none
.mobile-nav-link
animation: none
opacity: 1
transform: none
</style>

View File

@@ -1,304 +0,0 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<v-sheet
ref="root"
class="app-markup overflow-hidden"
:color="theme.name.value === 'light' ? 'surface-bright' : undefined"
dir="ltr"
:rounded="rounded"
:theme="theme.name.value === 'light' ? 'dark' : theme.name.value"
>
<v-toolbar v-if="resource" class="px-1" height="44">
<v-sheet
v-if="resource"
class="text-body-2 px-3 pt-3 text-medium-emphasis"
color="transparent"
height="44"
rounded="tl"
>
<v-icon icon="mdi-file-tree" />
{{ resource }}
</v-sheet>
</v-toolbar>
<v-tooltip location="start">
<template #activator="{ props: activatorProps }">
<v-fade-transition hide-on-leave>
<v-btn
:key="icon"
class="text-disabled me-3 mt-1 app-markup-btn"
density="comfortable"
:icon="icon"
v-bind="activatorProps"
variant="text"
@click="copy"
/>
</v-fade-transition>
</template>
<span>copy source</span>
</v-tooltip>
<div class="pa-4 pe-12">
<slot>
<pre v-if="inline" :class="className">
<!-- eslint-disable-next-line vue/no-v-html -->
<code
:class="className"
v-html="highlighted"
/>
</pre>
<!-- eslint-disable-next-line vue/no-v-html -->
<code v-else :class="className" v-html="highlighted" />
</slot>
</div>
</v-sheet>
<!-- eslint-enable vue/no-v-html -->
</template>
<script lang="ts" setup>
import { useTheme } from 'vuetify'
// Styles
import 'prismjs/themes/prism.css'
// Services
import { highlightCode } from '@/services/prism'
import { wait } from '@/utils/helpers'
import { logger } from '@/utils/logger'
// Types
import type { ComponentPublicInstance } from 'vue'
interface MarkupProps {
readonly resource?: string
readonly code?: string | null
readonly inline?: boolean
readonly language?: string
readonly rounded?: boolean
}
const props = withDefaults(defineProps<MarkupProps>(), {
inline: false,
language: 'markup',
resource: undefined,
code: null,
rounded: true,
})
const theme = useTheme()
const clicked = ref(false)
const root = ref<ComponentPublicInstance | null>(null)
const highlighted = ref('')
let highlightRequestId = 0
watchEffect(async () => {
highlightRequestId += 1
const currentRequestId = highlightRequestId
if (!props.code) {
highlighted.value = ''
return
}
try {
const result = await highlightCode(props.code, props.language)
if (currentRequestId === highlightRequestId) {
highlighted.value = result
}
} catch (error) {
logger.error('Failed to highlight code snippet', error)
if (currentRequestId === highlightRequestId) {
highlighted.value = props.code
}
}
})
const className = computed(() => `language-${props.language}`)
const icon = computed(() =>
clicked.value ? 'mdi-check' : 'mdi-clipboard-text-outline'
)
async function copy(): Promise<void> {
const el = root.value?.$el.querySelector('code')
try {
await navigator.clipboard.writeText(props.code ?? el?.textContent ?? '')
} catch (error) {
logger.error('Failed to copy code snippet', error)
}
clicked.value = true
await wait(2000)
clicked.value = false
}
</script>
<style lang="sass" scoped>
.v-sheet.app-markup
position: relative
.app-markup-btn
position: absolute
right: 0
top: 0
&:not(:hover)
.app-markup-btn
opacity: 0 !important
&:not(:hover) .v-btn--copy .v-icon
opacity: .4
code,
pre
background: none
color: currentColor !important
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace
font-size: 1rem
font-weight: 300
hyphens: none
line-height: 1.5
margin: 0
padding: 0
tab-size: 4
text-align: left
text-shadow: none
white-space: pre-wrap
word-break: normal
word-spacing: normal
word-wrap: normal
pre,
code
&::after
bottom: .5rem
color: hsla(0, 0%, 19%, 0.5)
font-family: inherit
font-size: 0.7rem
font-weight: 700
pointer-events: none
position: absolute
right: 1rem
text-transform: uppercase
code.language-bash::after
content: ' sh '
code.language-html::after
content: 'html'
code.language-js::after
content: ' js '
code.language-json::after
content: 'json'
code.language-css::after
content: 'css'
code.language-sass::after
content: 'sass'
code.language-scss::after
content: 'scss'
code.language-ts::after
content: ' ts '
code.language-vue::after
content: 'vue'
// TODO: handle this differently
&.v-theme--blackguard,
&.v-theme--dark
--prism-interpolation: var(--prism-operator)
code,
pre
color: #ccc !important
&::selection, ::selection
background-color: #113663
code,
pre
&::after
color: hsla(0, 0%, 50%, 1)
&.v-sheet--outlined
border: thin solid hsla(0, 0%, 100%, .12) !important
.token.operator,
.token.string
background: none
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata
color: #999
.token.punctuation
color: #ccc
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted
color: #e2777a
.token.function-name
color: #6196cc
.token.boolean,
.token.number,
.token.function
color: #f08d49
.token.property,
.token.class-name,
.token.constant,
.token.symbol
color: #f8c555
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin
color: #cc99cd
.token.string,
.token.char,
.token.attr-value,
.token.regex,
.token.variable
color: #7ec699
.token.operator,
.token.entity,
.token.url
color: #67cdcc
.token.important,
.token.bold
font-weight: bold
.token.italic
font-style: italic
.token.entity
cursor: help
.token.inserted
color: green
</style>

View File

@@ -0,0 +1,44 @@
<template>
<v-row class="py-4">
<v-col v-for="channel in channels" :key="channel.label" cols="12" md="4">
<v-card
class="pa-6 h-100 d-flex flex-column justify-space-between"
elevation="3"
rounded="xl"
>
<div>
<v-icon class="mb-4" color="primary" size="36" :icon="channel.icon" />
<h3 class="text-h5 font-weight-bold mb-2">
{{ channel.label }}
</h3>
<p class="text-body-2 text-medium-emphasis">
{{ channel.value }}
</p>
</div>
<v-btn
class="mt-6 text-none font-weight-bold accent-hover"
color="primary"
rounded="pill"
:href="channel.href"
target="_blank"
rel="noopener"
>
Connect
</v-btn>
</v-card>
</v-col>
</v-row>
</template>
<script lang="ts" setup>
import type { ContactChannel } from '@/services/portfolio'
interface ContactGridProps {
readonly channels: readonly ContactChannel[]
}
defineProps<ContactGridProps>()
</script>

View File

@@ -0,0 +1,310 @@
<template>
<div class="experience-timeline">
<v-timeline
align="start"
density="comfortable"
side="end"
line-color="rgba(255, 20, 225, 0.3)"
>
<v-timeline-item
v-for="(entry, index) in entries"
:key="entry.company"
v-reveal="index * 200"
dot-color="primary"
elevation="0"
fill-dot
>
<template #icon>
<div class="timeline-dot">
<v-icon icon="mdi-office-building" size="16" />
</div>
</template>
<template #opposite>
<div class="timeline-date">
<span class="timeline-timeframe">{{ entry.timeframe }}</span>
<span class="timeline-location">{{ entry.location }}</span>
</div>
</template>
<v-card
class="experience-card"
elevation="0"
rounded="xl"
variant="outlined"
>
<div class="experience-card__content">
<div class="experience-card__media">
<v-img
:alt="`${entry.company} office`"
:src="entry.image"
aspect-ratio="16/9"
class="rounded-lg"
loading="lazy"
:lazy-src="`${entry.image}&w=32&auto=format&blur=50`"
cover
/>
<div class="experience-card__overlay">
<span class="company-badge">{{ entry.company }}</span>
</div>
</div>
<div class="experience-card__details">
<header class="experience-card__header">
<h3 class="experience-role">
{{ entry.role }}
</h3>
<div class="experience-meta">
<span class="experience-company">{{ entry.company }}</span>
<span class="experience-period">{{ entry.timeframe }}</span>
</div>
</header>
<p class="experience-description">
{{ entry.description }}
</p>
<div class="experience-achievements">
<h4 class="achievements-title">Key Achievements</h4>
<ul class="achievements-list">
<li
v-for="achievement in entry.achievements"
:key="achievement"
>
{{ achievement }}
</li>
</ul>
</div>
<div class="experience-tech">
<h4 class="tech-title">Technologies</h4>
<div class="tech-stack">
<v-chip
v-for="tech in entry.tech"
:key="tech"
class="tech-chip"
color="primary"
density="comfortable"
label
variant="flat"
size="small"
>
{{ tech }}
</v-chip>
</div>
</div>
</div>
</div>
</v-card>
</v-timeline-item>
</v-timeline>
</div>
</template>
<script lang="ts" setup>
import type { Experience } from '@/services/portfolio'
interface ExperienceTimelineProps {
readonly entries: readonly Experience[]
}
defineProps<ExperienceTimelineProps>()
</script>
<style scoped lang="sass">
.experience-timeline
position: relative
// Timeline Dots & Dating
.timeline-dot
width: 40px
height: 40px
background: var(--portfolio-accent-gradient)
border-radius: 50%
display: flex
align-items: center
justify-content: center
color: white
box-shadow: 0 4px 12px rgba(255, 20, 225, 0.3)
.timeline-date
text-align: right
padding-right: 20px
.timeline-timeframe
display: block
font-size: 0.9rem
font-weight: 700
color: var(--portfolio-accent)
text-transform: uppercase
letter-spacing: 0.05em
.timeline-location
display: block
font-size: 0.8rem
color: var(--color-text-muted)
margin-top: 4px
// Experience Cards
.experience-card
background: rgba(34, 35, 40, 0.85)
border: 1px solid rgba(255, 255, 255, 0.08)
backdrop-filter: blur(12px)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
overflow: hidden
margin-left: 20px
.experience-card:hover
transform: translateY(-4px)
box-shadow: 0 20px 40px rgba(255, 20, 225, 0.15), 0 8px 32px rgba(8, 10, 20, 0.3)
border-color: rgba(255, 255, 255, 0.12)
.experience-card__content
display: grid
grid-template-columns: 1fr 2fr
gap: 24px
padding: 24px
.experience-card__media
position: relative
overflow: hidden
border-radius: 12px
.experience-card__overlay
position: absolute
inset: 0
background: linear-gradient(180deg, rgba(9, 10, 15, 0) 0%, rgba(9, 10, 15, 0.8) 100%)
display: flex
align-items: flex-end
padding: 16px
z-index: 2
.company-badge
padding: 6px 12px
background: rgba(17, 17, 17, 0.8)
backdrop-filter: blur(8px)
border-radius: 999px
font-size: 0.8rem
font-weight: 600
color: #ffffff
border: 1px solid rgba(255, 255, 255, 0.1)
.experience-card__details
display: flex
flex-direction: column
gap: 20px
.experience-card__header
border-bottom: 1px solid rgba(255, 255, 255, 0.08)
padding-bottom: 16px
.experience-role
font-size: 1.4rem
font-weight: 700
color: #ffffff
margin-bottom: 8px
line-height: 1.2
.experience-meta
display: flex
flex-direction: column
gap: 4px
.experience-company
font-size: 1rem
font-weight: 600
color: var(--portfolio-accent)
.experience-period
font-size: 0.85rem
color: var(--color-text-muted)
text-transform: uppercase
letter-spacing: 0.05em
.experience-description
font-size: 1rem
line-height: 1.6
color: var(--color-text-muted)
margin: 0
.experience-achievements,
.experience-tech
.achievements-title,
.tech-title
font-size: 0.8rem
font-weight: 700
text-transform: uppercase
letter-spacing: 0.1em
color: var(--color-text-muted)
margin-bottom: 12px
.achievements-list
list-style: none
padding: 0
margin: 0
display: flex
flex-direction: column
gap: 8px
.achievements-list li
position: relative
padding-left: 20px
font-size: 0.9rem
line-height: 1.5
color: #e0e0e0
.achievements-list li::before
content: ''
position: absolute
left: 0
top: 0.6em
width: 6px
height: 6px
border-radius: 50%
background: var(--portfolio-accent)
.tech-stack
display: flex
flex-wrap: wrap
gap: 8px
.tech-chip
background-color: rgba(124, 92, 255, 0.15) !important
border: 1px solid rgba(255, 255, 255, 0.1) !important
color: #ffffff !important
font-size: 0.75rem
font-weight: 500
// Responsive Design
@media (max-width: 768px)
.experience-card__content
grid-template-columns: 1fr
gap: 20px
padding: 20px
.timeline-date
text-align: left
padding-right: 0
padding-bottom: 12px
.experience-card
margin-left: 0
.experience-role
font-size: 1.2rem
.experience-meta
flex-direction: row
gap: 12px
@media (max-width: 599px)
.experience-card__content
padding: 16px
.achievements-list li
font-size: 0.85rem
// Timeline line enhancement
:deep(.v-timeline-divider__dot)
box-shadow: 0 0 0 4px rgba(255, 20, 225, 0.2)
</style>

View File

@@ -0,0 +1,321 @@
<template>
<v-container
id="hero"
class="hero-container px-6 px-sm-10 py-16 py-md-24"
data-nav-section="hero"
fluid
>
<v-row align="center" class="hero-sheet" justify="center">
<v-col v-reveal class="hero-text" cols="12" md="7">
<header class="hero-heading">
<p class="text-caption text-uppercase text-medium-emphasis mb-2">
{{ intro.title }} · {{ intro.location }}
</p>
<h1 class="hero-title text-h3 text-md-h2">
{{ intro.name }}
</h1>
<h2 class="hero-subtitle text-h4 text-md-h3">
{{ intro.valueLead }}
<template v-if="emphasisWords.length">
<span class="accent-text">{{ emphasisWords[0] }}</span>
<template v-if="emphasisWords.length > 1">
&nbsp;and&nbsp;
<span class="accent-text">{{ emphasisWords[1] }}</span>
</template>
</template>
{{ intro.valueTail }}
</h2>
</header>
<p class="text-body-1 text-medium-emphasis hero-narrative">
{{ intro.narrative }}
</p>
<ul v-reveal="40" class="hero-specialties">
<li v-for="specialty in intro.specialties" :key="specialty">
<v-icon icon="mdi-checkbox-blank-circle" size="10" />
<span>{{ specialty }}</span>
</li>
</ul>
<div
v-reveal="80"
class="hero-cta d-flex flex-column flex-sm-row align-center"
>
<v-btn
:to="intro.primaryAction.to"
class="text-none text-body-2 font-weight-bold accent-btn"
color="primary"
min-height="48"
rounded="pill"
size="large"
>
{{ intro.primaryAction.label }}
</v-btn>
<v-btn
:to="intro.secondaryAction.to"
class="text-accent font-weight-bold accent-outline"
color="surface"
min-height="48"
rounded="pill"
size="large"
variant="outlined"
>
{{ intro.secondaryAction.label }}
</v-btn>
</div>
<div
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="d-flex align-center gap-3">
<v-btn
v-for="social in socials"
:key="social.label"
:aria-label="`Open ${social.label}`"
:href="social.url"
class="text-none hero-social"
color="primary"
icon
rel="noopener"
rounded
size="large"
target="_blank"
>
<v-icon :icon="social.icon" />
</v-btn>
</div>
</div>
</v-col>
<v-col
v-reveal="120"
class="d-flex justify-center hero-image-col"
cols="12"
md="5"
>
<div class="hero-image-frame">
<picture class="hero-picture">
<source
media="(min-width: 1280px)"
srcset="
https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1400&q=80
"
/>
<source
media="(min-width: 768px)"
srcset="
https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=960&q=80
"
/>
<img
alt="workspace desk"
class="hero-image"
decoding="async"
fetchpriority="high"
loading="eager"
src="https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=640&q=80"
/>
</picture>
</div>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts" setup>
import { computed, toRefs } from 'vue'
import type { IntroContent, SocialLink } from '@/services/portfolio'
interface HeroSectionProps {
readonly intro: IntroContent
readonly socials: readonly SocialLink[]
}
const props = defineProps<HeroSectionProps>()
const { intro, socials } = toRefs(props)
const emphasisWords = computed(
() => intro.value.valueEmphasis?.filter(Boolean) ?? []
)
</script>
<style lang="sass" scoped>
.hero-container
position: relative
max-width: 1200px
margin-inline: auto
.hero-sheet
position: relative
overflow: hidden
border-radius: 28px
padding: clamp(2.75rem, 5vw, 5rem)
border: 1px solid rgba(255, 255, 255, 0.06)
background: linear-gradient(150deg, rgba(22, 23, 31, 0.92) 0%, rgba(19, 22, 32, 0.92) 55%, rgba(30, 25, 45, 0.88) 100%)
box-shadow: 0 32px 120px rgba(8, 10, 20, 0.45)
.hero-sheet::before
content: ''
position: absolute
inset: 0
background: radial-gradient(circle at top left, rgba(255, 20, 225, 0.25), transparent 55%), radial-gradient(circle at bottom right, rgba(20, 67, 255, 0.18), transparent 55%), radial-gradient(circle at top right, rgba(255, 121, 198, 0.15), transparent 45%)
opacity: 0.9
pointer-events: none
.hero-sheet > *
position: relative
z-index: 1
.hero-text
display: flex
flex-direction: column
gap: clamp(1.75rem, 4vw, 2.75rem)
text-align: center
align-items: center
justify-content: center
padding-inline: 0.25rem
@media (min-width: 960px)
.hero-text
text-align: left
align-items: flex-start
padding-inline: 0
.hero-heading
display: flex
flex-direction: column
gap: 0.75rem
.hero-title
font-weight: 800
letter-spacing: -0.02em
font-size: clamp(2.25rem, 6vw, 3.5rem)
.hero-subtitle
font-weight: 600
line-height: var(--line-height-tight)
max-width: 20ch
.hero-subtitle .accent-text
background: var(--portfolio-accent-gradient)
-webkit-background-clip: text
color: transparent
.hero-narrative
max-width: 44ch
margin: 0 auto
text-align: center
line-height: 1.75
@media (min-width: 960px)
.hero-narrative
margin-left: 0
text-align: left
.hero-specialties
display: grid
gap: 0.75rem
padding: 0
margin: 0
list-style: none
.hero-specialties li
display: inline-flex
align-items: center
gap: 0.65rem
font-size: var(--font-size-sm)
color: var(--color-text-muted)
.hero-cta
gap: var(--space-2)
width: 100%
.hero-cta .v-btn
width: 100%
min-width: 0
padding-inline: 1.75rem
min-height: 52px
.hero-social-row
margin-top: var(--space-3)
gap: var(--space-2)
justify-content: center
width: 100%
@media (min-width: 960px)
.hero-social-row
justify-content: flex-start
.hero-social-row > .text-caption
min-width: max-content
.hero-social-row .gap-3
gap: 1.25rem !important
.hero-image-col
align-items: center
padding-top: clamp(0rem, 5vw, 2rem)
.hero-image-frame
position: relative
width: min(100%, 380px)
@media (min-width: 1280px)
.hero-image-frame
width: 420px
.hero-picture
display: block
width: 100%
height: 100%
.hero-image-frame::after
content: ''
position: absolute
inset: 22px -18px -18px 18px
border-radius: 28px
border: 1px solid rgba(255, 20, 225, 0.45)
z-index: 0
.hero-image
border-radius: 28px
width: 100%
height: clamp(280px, 45vw, 420px)
box-shadow: 0 18px 54px rgba(20, 67, 255, 0.28)
object-fit: cover
position: relative
z-index: 1
@media (min-width: 600px)
.hero-cta
flex-direction: row
align-items: center
width: auto
.hero-cta .v-btn
width: auto
@media (min-width: 768px)
.hero-specialties
grid-template-columns: repeat(2, minmax(0, 1fr))
@media (min-width: 1024px)
.hero-specialties
grid-template-columns: repeat(3, minmax(0, 1fr))
.hero-social
width: 48px !important
height: 48px !important
transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease
.hero-social:hover,
.hero-social:focus-visible
transform: translateY(-2px)
background: rgba(255, 20, 225, 0.25) !important
box-shadow: 0 10px 20px rgba(255, 20, 225, 0.2)
</style>

View File

@@ -0,0 +1,310 @@
<template>
<v-card
:to="`/projects/${project.slug}`"
class="portfolio-card h-100 d-flex flex-column"
elevation="0"
rounded="xl"
variant="elevated"
>
<div class="project-card__media">
<v-img
:alt="project.name"
:src="project.heroImage"
aspect-ratio="16/9"
class="project-card__image"
loading="lazy"
:lazy-src="`${project.heroImage}&w=32&auto=format&blur=50`"
cover
/>
<div class="project-card__overlay">
<span class="project-card__label text-caption">
{{ project.tagline }}
</span>
<div v-if="project.featured" class="project-card__featured-badge">
<v-icon icon="mdi-star" size="12" />
<span>Featured</span>
</div>
</div>
</div>
<v-card-text class="py-6 px-6 flex-grow-1 d-flex flex-column gap-5">
<header class="project-card__header">
<div class="d-flex align-center justify-space-between mb-2">
<h3 class="text-h5 font-weight-bold project-card__title">
{{ project.name }}
</h3>
<span
class="text-caption text-medium-emphasis project-card__timeframe"
>
{{ project.timeframe }}
</span>
</div>
<p class="text-body-2 text-medium-emphasis mb-0 project-card__summary">
{{ project.summary }}
</p>
</header>
<div class="project-card__metrics-section">
<h4
class="text-caption text-uppercase text-medium-emphasis mb-3 font-weight-bold tracking-wider"
>
Key Impact
</h4>
<ul class="project-card__metrics">
<li v-for="metric in project.metrics.slice(0, 3)" :key="metric">
<v-icon icon="mdi-trending-up" size="14" />
<span>{{ metric }}</span>
</li>
</ul>
</div>
<div class="project-card__tech-section">
<h4
class="text-caption text-uppercase text-medium-emphasis mb-3 font-weight-bold tracking-wider"
>
Tech Stack
</h4>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="tech in project.tech.slice(0, 4)"
:key="tech"
class="badge-chip"
color="primary"
density="comfortable"
label
variant="flat"
size="small"
>
{{ tech }}
</v-chip>
<v-chip
v-if="project.tech.length > 4"
class="badge-chip"
color="surface"
density="comfortable"
label
variant="outlined"
size="small"
>
+{{ project.tech.length - 4 }} more
</v-chip>
</div>
</div>
</v-card-text>
<v-card-actions class="px-6 pb-6 pt-0 project-card__actions">
<v-btn
v-for="link in primaryLinks"
:key="link.url"
class="text-none font-weight-bold accent-hover project-card__link"
color="primary"
rounded="pill"
size="large"
variant="flat"
:href="link.url"
target="_blank"
rel="noopener"
@click.prevent.stop="openLink(link.url)"
>
<v-icon :icon="getLinkIcon(link.type)" size="16" class="me-2" />
{{ link.label }}
</v-btn>
</v-card-actions>
</v-card>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import type { Project, ProjectLink } from '@/services/portfolio'
interface ProjectCardProps {
readonly project: Project
}
const props = defineProps<ProjectCardProps>()
const preferredOrder: ProjectLink['type'][] = ['demo', 'case-study', 'code']
const primaryLinks = computed(() => {
const ordered = [...props.project.links].sort((a, b) => {
const aIndex = preferredOrder.indexOf(a.type ?? 'case-study')
const bIndex = preferredOrder.indexOf(b.type ?? 'case-study')
return (
(aIndex === -1 ? preferredOrder.length : aIndex) -
(bIndex === -1 ? preferredOrder.length : bIndex)
)
})
return ordered.slice(0, 2)
})
function getLinkIcon(type: ProjectLink['type']) {
switch (type) {
case 'demo':
return 'mdi-open-in-new'
case 'code':
return 'mdi-github'
case 'case-study':
return 'mdi-file-document-outline'
default:
return 'mdi-link'
}
}
function openLink(url: string) {
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>
<style scoped lang="sass">
.portfolio-card
background: rgba(34, 35, 40, 0.85)
border: 1px solid rgba(255, 255, 255, 0.08)
backdrop-filter: blur(12px)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
overflow: hidden
box-shadow: 0 20px 40px rgba(8, 10, 20, 0.3)
.portfolio-card::before
content: ''
position: absolute
inset: 0
background: radial-gradient(circle at top right, rgba(255, 20, 225, 0.1), transparent 50%), radial-gradient(circle at bottom left, rgba(20, 67, 255, 0.08), transparent 50%)
opacity: 0
transition: opacity 0.3s ease
pointer-events: none
z-index: 0
.portfolio-card:hover::before
opacity: 1
.portfolio-card:hover
transform: translateY(-12px) scale(1.02)
box-shadow: 0 40px 80px rgba(255, 20, 225, 0.2), 0 20px 40px rgba(8, 10, 20, 0.4)
border-color: rgba(255, 255, 255, 0.15)
.portfolio-card > *
position: relative
z-index: 1
.project-card__media
position: relative
overflow: hidden
.project-card__image
border-top-left-radius: inherit
border-top-right-radius: inherit
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)
.portfolio-card:hover .project-card__image
transform: scale(1.05)
.project-card__overlay
position: absolute
inset: 0
background: linear-gradient(180deg, rgba(9, 10, 15, 0) 0%, rgba(9, 10, 15, 0.75) 100%)
display: flex
align-items: flex-end
justify-content: space-between
padding: 16px
z-index: 2
.project-card__label
padding: 6px 14px
border-radius: 999px
background-color: rgba(17, 17, 17, 0.75)
backdrop-filter: blur(10px)
text-transform: uppercase
letter-spacing: 1px
border: 1px solid rgba(255, 255, 255, 0.1)
.project-card__featured-badge
display: flex
align-items: center
gap: 4px
padding: 4px 10px
border-radius: 999px
background: linear-gradient(135deg, var(--portfolio-accent), var(--portfolio-accent-alt))
color: white
font-size: 11px
font-weight: 600
text-transform: uppercase
letter-spacing: 0.5px
box-shadow: 0 4px 12px rgba(255, 20, 225, 0.3)
.project-card__header
padding-bottom: 4px
.project-card__title
font-size: 1.35rem
line-height: 1.3
color: #ffffff
.project-card__timeframe
color: var(--portfolio-accent)
font-weight: 600
letter-spacing: 0.5px
.project-card__summary
line-height: 1.6
margin-top: 8px
.project-card__metrics-section,
.project-card__tech-section
h4
color: var(--color-text-muted)
margin-bottom: 12px !important
.project-card__metrics
display: grid
gap: 8px
list-style: none
margin: 0
padding: 0
color: var(--color-text-muted)
font-size: 0.85rem
.project-card__metrics li
display: flex
gap: 8px
align-items: center
padding: 4px 0
.project-card__metrics .v-icon
color: var(--portfolio-accent)
.project-card__actions
gap: 12px
flex-wrap: wrap
.project-card__actions .v-btn
min-width: 0
transition: all 0.2s ease
.project-card__link
padding-inline: 20px
min-height: 44px
font-size: 0.9rem
.project-card__link:hover
transform: translateY(-2px)
box-shadow: 0 8px 20px rgba(124, 92, 255, 0.3)
.tracking-wider
letter-spacing: 0.1em
@media (max-width: 599px)
.project-card__link
width: 100%
justify-content: center
.project-card__overlay
padding: 12px
.project-card__header .d-flex
flex-direction: column
align-items: flex-start
.project-card__timeframe
margin-top: 4px
</style>

View File

@@ -1,58 +0,0 @@
import { computed } from 'vue'
import {
componentCatalog,
createComponentTopicMap,
getComponentTopics,
} from '@/services/componentCatalog'
import {
componentNavigation,
groupNavigationByTopic,
type NavItem,
} from '@/services/navigation'
export interface ComponentSection<T> {
readonly topic: string
readonly label: string
readonly items: readonly T[]
}
const toTitleCase = (value: string) =>
value
.split(/[-_\s]/)
.filter(Boolean)
.map(segment => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ')
export function useComponentCatalog() {
const topics = computed(() => getComponentTopics())
const groupedComponents = computed<
ComponentSection<(typeof componentCatalog)[number]>[]
>(() => {
const map = createComponentTopicMap()
return Array.from(map.entries()).map(([topic, items]) => ({
topic,
label: toTitleCase(topic),
items,
}))
})
const navigationSections = computed<ComponentSection<NavItem>[]>(() => {
const grouped = groupNavigationByTopic(componentNavigation)
return Object.entries(grouped).map(([topic, items]) => ({
topic,
label: toTitleCase(topic === 'overview' ? 'all components' : topic),
items,
}))
})
return {
catalog: computed(() => componentCatalog),
topics,
groupedComponents,
navigationSections,
}
}

View File

@@ -0,0 +1,36 @@
import {
contactChannels,
experience,
getFeaturedProjects,
introContent,
projects,
socialLinks,
type Experience as ExperienceEntry,
type Project,
type ContactChannel,
type SocialLink,
} from '@/services/portfolio'
interface PortfolioData {
readonly intro: typeof introContent
readonly projects: readonly Project[]
readonly featuredProjects: readonly Project[]
readonly timeline: readonly ExperienceEntry[]
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]))
return {
intro: introContent,
projects,
featuredProjects: getFeaturedProjects(),
timeline: experience,
contact: contactChannels,
socials: socialLinks,
findProject: (slug: string) => projectMap.get(slug),
}
}

84
src/directives/reveal.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { DirectiveBinding, ObjectDirective } from 'vue'
interface RevealBindingValue {
readonly delay?: number
}
type RevealBinding = number | RevealBindingValue | undefined
const getDelay = (binding: RevealBinding): number => {
if (typeof binding === 'number') {
return binding
}
if (binding && typeof binding === 'object' && 'delay' in binding) {
const value = Number(binding.delay)
return Number.isNaN(value) ? 0 : value
}
return 0
}
const observedDelays = new WeakMap<Element, number>()
const createObserver = () => {
const instance = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) {
return
}
const target = entry.target as HTMLElement
const delay = observedDelays.get(target)
if (typeof delay === 'number') {
target.style.setProperty('--reveal-delay', `${delay}ms`)
setTimeout(() => {
target.classList.add('reveal--visible')
}, delay)
} else {
target.classList.add('reveal--visible')
}
instance.unobserve(target)
})
},
{
threshold: 0.15,
rootMargin: '0px 0px -5% 0px',
}
)
return instance
}
const observer =
typeof window !== 'undefined' && 'IntersectionObserver' in window
? createObserver()
: null
const revealDirective: ObjectDirective<HTMLElement, RevealBinding> = {
mounted(el: HTMLElement, binding: DirectiveBinding<RevealBinding>) {
const delay = getDelay(binding.value)
observedDelays.set(el, delay)
el.classList.add('reveal')
if (!observer) {
requestAnimationFrame(() => {
el.classList.add('reveal--visible')
})
return
}
observer.observe(el)
},
unmounted(el: HTMLElement) {
observedDelays.delete(el)
observer?.unobserve(el)
},
}
export default revealDirective

View File

@@ -1,75 +0,0 @@
<template>
<v-app>
<Nav />
<v-navigation-drawer
color="grey-darken-4"
name="components-nav"
permanent
width="300"
>
<div class="d-flex justify-start h-100">
<v-list id="components-nav" class="ms-4" width="100%">
<template v-if="overviewSection">
<v-list-item
v-for="route in overviewSection.items"
:key="route.path"
>
<RouterLink
class="text-white text-decoration-none font-weight-black components-nav-link app-link"
:to="{ path: route.path }"
>
{{ route.label }}
</RouterLink>
</v-list-item>
</template>
<template v-for="section in componentSections" :key="section.topic">
<h4 class="text-uppercase ms-4 components-nav-heading">
{{ section.label }}
</h4>
<v-list-item v-for="route in section.items" :key="route.path">
<RouterLink
class="text-white text-decoration-none font-weight-black components-nav-link app-link"
:to="{ path: route.path }"
>
{{ route.label }}
</RouterLink>
</v-list-item>
</template>
<v-divider class="ms-n4" />
</v-list>
</div>
</v-navigation-drawer>
<v-main>
<router-view />
</v-main>
<Footer />
</v-app>
</template>
<script lang="ts" setup>
import { useComponentCatalog } from '@/composables/useComponentCatalog'
const { navigationSections } = useComponentCatalog()
const overviewSection = computed(() =>
navigationSections.value.find(section => section.topic === 'overview')
)
const componentSections = computed(() =>
navigationSections.value.filter(section => section.topic !== 'overview')
)
</script>
<style lang="sass" scoped>
.components-nav-link
font-size: 1rem !important
letter-spacing: 1px
.components-nav-heading
font-size: 0.8rem
</style>

View File

@@ -2,7 +2,7 @@
<v-app>
<Nav />
<v-main>
<v-main id="main-content">
<router-view />
</v-main>

View File

@@ -1,66 +0,0 @@
<route lang="json">
{
"meta": {
"layout": "components"
}
}
</route>
<template>
<v-container class="pa-12">
<v-row
v-for="(section, index) in groupedComponents"
:key="section.topic"
class="d-flex flex-column"
>
<h2 class="mb-4 text-uppercase">
{{ section.label }}
</h2>
<v-col class="d-flex flex-wrap components-grid">
<v-hover v-for="component in section.items" :key="component.path">
<template #default="{ isHovering, props }">
<v-card
:class="['component-card', { 'hover-effect': isHovering }]"
rounded="shaped"
:to="component.path"
v-bind="props"
variant="outlined"
width="300"
>
<v-card-title>
{{ component.name }}
</v-card-title>
<v-divider />
<v-card-text>
{{ component.description }}
</v-card-text>
</v-card>
</template>
</v-hover>
</v-col>
<v-divider v-if="index < groupedComponents.length - 1" class="my-6" />
</v-row>
</v-container>
</template>
<script lang="ts" setup>
import { useComponentCatalog } from '@/composables/useComponentCatalog'
const { groupedComponents } = useComponentCatalog()
</script>
<style lang="sass" scoped>
.components-grid
gap: 2rem
.component-card
aspect-ratio: 3 / 2
border: 2px solid rgba(255, 255, 255, 0.7)
.hover-effect
box-shadow: 0 0 4px cyan, 0 0 8px cyan, 0 0 12px cyan, 0 0 16px cyan, 0 0 20px cyan
transition: box-shadow 0.1s ease
</style>

View File

@@ -1,81 +0,0 @@
<route lang="json">
{
"meta": {
"layout": "components"
}
}
</route>
<template>
<v-container>
<v-row>
<v-col>
<h5 class="text-h5">Testing Markup Component</h5>
</v-col>
</v-row>
<v-row>
<v-col>
<h5 class="text-h5">Beispiel</h5>
<!-- eslint-disable vue/no-v-html -->
<div class="text-error" v-html="htmlCode" />
<!-- eslint-enable vue/no-v-html -->
</v-col>
</v-row>
<v-row v-for="sample in codeSamples" :key="sample.language">
<v-col>
<h6 class="text-h6">
{{ sample.heading }}
</h6>
<Markup :code="sample.code" :language="sample.language" />
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts" setup>
const Markup = defineAsyncComponent(() => import('@/components/app/Markup.vue'))
// htmlCode is rendered directly for demo purposes and remains static
const htmlCode = `<h2 class="header-1">
Hello world!
</h2>`
interface CodeSample {
readonly heading: string
readonly language: string
readonly code: string
}
const codeSamples: readonly CodeSample[] = [
{ heading: 'HTML Code', language: 'html', code: htmlCode },
{
heading: 'TypeScript Code',
language: 'ts',
code: `console.log('hello world!')`,
},
{
heading: 'CSS Code',
language: 'css',
code: `.header-1 {
color: red;
}`,
},
{
heading: 'SCSS Code',
language: 'scss',
code: `.header-1 {
color: red;
}`,
},
{
heading: 'SASS Code',
language: 'sass',
code: `.header-1
color: red`,
},
]
</script>
<style lang="sass" scoped></style>

286
src/pages/contact/index.vue Normal file
View File

@@ -0,0 +1,286 @@
<route lang="json">
{
"meta": {
"layout": "default",
"title": "Contact"
}
}
</route>
<template>
<div class="contact-page">
<div class="contact-content">
<header v-reveal class="contact-hero text-center mb-16">
<p
class="text-caption text-uppercase text-medium-emphasis mb-2 font-weight-bold tracking-widest"
>
Contact
</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?
</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.
</p>
</header>
<div v-reveal="80" class="contact-stats mb-12">
<v-row justify="center">
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">&lt;24h</div>
<div class="stat-label">Response Time</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>
</v-col>
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">3</div>
<div class="stat-label">Time Zones</div>
</div>
</v-col>
</v-row>
</div>
<div v-reveal="160" class="contact-grid-container mb-16">
<ContactGrid :channels="portfolio.contact" />
</div>
<div v-reveal="240" class="contact-cta-section">
<v-card class="cta-card pa-8" rounded="2xl" variant="outlined">
<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?
</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.
</p>
</v-col>
<v-col cols="12" md="4" class="text-center text-md-end">
<v-btn
class="text-none font-weight-bold cta-button"
color="primary"
href="mailto:hello@nachtigall.dev"
rounded="pill"
size="large"
>
Start a conversation
<v-icon icon="mdi-send" size="18" class="ms-2" />
</v-btn>
</v-col>
</v-row>
</v-card>
</div>
<div v-reveal="320" class="contact-additional mt-16">
<v-row justify="center">
<v-col cols="12" md="8">
<div class="additional-info">
<h3 class="text-h5 font-weight-bold mb-4 text-center">
Collaboration Details
</h3>
<v-row>
<v-col cols="12" sm="6">
<div class="info-card">
<v-icon
icon="mdi-clock-outline"
size="24"
class="info-icon mb-3"
/>
<h4 class="info-title">Availability</h4>
<p class="info-text">
Currently accepting new projects. Best availability for
calls: 9 AM - 6 PM CET
</p>
</div>
</v-col>
<v-col cols="12" sm="6">
<div class="info-card">
<v-icon
icon="mdi-laptop"
size="24"
class="info-icon mb-3"
/>
<h4 class="info-title">Work Style</h4>
<p class="info-text">
Collaborative remote work with regular check-ins.
Comfortable with async communication and pair programming.
</p>
</div>
</v-col>
</v-row>
</div>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import ContactGrid from '@/components/portfolio/ContactGrid.vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
</script>
<style scoped lang="sass">
.contact-page
min-height: 100vh
background: linear-gradient(135deg, rgba(15, 16, 21, 0.95), rgba(24, 25, 32, 0.9))
.contact-content
max-width: 1200px
margin: 0 auto
padding: 64px 32px 64px
.contact-hero
max-width: 800px
margin: 0 auto
.contact-title
font-size: clamp(2.5rem, 5vw, 4rem)
line-height: 1.1
letter-spacing: -0.02em
.contact-subtitle
max-width: 600px
line-height: 1.6
font-weight: 400
.accent-text
background: var(--portfolio-accent-gradient)
-webkit-background-clip: text
color: transparent
.tracking-widest
letter-spacing: 0.2em
.contact-stats
.stat-item
text-align: center
padding: 0 24px
.stat-number
font-size: 2.5rem
font-weight: 800
background: var(--portfolio-accent-gradient)
-webkit-background-clip: text
color: transparent
line-height: 1
.stat-label
font-size: 0.875rem
text-transform: uppercase
letter-spacing: 0.1em
color: var(--color-text-muted)
margin-top: 4px
font-weight: 600
.contact-grid-container
max-width: 900px
margin: 0 auto
.contact-cta-section
max-width: 1000px
margin: 0 auto
.cta-card
background: rgba(34, 35, 40, 0.85)
border: 1px solid rgba(255, 255, 255, 0.08)
backdrop-filter: blur(12px)
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1)
overflow: hidden
.cta-card:hover
transform: translateY(-2px)
box-shadow: 0 20px 40px rgba(255, 20, 225, 0.15), 0 8px 32px rgba(8, 10, 20, 0.3)
border-color: rgba(255, 255, 255, 0.12)
.cta-button
background: var(--portfolio-accent-gradient) !important
color: white !important
min-height: 56px
padding-inline: 2rem
transition: all 0.3s ease
.cta-button:hover
transform: translateY(-2px)
box-shadow: 0 12px 24px rgba(255, 20, 225, 0.3)
.contact-additional
max-width: 900px
margin: 0 auto
.additional-info
text-align: center
.info-card
text-align: center
padding: 2rem 1.5rem
background: rgba(255, 255, 255, 0.02)
border: 1px solid rgba(255, 255, 255, 0.05)
border-radius: 16px
transition: all 0.3s ease
height: 100%
.info-card:hover
background: rgba(255, 255, 255, 0.04)
border-color: rgba(255, 20, 225, 0.2)
transform: translateY(-2px)
.info-icon
color: var(--portfolio-accent)
.info-title
font-size: 1.1rem
font-weight: 700
color: #ffffff
margin-bottom: 0.75rem
.info-text
font-size: 0.9rem
color: var(--color-text-muted)
line-height: 1.6
margin: 0
// Responsive Design
@media (max-width: 959px)
.contact-stats
.stat-item
padding: 0 16px
.stat-number
font-size: 2rem
@media (max-width: 767px)
.contact-content
padding: 64px 20px 64px
.contact-stats .v-row
gap: 1rem
.info-card
padding: 1.5rem 1rem
@media (max-width: 599px)
.contact-content
padding: 64px 16px 64px
.contact-stats .v-row
gap: 0.75rem
.additional-info .v-row
gap: 1rem
</style>

View File

@@ -0,0 +1,200 @@
<route lang="json">
{
"meta": {
"layout": "default",
"title": "Experience"
}
}
</route>
<template>
<div class="experience-page">
<v-container class="py-16 py-md-20">
<header class="mb-12 section-heading text-center">
<p
class="text-caption text-uppercase text-medium-emphasis mb-2 font-weight-bold tracking-widest"
>
Experience
</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
</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.
</p>
</header>
<div class="experience-stats mb-10">
<v-row justify="center">
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">{{ portfolio.timeline.length }}+</div>
<div class="stat-label">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>
</v-col>
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">
{{ uniqueTechCount }}
</div>
<div class="stat-label">Technologies</div>
</div>
</v-col>
</v-row>
</div>
<div class="timeline-container">
<ExperienceTimeline :entries="portfolio.timeline" />
</div>
<!-- Call to Action -->
<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?
</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.
</p>
<v-btn
class="text-none font-weight-bold cta-button"
color="primary"
rounded="pill"
size="large"
to="/contact"
>
Let's Connect
<v-icon icon="mdi-arrow-right" size="18" class="ms-2" />
</v-btn>
</v-card>
</div>
</v-container>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import ExperienceTimeline from '@/components/portfolio/ExperienceTimeline.vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const yearsOfExperience = computed(() => {
const currentYear = new Date().getFullYear()
const startYear = 2019 // Based on the experience data
return currentYear - startYear
})
const uniqueTechCount = computed(() => {
const allTech = new Set()
portfolio.timeline.forEach(entry => {
entry.tech.forEach(tech => allTech.add(tech))
})
return allTech.size
})
</script>
<style scoped lang="sass">
.experience-page
min-height: 100vh
background: linear-gradient(135deg, rgba(15, 16, 21, 0.95), rgba(24, 25, 32, 0.9))
.section-heading
max-width: 800px
margin: 0 auto
.experience-title
font-size: clamp(2.5rem, 5vw, 4rem)
line-height: 1.1
letter-spacing: -0.02em
.experience-subtitle
max-width: 600px
line-height: 1.6
font-weight: 400
.tracking-widest
letter-spacing: 0.2em
.experience-stats
.stat-item
text-align: center
padding: 0 24px
.stat-number
font-size: 2.5rem
font-weight: 800
background: var(--portfolio-accent-gradient)
-webkit-background-clip: text
color: transparent
line-height: 1
.stat-label
font-size: 0.875rem
text-transform: uppercase
letter-spacing: 0.1em
color: var(--color-text-muted)
margin-top: 4px
font-weight: 600
.timeline-container
max-width: 1000px
margin: 0 auto
.experience-cta
max-width: 600px
margin: 0 auto
.cta-card
background: rgba(24, 25, 32, 0.8)
border: 1px solid rgba(255, 255, 255, 0.1)
backdrop-filter: blur(20px)
box-shadow: 0 20px 40px rgba(8, 10, 20, 0.3)
position: relative
overflow: hidden
.cta-card::before
content: ''
position: absolute
inset: 0
background: radial-gradient(circle at top right, rgba(255, 20, 225, 0.1), transparent 50%), radial-gradient(circle at bottom left, rgba(20, 67, 255, 0.08), transparent 50%)
opacity: 1
pointer-events: none
.cta-card > *
position: relative
z-index: 1
.cta-button
background: var(--portfolio-accent-gradient) !important
color: white !important
box-shadow: 0 8px 20px rgba(255, 20, 225, 0.3)
transition: all 0.3s ease
padding-inline: 32px !important
min-height: 48px
.cta-button:hover
transform: translateY(-2px)
box-shadow: 0 12px 28px rgba(255, 20, 225, 0.4)
@media (max-width: 959px)
.experience-stats
.stat-item
padding: 0 16px
.stat-number
font-size: 2rem
@media (max-width: 599px)
.experience-stats .v-row
gap: 1rem
</style>

View File

@@ -7,9 +7,381 @@
</route>
<template>
<h4>welcome to nachtigall.dev</h4>
<HeroSection :intro="portfolio.intro" :socials="portfolio.socials" />
<v-container class="page-container px-6 px-sm-10 py-20 py-md-24">
<section
id="projects"
v-reveal
data-nav-section="projects"
class="section-spacing mb-16"
>
<div class="section-heading mb-8">
<div class="section-header">
<p class="text-caption text-uppercase text-medium-emphasis mb-1">
Selected Work
</p>
<h2 class="text-h4 font-weight-bold mb-3">Featured projects</h2>
</div>
<v-btn
class="text-none font-weight-bold accent-hover"
color="primary"
rounded="pill"
size="small"
variant="tonal"
to="/projects"
>
View all
</v-btn>
</div>
<v-row class="projects-row">
<v-col
v-for="(project, index) in portfolio.featuredProjects.slice(0, 2)"
:key="project.slug"
v-reveal="index * 80"
cols="12"
sm="6"
class="project-col d-flex"
>
<RouterLink :to="`/projects/${project.slug}`" class="project-link">
<ProjectCard :project="project" />
</RouterLink>
</v-col>
</v-row>
</section>
<section
id="experience"
v-reveal="160"
data-nav-section="experience"
class="section-spacing mb-16"
>
<div class="section-heading mb-8">
<div class="section-header">
<p class="text-caption text-uppercase text-medium-emphasis mb-1">
Experience
</p>
<h2 class="text-h4 font-weight-bold mb-3">Recent positions</h2>
</div>
<v-btn
class="text-none font-weight-bold accent-hover"
color="primary"
rounded="pill"
size="small"
variant="tonal"
to="/experience"
>
View timeline
</v-btn>
</div>
<v-row class="experience-row">
<v-col
v-for="(entry, index) in portfolio.timeline.slice(0, 2)"
:key="entry.company"
v-reveal="240 + index * 80"
cols="12"
sm="6"
class="experience-col d-flex"
>
<div class="experience-card">
<div class="experience-card-content">
<div class="experience-header">
<h3 class="experience-role">
{{ entry.role }}
</h3>
<span class="experience-company">{{ entry.company }}</span>
<span class="experience-period">{{ entry.timeframe }}</span>
</div>
<p class="experience-description">
{{ entry.description }}
</p>
<div class="experience-achievement">
<v-icon
icon="mdi-trending-up"
size="14"
class="achievement-icon"
/>
<span>{{ entry.achievements[0] }}</span>
</div>
</div>
</div>
</v-col>
</v-row>
</section>
<section
id="contact"
v-reveal
data-nav-section="contact"
class="callout section-callout py-14 px-8 px-sm-16 rounded-2xl ring-1 ring-white/15"
>
<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?
</h2>
<p class="text-body-1 text-white text-opacity-75 mb-0">
Lets collaborate on a product that feels fast, inclusive, and
effortless to maintain.
</p>
</v-col>
<v-col cols="12" md="4" class="text-center text-md-end">
<v-btn
class="text-none font-weight-bold callout-btn"
color="white"
rounded="pill"
size="large"
to="/contact"
>
Get in touch
</v-btn>
</v-col>
</v-row>
</section>
</v-container>
</template>
<script lang="ts" setup></script>
<script lang="ts" setup>
import HeroSection from '@/components/portfolio/HeroSection.vue'
import ProjectCard from '@/components/portfolio/ProjectCard.vue'
import { usePortfolio } from '@/composables/usePortfolio'
<style lang="sass" scoped></style>
const portfolio = usePortfolio()
</script>
<style scoped lang="sass">
ul
padding-left: 1.1rem
line-height: 1.6
list-style: none
ul li
position: relative
padding-left: 0.75rem
ul li::before
content: ''
position: absolute
left: 0
top: 0.65em
width: 6px
height: 6px
border-radius: 50%
background: var(--portfolio-accent)
.page-container
max-width: 1200px
margin-inline: auto
.section-block
position: relative
padding: clamp(2.75rem, 4vw, 3.75rem)
border-radius: 32px
background: rgba(24, 25, 32, 0.7)
border: 1px solid rgba(255, 255, 255, 0.05)
box-shadow: 0 24px 80px rgba(8, 10, 20, 0.42)
overflow: hidden
.section-block::before
content: ''
position: absolute
inset: -20% -35%
background: radial-gradient(circle at top left, rgba(255, 20, 225, 0.18), transparent 60%), radial-gradient(circle at bottom right, rgba(20, 67, 255, 0.16), transparent 60%)
opacity: 1
pointer-events: none
.section-block > *
position: relative
z-index: 1
.section-heading
align-items: flex-start
flex-wrap: wrap
gap: var(--space-2)
.section-heading > div:last-child
text-align: left
@media (min-width: 960px)
.section-heading
align-items: center
flex-wrap: nowrap
.spacious-row
gap: clamp(1.75rem, 4vw, 2.75rem)
.section-callout
border: 1px solid rgba(255, 255, 255, 0.08)
box-shadow: 0 24px 80px rgba(8, 10, 20, 0.48)
.section-callout::after
content: ''
position: absolute
inset: auto 35% -28% -18%
height: 240px
background: radial-gradient(circle, rgba(255, 255, 255, 0.2), transparent 70%)
opacity: 0.45
pointer-events: none
.section-callout .v-btn
padding-inline: 2.25rem
min-height: 56px
@media (min-width: 1280px)
.section-block
padding-inline: 4.5rem
.section-callout
padding-inline: 5rem
.callout
position: relative
overflow: hidden
background: linear-gradient(120deg, rgba(255, 20, 225, 0.85), rgba(20, 67, 255, 0.9))
.callout::before
content: ''
position: absolute
inset: 0
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0, rgba(255, 255, 255, 0.1) 12px, transparent 12px, transparent 24px)
opacity: 0.18
.callout > *
position: relative
z-index: 1
.callout-btn
color: #111111 !important
background-color: #ffffff !important
transition: transform 0.2s ease, box-shadow 0.2s ease
.callout-btn:hover,
.callout-btn:focus-visible
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.25)
transform: translateY(-2px)
// Section Layout
.section-spacing
margin-bottom: 4rem
.section-heading
display: flex
justify-content: space-between
align-items: flex-start
flex-wrap: wrap
gap: 1rem
.section-header
flex: 1
// Projects Section
.projects-row
margin: 0 -1rem
.project-col
padding: 0 1rem
overflow: hidden
border-radius: 16px
.project-link
text-decoration: none
color: inherit
display: block
width: 100%
height: 100%
overflow: hidden
border-radius: 16px
// Experience Section
.experience-row
margin: 0 -1rem
.experience-col
padding: 0 1rem
.experience-card
width: 100%
height: 100%
overflow: hidden
border-radius: 16px
.experience-card-content
padding: 1.5rem
background: rgba(255, 255, 255, 0.02)
border: 1px solid rgba(255, 255, 255, 0.05)
border-radius: 16px
transition: all 0.3s ease
height: 100%
overflow: hidden
.experience-card-content:hover
background: rgba(255, 255, 255, 0.04)
border-color: rgba(20, 67, 255, 0.2)
transform: translateY(-2px)
.experience-header
margin-bottom: 1rem
.experience-role
font-size: 1.1rem
font-weight: 700
color: #ffffff
margin-bottom: 0.25rem
line-height: 1.3
.experience-company
display: block
font-size: 0.9rem
font-weight: 600
color: var(--portfolio-accent)
margin-bottom: 0.25rem
.experience-period
display: block
font-size: 0.8rem
color: var(--color-text-muted)
text-transform: uppercase
letter-spacing: 0.05em
.experience-description
font-size: 0.9rem
color: var(--color-text-muted)
line-height: 1.5
margin-bottom: 1rem
display: -webkit-box
-webkit-line-clamp: 3
-webkit-box-orient: vertical
overflow: hidden
.experience-achievement
display: flex
align-items: center
gap: 0.5rem
font-size: 0.85rem
color: #e0e0e0
padding: 0.75rem
background: rgba(255, 255, 255, 0.03)
border-radius: 8px
border-left: 3px solid var(--portfolio-accent)
.achievement-icon
color: var(--portfolio-accent)
// Responsive Design
@media (max-width: 767px)
.section-heading
flex-direction: column
align-items: flex-start
.projects-row,
.experience-row
gap: 1rem
.experience-card-content
padding: 1.25rem
.experience-description
-webkit-line-clamp: 2
</style>

View File

@@ -0,0 +1,170 @@
<route lang="json">
{
"meta": {
"layout": "default"
}
}
</route>
<template>
<v-container v-if="project" class="py-16">
<v-row class="mb-10" justify="center">
<v-col cols="12" lg="10">
<v-img
:alt="project.name"
:src="project.heroImage"
aspect-ratio="16/9"
class="rounded-xl mb-6"
fetchpriority="high"
cover
/>
<h1 class="text-h3 font-weight-bold mb-3">
{{ project.name }}
</h1>
<p class="text-body-1 text-medium-emphasis mb-6">
{{ project.summary }}
</p>
<ul class="project-metrics">
<li v-for="metric in project.metrics" :key="metric">
<v-icon icon="mdi-trending-up" size="16" />
<span>{{ metric }}</span>
</li>
</ul>
<div class="d-flex flex-wrap gap-2 mb-8">
<v-chip
v-for="tech in project.tech"
:key="tech"
class="badge-chip"
color="primary"
density="comfortable"
label
variant="flat"
>
{{ tech }}
</v-chip>
</div>
<v-row>
<v-col cols="12" md="7">
<h2 class="text-h5 font-weight-bold mb-3">Role & timeframe</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>
<p class="text-body-1 text-medium-emphasis">
{{ project.description }}
</p>
</v-col>
<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>
<ul class="text-body-2 text-medium-emphasis highlight-list">
<li
v-for="highlight in project.highlights"
:key="highlight.title"
>
<strong class="d-block text-body-1">
{{ highlight.title }}
</strong>
<span>{{ highlight.description }}</span>
</li>
</ul>
<div class="d-flex flex-column gap-3 mt-6">
<v-btn
v-for="link in project.links"
:key="link.url"
:href="link.url"
class="text-none font-weight-bold accent-hover"
color="primary"
rounded="pill"
variant="flat"
target="_blank"
rel="noopener"
>
{{ link.label }}
</v-btn>
</div>
</v-card>
</v-col>
</v-row>
</v-col>
</v-row>
</v-container>
<v-container v-else class="py-16">
<v-row justify="center">
<v-col cols="12" md="8">
<v-card class="pa-10 text-center" rounded="xl" variant="outlined">
<v-icon
class="mb-4"
color="primary"
size="48"
icon="mdi-emoticon-sad-outline"
/>
<h1 class="text-h4 font-weight-bold mb-3">
This project is off exploring new ideas
</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.
</p>
<v-btn
class="text-none font-weight-bold accent-hover"
color="primary"
rounded="pill"
variant="flat"
to="/projects"
>
Back to projects
</v-btn>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router/auto'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const route = useRoute<'/projects/[slug]'>()
const slug = computed(() => route.params.slug)
const project = computed(() => portfolio.findProject(slug.value) ?? null)
</script>
<style scoped lang="sass">
.highlight-list
padding-left: 1.1rem
margin: 0
.highlight-list li
margin-bottom: 1rem
line-height: 1.6
.project-metrics
display: grid
gap: 0.5rem
list-style: none
padding: 0
margin: 0 0 2rem
color: var(--color-text-muted)
font-size: var(--font-size-sm)
.project-metrics li
display: inline-flex
align-items: center
gap: 0.5rem
.project-metrics .v-icon
color: var(--portfolio-accent)
</style>

View File

@@ -0,0 +1,244 @@
<route lang="json">
{
"meta": {
"layout": "default",
"title": "Projects"
}
}
</route>
<template>
<div class="projects-page">
<div class="projects-content">
<header class="mb-12 section-heading text-center">
<p
class="text-caption text-uppercase text-medium-emphasis mb-2 font-weight-bold tracking-widest"
>
Portfolio
</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>
</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.
</p>
</header>
<div class="projects-stats mb-10">
<v-row justify="center">
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">
{{ portfolio.projects.length }}
</div>
<div class="stat-label">Projects</div>
</div>
</v-col>
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">
{{ portfolio.featuredProjects.length }}
</div>
<div class="stat-label">Featured</div>
</div>
</v-col>
<v-col cols="auto">
<div class="stat-item">
<div class="stat-number">
{{ uniqueTechCount }}
</div>
<div class="stat-label">Technologies</div>
</div>
</v-col>
</v-row>
</div>
<div class="projects-filter mb-8">
<div class="d-flex justify-center flex-wrap gap-3">
<v-btn
:class="{ 'filter-active': selectedFilter === 'all' }"
class="text-accent filter-btn"
color="surface"
rounded="pill"
variant="outlined"
@click="selectedFilter = 'all'"
>
All Projects
</v-btn>
<v-btn
:class="{ 'filter-active': selectedFilter === 'featured' }"
class="text-accent filter-btn"
color="surface"
rounded="pill"
variant="outlined"
@click="selectedFilter = 'featured'"
>
<v-icon class="me-2" icon="mdi-star" size="16" />
Featured
</v-btn>
</div>
</div>
<v-row class="projects-grid" justify="center">
<v-col
v-for="(project, index) in filteredProjects"
:key="project.slug"
v-reveal="index * 100"
class="project-col"
cols="12"
lg="4"
md="6"
sm="6"
xl="4"
>
<ProjectCard :project="project" />
</v-col>
</v-row>
<div v-if="filteredProjects.length === 0" class="text-center py-16">
<v-icon
class="text-medium-emphasis mb-4"
icon="mdi-folder-open-outline"
size="64"
/>
<h3 class="text-h5 mb-2">No projects found</h3>
<p class="text-body-1 text-medium-emphasis">
Try adjusting your filter selection.
</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import ProjectCard from '@/components/portfolio/ProjectCard.vue'
import { usePortfolio } from '@/composables/usePortfolio'
const portfolio = usePortfolio()
const selectedFilter = ref<'all' | 'featured'>('all')
const filteredProjects = computed(() => {
if (selectedFilter.value === 'featured') {
return portfolio.featuredProjects
}
return portfolio.projects
})
const uniqueTechCount = computed(() => {
const allTech = new Set()
portfolio.projects.forEach(project => {
project.tech.forEach(tech => allTech.add(tech))
})
return allTech.size
})
</script>
<style lang="sass" scoped>
.projects-page
min-height: 100vh
background: linear-gradient(135deg, rgba(15, 16, 21, 0.95), rgba(24, 25, 32, 0.9))
.projects-content
max-width: 1600px
margin: 0 auto
padding: 64px 32px 32px
.section-heading
max-width: 800px
margin: 0 auto
.projects-title
font-size: clamp(2.5rem, 5vw, 4rem)
line-height: 1.1
letter-spacing: -0.02em
.projects-subtitle
max-width: 600px
line-height: 1.6
font-weight: 400
.tracking-widest
letter-spacing: 0.2em
.projects-stats
.stat-item
text-align: center
padding: 0 24px
.stat-number
font-size: 2.5rem
font-weight: 800
background: var(--portfolio-accent-gradient)
-webkit-background-clip: text
color: transparent
line-height: 1
.stat-label
font-size: 0.875rem
text-transform: uppercase
letter-spacing: 0.1em
color: var(--color-text-muted)
margin-top: 4px
font-weight: 600
.projects-filter
.filter-btn
border-color: rgba(255, 255, 255, 0.15)
color: var(--color-text-muted)
padding-inline: 24px
min-height: 44px
font-weight: 600
transition: all 0.3s ease
.filter-btn:hover,
.filter-btn.filter-active
border-color: var(--portfolio-accent)
color: var(--portfolio-accent)
background: rgba(255, 20, 225, 0.1)
transform: translateY(-2px)
.projects-grid
gap: 2rem
max-width: 1400px
margin: 0 auto
.project-col
display: flex
align-items: stretch
justify-content: center
// Better centering for fewer items
.v-row.projects-grid
justify-content: center
@media (max-width: 959px)
.projects-stats
.stat-item
padding: 0 16px
.stat-number
font-size: 2rem
@media (max-width: 959px)
.projects-content
padding: 64px 20px 32px
.projects-grid
gap: 1.5rem
@media (max-width: 599px)
.projects-content
padding: 64px 16px 32px
.projects-stats .v-row
gap: 1rem
.projects-filter .d-flex
gap: 1rem
.projects-grid
gap: 1rem
</style>

View File

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

View File

@@ -13,9 +13,30 @@ import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
const nightfallTheme = {
dark: true,
colors: {
primary: '#7C5CFF',
secondary: '#1F2027',
accent: '#FF4EC7',
info: '#4CC9F0',
success: '#22C55E',
warning: '#FACC15',
error: '#F97316',
background: '#0F1015',
surface: '#16171F',
'surface-bright': '#1F2027',
'surface-variant': '#242630',
outline: '#3B3C46',
},
}
export default createVuetify({
theme: {
defaultTheme: 'dark',
defaultTheme: 'nightfall',
themes: {
nightfall: nightfallTheme,
},
},
icons: {
defaultSet: 'mdi',

View File

@@ -1,36 +0,0 @@
export type ComponentTopic = 'testing'
export interface ComponentMeta {
readonly name: string
readonly description: string
readonly path: string
readonly topic: ComponentTopic
}
export const componentCatalog: readonly ComponentMeta[] = [
{
name: 'Tester',
description:
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Accusamus, aspernatur dicta earum excepturi maxime officiis placeat provident quos repudiandae totam.',
path: '/components/tester',
topic: 'testing',
},
]
export function getComponentTopics(
catalog: readonly ComponentMeta[] = componentCatalog
) {
return Array.from(new Set(catalog.map(component => component.topic)))
}
export function createComponentTopicMap(
catalog: readonly ComponentMeta[] = componentCatalog
): Map<ComponentTopic, ComponentMeta[]> {
return catalog.reduce((map, component) => {
const entries = map.get(component.topic) ?? []
entries.push(component)
map.set(component.topic, entries)
return map
}, new Map<ComponentTopic, ComponentMeta[]>())
}

View File

@@ -1,36 +1,12 @@
import type { ComponentMeta, ComponentTopic } from './componentCatalog'
import { componentCatalog } from './componentCatalog'
export interface NavItem {
readonly label: string
readonly path: string
readonly topic?: ComponentTopic | 'overview'
readonly sectionId?: string
}
export const mainNavigation: readonly NavItem[] = [
{ label: 'Home', path: '/' },
{ label: 'Components', path: '/components' },
{ label: 'Projects', path: '/projects' },
{ label: 'Experience', path: '/experience' },
{ label: 'Contact', path: '/contact' },
]
export const componentNavigation: readonly NavItem[] = [
{ label: 'All Components', path: '/components', topic: 'overview' },
...componentCatalog.map<NavItem>((component: ComponentMeta) => ({
label: component.name,
path: component.path,
topic: component.topic,
})),
]
export function groupNavigationByTopic(items: readonly NavItem[]) {
return items.reduce<Record<string, NavItem[]>>((accumulator, item) => {
if (!item.topic) {
return accumulator
}
const existing = accumulator[item.topic] ?? []
existing.push(item)
accumulator[item.topic] = existing
return accumulator
}, {})
}

376
src/services/portfolio.ts Normal file
View File

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

View File

@@ -1,75 +0,0 @@
import type PrismType from 'prismjs'
import { stripLinks } from '@/utils/api'
import { logger } from '@/utils/logger'
let prismPromise: Promise<typeof PrismType> | null = null
let hyperlinkHookRegistered = false
const languageLoaders: ReadonlyArray<() => Promise<unknown>> = [
() => import('prismjs/components/prism-bash'),
() => import('prismjs/components/prism-css'),
() => import('prismjs/components/prism-javascript'),
() => import('prismjs/components/prism-json'),
() => import('prismjs/components/prism-sass'),
() => import('prismjs/components/prism-scss'),
() => import('prismjs/components/prism-typescript'),
]
async function loadPrism(): Promise<typeof PrismType> {
if (!prismPromise) {
prismPromise = import('prismjs')
.then(async module => {
await Promise.all(
languageLoaders.map(async loader => {
try {
await loader()
} catch (error) {
logger.error('Failed to load Prism language', error)
throw error
}
})
)
const prism: typeof PrismType =
module.default ?? (module as unknown as typeof PrismType)
if (!hyperlinkHookRegistered) {
registerHyperlinkHook(prism)
hyperlinkHookRegistered = true
}
return prism
})
.catch(error => {
prismPromise = null
logger.error('Failed to initialise Prism', error)
throw error
})
}
return prismPromise
}
function registerHyperlinkHook(prism: typeof PrismType) {
prism.languages.insertBefore('typescript', 'string', {
hyperlink: /<a.*?>(.*?)<\/a>/g,
})
prism.hooks.add('wrap', env => {
if (env.type === 'hyperlink' && env.tag !== 'a') {
env.tag = 'a'
env.content = env.content.replaceAll('&lt;', '<')
env.attributes.href = /href="(.*?)"/.exec(env.content)?.[1] ?? ''
env.attributes.target = '_blank'
env.content = stripLinks(env.content)[0]
}
})
}
export async function highlightCode(code: string, language: string) {
const prism = await loadPrism()
const grammar = prism.languages[language] ?? prism.languages.markup
return prism.highlight(code, grammar, language)
}

View File

@@ -1,3 +1,175 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #7c5cff;
--color-secondary: #1f2027;
--color-surface: #16171f;
--color-surface-muted: #242630;
--color-accent: #ff4ec7;
--color-accent-alt: #ff70cc;
--color-text: #f4f4ff;
--color-text-muted: #a6a7b5;
--portfolio-accent: var(--color-accent);
--portfolio-accent-alt: var(--color-accent-alt);
--portfolio-accent-gradient: linear-gradient(
120deg,
#ff4ec7 0%,
#ff70cc 40%,
#7c5cff 100%
);
--portfolio-badge-bg: rgba(124, 92, 255, 0.12);
--portfolio-badge-border: rgba(255, 255, 255, 0.16);
--font-family-base: 'Inter', 'Roboto', 'Segoe UI', sans-serif;
--font-family-heading: 'Inter', 'Roboto', 'Segoe UI', sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.25rem;
--font-size-xl: 1.75rem;
--font-size-xxl: 2.5rem;
--line-height-base: 1.6;
--line-height-tight: 1.3;
--space-1: 0.5rem;
--space-2: 1rem;
--space-3: 1.5rem;
--space-4: 2rem;
--space-5: 3rem;
}
body {
font-family: var(--font-family-base);
color: var(--color-text);
line-height: var(--line-height-base);
background: #0f1015;
}
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
// Content spacing for floating navigation
.v-main {
padding-top: 0 !important;
}
// Page containers should have proper top spacing
.page-container {
padding-top: 100px;
}
// Home page hero section needs special spacing
#hero {
margin-top: 60px;
}
// Subpage containers
.projects-page,
.experience-page,
.contact-page {
padding-top: 100px;
}
html {
scroll-behavior: smooth;
}
a:focus-visible,
button:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--portfolio-accent);
outline-offset: 2px;
}
.skip-link {
position: absolute;
top: -40px;
left: 16px;
z-index: 2000;
padding: 0.5rem 1rem;
border-radius: 999px;
background: var(--portfolio-accent-gradient);
color: #0f1015;
font-weight: 600;
text-decoration: none;
transition: top 0.2s ease;
}
.skip-link:focus,
.skip-link:focus-visible,
.skip-link:active {
top: 16px;
}
h1,
h2,
h3,
h4,
h5 {
font-family: var(--font-family-heading);
font-weight: 700;
letter-spacing: -0.01em;
margin-bottom: 0.5em;
line-height: 1.2;
}
h1 {
letter-spacing: -0.025em;
line-height: 1.1;
}
h2 {
letter-spacing: -0.02em;
line-height: 1.15;
}
h3,
h4,
h5 {
letter-spacing: -0.01em;
}
.text-caption,
.text-body-2,
.text-body-1 {
line-height: var(--line-height-base);
}
.text-caption {
font-weight: 500;
letter-spacing: 0.05em;
}
.text-body-1 {
font-size: 1.1rem;
line-height: 1.7;
}
.text-body-2 {
line-height: 1.6;
}
.text-medium-emphasis {
color: rgba(244, 244, 255, 0.82) !important;
}
p {
margin-bottom: 1em;
}
p:last-child {
margin-bottom: 0;
}
.app-link {
letter-spacing: 1px;
transition: color 0.2s ease;
@@ -27,3 +199,175 @@
color: #609926;
font-size: 1rem;
}
.accent-text {
color: var(--portfolio-accent);
}
.accent-btn {
background: var(--portfolio-accent-gradient) !important;
color: #ffffff !important;
box-shadow: 0 12px 30px rgba(255, 20, 225, 0.35);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.accent-btn .v-btn__content,
.accent-btn .v-icon {
color: inherit !important;
-webkit-text-fill-color: currentColor !important;
}
.accent-btn:hover,
.accent-btn:focus-visible {
box-shadow: 0 18px 42px rgba(255, 20, 225, 0.45);
transform: translateY(-2px);
}
.accent-outline {
border-color: rgba(255, 255, 255, 0.24) !important;
color: #ffffff !important;
transition:
border-color 0.2s ease,
color 0.2s ease,
transform 0.2s ease;
}
.accent-outline .v-btn__content,
.accent-outline .v-icon {
color: inherit !important;
-webkit-text-fill-color: currentColor !important;
}
.accent-outline:hover,
.accent-outline:focus-visible {
border-color: var(--portfolio-accent) !important;
color: var(--portfolio-accent) !important;
transform: translateY(-1px);
}
.accent-outline:hover .v-btn__content,
.accent-outline:focus-visible .v-btn__content,
.accent-outline:hover .v-icon,
.accent-outline:focus-visible .v-icon {
color: inherit !important;
-webkit-text-fill-color: currentColor !important;
}
.accent-hover {
color: var(--color-text) !important;
transition:
color 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
}
.accent-hover .v-btn__content,
.accent-hover .v-icon {
color: inherit !important;
-webkit-text-fill-color: currentColor !important;
}
.accent-hover:hover,
.accent-hover:focus-visible {
color: var(--portfolio-accent) !important;
border-color: rgba(255, 20, 225, 0.35) !important;
background-color: rgba(255, 20, 225, 0.12) !important;
}
.accent-hover:hover .v-btn__content,
.accent-hover:focus-visible .v-btn__content,
.accent-hover:hover .v-icon,
.accent-hover:focus-visible .v-icon {
color: inherit !important;
-webkit-text-fill-color: currentColor !important;
}
.badge-chip {
background-color: var(--portfolio-badge-bg) !important;
border-color: var(--portfolio-badge-border) !important;
color: #ffffff !important;
backdrop-filter: blur(6px);
border-radius: 999px !important;
}
.section-heading {
position: relative;
padding-top: 1.5rem;
}
.section-heading::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 72px;
height: 2px;
border-radius: 999px;
background: var(--portfolio-accent-gradient);
}
.section-heading .text-caption {
letter-spacing: 0.24em;
font-weight: 600;
}
.section-divider {
width: 100%;
height: 1px;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0.15) 20%,
rgba(255, 255, 255, 0.15) 80%,
rgba(255, 255, 255, 0)
);
}
.experience-card,
.portfolio-card {
background: rgba(34, 35, 40, 0.85) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
backdrop-filter: blur(8px);
}
.reveal {
opacity: 0;
transform: translateY(32px);
transition:
opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: var(--reveal-delay, 0ms);
}
.reveal--visible {
opacity: 1;
transform: translateY(0);
}
// Enhanced scroll effects
.reveal.reveal--scale {
transform: translateY(32px) scale(0.95);
}
.reveal.reveal--scale.reveal--visible {
transform: translateY(0) scale(1);
}
.reveal.reveal--blur {
filter: blur(4px);
}
.reveal.reveal--blur.reveal--visible {
filter: blur(0);
}
@media (prefers-reduced-motion: reduce) {
.reveal,
.reveal--visible {
opacity: 1 !important;
transform: none !important;
transition: none !important;
}
}

22
src/typed-router.d.ts vendored
View File

@@ -19,8 +19,10 @@ declare module 'vue-router/auto-routes' {
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/components/': RouteRecordInfo<'/components/', '/components', Record<never, never>, Record<never, never>>,
'/components/tester': RouteRecordInfo<'/components/tester', '/components/tester', Record<never, never>, Record<never, never>>,
'/contact/': RouteRecordInfo<'/contact/', '/contact', Record<never, never>, Record<never, never>>,
'/experience/': RouteRecordInfo<'/experience/', '/experience', Record<never, never>, Record<never, never>>,
'/projects/': RouteRecordInfo<'/projects/', '/projects', Record<never, never>, Record<never, never>>,
'/projects/[slug]': RouteRecordInfo<'/projects/[slug]', '/projects/:slug', { slug: ParamValue<true> }, { slug: ParamValue<false> }>,
}
/**
@@ -38,12 +40,20 @@ declare module 'vue-router/auto-routes' {
routes: '/'
views: never
}
'src/pages/components/index.vue': {
routes: '/components/'
'src/pages/contact/index.vue': {
routes: '/contact/'
views: never
}
'src/pages/components/tester.vue': {
routes: '/components/tester'
'src/pages/experience/index.vue': {
routes: '/experience/'
views: never
}
'src/pages/projects/index.vue': {
routes: '/projects/'
views: never
}
'src/pages/projects/[slug].vue': {
routes: '/projects/[slug]'
views: never
}
}

View File

@@ -1,4 +0,0 @@
declare module 'prismjs/components/*' {
const language: unknown
export default language
}

View File

@@ -1,169 +0,0 @@
/// <reference lib="dom" />
export interface Item {
readonly name: string
readonly source: string
readonly type?: string | readonly string[]
readonly anyOf: readonly Item[]
readonly enum?: readonly string[]
readonly parameters?: readonly Item[]
readonly returnType?: Item
readonly default: unknown
readonly description: Record<string, string>
readonly descriptionSource?: Record<string, string>
readonly snippet: string
readonly value: unknown
readonly example: string
readonly props: unknown
readonly $ref?: string
readonly properties?: Record<string, Item>
readonly items?: Item | readonly Item[]
readonly minItems?: number
readonly maxItems?: number
readonly allOf?: readonly Item[]
}
export interface ApiResponse<T = unknown> {
readonly data: T
readonly status: number
readonly statusText: string
readonly headers: Record<string, string>
}
export interface ApiError {
readonly message: string
readonly status?: number
readonly statusText?: string
readonly code?: string
}
export class ApiException extends Error implements ApiError {
readonly status?: number
readonly statusText?: string
readonly code?: string
constructor(
message: string,
status?: number,
statusText?: string,
code?: string
) {
super(message)
this.name = 'ApiException'
this.status = status
this.statusText = statusText
this.code = code
}
}
export interface FetchOptions extends RequestInit {
readonly baseURL?: string
readonly timeout?: number
readonly retries?: number
readonly retryDelay?: number
}
/**
* Enhanced fetch utility with error handling, retries, and timeout
*/
export async function apiRequest<T = unknown>(
url: string,
options: FetchOptions = {}
): Promise<ApiResponse<T>> {
const {
baseURL = '',
timeout = 10000,
retries = 3,
retryDelay = 1000,
...fetchOptions
} = options
const fullUrl = baseURL ? `${baseURL}${url}` : url
let lastError: Error = new Error('Unknown error')
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const response = await fetch(fullUrl, {
...fetchOptions,
signal: controller.signal,
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new ApiException(
`Request failed: ${response.statusText}`,
response.status,
response.statusText
)
}
const data = await response.json()
const headers: Record<string, string> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})
return {
data,
status: response.status,
statusText: response.statusText,
headers,
}
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error))
if (attempt < retries) {
await new Promise(resolve => setTimeout(resolve, retryDelay))
continue
}
throw lastError instanceof ApiException
? lastError
: new ApiException(lastError.message)
}
}
throw lastError
}
/**
* Utility function to strip HTML links from string
*/
export function stripLinks(str: string): [string, Record<string, string>] {
let out = str.slice()
const obj: Record<string, string> = {}
const regexp = /a.*?>(.*?)<\/a>/g
let matches = regexp.exec(str)
while (matches !== null) {
obj[matches[1]] = matches[0]
out = out.replace(matches[0], matches[1])
matches = regexp.exec(str)
}
return [out, obj]
}
/**
* Utility function to insert HTML links back into string
*/
export function insertLinks(
str: string,
stripped: Record<string, string>
): string {
let result = str
for (const [key, value] of Object.entries(stripped)) {
result = result.replaceAll(
new RegExp(`(^|\\W)(${key})(\\W|$)`, 'g'),
`$1${value}$3`
)
}
return result
}

22
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
corePlugins: {
preflight: false,
},
theme: {
extend: {
colors: {
portfolio: {
accent: 'var(--portfolio-accent)',
accentAlt: 'var(--portfolio-accent-alt)',
},
},
fontFamily: {
heading: 'var(--font-family-heading)',
body: 'var(--font-family-base)',
},
},
},
plugins: [],
}

1639
yarn.lock

File diff suppressed because it is too large Load Diff