initial commit
Some checks failed
ci / ci (22, ubuntu-latest) (push) Failing after 42s

This commit is contained in:
2025-12-02 23:50:28 -05:00
commit 0def745b98
50 changed files with 14257 additions and 0 deletions

13
.editorconfig Executable file
View File

@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# Public URL, used for OG Image when running nuxt generate
NUXT_PUBLIC_SITE_URL=

34
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# VSC
.history

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
shamefully-hoist=true

47
README.md Normal file
View File

@@ -0,0 +1,47 @@
# Portfolio
## Quick Start
```bash [Terminal]
npm create nuxt@latest -- -t github:nuxt-ui-templates/portfolio
```
## Deploy your own
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-name=portfolio&repository-url=https%3A%2F%2Fgithub.com%2Fnuxt-ui-templates%2Fportfolio&demo-image=https%3A%2F%2Fui.nuxt.com%2Fassets%2Ftemplates%2Fnuxt%2Fportfolio-dark.png&demo-url=https%3A%2F%2Fportfolio-template.nuxt.dev%2F&demo-title=Nuxt%20Portfolio%20Template&demo-description=A%20sleek%20portfolio%20template%20to%20showcase%20your%20work%2C%20skills%20and%20blog%20powered%20by%20Nuxt%20Content.)
## Setup
Make sure to install the dependencies:
```bash
pnpm install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
pnpm dev
```
## Production
Build the application for production:
```bash
pnpm build
```
Locally preview production build:
```bash
pnpm preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
## Renovate integration
Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go.

45
app/app.config.ts Normal file
View 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
View 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
View 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}]

143
content.config.ts Normal file
View File

@@ -0,0 +1,143 @@
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
const createBaseSchema = () => z.object({
title: z.string(),
description: z.string()
})
const createButtonSchema = () => z.object({
label: z.string(),
icon: z.string().optional(),
to: z.string().optional(),
color: z.enum(['primary', 'neutral', 'success', 'warning', 'error', 'info']).optional(),
size: z.enum(['xs', 'sm', 'md', 'lg', 'xl']).optional(),
variant: z.enum(['solid', 'outline', 'subtle', 'soft', 'ghost', 'link']).optional(),
target: z.enum(['_blank', '_self']).optional()
})
const createImageSchema = () => z.object({
src: z.string().editor({ input: 'media' }),
alt: z.string()
})
const createAuthorSchema = () => z.object({
name: z.string(),
description: z.string().optional(),
username: z.string().optional(),
twitter: z.string().optional(),
to: z.string().optional(),
avatar: createImageSchema().optional()
})
const createTestimonialSchema = () => z.object({
quote: z.string(),
author: createAuthorSchema()
})
export default defineContentConfig({
collections: {
index: defineCollection({
type: 'page',
source: 'index.yml',
schema: z.object({
hero: z.object({
links: z.array(createButtonSchema()),
images: z.array(createImageSchema())
}),
about: createBaseSchema(),
experience: createBaseSchema().extend({
items: z.array(z.object({
date: z.date(),
position: z.string(),
company: z.object({
name: z.string(),
url: z.string(),
logo: z.string().editor({ input: 'icon' }),
color: z.string()
})
}))
}),
education: createBaseSchema().extend({
items: z.array(z.object({
date: z.date(),
degree: z.string(),
institution: z.object({
name: z.string(),
url: z.string(),
logo: z.string().editor({ input: 'icon' }),
color: z.string()
})
}))
}),
testimonials: z.array(createTestimonialSchema()),
blog: createBaseSchema(),
faq: createBaseSchema().extend({
categories: z.array(
z.object({
title: z.string().nonempty(),
questions: z.array(
z.object({
label: z.string().nonempty(),
content: z.string().nonempty()
})
)
}))
})
})
}),
projects: defineCollection({
type: 'data',
source: 'projects/*.yml',
schema: z.object({
title: z.string().nonempty(),
description: z.string().nonempty(),
image: z.string().nonempty().editor({ input: 'media' }),
url: z.string().nonempty(),
tags: z.array(z.string()),
date: z.date()
})
}),
blog: defineCollection({
type: 'page',
source: 'blog/*.md',
schema: z.object({
minRead: z.number(),
date: z.date(),
image: z.string().nonempty().editor({ input: 'media' }),
author: createAuthorSchema()
})
}),
pages: defineCollection({
type: 'page',
source: [
{ include: 'projects.yml' },
{ include: 'blog.yml' }
],
schema: z.object({
links: z.array(createButtonSchema())
})
}),
speaking: defineCollection({
type: 'page',
source: 'speaking.yml',
schema: z.object({
links: z.array(createButtonSchema()),
events: z.array(z.object({
category: z.enum(['Live talk', 'Podcast', 'Conference']),
title: z.string(),
date: z.date(),
location: z.string(),
url: z.string().optional()
}))
})
}),
about: defineCollection({
type: 'page',
source: 'about.yml',
schema: z.object({
content: z.object({}),
images: z.array(createImageSchema())
})
}),
}
})

23
content/about.yml Normal file
View File

@@ -0,0 +1,23 @@
title: About Me
description: Learn more about my journey as a Florida-based Back-End Developer, my engineering philosophy, and my passion for crafting robust, performant systems.
content: |
Hi, I'm **Nick Kalar**, a back-end software engineering from California, currently living in Florida. For the past seven years, I've been immersed in the world of digital product creation, focusing on building experiences that are not just functional, but genuinely intuitive and engaging for users.
My path started when I started my first IT job as a Mobile Device Administrator for Aera Energy LLC. Aera was transitioning to a more mobile forward approach. The expansion of tablet computing led Aera to investigate using tablets to handle monitoring, maintenance, and asset management in the field. As the Mobile
Device Administrator, I was at the forefront of this transition, building the processes and systems of support, training field engineers and workers, and managing the small army of mobile devices deployed across the company's operations. This role ignited my passion for technology and problem-solving, leading me
to pursue further my education. After leaving Aera, I decided to go back to college at **California State University, Bakersfield** (Go Roadrunners), where I earned a Bachelor's of Science in Computer Science - Information Systems. It was there I discovered my love for coding and development. I spent countless
hours honing my skills in various programming languages and frameworks, collaborating with other students to learn and grow with.
### My Engineering Philosophy
My engineering philosophy is rooted in **collaboration**, **humanity**, and **curiosity**. I believe great software starts with people coming together and understanding the 'why' - the problem(s) at hand, the advantages and drawbacks of the available technology, and the systems needed to solve the issues.
I use research, iterative prototyping (often starting with a language like Python and moving to something faster later), and Test Driven Development to ensure the solutions I build are truly effective. My goal is to create systems that can grow and be maintained easily; a fusion of function and simplicity.
Whether I'm building microservice APIs in the cloud or writing designing and coding a fullstack system, my target is always on creating value for the end-user.
### What Drives Me
The thing that keeps me going is the joy of solving complex problems and seeing ideas come to life. I love collaborating with people who are passionate about making a difference through technology. Every project is an opportunity to learn something new, push my boundaries, and contribute to something meaningful.
### Beyond the Screen
When I'm not coding, I like to explore bookshops for interesting reads, drink coffee at local cafes, and spend quality time with my pets and my partner, Michelle.
Thanks for stopping by. Feel free to browse my [projects](/projects) or [get in touch](/#contact) if you'd like to collaborate!
# images:
# - src: https://images.unsplash.com/photo-1744877478622-a78c7a3336f6?q=80&w=1587&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
# alt: My coffee workspace

2
content/blog.yml Normal file
View File

@@ -0,0 +1,2 @@
title: Latest Articles
description: Some of my recent thoughts on design, development, and the tech industry.

101
content/index.yml Normal file
View File

@@ -0,0 +1,101 @@
seo:
title: "Nick Kalar - Software Engineer & Data Enthusiast"
description: "Welcome to my portfolio! I'm Nick Kalar, a Software Engineer and back-end developer based in Florida. I specialize in building performant services to help companies deliver to their customers faster and more reliably."
title: "Hey, I'm Nick Kalar Software Engineer"
description: "I craft efficient, thoughtful systems. Based in Florida, bringing ideas to life through code and creativity."
hero:
links:
- label: ""
to:
color: "neutral"
images:
# - src: /hero/random-1.avif
# alt: Random Image 1
about:
title: "About Me"
description: |
As a back-end developer with 7 years of experience, I leverage my curiosity and subject matter expertise to craft robust, scalable systems that drive business success.
My approach blends collaboration and strategy with technical expertise, transforming concepts into functional, purposeful applications that seamlessly integrate into larger systems.
experience:
title: Work Experience
items:
- position: "Senior Back-end Software Engineer at"
date: "2021 - 2025"
company:
name: EY
logo: "https://cdn.brandfetch.io/idB8IjfqRq/w/32/h/32/theme/dark/icon?c=1bxid64Mup7aczewSAYMX"
url: "https://ey.com"
color: "#FFE600"
education:
title: Education
items:
- degree: "B.S. in Computer Science from"
date: "2018 - 2020"
institution:
name: CSUB
logo: "https://www.csub.edu/_resources/images/csub-logo-footer.png"
url: "https://www.csub.edu"
color: "#003594"
- degree: "A.A. from"
date: "2015 - 2018"
institution:
name: Bakersfield College
logo: "https://upload.wikimedia.org/wikipedia/en/7/70/Bakersfield_college_seal.png"
url: "https://www.bakersfieldcollege.edu/index.html"
color: "#b10b2d"
testimonials:
- quote: |
Nick has been a wonderful mentor, peer, and friend. I will always
remember the patience and leadership he demonstrated when I joined our
team, and he taught me how to be an engineer in a client facing role
from the ground up. His technical agility has consistently inspired me
to grow and challenge myself.
author:
name: 'Elizabeth Drebin'
description: 'Biomedical Engineering Student at Carnegie Mellon University'
avatar:
src: 'https://media.licdn.com/dms/image/v2/D4E03AQE83RWEEpmimQ/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1700970362718?e=1766016000&v=beta&t=hUMKcwhTJaPnZksBi1-yBuSuoaH83C8YjIFMCqtZFiQ'
srcset: 'https://media.licdn.com/dms/image/v2/D4E03AQE83RWEEpmimQ/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1700970362718?e=1766016000&v=beta&t=hUMKcwhTJaPnZksBi1-yBuSuoaH83C8YjIFMCqtZFiQ 2x'
- quote: |
Nick has engaged with [Client] team members across development, product
management, and quality assurance. He's fostered stronger relationships
and open communication with these teams, gaining a deeper understanding
of the broader context of the issues he is addressing and contributing
to more holistic solutions.
author:
name: 'Kevin Von Storch'
description: 'Digital Solution Delivery Lead at Ernst & Young'
avatar:
src: 'https://media.licdn.com/dms/image/v2/C5603AQHoTzQwnBe7HQ/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1591756110221?e=1766016000&v=beta&t=qrXnoLQktukfddE6Yv0Zc2VuUUmB8hufq95xLv9vlQg'
srcset: 'https://media.licdn.com/dms/image/v2/C5603AQHoTzQwnBe7HQ/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1591756110221?e=1766016000&v=beta&t=qrXnoLQktukfddE6Yv0Zc2VuUUmB8hufq95xLv9vlQg 2x'
- quote: |
Nick is the definition of a utility player. I am so lucky that his
interests are in mobile development, allowing me the opportunity to see
his work first hand. Nick has come in and immediately added immense value
through detailed code reviews, feature creation, service level blueprint
creation, architecture design, backlog refinement and more. Nick's ability
to manage and lead team members, both more senior and below his rank, is
a testament to his leadership skills.
author:
name: 'Janaeé Wallace'
description: 'Technical Product Manager at Ernst & Young'
avatar:
src: 'https://media.licdn.com/dms/image/v2/C5603AQHD9pDwfCgKJw/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1517606283329?e=1766016000&v=beta&t=sl-Uhox5hXrTEndOB3ZQ94qdg3uolF3cOzTZI3BO02E'
srcset: 'https://media.licdn.com/dms/image/v2/C5603AQHD9pDwfCgKJw/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1517606283329?e=1766016000&v=beta&t=sl-Uhox5hXrTEndOB3ZQ94qdg3uolF3cOzTZI3BO02E 2x'
# blog:
# title: "Latest Articles"
# description: "Some of my recent thoughts"
# faq:
# title: Frequently Asked Questions
# description: Answers to common questions about my process and services.
# categories:
# - title:
# questions:
# - label:
# content: |
# - label:
# content: |
# - label:
# content: |

6
content/projects.yml Normal file
View File

@@ -0,0 +1,6 @@
title: Solving Problems, Building Experiences.
description: In my spare time, I like to build things to help me learn new skills or solve problems I encounter. Here are some of those projects.
links:
- label: "Let's talk"
color: "neutral"
- label: 'Email me'

View File

@@ -0,0 +1,6 @@
title: Incident Report Generator
description: A Python program built using the Pandas library to transform a comma-delimited CSV file to an Excel spreadsheet with charts and tables.
image: https://images.unsplash.com/photo-1573068111653-f18bef611c8a?q=80&w=2874&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
url: "https://git.kalar.codes/NickKalar/Incident_Report_Generator"
tags: ["Python", "Pandas", "Data Analysis", "Excel", "CSV"]
date: "02-14-2025"

View File

@@ -0,0 +1,6 @@
title: Library Management ETL App
description: An Extract, Transform, Load (ETL) app to gather book information from public APIs for a Proof of Concept Library Management System project.
image: https://images.unsplash.com/photo-1679055324332-189b4a7bc99e?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
url: "https://git.kalar.codes/NickKalar/LMS-DB-ETL"
tags: ["Python", "PostgreSQL", "ETL", "APIs", "Data Engineering"]
date: "06-01-2025"

View File

@@ -0,0 +1,6 @@
title: Meal Picker
description: An app made to help keep track of ingredients for meals, and to build markdown checklists for shopping.
image: https://images.unsplash.com/photo-1623265300797-4a3541e29a60?q=80&w=2940&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
url: "https://git.kalar.codes/NickKalar/Meal-Picker"
tags: ["Python", "Flet", "UI"]
date: "11-15-2025"

21
content/speaking.yml Normal file
View File

@@ -0,0 +1,21 @@
# title: Talks & Workshops
# description:
# links:
# - label: Invite me to speak
# size: md
# events:
# - category: Conference
# title: ""
# date: "2024-10-26"
# location: ""
# url: "#"
# - category: Live talk
# title: ""
# date: "2024-08-20"
# location: ""
# url: "#"
# - category: Podcast
# title: ""
# date: "2024-01-30"
# location: ""
# url: "#"

8
eslint.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'@typescript-eslint/no-explicit-any': 'off'
}
})

38
nuxt.config.ts Normal file
View File

@@ -0,0 +1,38 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
modules: [
'@nuxt/eslint',
'@nuxt/image',
'@nuxt/ui',
'@nuxt/content',
'@vueuse/nuxt',
'nuxt-og-image',
'motion-v/nuxt'
],
devtools: {
enabled: true
},
css: ['~/assets/css/main.css'],
compatibilityDate: '2024-11-01',
nitro: {
prerender: {
routes: [
'/'
],
crawlLinks: true
}
},
eslint: {
config: {
stylistic: {
commaDangle: 'never',
braceStyle: '1tbs'
}
}
}
})

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.75",
"@iconify-json/simple-icons": "^1.2.60",
"@nuxt/content": "^3.8.2",
"@nuxt/image": "^2.0.0",
"@nuxt/ui": "^4.2.1",
"@vueuse/nuxt": "^13.9.0",
"better-sqlite3": "^12.4.6",
"motion-v": "^1.7.3",
"nuxt": "^4.2.1",
"nuxt-og-image": "^5.1.12"
},
"devDependencies": {
"@nuxt/eslint": "^1.10.0",
"eslint": "^9.39.1",
"typescript": "^5.9.3",
"vue-tsc": "^3.1.5"
},
"packageManager": "pnpm@10.23.0"
}

12263
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

10
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,10 @@
ignoredBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- esbuild
- unrs-resolver
- vue-demi
onlyBuiltDependencies:
- better-sqlite3
- sharp

Binary file not shown.

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

1
public/robots.txt Normal file
View File

@@ -0,0 +1 @@

13
renovate.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}],
"postUpdateOptions": ["pnpmDedupe"]
}

10
tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{ "path": "./.nuxt/tsconfig.app.json" },
{ "path": "./.nuxt/tsconfig.server.json" },
{ "path": "./.nuxt/tsconfig.shared.json" },
{ "path": "./.nuxt/tsconfig.node.json" }
]
}