@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Public URL, used for OG Image when running nuxt generate
|
||||
NUXT_PUBLIC_SITE_URL=
|
||||
@@ -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@v5
|
||||
|
||||
- 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Nuxt UI Templates
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Nuxt Portfolio Template
|
||||
|
||||
[](https://ui.nuxt.com)
|
||||
|
||||
Use this template to create your own portfolio with [Nuxt UI](https://ui.nuxt.com).
|
||||
|
||||
- [Live demo](https://portfolio-template.nuxt.dev/)
|
||||
- [Documentation](https://ui.nuxt.com/getting-started/installation)
|
||||
|
||||
<a href="https://portfolio-template.nuxt.dev/" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://ui.nuxt.com/assets/templates/nuxt/portfolio-dark.png">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://ui.nuxt.com/assets/templates/nuxt/portfolio-light.png">
|
||||
<img alt="Nuxt Portfolio Template" src="https://ui.nuxt.com/assets/templates/nuxt/portfolio-dark.png">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash [Terminal]
|
||||
npm create nuxt@latest -- -t ui/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.
|
||||
@@ -0,0 +1,50 @@
|
||||
export default defineAppConfig({
|
||||
global: {
|
||||
picture: {
|
||||
dark: "/images/focinho.jpg",
|
||||
light: "/images/focinho.jpg",
|
||||
alt: "My profile picture",
|
||||
},
|
||||
meetingLink: "https://cal.com/",
|
||||
email: "[email protected]",
|
||||
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 • © ${new Date().getFullYear()}`,
|
||||
colorMode: false,
|
||||
links: [
|
||||
{
|
||||
icon: "i-simple-icons-discord",
|
||||
to: "https://go.nuxt.com/discord",
|
||||
target: "_blank",
|
||||
"aria-label": "Nuxt on Discord",
|
||||
},
|
||||
{
|
||||
icon: "i-simple-icons-x",
|
||||
to: "https://go.nuxt.com/x",
|
||||
target: "_blank",
|
||||
"aria-label": "Nuxt on X",
|
||||
},
|
||||
{
|
||||
icon: "i-simple-icons-github",
|
||||
to: "https://github.com/nuxt/ui",
|
||||
target: "_blank",
|
||||
"aria-label": "Nuxt UI on GitHub",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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 - Nuxt Portfolio Template',
|
||||
ogImage: 'https://ui.nuxt.com/assets/templates/nuxt/portfolio-light.png',
|
||||
twitterImage: 'https://ui.nuxt.com/assets/templates/nuxt/portfolio-light.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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<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',
|
||||
linkLeadingIcon: 'hidden'
|
||||
}"
|
||||
>
|
||||
<template #list-trailing>
|
||||
<ColorModeButton />
|
||||
</template>
|
||||
</UNavigationMenu>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,64 @@
|
||||
<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>
|
||||
<NuxtImg
|
||||
v-if="experience.company.image"
|
||||
:src="experience.company.image"
|
||||
alt="Company Logo"
|
||||
class="w-5 h-5 object-contain"
|
||||
/>
|
||||
<UIcon
|
||||
v-else-if="experience.company.logo"
|
||||
:name="experience.company.logo"
|
||||
/>
|
||||
</div>
|
||||
</ULink>
|
||||
</Motion>
|
||||
</div>
|
||||
</template>
|
||||
</UPageSection>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,81 @@
|
||||
<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"
|
||||
:title="post.title"
|
||||
:description="post.description"
|
||||
:image="post.image"
|
||||
:date="post.date"
|
||||
:author="post.author"
|
||||
:badge="{ label: `${post.minRead} min read`, color: 'neutral' }"
|
||||
: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>
|
||||
@@ -0,0 +1,36 @@
|
||||
<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 />
|
||||
</UPageSection>
|
||||
<LandingBlog :page />
|
||||
<LandingTestimonials :page />
|
||||
<LandingFAQ :page />
|
||||
</UPage>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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' })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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'
|
||||
}]
|
||||
@@ -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" }).optional(),
|
||||
image: z.string().editor({ input: "media" }).optional(),
|
||||
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()),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
title: About Me
|
||||
description: Learn more about my journey as a Boston-based UX/UI Designer & Front-End Developer, my design philosophy, and my passion for crafting intuitive digital experiences.
|
||||
content: |
|
||||
Hi, I'm **Emma Thompson**, a UX/UI Designer and Front-End Developer based right here in Boston. For the past six 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 at **Boston University**, where I earned a degree in Interactive Design. It was there I discovered my dual passion for the visual language of design and the logical puzzle of code. Since then, I've honed my skills in both areas, believing that the best digital products are born from a deep understanding of both aesthetics and implementation.
|
||||
|
||||
### My Design Philosophy
|
||||
|
||||
My design philosophy is rooted in **empathy** and **problem-solving**. I believe great design starts with understanding the 'why' – the user's needs, motivations, and pain points. I use research, iterative prototyping (often with tools like Figma), and user testing to ensure the solutions I build are truly effective.
|
||||
|
||||
I strive to create interfaces that feel effortless, where functionality and beauty work hand-in-hand. Whether I'm crafting pixel-perfect UIs or writing clean Vue.js/Nuxt.js code, my focus is always on creating value for the end-user.
|
||||
|
||||
### What Drives Me
|
||||
|
||||
What keeps me excited about this work is the constant learning and the challenge of translating complex ideas into simple, elegant solutions. There's nothing more rewarding than seeing a design come to life and knowing it's making someone's digital interaction easier or more enjoyable.
|
||||
|
||||
### Beyond the Screen
|
||||
|
||||
When I'm not designing or coding, you might find me exploring Boston's latest coffee shops, hiking nearby trails, or occasionally contributing to open-source projects.
|
||||
|
||||
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
|
||||
- src: https://images.unsplash.com/photo-1744429523595-2c06b8611242?q=80&w=1587&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
|
||||
alt: My trip to Tokyo
|
||||
@@ -0,0 +1,2 @@
|
||||
title: Latest Articles
|
||||
description: Some of my recent thoughts on design, development, and the tech industry.
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: "From Mockup to Market: My End-to-End Product Design Process"
|
||||
description: A detailed breakdown of my iterative design methodology, from
|
||||
initial research to final handoff, with practical tips for designers at every
|
||||
stage.
|
||||
date: 2025-04-23
|
||||
image: https://images.pexels.com/photos/1050312/pexels-photo-1050312.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1
|
||||
minRead: 8
|
||||
author:
|
||||
name: Emma Thompson
|
||||
avatar:
|
||||
src: https://images.unsplash.com/photo-1701615004837-40d8573b6652?q=80&w=1480&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
|
||||
alt: Emma Thompson
|
||||
---
|
||||
|
||||
Creating successful digital products isn't about following a rigid formula—it's about developing a flexible framework that adapts to the unique challenges of each project. After refining my approach across dozens of products, I've developed a process that consistently delivers results while leaving room for creativity and iteration.
|
||||
|
||||
In this article, I'll walk through my end-to-end design process, from initial discovery to developer handoff, using my recent work on the EcoTrack application as a case study.
|
||||
|
||||
## Phase 1: Discovery & Research
|
||||
|
||||
Every great product starts with understanding the problem it's trying to solve. For EcoTrack, our challenge was creating an engaging way for users to track their environmental impact without feeling overwhelmed by guilt or complex data.
|
||||
|
||||
### User Interviews
|
||||
|
||||
I began by conducting interviews with 12 potential users across different demographics, focusing on their current habits and attitudes toward sustainability. These conversations revealed a crucial insight: most people wanted to make environmentally friendly choices but felt paralyzed by the complexity of calculating their impact.
|
||||
|
||||
> "I care about the environment, but I have no idea if using a paper bag is actually better than plastic, or if my reusable water bottle makes any difference." — Interview participant
|
||||
|
||||
### Competitive Analysis
|
||||
|
||||
Next, I analyzed existing sustainability apps, creating a feature comparison matrix to identify gaps and opportunities. Most competitors focused on carbon footprint calculations but failed to provide actionable guidance or positive reinforcement.
|
||||
|
||||
### Defining Success
|
||||
|
||||
Before opening Figma, I collaborated with stakeholders to define clear success metrics:
|
||||
|
||||
- Increase daily active usage by 40%
|
||||
- Improve user-reported understanding of environmental impact
|
||||
- Drive measurable behavior changes in at least two sustainability categories
|
||||
|
||||
## Phase 2: Ideation & Conceptualization
|
||||
|
||||
With a solid understanding of the problem space, I moved into the creative phase of the process.
|
||||
|
||||
### Sketching
|
||||
|
||||
I always start with pen and paper, rapidly exploring different approaches without the constraints of digital tools. For EcoTrack, I filled three sketchbooks with concepts ranging from gamified experiences to data-heavy dashboards.
|
||||
|
||||
### Information Architecture
|
||||
|
||||
Based on research insights, I developed a user-centered information architecture that prioritized simplicity and actionable information:
|
||||
|
||||
1. **Dashboard** — Personalized overview with immediate impact insights
|
||||
2. **Daily Tracker** — Simple logging of activities with immediate feedback
|
||||
3. **Impact Journey** — Visualization of progress over time
|
||||
4. **Action Center** — Customized recommendations based on user behavior
|
||||
|
||||
### Design Principles
|
||||
|
||||
I established four core design principles to guide all decisions:
|
||||
|
||||
- **Simplify complexity** — Translate environmental impact into understandable units
|
||||
- **Celebrate progress** — Focus on positive reinforcement rather than guilt
|
||||
- **Enable informed choices** — Provide context for decision-making
|
||||
- **Design for habit formation** — Create satisfying interaction loops
|
||||
|
||||
## Phase 3: Prototyping & Testing
|
||||
|
||||
With the conceptual framework in place, I moved into the iterative cycle of prototyping and testing.
|
||||
|
||||
### Low-Fidelity Wireframes
|
||||
|
||||
I created wireframes focusing on user flows and information hierarchy, deliberately keeping the visual design minimal to focus feedback on functionality and structure.
|
||||
|
||||
### User Testing (Round 1)
|
||||
|
||||
Testing wireframes with 8 participants revealed several key insights:
|
||||
|
||||
- Users wanted more immediate feedback when logging activities
|
||||
- The impact visualization wasn't intuitive for most users
|
||||
- People were confused by technical environmental terminology
|
||||
|
||||
### Mid-Fidelity Prototypes
|
||||
|
||||
Based on testing feedback, I refined the concept and developed interactive prototypes with more visual detail, focusing on:
|
||||
|
||||
- Simplified data visualization using familiar metaphors
|
||||
- Immediate positive reinforcement for logged activities
|
||||
- Progressive disclosure of more complex environmental information
|
||||
|
||||
### User Testing (Round 2)
|
||||
|
||||
A second round of testing showed significant improvements in usability, but highlighted new challenges:
|
||||
|
||||
- Users wanted to compare their impact with friends or community averages
|
||||
- Weekly summaries were more motivating than daily statistics
|
||||
- The onboarding process felt too lengthy
|
||||
|
||||
## Phase 4: Visual Design & Refinement
|
||||
|
||||
With the core experience validated, I moved into high-fidelity visual design.
|
||||
|
||||
### Visual Language
|
||||
|
||||
I developed a visual language that balanced approachability with credibility:
|
||||
|
||||
- A nature-inspired color palette with clear functional color coding
|
||||
- Custom iconography that simplified complex concepts
|
||||
- Typography that prioritized readability across devices
|
||||
- Micro-interactions that provided satisfaction and reinforcement
|
||||
|
||||
### Design System
|
||||
|
||||
To ensure consistency and facilitate development, I created a comprehensive design system including:
|
||||
|
||||
- Component library with documented states and behaviors
|
||||
- Responsive layout guidelines
|
||||
- Animation specifications
|
||||
- Accessibility standards
|
||||
|
||||
### Final Prototype
|
||||
|
||||
The final prototype brought together all elements into a cohesive experience, which we tested with a broader user group before moving to development.
|
||||
|
||||
## Phase 5: Implementation & Iteration
|
||||
|
||||
The design process doesn't end when development begins—it evolves.
|
||||
|
||||
### Developer Collaboration
|
||||
|
||||
I worked closely with developers throughout implementation, participating in code reviews and adjusting designs to address technical constraints while preserving the core experience.
|
||||
|
||||
### Analytics Implementation
|
||||
|
||||
We integrated analytics to track our success metrics, setting up dashboards to monitor key interactions and user journeys.
|
||||
|
||||
### Post-Launch Iteration
|
||||
|
||||
After launch, we established a regular cycle of analysis and iteration:
|
||||
|
||||
- Weekly reviews of user feedback and behavior data
|
||||
- Bi-weekly design sprints to address emerging issues
|
||||
- Monthly feature planning based on usage patterns
|
||||
|
||||
## Results & Learnings
|
||||
|
||||
Six months after launch, EcoTrack has exceeded our initial success metrics:
|
||||
|
||||
- 52% increase in daily active usage
|
||||
- 78% of users report better understanding of their environmental impact
|
||||
- Average user has adopted 3.4 new sustainable habits
|
||||
|
||||
The most valuable lesson from this project was the importance of making abstract concepts tangible. By translating complex environmental data into personal, actionable insights, we created an experience that not only educated users but empowered them to make meaningful changes.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Effective product design is never a linear journey—it's a continuous cycle of learning and refinement. By staying focused on user needs while maintaining a flexible approach to problem-solving, we can create products that not only meet business objectives but genuinely improve people's lives.
|
||||
|
||||
I'd love to hear about your own design process and how you approach similar challenges. Feel free to reach out with questions or share your experiences in the comments below.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
title: How I Built My Design System from Scratch
|
||||
description: A practical guide to creating your own design system, from initial
|
||||
audit to implementation, and the lessons learned along the way.
|
||||
date: 2025-03-05
|
||||
image: https://images.pexels.com/photos/196644/pexels-photo-196644.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1
|
||||
minRead: 6
|
||||
author:
|
||||
name: Emma Thompson
|
||||
avatar:
|
||||
src: https://images.unsplash.com/photo-1701615004837-40d8573b6652?q=80&w=1480&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
|
||||
alt: Emma Thompson
|
||||
---
|
||||
|
||||
After years of starting each project with a blank Figma file, I finally took the plunge and created my own comprehensive design system. The process was both challenging and incredibly rewarding, and I wanted to share my approach for other designers considering the same journey.
|
||||
|
||||
I started by auditing five of my recent projects, identifying common patterns and components that appeared across different designs. This revealed inconsistencies in my work that I hadn't noticed before—seven slightly different button styles, inconsistent spacing rules, and text styles that varied without clear purpose.
|
||||
|
||||
Rather than creating a rigid system upfront, I built it iteratively through a real client project. For the EcoTrack app, I documented each component as I designed it, creating a living system that evolved with the project's needs.
|
||||
|
||||
The core of my system includes:
|
||||
|
||||
- A flexible color system with semantic naming conventions
|
||||
- Typography scales based on the golden ratio
|
||||
- Component variants with clear usage guidelines
|
||||
- Spacing and layout rules that maintain consistency across devices
|
||||
|
||||
The biggest challenge wasn't technical but psychological—learning to trust the system instead of reinventing solutions for each new problem. But the payoff has been enormous: my design process is now 40% faster, client revisions have decreased significantly, and handoff to development is much smoother.
|
||||
|
||||
If you're considering building your own system, my advice is to start small with core elements, test them on real projects, and document as you go. A good design system should feel like a trusted collaborator, not a set of restrictions.
|
||||
|
||||
I've attached a template of my component documentation method below—feel free to adapt it for your own workflow!
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
title: The Psychology of Color in UI Design
|
||||
description: Exploring how strategic color choices can influence user behavior,
|
||||
evoke emotions, and enhance the overall user experience of digital products.
|
||||
date: 2025-03-15
|
||||
image: https://images.pexels.com/photos/40799/paper-colorful-color-loose-40799.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1
|
||||
minRead: 5
|
||||
author:
|
||||
name: Emma Thompson
|
||||
avatar:
|
||||
src: https://images.unsplash.com/photo-1701615004837-40d8573b6652?q=80&w=1480&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
|
||||
alt: Emma Thompson
|
||||
---
|
||||
|
||||
Color is one of the most powerful tools in my design arsenal, yet I find it's often reduced to mere aesthetics or brand guidelines. After conducting a series of A/B tests for the Wavelength music app redesign, I've gathered some fascinating insights about how color psychology directly impacts user behavior.
|
||||
|
||||
When we initially launched the app, we used a vibrant purple as our primary action color. The color looked great with our brand palette, but our conversion metrics were underwhelming. On a hunch, I proposed testing different primary colors while keeping all other elements identical.
|
||||
|
||||
The results were striking: switching to a specific shade of blue increased our call-to-action conversion by 34%. Even more interesting was how different user segments responded to color variations—younger users engaged more with vibrant tones, while our 35+ demographic showed stronger preference for more subdued colors.
|
||||
|
||||
Beyond conversion metrics, I discovered that color significantly affected how users perceived waiting times. By implementing a softer color progression in our loading animations, users reported that the app felt faster, even though the actual loading times remained unchanged.
|
||||
|
||||
I've since developed a framework for color decision-making that goes beyond aesthetics:
|
||||
|
||||
1. Consider the emotional response you want to evoke
|
||||
2. Test color choices with your specific user demographics
|
||||
3. Use color to create visual hierarchies that guide users naturally
|
||||
4. Consider cultural associations of colors for international audiences
|
||||
5. Ensure sufficient contrast for readability and accessibility
|
||||
|
||||
The most valuable lesson I've learned is that there are no universal "right" colors—only colors that effectively communicate your message and guide users toward their goals within your specific context.
|
||||
|
||||
Next time you're selecting a color palette, think beyond what looks good and consider what your colors are actually saying to your users.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: The Case for Slow Design in a Fast-Paced Digital World
|
||||
description: Why designing digital experiences that encourage users to slow down
|
||||
and engage deeply can lead to more meaningful interactions and better
|
||||
outcomes.
|
||||
date: 2025-01-28
|
||||
image: https://images.pexels.com/photos/4050314/pexels-photo-4050314.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1
|
||||
minRead: 7
|
||||
author:
|
||||
name: Emma Thompson
|
||||
avatar:
|
||||
src: https://images.unsplash.com/photo-1701615004837-40d8573b6652?q=80&w=1480&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
|
||||
alt: Emma Thompson
|
||||
---
|
||||
|
||||
I recently took on a project that challenged everything about my usual design process. A small literary journal wanted a digital platform that encouraged readers to slow down and engage deeply with content—the exact opposite of most websites optimized for quick consumption.
|
||||
|
||||
This got me thinking about what I'm calling "slow design"—an approach that intentionally creates space for contemplation rather than rapid interaction.
|
||||
|
||||
For the Wordsmith Journal, I experimented with subtle animations that respond to reading pace, typography that encourages focus, and navigation that reveals content gradually rather than all at once. The result feels more like turning pages in a physical book than scrolling through a typical website.
|
||||
|
||||
User testing revealed something fascinating: readers spent 3x longer with articles and reported higher satisfaction and better recall of content compared to the journal's previous site. By designing for attention rather than distraction, we created a digital experience that honors the thoughtful nature of the content itself.
|
||||
|
||||
I'm now incorporating elements of slow design into all my projects, asking: "Where can we create moments of pause? How can we reward attention rather than just capturing it?"
|
||||
|
||||
In our rush to optimize for engagement metrics, I think we've forgotten that sometimes the most meaningful digital experiences are the ones that don't demand immediate action but instead create space for thought.
|
||||
@@ -0,0 +1,107 @@
|
||||
seo:
|
||||
title: "Fernando Videira - Full Stack Developer"
|
||||
description: "Welcome to my portfolio! I'm Fernando Videira, a full stack developer based in Portugal. I specialize in creating user-centered digital experiences that are both beautiful and functional."
|
||||
title: "Hey, I'm Fernando Videira a Full Stack Developer"
|
||||
description: "I craft intuitive digital experiences where design meets functionality. Based in Portugal, bringing ideas to life through code and creativity."
|
||||
|
||||
hero:
|
||||
links:
|
||||
- label: "Use this template"
|
||||
to: https://github.com/nuxt-ui-templates/portfolio
|
||||
color: "neutral"
|
||||
images:
|
||||
- src: /hero/random-1.avif
|
||||
alt: Random Image 1
|
||||
- src: /hero/random-2.avif
|
||||
alt: Random Image 2
|
||||
- src: /hero/random-3.avif
|
||||
alt: Random Image 3
|
||||
- src: /hero/random-4.avif
|
||||
alt: Random Image 4
|
||||
- src: /hero/random-5.avif
|
||||
alt: Random Image 5
|
||||
- src: /hero/random-6.avif
|
||||
alt: Random Image 6
|
||||
- src: /hero/random-7.avif
|
||||
alt: Random Image 7
|
||||
- src: /hero/random-8.avif
|
||||
alt: Random Image 8
|
||||
- src: /hero/random-9.avif
|
||||
alt: Random Image 9
|
||||
about:
|
||||
title: "About Me"
|
||||
description: |
|
||||
I am a Software Engineering student from Portugal with 4+ years of experience from my internships and Self Learning + Personal Projects.
|
||||
I am passionate about technology and always eager to learn more.
|
||||
experience:
|
||||
title: Work Experience
|
||||
items:
|
||||
- position: "QA Engineer Intern at"
|
||||
date: "Feb 2023 - Jul 2023"
|
||||
company:
|
||||
name: Void Software
|
||||
image: "/images/void-software.png"
|
||||
url: "https://void.pt"
|
||||
color: "#00DC82"
|
||||
testimonials:
|
||||
- quote: "Emma's approach to UX design completely transformed our product. She has a rare ability to balance beautiful aesthetics with functional simplicity. The redesign not only looked better, but it reduced our customer support tickets by 40% and increased conversion rates across all key metrics."
|
||||
author:
|
||||
name: "Sarah Chen"
|
||||
description: "Product Director at Bloom Finance"
|
||||
avatar:
|
||||
src: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=40&h=40&q=80"
|
||||
srcset: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=80&h=80&q=80 2x"
|
||||
- quote: "Working with Emma was the best decision we made for our startup. She didn't just deliver designs—she challenged our assumptions, conducted thorough user research, and created an experience that truly resonated with our audience. Her technical knowledge of front-end development meant the handoff to our engineering team was seamless."
|
||||
author:
|
||||
name: "Michael Rodriguez"
|
||||
description: "Co-founder of Wavelength Music"
|
||||
avatar:
|
||||
src: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=40&h=40&q=80"
|
||||
srcset: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=80&h=80&q=80 2x"
|
||||
- quote: "Emma stands out in her ability to translate complex sustainability data into intuitive interfaces. Her work on EcoTrack wasn't just visually stunning—it fundamentally changed how our users interact with environmental information. She approaches each problem with both creativity and analytical rigor, which is exactly what we needed."
|
||||
author:
|
||||
name: "Dr. Aisha Johnson"
|
||||
description: "Chief Innovation Officer at GreenTech Solutions"
|
||||
avatar:
|
||||
src: "https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=40&h=40&q=80"
|
||||
srcset: "https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=80&h=80&q=80 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: Services & Process
|
||||
questions:
|
||||
- label: What services do you offer?
|
||||
content: |
|
||||
I specialize in UX/UI design and front-end development. This includes user research, wireframing, interactive prototyping, creating intuitive user interfaces, building responsive websites and web applications (especially with Vue.js/Nuxt.js), and developing design systems. My goal is to create seamless digital experiences from concept to deployment.
|
||||
- label: What is your design process like?
|
||||
content: |
|
||||
My process is collaborative and iterative, typically involving stages like Discovery & Research, Ideation & Prototyping, User Testing, Visual Design, and close collaboration with development teams during implementation. I tailor the process based on project needs, always focusing on user-centered solutions.
|
||||
- label: Do you work with startups?
|
||||
content: |
|
||||
Absolutely! I enjoy working with startups to help shape their product vision and create user-friendly interfaces from the ground up. I can adapt my process to fit the fast-paced startup environment.
|
||||
- title: Pricing & Timelines
|
||||
questions:
|
||||
- label: How much does a project typically cost?
|
||||
content: |
|
||||
Project costs vary based on scope, complexity, features, and timeline. For comprehensive UX/UI design and front-end development projects, my engagements typically start around $5,000, with average projects ranging between $8,000 and $25,000. For consulting or specific design tasks, my day rate is $700.
|
||||
- label: What are your payment terms?
|
||||
content: |
|
||||
I generally require a 40% deposit to schedule the project and begin work, with the remaining 60% due upon successful project completion and delivery. I accept payments via bank transfer and Stripe.
|
||||
- label: How long does a typical project take?
|
||||
content: |
|
||||
Timelines depend heavily on the project's scope and complexity. Smaller projects might take 3-4 weeks, while larger, more involved projects can range from 2 to 4 months. I always provide a detailed timeline estimate after the initial discovery phase.
|
||||
- label: Do you offer retainers or ongoing support?
|
||||
content: |
|
||||
Yes, for clients needing ongoing design support, feature development, or maintenance, I offer monthly retainer options tailored to specific needs. Let's discuss if this is something you're interested in.
|
||||
- title: About Me
|
||||
questions:
|
||||
- label: What do you enjoy most about your work?
|
||||
content: |
|
||||
I love the challenge of solving complex problems through design and technology. It's incredibly rewarding to see people interact with something I've created and find it genuinely useful and easy to navigate. Bridging the gap between user needs and technical possibilities is what truly excites me.
|
||||
- label: What are your hobbies outside of work?
|
||||
content: |
|
||||
When I'm not designing or coding, I enjoy exploring Portugal's neighborhoods, trying out new coffee shops, and hiking in the nearby reservations. I'm also passionate about photography and occasionally contribute to open-source projects.
|
||||
@@ -0,0 +1,6 @@
|
||||
title: Designing Interfaces, Building Experiences.
|
||||
description: I've worked on a variety of projects, focusing on creating intuitive digital experiences where thoughtful design meets clean code. Here are some highlights I'm proud of, showcasing my process from concept to execution.
|
||||
links:
|
||||
- label: "Let's talk"
|
||||
color: "neutral"
|
||||
- label: 'Email me'
|
||||
@@ -0,0 +1,6 @@
|
||||
title: Bloom Finance App Redesign
|
||||
description: Led the complete UX/UI overhaul and front-end implementation for a personal finance platform, focusing on data visualization clarity and improving user onboarding flow. Resulted in a 32% increase in user retention.
|
||||
image: https://images.unsplash.com/photo-1551288049-bebda4e38f71?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80 # Example image of dashboards/finance app
|
||||
url: "#"
|
||||
tags: ["UX Design", "UI Design"]
|
||||
date: "2024"
|
||||
@@ -0,0 +1,6 @@
|
||||
title: EcoTrack Sustainability App
|
||||
description: Created a mobile-first application to help users track and reduce their environmental impact. Translated complex sustainability metrics into an accessible and motivating user interface.
|
||||
image: https://images.unsplash.com/photo-1613858749733-3a3e456e3d9e?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D
|
||||
url: "#"
|
||||
tags: ["UX Research", "UI Design", "Mobile App"]
|
||||
date: "2023"
|
||||
@@ -0,0 +1,6 @@
|
||||
title: Internal Developer Hub (Nuxt Team)
|
||||
description: Designed and built key components for an internal documentation and tooling hub for Nuxt developers, focusing on improving developer experience and streamlining access to resources.
|
||||
image: https://images.unsplash.com/photo-1517694712202-14dd9538aa97?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80 # Example image related to code/development
|
||||
url: "#"
|
||||
tags: ["Developer Tools", "UX Design", "Nuxt", "Design System", "Internal Tools"]
|
||||
date: "2024"
|
||||
@@ -0,0 +1,6 @@
|
||||
title: Wavelength Music Streaming Service
|
||||
description: Designed and developed the user interface for an indie music streaming service, focusing on discovery features and creating a unique, engaging listening experience using custom audio visualizations.
|
||||
image: https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80 # Example image related to music/audio
|
||||
url: "#"
|
||||
tags: ["UI Design", "Front-End Dev", "Animation"]
|
||||
date: "2023"
|
||||
@@ -0,0 +1,48 @@
|
||||
title: Talks & Workshops
|
||||
description: Explore talks and workshops I've given on UX design, front-end development, and building better digital products.
|
||||
links:
|
||||
- label: Invite me to speak
|
||||
size: md
|
||||
events:
|
||||
- category: Conference
|
||||
title: "Bridging the Gap: Design Systems for Designer-Developer Collaboration"
|
||||
date: "2024-10-26"
|
||||
location: "VueConf US, Miami, FL"
|
||||
url: "#"
|
||||
- category: Conference
|
||||
title: "Intuitive Interfaces: Applying UX Principles in Nuxt.js Applications"
|
||||
date: "2024-06-15"
|
||||
location: "Nuxt Nation, Online"
|
||||
url: "#"
|
||||
- category: Conference
|
||||
title: "Accessibility First: Building Inclusive Web Apps"
|
||||
date: "2023-11-08"
|
||||
location: "Boston Web Performance Meetup, Boston, MA"
|
||||
- category: Live talk
|
||||
title: "Mastering Component Design in Figma & Vue"
|
||||
date: "2024-08-20"
|
||||
location: "Design & Dev Boston Meetup, Boston, MA"
|
||||
url: "#"
|
||||
- category: Live talk
|
||||
title: "From Wireframe to Web: A Practical Front-End Workflow"
|
||||
date: "2024-03-05"
|
||||
location: "Internal Workshop @ PreviousCompany Inc."
|
||||
- category: Live talk
|
||||
title: "The Power of User Research in Product Development"
|
||||
date: "2023-09-12"
|
||||
location: "Boston University Guest Lecture, Boston, MA"
|
||||
- category: Podcast
|
||||
title: "Designing for Developers: A UX Perspective"
|
||||
date: "2024-07-18"
|
||||
location: "Syntax.fm (Guest)"
|
||||
url: "#"
|
||||
- category: Podcast
|
||||
title: "The Future of Front-End Frameworks & Design Tooling"
|
||||
date: "2024-01-30"
|
||||
location: "Design Details Podcast (Guest)"
|
||||
url: "#"
|
||||
- category: Podcast
|
||||
title: "Building a Career in UX/UI & Development"
|
||||
date: "2023-05-22"
|
||||
location: "TechForward Boston Podcast"
|
||||
url: "#"
|
||||
@@ -0,0 +1,8 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt({
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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,
|
||||
},
|
||||
|
||||
vite: {
|
||||
optimizeDeps: {
|
||||
exclude: ["@nuxtjs/mdc"],
|
||||
},
|
||||
},
|
||||
|
||||
css: ["~/assets/css/main.css"],
|
||||
|
||||
compatibilityDate: "2024-11-01",
|
||||
|
||||
nitro: {
|
||||
prerender: {
|
||||
routes: ["/"],
|
||||
crawlLinks: true,
|
||||
},
|
||||
},
|
||||
|
||||
eslint: {
|
||||
config: {
|
||||
stylistic: {
|
||||
commaDangle: "never",
|
||||
braceStyle: "1tbs",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "portfolio",
|
||||
"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.100",
|
||||
"@iconify-json/simple-icons": "^1.2.75",
|
||||
"@nuxt/content": "^3.12.0",
|
||||
"@nuxt/image": "^2.0.0",
|
||||
"@nuxt/ui": "^4.6.0",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"motion-v": "^1.10.3",
|
||||
"nuxt": "^4.4.2",
|
||||
"nuxt-og-image": "^5.1.13",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "^1.15.2",
|
||||
"eslint": "^10.1.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"packageManager": "[email protected]"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
ignoredBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- unrs-resolver
|
||||
- vue-demi
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- sharp
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 363 KiB |
|
After Width: | Height: | Size: 313 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 642 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 590 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": [
|
||||
"github>nuxt/renovate-config-nuxt"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [{
|
||||
"matchDepTypes": ["resolutions"],
|
||||
"enabled": false
|
||||
}],
|
||||
"postUpdateOptions": ["pnpmDedupe"]
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||