This commit is contained in:
45
app/app.config.ts
Normal file
45
app/app.config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export default defineAppConfig({
|
||||
global: {
|
||||
picture: {
|
||||
dark: 'https://media.licdn.com/dms/image/v2/D4E03AQFH5BVMre2e1A/profile-displayphoto-crop_800_800/B4EZplNLbYGYAM-/0/1762634545622?e=1766016000&v=beta&t=_jK5TKTJ8agI3add11w-IRaISiFUfhtVlhaCG_Jwidw',
|
||||
light: 'https://media.licdn.com/dms/image/v2/D4E03AQFH5BVMre2e1A/profile-displayphoto-crop_800_800/B4EZplNLbYGYAM-/0/1762634545622?e=1766016000&v=beta&t=_jK5TKTJ8agI3add11w-IRaISiFUfhtVlhaCG_Jwidw',
|
||||
alt: 'My profile picture'
|
||||
},
|
||||
meetingLink: 'https://calendar.google.com/calendar/appointments/schedules/AcZssZ0BRGpbzvnsdqUpiWdhL5qiagpKYh9UF1UG3yfC_1Ga385_5-ajCy8JOnsBZJCFwd-R_si5BeT2',
|
||||
email: 'nick@kalar.codes',
|
||||
available: true
|
||||
},
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'blue',
|
||||
neutral: 'neutral'
|
||||
},
|
||||
pageHero: {
|
||||
slots: {
|
||||
container: 'py-18 sm:py-24 lg:py-32',
|
||||
title: 'mx-auto max-w-xl text-pretty text-3xl sm:text-4xl lg:text-5xl',
|
||||
description: 'mt-2 text-md mx-auto max-w-2xl text-pretty sm:text-md text-muted'
|
||||
}
|
||||
}
|
||||
},
|
||||
footer: {
|
||||
credits: `Built with Nuxt UI • © Nick Kalar ${new Date().getFullYear()}`,
|
||||
colorMode: false,
|
||||
links: [{
|
||||
'icon': 'i-simple-icons-linkedin',
|
||||
'to': 'https://www.linkedin.com/in/nickkalar/',
|
||||
'target': '_blank',
|
||||
'aria-label': 'Nick on LinkedIn'
|
||||
}, {
|
||||
'icon': 'i-simple-icons-twitter',
|
||||
'to': 'https://www.x.com/nickkalar',
|
||||
'target': '_blank',
|
||||
'aria-label': 'Nick on Twitter'
|
||||
}, {
|
||||
'icon': 'i-simple-icons-gitea',
|
||||
'to': 'https://git.kalar.codes/NickKalar',
|
||||
'target': '_blank',
|
||||
'aria-label': 'Nick on Gitea'
|
||||
}]
|
||||
}
|
||||
})
|
||||
64
app/app.vue
Normal file
64
app/app.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const color = computed(() => colorMode.value === 'dark' ? '#020618' : 'white')
|
||||
|
||||
useHead({
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ key: 'theme-color', name: 'theme-color', content: color }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', href: '/favicon.ico' }
|
||||
],
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
}
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
titleTemplate: '%s',
|
||||
ogImage: 'https://i.postimg.cc/ydfWjvKB/nick-kalar-main.png',
|
||||
twitterImage: 'https://i.postimg.cc/ydfWjvKB/nick-kalar-main.png',
|
||||
twitterCard: 'summary_large_image'
|
||||
})
|
||||
|
||||
const [{ data: navigation }, { data: files }] = await Promise.all([
|
||||
useAsyncData('navigation', () => {
|
||||
return Promise.all([
|
||||
queryCollectionNavigation('blog')
|
||||
])
|
||||
}, {
|
||||
transform: data => data.flat()
|
||||
}),
|
||||
useLazyAsyncData('search', () => {
|
||||
return Promise.all([
|
||||
queryCollectionSearchSections('blog')
|
||||
])
|
||||
}, {
|
||||
server: false,
|
||||
transform: data => data.flat()
|
||||
})
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<NuxtLayout>
|
||||
<UMain class="relative">
|
||||
<NuxtPage />
|
||||
</UMain>
|
||||
</NuxtLayout>
|
||||
|
||||
<ClientOnly>
|
||||
<LazyUContentSearch
|
||||
:files="files"
|
||||
:navigation="navigation"
|
||||
shortcut="meta_k"
|
||||
:links="navLinks"
|
||||
:fuse="{ resultLimit: 42 }"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</UApp>
|
||||
</template>
|
||||
25
app/assets/css/main.css
Normal file
25
app/assets/css/main.css
Normal file
@@ -0,0 +1,25 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@source "../../../content/**/*";
|
||||
|
||||
@theme static {
|
||||
--font-sans: 'Public Sans', sans-serif;
|
||||
--font-serif: 'Instrument Serif', serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
--ui-container: var(--container-4xl);
|
||||
|
||||
::selection {
|
||||
color: #282a30;
|
||||
background-color: #c8c8c8;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
::selection {
|
||||
color: #ffffff;
|
||||
background-color: #474747;
|
||||
}
|
||||
}
|
||||
BIN
app/assets/images/shopping-list.jpg
Normal file
BIN
app/assets/images/shopping-list.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
24
app/components/AppFooter.vue
Normal file
24
app/components/AppFooter.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const { footer } = useAppConfig()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UFooter
|
||||
class="z-10 bg-default"
|
||||
:ui="{ left: 'text-muted text-xs' }"
|
||||
>
|
||||
<template #left>
|
||||
{{ footer.credits }}
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<template v-if="footer?.links">
|
||||
<UButton
|
||||
v-for="(link, index) of footer?.links"
|
||||
:key="index"
|
||||
v-bind="{ size: 'xs', color: 'neutral', variant: 'ghost', ...link }"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</UFooter>
|
||||
</template>
|
||||
25
app/components/AppHeader.vue
Normal file
25
app/components/AppHeader.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||
|
||||
defineProps<{
|
||||
links: NavigationMenuItem[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed top-2 sm:top-4 mx-auto left-1/2 transform -translate-x-1/2 z-10">
|
||||
<UNavigationMenu
|
||||
:items="links"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
class="bg-muted/80 backdrop-blur-sm rounded-full px-2 sm:px-4 border border-muted/50 shadow-lg shadow-neutral-950/5"
|
||||
:ui="{
|
||||
link: 'px-2 py-1',
|
||||
}"
|
||||
>
|
||||
<template #list-trailing>
|
||||
<ColorModeButton />
|
||||
</template>
|
||||
</UNavigationMenu>
|
||||
</div>
|
||||
</template>
|
||||
76
app/components/ColorModeButton.vue
Normal file
76
app/components/ColorModeButton.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const nextTheme = computed(() => (colorMode.value === 'dark' ? 'light' : 'dark'))
|
||||
|
||||
const switchTheme = () => {
|
||||
colorMode.preference = nextTheme.value
|
||||
}
|
||||
|
||||
const startViewTransition = (event: MouseEvent) => {
|
||||
if (!document.startViewTransition) {
|
||||
switchTheme()
|
||||
return
|
||||
}
|
||||
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, window.innerWidth - x),
|
||||
Math.max(y, window.innerHeight - y)
|
||||
)
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
switchTheme()
|
||||
})
|
||||
|
||||
transition.ready.then(() => {
|
||||
const duration = 600
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: [
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${endRadius}px at ${x}px ${y}px)`
|
||||
]
|
||||
},
|
||||
{
|
||||
duration: duration,
|
||||
easing: 'cubic-bezier(.76,.32,.29,.99)',
|
||||
pseudoElement: '::view-transition-new(root)'
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<UButton
|
||||
:aria-label="`Switch to ${nextTheme} mode`"
|
||||
:icon="`i-lucide-${nextTheme === 'dark' ? 'sun' : 'moon'}`"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="rounded-full"
|
||||
@click="startViewTransition"
|
||||
/>
|
||||
<template #fallback>
|
||||
<div class="size-4" />
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
::view-transition-old(root),
|
||||
::view-transition-new(root) {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
28
app/components/PolaroidItem.vue
Normal file
28
app/components/PolaroidItem.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
image: {
|
||||
src: string
|
||||
alt: string
|
||||
}
|
||||
index: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-white p-2 flex flex-col drop-shadow-2xl transition-transform duration-300 ease-in-out hover:scale-105 hover:rotate-0 hover:z-10"
|
||||
:class="[
|
||||
index % 2 === 0 ? '-rotate-5' : 'rotate-5',
|
||||
index % 2 === 0 ? 'hover:-translate-x-4' : 'hover:translate-x-4'
|
||||
]"
|
||||
>
|
||||
<img
|
||||
:src="image.src"
|
||||
:alt="image.alt"
|
||||
class="size-32 object-cover"
|
||||
>
|
||||
<span class="w-32 text-xs text-black font-serif font-medium text-center mt-2">
|
||||
{{ image.alt }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
23
app/components/landing/About.vue
Normal file
23
app/components/landing/About.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndexCollectionItem } from '@nuxt/content'
|
||||
|
||||
defineProps<{
|
||||
page: IndexCollectionItem
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageSection
|
||||
:title="page.about.title"
|
||||
:description="page.about.description"
|
||||
:ui="{
|
||||
container: '!p-0',
|
||||
title: 'text-left text-xl sm:text-xl lg:text-2xl font-medium',
|
||||
description: 'text-left mt-3 text-sm sm:text-md lg:text-sm text-muted'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
61
app/components/landing/Blog.vue
Normal file
61
app/components/landing/Blog.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndexCollectionItem } from '@nuxt/content'
|
||||
|
||||
defineProps<{
|
||||
page: IndexCollectionItem
|
||||
}>()
|
||||
|
||||
const { data: posts } = await useAsyncData('index-blogs', () =>
|
||||
queryCollection('blog').order('date', 'DESC').limit(3).all()
|
||||
)
|
||||
if (!posts.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'blogs posts not found', fatal: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageSection
|
||||
:title="page.blog.title"
|
||||
:description="page.blog.description"
|
||||
:ui="{
|
||||
container: 'px-0 !pt-0 sm:gap-6 lg:gap-8',
|
||||
title: 'text-left text-xl sm:text-xl lg:text-2xl font-medium',
|
||||
description: 'text-left mt-2 text-sm sm:text-md lg:text-sm text-muted'
|
||||
}"
|
||||
>
|
||||
<UBlogPosts
|
||||
orientation="vertical"
|
||||
class="gap-4 lg:gap-y-4"
|
||||
>
|
||||
<UBlogPost
|
||||
v-for="(post, index) in posts"
|
||||
:key="index"
|
||||
orientation="horizontal"
|
||||
variant="naked"
|
||||
v-bind="post"
|
||||
:to="post.path"
|
||||
:ui="{
|
||||
root: 'group relative lg:items-start lg:flex ring-0 hover:ring-0',
|
||||
body: '!px-0',
|
||||
header: 'hidden'
|
||||
}"
|
||||
>
|
||||
<template #footer>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="link"
|
||||
class="px-0 gap-0"
|
||||
label="Read Article"
|
||||
>
|
||||
<template #trailing>
|
||||
<UIcon
|
||||
name="i-lucide-arrow-right"
|
||||
class="size-4 text-primary transition-all opacity-0 group-hover:translate-x-1 group-hover:opacity-100"
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
</template>
|
||||
</UBlogPost>
|
||||
</UBlogPosts>
|
||||
</UPageSection>
|
||||
</template>
|
||||
61
app/components/landing/Education.vue
Normal file
61
app/components/landing/Education.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndexCollectionItem } from '@nuxt/content'
|
||||
|
||||
defineProps<{
|
||||
page: IndexCollectionItem
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageSection
|
||||
:title="page.education.title"
|
||||
:ui="{
|
||||
container: '!p-0 gap-4 sm:gap-4',
|
||||
title: 'text-left text-xl sm:text-xl lg:text-2xl font-medium',
|
||||
description: 'mt-2'
|
||||
}"
|
||||
>
|
||||
<template #description>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Motion
|
||||
v-for="(education, index) in page.education.items"
|
||||
:key="index"
|
||||
:initial="{ opacity: 0, transform: 'translateY(20px)' }"
|
||||
:while-in-view="{ opacity: 1, transform: 'translateY(0)' }"
|
||||
:transition="{ delay: 0.4 + 0.2 * index }"
|
||||
:in-view-options="{ once: true }"
|
||||
class="text-muted flex items-center text-nowrap gap-2"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{{ education.date }}
|
||||
</p>
|
||||
<USeparator />
|
||||
<ULink
|
||||
class="flex items-center gap-1"
|
||||
:to="education.institution.url"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{{ education.degree }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1"
|
||||
:style="{ color: education.institution.color }"
|
||||
>
|
||||
<span class="font-medium">{{ education.institution.name }}</span>
|
||||
<img
|
||||
:src="education.institution.logo"
|
||||
:alt="education.institution.name"
|
||||
:width=32
|
||||
/>
|
||||
</div>
|
||||
</ULink>
|
||||
</Motion>
|
||||
</div>
|
||||
</template>
|
||||
</UPageSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
64
app/components/landing/FAQ.vue
Normal file
64
app/components/landing/FAQ.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndexCollectionItem } from '@nuxt/content'
|
||||
|
||||
const props = defineProps<{
|
||||
page: IndexCollectionItem
|
||||
}>()
|
||||
|
||||
const items = computed(() => {
|
||||
return props.page.faq?.categories.map((faq) => {
|
||||
return {
|
||||
label: faq.title,
|
||||
key: faq.title.toLowerCase(),
|
||||
questions: faq.questions
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const ui = {
|
||||
root: 'flex items-center gap-4 w-full',
|
||||
list: 'relative flex bg-transparent dark:bg-transparent gap-2 px-0',
|
||||
indicator: 'absolute top-[4px] duration-200 ease-out focus:outline-none rounded-lg bg-elevated/60',
|
||||
trigger: 'px-3 py-2 rounded-lg hover:bg-muted/50 data-[state=active]:text-highlighted data-[state=inactive]:text-muted',
|
||||
label: 'truncate'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageSection
|
||||
:title="page.faq.title"
|
||||
:description="page.faq.description"
|
||||
:ui="{
|
||||
container: 'px-0 !pt-0 gap-4 sm:gap-4',
|
||||
title: 'text-left text-xl sm:text-xl lg:text-2xl font-medium',
|
||||
description: 'text-left mt-2 text-sm sm:text-md lg:text-sm text-muted'
|
||||
}"
|
||||
>
|
||||
<UTabs
|
||||
:items
|
||||
orientation="horizontal"
|
||||
:ui
|
||||
>
|
||||
<template #content="{ item }">
|
||||
<UAccordion
|
||||
trailing-icon="lucide:plus"
|
||||
:items="item.questions"
|
||||
:unmount-on-hide="false"
|
||||
:ui="{
|
||||
item: 'border-none',
|
||||
trigger: 'mb-2 border-0 group px-4 transform-gpu rounded-lg bg-elevated/60 will-change-transform hover:bg-muted/50 text-base',
|
||||
trailingIcon: 'group-data-[state=closed]:rotate-0 group-data-[state=open]:rotate-135 text-base text-muted'
|
||||
}"
|
||||
>
|
||||
<template #body="{ item: _item }">
|
||||
<MDC
|
||||
:value="_item.content"
|
||||
unwrap="p"
|
||||
class="px-4"
|
||||
/>
|
||||
</template>
|
||||
</UAccordion>
|
||||
</template>
|
||||
</UTabs>
|
||||
</UPageSection>
|
||||
</template>
|
||||
191
app/components/landing/Hero.vue
Normal file
191
app/components/landing/Hero.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndexCollectionItem } from '@nuxt/content'
|
||||
|
||||
const { footer, global } = useAppConfig()
|
||||
|
||||
defineProps<{
|
||||
page: IndexCollectionItem
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageHero
|
||||
:ui="{
|
||||
headline: 'flex items-center justify-center',
|
||||
title: 'text-shadow-md max-w-lg mx-auto',
|
||||
links: 'mt-4 flex-col justify-center items-center'
|
||||
}"
|
||||
>
|
||||
<template #headline>
|
||||
<Motion
|
||||
:initial="{
|
||||
scale: 1.1,
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)'
|
||||
}"
|
||||
:animate="{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)'
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.6,
|
||||
delay: 0.1
|
||||
}"
|
||||
>
|
||||
<UColorModeAvatar
|
||||
class="size-18 ring ring-default ring-offset-3 ring-offset-(--ui-bg)"
|
||||
:light="global.picture?.light!"
|
||||
:dark="global.picture?.dark!"
|
||||
:alt="global.picture?.alt!"
|
||||
/>
|
||||
</Motion>
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<Motion
|
||||
:initial="{
|
||||
scale: 1.1,
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)'
|
||||
}"
|
||||
:animate="{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)'
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.6,
|
||||
delay: 0.1
|
||||
}"
|
||||
>
|
||||
{{ page.title }}
|
||||
</Motion>
|
||||
</template>
|
||||
|
||||
<template #description>
|
||||
<Motion
|
||||
:initial="{
|
||||
scale: 1.1,
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)'
|
||||
}"
|
||||
:animate="{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)'
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.6,
|
||||
delay: 0.3
|
||||
}"
|
||||
>
|
||||
{{ page.description }}
|
||||
</Motion>
|
||||
</template>
|
||||
|
||||
<template #links>
|
||||
<Motion
|
||||
:initial="{
|
||||
scale: 1.1,
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)'
|
||||
}"
|
||||
:animate="{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)'
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.6,
|
||||
delay: 0.5
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="page.hero.links"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<!-- <UButton v-bind="page.hero.links[0]" /> -->
|
||||
<UButton
|
||||
:color="global.available ? 'success' : 'error'"
|
||||
variant="ghost"
|
||||
class="gap-2"
|
||||
:to="global.available ? global.meetingLink : ''"
|
||||
:label="global.available ? 'Available for new projects' : 'Not available at the moment'"
|
||||
>
|
||||
<template #leading>
|
||||
<span class="relative flex size-2">
|
||||
<span
|
||||
class="absolute inline-flex size-full rounded-full opacity-75"
|
||||
:class="global.available ? 'bg-success animate-ping' : 'bg-error'"
|
||||
/>
|
||||
<span
|
||||
class="relative inline-flex size-2 scale-90 rounded-full"
|
||||
:class="global.available ? 'bg-success' : 'bg-error'"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</UButton>
|
||||
</div>
|
||||
</Motion>
|
||||
|
||||
<div class="gap-x-4 inline-flex mt-4">
|
||||
<Motion
|
||||
v-for="(link, index) of footer?.links"
|
||||
:key="index"
|
||||
|
||||
:initial="{
|
||||
scale: 1.1,
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)'
|
||||
}"
|
||||
:animate="{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)'
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.6,
|
||||
delay: 0.5 + index * 0.1
|
||||
}"
|
||||
>
|
||||
<UButton
|
||||
v-bind="{ size: 'md', color: 'neutral', variant: 'ghost', ...link }"
|
||||
/>
|
||||
</Motion>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- <UMarquee
|
||||
pause-on-hover
|
||||
class="py-2 -mx-8 sm:-mx-12 lg:-mx-16 [--duration:40s]"
|
||||
>
|
||||
<Motion
|
||||
v-for="(img, index) in page.hero.images"
|
||||
:key="index"
|
||||
:initial="{
|
||||
scale: 1.1,
|
||||
opacity: 0,
|
||||
filter: 'blur(20px)'
|
||||
}"
|
||||
:animate="{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)'
|
||||
}"
|
||||
:transition="{
|
||||
duration: 0.6,
|
||||
delay: index * 0.1
|
||||
}"
|
||||
>
|
||||
<NuxtImg
|
||||
width="234"
|
||||
height="234"
|
||||
class="rounded-lg aspect-square object-cover"
|
||||
:class="index % 2 === 0 ? '-rotate-2' : 'rotate-2'"
|
||||
v-bind="img"
|
||||
/>
|
||||
</Motion>
|
||||
</UMarquee> -->
|
||||
</UPageHero>
|
||||
</template>
|
||||
42
app/components/landing/Testimonials.vue
Normal file
42
app/components/landing/Testimonials.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndexCollectionItem } from '@nuxt/content'
|
||||
|
||||
defineProps<{
|
||||
page: IndexCollectionItem
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageSection
|
||||
:ui="{
|
||||
container: 'px-0 !pt-0'
|
||||
}"
|
||||
>
|
||||
<UCarousel
|
||||
v-slot="{ item }"
|
||||
:items="page.testimonials"
|
||||
:autoplay="{ delay: 4000 }"
|
||||
loop
|
||||
dots
|
||||
:ui="{
|
||||
viewport: '-mx-4 sm:-mx-12 lg:-mx-16 bg-elevated/50 max-w-(--ui-container)'
|
||||
}"
|
||||
>
|
||||
<UPageCTA
|
||||
:description="item.quote"
|
||||
variant="naked"
|
||||
class="rounded-none"
|
||||
:ui="{
|
||||
container: 'sm:py-12 lg:py-12 sm:gap-8',
|
||||
description: '!text-base text-balance before:content-[open-quote] before:text-5xl lg:before:text-7xl before:inline-block before:text-dimmed before:absolute before:-ml-6 lg:before:-ml-10 before:-mt-2 lg:before:-mt-4 after:content-[close-quote] after:text-5xl lg:after:text-7xl after:inline-block after:text-dimmed after:absolute after:mt-1 lg:after:mt-0 after:ml-1 lg:after:ml-2'
|
||||
}"
|
||||
>
|
||||
<UUser
|
||||
v-bind="item.author"
|
||||
size="xl"
|
||||
class="justify-center"
|
||||
/>
|
||||
</UPageCTA>
|
||||
</UCarousel>
|
||||
</UPageSection>
|
||||
</template>
|
||||
60
app/components/landing/WorkExperience.vue
Normal file
60
app/components/landing/WorkExperience.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { IndexCollectionItem } from '@nuxt/content'
|
||||
|
||||
defineProps<{
|
||||
page: IndexCollectionItem
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPageSection
|
||||
:title="page.experience.title"
|
||||
:ui="{
|
||||
container: '!p-0 gap-4 sm:gap-4',
|
||||
title: 'text-left text-xl sm:text-xl lg:text-2xl font-medium',
|
||||
description: 'mt-2'
|
||||
}"
|
||||
>
|
||||
<template #description>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Motion
|
||||
v-for="(experience, index) in page.experience.items"
|
||||
:key="index"
|
||||
:initial="{ opacity: 0, transform: 'translateY(20px)' }"
|
||||
:while-in-view="{ opacity: 1, transform: 'translateY(0)' }"
|
||||
:transition="{ delay: 0.4 + 0.2 * index }"
|
||||
:in-view-options="{ once: true }"
|
||||
class="text-muted flex items-center text-nowrap gap-2"
|
||||
>
|
||||
<p class="text-sm">
|
||||
{{ experience.date }}
|
||||
</p>
|
||||
<USeparator />
|
||||
<ULink
|
||||
class="flex items-center gap-1"
|
||||
:to="experience.company.url"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="text-sm">
|
||||
{{ experience.position }}
|
||||
</span>
|
||||
<div
|
||||
class="inline-flex items-center gap-1"
|
||||
:style="{ color: experience.company.color }"
|
||||
>
|
||||
<span class="font-medium">{{ experience.company.name }}</span>
|
||||
<img
|
||||
:src="experience.company.logo"
|
||||
:alt="experience.company.name"
|
||||
/>
|
||||
</div>
|
||||
</ULink>
|
||||
</Motion>
|
||||
</div>
|
||||
</template>
|
||||
</UPageSection>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
67
app/error.vue
Normal file
67
app/error.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { NuxtError } from '#app'
|
||||
|
||||
defineProps({
|
||||
error: {
|
||||
type: Object as PropType<NuxtError>,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
useHead({
|
||||
htmlAttrs: {
|
||||
lang: 'en'
|
||||
}
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Page not found',
|
||||
description: 'We are sorry but this page could not be found.'
|
||||
})
|
||||
|
||||
const [{ data: navigation }, { data: files }] = await Promise.all([
|
||||
useAsyncData('navigation', () => {
|
||||
return Promise.all([
|
||||
queryCollectionNavigation('blog')
|
||||
])
|
||||
}, {
|
||||
transform: data => data.flat()
|
||||
}),
|
||||
useLazyAsyncData('search', () => {
|
||||
return Promise.all([
|
||||
queryCollectionSearchSections('blog')
|
||||
])
|
||||
}, {
|
||||
server: false,
|
||||
transform: data => data.flat()
|
||||
})
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader :links="navLinks" />
|
||||
|
||||
<UMain>
|
||||
<UContainer>
|
||||
<UPage>
|
||||
<UError :error="error" />
|
||||
</UPage>
|
||||
</UContainer>
|
||||
</UMain>
|
||||
|
||||
<AppFooter />
|
||||
|
||||
<ClientOnly>
|
||||
<LazyUContentSearch
|
||||
:files="files"
|
||||
shortcut="meta_k"
|
||||
:navigation="navigation"
|
||||
:links="navLinks"
|
||||
:fuse="{ resultLimit: 42 }"
|
||||
/>
|
||||
</ClientOnly>
|
||||
|
||||
<UToaster />
|
||||
</div>
|
||||
</template>
|
||||
12
app/layouts/default.vue
Normal file
12
app/layouts/default.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UContainer class="sm:border-x border-default pt-10">
|
||||
<AppHeader :links="navLinks" />
|
||||
<slot />
|
||||
<AppFooter />
|
||||
</UContainer>
|
||||
</div>
|
||||
</template>
|
||||
62
app/pages/about.vue
Normal file
62
app/pages/about.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
const { data: page } = await useAsyncData('about', () => {
|
||||
return queryCollection('about').first()
|
||||
})
|
||||
if (!page.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Page not found',
|
||||
fatal: true
|
||||
})
|
||||
}
|
||||
|
||||
const { global } = useAppConfig()
|
||||
|
||||
useSeoMeta({
|
||||
title: page.value?.seo?.title || page.value?.title,
|
||||
ogTitle: page.value?.seo?.title || page.value?.title,
|
||||
description: page.value?.seo?.description || page.value?.description,
|
||||
ogDescription: page.value?.seo?.description || page.value?.description
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPage v-if="page">
|
||||
<UPageHero
|
||||
:title="page.title"
|
||||
:description="page.description"
|
||||
orientation="horizontal"
|
||||
:ui="{
|
||||
container: 'lg:flex sm:flex-row items-center',
|
||||
title: '!mx-0 text-left',
|
||||
description: '!mx-0 text-left',
|
||||
links: 'justify-start'
|
||||
}"
|
||||
>
|
||||
<UColorModeAvatar
|
||||
class="sm:rotate-4 size-36 rounded-lg ring ring-default ring-offset-3 ring-offset-(--ui-bg)"
|
||||
:light="global.picture?.light!"
|
||||
:dark="global.picture?.dark!"
|
||||
:alt="global.picture?.alt!"
|
||||
/>
|
||||
</UPageHero>
|
||||
<UPageSection
|
||||
:ui="{
|
||||
container: '!pt-0'
|
||||
}"
|
||||
>
|
||||
<MDC
|
||||
:value="page.content"
|
||||
unwrap="p"
|
||||
/>
|
||||
<div class="flex flex-row justify-center items-center py-10 space-x-[-2rem]">
|
||||
<PolaroidItem
|
||||
v-for="(image, index) in page.images"
|
||||
:key="index"
|
||||
:image="image"
|
||||
:index
|
||||
/>
|
||||
</div>
|
||||
</UPageSection>
|
||||
</UPage>
|
||||
</template>
|
||||
118
app/pages/blog/[...slug].vue
Normal file
118
app/pages/blog/[...slug].vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import type { ContentNavigationItem } from '@nuxt/content'
|
||||
import { mapContentNavigation } from '@nuxt/ui/utils/content'
|
||||
import { findPageBreadcrumb } from '@nuxt/content/utils'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { data: page } = await useAsyncData(route.path, () =>
|
||||
queryCollection('blog').path(route.path).first()
|
||||
)
|
||||
if (!page.value) throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true })
|
||||
const { data: surround } = await useAsyncData(`${route.path}-surround`, () =>
|
||||
queryCollectionItemSurroundings('blog', route.path, {
|
||||
fields: ['description']
|
||||
})
|
||||
)
|
||||
|
||||
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation', ref([]))
|
||||
const blogNavigation = computed(() => navigation.value.find(item => item.path === '/blog')?.children || [])
|
||||
|
||||
const breadcrumb = computed(() => mapContentNavigation(findPageBreadcrumb(blogNavigation?.value, page.value?.path)).map(({ icon, ...link }) => link))
|
||||
|
||||
if (page.value.image) {
|
||||
defineOgImage({ url: page.value.image })
|
||||
} else {
|
||||
defineOgImageComponent('Blog', {
|
||||
headline: breadcrumb.value.map(item => item.label).join(' > ')
|
||||
}, {
|
||||
fonts: ['Geist:400', 'Geist:600']
|
||||
})
|
||||
}
|
||||
|
||||
const title = page.value?.seo?.title || page.value?.title
|
||||
const description = page.value?.seo?.description || page.value?.description
|
||||
|
||||
useSeoMeta({
|
||||
title,
|
||||
description,
|
||||
ogDescription: description,
|
||||
ogTitle: title
|
||||
})
|
||||
|
||||
const articleLink = computed(() => `${window?.location}`)
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UMain class="mt-20 px-2">
|
||||
<UContainer class="relative min-h-screen">
|
||||
<UPage v-if="page">
|
||||
<ULink
|
||||
to="/blog"
|
||||
class="text-sm flex items-center gap-1"
|
||||
>
|
||||
<UIcon name="lucide:chevron-left" />
|
||||
Blog
|
||||
</ULink>
|
||||
<div class="flex flex-col gap-3 mt-8">
|
||||
<div class="flex text-xs text-muted items-center justify-center gap-2">
|
||||
<span v-if="page.date">
|
||||
{{ formatDate(page.date) }}
|
||||
</span>
|
||||
<span v-if="page.date && page.minRead">
|
||||
-
|
||||
</span>
|
||||
<span v-if="page.minRead">
|
||||
{{ page.minRead }} MIN READ
|
||||
</span>
|
||||
</div>
|
||||
<NuxtImg
|
||||
:src="page.image"
|
||||
:alt="page.title"
|
||||
class="rounded-lg w-full h-[300px] object-cover object-center"
|
||||
/>
|
||||
<h1 class="text-4xl text-center font-medium max-w-3xl mx-auto mt-4">
|
||||
{{ page.title }}
|
||||
</h1>
|
||||
<p class="text-muted text-center max-w-2xl mx-auto">
|
||||
{{ page.description }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 mt-2">
|
||||
<UUser
|
||||
orientation="vertical"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="justify-center items-center text-center"
|
||||
v-bind="page.author"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UPageBody class="max-w-3xl mx-auto">
|
||||
<ContentRenderer
|
||||
v-if="page.body"
|
||||
:value="page"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 text-sm text-muted">
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="link"
|
||||
color="neutral"
|
||||
label="Copy link"
|
||||
@click="copyToClipboard(articleLink, 'Article link copied to clipboard')"
|
||||
/>
|
||||
</div>
|
||||
<UContentSurround :surround />
|
||||
</UPageBody>
|
||||
</UPage>
|
||||
</UContainer>
|
||||
</UMain>
|
||||
</template>
|
||||
76
app/pages/blog/index.vue
Normal file
76
app/pages/blog/index.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
const { data: page } = await useAsyncData('blog-page', () => {
|
||||
return queryCollection('pages').path('/blog').first()
|
||||
})
|
||||
if (!page.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Page not found',
|
||||
fatal: true
|
||||
})
|
||||
}
|
||||
const { data: posts } = await useAsyncData('blogs', () =>
|
||||
queryCollection('blog').order('date', 'DESC').all()
|
||||
)
|
||||
if (!posts.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'blogs posts not found',
|
||||
fatal: true
|
||||
})
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: page.value?.seo?.title || page.value?.title,
|
||||
ogTitle: page.value?.seo?.title || page.value?.title,
|
||||
description: page.value?.seo?.description || page.value?.description,
|
||||
ogDescription: page.value?.seo?.description || page.value?.description
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPage v-if="page">
|
||||
<UPageHero
|
||||
:title="page.title"
|
||||
:description="page.description"
|
||||
:links="page.links"
|
||||
:ui="{
|
||||
title: '!mx-0 text-left',
|
||||
description: '!mx-0 text-left',
|
||||
links: 'justify-start'
|
||||
}"
|
||||
/>
|
||||
<UPageSection
|
||||
:ui="{
|
||||
container: '!pt-0'
|
||||
}"
|
||||
>
|
||||
<UBlogPosts orientation="vertical">
|
||||
<Motion
|
||||
v-for="(post, index) in posts"
|
||||
:key="index"
|
||||
:initial="{ opacity: 0, transform: 'translateY(10px)' }"
|
||||
:while-in-view="{ opacity: 1, transform: 'translateY(0)' }"
|
||||
:transition="{ delay: 0.2 * index }"
|
||||
:in-view-options="{ once: true }"
|
||||
>
|
||||
<UBlogPost
|
||||
variant="naked"
|
||||
orientation="horizontal"
|
||||
:to="post.path"
|
||||
v-bind="post"
|
||||
:ui="{
|
||||
root: 'md:grid md:grid-cols-2 group overflow-visible transition-all duration-300',
|
||||
image:
|
||||
'group-hover/blog-post:scale-105 rounded-lg shadow-lg border-4 border-muted ring-2 ring-default',
|
||||
header:
|
||||
index % 2 === 0
|
||||
? 'sm:-rotate-1 overflow-visible'
|
||||
: 'sm:rotate-1 overflow-visible'
|
||||
}"
|
||||
/>
|
||||
</Motion>
|
||||
</UBlogPosts>
|
||||
</UPageSection>
|
||||
</UPage>
|
||||
</template>
|
||||
37
app/pages/index.vue
Normal file
37
app/pages/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
const { data: page } = await useAsyncData('index', () => {
|
||||
return queryCollection('index').first()
|
||||
})
|
||||
if (!page.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Page not found',
|
||||
fatal: true
|
||||
})
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: page.value?.seo.title || page.value?.title,
|
||||
ogTitle: page.value?.seo.title || page.value?.title,
|
||||
description: page.value?.seo.description || page.value?.description,
|
||||
ogDescription: page.value?.seo.description || page.value?.description
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPage v-if="page">
|
||||
<LandingHero :page />
|
||||
<UPageSection
|
||||
:ui="{
|
||||
container: '!pt-0 lg:grid lg:grid-cols-2 lg:gap-8'
|
||||
}"
|
||||
>
|
||||
<LandingAbout :page />
|
||||
<LandingWorkExperience :page />
|
||||
<LandingEducation :page />
|
||||
</UPageSection>
|
||||
<!-- <LandingBlog :page /> -->
|
||||
<!-- <LandingTestimonials :page /> -->
|
||||
<!-- <LandingFAQ :page /> -->
|
||||
</UPage>
|
||||
</template>
|
||||
107
app/pages/projects.vue
Normal file
107
app/pages/projects.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
const { data: page } = await useAsyncData('projects-page', () => {
|
||||
return queryCollection('pages').path('/projects').first()
|
||||
})
|
||||
if (!page.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Page not found',
|
||||
fatal: true
|
||||
})
|
||||
}
|
||||
|
||||
const { data: projects } = await useAsyncData('projects', () => {
|
||||
return queryCollection('projects').all()
|
||||
})
|
||||
|
||||
const { global } = useAppConfig()
|
||||
|
||||
useSeoMeta({
|
||||
title: page.value?.seo?.title || page.value?.title,
|
||||
ogTitle: page.value?.seo?.title || page.value?.title,
|
||||
description: page.value?.seo?.description || page.value?.description,
|
||||
ogDescription: page.value?.seo?.description || page.value?.description
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPage v-if="page">
|
||||
<UPageHero
|
||||
:title="page.title"
|
||||
:description="page.description"
|
||||
:links="page.links"
|
||||
:ui="{
|
||||
title: '!mx-0 text-left',
|
||||
description: '!mx-0 text-left',
|
||||
links: 'justify-start'
|
||||
}"
|
||||
>
|
||||
<template #links>
|
||||
<div
|
||||
v-if="page.links"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<UButton
|
||||
:label="page.links[0]?.label"
|
||||
:to="global.meetingLink"
|
||||
v-bind="page.links[0]"
|
||||
/>
|
||||
<UButton
|
||||
:to="`mailto:${global.email}`"
|
||||
v-bind="page.links[1]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</UPageHero>
|
||||
<UPageSection
|
||||
:ui="{
|
||||
container: '!pt-0'
|
||||
}"
|
||||
>
|
||||
<Motion
|
||||
v-for="(project, index) in projects"
|
||||
:key="project.title"
|
||||
:initial="{ opacity: 0, transform: 'translateY(10px)' }"
|
||||
:while-in-view="{ opacity: 1, transform: 'translateY(0)' }"
|
||||
:transition="{ delay: 0.2 * index }"
|
||||
:in-view-options="{ once: true }"
|
||||
>
|
||||
<UPageCard
|
||||
:title="project.title"
|
||||
:description="project.description"
|
||||
:to="project.url"
|
||||
orientation="horizontal"
|
||||
variant="naked"
|
||||
:reverse="index % 2 === 1"
|
||||
class="group"
|
||||
:ui="{
|
||||
wrapper: 'max-sm:order-last'
|
||||
}"
|
||||
>
|
||||
<template #leading>
|
||||
<span class="text-sm text-muted">
|
||||
{{ new Date(project.date).getFullYear() }}
|
||||
</span>
|
||||
</template>
|
||||
<template #footer>
|
||||
<ULink
|
||||
:to="project.url"
|
||||
class="text-sm text-primary flex items-center"
|
||||
>
|
||||
View Project
|
||||
<UIcon
|
||||
name="i-lucide-arrow-right"
|
||||
class="size-4 text-primary transition-all opacity-0 group-hover:translate-x-1 group-hover:opacity-100"
|
||||
/>
|
||||
</ULink>
|
||||
</template>
|
||||
<img
|
||||
:src="project.image"
|
||||
:alt="project.title"
|
||||
class="object-cover w-full h-48 rounded-lg"
|
||||
>
|
||||
</UPageCard>
|
||||
</Motion>
|
||||
</UPageSection>
|
||||
</UPage>
|
||||
</template>
|
||||
128
app/pages/speaking.vue
Normal file
128
app/pages/speaking.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
type Event = {
|
||||
title: string
|
||||
date: string
|
||||
location: string
|
||||
url?: string
|
||||
category: 'Conference' | 'Live talk' | 'Podcast'
|
||||
}
|
||||
|
||||
const { data: page } = await useAsyncData('speaking', () => {
|
||||
return queryCollection('speaking').first()
|
||||
})
|
||||
if (!page.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Page not found',
|
||||
fatal: true
|
||||
})
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: page.value?.seo?.title || page.value?.title,
|
||||
ogTitle: page.value?.seo?.title || page.value?.title,
|
||||
description: page.value?.seo?.description || page.value?.description,
|
||||
ogDescription: page.value?.seo?.description || page.value?.description
|
||||
})
|
||||
|
||||
const { global } = useAppConfig()
|
||||
|
||||
const groupedEvents = computed((): Record<Event['category'], Event[]> => {
|
||||
const events = page.value?.events || []
|
||||
const grouped: Record<Event['category'], Event[]> = {
|
||||
'Conference': [],
|
||||
'Live talk': [],
|
||||
'Podcast': []
|
||||
}
|
||||
for (const event of events) {
|
||||
if (grouped[event.category]) grouped[event.category].push(event)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString('en-US', { year: 'numeric', month: 'long' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UPage v-if="page">
|
||||
<UPageHero
|
||||
:title="page.title"
|
||||
:description="page.description"
|
||||
:ui="{
|
||||
title: '!mx-0 text-left',
|
||||
description: '!mx-0 text-left',
|
||||
links: 'justify-start'
|
||||
}"
|
||||
>
|
||||
<template #links>
|
||||
<UButton
|
||||
v-if="page.links"
|
||||
:to="`mailto:${global.email}`"
|
||||
v-bind="page.links[0]"
|
||||
/>
|
||||
</template>
|
||||
</UPageHero>
|
||||
<UPageSection
|
||||
:ui="{
|
||||
container: '!pt-0'
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(eventsInCategory, category) in groupedEvents"
|
||||
:key="category"
|
||||
class="grid grid-cols-1 lg:grid-cols-3 lg:gap-8 mb-16 last:mb-0"
|
||||
>
|
||||
<div class="lg:col-span-1 mb-4 lg:mb-0">
|
||||
<h2
|
||||
class="lg:sticky lg:top-16 text-xl font-semibold tracking-tight text-highlighted"
|
||||
>
|
||||
{{ category.replace(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase()) }}s
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<div
|
||||
v-for="(event, index) in eventsInCategory"
|
||||
:key="`${category}-${index}`"
|
||||
class="group relative pl-6 border-l border-default"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="event.url"
|
||||
:to="event.url"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<div class="mb-1 text-sm font-medium text-muted">
|
||||
<span>{{ event.location }}</span>
|
||||
<span
|
||||
v-if="event.location && event.date"
|
||||
class="mx-1"
|
||||
>·</span>
|
||||
<span v-if="event.date">{{ formatDate(event.date) }}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-highlighted">
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
|
||||
<UButton
|
||||
v-if="event.url"
|
||||
target="_blank"
|
||||
:label="event.category === 'Podcast' ? 'Listen' : 'Watch'"
|
||||
variant="link"
|
||||
class="p-0 pt-2 gap-0"
|
||||
>
|
||||
<template #trailing>
|
||||
<UIcon
|
||||
name="i-lucide-arrow-right"
|
||||
class="size-4 transition-all opacity-0 group-hover:translate-x-1 group-hover:opacity-100"
|
||||
/>
|
||||
</template>
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UPageSection>
|
||||
</UPage>
|
||||
</template>
|
||||
6
app/utils/clipboard.ts
Normal file
6
app/utils/clipboard.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function copyToClipboard(toCopy: string, message: string = 'Copied to clipboard') {
|
||||
const toast = useToast()
|
||||
navigator.clipboard.writeText(toCopy).then(() => {
|
||||
toast.add({ title: message, color: 'success', icon: 'i-lucide-check-circle' })
|
||||
})
|
||||
}
|
||||
41
app/utils/links.ts
Normal file
41
app/utils/links.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { NavigationMenuItem } from '@nuxt/ui'
|
||||
|
||||
export const navLinks: NavigationMenuItem[] = [{
|
||||
label: 'Home',
|
||||
icon: 'i-lucide-home',
|
||||
to: '/'
|
||||
}, {
|
||||
label: 'Projects',
|
||||
icon: 'i-lucide-folder',
|
||||
to: '/projects'
|
||||
// }, {
|
||||
// label: 'Blog',
|
||||
// icon: 'i-lucide-file-text',
|
||||
// to: '/blog'
|
||||
// }, {
|
||||
// label: 'Speaking',
|
||||
// icon: 'i-lucide-mic',
|
||||
// to: '/speaking'
|
||||
}, {
|
||||
label: 'About',
|
||||
icon: 'i-lucide-user',
|
||||
to: '/about'
|
||||
}, {
|
||||
label: 'Git',
|
||||
icon: 'i-simple-icons-gitea',
|
||||
to: 'https://git.kalar.codes/NickKalar',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}, {
|
||||
label: 'Resume',
|
||||
icon: 'i-lucide-file',
|
||||
onSelect: () => { const fileUrl = '/2025_Kalar_Resume.pdf';
|
||||
const fileName = '2025_Kalar_Resume.pdf';
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
}]
|
||||
Reference in New Issue
Block a user