@minch/bentolio 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -21
- package/app/app.vue +77 -140
- package/app/assets/css/tailwind.css +86 -0
- package/app/components/section/app-about.vue +27 -0
- package/app/components/section/app-hero.vue +71 -0
- package/app/components/section/app-miscellany.vue +56 -0
- package/app/components/theme-toggle.vue +32 -0
- package/app/utils/types.ts +29 -28
- package/eslint.config.mjs +1 -0
- package/nuxt.config.ts +7 -0
- package/package.json +7 -6
- package/app/app.config.ts +0 -53
- package/app/assets/css/main.css +0 -85
- package/app/components/ThemeToggle.vue +0 -26
- package/app/layouts/default.vue +0 -73
- package/public/favicon.svg +0 -6
- package/public/logo.svg +0 -6
- package/public/profile.jpg +0 -0
- /package/app/components/{AppFooter.vue → section/app-footer.vue} +0 -0
- /package/app/components/{AppHeader.vue → section/app-header.vue} +0 -0
- /package/app/components/{SocialMediaLink.vue → social-media-link.vue} +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Bentolio
|
|
2
2
|
|
|
3
|
-
A minimal portfolio template built with Nuxt 4, Tailwind CSS 4, and GSAP.
|
|
3
|
+
A minimal portfolio template and Nuxt layer built with Nuxt 4, Tailwind CSS 4, and GSAP.
|
|
4
4
|
|
|
5
5
|
All content and basic styling are configured via `app/app.config.ts`.
|
|
6
6
|
|
|
@@ -10,7 +10,7 @@ All content and basic styling are configured via `app/app.config.ts`.
|
|
|
10
10
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
|
-
- **Nuxt
|
|
13
|
+
- **Nuxt Layer**: Modern DX, serverless-friendly, and SSG by default
|
|
14
14
|
- **Tailwind CSS 4**: Zero-config utility classes with CSS-first authoring
|
|
15
15
|
- **GSAP animations**: Smooth entrance animations and micro‑interactions
|
|
16
16
|
- **Dark Mode**: Toggle via `@nuxtjs/color-mode` (class strategy)
|
|
@@ -19,26 +19,25 @@ All content and basic styling are configured via `app/app.config.ts`.
|
|
|
19
19
|
- **Static generation**: Pre‑rendered pages for easy deployment to any static host
|
|
20
20
|
- **OG Image**: Automatically generated OG image via `nuxt-og-image`
|
|
21
21
|
|
|
22
|
-
##
|
|
22
|
+
## Configure Your Portfolio
|
|
23
|
+
|
|
24
|
+
To use as a template, you can simply clone and update the files to your liking.
|
|
23
25
|
|
|
26
|
+
Alternatively, you can also use it as a Nuxt layer:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm i -D @minch/bentolio
|
|
24
30
|
```
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
layouts/default.vue # Layout + SEO meta + GSAP entrance animations
|
|
31
|
-
plugins/gsap.client.ts # Nuxt plugin providing `$gsap`
|
|
32
|
-
utils/types.ts # Types for the app config
|
|
33
|
-
public/
|
|
34
|
-
favicon.svg, logo.svg, profile.jpg, robots.txt
|
|
35
|
-
nuxt.config.ts # Nuxt configuration
|
|
36
|
-
package.json # Scripts and dependencies
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
export default defineNuxtConfig({
|
|
34
|
+
extends: ["@minch/bentolio"]
|
|
35
|
+
});
|
|
37
36
|
```
|
|
38
37
|
|
|
39
|
-
|
|
38
|
+
Have a look at [.playground](/.playground/) to see how to set up as a layer.
|
|
40
39
|
|
|
41
|
-
All
|
|
40
|
+
All page content lives in `app/app.config.ts` and is fully typed (see `app/utils/types.ts`). Edit the example values to your own:
|
|
42
41
|
|
|
43
42
|
```ts
|
|
44
43
|
// app/app.config.ts (excerpt)
|
|
@@ -80,15 +79,17 @@ Supported social keys by default: `"X" | "LinkedIn" | "Bluesky" | "GitHub"`. To
|
|
|
80
79
|
### Assets
|
|
81
80
|
|
|
82
81
|
- Replace `public/profile.jpg` with your own avatar.
|
|
83
|
-
- Update `public/favicon.svg` and `public/logo.svg` if desired.
|
|
84
|
-
- OG image
|
|
82
|
+
- Update `public/favicon.svg`, `public/og-image.png`, `public/logo.svg` and `public/logo.svg` if desired.
|
|
83
|
+
- OG image can be auto-generated using the `nuxt-og-image` module.
|
|
85
84
|
|
|
86
85
|
### Theming and Styles
|
|
87
86
|
|
|
88
|
-
- Tailwind entry is `app/assets/css/
|
|
87
|
+
- Tailwind entry is `app/assets/css/tailwind.css`.
|
|
89
88
|
- Primary color is set via CSS theme variable:
|
|
90
89
|
|
|
91
90
|
```css
|
|
91
|
+
@import "@minch/bentolio/tailwind";
|
|
92
|
+
|
|
92
93
|
/* Change `--color-primary` to customize your accent. */
|
|
93
94
|
@theme {
|
|
94
95
|
--color-primary: var(--color-lime-500);
|
|
@@ -98,7 +99,7 @@ Supported social keys by default: `"X" | "LinkedIn" | "Bluesky" | "GitHub"`. To
|
|
|
98
99
|
### Animations
|
|
99
100
|
|
|
100
101
|
- GSAP is provided via a Nuxt plugin (`$gsap`). See `app/layouts/default.vue` for entrance animations.
|
|
101
|
-
- Elements with the `animate-element` utility class will animate in on page load. The initial state and transitions are defined in `app/assets/css/
|
|
102
|
+
- Elements with the `animate-element` utility class will animate in on page load. The initial state and transitions are defined in `app/assets/css/tailwind.css`.
|
|
102
103
|
- The waving hand emoji animation is controlled via the `#wave` id in CSS.
|
|
103
104
|
|
|
104
105
|
## Deployment
|
package/app/app.vue
CHANGED
|
@@ -1,156 +1,93 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import "@fontsource/space-grotesk/400.css";
|
|
3
|
+
import "@fontsource/space-grotesk/500.css";
|
|
4
|
+
import "@fontsource/space-grotesk/600.css";
|
|
5
|
+
import "@fontsource/space-grotesk/700.css";
|
|
6
|
+
import gsap from "gsap";
|
|
3
7
|
|
|
4
|
-
const { portfolio
|
|
8
|
+
const { portfolio } = useAppConfig();
|
|
5
9
|
|
|
6
|
-
const {
|
|
7
|
-
name,
|
|
8
|
-
subtitle,
|
|
9
|
-
avatar,
|
|
10
|
-
company,
|
|
11
|
-
bio,
|
|
12
|
-
cta: { contact, location, link },
|
|
13
|
-
socials
|
|
14
|
-
} = portfolio;
|
|
10
|
+
const { name, subtitle, avatar, bio, customOgImage } = portfolio;
|
|
15
11
|
|
|
16
|
-
const {
|
|
12
|
+
const { $gsap } = useNuxtApp();
|
|
17
13
|
|
|
18
|
-
const
|
|
19
|
-
Object.entries(socials).slice(0, 4)
|
|
20
|
-
);
|
|
14
|
+
const ctx = ref<gsap.Context | null>(null);
|
|
21
15
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
onMounted(() => {
|
|
17
|
+
ctx.value = $gsap.context(() => {
|
|
18
|
+
$gsap.fromTo(
|
|
19
|
+
".animate-layout",
|
|
20
|
+
{ opacity: 0 },
|
|
21
|
+
{
|
|
22
|
+
opacity: 1,
|
|
23
|
+
duration: 0.75,
|
|
24
|
+
delay: 0.5,
|
|
25
|
+
ease: "elastic.out(0.5, 0.3)"
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
$gsap.fromTo(
|
|
29
|
+
".animate-element",
|
|
30
|
+
{ scale: 0.75, opacity: 0, y: 20 },
|
|
31
|
+
{
|
|
32
|
+
scale: 1,
|
|
33
|
+
opacity: 1,
|
|
34
|
+
y: 0,
|
|
35
|
+
duration: 0.15,
|
|
36
|
+
delay: 0.9,
|
|
37
|
+
ease: "elastic.out(0.5, 0.3)",
|
|
38
|
+
stagger: 0.1
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
subtitle: subtitle,
|
|
34
|
-
avatar: avatar
|
|
44
|
+
onUnmounted(() => {
|
|
45
|
+
ctx.value && ctx.value.revert();
|
|
35
46
|
});
|
|
36
|
-
</script>
|
|
37
47
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<!-- Hero -->
|
|
44
|
-
<div class="grid-rows-2">
|
|
45
|
-
<!-- Intro -->
|
|
46
|
-
<div
|
|
47
|
-
:class="[
|
|
48
|
-
'animate-element col-span-full lg:col-span-8 row-span-2',
|
|
49
|
-
{ 'rounded-lg': roundedItems }
|
|
50
|
-
]">
|
|
51
|
-
<img
|
|
52
|
-
v-if="avatar"
|
|
53
|
-
:src="avatar"
|
|
54
|
-
alt="My Profile Picture"
|
|
55
|
-
class="size-20 mb-4 mx-auto lg:mx-0 bg-transparent rounded-full ring ring-zinc-200 dark:ring-zinc-800 p-0.5" />
|
|
48
|
+
useHead({
|
|
49
|
+
bodyAttrs: {
|
|
50
|
+
class: "animate-layout opacity-0"
|
|
51
|
+
}
|
|
52
|
+
});
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
54
|
+
useSeoMeta({
|
|
55
|
+
title: `${name} · ${subtitle}`,
|
|
56
|
+
description: bio.text,
|
|
57
|
+
ogTitle: `${name} · ${subtitle}`,
|
|
58
|
+
ogDescription: bio.text,
|
|
59
|
+
ogImage: customOgImage,
|
|
60
|
+
twitterImage: customOgImage,
|
|
61
|
+
twitterCard: "summary_large_image"
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
if (!customOgImage) {
|
|
65
|
+
defineOgImageComponent("SocialImage", {
|
|
66
|
+
name: name,
|
|
67
|
+
subtitle: subtitle,
|
|
68
|
+
avatar: avatar
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
:key="key"
|
|
75
|
-
:to="item.profileUrl"
|
|
76
|
-
:name="key as SocialMediaKey"
|
|
77
|
-
:icon="item.socialMediaIcon"
|
|
78
|
-
:style="{
|
|
79
|
-
backgroundColor: item.socialMediaBgColor,
|
|
80
|
-
textColor: item.socialMediaTextColor,
|
|
81
|
-
borderColor: item.socialMediaBorderColor
|
|
82
|
-
}"
|
|
83
|
-
:class="[
|
|
84
|
-
'animate-element',
|
|
85
|
-
socialLinkClass,
|
|
86
|
-
{ 'rounded-lg': roundedItems }
|
|
87
|
-
]" />
|
|
88
|
-
</div>
|
|
73
|
+
<template>
|
|
74
|
+
<div class="space-y-5 sm:space-y-10">
|
|
75
|
+
<app-header />
|
|
89
76
|
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
'animate-element leading-relaxed col-span-full font-medium gap-4 py-6! sm:py-10! text-zinc-700 dark:text-zinc-300 text-lg sm:text-xl lg:text-2xl',
|
|
96
|
-
{ 'rounded-lg': roundedItems }
|
|
97
|
-
]"
|
|
98
|
-
v-html="bio.html"></p>
|
|
99
|
-
<p
|
|
100
|
-
v-else
|
|
101
|
-
:class="[
|
|
102
|
-
'animate-element leading-relaxed col-span-full font-medium gap-4 py-6! sm:py-10! text-zinc-700 dark:text-zinc-300 text-lg sm:text-xl lg:text-2xl',
|
|
103
|
-
{ 'rounded-lg': roundedItems }
|
|
104
|
-
]">
|
|
105
|
-
{{ bio.text }}
|
|
106
|
-
</p>
|
|
107
|
-
</div>
|
|
77
|
+
<main class="min-h-full">
|
|
78
|
+
<article
|
|
79
|
+
class="flex flex-col gap-4 *:grid *:grid-cols-12 *:gap-4 *:w-full *:*:min-h-16 *:*:py-4 *:*:px-6 sm:*:*:py-6 sm:*:*:px-10 *:*:border *:*:border-zinc-200 dark:*:*:border-zinc-700 *:*:bg-zinc-50/50 dark:*:*:bg-zinc-800/25 *:text-zinc-950 dark:*:text-zinc-300">
|
|
80
|
+
<!-- Hero -->
|
|
81
|
+
<app-hero />
|
|
108
82
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
class="grid-rows-1 grid-cols-1! sm:grid-cols-[repeat(auto-fit,minmax(100px,1fr))]!">
|
|
112
|
-
<!-- Link -->
|
|
113
|
-
<nuxt-link
|
|
114
|
-
v-if="link"
|
|
115
|
-
:to="link.url"
|
|
116
|
-
:class="[
|
|
117
|
-
'animate-element flex flex-col items-center justify-center gap-y-2 focus-visible:global-focus hover:scale-105!',
|
|
118
|
-
{ 'rounded-lg': roundedItems }
|
|
119
|
-
]">
|
|
120
|
-
<Icon
|
|
121
|
-
name="ph:arrow-square-up-right-duotone"
|
|
122
|
-
class="text-3xl lg:text-4xl text-primary" />
|
|
123
|
-
<span class="text-center text-lg">
|
|
124
|
-
{{ link.label }}
|
|
125
|
-
</span>
|
|
126
|
-
</nuxt-link>
|
|
83
|
+
<!-- About -->
|
|
84
|
+
<app-about />
|
|
127
85
|
|
|
128
|
-
<!--
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{ 'rounded-lg': roundedItems }
|
|
133
|
-
]">
|
|
134
|
-
<Icon
|
|
135
|
-
name="ph:map-pin-duotone"
|
|
136
|
-
class="text-3xl lg:text-4xl text-primary" />
|
|
137
|
-
<span class="text-center text-lg"> {{ location }} </span>
|
|
138
|
-
</p>
|
|
86
|
+
<!-- Miscellany -->
|
|
87
|
+
<app-miscellany />
|
|
88
|
+
</article>
|
|
89
|
+
</main>
|
|
139
90
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
:class="[
|
|
143
|
-
'animate-element flex flex-col items-center justify-center gap-y-2',
|
|
144
|
-
{ 'rounded-lg': roundedItems }
|
|
145
|
-
]">
|
|
146
|
-
<Icon
|
|
147
|
-
name="ph:chat-circle-text-duotone"
|
|
148
|
-
class="text-3xl lg:text-4xl text-primary" />
|
|
149
|
-
<span class="text-center text-lg">
|
|
150
|
-
{{ contact }}
|
|
151
|
-
</span>
|
|
152
|
-
</p>
|
|
153
|
-
</div>
|
|
154
|
-
</article>
|
|
155
|
-
</NuxtLayout>
|
|
91
|
+
<app-footer />
|
|
92
|
+
</div>
|
|
156
93
|
</template>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
3
|
+
@source "../../**/*";
|
|
4
|
+
|
|
5
|
+
@theme {
|
|
6
|
+
--color-primary: var(--color-lime-500);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
@utility global-focus {
|
|
10
|
+
@apply outline-none ring-2 ring-offset-0 ring-primary/75;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@utility animate-element {
|
|
14
|
+
@apply transition-transform opacity-0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
* {
|
|
18
|
+
@apply box-border selection:bg-primary/50;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
body {
|
|
22
|
+
font-family: "Space Grotesk", sans-serif;
|
|
23
|
+
word-wrap: break-word;
|
|
24
|
+
@apply w-full bg-white dark:bg-zinc-900;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#app {
|
|
28
|
+
@apply max-w-5xl min-w-xs w-full bg-white dark:bg-zinc-900 mx-auto break-words my-0 p-4 sm:p-6 md:p-12;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Animated Wave Emoji */
|
|
32
|
+
#wave {
|
|
33
|
+
@apply inline-block ml-1 origin-[70%_70%] animate-[10s_ease_2s_infinite_wave] hover:animate-[1.5s_ease_hoverwave];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@keyframes wave {
|
|
37
|
+
0% {
|
|
38
|
+
transform: rotate(0deg);
|
|
39
|
+
}
|
|
40
|
+
2.5%,
|
|
41
|
+
7.5% {
|
|
42
|
+
transform: rotate(14deg);
|
|
43
|
+
}
|
|
44
|
+
5% {
|
|
45
|
+
transform: rotate(-8deg);
|
|
46
|
+
}
|
|
47
|
+
10% {
|
|
48
|
+
transform: rotate(-4deg);
|
|
49
|
+
}
|
|
50
|
+
12.5% {
|
|
51
|
+
transform: rotate(10deg);
|
|
52
|
+
}
|
|
53
|
+
15% {
|
|
54
|
+
transform: rotate(0deg);
|
|
55
|
+
}
|
|
56
|
+
100% {
|
|
57
|
+
transform: rotate(0deg);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@keyframes hoverwave {
|
|
62
|
+
0% {
|
|
63
|
+
transform: rotate(0deg);
|
|
64
|
+
}
|
|
65
|
+
10% {
|
|
66
|
+
transform: rotate(14deg);
|
|
67
|
+
}
|
|
68
|
+
20% {
|
|
69
|
+
transform: rotate(-8deg);
|
|
70
|
+
}
|
|
71
|
+
30% {
|
|
72
|
+
transform: rotate(14deg);
|
|
73
|
+
}
|
|
74
|
+
40% {
|
|
75
|
+
transform: rotate(-4deg);
|
|
76
|
+
}
|
|
77
|
+
50% {
|
|
78
|
+
transform: rotate(10deg);
|
|
79
|
+
}
|
|
80
|
+
60% {
|
|
81
|
+
transform: rotate(0deg);
|
|
82
|
+
}
|
|
83
|
+
100% {
|
|
84
|
+
transform: rotate(0deg);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
const { portfolio, style } = useAppConfig();
|
|
3
|
+
|
|
4
|
+
const { bio } = portfolio;
|
|
5
|
+
|
|
6
|
+
const { roundedItems } = style;
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="grid-rows-1">
|
|
11
|
+
<p
|
|
12
|
+
v-if="bio.html"
|
|
13
|
+
:class="[
|
|
14
|
+
'animate-element leading-relaxed col-span-full font-medium gap-4 py-6! sm:py-10! text-zinc-700 dark:text-zinc-300 text-lg sm:text-xl lg:text-2xl',
|
|
15
|
+
{ 'rounded-lg': roundedItems }
|
|
16
|
+
]"
|
|
17
|
+
v-html="bio.html"></p>
|
|
18
|
+
<p
|
|
19
|
+
v-else
|
|
20
|
+
:class="[
|
|
21
|
+
'animate-element leading-relaxed col-span-full font-medium gap-4 py-6! sm:py-10! text-zinc-700 dark:text-zinc-300 text-lg sm:text-xl lg:text-2xl',
|
|
22
|
+
{ 'rounded-lg': roundedItems }
|
|
23
|
+
]">
|
|
24
|
+
{{ bio.text }}
|
|
25
|
+
</p>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
import type { SocialMediaKey } from "~";
|
|
3
|
+
|
|
4
|
+
const { portfolio, style } = useAppConfig();
|
|
5
|
+
|
|
6
|
+
const { name, subtitle, avatar, company, socials } = portfolio;
|
|
7
|
+
|
|
8
|
+
const { roundedItems } = style;
|
|
9
|
+
|
|
10
|
+
const topFourSocials = Object.fromEntries(
|
|
11
|
+
Object.entries(socials).slice(0, 4)
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
const numSocials = Object.entries(topFourSocials).length;
|
|
15
|
+
const socialLinkClass = {
|
|
16
|
+
"col-span-full lg:col-span-4 lg:row-span-2": numSocials === 1,
|
|
17
|
+
"col-span-6 lg:col-span-4": numSocials === 2,
|
|
18
|
+
"col-span-4 last:col-span-4 lg:col-span-2 lg:last:col-span-4":
|
|
19
|
+
numSocials === 3,
|
|
20
|
+
"col-span-3 lg:col-span-2": numSocials === 4
|
|
21
|
+
};
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="grid-rows-2">
|
|
26
|
+
<!-- Intro -->
|
|
27
|
+
<div
|
|
28
|
+
:class="[
|
|
29
|
+
'animate-element col-span-full lg:col-span-8 row-span-2',
|
|
30
|
+
{ 'rounded-lg': roundedItems }
|
|
31
|
+
]">
|
|
32
|
+
<img
|
|
33
|
+
v-if="avatar"
|
|
34
|
+
:src="avatar"
|
|
35
|
+
alt="My Profile Picture"
|
|
36
|
+
class="size-20 mb-4 mx-auto lg:mx-0 bg-transparent rounded-full ring ring-zinc-200 dark:ring-zinc-800 p-0.5" />
|
|
37
|
+
|
|
38
|
+
<div class="space-y-4 py-6 sm:pb-8">
|
|
39
|
+
<h1
|
|
40
|
+
class="text-2xl sm:text-3xl lg:text-4xl text-center lg:text-left font-bold *:inline-block">
|
|
41
|
+
Hi, I'm {{ name }}
|
|
42
|
+
<span id="wave">👋</span>
|
|
43
|
+
</h1>
|
|
44
|
+
<h2
|
|
45
|
+
class="text-center lg:text-left font-bold *:inline-block text-zinc-500 dark:text-primary text-xl sm:text-2xl lg:text-3xl">
|
|
46
|
+
<span>{{ subtitle }}</span
|
|
47
|
+
><span v-if="company"> @ {{ company }}</span>
|
|
48
|
+
</h2>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Social Media -->
|
|
53
|
+
<template v-for="(item, key) in topFourSocials" :key="key">
|
|
54
|
+
<social-media-link
|
|
55
|
+
v-if="item"
|
|
56
|
+
:to="item.profileUrl"
|
|
57
|
+
:name="key as SocialMediaKey"
|
|
58
|
+
:icon="item.socialMediaIcon"
|
|
59
|
+
:style="{
|
|
60
|
+
backgroundColor: item.socialMediaBgColor,
|
|
61
|
+
textColor: item.socialMediaTextColor,
|
|
62
|
+
borderColor: item.socialMediaBorderColor
|
|
63
|
+
}"
|
|
64
|
+
:class="[
|
|
65
|
+
'animate-element',
|
|
66
|
+
socialLinkClass,
|
|
67
|
+
{ 'rounded-lg': roundedItems }
|
|
68
|
+
]" />
|
|
69
|
+
</template>
|
|
70
|
+
</div>
|
|
71
|
+
</template>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
const { portfolio, style } = useAppConfig();
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
cta: { contact, location, link }
|
|
6
|
+
} = portfolio;
|
|
7
|
+
|
|
8
|
+
const { roundedItems } = style;
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<div
|
|
13
|
+
class="grid-rows-1 grid-cols-1! sm:grid-cols-[repeat(auto-fit,minmax(100px,1fr))]!">
|
|
14
|
+
<!-- Link -->
|
|
15
|
+
<nuxt-link
|
|
16
|
+
v-if="link"
|
|
17
|
+
:to="link.url"
|
|
18
|
+
:class="[
|
|
19
|
+
'animate-element flex flex-col items-center justify-center gap-y-2 focus-visible:global-focus hover:scale-105!',
|
|
20
|
+
{ 'rounded-lg': roundedItems }
|
|
21
|
+
]">
|
|
22
|
+
<Icon
|
|
23
|
+
name="ph:arrow-square-up-right-duotone"
|
|
24
|
+
class="text-3xl lg:text-4xl text-primary" />
|
|
25
|
+
<span class="text-center text-lg">
|
|
26
|
+
{{ link.label }}
|
|
27
|
+
</span>
|
|
28
|
+
</nuxt-link>
|
|
29
|
+
|
|
30
|
+
<!-- Location -->
|
|
31
|
+
<p
|
|
32
|
+
:class="[
|
|
33
|
+
'animate-element flex flex-col items-center justify-center gap-y-2',
|
|
34
|
+
{ 'rounded-lg': roundedItems }
|
|
35
|
+
]">
|
|
36
|
+
<Icon
|
|
37
|
+
name="ph:map-pin-duotone"
|
|
38
|
+
class="text-3xl lg:text-4xl text-primary" />
|
|
39
|
+
<span class="text-center text-lg"> {{ location }} </span>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<!-- Contact -->
|
|
43
|
+
<p
|
|
44
|
+
:class="[
|
|
45
|
+
'animate-element flex flex-col items-center justify-center gap-y-2',
|
|
46
|
+
{ 'rounded-lg': roundedItems }
|
|
47
|
+
]">
|
|
48
|
+
<Icon
|
|
49
|
+
name="ph:chat-circle-text-duotone"
|
|
50
|
+
class="text-3xl lg:text-4xl text-primary" />
|
|
51
|
+
<span class="text-center text-lg">
|
|
52
|
+
{{ contact }}
|
|
53
|
+
</span>
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const colorMode = useColorMode();
|
|
3
|
+
const { style } = useAppConfig();
|
|
4
|
+
|
|
5
|
+
const { roundedItems } = style;
|
|
6
|
+
|
|
7
|
+
const onClick = () =>
|
|
8
|
+
colorMode.value === "light"
|
|
9
|
+
? (colorMode.preference = "dark")
|
|
10
|
+
: (colorMode.preference = "light");
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<button
|
|
15
|
+
aria-label="Color Mode"
|
|
16
|
+
:class="[
|
|
17
|
+
'w-10 h-10 flex items-center justify-center text-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50/50 dark:bg-zinc-800/25 hover:bg-zinc-200 dark:hover:bg-zinc-700 *:text-zinc-950 dark:*:text-zinc-300 focus-visible:global-focus',
|
|
18
|
+
{ 'rounded-lg': roundedItems }
|
|
19
|
+
]"
|
|
20
|
+
@click="onClick">
|
|
21
|
+
<ColorScheme placeholder="...">
|
|
22
|
+
<template v-if="colorMode.value === 'dark'">
|
|
23
|
+
<Icon name="ph:sun-duotone" class="w-5 h-5" />
|
|
24
|
+
<span class="sr-only">Dark Mode</span>
|
|
25
|
+
</template>
|
|
26
|
+
<template v-else>
|
|
27
|
+
<Icon name="ph:moon-duotone" class="w-5 h-5" />
|
|
28
|
+
<span class="sr-only">Light Mode</span>
|
|
29
|
+
</template>
|
|
30
|
+
</ColorScheme>
|
|
31
|
+
</button>
|
|
32
|
+
</template>
|
package/app/utils/types.ts
CHANGED
|
@@ -1,42 +1,43 @@
|
|
|
1
1
|
export type SocialMediaKey = "X" | "LinkedIn" | "Bluesky" | "GitHub";
|
|
2
2
|
|
|
3
3
|
export interface SocialMediaValue {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
socialMediaIcon: string;
|
|
5
|
+
socialMediaBgColor: string;
|
|
6
|
+
socialMediaBorderColor: string;
|
|
7
|
+
socialMediaTextColor: string;
|
|
8
|
+
profileUrl: string;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface Portfolio {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
12
|
+
name: string;
|
|
13
|
+
subtitle: string;
|
|
14
|
+
avatar?: string;
|
|
15
|
+
customOgImage?: string;
|
|
16
|
+
company?: string;
|
|
17
|
+
socials: Partial<Record<SocialMediaKey, SocialMediaValue>>;
|
|
18
|
+
bio: {
|
|
19
|
+
html?: string;
|
|
20
|
+
text: string;
|
|
21
|
+
};
|
|
22
|
+
cta: {
|
|
23
|
+
contact?: string;
|
|
24
|
+
location?: string;
|
|
25
|
+
link?: { url: string; label: string };
|
|
26
|
+
};
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface Style {
|
|
29
|
-
|
|
30
|
+
roundedItems: boolean;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
declare module "nuxt/schema" {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
interface AppConfigInput {
|
|
35
|
+
portfolio: Portfolio;
|
|
36
|
+
style?: Style;
|
|
37
|
+
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
interface AppConfig {
|
|
40
|
+
portfolio: Portfolio;
|
|
41
|
+
style?: Style;
|
|
42
|
+
}
|
|
42
43
|
}
|
package/eslint.config.mjs
CHANGED
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@minch/bentolio",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "A minimal portfolio template and layer built with Nuxt 4, Tailwind CSS 4, and GSAP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -52,10 +52,11 @@
|
|
|
52
52
|
"vue-router": "^4.5.1"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
|
-
"
|
|
56
|
-
"dev": "nuxt
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
55
|
+
"dev": "nuxt dev .playground",
|
|
56
|
+
"dev:prepare": "nuxt prepare .playground",
|
|
57
|
+
"build": "nuxt build .playground",
|
|
58
|
+
"generate": "nuxt generate .playground",
|
|
59
|
+
"preview": "nuxt preview .playground",
|
|
60
|
+
"lint": "eslint ."
|
|
60
61
|
}
|
|
61
62
|
}
|
package/app/app.config.ts
DELETED
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
export default defineAppConfig({
|
|
2
|
-
style: {
|
|
3
|
-
roundedItems: false,
|
|
4
|
-
},
|
|
5
|
-
portfolio: {
|
|
6
|
-
name: "Dawit",
|
|
7
|
-
subtitle: "I Craft Delightful Web Apps",
|
|
8
|
-
// company: "[COMPANY]",
|
|
9
|
-
avatar: "/profile.jpg",
|
|
10
|
-
bio: {
|
|
11
|
-
html: "I'm a Full-Stack Problem-Solver specializing in <span class='font-bold text-primary'>Vue</span>, <span class='font-bold text-primary'>Nuxt</span>, <span class='font-bold text-primary'>Tailwind CSS</span>, and <span class='font-bold text-primary'>Node</span>. I sometimes write articles on various software development topics. I'm also a hobbyist photographer.",
|
|
12
|
-
text: "I'm a Full-Stack Problem-Solver Specializing in Vue, Nuxt, Tailwind CSS and Node.js. I Sometimes Write Articles on Various Web Dev Topics. I'm Also a Hobbyist Photographer.",
|
|
13
|
-
},
|
|
14
|
-
socials: {
|
|
15
|
-
Bluesky: {
|
|
16
|
-
socialMediaIcon: "simple-icons:bluesky",
|
|
17
|
-
profileUrl: "https://bsky.app/profile/minch.dev",
|
|
18
|
-
socialMediaBgColor: "bg-blue-500!",
|
|
19
|
-
socialMediaBorderColor: "border-blue-500!",
|
|
20
|
-
socialMediaTextColor: "text-white!",
|
|
21
|
-
},
|
|
22
|
-
GitHub: {
|
|
23
|
-
socialMediaIcon: "simple-icons:github",
|
|
24
|
-
profileUrl: "https://x.com/oneminch",
|
|
25
|
-
socialMediaBgColor: "bg-black!",
|
|
26
|
-
socialMediaBorderColor: "border-black!",
|
|
27
|
-
socialMediaTextColor: "text-white!",
|
|
28
|
-
},
|
|
29
|
-
X: {
|
|
30
|
-
socialMediaIcon: "simple-icons:x",
|
|
31
|
-
profileUrl: "https://x.com/oneminch",
|
|
32
|
-
socialMediaBgColor: "bg-black!",
|
|
33
|
-
socialMediaBorderColor: "border-black!",
|
|
34
|
-
socialMediaTextColor: "text-white!",
|
|
35
|
-
},
|
|
36
|
-
LinkedIn: {
|
|
37
|
-
socialMediaIcon: "simple-icons:linkedin",
|
|
38
|
-
profileUrl: "https://linkedin.com/in/oneminch",
|
|
39
|
-
socialMediaBgColor: "bg-blue-600!",
|
|
40
|
-
socialMediaBorderColor: "border-blue-600!",
|
|
41
|
-
socialMediaTextColor: "text-white!",
|
|
42
|
-
},
|
|
43
|
-
},
|
|
44
|
-
cta: {
|
|
45
|
-
contact: "[firstname]@example.com",
|
|
46
|
-
location: "Planet Earth",
|
|
47
|
-
link: {
|
|
48
|
-
url: "https://github.com/oneminch/Bentolio",
|
|
49
|
-
label: "Source Code",
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
});
|
package/app/assets/css/main.css
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
@import "tailwindcss";
|
|
2
|
-
@custom-variant dark (&:where(.dark, .dark *));
|
|
3
|
-
|
|
4
|
-
@theme {
|
|
5
|
-
--color-primary: var(--color-lime-500);
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
@utility global-focus {
|
|
9
|
-
@apply outline-none ring-2 ring-offset-0 ring-primary/75;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
@utility animate-element {
|
|
13
|
-
@apply transition-transform opacity-0;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
* {
|
|
17
|
-
@apply box-border selection:bg-primary/50;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
body {
|
|
21
|
-
font-family: "Space Grotesk", sans-serif;
|
|
22
|
-
word-wrap: break-word;
|
|
23
|
-
@apply w-full bg-white dark:bg-zinc-900;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
#app {
|
|
27
|
-
@apply max-w-5xl min-w-xs w-full bg-white dark:bg-zinc-900 mx-auto break-words my-0 p-4 sm:p-6 md:p-12;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/* Animated Wave Emoji */
|
|
31
|
-
#wave {
|
|
32
|
-
@apply inline-block ml-1 origin-[70%_70%] animate-[10s_ease_2s_infinite_wave] hover:animate-[1.5s_ease_hoverwave];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
@keyframes wave {
|
|
36
|
-
0% {
|
|
37
|
-
transform: rotate(0deg);
|
|
38
|
-
}
|
|
39
|
-
2.5%,
|
|
40
|
-
7.5% {
|
|
41
|
-
transform: rotate(14deg);
|
|
42
|
-
}
|
|
43
|
-
5% {
|
|
44
|
-
transform: rotate(-8deg);
|
|
45
|
-
}
|
|
46
|
-
10% {
|
|
47
|
-
transform: rotate(-4deg);
|
|
48
|
-
}
|
|
49
|
-
12.5% {
|
|
50
|
-
transform: rotate(10deg);
|
|
51
|
-
}
|
|
52
|
-
15% {
|
|
53
|
-
transform: rotate(0deg);
|
|
54
|
-
}
|
|
55
|
-
100% {
|
|
56
|
-
transform: rotate(0deg);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
@keyframes hoverwave {
|
|
61
|
-
0% {
|
|
62
|
-
transform: rotate(0deg);
|
|
63
|
-
}
|
|
64
|
-
10% {
|
|
65
|
-
transform: rotate(14deg);
|
|
66
|
-
}
|
|
67
|
-
20% {
|
|
68
|
-
transform: rotate(-8deg);
|
|
69
|
-
}
|
|
70
|
-
30% {
|
|
71
|
-
transform: rotate(14deg);
|
|
72
|
-
}
|
|
73
|
-
40% {
|
|
74
|
-
transform: rotate(-4deg);
|
|
75
|
-
}
|
|
76
|
-
50% {
|
|
77
|
-
transform: rotate(10deg);
|
|
78
|
-
}
|
|
79
|
-
60% {
|
|
80
|
-
transform: rotate(0deg);
|
|
81
|
-
}
|
|
82
|
-
100% {
|
|
83
|
-
transform: rotate(0deg);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
const colorMode = useColorMode();
|
|
3
|
-
const onClick = () =>
|
|
4
|
-
colorMode.value === "light"
|
|
5
|
-
? (colorMode.preference = "dark")
|
|
6
|
-
: (colorMode.preference = "light");
|
|
7
|
-
</script>
|
|
8
|
-
|
|
9
|
-
<template>
|
|
10
|
-
<button
|
|
11
|
-
aria-label="Color Mode"
|
|
12
|
-
class="w-10 h-10 flex items-center justify-center text-xl border border-zinc-200 dark:border-zinc-700 bg-zinc-50/50 dark:bg-zinc-800/25 hover:bg-zinc-200 dark:hover:bg-zinc-700 *:text-zinc-950 dark:*:text-zinc-300 focus-visible:global-focus"
|
|
13
|
-
@click="onClick"
|
|
14
|
-
>
|
|
15
|
-
<ColorScheme placeholder="...">
|
|
16
|
-
<template v-if="colorMode.value === 'dark'">
|
|
17
|
-
<Icon name="ph:sun-duotone" class="w-5 h-5" />
|
|
18
|
-
<span class="sr-only">Dark Mode</span>
|
|
19
|
-
</template>
|
|
20
|
-
<template v-else>
|
|
21
|
-
<Icon name="ph:moon-duotone" class="w-5 h-5" />
|
|
22
|
-
<span class="sr-only">Light Mode</span>
|
|
23
|
-
</template>
|
|
24
|
-
</ColorScheme>
|
|
25
|
-
</button>
|
|
26
|
-
</template>
|
package/app/layouts/default.vue
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import "@fontsource/space-grotesk/400.css";
|
|
3
|
-
import "@fontsource/space-grotesk/500.css";
|
|
4
|
-
import "@fontsource/space-grotesk/600.css";
|
|
5
|
-
import "@fontsource/space-grotesk/700.css";
|
|
6
|
-
import gsap from "gsap";
|
|
7
|
-
|
|
8
|
-
const { portfolio } = useAppConfig();
|
|
9
|
-
|
|
10
|
-
const { name, subtitle, bio } = portfolio;
|
|
11
|
-
|
|
12
|
-
const { $gsap } = useNuxtApp();
|
|
13
|
-
|
|
14
|
-
const ctx = ref<gsap.Context | null>(null);
|
|
15
|
-
|
|
16
|
-
onMounted(() => {
|
|
17
|
-
ctx.value = $gsap.context(() => {
|
|
18
|
-
$gsap.fromTo(
|
|
19
|
-
".animate-layout",
|
|
20
|
-
{ opacity: 0 },
|
|
21
|
-
{
|
|
22
|
-
opacity: 1,
|
|
23
|
-
duration: 0.75,
|
|
24
|
-
delay: 0.5,
|
|
25
|
-
ease: "elastic.out(0.5, 0.3)",
|
|
26
|
-
}
|
|
27
|
-
);
|
|
28
|
-
$gsap.fromTo(
|
|
29
|
-
".animate-element",
|
|
30
|
-
{ scale: 0.75, opacity: 0, y: 20 },
|
|
31
|
-
{
|
|
32
|
-
scale: 1,
|
|
33
|
-
opacity: 1,
|
|
34
|
-
y: 0,
|
|
35
|
-
duration: 0.15,
|
|
36
|
-
delay: 0.9,
|
|
37
|
-
ease: "elastic.out(0.5, 0.3)",
|
|
38
|
-
stagger: 0.1,
|
|
39
|
-
}
|
|
40
|
-
);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
onUnmounted(() => {
|
|
45
|
-
ctx.value && ctx.value.revert();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
useHead({
|
|
49
|
-
bodyAttrs: {
|
|
50
|
-
class: "animate-layout opacity-0",
|
|
51
|
-
},
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
useSeoMeta({
|
|
55
|
-
title: `${name} · ${subtitle}`,
|
|
56
|
-
description: bio.text,
|
|
57
|
-
ogTitle: `${name} · ${subtitle}`,
|
|
58
|
-
ogDescription: bio.text,
|
|
59
|
-
twitterCard: "summary_large_image",
|
|
60
|
-
});
|
|
61
|
-
</script>
|
|
62
|
-
|
|
63
|
-
<template>
|
|
64
|
-
<div class="space-y-5 sm:space-y-10">
|
|
65
|
-
<app-header />
|
|
66
|
-
|
|
67
|
-
<main class="min-h-full">
|
|
68
|
-
<slot />
|
|
69
|
-
</main>
|
|
70
|
-
|
|
71
|
-
<app-footer />
|
|
72
|
-
</div>
|
|
73
|
-
</template>
|
package/public/favicon.svg
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
-
<path d="M181 0H75C33.5786 0 0 33.5786 0 75V181C0 222.421 33.5786 256 75 256H181C222.421 256 256 222.421 256 181V75C256 33.5786 222.421 0 181 0Z" fill="#020617"/>
|
|
3
|
-
<rect x="62" y="63" width="58" height="131" rx="20" fill="#D9D9D9"/>
|
|
4
|
-
<rect x="135" y="63" width="58" height="58" rx="20" fill="#D9D9D9"/>
|
|
5
|
-
<rect x="135" y="136" width="58" height="58" rx="20" fill="#D9D9D9"/>
|
|
6
|
-
</svg>
|
package/public/logo.svg
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
-
<path d="M181 0H75C33.5786 0 0 33.5786 0 75V181C0 222.421 33.5786 256 75 256H181C222.421 256 256 222.421 256 181V75C256 33.5786 222.421 0 181 0Z" fill="#020617"/>
|
|
3
|
-
<rect x="62" y="63" width="58" height="131" rx="20" fill="#D9D9D9"/>
|
|
4
|
-
<rect x="135" y="63" width="58" height="58" rx="20" fill="#D9D9D9"/>
|
|
5
|
-
<rect x="135" y="136" width="58" height="58" rx="20" fill="#D9D9D9"/>
|
|
6
|
-
</svg>
|
package/public/profile.jpg
DELETED
|
Binary file
|
|
File without changes
|
|
File without changes
|
|
File without changes
|