FIX: mpa, nuxt4 future, improvements in intro and services

Added mpa support, nuxt4 future compatibility, improvements in intro and services
This commit is contained in:
webfussel 2025-02-12 13:18:55 +01:00
parent 078d4bfd82
commit 9642496e5a
35 changed files with 324 additions and 172 deletions

39
app/app.vue Normal file
View file

@ -0,0 +1,39 @@
<template>
<section>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</section>
</template>
<script setup>
useSeoMeta({
title: 'webfussel | mehr Fussel im Web by Fiona Urban',
description: 'Headless CMS, Components & APIs by Fiona Urban. Storyblok, FirstSpirit, Nuxt.',
author: 'webfussel',
robots: 'index, follow',
themeColor: '#2a2723',
ogTitle: 'webfussel | mehr Fussel im Web',
ogDescription: 'Headless CMS, Components & APIs by Fiona Urban. Storyblok, FirstSpirit, Nuxt.',
ogImage: '/img/og.webp',
ogImageAlt: 'Das webfussel Logo auf einem dunklen Hintergrund',
ogUrl: 'https://webfussel.de',
twitterTitle: 'webfussel | mehr Fussel im Web',
twitterDescription: 'Headless CMS, Components & APIs by Fiona Urban. Storyblok, FirstSpirit, Nuxt.',
twitterImage: '/img/og.webp',
twitterImageAlt: 'Das webfussel Logo auf einem dunklen Hintergrund',
twitterUrl: 'https://webfussel.de',
})
</script>
<style>
.page-enter-active,
.page-leave-active {
transition: all 0.4s;
}
.page-enter-from,
.page-leave-to {
opacity: 0;
filter: blur(.5rem);
}
</style>

75
app/assets/css/button.css Normal file
View file

@ -0,0 +1,75 @@
.Button {
all: unset;
transition: 250ms;
cursor: pointer;
padding: 1rem 1.5rem;
outline: 3px solid transparent;
box-shadow: 0 0 0 0 var(--color-orange);
border-radius: 99999px;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
&.default {
background: var(--color-orange);
color: var(--color-black);
}
&.white {
background: var(--color-white);
color: var(--color-black);
}
&:hover {
outline-color: var(--color-black);
box-shadow: 0 0 0 6px var(--color-orange);
}
&.cta {
font-size: clamp(1rem, 2vw, 1.5rem);
}
}
.DualButton {
--size: 2.2rem;
display: flex;
width: 100%;
& .divider {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-black);
color: var(--color-white);
border-radius: 9999px;
width: var(--size);
height: var(--size);
padding: var(--size);
font-size: 1.2rem;
z-index: 1;
margin-left: calc(var(--size) * -1 - 25px);
border: 2px solid var(--color-black);
}
.Button {
border: 2px solid currentColor;
}
& .Button:hover {
outline: none;
box-shadow: none;
background-color: var(--color-black);
color: var(--color-white);
border-color: var(--color-orange);
}
& .Button:first-child {
padding-right: calc(var(--size) * 2);
}
& .Button:last-child {
padding-left: calc(var(--size) * 2);
margin-left: calc(var(--size) * -1 - 25px);
}
}

View file

@ -0,0 +1,112 @@
.Customers {
& .customer-list {
display: flex;
flex-wrap: wrap;
& img {
height: 50px;
&.white {
filter: brightness(0) invert(1);
}
}
}
& .projects-list {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
& > article {
flex-grow: 1;
flex-shrink: 0;
flex-basis: clamp(350px, calc(33% - 3rem), 400px);
height: 350px;
display: grid;
overflow: hidden;
& .bg {
padding: 0;
height: 350px;
width: 100%;
& img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
& > * {
grid-column: 1;
grid-row: 1;
}
& > div {
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.88);
backdrop-filter: blur(1px);
transition: var(--transition-time);
height: 100%;
text-shadow: 0 0 5px rgba(0, 0, 0, .7);
text-align: center;
padding: 1rem;
& > main {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
max-height: 6rem;
overflow: hidden;
transition: var(--transition-time);
& .customer {
font-size: 1rem;
color: var(--color-white-transparent);
}
& .title {
margin-top: -1rem;
}
& ul {
gap: 1rem;
justify-content: center;
}
& a {
color: var(--color-orange);
&:hover {
color: var(--color-white);
}
}
}
&:hover {
backdrop-filter: blur(5px);
& > main {
max-height: 25rem;
}
}
}
}
}
}
@media (width <= 780px) {
.Customers {
& .customer-list {
align-items: center;
justify-content: center;
}
}
}

24
app/assets/css/fonts.css Normal file
View file

@ -0,0 +1,24 @@
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
font-display: swap;
src: url('/fonts/opensans.woff2') format('woff2');
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/roboto_con_reg.woff2') format('woff2');
}
@font-face {
font-family: 'Roboto Condensed';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/roboto_con_bold.woff2') format('woff2');
}

27
app/assets/css/footer.css Normal file
View file

