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:
16
.claude/settings.local.json
Normal file
16
.claude/settings.local.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
26
README.md
26
README.md
@@ -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.
|
||||
|
||||
|
||||
36
index.html
36
index.html
@@ -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"
|
||||
|
||||
24391
localhost_2025-09-27_00-35-13.report.html
Normal file
24391
localhost_2025-09-27_00-35-13.report.html
Normal file
File diff suppressed because one or more lines are too long
23730
localhost_2025-09-27_00-38-57.report.html
Normal file
23730
localhost_2025-09-27_00-38-57.report.html
Normal file
File diff suppressed because one or more lines are too long
10391
package-lock.json
generated
10391
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -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
6
postcss.config.cjs
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
18
src/App.vue
18
src/App.vue
@@ -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
5
src/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,12 +6,20 @@
|
||||
© {{ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
44
src/components/portfolio/ContactGrid.vue
Normal file
44
src/components/portfolio/ContactGrid.vue
Normal 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>
|
||||
310
src/components/portfolio/ExperienceTimeline.vue
Normal file
310
src/components/portfolio/ExperienceTimeline.vue
Normal 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>
|
||||
321
src/components/portfolio/HeroSection.vue
Normal file
321
src/components/portfolio/HeroSection.vue
Normal 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">
|
||||
and
|
||||
<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>
|
||||
310
src/components/portfolio/ProjectCard.vue
Normal file
310
src/components/portfolio/ProjectCard.vue
Normal 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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
36
src/composables/usePortfolio.ts
Normal file
36
src/composables/usePortfolio.ts
Normal 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
84
src/directives/reveal.ts
Normal 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
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@
|
||||
<v-app>
|
||||
<Nav />
|
||||
|
||||
<v-main>
|
||||
<v-main id="main-content">
|
||||
<router-view />
|
||||
</v-main>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
286
src/pages/contact/index.vue
Normal 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"><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>
|
||||
200
src/pages/experience/index.vue
Normal file
200
src/pages/experience/index.vue
Normal 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>
|
||||
@@ -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">
|
||||
Let’s 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>
|
||||
|
||||
170
src/pages/projects/[slug].vue
Normal file
170
src/pages/projects/[slug].vue
Normal 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>
|
||||
244
src/pages/projects/index.vue
Normal file
244
src/pages/projects/index.vue
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[]>())
|
||||
}
|
||||
@@ -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
376
src/services/portfolio.ts
Normal 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: 'Let’s Collaborate',
|
||||
to: '/contact',
|
||||
},
|
||||
}
|
||||
|
||||
export const socialLinks: readonly SocialLink[] = [
|
||||
{
|
||||
label: 'GitHub',
|
||||
icon: 'mdi-github',
|
||||
url: 'https://github.com/cnachtigall',
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
icon: 'mdi-linkedin',
|
||||
url: 'https://www.linkedin.com/in/cnachtigall',
|
||||
},
|
||||
{ label: 'Email', icon: 'mdi-email', url: 'mailto:hello@nachtigall.dev' },
|
||||
]
|
||||
|
||||
export const projects: readonly Project[] = [
|
||||
{
|
||||
slug: 'optimus-ui',
|
||||
name: 'Optimus UI Library',
|
||||
tagline: 'Design system for a fintech scale-up',
|
||||
summary:
|
||||
'Scaled a Vue component library across five product teams, cutting feature delivery time by 35%.',
|
||||
description:
|
||||
'Led the architecture of a reusable component system that unified product experiences across web and mobile banking surfaces. Focused on accessibility, token-driven theming, and automated visual regression coverage.',
|
||||
role: 'Lead Front-end Engineer',
|
||||
timeframe: '2023 – 2024',
|
||||
heroImage:
|
||||
'https://images.unsplash.com/photo-1523475472560-d2df97ec485c?auto=format&fit=crop&w=1600&q=80',
|
||||
tech: ['Vue 3', 'Vuetify', 'TypeScript', 'Storybook', 'Playwright'],
|
||||
metrics: [
|
||||
'35% faster feature delivery',
|
||||
'98 Lighthouse accessibility score',
|
||||
'Shared across 5 product teams',
|
||||
],
|
||||
highlights: [
|
||||
{
|
||||
title: 'Design-token pipeline',
|
||||
description:
|
||||
'Automated token exports for design, docs, and production builds, guaranteeing pixel-perfect parity across channels.',
|
||||
},
|
||||
{
|
||||
title: 'Accessibility reviews',
|
||||
description:
|
||||
'Shipped AA-compliant components with axe-core CI checks, ensuring inclusive experiences out of the box.',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
label: 'Case study',
|
||||
url: 'https://nachtigall.dev/projects/optimus-ui',
|
||||
type: 'case-study',
|
||||
},
|
||||
{
|
||||
label: 'Live Storybook',
|
||||
url: 'https://storybook.nachtigall.dev',
|
||||
type: 'demo',
|
||||
},
|
||||
{
|
||||
label: 'Design tokens repo',
|
||||
url: 'https://github.com/cnachtigall/optimus-design-tokens',
|
||||
type: 'code',
|
||||
},
|
||||
],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: 'aurora-analytics',
|
||||
name: 'Aurora Analytics',
|
||||
tagline: 'Insights dashboard with collaborative commenting',
|
||||
summary:
|
||||
'Delivered a data storytelling surface with inline annotations that helped analysts close feedback loops 2× faster.',
|
||||
description:
|
||||
'Implemented a modular dashboard that blends real-time metrics, saved explorations, and collaborative commenting. Emphasis on smooth transitions, optimistic UI, and resilient API integrations.',
|
||||
role: 'Senior Front-end Engineer',
|
||||
timeframe: '2022 – 2023',
|
||||
heroImage:
|
||||
'https://images.unsplash.com/photo-1556157382-97eda2d62296?auto=format&fit=crop&w=1600&q=80',
|
||||
tech: ['Vue 3', 'Pinia', 'Apollo GraphQL', 'D3.js', 'Vitest'],
|
||||
metrics: [
|
||||
'2× faster analyst feedback loops',
|
||||
'Sub-1s dashboard interactions',
|
||||
'Collaborative features adopted by 180+ users',
|
||||
],
|
||||
highlights: [
|
||||
{
|
||||
title: 'Realtime presence',
|
||||
description:
|
||||
'Built WebSocket-powered presence indicators and live cursors to highlight concurrent viewers and edits.',
|
||||
},
|
||||
{
|
||||
title: 'Narrative exports',
|
||||
description:
|
||||
'Rendered dashboards to branded PDF stories with contextual annotations and executive summaries.',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
label: 'See Live Demo',
|
||||
url: 'https://aurora.nachtigall.dev',
|
||||
type: 'demo',
|
||||
},
|
||||
{
|
||||
label: 'View Code',
|
||||
url: 'https://github.com/cnachtigall/aurora-analytics',
|
||||
type: 'code',
|
||||
},
|
||||
{
|
||||
label: 'Case study',
|
||||
url: 'https://nachtigall.dev/projects/aurora-analytics',
|
||||
type: 'case-study',
|
||||
},
|
||||
],
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
slug: 'pulse-health',
|
||||
name: 'Pulse Health Platform',
|
||||
tagline: 'Patient engagement portal for clinics',
|
||||
summary:
|
||||
'Introduced virtual waiting rooms and asynchronous triage, cutting phone volume by 40%.',
|
||||
description:
|
||||
'Collaborated with clinicians to ship a responsive health portal with appointment bookings, symptom triage, and secure messaging. Balanced strict compliance requirements with empathetic UX.',
|
||||
role: 'Front-end Engineer',
|
||||
timeframe: '2021 – 2022',
|
||||
heroImage:
|
||||
'https://images.unsplash.com/photo-1521790797524-b2497295b8a0?auto=format&fit=crop&w=1600&q=80',
|
||||
tech: ['Nuxt', 'Vuetify', 'TypeScript', 'Cypress', 'i18n'],
|
||||
metrics: [
|
||||
'40% reduction in call volume',
|
||||
'Appointment drop-offs down 22%',
|
||||
'Deployed to 12 clinics in 3 countries',
|
||||
],
|
||||
highlights: [
|
||||
{
|
||||
title: 'Virtual triage',
|
||||
description:
|
||||
'Designed a symptom checker that prioritised urgent cases and surfaced care guidance instantly.',
|
||||
},
|
||||
{
|
||||
title: 'Offline resilience',
|
||||
description:
|
||||
'Implemented offline-ready forms with queue syncing for communities with spotty connectivity.',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
label: 'Product Tour',
|
||||
url: 'https://pulse.nachtigall.dev',
|
||||
type: 'demo',
|
||||
},
|
||||
{
|
||||
label: 'Case study',
|
||||
url: 'https://nachtigall.dev/projects/pulse-health',
|
||||
type: 'case-study',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'lumen-labs',
|
||||
name: 'Lumen Labs Microsite',
|
||||
tagline: 'Interactive marketing microsite for a VR studio',
|
||||
summary:
|
||||
'Launched a cinematic landing experience with scroll-driven storytelling in under four weeks.',
|
||||
description:
|
||||
'Partnered with a creative agency to prototype and deliver an immersive microsite showcasing upcoming VR titles. Focused on buttery animations, 3D asset integration, and SEO-readiness.',
|
||||
role: 'Creative Developer',
|
||||
timeframe: '2020 – 2021',
|
||||
heroImage:
|
||||
'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1600&q=80',
|
||||
tech: ['Vue', 'GSAP', 'Three.js', 'Netlify'],
|
||||
metrics: [
|
||||
'Launched in 4 weeks',
|
||||
'Time on page +63%',
|
||||
'Custom shader pipeline under 60kb',
|
||||
],
|
||||
highlights: [
|
||||
{
|
||||
title: 'Scroll choreography',
|
||||
description:
|
||||
'Crafted narrative beats synced to scroll depth, using GPU-friendly shaders for lighting effects.',
|
||||
},
|
||||
{
|
||||
title: 'CMS handoff',
|
||||
description:
|
||||
'Integrated a headless CMS so the studio could update copy and assets without developer support.',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
label: 'Visit Site',
|
||||
url: 'https://lumenlabs.example.com',
|
||||
type: 'demo',
|
||||
},
|
||||
{
|
||||
label: 'Animation snippets',
|
||||
url: 'https://github.com/cnachtigall/lumen-labs-microsite',
|
||||
type: 'code',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const experience: readonly Experience[] = [
|
||||
{
|
||||
company: 'Finwave',
|
||||
role: 'Lead Front-end Engineer',
|
||||
timeframe: '2023 – Present',
|
||||
location: 'Berlin (Hybrid)',
|
||||
description:
|
||||
'Heading the experience engineering guild responsible for the design system, accessibility, and shared tooling across five product pillars.',
|
||||
achievements: [
|
||||
'Scaled design system to 120+ contributors with automated release notes.',
|
||||
'Improved Core Web Vitals median score by 18% in one quarter.',
|
||||
'Mentored and coached a distributed team of eight front-end engineers.',
|
||||
],
|
||||
tech: ['Vue 3', 'Vite', 'Vuetify', 'TypeScript', 'Storybook'],
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
company: 'DataForge',
|
||||
role: 'Senior Front-end Engineer',
|
||||
timeframe: '2021 – 2023',
|
||||
location: 'Remote (EU)',
|
||||
description:
|
||||
'Delivered analytics workflows and collaboration features for enterprise customers transitioning off legacy BI suites.',
|
||||
achievements: [
|
||||
'Rolled out collaborative dashboards with optimistic UI and real-time presence.',
|
||||
'Reduced bundle size by 28% through module federation and code splitting.',
|
||||
'Partnered with design and data science to ship 6 customer-facing features per quarter.',
|
||||
],
|
||||
tech: ['Vue 3', 'Apollo', 'GraphQL', 'Pinia'],
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
{
|
||||
company: 'BrightHealth',
|
||||
role: 'Front-end Engineer',
|
||||
timeframe: '2019 – 2021',
|
||||
location: 'Hamburg (On-site)',
|
||||
description:
|
||||
'Built patient engagement tools with a focus on accessible workflows and clinician trust.',
|
||||
achievements: [
|
||||
'Launched asynchronous triage saving clinicians ~4 hours per shift.',
|
||||
'Localised the portal for five languages, including full RTL support.',
|
||||
'Co-led accessibility remediation to achieve WCAG AA compliance.',
|
||||
],
|
||||
tech: ['Nuxt', 'Vuex', 'Cypress', 'i18n'],
|
||||
image:
|
||||
'https://images.unsplash.com/photo-1472289065668-ce650ac443d2?auto=format&fit=crop&w=1200&q=80',
|
||||
},
|
||||
]
|
||||
|
||||
export const contactChannels: readonly ContactChannel[] = [
|
||||
{
|
||||
label: 'Email',
|
||||
value: 'hello@nachtigall.dev',
|
||||
icon: 'mdi-email-outline',
|
||||
href: 'mailto:hello@nachtigall.dev',
|
||||
},
|
||||
{
|
||||
label: 'LinkedIn',
|
||||
value: 'linkedin.com/in/cnachtigall',
|
||||
icon: 'mdi-linkedin',
|
||||
href: 'https://www.linkedin.com/in/cnachtigall',
|
||||
},
|
||||
{
|
||||
label: 'Calendly',
|
||||
value: 'Book a 30 min chat',
|
||||
icon: 'mdi-calendar-clock',
|
||||
href: 'https://calendly.com/cnachtigall/coffee',
|
||||
},
|
||||
]
|
||||
|
||||
export function getProjectBySlug(slug: string): Project | undefined {
|
||||
return projects.find(project => project.slug === slug)
|
||||
}
|
||||
|
||||
export function getFeaturedProjects(): readonly Project[] {
|
||||
return projects.filter(project => project.featured)
|
||||
}
|
||||
@@ -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('<', '<')
|
||||
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)
|
||||
}
|
||||
@@ -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
22
src/typed-router.d.ts
vendored
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
4
src/types/prism.d.ts
vendored
4
src/types/prism.d.ts
vendored
@@ -1,4 +0,0 @@
|
||||
declare module 'prismjs/components/*' {
|
||||
const language: unknown
|
||||
export default language
|
||||
}
|
||||
169
src/utils/api.ts
169
src/utils/api.ts
@@ -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
22
tailwind.config.cjs
Normal 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: [],
|
||||
}
|
||||
Reference in New Issue
Block a user