Initial commit WIP
This commit is contained in:
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
BIN
app/assets/logo.png
Normal file
BIN
app/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
6
app/assets/logo.svg
Normal file
6
app/assets/logo.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M261.126 140.65L164.624 307.732L256.001 466L377.028 256.5L498.001 47H315.192L261.126 140.65Z" fill="#1697F6"/>
|
||||
<path d="M135.027 256.5L141.365 267.518L231.64 111.178L268.731 47H256H14L135.027 256.5Z" fill="#AEDDFF"/>
|
||||
<path d="M315.191 47C360.935 197.446 256 466 256 466L164.624 307.732L315.191 47Z" fill="#1867C0"/>
|
||||
<path d="M268.731 47C76.0026 47 141.366 267.518 141.366 267.518L268.731 47Z" fill="#7BC6FF"/>
|
||||
</svg>
|
After Width: | Height: | Size: 526 B |
10
app/assets/settings.scss
Normal file
10
app/assets/settings.scss
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* src/styles/settings.scss
|
||||
*
|
||||
* Configures SASS variables and Vuetify overwrites
|
||||
*/
|
||||
|
||||
// https://vuetifyjs.com/features/sass-variables/`
|
||||
// @use 'vuetify/settings' with (
|
||||
// $color-pack: false
|
||||
// );
|
82
app/components/AppFooter.vue
Normal file
82
app/components/AppFooter.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
const items = shallowRef([
|
||||
{
|
||||
title: 'Vuetify Documentation',
|
||||
icon: `$vuetify`,
|
||||
href: 'https://vuetifyjs.com/',
|
||||
},
|
||||
{
|
||||
title: 'Vuetify Support',
|
||||
icon: 'mdi-shield-star-outline',
|
||||
href: 'https://support.vuetifyjs.com/',
|
||||
},
|
||||
{
|
||||
title: 'Vuetify X',
|
||||
icon: ['M2.04875 3.00002L9.77052 13.3248L1.99998 21.7192H3.74882L10.5519 14.3697L16.0486 21.7192H22L13.8437 10.8137L21.0765 3.00002H19.3277L13.0624 9.76874L8.0001 3.00002H2.04875ZM4.62054 4.28821H7.35461L19.4278 20.4308H16.6937L4.62054 4.28821Z'],
|
||||
href: 'https://x.com/vuetifyjs',
|
||||
},
|
||||
{
|
||||
title: 'Vuetify GitHub',
|
||||
icon: `mdi-github`,
|
||||
href: 'https://github.com/vuetifyjs/vuetify',
|
||||
},
|
||||
{
|
||||
title: 'Vuetify Discord',
|
||||
icon: ['M22,24L16.75,19L17.38,21H4.5A2.5,2.5 0 0,1 2,18.5V3.5A2.5,2.5 0 0,1 4.5,1H19.5A2.5,2.5 0 0,1 22,3.5V24M12,6.8C9.32,6.8 7.44,7.95 7.44,7.95C8.47,7.03 10.27,6.5 10.27,6.5L10.1,6.33C8.41,6.36 6.88,7.53 6.88,7.53C5.16,11.12 5.27,14.22 5.27,14.22C6.67,16.03 8.75,15.9 8.75,15.9L9.46,15C8.21,14.73 7.42,13.62 7.42,13.62C7.42,13.62 9.3,14.9 12,14.9C14.7,14.9 16.58,13.62 16.58,13.62C16.58,13.62 15.79,14.73 14.54,15L15.25,15.9C15.25,15.9 17.33,16.03 18.73,14.22C18.73,14.22 18.84,11.12 17.12,7.53C17.12,7.53 15.59,6.36 13.9,6.33L13.73,6.5C13.73,6.5 15.53,7.03 16.56,7.95C16.56,7.95 14.68,6.8 12,6.8M9.93,10.59C10.58,10.59 11.11,11.16 11.1,11.86C11.1,12.55 10.58,13.13 9.93,13.13C9.29,13.13 8.77,12.55 8.77,11.86C8.77,11.16 9.28,10.59 9.93,10.59M14.1,10.59C14.75,10.59 15.27,11.16 15.27,11.86C15.27,12.55 14.75,13.13 14.1,13.13C13.46,13.13 12.94,12.55 12.94,11.86C12.94,11.16 13.45,10.59 14.1,10.59Z'],
|
||||
href: 'https://community.vuetifyjs.com/',
|
||||
},
|
||||
{
|
||||
title: 'Vuetify Reddit',
|
||||
icon: `mdi-reddit`,
|
||||
href: 'https://reddit.com/r/vuetifyjs',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-footer
|
||||
app
|
||||
height="40"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
class="d-inline-block mx-2 social-link"
|
||||
:href="item.href"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
:title="item.title"
|
||||
>
|
||||
<v-icon
|
||||
:icon="item.icon"
|
||||
:size="item.icon === '$vuetify' ? 24 : 16"
|
||||
/>
|
||||
</NuxtLink>
|
||||
|
||||
<div
|
||||
class="text-caption text-disabled"
|
||||
style="position: absolute; right: 16px;"
|
||||
>
|
||||
© 2016-{{ (new Date()).getFullYear() }} <span class="d-none d-sm-inline-block">Vuetify, LLC</span>
|
||||
—
|
||||
<NuxtLink
|
||||
class="text-decoration-none on-surface"
|
||||
href="https://vuetifyjs.com/about/licensing/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
MIT License
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</v-footer>
|
||||
</template>
|
||||
|
||||
<style scoped lang="sass">
|
||||
.social-link :deep(.v-icon)
|
||||
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
|
||||
text-decoration: none
|
||||
transition: .2s ease-in-out
|
||||
|
||||
&:hover
|
||||
color: rgba(25, 118, 210, 1)
|
||||
</style>
|
25
app/components/BoxGradient.vue
Normal file
25
app/components/BoxGradient.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
const { bgClass, gradClass, roundnessClass, contentClass, as } = withDefaults(defineProps<{
|
||||
bgClass?: string;
|
||||
gradClass?: string;
|
||||
roundnessClass?: string;
|
||||
contentClass?: string;
|
||||
as?: string;
|
||||
}>(),{
|
||||
bgClass: "bg-white dark:bg-zinc-900",
|
||||
gradClass: "bg-gradient-to-r from-yellow-400 to-pink-400 opacity-30",
|
||||
roundnessClass: "rounded-2xl",
|
||||
contentClass: "",
|
||||
as: "div"
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="as" :class="$cls('group relative p-8', roundnessClass)" v-bind="$attrs">
|
||||
<div :class="$cls('absolute -inset-px', gradClass, roundnessClass)" aria-hidden="true"></div>
|
||||
<div :class="$cls('absolute inset-0', bgClass, roundnessClass)" aria-hidden="true"></div>
|
||||
<div class="relative z-10" :class="contentClass">
|
||||
<slot />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
27
app/components/ColorModeSwitch.vue
Normal file
27
app/components/ColorModeSwitch.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { SunIcon, MoonIcon } from "@heroicons/vue/20/solid";
|
||||
const colorMode = useColorMode();
|
||||
const onClick = () =>
|
||||
colorMode.value === "light"
|
||||
? (colorMode.preference = "dark")
|
||||
: (colorMode.preference = "light");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
aria-label="Color Mode"
|
||||
class="border rounded-full px-2 py-2 text-zinc-500 border-zinc-500 hover:bg-white hover:text-zinc-900 hover:border-zinc-900 active:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-700 focus-visible:ring-opacity-75"
|
||||
@click="onClick"
|
||||
>
|
||||
<ColorScheme placeholder="...">
|
||||
<template v-if="colorMode.value === 'dark'">
|
||||
<SunIcon name="dark-mode" class="w-4 h-4" />
|
||||
<span class="sr-only">Dark Mode</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<MoonIcon name="light-mode" class="w-4 h-4" />
|
||||
<span class="sr-only">Light Mode</span>
|
||||
</template>
|
||||
</ColorScheme>
|
||||
</button>
|
||||
</template>
|
7
app/components/ContainerWider.vue
Normal file
7
app/components/ContainerWider.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="sm:px-8">
|
||||
<div class="mx-auto max-w-8xl lg:px-8">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
10
app/components/Footer.vue
Normal file
10
app/components/Footer.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<div class="dark:text-zinc-500 pb-16 mt-16">
|
||||
<div class="text-center">
|
||||
<!-- copyright -->
|
||||
<p class="text-sm">© {{ new Date().getFullYear() }} <NuxtLink to="/" class="text-zinc-500 hover:text-zinc-400">Oscar Mattern</NuxtLink>, All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
60
app/components/Header.vue
Normal file
60
app/components/Header.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
const links = [
|
||||
{ name: "Home", to: "/" },
|
||||
{ name: "Galleries", to: "/galleries" },
|
||||
{ name: "Stories", to: "/stories" },
|
||||
{ name: "Hire me", to: "/hire-me" },
|
||||
] as { name: string; to: string }[];
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", onScroll);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
});
|
||||
|
||||
const showHeader = ref(true);
|
||||
const lastScrollPosition = ref(0);
|
||||
|
||||
const onScroll = () => {
|
||||
const currentScrollPosition =
|
||||
window.pageYOffset || document.documentElement.scrollTop;
|
||||
// Momentum scrolling on iOS can cause the scroll position to be negative
|
||||
if (currentScrollPosition < 0) return;
|
||||
|
||||
// add 60px delay
|
||||
if (Math.abs(currentScrollPosition - lastScrollPosition.value) < 60) return;
|
||||
|
||||
// show if scrolling up
|
||||
showHeader.value = currentScrollPosition < lastScrollPosition.value;
|
||||
|
||||
lastScrollPosition.value = currentScrollPosition;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
showHeader ? 'translate-y-0' : '-translate-y-full',
|
||||
'transform-gpu transition-transform duration-500 sticky top-0 z-50',
|
||||
]"
|
||||
>
|
||||
<v-container class="pt-4 lg:pt-10">
|
||||
<div class="flex items-center justify-between">
|
||||
<Logo />
|
||||
<div
|
||||
class="border border-zinc-300/50 dark:border-zinc-900/60 rounded-full pl-2 lg:pl-4 pr-2 py-2 backdrop-blur-lg bg-zinc-100/50 dark:bg-zinc-800/50"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="hidden lg:block">
|
||||
<NavLinks :links="links" />
|
||||
</div>
|
||||
<div class="lg:hidden">
|
||||
<NavLinksMobile :links="links" />
|
||||
</div>
|
||||
<ColorModeSwitch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-container>
|
||||
</div>
|
||||
</template>
|
90
app/components/HelloWorld.vue
Normal file
90
app/components/HelloWorld.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<v-container class="fill-height" max-width="900">
|
||||
<div>
|
||||
<v-img
|
||||
class="mb-4"
|
||||
height="150"
|
||||
src="~/assets/logo.png"
|
||||
/>
|
||||
|
||||
<div class="mb-8 text-center">
|
||||
<div class="text-body-2 font-weight-light mb-n1">Welcome to</div>
|
||||
<h1 class="text-h2 font-weight-bold">Vuetify</h1>
|
||||
</div>
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card
|
||||
class="py-4"
|
||||
color="surface-variant"
|
||||
image="https://cdn.vuetifyjs.com/docs/images/one/create/feature.png"
|
||||
prepend-icon="mdi-rocket-launch-outline"
|
||||
rounded="lg"
|
||||
variant="tonal"
|
||||
>
|
||||
<template #image>
|
||||
<v-img position="top right" />
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<h2 class="text-h5 font-weight-bold">
|
||||
Get started
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<template #subtitle>
|
||||
<div class="text-subtitle-1">
|
||||
Change this page by updating <v-kbd>{{ `<HelloWorld />` }}</v-kbd> in <v-kbd>components/HelloWorld.vue</v-kbd>.
|
||||
</div>
|
||||
</template>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col v-for="link in links" :key="link.href" cols="6">
|
||||
<v-card
|
||||
append-icon="mdi-open-in-new"
|
||||
class="py-4"
|
||||
color="surface-variant"
|
||||
:href="link.href"
|
||||
:prepend-icon="link.icon"
|
||||
rel="noopener noreferrer"
|
||||
rounded="lg"
|
||||
:subtitle="link.subtitle"
|
||||
target="_blank"
|
||||
:title="link.title"
|
||||
variant="tonal"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const links = [
|
||||
{
|
||||
href: 'https://vuetifyjs.com/',
|
||||
icon: 'mdi-text-box-outline',
|
||||
subtitle: 'Learn about all things Vuetify in our documentation.',
|
||||
title: 'Documentation',
|
||||
},
|
||||
{
|
||||
href: 'https://vuetifyjs.com/introduction/why-vuetify/#feature-guides',
|
||||
icon: 'mdi-star-circle-outline',
|
||||
subtitle: 'Explore available framework Features.',
|
||||
title: 'Features',
|
||||
},
|
||||
{
|
||||
href: 'https://vuetifyjs.com/components/all',
|
||||
icon: 'mdi-widgets-outline',
|
||||
subtitle: 'Discover components in the API Explorer.',
|
||||
title: 'Components',
|
||||
},
|
||||
{
|
||||
href: 'https://discord.vuetifyjs.com',
|
||||
icon: 'mdi-account-group-outline',
|
||||
subtitle: 'Connect with Vuetify developers.',
|
||||
title: 'Community',
|
||||
},
|
||||
]
|
||||
</script>
|
116
app/components/HomeImageGrid.vue
Normal file
116
app/components/HomeImageGrid.vue
Normal file
@ -0,0 +1,116 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center gap-5 py-4 sm:gap-6 relative z-20">
|
||||
<!-- col1 -->
|
||||
<div class="hidden lg:flex flex-col gap-5 sm:gap-6 " v-parallax data-rellax-speed="3">
|
||||
<div
|
||||
class="relative aspect-[2/3] w-36 lg:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/img/home/grid-01.jpg"
|
||||
alt="Alt text"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col2 -->
|
||||
<div class="flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="1">
|
||||
<div
|
||||
class="relative aspect-[4/3] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/img/home/grid-02.jpg"
|
||||
alt="Alt text"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="relative aspect-[3/4] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/img/home/grid-03.jpg"
|
||||
alt="Alt text"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col3 -->
|
||||
<div class="flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="0">
|
||||
<div
|
||||
class="relative aspect-[2/3] w-72 md:w-80 lg:w-96 flex-none overflow-hidden rounded-2xl bg-zinc-100 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:380px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/img/home/grid-07.jpg"
|
||||
alt="Alt text"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col4 -->
|
||||
<div class="flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="1">
|
||||
<div
|
||||
class="relative aspect-[3/4] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/img/home/grid-04.jpg"
|
||||
alt="Alt text"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="relative aspect-[4/3] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/img/home/grid-05.jpg"
|
||||
alt="Alt text"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col5 -->
|
||||
<div class="hidden lg:flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="3">
|
||||
<div
|
||||
class="relative aspect-[2/3] w-44 lg:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
src="/img/home/grid-06.jpg"
|
||||
alt="Alt text"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
15
app/components/LinkArrorw.vue
Normal file
15
app/components/LinkArrorw.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import {ArrowLongRightIcon} from "@heroicons/vue/20/solid"
|
||||
const { to } = withDefaults(defineProps<{
|
||||
to: string;
|
||||
}>(),{
|
||||
to: "/",
|
||||
})
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<NuxtLink :to="to" class="group/arrowlink flex items-center gap-2 text-gray-500 hover:text-gray-900 dark:text-zinc-300 dark:hover:text-zinc-100">
|
||||
<span><slot/></span>
|
||||
<ArrowLongRightIcon class="w-5 h-5 transition transform-gpu group-hover/arrowlink:translate-x-1" />
|
||||
</NuxtLink>
|
||||
</template>
|
18
app/components/Logo.vue
Normal file
18
app/components/Logo.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="group flex items-center gap-4 text-zinc-700 dark:text-zinc-400 hover:text-zinc-900 focus:outline-none focus-visible:ring-offset-2 ring-offset-zinc-900 focus:rounded-full focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75"
|
||||
>
|
||||
<div class="rounded-full border-2 border-white dark:border-zinc-400 shadow-md group-hover:shadow-xl transition-shadow duration-100 shrink-0">
|
||||
<NuxtImg
|
||||
class="inline-block h-14 w-14 rounded-full"
|
||||
src="/img/logo.jpg"
|
||||
alt="Oscar Mattern"
|
||||
format="webp"
|
||||
width="100"
|
||||
height="100"
|
||||
/>
|
||||
</div>
|
||||
<span class="uppercase tracking-widest hidden md:inline-flex shrink-1 sr-only">Oscar Mattern</span>
|
||||
</NuxtLink>
|
||||
</template>
|
130
app/components/MasonryWall.vue
Normal file
130
app/components/MasonryWall.vue
Normal file
@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import type { Ref } from "vue";
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch } from "vue";
|
||||
|
||||
type Column = number[];
|
||||
|
||||
interface Props {
|
||||
items: unknown[];
|
||||
columnWidth?: number;
|
||||
gap?: number;
|
||||
rtl?: boolean;
|
||||
ssrColumns?: number;
|
||||
scrollContainer?: HTMLElement | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
columnWidth: 400,
|
||||
gap: 0,
|
||||
rtl: false,
|
||||
ssrColumns: 0,
|
||||
scrollContainer: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "redraw"): void;
|
||||
(event: "redrawSkip"): void;
|
||||
}>();
|
||||
|
||||
const { columnWidth, items, gap, rtl, ssrColumns, scrollContainer } =
|
||||
toRefs(props);
|
||||
const columns = ref<Column[]>([]);
|
||||
const wall = ref<HTMLDivElement>() as Ref<HTMLDivElement>;
|
||||
|
||||
function columnCount(): number {
|
||||
const count = Math.floor(
|
||||
(wall.value.getBoundingClientRect().width + gap.value) /
|
||||
(columnWidth.value + gap.value)
|
||||
);
|
||||
return count > 0 ? count : 1;
|
||||
}
|
||||
|
||||
function createColumns(count: number): Column[] {
|
||||
return [...new Array(count)].map(() => []);
|
||||
}
|
||||
|
||||
if (ssrColumns.value > 0) {
|
||||
const newColumns = createColumns(ssrColumns.value);
|
||||
items.value.forEach((_: unknown, i: number) =>
|
||||
newColumns[i % ssrColumns.value]!.push(i)
|
||||
);
|
||||
columns.value = newColumns;
|
||||
}
|
||||
|
||||
async function fillColumns(itemIndex: number) {
|
||||
if (itemIndex >= items.value.length) {
|
||||
return;
|
||||
}
|
||||
await nextTick();
|
||||
const columnDivs = [...wall.value.children] as HTMLDivElement[];
|
||||
if (rtl.value) {
|
||||
columnDivs.reverse();
|
||||
}
|
||||
const target = columnDivs.reduce((prev, curr) =>
|
||||
curr.getBoundingClientRect().height < prev.getBoundingClientRect().height
|
||||
? curr
|
||||
: prev
|
||||
);
|
||||
columns.value[+target.dataset.index!]!.push(itemIndex);
|
||||
await fillColumns(itemIndex + 1);
|
||||
}
|
||||
|
||||
async function redraw(force = false) {
|
||||
if (columns.value.length === columnCount() && !force) {
|
||||
emit("redrawSkip");
|
||||
return;
|
||||
}
|
||||
columns.value = createColumns(columnCount());
|
||||
const scrollTarget = scrollContainer?.value;
|
||||
const scrollY = scrollTarget ? scrollTarget.scrollTop : window.scrollY;
|
||||
await fillColumns(0);
|
||||
scrollTarget
|
||||
? scrollTarget.scrollBy({ top: scrollY - scrollTarget.scrollTop })
|
||||
: window.scrollTo({ top: scrollY });
|
||||
emit("redraw");
|
||||
}
|
||||
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? undefined
|
||||
: new ResizeObserver(() => redraw());
|
||||
|
||||
onMounted(() => {
|
||||
redraw();
|
||||
resizeObserver?.observe(wall.value);
|
||||
});
|
||||
onBeforeUnmount(() => resizeObserver?.unobserve(wall.value));
|
||||
|
||||
watch([items, rtl], () => redraw(true));
|
||||
watch([columnWidth, gap], () => redraw());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="wall"
|
||||
class="masonry-wall"
|
||||
:style="{ display: 'flex', gap: `${gap}px` }"
|
||||
>
|
||||
<div
|
||||
v-for="(column, columnIndex) in columns"
|
||||
:key="columnIndex"
|
||||
class="masonry-column"
|
||||
:data-index="columnIndex"
|
||||
:style="{
|
||||
display: 'flex',
|
||||
'flex-basis': '0px',
|
||||
'flex-direction': 'column',
|
||||
'flex-grow': 1,
|
||||
gap: `${gap}px`,
|
||||
height: ['-webkit-max-content', '-moz-max-content', 'max-content'] as any,
|
||||
'min-width': 0
|
||||
}"
|
||||
>
|
||||
<div v-for="itemIndex in column" :key="itemIndex" class="masonry-item">
|
||||
<slot :item="items[itemIndex]" :index="itemIndex">
|
||||
{{ items[itemIndex] }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
21
app/components/NavLinks.vue
Normal file
21
app/components/NavLinks.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const { links } = defineProps<{
|
||||
links: { name: string; to: string }[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="flex items-center gap-4">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
:class="[
|
||||
$route.path === link.to? 'text-gradient': 'text-zinc-700 dark:text-zinc-400',
|
||||
'px-2 hover:text-zinc-900 dark:hover:text-zinc-200'
|
||||
]"
|
||||
v-for="(link, index) in links"
|
||||
:key="`navlinks-${index}`"
|
||||
>
|
||||
{{ link.name }}
|
||||
</NuxtLink>
|
||||
</ul>
|
||||
</template>
|
53
app/components/NavLinksMobile.vue
Normal file
53
app/components/NavLinksMobile.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { Bars2Icon } from "@heroicons/vue/20/solid";
|
||||
|
||||
const { links } = defineProps<{
|
||||
links: { name: string; to: string }[];
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<nuxt-ui-dropdown as="div" class="inline-block text-left z-10">
|
||||
<div>
|
||||
<nuxt-ui-dropdown-toggle class="border rounded-full px-2 py-2 text-zinc-500 border-zinc-500 hover:bg-white hover:text-zinc-900 hover:border-zinc-900 active:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-yellow-700 focus-visible:ring-opacity-75"
|
||||
>
|
||||
<span class="sr-only">Menu</span>
|
||||
<Bars2Icon class="h-4 w-4" aria-hidden="true" />
|
||||
</nuxt-ui-dropdown-toggle>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<nuxt-ui-dropdown-menu
|
||||
class="absolute right-0 mt-4 w-56 origin-top-right divide-y divide-zinc-100 dark:divide-zinc-700 rounded-xl bg-white dark:bg-black shadow-lg ring-1 ring-black dark:ring-white ring-opacity-5 dark:ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div class="px-2 py-2 w-full">
|
||||
<nuxt-ui-dropdown-item
|
||||
v-for="(link, index) in links"
|
||||
:key="index"
|
||||
v-slot="{ close }"
|
||||
>
|
||||
<NuxtLink
|
||||
:class="[
|
||||
$route.path === link.to
|
||||
? 'bg-zinc-200 dark:bg-zinc-900 text-zinc-900 dark:text-zinc-200'
|
||||
: 'text-zinc-900 dark:text-zinc-200',
|
||||
'group block w-full items-center rounded-xl text-sm',
|
||||
]"
|
||||
:to="link.to"
|
||||
>
|
||||
<span @click.native="close" class="truncate px-4 py-2 block">{{
|
||||
link.name
|
||||
}}</span>
|
||||
</NuxtLink>
|
||||
</nuxt-ui-dropdown-item>
|
||||
</div>
|
||||
</nuxt-ui-dropdown-menu>
|
||||
</transition>
|
||||
</nuxt-ui-dropdown>
|
||||
</template>
|
35
app/components/PhotoSwipe.vue
Normal file
35
app/components/PhotoSwipe.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import PhotoSwipeLightbox from "photoswipe/lightbox";
|
||||
import "photoswipe/style.css";
|
||||
|
||||
const gallery = ref(null);
|
||||
const slots = useSlots();
|
||||
const children = slots.default() ? slots.default()[0].children : false;
|
||||
const childrenType = children ? children[0].type : false;
|
||||
|
||||
let lightbox;
|
||||
|
||||
onMounted(() => {
|
||||
if (!lightbox && childrenType) {
|
||||
lightbox = new PhotoSwipeLightbox({
|
||||
gallery: gallery.value,
|
||||
children: "a",
|
||||
pswpModule: () => import("photoswipe"),
|
||||
});
|
||||
lightbox.init();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (lightbox) {
|
||||
lightbox.destroy();
|
||||
lightbox = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="gallery">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
1
app/components/SocialIconsList.vue
Normal file
1
app/components/SocialIconsList.vue
Normal file
@ -0,0 +1 @@
|
||||
<template>hi</template>
|
25
app/components/content/AwardsItem.vue
Normal file
25
app/components/content/AwardsItem.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
year: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<li class="dark:text-zinc-400 flex flex-col gap-1">
|
||||
<p class="flex items-baseline gap-2">
|
||||
<span class="font-display text-2xl text-zinc-500 dark:text-zinc-500">{{ year }}</span>
|
||||
<span><strong class="font-semibold dark:text-zinc-200">{{ title }}</strong></span>
|
||||
</p>
|
||||
<span class="text-sm">{{ description }}</span>
|
||||
</li>
|
||||
</template>
|
28
app/components/content/AwardsList.vue
Normal file
28
app/components/content/AwardsList.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
interface Award {
|
||||
title?: string;
|
||||
year?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
defineProps({
|
||||
titleText: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
awards: {
|
||||
type: Array as PropType<Award[]>,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="not-prose flex flex-col gap-4 lg:w-1/2">
|
||||
<h3 class="uppercase tracking-widest font-light text-zinc-900 dark:text-orange-600">
|
||||
{{ titleText }}
|
||||
</h3>
|
||||
<ul class="space-y-4">
|
||||
<AwardsItem v-for="(item, index) in awards" :key="index" :title="item.title" :year="item.year" :description="item.description" />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
17
app/components/content/BlogImage.vue
Normal file
17
app/components/content/BlogImage.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const { src, alt } = defineProps<{
|
||||
src: string;
|
||||
alt?: string;
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<NuxtImg
|
||||
v-if="src"
|
||||
placeholder
|
||||
sizes="sm:100vw md:70vw"
|
||||
class="w-full"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
loading="lazy"
|
||||
/>
|
||||
</template>
|
16
app/components/content/Container.vue
Normal file
16
app/components/content/Container.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const { innerClass } = defineProps<{
|
||||
innerClass?: string;
|
||||
}>();
|
||||
</script>
|
||||
<template>
|
||||
<div class="sm:px-8">
|
||||
<div class="mx-auto max-w-7xl lg:px-8">
|
||||
<div class="relative px-4 sm:px-8 lg:px-12" :class="innerClass">
|
||||
<div class="mx-auto max-w-2xl lg:max-w-5xl">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
34
app/components/content/GalleriesList.vue
Normal file
34
app/components/content/GalleriesList.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { withTrailingSlash } from "ufo";
|
||||
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
default: "galleries",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: _galleries } = await useAsyncData(
|
||||
"galleries",
|
||||
async () =>
|
||||
await queryContent(withTrailingSlash(props.path)).find()
|
||||
);
|
||||
|
||||
const galleries = computed(() => _galleries.value || [])
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="galleries?.length" class="not-prose grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<GalleryListItem
|
||||
v-for="(gallery, index) in galleries"
|
||||
:key="index"
|
||||
:gallery="gallery"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="">
|
||||
No galleries found.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
62
app/components/content/GalleryListItem.vue
Normal file
62
app/components/content/GalleryListItem.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { Image } from "~/../types/image";
|
||||
type Gallery = {
|
||||
_path: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
cover?: Image;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
gallery: {
|
||||
type: Object as PropType<Gallery>,
|
||||
required: true,
|
||||
validator: (value: Gallery) => {
|
||||
if (value?._path && value.title) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="gallery._path" class="group">
|
||||
<div
|
||||
class="relative w-full overflow-hidden rounded-lg aspect-[3/2] md:aspect-[2/3] dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="gallery.cover?.src || 'img/placeholder.jpg'"
|
||||
:alt="gallery.cover?.alt || gallery.title"
|
||||
:width="gallery.cover?.width"
|
||||
:height="gallery.cover?.height"
|
||||
class="h-full w-full object-cover object-center group-hover:opacity-75"
|
||||
sizes="sm:100vw md:50vw lg:30vw"
|
||||
loading="lazy"
|
||||
placeholder
|
||||
/>
|
||||
<div class="absolute bottom-0 w-full p-4 grid grid-cols-4 gap-3" v-if="gallery?.images?.length">
|
||||
<div
|
||||
v-for="thumbnail, index in gallery.images.slice(0, 4)"
|
||||
:key="index"
|
||||
class="col-span-1 aspect-square w-full rounded-lg overflow-hidden group-hover:opacity-75 dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="thumbnail.src"
|
||||
alt="thumbnail.alt"
|
||||
class="h-full w-full object-cover object-center"
|
||||
loading="lazy"
|
||||
sizes="sm:70px md:75px"
|
||||
placeholder
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between text-base font-medium dark:text-zinc-200"
|
||||
>
|
||||
<h3>{{ gallery.title }}</h3>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
153
app/components/content/HeroGrid.vue
Normal file
153
app/components/content/HeroGrid.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from "vue";
|
||||
import type { Image } from "../../types/image";
|
||||
|
||||
defineProps({
|
||||
image1: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
image2: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
image3: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
image4: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
image5: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
image6: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
image7: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const defaultImage = "img/placeholder.jpg";
|
||||
</script>
|
||||
<template>
|
||||
<div class="not-prose mt-16 sm:mt-24 content-visibility-visible contain-intrinsic-size-[auto_600px]">
|
||||
<div class="flex items-center justify-center gap-5 py-4 sm:gap-6 relative z-20">
|
||||
<!-- col1 -->
|
||||
<div class="hidden lg:flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="3">
|
||||
<div class="relative aspect-[2/3] w-36 lg:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
:src="image1?.src ? image1.src : defaultImage"
|
||||
:alt="image1?.alt ? image1.alt : 'No alt text'"
|
||||
:width="image1?.width ? image1.width : 1"
|
||||
:height="image1?.height ? image1.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col2 -->
|
||||
<div class="flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="1">
|
||||
<div class="relative aspect-[4/3] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
:src="image2?.src ? image2.src : defaultImage"
|
||||
:alt="image2?.alt ? image2.alt : 'No alt text'"
|
||||
:width="image2?.width ? image2.width : 1"
|
||||
:height="image2?.height ? image2.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative aspect-[3/4] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
:src="image3?.src ? image3.src : defaultImage"
|
||||
:alt="image3?.alt ? image3.alt : 'No alt text'"
|
||||
:width="image3?.width ? image3.width : 1"
|
||||
:height="image3?.height ? image3.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col3 -->
|
||||
<div class="flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="0">
|
||||
<div class="relative aspect-[2/3] w-72 md:w-80 lg:w-96 flex-none overflow-hidden rounded-2xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:380px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
:src="image4?.src ? image4.src : defaultImage"
|
||||
:alt="image4?.alt ? image4.alt : 'No alt text'"
|
||||
:width="image4?.width ? image4.width : 1"
|
||||
:height="image4?.height ? image4.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col4 -->
|
||||
<div class="flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="1">
|
||||
<div class="relative aspect-[3/4] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
:src="image5?.src ? image5.src : defaultImage"
|
||||
:alt="image5?.alt ? image5.alt : 'No alt text'"
|
||||
:width="image5?.width ? image5.width : 1"
|
||||
:height="image5?.height ? image5.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative aspect-[4/3] w-44 md:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
:src="image6?.src ? image6.src : defaultImage"
|
||||
:alt="image6?.alt ? image6.alt : 'No alt text'"
|
||||
:width="image6?.width ? image6.width : 1"
|
||||
:height="image6?.height ? image6.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- col5 -->
|
||||
<div class="hidden lg:flex flex-col gap-5 sm:gap-6" v-parallax data-rellax-speed="3">
|
||||
<div class="relative aspect-[2/3] w-44 lg:w-52 flex-none overflow-hidden rounded-xl bg-zinc-100 dark:bg-zinc-800">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
sizes="sm:100vw md:50vw lg:220px"
|
||||
class="absolute inset-0 h-full w-full object-cover"
|
||||
:src="image7?.src ? image7.src : defaultImage"
|
||||
:alt="image7?.alt ? image7.alt : 'No alt text'"
|
||||
:width="image7?.width ? image7.width : 1"
|
||||
:height="image7?.height ? image7.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
12
app/components/content/HeroText.vue
Normal file
12
app/components/content/HeroText.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="not-prose lg:max-w-3xl mx-auto pt-8 lg:pt-44 content-visibility-auto contain-intrinsic-size-[auto_400px]">
|
||||
<div class="flex flex-col gap-9">
|
||||
<h1 class="text-center font-display font-light leading-snug text-3xl md:text-4xl lg:text-5xl text-zinc-700 dark:text-zinc-300">
|
||||
<slot> Inspire Emotion and Evoke Storytelling through the Art of Photography. </slot>
|
||||
</h1>
|
||||
<p class="text-zinc-700 dark:text-zinc-400 text-center text-sm lg:text-base px-0 md:px-14 lg:px-24">
|
||||
<slot name="description"> Explore my photography portfolio and see the world through my creative lens. Contact me if you would like to hire me. </slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
30
app/components/content/MasonryGallery.vue
Normal file
30
app/components/content/MasonryGallery.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { Image } from "~/../types/image";
|
||||
|
||||
interface itemPropsT {
|
||||
item: Image;
|
||||
index: number;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
images: Image[];
|
||||
};
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="not-prose">
|
||||
<PhotoSwipe>
|
||||
<div>
|
||||
<MasonryWall :items="images" :ssr-columns="1" :column-width="300" :gap="32" class="grid grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<template #default="{ item }: itemPropsT">
|
||||
<a class="photoswipe-item rounded-xl overflow-hidden block dark:bg-zinc-800 bg-zinc-200" :href="$img(item.src, { width: 1600 })" data-cropped="true" :data-pswp-width="item.width" :data-pswp-height="item.height">
|
||||
<NuxtImg :src="item.src" alt="Some image" sizes="sm:90vw md:50vw lg:30vw" class="w-full h-full object-cover object-center" :width="item.width" :height="item.height" loading="lazy" />
|
||||
</a>
|
||||
</template>
|
||||
</MasonryWall>
|
||||
</div>
|
||||
</PhotoSwipe>
|
||||
</div>
|
||||
</template>
|
49
app/components/content/PackagePrice.vue
Normal file
49
app/components/content/PackagePrice.vue
Normal file
@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="not-prose">
|
||||
<div class="mx-auto max-w-2xl rounded-3xl ring-1 ring-zinc-200 dark:ring-zinc-50/5 lg:mx-0 lg:flex lg:max-w-none lg:items-stretch">
|
||||
<div class="p-8 sm:p-10 lg:flex-auto">
|
||||
<h3 class="text-2xl font-bold tracking-tight text-zinc-900 dark:text-zinc-200">{{ title }}</h3>
|
||||
<p v-if="description" class="mt-6 text-base leading-7 text-zinc-600 dark:text-zinc-500">{{ description }}</p>
|
||||
<ul role="list" class="mt-8 grid grid-cols-1 gap-4 text-sm leading-6 text-zinc-600 dark:text-zinc-400">
|
||||
<li v-for="feature in includedFeatures" :key="feature" class="flex gap-x-2">
|
||||
<Icon name="heroicons:check" class="h-6 w-5 flex-none text-yellow-600" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="-mt-2 p-2 lg:mt-0 lg:w-full lg:max-w-sm lg:flex-shrink-0">
|
||||
<div
|
||||
class="relative overflow-hidden rounded-2xl py-10 text-center ring-1 ring-inset ring-zinc-900/5 lg:flex lg:flex-col lg:justify-center lg:py-16 lg:h-full"
|
||||
:class="[image ? 'bg-zinc-200 dark:bg-zinc-800' : 'bg-zinc-100 dark:bg-zinc-800/30']"
|
||||
>
|
||||
<div v-if="image" class="absolute inset-0 mix-blend-overlay">
|
||||
<NuxtImg :src="image.src" :alt="image.alt" :width="image.width" :height="image.height" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="mx-auto max-w-xs px-8 z-10">
|
||||
<p class="flex items-baseline justify-center gap-x-2">
|
||||
<span class="text-5xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100">{{ price }}</span>
|
||||
<span class="text-sm font-semibold leading-6 tracking-wide text-zinc-800 dark:text-zinc-200">{{ currency }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Image } from "~/../types/image";
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
includedFeatures: string[];
|
||||
price: string;
|
||||
currency?: string;
|
||||
image?: Image;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
description: "",
|
||||
currency: "USD",
|
||||
});
|
||||
</script>
|
47
app/components/content/PageHeader.vue
Normal file
47
app/components/content/PageHeader.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
orientation: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "left",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="not-prose relative pt-14 pb-10 lg:mt-32 lg:pb-24">
|
||||
<div class="absolute top-0 left-0 pointer-events-none w-full text-clip overflow-hidden" :class="[orientation === 'center' ? 'flex justify-center' : '']" v-parallax data-rellax-speed="4">
|
||||
<span class="text-[9rem] lg:text-[10rem] font-display text-zinc-900 dark:text-zinc-50 opacity-2 truncate">{{ title }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<template v-if="orientation === 'left'">
|
||||
<div class="max-w-xl">
|
||||
<h1 class="font-thin font-display text-5xl text-gradient leading-tighter w-max max-w-full">{{ title }}</h1>
|
||||
</div>
|
||||
<div class="max-w-2xl">
|
||||
<p class="mt-6 lg:mt-9 dark:text-zinc-500">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="orientation === 'center'">
|
||||
<div class="max-w-xl mx-auto flex justify-center">
|
||||
<h1 class="font-thin font-display text-5xl text-gradient leading-tighter text-center w-max">{{ title }}</h1>
|
||||
</div>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<p class="mt-6 lg:mt-9 dark:text-zinc-500 text-center">
|
||||
<slot name="description">{{ description }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
56
app/components/content/SectionAboutMe.vue
Normal file
56
app/components/content/SectionAboutMe.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import type { Image } from "../../types/image";
|
||||
defineProps({
|
||||
firstname: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
lastname: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
photo: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="not-prose mt-16 lg:mt-48 content-visibility-visible contain-intrinsic-size-[auto_750px]">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-7 gap-8">
|
||||
<div class="col-span-4 flex flex-col gap-8 order-2 lg:order-1">
|
||||
<h2 class="text-4xl lg:text-6xl font-display text-zinc-800 dark:text-zinc-200">
|
||||
{{ firstname }} <br />
|
||||
{{ lastname }}
|
||||
</h2>
|
||||
<div class="dark:text-zinc-400 flex flex-col gap-4">
|
||||
<slot name="description"></slot>
|
||||
</div>
|
||||
<NuxtImg src="/img/home/sign.png" alt="signature" class="h-8 mr-auto" />
|
||||
</div>
|
||||
|
||||
<div v-parallax
|
||||
data-rellax-xs-speed="0"
|
||||
data-rellax-mobile-speed="0"
|
||||
data-rellax-tablet-speed="0"
|
||||
data-parallax-speed="-1" data-rellax-percentage="0.5" class="col-span-3 order-1 lg:order-2 pl-2 pt-2">
|
||||
<div class="lg:absolute aspect-square lg:aspect-[2/3] flex-none overflow-hidden bg-zinc-100 dark:bg-zinc-800 w-32 lg:w-72 rounded-2xl rotate-3">
|
||||
<NuxtImg
|
||||
placeholder
|
||||
:src="photo?.src ? photo.src : 'img/placeholder.jpg'"
|
||||
:alt="photo?.alt ? photo.alt : 'Oscar Mattern'"
|
||||
:width="photo?.width ? photo.width : 1"
|
||||
:height="photo?.height ? photo.height : 1"
|
||||
format="webp"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover"
|
||||
sizes="sm:50vw md:50vw lg:30vw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-10 h-px border-0 bg-zinc-200 dark:bg-zinc-800" />
|
||||
<slot name="extra"></slot>
|
||||
</div>
|
||||
</template>
|
55
app/components/content/SectionCtaHireMe.vue
Normal file
55
app/components/content/SectionCtaHireMe.vue
Normal file
@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="not-prose">
|
||||
<div class="relative px-4 py-12 lg:py-24 rounded-2xl overflow-hidden border border-zinc-200/50 dark:border-yellow-100/5 shadow-sm dark:shadow-2xl">
|
||||
<div class="">
|
||||
<h2 class="font-display font-thin text-4xl max-w-2xl mx-auto text-center text-zinc-900 dark:text-gradient">
|
||||
<slot name="title">Questions or collaboration ideas?</slot>
|
||||
</h2>
|
||||
<div class="flex flex-col gap-4 mt-8 max-w-lg mx-auto text-center text-zinc-600 dark:text-zinc-400 text-sm">
|
||||
<slot name="description">
|
||||
<p>I would love to hear from you! Whether you have questions about my work, have collaboration ideas, or are interested in hiring me for a project, don't hesitate to reach out.</p>
|
||||
<p>I usually respond within hours!</p>
|
||||
</slot>
|
||||
</div>
|
||||
<ul class="mt-16 flex flex-col gap-4 lg:gap-12 lg:flex-row items-center justify-center max-w-2xl mx-auto">
|
||||
<li>
|
||||
<NuxtLink to="mailto:oscar@mattern.com" class="flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100">
|
||||
<Icon name="heroicons:envelope-20-solid" size="1.8rem" />
|
||||
<span>oscar@mattern.com</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="https://instagram.org" class="flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100">
|
||||
<Icon name="fe:instagram" size="1.8rem" />
|
||||
<span>oscarphotography</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="https://instagram.org" class="flex items-center gap-2 text-sm text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100">
|
||||
<Icon name="fe:twitter" size="1.8rem" />
|
||||
<span>oscarphotography</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<svg viewBox="0 0 1024 1024" class="hidden dark:block absolute top-1/2 left-1/2 h-[64rem] w-[64rem] -translate-x-1/2 pointer-events-none" aria-hidden="true">
|
||||
<circle cx="512" cy="512" r="512" fill="url(#759c1415-0410-454c-8f7c-9a820de03641)" fill-opacity="0.7"></circle>
|
||||
<defs>
|
||||
<radialGradient id="759c1415-0410-454c-8f7c-9a820de03641" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(512 512) rotate(90) scale(512)">
|
||||
<stop stop-color="#18181B"></stop>
|
||||
<stop offset="1" stop-color="#333626" stop-opacity="0"></stop>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
<svg viewBox="0 0 1024 1024" class="dark:hidden absolute top-1/2 left-1/2 h-[64rem] w-[64rem] -translate-x-1/2 pointer-events-none" aria-hidden="true">
|
||||
<circle cx="512" cy="512" r="512" fill="url(#759c1415-0410-454c-8f7c-9a820de03641)" fill-opacity="0.7"></circle>
|
||||
<defs>
|
||||
<radialGradient id="759c1415-0410-454c-8f7c-9a820de03641" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(512 512) rotate(90) scale(512)">
|
||||
<stop stop-color="#F0F0F0"></stop>
|
||||
<stop offset="1" stop-color="#F3E0AD" stop-opacity="0"></stop>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
12
app/components/content/SectionFaq.vue
Normal file
12
app/components/content/SectionFaq.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<template>
|
||||
<div class="not-prose mt-32">
|
||||
<div class="divide-y divide-zinc-900/10 dark:divide-zinc-100/10">
|
||||
<h2 class="text-2xl font-bold leading-10 tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||
<slot name="title">Frequently asked questions</slot>
|
||||
</h2>
|
||||
<dl class="mt-10 space-y-6 divide-y divide-zinc-900/10 dark:divide-zinc-100/10">
|
||||
<slot name="items" />
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
7
app/components/content/SectionPackages.vue
Normal file
7
app/components/content/SectionPackages.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div class="not-prose">
|
||||
<div class="flex flex-col gap-8">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
10
app/components/content/SectionTestimonials.vue
Normal file
10
app/components/content/SectionTestimonials.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="mt-24">
|
||||
<div class="text-center">
|
||||
<slot name="title" />
|
||||
</div>
|
||||
<div class="not-prose -mt-8 sm:-mx-4 sm:columns-2 sm:text-[0]">
|
||||
<slot name="items" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
31
app/components/content/StoriesList.vue
Normal file
31
app/components/content/StoriesList.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { withTrailingSlash } from "ufo";
|
||||
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
default: "stories",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: _stories } = await useAsyncData(
|
||||
"stories",
|
||||
async () =>
|
||||
await queryContent(withTrailingSlash(props.path)).sort({ date: -1 }).find()
|
||||
);
|
||||
|
||||
const stories = computed(() => _stories.value || []);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="stories?.length"
|
||||
class="not-prose grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
>
|
||||
<StoryListItem v-for="story in stories" :key="story._path" :story="story" />
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="">No Stories found.</p>
|
||||
</div>
|
||||
</template>
|
52
app/components/content/StoryListItem.vue
Normal file
52
app/components/content/StoryListItem.vue
Normal file
@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { Image } from "~/../types/image";
|
||||
|
||||
type Story = {
|
||||
_path: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
cover?: Image;
|
||||
date?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
defineProps({
|
||||
story: {
|
||||
type: Object as PropType<Story>,
|
||||
required: true,
|
||||
validator: (value: Story) =>{
|
||||
if (value?._path && value?.title) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="story._path" class="group">
|
||||
<div
|
||||
class="relative w-full overflow-hidden rounded-lg sm:aspect-[3/2] md:aspect-square lg:aspect-[2/3] dark:bg-zinc-800"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="story.cover?.src || 'img/placeholder.jpg'"
|
||||
:alt="story.cover?.alt || story.title"
|
||||
:width="story.cover?.width || 2"
|
||||
:height="story.cover?.height || 3"
|
||||
class="h-full w-full object-cover object-center group-hover:opacity-75"
|
||||
sizes="sm:100vw md:50vw lg:30vw"
|
||||
loading="lazy"
|
||||
placeholder
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between text-base font-medium dark:text-zinc-300"
|
||||
>
|
||||
<h3>{{ story.title }}</h3>
|
||||
</div>
|
||||
<p v-if="story.date" class="mt-1 text-sm italic dark:text-zinc-500">
|
||||
{{ formatDate(story.date) }}
|
||||
</p>
|
||||
</NuxtLink>
|
||||
</template>
|
31
app/components/content/Testimonial.vue
Normal file
31
app/components/content/Testimonial.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import type { Image } from '~/../types/image';
|
||||
defineProps({
|
||||
quote: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
image: {
|
||||
type: Object as PropType<Image>,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pt-8 sm:inline-block sm:w-full sm:px-4">
|
||||
<figure class="rounded-2xl bg-zinc-50 dark:bg-zinc-800/5 p-6 text-sm leading-6 dark:border dark:border-zinc-800/50">
|
||||
<blockquote class="text-zinc-900 dark:text-zinc-400">
|
||||
<p>“{{ quote }}”</p>
|
||||
</blockquote>
|
||||
<figcaption class="mt-6 flex items-center gap-x-4">
|
||||
<NuxtImg class="h-10 w-10 rounded-full bg-zinc-50 dark:bg-zinc-800 object-cover" :src="image?.src ? image.src : '/img/placeholder.jpg'" :alt="image?.alt ? image.alt : 'Placeholder'" :width="image?.width ? image.width : 80" :height="image?.height ? image.height : 80" />
|
||||
<div class="font-semibold text-zinc-900 dark:text-zinc-300">{{ name }}</div>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</template>
|
44
app/components/content/ThreeImages.vue
Normal file
44
app/components/content/ThreeImages.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
const { src1, src2, src3, alt1, alt2, alt3 } = defineProps<{
|
||||
src1: string;
|
||||
alt1: string;
|
||||
src2: string;
|
||||
alt2: string;
|
||||
src3: string;
|
||||
alt3: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="not-prose pb-8 grid lg:grid-cols-3 gap-3 lg:gap-6 aspect-[1] sm:aspect-[4/3] lg:aspect-[4/3] lg:-mr-12 lg:-ml-12">
|
||||
<div
|
||||
class="col-span-2 row-span-2 dark:bg-gray-800 rounded-md lg:rounded-xl overflow-hidden"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="src1"
|
||||
:alt="alt1"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover object-center"
|
||||
sizes="sm:100vw md:50vw"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-1 dark:bg-gray-800 rounded-md lg:rounded-xl overflow-hidden">
|
||||
<NuxtImg
|
||||
:src="src2"
|
||||
:alt="alt2"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover object-center"
|
||||
sizes="sm:100vw md:30vw"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-span-1 dark:bg-gray-800 rounded-md lg:rounded-xl overflow-hidden">
|
||||
<NuxtImg
|
||||
:src="src3"
|
||||
:alt="alt3"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover object-center"
|
||||
sizes="sm:100vw md:30vw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
41
app/layouts/default.vue
Normal file
41
app/layouts/default.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<article class="min-h-screen flex flex-col items-stretch">
|
||||
|
||||
<!-- ring -->
|
||||
<div class="fixed inset-0 flex justify-center sm:px-8">
|
||||
<div class="flex w-full max-w-7xl lg:px-8">
|
||||
<div
|
||||
class="w-full bg-white ring-1 ring-zinc-200/50 dark:bg-zinc-900 dark:ring-zinc-500/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- background -->
|
||||
<v-container class="relative">
|
||||
<div class="absolute top-0 right-0">
|
||||
<NuxtImg
|
||||
src="img/bg-glow.png"
|
||||
aria-hidden="true"
|
||||
class="w-[44rem]"
|
||||
format="webp"
|
||||
width="944"
|
||||
height="586"
|
||||
/>
|
||||
</div>
|
||||
</v-container>
|
||||
|
||||
<!-- header -->
|
||||
<Header />
|
||||
|
||||
<!-- main -->
|
||||
<main class="maya-prose">
|
||||
<v-container>
|
||||
<slot />
|
||||
</v-container>
|
||||
</main>
|
||||
|
||||
<!-- footer -->
|
||||
<Footer class="mt-auto" />
|
||||
|
||||
</article>
|
||||
</template>
|
124
app/pages/index.vue
Normal file
124
app/pages/index.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<v-container fluid>
|
||||
<!-- Hero Section -->
|
||||
<v-row justify="center" class="text-center my-16">
|
||||
<v-col cols="12" md="8">
|
||||
<h1 class="text-h3 font-weight-bold mb-4">
|
||||
Inspire Emotion and Evoke Storytelling through the Art of Photography.
|
||||
</h1>
|
||||
<p class="text-body-1">
|
||||
Explore my photography portfolio and see the world through my creative lens.
|
||||
Contact me if you would like to hire me.
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Hero Image Grid -->
|
||||
<v-row dense class="mb-16">
|
||||
<v-col
|
||||
v-for="(image, index) in heroImages"
|
||||
:key="index"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
>
|
||||
<v-img
|
||||
:src="image.src"
|
||||
:alt="image.alt"
|
||||
:width="image.width"
|
||||
:height="image.height"
|
||||
class="rounded-lg elevation-2"
|
||||
cover
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- About Me Section -->
|
||||
<v-row class="align-center my-16">
|
||||
<v-col cols="12" md="5">
|
||||
<v-img
|
||||
src="/img/home/personal-photo.webp"
|
||||
alt="Oscar Mattern"
|
||||
width="100%"
|
||||
height="auto"
|
||||
class="rounded-lg"
|
||||
cover
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="7">
|
||||
<h2 class="text-h4 font-weight-bold mb-4">About Me</h2>
|
||||
<p class="text-body-1 mb-4">
|
||||
I am a photographer based in New York City. I specialize in
|
||||
<strong>landscape, travel, and portrait photography</strong>. I have been
|
||||
photographing for over 10 years and have been published in many magazines and
|
||||
newspapers. I am available for hire for weddings, events, and portraits.
|
||||
</p>
|
||||
<p class="text-body-1">
|
||||
I am a passionate photographer with a keen eye for capturing the beauty of life
|
||||
through the art of photography.
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Awards Section -->
|
||||
<v-row class="my-12">
|
||||
<v-col cols="12">
|
||||
<h2 class="text-h5 font-weight-bold mb-6">Awards</h2>
|
||||
<v-timeline side="end" dense>
|
||||
<v-timeline-item
|
||||
v-for="(award, index) in awards"
|
||||
:key="index"
|
||||
dot-color="primary"
|
||||
size="small"
|
||||
>
|
||||
<strong>{{ award.title }} ({{ award.year }})</strong><br />
|
||||
<span class="text-body-2">{{ award.description }}</span>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- CTA Hire Me Section -->
|
||||
<v-row justify="center" class="text-center mt-16">
|
||||
<v-col cols="12" md="8">
|
||||
<h2 class="text-h4 font-weight-bold mb-4">Questions or collaboration ideas?</h2>
|
||||
<p class="text-body-1 mb-6">
|
||||
I would love to hear from you! Whether you have <strong>questions</strong> about
|
||||
my work, have <strong>collaboration ideas</strong>, or are interested in
|
||||
<strong>hiring me</strong> for a project, don't hesitate to reach out.
|
||||
</p>
|
||||
<p class="text-body-1">I usually respond within hours!</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const heroImages = [
|
||||
{ src: '/img/home/hero-grid-01.webp', alt: 'Image 1', width: 1600, height: 2400 },
|
||||
{ src: '/img/home/hero-grid-02.webp', alt: 'Image 2', width: 1600, height: 2199 },
|
||||
{ src: '/img/home/hero-grid-03.webp', alt: 'Image 3', width: 1600, height: 2400 },
|
||||
{ src: '/img/home/hero-grid-07.webp', alt: 'Image 4', width: 1600, height: 2400 },
|
||||
{ src: '/img/home/hero-grid-05.webp', alt: 'Image 5', width: 1600, height: 2000 },
|
||||
{ src: '/img/home/hero-grid-06.webp', alt: 'Image 6', width: 1600, height: 2400 },
|
||||
{ src: '/img/home/hero-grid-04.webp', alt: 'Image 7', width: 1600, height: 2400 },
|
||||
]
|
||||
|
||||
const awards = [
|
||||
{
|
||||
title: 'Best Landscape Photographer',
|
||||
year: '2019',
|
||||
description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, quod.',
|
||||
},
|
||||
{
|
||||
title: 'Best Portrait Photographer',
|
||||
year: '2018',
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
},
|
||||
{
|
||||
title: 'Best Travel Photographer',
|
||||
year: '2017',
|
||||
description: 'Lorem ipsum dolor sit amet.',
|
||||
},
|
||||
]
|
||||
</script>
|
8
app/plugins/vuetify.ts
Normal file
8
app/plugins/vuetify.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export default defineNuxtPlugin(nuxtApp => {
|
||||
// check https://vuetify-nuxt-module.netlify.app/guide/nuxt-runtime-hooks.html
|
||||
nuxtApp.hook('vuetify:before-create', options => {
|
||||
if (import.meta.client) {
|
||||
console.log('vuetify:before-create', options)
|
||||
}
|
||||
})
|
||||
})
|
Reference in New Issue
Block a user