@ -0,0 +1,27 @@
.Footer {
align-items: center;
padding: 1rem 15vw;
& .notes {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-evenly;
gap: 3rem .5rem;
}
& a:hover {
color: var(--color-orange);
}
& p {
color: var(--color-white-transparent);
white-space: nowrap;
}
& .sitemap {
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
}

252
app/assets/css/global.css Normal file
View file

@ -0,0 +1,252 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
::selection {
background: var(--color-orange);
color: var(--color-black);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-button {
display: none;
}
::-webkit-scrollbar-thumb {
border-radius: 20px;
background: #b2bec3;
transition: var(--transition-time);
}
::-webkit-scrollbar-track {
border-radius: 20px;
background: rgba(0, 0, 0, 0.3);
}
::-webkit-scrollbar-corner {
border-radius: 100%;
background: #b2bec3;
}
:root {
--spacing-standard: 3rem;
--transition-time: 250ms;
--radius-standard: 4px;
--color-white: #ecf0f1;
--color-white-transparent: rgba(236, 240, 241, 0.7);
--color-black: #2a2723;
--color-black-transparent: #2a2723aa;
--color-orange: #ff9100;
--color-orange-light: #ffc36f;
--color-orange-black: #332b22;
}
html,
body {
min-height: 100vh;
width: 100vw;
}
html {
scroll-behavior: smooth;
scrollbar-gutter: auto;
overflow-y: auto;
overflow-x: hidden;
&.layer {
overflow: hidden;
}
}
body {
font-family: 'Open Sans', sans-serif;
color: var(--color-white);
background: var(--color-black);
}
.h1, .h2, .h3, .h4, .h5, .h6,
h1, h2, h3, h4, h5, h6 {
text-align: left;
font-family: 'Roboto Condensed', sans-serif;
}
h1 {
font-size: 4rem;
}
.h2,
h2,
h3 {
font-size: 1.5rem;
font-weight: bold;
}
a {
text-decoration: none;
transition: var(--transition-time);
color: var(--color-white);
}
.nowrap {
white-space: nowrap;
}
a.mail {
display: inline-flex;
align-items: center;
gap: .5rem;
color: var(--color-orange);
font-weight: bold;
& img {
filter: invert(50%) sepia(84%) saturate(868%) hue-rotate(1deg) brightness(103%) contrast(100%);
}
&:hover {
color: var(--color-orange-light);
& img {
filter: invert(72%) sepia(59%) saturate(390%) hue-rotate(343deg) brightness(102%) contrast(103%);
}
}
}
ul {
list-style: none;
display: flex;
}
span.highlight {
color: var(--color-orange);
}
span.chip {
background: var(--color-orange);
border-radius: 999px;
font-size: 1rem;
color: var(--color-black);
height: max-content;
padding: .5em 1em;
user-select: none;
}
.card {
padding: 2rem;
background: var(--color-black);
border-radius: 20px;
min-width: 300px;
flex: 1;
}
.color-icon {
filter: invert(50%) sepia(84%) saturate(868%) hue-rotate(1deg) brightness(103%) contrast(100%);
}
.content {
position: relative;
z-index: 100;
padding: 150px 15vw;
}
.full {
min-height: 100vh;
}
.margin-top {
margin-top: 3rem;
}
.margin-top-big {
margin-top: 6rem;
}
.flex-col {
display: flex;
flex-direction: column;
}
.default-gap {
gap: 3rem;
}
.z-0 {
box-shadow: 0 0 0 rgba(0, 0, 0, 0), 0 0 0 rgba(0, 0, 0, 0);
}
.z-1 {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
}
.z-2 {
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
}
.z-3 {
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
}
.z-4 {
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
}
.z-5 {
box-shadow: 0 19px 38px rgba(0, 0, 0, 0.30), 0 15px 12px rgba(0, 0, 0, 0.22);
}
.tip-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.tip-container .tip {
scale: 0;
position: absolute;
top: -3rem;
width: max-content;
border: 1px solid var(--color-white);
border-radius: 999px;
background-color: var(--color-black);
padding: .5em 1.5rem;
transition: 150ms;
}
.tip-container:hover .tip {
scale: 1;
}
.animate-up-down {
animation: up-down 1.5s ease-in-out alternate-reverse infinite;
}
@keyframes up-down {
0% {
translate: 0 -0.1rem;
}
100% {
translate: 0 0.4em;
}
}
@media (width <= 780px) {
h1, h2, h3, h4, h5, h6, p {
text-align: center;
}
}
@media (width <= 450px) {
.content {
padding: 150px 10vw;
}
}

189
app/assets/css/header.css Normal file
View file

@ -0,0 +1,189 @@
.stickyWatch {
height: 0;
}
.Header {
padding: 15px calc(15vw - 30px);
width: 100%;
background: transparent;
top: 0;
position: fixed;
z-index: 1000;
& .logo {
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linecap: round;
stroke-linejoin: round;
stroke-miterlimit: 1.5;
& .fussel {
stroke: white;
fill: var(--color-black);
stroke-width: 20px;
}
& .glasses {
fill: none;
stroke: white;
stroke-width: 62px;
}
}
& strong {
font-family: 'Roboto Condensed', sans-serif;
font-size: 1.5rem;
flex: 1.5;
cursor: default;
display: flex;
align-items: center;
gap: 1rem;
& svg {
--size: 40px;
width: var(--size);
height: var(--size);
}
}
& nav {
position: relative;
font-weight: normal;
font-size: 1.2rem;
display: flex;
justify-content: space-between;
align-items: center;
& .active {
color: var(--color-orange);
}
}
& > .wrapper {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 22px;
transition: 750ms;
backdrop-filter: blur(10px);
border-radius: 0;
& > label {
display: none;
width: 30px;
height: 25px;
position: relative;
transform: rotate(0deg);
transition: var(--transition-time) ease-in-out;
cursor: pointer;
z-index: 20000;
& > span {
display: block;
position: absolute;
height: 5px;
width: 100%;
background: var(--color-white);
border-radius: 9px;
opacity: 1;
left: 0;
transform: rotate(0deg);
transition: .25s ease-in-out;
&:nth-child(1) {
top: 0;
}
&:nth-child(2), &:nth-child(3) {
top: 9px;
}
&:nth-child(4) {
top: 18px;
}
}
}
& > input[type="checkbox"]:checked + label span:nth-child(1) {
top: 18px;
width: 0;
left: 50%;
}
& > input[type="checkbox"]:checked + label span:nth-child(2) {
transform: rotate(45deg);
}
& > input[type="checkbox"]:checked + label span:nth-child(3) {
transform: rotate(-45deg);
}
& > input[type="checkbox"]:checked + label span:nth-child(4) {
top: 18px;
width: 0;
left: 50%;
}
}
&.sticks > .wrapper {
background: var(--color-black-transparent);
border-radius: 20px;
}
& input[type="checkbox"] {
display: none;
}
& ul {
gap: var(--spacing-standard);
transform: scale(1);
& a {
display: block;
text-align: center;
&:hover {
transform: scale(1.15);
color: var(--color-orange);
}
}
}
}
@media screen and (width < 1180px) {
.Header {
& > .wrapper.wrapper > label {
display: block;
}
& input[type="checkbox"]:checked ~ nav {
transform: translateX(-15vw);
}
& nav {
background: var(--color-black);
position: absolute;
overflow: hidden;
height: 100vh;
width: 100vw;
top: -15px;
transition: var(--transition-time);
transform: translateX(100%);
color: var(--color-white);
flex-direction: column;
& ul {
flex-direction: column;
justify-content: center;
height: 100vh;
gap: 8vh;
& li {
font-size: clamp(1rem, 10vw, 3rem);
}
}
}
}
}

82
app/assets/css/intro.css Normal file
View file

@ -0,0 +1,82 @@
.Intro {
background-image: radial-gradient(circle at -50vw -50vh, rgba(255,145,0,0.2) 0%, rgba(0,0,0,0) 63%, rgba(0,0,0,0) 100%);
background-repeat: no-repeat;
display: grid;
grid-template-columns: 1fr 1fr;
& .intro-img {
width: 750px;
display: flex;
position: absolute;
bottom: 0;
right: 0;
& img {
transition: 250ms;
position: relative;
width: 100%;
}
}
.intro-text {
height: 100%;
justify-content: center;
z-index: 1;
& h1 {
position: relative;
& .dot {
position: relative;
right: 1rem;
}
& small {
position: absolute;
font-size: 1.5rem;
rotate: -30deg;
translate: -30px -20px;
&:before {
content: '(';
}
&:after {
content: ')';
}
}
}
& .fulltext {
color: var(--color-white);
}
}
}
@media (width <= 430px) {
.Intro {
& .intro-img{
width: 430px;
}
}
}
@media (width < 900px) {
.Intro {
grid-template-columns: 1fr;
& .intro-text, & .intro-img {
grid-column-start: 1;
}
& .intro-text,
& h1 {
align-items: center;
text-align: center;
}
& .intro-img img {
filter: brightness(.5);
}
}
}

41
app/assets/css/person.css Normal file
View file

@ -0,0 +1,41 @@
.Person {
flex-basis: clamp(350px, calc(33% - 3rem), 500px);
flex-grow: 1;
flex-shrink: 0;
align-items: center;
justify-content: stretch;
gap: 1rem;
& img {
outline: 4px solid var(--color-orange);
border-radius: 50%;
width: 150px;
height: 150px;
object-fit: cover;
}
& span {
font-family: 'Roboto Condensed', sans-serif;
font-weight: bold;
&:not(:last-child):after {
content: " | "
}
}
& p {
text-align: center;
white-space: pre-wrap;
&:first-of-type {
margin-top: -1rem;
}
}
& .flavour {}
& .button {
margin-top: auto;
}
}

View file

@ -0,0 +1,72 @@
.Services {
background-image: radial-gradient(circle at 100vw 100vh, rgba(255,145,0,0.2) 0%, rgba(0,0,0,0) 63%, rgba(0,0,0,0) 100%);
background-color: var(--color-orange-black);
background-repeat: no-repeat;
.service-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-rows: repeat(4, auto);
& article {
grid-row: span 4;
display: grid;
grid-template-rows: subgrid;
position: relative;
& .chip {
position: absolute;
right: -1rem;
top: calc(-1rem - 3px);
}
& header {
display: flex;
}
& ul {
gap: 1rem;
& li {
display: flex;
align-items: center;
gap: 1rem;
}
}
& .extra {
margin-top: auto;
}
}
}
.network-list {
width: 100%;
overflow-x: auto;
padding-top: 4px;
& .scroll-container {
display: flex;
padding-bottom: 3rem;
}
}
}
@media (width < 1423px) {
.Services {
& .service-list article:last-child {
grid-column: 1/-1;
}
}
}
@media (width < 600px) {
.Services {
& .network-list {
--height: 380px;
& article {
flex-basis: 70vw;
}
}
}
}

20
app/assets/css/skills.css Normal file
View file

@ -0,0 +1,20 @@
.Skills {
background: var(--color-orange-black);
background-image: radial-gradient(circle at 90vw 0, rgba(255,145,0,0.2) 0%, rgba(0,0,0,0) 63%, rgba(0,0,0,0) 100%);
background-repeat: no-repeat;
& .skill-list {
display: flex;
flex-wrap: wrap;
}
& .tech-list ul {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
& .bottom {
align-items: center;
}
}

View file

@ -0,0 +1,16 @@
.Technology {
position: relative;
align-items: center;
&.s img {
height: 15px;
}
&.m img {
height: 30px;
}
&.l img {
height: 50px;
}
}

29
app/components/Button.vue Normal file
View file

@ -0,0 +1,29 @@
<template>
<component :is="type" v-bind="actualProps()" class="Button" :class="[design]">
<slot />
</component>
</template>
<script setup lang="ts">
type Props = {
type ?: 'a' | 'button'
href ?: string
design ?: string
}
const {
type = 'a',
href = '#',
design = 'default',
} = defineProps<Props>()
const actualProps = () => {
if (type === 'a') {
return {
href: href,
target: href!.startsWith('https://') ? '_blank' : undefined,
}
}
}
</script>

View file

@ -0,0 +1,204 @@
<template>
<section id="customers" class="Customers content">
<h2>Kunden <span class="highlight">&</span> Projekte.</h2>
<h3>Meine bisherigen Geschäftpartner</h3>
<div class="customer-list margin-top default-gap">
<a v-for="customer in customers" :href="customer.link" target="_blank" rel="noopener noreferrer">
<img
loading="lazy"
height="50"
:width="customer.logo.width"
:alt="customer.name"
:src="`/img/customers/${customer.logo.src}.svg`"
/>
</a>
</div>
<h3 class="margin-top-big">Projektauswahl</h3>
<div class="projects-list margin-top">
<article v-for="pr in projects">
<div class="bg">
<img height="350" width="400" loading="lazy" :alt="pr.title" :src="pr.image" aria-hidden="true"/>
</div>
<div>
<main>
<small class="customer">{{ pr.customer }}</small>
<h3 class="title">{{ pr.title }}</h3>
<ul>
<li v-for="skill in pr.technologies">
<Technology v-bind="skill" link="" />
</li>
</ul>
<p v-for="d in pr.desc">{{ d }}</p>
<a v-if="pr.link" :href="pr.link" target="_blank">Zur {{pr.type ?? 'Seite'}}</a>
</main>
</div>
</article>
</div>
</section>
</template>
<script setup lang="ts">
import { android, css, dart, flutter, html, js, njs, nuxt, pcss, scss, ts, tw, vue } from '../utils/skills'
const customers = [
{
name: 'Bounce Commerce',
link: 'https://bounce-commerce.de',
logo: {
src: 'bounce',
width: 150,
},
},
{
name: 'GMX',
link: 'https://gmx.net',
logo: {
src: 'gmx',
width: 148,
white: true,
},
},
{
name: 'WEB.DE',
link: 'https://web.de',
logo: {
src: 'webde',
width: 50,
},
},
{
name: '1&1',
link: 'https://1und1.de',
logo: {
src: '1u1',
width: 50,
},
},
{
name: 'Körrie',
link: 'https://körrie.de',
logo: {
src: 'koerrie',
width: 50,
}
},
{
name: 'Pembe',
link: 'https://pembe.io',
logo: {
src: 'pembe',
width: 48,
white: true,
},
},
{
name: 'SAE Institute Germany',
link: 'https://www.sae.edu/deu/en/sae-home/',
logo: {
src: 'sae',
width: 77,
white: true,
},
}
]
const projects = [
{
title: 'Kauft Körrie! App',
customer: 'KVK Berlin',
image: '/img/projects/koerrie_app.webp',
desc: [
'Entwicklung einer Android Info-App für die Gewürzmischungen "Körrie" und passendem Zubehör.',
'Zusätzlich die Übertragung des Körrie-O-Mat von der Landingpage in die App mit Ergebnisverlauf.',
],
technologies: [flutter, dart, android],
link: 'https://play.google.com/store/apps/details?id=com.koerrieomat&hl=de',
type: 'App',
},
{
title: 'Unterricht',
customer: 'SAE Institute Germany',
image: '/img/projects/education.webp',
desc: [
'Vorbereitung und Durchführung von Unterricht in JavaScript und TypeScript.',
],
technologies: [js, ts]
},
{
title: 'Headless CMS & Cache',
customer: 'DEKRA',
image: '/img/projects/dekra.webp',
desc: [
'Anbindung an ein Headless CMS und Entwicklung der dazugehörigen Komponentenbibliothek unter Einsatz von Tailwind, sowie serverseitiges Caching.',
],
technologies: [ts, nuxt, tw, njs]
},
{
title: 'Bounce Script',
customer: 'Bounce Commerce',
link: 'https://bounce-commerce.de',
image: '/img/projects/bounce.webp',
desc: [
'Script zum Einbinden in Web Shops für Bounce Management.',
'Pures JavaScript, so klein gehalten wie möglich zur einfach Integration.',
],
technologies: [js]
},
{
title: 'WEB.DE / GMX',
customer: '1&1 Mail & Media',
link: 'https://web.de',
image: '/img/projects/webde.webp',
desc: [
'Neubau der Seiten web.de und GMX mit einem komponentenbasierten Ansatz unter Verwendung von VueJS.',
'Optimiert für moderne Browser, während Internet Explorer in einer Extraversion angefertigt wurde.',
],
technologies: [js, vue, scss]
},
{
title: 'Körrie! Landingpage',
customer: 'KVK Berlin',
link: 'https://körrie.de',
image: '/img/projects/krrie.webp',
desc: [
'Neubau der Landingpage für "Kauft Körrie!". Die Prämisse war: Kein Schnickschnack.',
'Deshalb aufgebaut mit simplem Js, HTML und CSS',
],
technologies: [html, css, js],
},
{
title: 'UI Tools',
customer: 'webfussel',
link: 'https://uitools.webfussel.de',
image: '/img/projects/uitools.webp',
desc: [
'Eine kleine Sammlung an Tools für die Erstellung von UIs.',
'Farbpalette, Kontraste und CSS Variablen.',
'Ist in aktiver Entwicklung.',
],
technologies: [nuxt, ts, pcss]
},
// {
// customer: 'webfussel',
// title: 'Shnaik - Teh Gaem',
// link: 'https://shnaik.webfussel.de',
// image: '/img/projects/shnaik.webp',
// desc: [
// 'Nachbau des bekannten Spiels "Snake" für die damaligen Nokia Handys.',
// 'Meine erste Erfahrung mit Gaming Libraries und wurde eher als Experiment und Zeitvertreib angefertigt.',
// ],
// technologies: [ts, css]
// },
// {
// title: 'PixelPalette',
// customer: 'webfussel',
// link: 'https://pixelpalette.webfussel.de',
// image: '/img/projects/pp.webp',
// desc: [
// 'Ich hatte einige Tage eine Idee, wie man Grafiken mit 4 Farben - angelehnt den Gameboy - komprimieren und im Speicher unterbringen kann.',
// 'Prototypisch zum Spaß erstellt.',
// ],
// technologies: [js, html, css]
// },
]
</script>

84
app/components/Footer.vue Normal file
View file

@ -0,0 +1,84 @@
<template>
<footer class="Footer flex-col default-gap">
<ul class="sitemap default-gap">
<li v-for="{ label, ...rest} in nav" :key="label">
<NuxtLink v-bind="rest">{{label}}</NuxtLink>
</li>
</ul>
<ul class="sitemap default-gap">
<li v-for="({icon, ...rest}) in socials" :key="rest.href">
<a v-bind="rest" target="_blank">
<Icon :name="icon" :alt="rest['aria-label']" size="1.5em" mode="svg" />
</a>
</li>
</ul>
<ul class="sitemap default-gap">
<li class="tip-container">
<Icon name="material-symbols:cookie-off-outline" size="1.5rem" mode="svg" />
<span class="tip">Ohne Cookies</span>
</li>
<li class="tip-container">
<Icon name="material-symbols:fingerprint-off" size="1.5rem" mode="svg" />
<span class="tip">Ohne Tracker</span>
</li>
</ul>
<p>&copy; 2024 by <a href="https://webfussel.de">webfussel</a></p>
</footer>
</template>
<script lang="ts" setup>
const nav = [
{
to: `/#intro`,
label: 'Über mich',
'aria-label': 'Link dieser Seite: Über mich'
}, {
to: `/#skills`,
label: 'Meine Expertise',
'aria-label': 'Link dieser Seite: Meine Expertise'
}, {
to: `/#customers`,
label: 'Kunden',
'aria-label': 'Link dieser Seite: Kunden'
}, {
to: `/#services`,
label: 'Services',
'aria-label': 'Link dieser Seite: Services'
}, {
to: `/#network`,
label: 'Mein Netzwerk',
'aria-label': 'Link dieser Seite: Mein Netzwerk'
}, {
to: '/imp',
label: 'Impressum',
'aria-label': 'Link dieser Seite: Impressum'
}
]
const socials = [
{
href: 'https://www.linkedin.com/in/webfussel/',
icon: 'ri:linkedin-box-line',
'aria-label': 'Externer Link: LinkedIn Profil'
},
{
href: 'https://mastodontech.de/@webfussel',
icon: 'ri:mastodon-line',
rel: 'me',
'aria-label': 'Externer Link: Mastodon Profil'
},
{
href: 'https://bsky.app/profile/webfussel.de',
icon: 'ri:bluesky-line',
'aria-label': 'Externer Link: Bluesky Profil'
},
{
href: 'https://ko-fi.com/webfussel',
icon: 'wf:kofi',
'aria-label': 'Externer Link: KoFi Profil'
},
]
</script>

90
app/components/Header.vue Normal file
View file

@ -0,0 +1,90 @@
<template>
<div ref="stickyWatch" />
<header ref="header" class="Header">
<div ref="headerWrapper" class="wrapper z-0">
<strong>
<svg aria-label="Logo" class="logo" height="40" viewBox="0 0 2500 2500" width="40">
<g id="Logo">
<g transform="matrix(2.00744,0,-5.91646e-31,2.00744,-1223.85,-1050.52)">
<path class="fussel"
d="M1232.34,1444.88L1356.88,1532.06C1356.88,1532.06 1405.5,1504.81 1444.06,1395.07C1464.03,1338.21 1476.18,1339.49 1506.32,1320.35C1579.8,1273.69 1638.29,1212.62 1630.86,1195.81C1560.6,1178.12 1512.77,1137.84 1506.32,1102.15L1618.41,946.736C1618.41,946.736 1514.23,877.412 1406.69,896.922C1407.7,845.817 1413.57,804.009 1481.42,759.931C1417.36,736.758 1260.23,740.351 1182.53,834.653C1115.13,783.067 1068.98,763.931 1008.18,759.931L1045.54,872.014C999.993,865.527 914.886,866.941 858.733,902.888C912.917,941.197 943.173,985.627 958.362,1033.91C883.905,1079.32 844.648,1134.09 808.918,1195.81C875.598,1205.68 938.224,1226.42 970.816,1282.99C1016.82,1362.83 1028.77,1456.11 1107.81,1532.06L1232.34,1444.88"/>
</g>
<g transform="matrix(1,0,0,1,-422.589,697.589)">
<path class="glasses" d="M1747.59,277.411C1695.36,294.131 1645.34,294.246 1597.59,277.411"/>
</g>
<path class="glasses"
d="M1175,975C1189.02,1037.51 1161.76,1216.53 1125,1300C1027.14,1307.22 909.088,1298.04 825,1275C798.072,1183.9 789.715,1050.66 825,950C935.158,934.697 1076.23,935.423 1175,975Z"/>
<g transform="matrix(-1,0,0,1,2500,2.20268e-13)">
<path class="glasses"
d="M1175,975C1189.02,1037.51 1161.76,1216.53 1125,1300C1027.14,1307.22 909.088,1298.04 825,1275C798.072,1183.9 789.715,1050.66 825,950C935.158,934.697 1076.23,935.423 1175,975Z"/>
</g>
</g>
</svg>
webfussel
</strong>
<input id="navToggle" v-model="isBurgerOpen" type="checkbox">
<label :aria-label="burgerLabel" for="navToggle">
<span/><span/><span/><span/>
</label>
<nav>
<ul class="main-nav">
<li v-for="({label, to, aria}) in nav" :key="label" @click="isBurgerOpen = false">
<NuxtLink :to="to" :aria-label="aria" active-class="active">{{ label }}</NuxtLink>
</li>
</ul>
</nav>
</div>
</header>
</template>
<script lang="ts" setup>
let observer: IntersectionObserver
const header = ref<HTMLElement | null>(null)
const headerWrapper = ref<HTMLElement | null>(null)
const stickyWatch = ref<HTMLElement | null>(null)
const isBurgerOpen = ref<boolean>(false)
const burgerOpenLabel = 'Burgermenü öffnen'
const burgerCloseLabel = 'Burgermenü schließen'
const burgerLabel = computed(() => isBurgerOpen.value ? burgerCloseLabel : burgerOpenLabel)
const nav = [
{
to: `/`,
label: 'home',
'aria-label': 'Link dieser Seite: Startseite'
},
{
to: `/services`,
label: 'leistungen',
aria: 'Link dieser Seite: Leistungen'
},
{
to: `/references`,
label: 'referenzen',
aria: 'Link dieser Seite: Referenzen'
},
{
to: `/contact`,
label: 'kontakt',
aria: 'Link dieser Seite: Kontakt'
},
]
onMounted(() => {
observer = new IntersectionObserver(([entry]) => {
if (!entry) return
const { isIntersecting } = entry
header.value?.classList.toggle('sticks', !isIntersecting)
headerWrapper.value?.classList.toggle('z-4', !isIntersecting)
headerWrapper.value?.classList.toggle('z-0', isIntersecting)
}, {
rootMargin: '3% 0px 0px 0px'
})
observer.observe(stickyWatch.value!)
})
onUnmounted(() => {
observer.disconnect()
})
</script>

37
app/components/Intro.vue Normal file
View file

@ -0,0 +1,37 @@
<template>
<section id="intro" class="Intro content full default-gap">
<div class="intro-text flex-col default-gap">
<h1 class="flex-col">
<span class="greeting">Moin.</span>
<span class="my-name-wrapper">Ich bin <span class="nowrap"><span class="highlight">Fiona </span><small>Urban</small><span class="dot">.</span></span></span>
</h1>
<h2>
Component <span class="highlight">&</span> API Entwicklerin
</h2>
<p class="fulltext">
Ich unterstütze Unternehmen dabei, ihre Daten von verschiedenen Endpunkten sauber aufzubereiten
und anschließend in einer Webapplication schön zu verpacken.
</p>
<p class="fulltext">
Mit über 20 Jahren Erfahrung in der Webentwicklung habe ich
inzwischen so ziemlich jeden Stuff miterlebt.
</p>
<p class="fulltext">
Egal, ob Komponenten, Schnittstellen oder Anbindung an Headless CMS.
Ich biete dir genau das, was du brauchst, um eine individuelle WebApp in Fahrt zu bringen, deren Inhalte einfach zu verändern sind.
</p>
<Button class="cta" href="#skills">
<Icon name="ph:lightbulb-duotone" size="1.5em" mode="svg" />
Fussel erklärt's dir
</Button>
</div>
<div class="intro-img">
<picture>
<source width="750" height="866" media="(min-width: 431px)" srcset="/img/profile_big.webp" />
<img width="430" height="866" src="/img/profile_small.webp" alt="Bild von Fiona Urban" />
</picture>
</div>
</section>
</template>
<script setup lang="ts">
</script>

34
app/components/Person.vue Normal file
View file

@ -0,0 +1,34 @@
<template>
<article class="Person flex-col">
<img
loading="lazy"
width="150"
height="150"
:srcset="[userImage('1x', true), userImage('2x', true), userImage('3x', true)].join(', ')"
:src="userImage('1x', false)"
:alt="`Bild von ${name}`"
/>
<h3>{{name}}</h3>
<p>
<span v-for="tag in tags">{{tag}}</span>
</p>
<p class="flavour">{{flavour}}</p>
<Button :href="link" class="button" target="_blank" rel="noreferrer noopener" :aria-label="`Externer Link zur Homepage von ${name}`">
Zur Homepage
</Button>
</article>
</template>
<script setup lang="ts">
type Props = {
img: string
name: string
tags: string[]
flavour: string
link: string
}
const { img } = defineProps<Props>()
const userImage = getImage('/img/network/', img)
</script>

157
app/components/Services.vue Normal file
View file

@ -0,0 +1,157 @@
<template>
<section id="services" class="Services content">
<h2>Services.</h2>
<h3>Du hast also beschlossen, dass du <span class="highlight">meine Hilfe</span> brauchst. Cool!</h3>
<p class="margin-top">Hinter meinen Angeboten gibt es <span class="highlight">keinerlei Abos oder versteckte Kosten</span>.
Aus Transparenzgründen sei aber gesagt, dass sich alle Preise zzgl. 19 % Umsatzsteuer verstehen.</p>
<div class="service-list margin-top default-gap">
<article v-for="service in services" class="z-2 card flex-col default-gap">
<h3 class="flex-col default-gap">
<span>{{service.title}}</span>
<span class="highlight">{{service.price}}</span>
</h3>
<span class="chip">{{service.availability}}</span>
<p>{{service.smallClaim}}</p>
<Button :href="service.link" class="cta" aria-label="Zur externen Seite von zur Terminbuchung">
{{ service.button }}
<Icon :name="`ph:${service.icon}-duotone`" size="1.5em" mode="svg"></Icon>
</Button>
<ul class="flex-col">
<li v-for="point in service.list">
<Icon class="color-icon" name="ph:caret-circle-double-right-duotone" aria-hidden="true" alt="checkmark icon" size="1.5em" mode="svg" />
<span>{{point}}</span>
</li>
</ul>
</article>
</div>
<article class="z-2 card flex-col default-gap margin-top">
<h3>Keinen Bock auf Telen? Understandable.</h3>
<p>Dann schreib mir einfach gerne direkt eine E-Mail an
<ClientOnly><a class="mail" href="mailto:anfragen@webfussel.de">anfragen@webfussel.de<Icon name="ri:mail-line" aria-hidden="true" alt="mail icon" mode="svg" /></a></ClientOnly>
</p>
<h3>Keine Kohle? Kommt vor.</h3>
<p>Meld dich trotzdem. Eventuell ist dein Projekt ja cool genug, dass ich dir da auch entsprechend entgegenkommen kann. :)</p>
</article>
<h3 id="network" class="margin-top-big">Mein Netzwerk</h3>
<p class="margin-top">Doch auch wenn ich mal voll ausgelastet bin - keine Sorge!
Mein <span class="highlight">Netzwerk an Profis</span> kann dir sicher auch weiterhelfen.
</p>
<ClientOnly>
<div class="network-list margin-top">
<div class="scroll-container default-gap">
<Person ref="persons" v-for="person in network" v-bind="person" />
</div>
</div>
</ClientOnly>
</section>
</template>
<script setup lang="ts">
import Person from '~/components/Person.vue'
const slots : number = 0
const slotsLabel = `${slots} ${slots === 1 ? `Slot` : `Slots`} frei`
const freeFromDate = new Date(2025, 6, 1)
const intl = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric'})
const readableDate = intl.format(freeFromDate)
const services = [
{
title: 'Quick Check',
price: '149 € / Einmalig',
availability: 'Frei',
smallClaim: 'Du hast eine Homepage und willst mal drüber schauen lassen?',
icon: 'magnifying-glass',
button: 'Jetzt untersuchen',
link: 'https://tidycal.com/webfussel/quick-check',
list: [
'Untersuchung des Quellcodes',
'Untersuchung der Performance',
'Tipps zu CSS und Best Practices',
'Behebung unkompliziert nachbuchen',
'Für selbst gebaute Seiten',
],
}, {
title: 'Projektbuchung',
price: 'ab 999 € je nach Umfang',
availability: slotsLabel,
smallClaim: 'Umsetzung deiner Vision. Von einzelnen Tickets bis hin zu kompletten Anwendungen.',
icon: 'trend-up',
button: 'Jetzt durchstarten',
link: 'https://tidycal.com/webfussel/project-booking',
list: [
'Anforderungsanalyse',
'Kontinuierliche Projekt-Updates',
'Fixe Kosten und Feature-Sets',
'Nur 50 % Projektpreis als Anzahlung',
],
}, {
title: 'Stundenbuchung',
availability: `Frei ab ${readableDate}`,
price: '105 € / Stunde',
smallClaim: 'Du brauchst einfach Unterstützung im Team, bis sich der Trubel legt?',
icon: 'timer',
button: 'Jetzt buchen',
link: 'https://tidycal.com/webfussel/hourly-booking',
list: [
'Flexible Aufgabenverteilung',
'Arbeiten nach agilen Prinzipien',
'Monatliche Abrechnung',
'Kündigungsfrist von einer Woche',
'Flexible Buchung ab 80 Stunden',
],
}
]
const shuffle = <T>(unshuffled : T[]) => unshuffled
.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
const network = shuffle([
{
name: 'Robert Janus',
img: 'robert',
tags: ['Digitalberatung', 'Webentwicklung', 'eCommerce'],
flavour: 'Website, SEO und Conversions. Auf einen Klick.',
link: 'https://robertjanus.de/webertoire',
},
// {
// name: 'Matthias Lehmann',
// img: 'matthias',
// tags: ['Onlineportale für Patienten', 'Kunden', 'Mitarbeiter'],
// flavour: 'Software die macht, was DU willst!',
// link: 'https://mind-deploy.de',
// },
{
name: 'Maximilian Schluer',
img: 'max',
tags: ['iOS Development', 'Software-QA'],
flavour: 'Kann dein iOS-Team unterstützen oder dein Software-Qualitätsproblem lösen egal welches.',
link: 'https://max-schluer.de',
},
// {
// name: 'Maria Salcedo',
// img: 'maria',
// tags: ['Backend', 'DevOps', 'Architektur'],
// flavour: 'Effizient und kommunikativ. "You build it, you run it."',
// link: 'https://masagu.dev',
// },
{
name: 'Judith Böhlert',
img: 'judith',
tags: ['Full-stack', 'Frontend'],
flavour: 'MVPs und Prototypen - schnell, schick und ohne Drama.',
link: 'https://judithboehlert.com',
},
{
name: 'Kevin Damiani',
img: 'kevin',
tags: ['Webentwicklung', 'Frontend'],
flavour: 'Erfahrener Frontend-Entwickler mit Fokus auf Performance, Barrierefreiheit und moderne Technologien.',
link: 'https://kevin-damiani.de',
},
])
</script>

64
app/components/Skills.vue Normal file
View file

@ -0,0 +1,64 @@
<template>
<section id="skills" class="Skills content">
<h2>Meine Expertise.</h2>
<h3>Dies sind meine <span class="highlight">Spezialgebiete</span> - aber ich bin flexibel!</h3>
<div class="skill-list margin-top default-gap">
<article class="z-2 card flex-col default-gap" v-for="skill in skills">
<h3>{{skill.title}}</h3>
<main>
<p v-for="(t, i) in skill.text" :class="[i === skills.length - 1 && 'margin-top bold']">{{t}}</p>
</main>
</article>
</div>
<article class="tech-list z-2 card flex-col default-gap margin-top">
<h3>Technologien</h3>
<p>Neben den klassischen Webentwicklungsstandards JavaScript, HTML und CSS biete ich außerdem folgende Technologien.</p>
<ul class="default-gap">
<li v-for="tech in technologies">
<Technology v-bind="tech" size="l"/>
</li>
</ul>
</article>
<div class="bottom flex-col margin-top default-gap">
<h3>Manche von euch haben hier sicher kein Wort verstanden.</h3>
<Button href="#services" class="cta">
<span class="animate-up-down">
<Icon name="ph:caret-double-down-duotone" size="1.5em" mode="svg" />
</span>
Für normale Menschen
</Button>
</div>
</section>
</template>
<script setup lang="ts">
import { gl, njs, nuxt, pcss, react, rust, ts, vitest, vue, webstorm } from '../utils/skills'
const technologies = [nuxt, ts, pcss, vue, react, njs, vitest, gl, webstorm, rust]
const skills = [
{
title: 'Komponenten',
text: [
'Komponenten sind die Teile in deiner Applikation, die alles ansehnlich machen.',
'Mit sauber implementierten, responsiven Bausteinen kannst du deine Seite gut Strukturieren, Daten sauber darstellen und den User einspannen.',
'Vom kleinen Button bis hin zur Umfangreichen Tabelle bau ich dir (fast) alles.'
],
}, {
title: 'APIs',
text: [
'Du hast Daten in einer Datenbank liegen, aber keine Ahnung, wie du da gescheit rankommen sollst?',
'Liegen deine Daten eventuell sogar verstreut an mehreren Orten, über Datenbanken, Dateien und anderen Storagemöglichkeiten verteilt?',
'Kein Ding. Ich bau dir eine Schnittstelle, die alles easy zusammenträgt.'
],
}, {
title: 'Headless CMS',
text: [
'Wenn man ein Headless CMS anbinden will, dann verknüpft das Komponenten und APIs.',
'Für eine saubere und dynamische Einbindung reicht die Library, die euch vom Hersteller zur Verfügung gestellt wird, oft nicht aus.',
'Übersichtliche Projektstruktur und saubere Auflösung der Daten - mit Fusselgarantie.'
],
}
]
</script>

View file

@ -0,0 +1,35 @@
<template>
<div class="Technology flex-col tip-container" :class="[size]">
<a v-if="link" :href="link" target="_blank" rel="noopener noreferrer">
<img loading="lazy" :src="img" :alt="altText()" :height="getPixelForSize()" :width="getWidth()" />
</a>
<img v-else loading="lazy" :height="getPixelForSize()" :width="getWidth()" :src="img" :alt="altText()"/>
<span class="tip">{{name}}</span>
</div>
</template>
<script setup lang="ts">
type Props = {
img: string
name: string
link?: string
width?: number
size?: 's' | 'm' | 'l'
}
const {
name,
size = 'm',
width = 50,
} = defineProps<Props>()
const sizes = {
s: 15,
m: 30,
l: 50,
}
const altText = () => `Icon für ${name}`
const getPixelForSize = () => sizes[size]
const getWidth = () => width / 50 * getPixelForSize()
</script>

5
app/layouts/default.vue Normal file
View file

@ -0,0 +1,5 @@
<template>
<Header />
<slot />
<Footer />
</template>

8
app/pages/contact.vue Normal file
View file

@ -0,0 +1,8 @@
<template>
<div>
<Intro />
<Skills />
<Customers />
<Services />
</div>
</template>

78
app/pages/imp.vue Normal file
View file

@ -0,0 +1,78 @@
<template>
<div>
<section class="Imp flex-col default-gap content full">
<div>
<p>
Fiona Lena Urban<br/>
Fiona Urban aka webfussel<br/>
Teich&auml;ckerweg 39<br/>
76297 Stutensee
</p>
</div>
<div>
<h3>Kontakt</h3>
<ClientOnly>
<p>
Telefon: 017631640961<br/>
E-Mail: fiona@webfussel.de
</p>
</ClientOnly>
</div>
<div>
<h3>Umsatzsteuer-ID</h3>
<p>
Umsatzsteuer-Identifikationsnummer gem&auml;&szlig; &sect; 27 a Umsatzsteuergesetz:<br/>
DE348500161
</p>
</div>
<div>
<h3>Angaben zur Berufs&shy;haftpflicht&shy;versicherung</h3>
<p>
<strong>Name und Sitz des Versicherers:</strong><br/>
Hiscox SA<br/>
Arnulfstr. 31<br/>
80636 M&uuml;nchen
</p>
<p>
<strong>Geltungsraum der Versicherung:</strong><br/>
Bundesrepublik Deutschland
</p>
</div>
<div>
<h3>Redaktionell verantwortlich</h3>
<p>Fiona Lena Urban</p>
</div>
<div>
<h3>Verbraucher&shy;streit&shy;beilegung / Universal&shy;schlichtungs&shy;stelle</h3>
<p>
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer
Verbraucherschlichtungsstelle teilzunehmen.
</p>
</div>
<div class="flex-col default-gap">
<h3>Quellenangaben</h3>
<div>
<h4>Bilder</h4>
<p>
Copyright © 2024 JetBrains s.r.o. WebStorm and the WebStorm logo are registered trademarks of JetBrains s.r.o.
</p>
</div>
<div>
<h4>Impressumstext</h4>
<p>Quelle: <a href="https://www.e-recht24.de">e-recht24.de</a></p>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
useSeoMeta({
robots: 'noindex, nofollow',
})
</script>

6
app/pages/index.vue Normal file
View file

@ -0,0 +1,6 @@
<template>
<div>
<Intro />
<Skills />
</div>
</template>

5
app/pages/references.vue Normal file
View file

@ -0,0 +1,5 @@
<template>
<div>
<Customers />
</div>
</template>

5
app/pages/services.vue Normal file
View file

@ -0,0 +1,5 @@
<template>
<div>
<Services />
</div>
</template>

3
app/utils/image.ts Normal file
View file

@ -0,0 +1,3 @@
export const getImage =
(path: string, img: string) => (size: "1x" | "2x" | "3x", set: boolean) =>
`${path}${img}@${size}.webp${set ? ` ${size}` : ""}`;

44
app/utils/skills.ts Normal file
View file

@ -0,0 +1,44 @@
const jsImg = '/img/skills/javascript.svg'
const tsImg = '/img/skills/typescript.svg'
const vueImg = '/img/skills/vue.svg'
const reactImg = '/img/skills/react.svg'
const postCssImg = '/img/skills/postcss.svg'
const scssImg = '/img/skills/sass.svg'
const cssImg = '/img/skills/css.svg'
const htmlImg = '/img/skills/html.svg'
const glImg = '/img/skills/gitlab.svg'
const vitestImg = '/img/skills/vitest.svg'
const njsImg = '/img/skills/nodejs.svg'
const webstormImg = '/img/skills/webstorm.svg'
const nuxtImg = '/img/skills/nuxt.svg'
const twImg = '/img/skills/tw.svg'
const rustImg = '/img/skills/rust.svg'
const flutterImg = '/img/skills/flutter.svg'
const dartImg = '/img/skills/dart.svg'
const androidImg = '/img/skills/android.svg'
export type ISkill = {
name: string
img: string
link ?: string
width ?: number
}
export const android: ISkill = {name: 'Android', img: androidImg, link: 'https://www.android.com', width: 88}
export const css: ISkill = {name: 'CSS', img: cssImg }
export const dart: ISkill = {name: 'Dart', img: dartImg, link: 'https://dart.dev'}
export const flutter: ISkill = {name: 'Flutter', img: flutterImg, link: 'https://flutter.dev', width: 40}
export const gl: ISkill = {name: 'GitLab', img: glImg, link: 'https://gitlab.com', width: 55}
export const html: ISkill = {name: 'HTML', img: htmlImg, width: 44}
export const js: ISkill = {name: 'JavaScript', img: jsImg}
export const njs: ISkill = {name: 'Nodejs', img: njsImg, link: 'https://nodejs.org/en', width: 46}
export const nuxt: ISkill = {name: 'Nuxt', img: nuxtImg, link: 'https://nuxt.com', width: 75}
export const pcss: ISkill = {name: 'PostCSS', img: postCssImg, link: 'https://postcss.org'}
export const react: ISkill = {name: 'React', img: reactImg, link: 'https://reactjs.org', width: 56}
export const rust: ISkill = {name: 'Rust', img: rustImg, link: 'https://www.rust-lang.org'}
export const scss: ISkill = {name: 'SCSS', img: scssImg, width: 67}
export const tw: ISkill = {name: 'Tailwind', img: twImg, width: 84}
export const ts: ISkill = {name: 'TypeScript', img: tsImg, link: 'https://www.typescriptlang.org'}
export const vitest: ISkill = {name: 'Vitest', img: vitestImg, link: 'https://vitest.dev', width: 55}
export const vue: ISkill = {name: 'Vue', img: vueImg, link: 'https://vuejs.org', width: 58}
export const webstorm: ISkill = {name: 'JetBrains IDEs', img: webstormImg, link: 'https://www.jetbrains.com/webstorm'}