This commit is contained in:
13
.editorconfig
Executable file
13
.editorconfig
Executable 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
2
.env.example
Normal 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
34
.github/workflows/ci.yml
vendored
Normal 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
27
.gitignore
vendored
Normal 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
|
||||
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Portfolio
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash [Terminal]
|
||||
npm create nuxt@latest -- -t github:nuxt-ui-templates/portfolio
|
||||
```
|
||||
|
||||
## Deploy your own
|
||||
|
||||
[](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
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);
|
||||
}
|
||||
}]
|
||||
143
content.config.ts
Normal file
143
content.config.ts
Normal 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
23
content/about.yml
Normal 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
2
content/blog.yml
Normal 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
101
content/index.yml
Normal 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
6
content/projects.yml
Normal 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'
|
||||
6
content/projects/incident-generator.yml
Normal file
6
content/projects/incident-generator.yml
Normal 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"
|
||||
6
content/projects/lms-etl.yml
Normal file
6
content/projects/lms-etl.yml
Normal 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"
|
||||
6
content/projects/meal-picker.yml
Normal file
6
content/projects/meal-picker.yml
Normal 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
21
content/speaking.yml
Normal 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
8
eslint.config.mjs
Normal 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
38
nuxt.config.ts
Normal 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
33
package.json
Normal 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
12263
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
pnpm-workspace.yaml
Normal file
10
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
ignoredBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- unrs-resolver
|
||||
- vue-demi
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- sharp
|
||||
BIN
public/2025_Kalar_Resume.pdf
Normal file
BIN
public/2025_Kalar_Resume.pdf
Normal file
Binary file not shown.
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
1
public/robots.txt
Normal file
1
public/robots.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
13
renovate.json
Normal file
13
renovate.json
Normal 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
10
tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user