Compare commits

..

10 commits

Author SHA1 Message Date
2c42f24d0f add: blog
blog post
2025-07-15 08:19:19 +02:00
9d691974ce add: Orell
Add images for Orell
2025-07-11 09:13:35 +02:00
cffb719a05 fix: caching of FAQ
Fix wrong caching of FAQs for correct generation
2025-07-10 10:05:08 +02:00
fa0435efdf add: more SEO meta
More seometa, better markdown parser
2025-06-11 18:49:19 +02:00
ca3868299c add: extract plain faq
Add Schema Org for FAQ in Flatrate
2025-06-11 14:14:49 +02:00
a86b89dc98 add: extract plain faq
Add Schema Org for FAQ
2025-06-11 08:54:32 +02:00
bb77bb6a5b fix: Typo fixes
Fixed typos and texts
2025-06-11 07:00:25 +02:00
48efe0f75b fix: FAQ in NuxtContent
FAQ converted to NuxtContent
2025-06-10 20:03:42 +02:00
8668b96eff fix: Typos
Fixes several typos
2025-06-10 07:52:31 +02:00
4e953392fc REMOVE: RichText
Removed all traces of RichText implementation in Favor of NuxtContent
2025-06-10 07:48:21 +02:00
45 changed files with 446 additions and 605 deletions

View file

@ -8,8 +8,6 @@
<script setup> <script setup>
useSeoMeta({ useSeoMeta({
title: 'Home',
description: 'Headless CMS, Components & APIs by Fiona Urban. Storyblok, FirstSpirit, Nuxt.',
author: 'webfussel', author: 'webfussel',
robots: 'index, follow', robots: 'index, follow',
themeColor: '#2a2723', themeColor: '#2a2723',

View file

@ -123,6 +123,10 @@ a.mail {
} }
ul { ul {
list-style-position: inside;
}
ul.row {
list-style: none; list-style: none;
display: flex; display: flex;
} }

View file

@ -1,6 +1,5 @@
.Spoiler { .Spoiler {
background: var(--color-black); background: var(--color-black);
padding: 1rem 2rem;
border-radius: 20px; border-radius: 20px;
& .icon { & .icon {
@ -17,13 +16,20 @@
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
font-weight: bold; font-weight: bold;
padding: 1rem 2rem;
border-radius: 20px;
transition: 150ms ease-in-out;
&:hover {
background: rgba(0, 0, 0, .3);
}
} }
& > div { & > div {
margin-top: 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
padding: 1rem 2rem;
} }
} }

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="Burger" :class="{ open }"> <div class="Burger" :class="{ open }">
<nav class="z-4" ref="navElement"> <nav class="z-4" ref="navElement">
<ul> <ul class="row">
<li v-for="({label, to, aria, icon}) in navigation" :key="label"> <li v-for="({label, to, aria, icon}) in navigation" :key="label">
<NuxtLink :to="to" :aria-label="aria" active-class="active" class="inline-flex-row big-gap" @click="close"> <NuxtLink :to="to" :aria-label="aria" active-class="active" class="inline-flex-row big-gap" @click="close">
<Icon :name="icon" mode="svg" /> <Icon :name="icon" mode="svg" />

View file

@ -1,6 +1,6 @@
<template> <template>
<article class="z-2 card flex-col margin-top font-big text-center"> <article class="z-2 card flex-col margin-top font-big text-center">
<p>Derzeit habe ich <span class="highlight">keine freien Plätze</span>.</p> <p>Derzeit habe ich <Highlight>keine freien Plätze</Highlight>.</p>
<p class="margin-top-small">Das ändert sich ab <span class="highlight">01. Januar 2026</span>.</p> <p class="margin-top-small">Das ändert sich ab <Highlight>01. Januar 2026</Highlight>.</p>
</article> </article>
</template> </template>

View file

@ -0,0 +1,3 @@
<template>
<span class="highlight"><slot /></span>
</template>

View file

@ -0,0 +1,3 @@
<template>
<Icon name="ph:arrow-square-out-duotone" mode="svg" />
</template>

View file

@ -0,0 +1,3 @@
<template>
<Icon name="ph:link-simple-duotone" mode="svg" />
</template>

View file

@ -7,7 +7,7 @@
<main> <main>
<small class="customer">{{ company }}</small> <small class="customer">{{ company }}</small>
<h3 class="title">{{ title }}</h3> <h3 class="title">{{ title }}</h3>
<ul> <ul class="row">
<li v-for="skill in tech"> <li v-for="skill in tech">
<Technology v-bind="skill" link="" /> <Technology v-bind="skill" link="" />
</li> </li>

View file

@ -1,40 +0,0 @@
<template>
<component :is="computedComponent.is" v-bind="computedComponent.props" />
</template>
<script setup lang="ts">
import type { RichText } from './Types'
type Props = {
element : RichText
}
const { element } = defineProps<Props>()
const computedComponent = computed(() => {
let is : ReturnType<typeof resolveComponent> = {}
switch (element.type) {
case 'plain':
is = resolveComponent('RichTextPlain')
break
case 'p':
is = resolveComponent('RichTextParagraph')
break
case 'span':
is = resolveComponent('RichTextString')
break
case 'a':
is = resolveComponent('RichTextLink')
break
case 'br':
is = resolveComponent('RichTextNewLine')
break
default:
break
}
return { is, props: { ...element } }
})
</script>

View file

@ -1,18 +0,0 @@
<template>
<a v-if="isExternal" :href="href" :target="target" class="text inline-flex-row">
{{ content }}
<Icon name="ph:arrow-square-out-duotone" mode="svg" />
</a>
<NuxtLink v-else :to="href" class="text inline-flex-row">
{{ content }}
<Icon name="ph:link-simple-duotone" mode="svg" />
</NuxtLink>
</template>
<script setup lang="ts">
import type { RichTextLink } from './Types'
const { href } = defineProps<RichTextLink>()
const isExternal = href.startsWith('http')
</script>

View file

@ -1,3 +0,0 @@
<template>
<br>
</template>

View file

@ -1,11 +0,0 @@
<template>
<p :class="css">
<RichTextGeneral v-for="element in children" :element="element" />
</p>
</template>
<script setup lang="ts">
import type { RichTextParagraph } from './Types'
defineProps<RichTextParagraph>()
</script>

View file

@ -1,9 +0,0 @@
<template>
{{ content }}
</template>
<script setup lang="ts">
import type { RichTextPlain } from './Types'
defineProps<RichTextPlain>()
</script>

View file

@ -1,20 +0,0 @@
<template>
<template v-if="Array.isArray(elements)">
<General v-for="element in elements" :element="element" />
</template>
<template v-else>
<General :element="elements" />
</template>
</template>
<script setup lang="ts">
import type { RichText } from './Types'
import General from './General.vue'
type Props = {
elements : RichText | RichText[]
}
defineProps<Props>()
</script>

View file

@ -1,11 +0,0 @@
<template>
<span :class="css">
{{ content }}
</span>
</template>
<script setup lang="ts">
import type { RichTextSpan } from './Types'
defineProps<RichTextSpan>()
</script>

View file

@ -1,30 +0,0 @@
type RichTextBasis = {
type: string
content: string
css ?: string
}
export type RichTextPlain = RichTextBasis & {
type: 'plain'
}
export type RichTextParagraph = Omit<RichTextBasis, 'content'> & {
type: 'p'
children ?: RichText[]
}
export type RichTextSpan = RichTextBasis & {
type: 'span'
}
export type RichTextLink = RichTextBasis & {
type: 'a'
href: string
target ?: string
}
export type RichTextNewLine = {
type: 'br'
}
export type RichText = RichTextPlain | RichTextParagraph | RichTextSpan | RichTextLink | RichTextNewLine

View file

@ -1,33 +1,44 @@
<template> <template>
<section id="services" class="Services content"> <section id="services" class="Services content">
<h1>Projekt buchen</h1> <h1>Projekt buchen</h1>
<h2>Du hast also beschlossen, dass du <span class="highlight">meine Hilfe</span> brauchst. Cool!</h2> <h2>Paketpreise für
<Highlight>feste Ergebnisse</Highlight>
.
</h2>
<p class="margin-top">Manchmal brauchen wir alle einfach nur eine Kleinigkeit und wollen uns nicht lange binden. Das ist natürlich <p class="margin-top">Manchmal brauchen wir alle einfach nur eine Kleinigkeit und wollen uns nicht lange binden. Das ist natürlich
völlig in Ordnung und genau deshalb biete ich dir die Möglichkeit mich gezielt für <span class="highlight">kleinere Projekte</span> zu buchen.</p> völlig in Ordnung und genau deshalb biete ich dir die Möglichkeit mich gezielt für
<p class="margin-top-small">Hinter diesen Angeboten gibt es <span class="highlight">keinerlei Abos oder versteckte Kosten</span>. <Highlight>kleinere Projekte</Highlight>
Aus Transparenzgründen sei aber gesagt, dass sich <span class="highlight">alle Preise zzgl. 19 % Umsatzsteuer</span> verstehen.</p> zu buchen.
</p>
<p class="margin-top-small">Hinter diesen Angeboten gibt es
<Highlight>keinerlei Abos oder versteckte Kosten</Highlight>
.
Aus Transparenzgründen sei aber gesagt, dass sich
<Highlight>alle Preise zzgl. 19 % Umsatzsteuer</Highlight>
verstehen.
</p>
<FreeInfo /> <FreeInfo/>
<div class="Pricing margin-top"> <div class="Pricing margin-top">
<article v-for="(service, index) in oneOff" :class="{ 'z-3-all': index === 1, 'z-2-all': index !== 1}"> <article v-for="(service, index) in oneOff" :class="{ 'z-3-all': index === 1, 'z-2-all': index !== 1}">
<header> <header>
<strong>{{service.title}}</strong> <strong>{{ service.title }}</strong>
<p class="claim">{{service.smallClaim}}</p> <p class="claim">{{ service.smallClaim }}</p>
<div class="price"> <div class="price">
<span v-if="service.price.pre">{{service.price.pre}}</span> <span v-if="service.price.pre">{{ service.price.pre }}</span>
<span>{{typeof service.price.value === 'number' ? intl.format(service.price.value) : service.price.value}}</span> <span>{{ typeof service.price.value === 'number' ? intl.format(service.price.value) : service.price.value }}</span>
<span v-if="service.price.post" class="post">{{service.price.post}}</span> <span v-if="service.price.post" class="post">{{ service.price.post }}</span>
</div> </div>
<div aria-hidden="true" class="bg-icon"> <div aria-hidden="true" class="bg-icon">
<Icon :name="service.icon" size="1.5em" mode="svg" /> <Icon :name="service.icon" size="1.5em" mode="svg"/>
</div> </div>
</header> </header>
<main> <main>
<div class="list-container"> <div class="list-container">
<ul> <ul>
<li v-for="point in service.list"> <li v-for="point in service.list">
<Icon class="icon yes" name="ph:caret-circle-double-right-duotone" size="1.5em" mode="svg" /> <Icon class="icon yes" name="ph:caret-circle-double-right-duotone" size="1.5em" mode="svg"/>
<span class="label">{{ point }}</span> <span class="label">{{ point }}</span>
</li> </li>
</ul> </ul>
@ -43,8 +54,8 @@
</article> </article>
</div> </div>
<div class="flex-col gap-sm margin-top"> <div v-if="faq" class="flex-col gap-sm margin-top">
<Spoiler v-for="entry in faq" v-bind="entry" class="z-2" /> <ContentRenderer :value="faq" :style="{ display: 'contents' }"/>
</div> </div>
</section> </section>
</template> </template>
@ -65,15 +76,16 @@ type Service = {
} }
const intl = new Intl.NumberFormat( const intl = new Intl.NumberFormat(
'de-DE', 'de-DE',
{ {
style: 'currency', style: 'currency',
currency: 'EUR', currency: 'EUR',
minimumFractionDigits: 0, minimumFractionDigits: 0,
maximumFractionDigits: 0, maximumFractionDigits: 0,
}) },
)
const oneOff : Service[] = [ const oneOff: Service[] = [
{ {
title: 'Quick Check', title: 'Quick Check',
price: { price: {
@ -128,36 +140,21 @@ const oneOff : Service[] = [
}, },
] ]
const faq = [ const { data: faq } = await useAsyncData('faq_booking', () => queryCollection('faq').path('/snippets/faq/booking').first())
{ const texts = generatePlainText<['title']>(faq.value?.body.value)
header: 'Warum machst du keine Stundensätze?',
content: [
'Ich finde Stundensätze haben für beide Seiten nur Nachteile:',
'Wenn ich schnell und gut arbeite, dann bekomme ich weniger Geld. Hab ich mal einen Knoten im Gehirn und brauche sehr lange, muss der Kunde mehr zahlen.',
'Klar kann man sagen, dass sich das irgendwann ausgleichen könnte - aber so weit will ich es garnicht erst kommen lassen.'
]
},
{
header: 'Welche Themen bietest du für deine Schulungen an?',
content: [
'Sprachen: JavaScript, TypeScript, HTML, CSS',
'Frameworks: Vue, Nuxt',
]
},
{
header: 'Wo finden die Schulungen statt?',
content: [
'Die Schulungen finden online statt. Normalerweise nutze ich dafür Google Meet, aber wenn du oder deine Firma eine andere Plattform wünschen und bereitstellen bin ich natürlich flexibel.',
'Wenn sich deine Firma in der Nähe meines Wohnortes befindet - und damit meine ich "In einer Stunde mit der Straßenbahn zu erreichen", dann kann alles natürlich auch vor Ort stattfinden.',
]
},
{
header: 'Ich hab ein cooles Projekt! Aber kein Geld...',
content: [
'Tja.',
'Ne, awas. Meld dich einfach trotzdem über meine E-Mail-Adresse und vielleicht finden wir eine Lösung.'
]
}
]
if (faq) {
useSchemaOrg({
'@context': 'https://schema.org',
'@type': 'FAQPage',
'mainEntity': texts.map(entity => ({
'@type': 'Question',
'name': entity.meta.title,
'acceptedAnswer': {
'@type': 'Answer',
'text': entity.text,
},
})),
})
}
</script> </script>

View file

@ -1,7 +1,7 @@
<template> <template>
<section id="contact" class="Contact content full gap-default"> <section id="contact" class="Contact content full gap-default">
<h1>Kontakt <span class="highlight">&</span> Social Media</h1> <h1>Kontakt <Highlight>&</Highlight> Social Media</h1>
<h2>Kannst ruhig in meine <span class="highlight">DMs sliden</span>. Oder in's Postfach.</h2> <h2>Kannst ruhig in meine <Highlight>DMs sliden</Highlight>. Oder in's Postfach.</h2>
<article class="z-2 card flex-col gap-sm margin-top"> <article class="z-2 card flex-col gap-sm margin-top">
<h3>Du willst einfach nur 'ne Mail schreiben?</h3> <h3>Du willst einfach nur 'ne Mail schreiben?</h3>
@ -12,10 +12,10 @@
<article class="z-2 card flex-col gap-sm margin-top"> <article class="z-2 card flex-col gap-sm margin-top">
<h3>Ich auf Social Media</h3> <h3>Ich auf Social Media</h3>
<p>Falls du irgendwo einen anderen Social Media Account von mir findest, der nicht hier aufgelistet ist, aber aktiv postet, dann ist dieser höchstwahrscheinlich <span class="highlight">Fake</span>. <p>Falls du irgendwo einen anderen Social Media Account von mir findest, der nicht hier aufgelistet ist, aber aktiv postet, dann ist dieser höchstwahrscheinlich <Highlight>Fake</Highlight>.
<br />Meld' dich gerne bei mir, wenn du so einen findest. <br />Meld' dich gerne bei mir, wenn du so einen findest.
</p> </p>
<ul class="social-media"> <ul class="row social-media">
<li v-for="({icon, name, ...rest}) in socials" :key="rest.href"> <li v-for="({icon, name, ...rest}) in socials" :key="rest.href">
<a v-bind="rest" target="_blank"> <a v-bind="rest" target="_blank">
<Icon :name="icon" :alt="rest['aria-label']" size="1.5em" mode="svg" /> <Icon :name="icon" :alt="rest['aria-label']" size="1.5em" mode="svg" />

View file

@ -1,6 +1,6 @@
<template> <template>
<section id="customers" class="Customers content"> <section id="customers" class="Customers content">
<h1>Kunden <span class="highlight">&</span> Projekte.</h1> <h1>Kunden <Highlight>&</Highlight> Projekte.</h1>
<h2>Meine bisherigen Geschäftpartner</h2> <h2>Meine bisherigen Geschäftpartner</h2>
<div class="customer-list margin-top gap-default"> <div class="customer-list margin-top gap-default">
<a v-for="customer in customers" :href="customer.link" target="_blank" rel="noopener noreferrer"> <a v-for="customer in customers" :href="customer.link" target="_blank" rel="noopener noreferrer">

View file

@ -1,33 +1,44 @@
<template> <template>
<section id="services" class="Services content"> <section id="services" class="Services content">
<h1>Prepaid Flatrates</h1> <h1>Prepaid Flatrates</h1>
<h2>Genieße fusselige <span class="highlight">Qualität</span> ohne groß herumzurechnen.</h2> <h2>Genieße fusselige
<Highlight>Qualität</Highlight>
ohne groß herumzurechnen.
</h2>
<p class="margin-top">Bei dir fällt ständig was an oder du hast ein langlaufendes Projekt, bei dem du immer wieder mal Unterstützung brauchst? Kein Ding. <p class="margin-top">Bei dir fällt ständig was an oder du hast ein langlaufendes Projekt, bei dem du immer wieder mal Unterstützung brauchst?
Hier gibt's die <span class="highlight">Entwickler-Flat</span> für planbare Kosten und On-Demand-Entwicklung.</p> Kein Ding.
<p class="margin-top-small">Aus Transparenzgründen sei gesagt, dass sich <span class="highlight">alle Preise zzgl. 19 % Umsatzsteuer</span> verstehen.</p> Hier gibt's die
<Highlight>Entwickler-Flat</Highlight>
für planbare Kosten und On-Demand-Entwicklung.
</p>
<p class="margin-top-small">Aus Transparenzgründen sei gesagt, dass sich
<Highlight>alle Preise zzgl. 19 % Umsatzsteuer</Highlight>
verstehen.
</p>
<FreeInfo /> <FreeInfo/>
<div class="Pricing margin-top"> <div class="Pricing margin-top">
<article v-for="(service, index) in flatrate" :class=" { 'z-2' : index === 1, 'z-1' : index !== 1 }"> <article v-for="(service, index) in flatrate" :class=" { 'z-2' : index === 1, 'z-1' : index !== 1 }">
<header> <header>
<span v-if="service.best" class="chip dark z-2"><Icon name="ph:fire-duotone" mode="svg"/> Beschd</span> <span v-if="service.best" class="chip dark z-2"><Icon name="ph:fire-duotone" mode="svg"/> Beschd</span>
<strong class="margin-top-small">{{service.title}}</strong> <strong class="margin-top-small">{{ service.title }}</strong>
<p class="claim">{{service.smallClaim}}</p> <p class="claim">{{ service.smallClaim }}</p>
<p class="price">{{intl.format(service.price)}}<span class="post">/ Monat</span></p> <p class="price">{{ intl.format(service.price) }}<span class="post">/ Monat</span></p>
<div aria-hidden="true" class="bg-icon"> <div aria-hidden="true" class="bg-icon">
<Icon :name="service.icon" size="1.5em" mode="svg" /> <Icon :name="service.icon" size="1.5em" mode="svg"/>
</div> </div>
</header> </header>
<main> <main>
<ul> <ul>
<li> <li>
<Icon class="icon yes" name="ph:check-circle-duotone" mode="svg" /> <Icon class="icon yes" name="ph:check-circle-duotone" mode="svg"/>
<span class="value">{{ service.hours }} Stunden pro Woche zugesichert</span> <span class="value">{{ service.hours }} Stunden pro Woche zugesichert</span>
</li> </li>
<li v-for="(check, index) in service.checks"> <li v-for="(check, index) in service.checks">
<Icon class="icon" :class="{ 'yes' : check, 'no' : !check }" :name="check ? 'ph:check-circle-duotone' : 'ph:x-circle-duotone'" mode="svg" /> <Icon class="icon" :class="{ 'yes' : check, 'no' : !check }" :name="check ? 'ph:check-circle-duotone' : 'ph:x-circle-duotone'"
mode="svg"/>
<span class="value">{{ points[index] }}</span> <span class="value">{{ points[index] }}</span>
</li> </li>
</ul> </ul>
@ -43,8 +54,8 @@
</article> </article>
</div> </div>
<div class="flex-col gap-sm margin-top"> <div v-if="faq" class="flex-col gap-sm margin-top">
<Spoiler v-for="entry in faq" v-bind="entry" class="z-2" /> <ContentRenderer :value="faq" :style="{ display: 'contents' }"/>
</div> </div>
</section> </section>
</template> </template>
@ -72,7 +83,7 @@ const intl = new Intl.NumberFormat(
maximumFractionDigits: 0, maximumFractionDigits: 0,
}) })
const points : string[] = [ const points: string[] = [
'Kontakt per E-Mail', 'Kontakt per E-Mail',
'Kontakt per Instant Messaging', 'Kontakt per Instant Messaging',
'Kontakt per Video Call', 'Kontakt per Video Call',
@ -80,106 +91,79 @@ const points : string[] = [
'Framework Migration', 'Framework Migration',
] ]
const flatrate : Service[] = const flatrate: Service[] =
[ [
{ {
title: 'Casual', title: 'Casual',
smallClaim: 'Für kleine Aufgaben nebenbei.', smallClaim: 'Für kleine Aufgaben nebenbei.',
icon: 'ph:baby-carriage-thin', icon: 'ph:baby-carriage-thin',
button: 'Jetzt klar machen', button: 'Jetzt klar machen',
link: 'https://tidycal.com/webfussel/flatrate-casual', link: 'https://tidycal.com/webfussel/flatrate-casual',
best: false, best: false,
price: 2950, price: 2950,
hours: 5, hours: 5,
contractMin: 3, contractMin: 3,
checks: [ checks: [
true, true,
false, false,
false, false,
false, false,
false, false,
], ],
}, },
{ {
title: 'Gold-Fussel', title: 'Gold-Fussel',
smallClaim: 'Wenn\'s mal wieder zu viel wird.', smallClaim: 'Wenn\'s mal wieder zu viel wird.',
icon: 'ph:coins-thin', icon: 'ph:coins-thin',
button: 'Jetzt Gold schürfen', button: 'Jetzt Gold schürfen',
link: 'https://tidycal.com/webfussel/flatrate-gold-fussel', link: 'https://tidycal.com/webfussel/flatrate-gold-fussel',
best: true, best: true,
price: 5555, price: 5555,
hours: 10, hours: 10,
contractMin: 3, contractMin: 3,
checks: [ checks: [
true, true,
true, true,
false, false,
true, true,
false, false,
], ],
}, },
{ {
title: 'Big Chonker', title: 'Big Chonker',
smallClaim: 'Für die richtig großen Sachen.', smallClaim: 'Für die richtig großen Sachen.',
icon: 'ph:skull-thin', icon: 'ph:skull-thin',
button: 'Jetzt Fett trimmen', button: 'Jetzt Fett trimmen',
link: 'https://tidycal.com/webfussel/flatrate-big-chonker', link: 'https://tidycal.com/webfussel/flatrate-big-chonker',
best: false, best: false,
price: 8550, price: 8550,
hours: 15, hours: 15,
contractMin: 6, contractMin: 6,
checks: [ checks: [
true, true,
true, true,
true, true,
true, true,
true, true,
], ],
} },
] ]
const faq = [ const { data: faq } = await useAsyncData('faq_flatrate', () => queryCollection('faq').path('/snippets/faq/flatrate').first())
{ const texts = generatePlainText<['title']>(faq.value?.body.value)
header: 'Was ist eine Entwickler-Flatrate?',
content: [ if (faq) {
'Die Entwickler-Flatrate ist ein Angebot, bei dem du für eine bestimmte Zeit eine bestimmte Menge an Leistungen erhältst.', useSchemaOrg({
'Sie wird auch oft als sogenannter "Retainer" bezeichnet und ist eine günstige Art immer wieder anfallende Aufgaben auszulagern.' '@context': 'https://schema.org',
] '@type': 'FAQPage',
}, 'mainEntity': texts.map(entity => ({
{ '@type': 'Question',
header: 'Wie läuft die Zusammenarbeit ab?', 'name': entity.meta.title,
content: [ 'acceptedAnswer': {
'Nach einer ersten Analyse deines Projekts legen wir gemeinsam den Umfang und die monatlichen Aufgaben fest. Du kannst mich je nach Paket auf den vereinbarten Wegen jederzeit kontaktieren.', '@type': 'Answer',
'Die Bearbeitung erfolgt innerhalb der vereinbarten Fristen.', 'text': entity.text,
] },
}, })),
{ })
header: 'Wie wird abgerechnet?', }
content: [
'Du zahlst monatlich einen festen Betrag im Prepaid-Format unabhängig davon wie viele Aufgaben tatsächlich anfallen.',
'Dadurch kannst du dein Budget besser einplanen und Aufgaben verteilen.'
]
},
{
header: 'Kann ich den Retainer jederzeit kündigen?',
content: [
'Ja klar. Du musst dabei nur die Mindestvertragslaufzeit beachten - die bezahlte Leistung steht dir weiterhin zu.',
'Falls wir merken, dass wir für eine Zusammenarbeit mehr als ungeeignet sind, bekommst du dein Geld anteilig der verbleibenden Zeit zurück und verlierst den Anspruch auf Leistungen ab diesem Zeitpunkt.'
]
},
{
header: 'Was passiert, wenn ich dich mal weniger brauche?',
content: [
'Der monatliche feste Betrag bleibt bestehen. Ähnlich wie bei einer Versicherung bezahlst du hier für den Fall, dass du mich brauchst und erhältst dann entsprechend die vereinbarten Leistungen.',
'Wenn du merkst, dass du mich nicht mehr brauchst, dann kannst du den Vertrag jederzeit kündigen und die Leistungen auslaufen lassen.'
]
},
{
header: 'Für wen lohnt sich sowas überhaupt?',
content: [
'Vor allem Unternehmen, bei denen immer wieder Aufgaben anfallen für die sich aber kein komplettes Projekt oder gar eine feste Stelle in der Firma lohnt, profitieren von dieser Art von Leistung.',
'Sie ist ideal für eine langfristige und zuverlässige Zusammenarbeit.'
]
}
]
</script> </script>

View file

@ -1,18 +1,18 @@
<template> <template>
<footer class="Footer flex-col gap-default"> <footer class="Footer flex-col gap-default">
<ul class="sitemap gap-default"> <ul class="row sitemap gap-default">
<li v-for="{ label, ...rest} in nav" :key="label"> <li v-for="{ label, ...rest} in nav" :key="label">
<NuxtLink v-bind="rest">{{label}}</NuxtLink> <NuxtLink v-bind="rest">{{label}}</NuxtLink>
</li> </li>
</ul> </ul>
<ul class="sitemap gap-default"> <ul class="row sitemap gap-default">
<li v-for="({icon, ...rest}) in socials" :key="rest.href"> <li v-for="({icon, ...rest}) in socials" :key="rest.href">
<a v-bind="rest" target="_blank"> <a v-bind="rest" target="_blank">
<Icon :name="icon" :alt="rest['aria-label']" size="1.5em" mode="svg" /> <Icon :name="icon" :alt="rest['aria-label']" size="1.5em" mode="svg" />
</a> </a>
</li> </li>
</ul> </ul>
<ul class="sitemap gap-default"> <ul class="row sitemap gap-default">
<li class="tip-container"> <li class="tip-container">
<Icon name="wf:cookie-slash" size="1.5rem" mode="svg" /> <Icon name="wf:cookie-slash" size="1.5rem" mode="svg" />
<span class="tip">Ohne Cookies</span> <span class="tip">Ohne Cookies</span>
@ -22,7 +22,7 @@
<span class="tip">Ohne Tracker</span> <span class="tip">Ohne Tracker</span>
</li> </li>
</ul> </ul>
<p class="inline-flex-row"><Icon name="ph:copyright-duotone" mode="svg"/> 2024 by <a href="https://webfussel.de">webfussel</a></p> <p class="inline-flex-row"><Icon name="ph:copyright-duotone" mode="svg"/> 2024 by <NuxtLink to="/">webfussel</NuxtLink></p>
</footer> </footer>
</template> </template>

View file

@ -12,7 +12,7 @@
<Icon name="ph:waves" mode="svg" size="2em" /> <Icon name="ph:waves" mode="svg" size="2em" />
</button> </button>
<nav> <nav>
<ul class="main-nav"> <ul class="row main-nav">
<li v-for="({label, to, aria, icon}) in navigation" :key="label"> <li v-for="({label, to, aria, icon}) in navigation" :key="label">
<NuxtLink :to="to" :aria-label="aria" active-class="active" class="inline-flex-row big-gap"> <NuxtLink :to="to" :aria-label="aria" active-class="active" class="inline-flex-row big-gap">
<Icon :name="icon" mode="svg" /> <Icon :name="icon" mode="svg" />

View file

@ -3,10 +3,10 @@
<div class="intro-text flex-col gap-default"> <div class="intro-text flex-col gap-default">
<h1 class="flex-col"> <h1 class="flex-col">
<span class="greeting inline-flex-row">Moin. <Icon name="ph:hand-peace-duotone" mode="svg"/></span> <span class="greeting inline-flex-row">Moin. <Icon name="ph:hand-peace-duotone" mode="svg"/></span>
<span class="my-name-wrapper">Ich bin <span class="highlight">Fiona</span>.</span> <span class="my-name-wrapper">Ich bin <Highlight>Fiona</Highlight>.</span>
</h1> </h1>
<h2> <h2>
Component <span class="highlight">&</span> API Entwicklerin Component <Highlight>&</Highlight> API Entwicklerin
</h2> </h2>
<p class="fulltext"> <p class="fulltext">
Ich unterstütze Unternehmen dabei, ihre Daten von verschiedenen Endpunkten sauber aufzubereiten Ich unterstütze Unternehmen dabei, ihre Daten von verschiedenen Endpunkten sauber aufzubereiten

View file

@ -1,7 +1,7 @@
<template> <template>
<section id="skills" class="Skills content"> <section id="skills" class="Skills content">
<h2>Meine Expertise.</h2> <h2>Meine Expertise.</h2>
<h3>Dies sind meine <span class="highlight">Spezialgebiete</span> - aber ich bin flexibel!</h3> <h3>Dies sind meine <Highlight>Spezialgebiete</Highlight> - aber ich bin flexibel!</h3>
<div class="skill-list margin-top gap-default"> <div class="skill-list margin-top gap-default">
<Card v-for="skill in skills" :title="skill.title" titleTag="h3"> <Card v-for="skill in skills" :title="skill.title" titleTag="h3">
<p v-for="(t, i) in skill.text" :class="[i === skill.text.length - 1 && 'bold']">{{t}}</p> <p v-for="(t, i) in skill.text" :class="[i === skill.text.length - 1 && 'bold']">{{t}}</p>
@ -9,7 +9,7 @@
</div> </div>
<Card title="Technologien" titleTag="h3" class="margin-top tech-list"> <Card title="Technologien" titleTag="h3" class="margin-top tech-list">
<p>Neben den klassischen Webentwicklungsstandards JavaScript, HTML und CSS biete ich außerdem folgende Technologien.</p> <p>Neben den klassischen Webentwicklungsstandards JavaScript, HTML und CSS biete ich außerdem folgende Technologien.</p>
<ul class="gap-default margin-top-small"> <ul class="row gap-default margin-top-small">
<li v-for="tech in technologies"> <li v-for="tech in technologies">
<Technology v-bind="tech" size="l"/> <Technology v-bind="tech" size="l"/>
</li> </li>

View file

@ -1,13 +1,13 @@
<template> <template>
<section id="skills_easy" class="Skills content"> <section id="skills_easy" class="Skills content">
<h2>Jetzt mal ganz konkret.</h2> <h2>Jetzt mal ganz konkret.</h2>
<h3>In diesem Abschnitt ganz <span class="highlight">ohne Technik-Blabla</span>.</h3> <h3>In diesem Abschnitt ganz <Highlight>ohne Technik-Blabla</Highlight>.</h3>
<div class="skill-container flex-col margin-top gap-default"> <div class="skill-container flex-col margin-top gap-default">
<ContentRenderer v-for="skill in skills" :key="skill.id" :value="skill" :style="{ display: 'contents' }" /> <ContentRenderer v-for="skill in skills" :key="skill.id" :value="skill" :style="{ display: 'contents' }" />
</div> </div>
<div class="bottom flex-col margin-top gap-default"> <div class="bottom flex-col margin-top gap-default">
<h3>Verwirkliche jetzt dein Webprojekt.</h3> <h3>Verwirkliche jetzt dein Webprojekt.</h3>
<Button href="/booking" class="cta"> <Button href="/booking/" class="cta">
<Icon name="ph:chat-circle-text-duotone" size="1.5em" mode="svg" /> <Icon name="ph:chat-circle-text-duotone" size="1.5em" mode="svg" />
Lass mal reden Lass mal reden
</Button> </Button>
@ -16,246 +16,5 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RichText } from '@/components/RichText/Types'
type Skill = {
img: string
title: string
text: RichText[]
}
const getExplanationImage = (img : string) => getImage('/img/explanations/', img)
// const skills : Skill[] = [
// {
// img: 'components',
// title: 'Das, was du sehen kannst',
// text: [
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Die meisten Anwendungen haben eine grafische Benutzeroberfläche - die so genannte GUI. Wenn etwas graphisch ist, ' +
// 'dann bedeutet das auch natürlich, dass es sinnvoll dargestellt werden muss. Dafür verwenden wir kleine Bausteine, die wir Komponenten nennen.',
// }
// ]
// },
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Grundsätzlich lassen sich komplette Anwendungen und auch einfache Webseiten ziemlich cool über Komponenten aufbauen, sodass wir wiederkehrende Elemente ' +
// 'wie Buttons, Textabschnitte, Links oder Teaser immer wieder verwenden können - selbst wenn sich diese in ihren Details wie Farben oder Icons unterscheiden.',
// }
// ]
// },
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Diese Komponenten so zu entwickeln, dass sie wirklich flexibel sind und auch perfekt mit dem Design übereinstimmen ist gar nicht so einfach, denn oft sollten sie ' +
// 'auch mehr können als im Design kurzfristig ersichtlich ist.',
// }
// ]
// },
// {
// type: 'p',
// css: 'place-bottom bold',
// children: [
// {
// type: 'plain',
// content: 'Diese Voraussicht, für den Fall der Fälle vorzusorgen:',
// },
// {
// type: 'br',
// },
// {
// type: 'plain',
// content: 'Die gibt\'s bei mir dazu.',
// },
// {
// type: 'br',
// },
// {
// type: 'span',
// css: 'highlight',
// content: 'Fussel-Ehrenwort.'
// }
// ]
// },
// ],
// }, {
// img: 'cms',
// title: 'Da, wo du eintragen kannst',
// text: [
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'In vielen Fällen ist es natürlich praktisch, wenn du einfach Inhalte einer Seite auf das ändern kannst, was gerade aktuell ist, ohne auf andere angewiesen zu sein.'
// + 'Damit das reibungslos möglich ist, entwickle ich dir gerne eine Anwendung oder Homepage, deren Inhalte du komplett selbst auf dem neuesten Stand halten kannst - und zwar '
// + 'mit einem sogenannten CMS - ein ',
// },
// {
// type: 'span',
// css: 'highlight',
// content: 'C'
// },
// {
// type: 'plain',
// content: 'ontent '
// },
// {
// type: 'span',
// css: 'highlight',
// content: 'M'
// },
// {
// type: 'plain',
// content: 'anagement '
// },
// {
// type: 'span',
// css: 'highlight',
// content: 'S'
// },
// {
// type: 'plain',
// content: 'ystem.'
// },
// ]
// },
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Für CMS setze ich in erster Linie auf die cloudbasierte Lösung ',
// },
// {
// type: 'a',
// target: '_blank',
// css: 'text',
// href: 'https://www.storyblok.com',
// content: 'Storyblok'
// },
// {
// type: 'plain',
// content: '. Dies stellt für die Meisten eine kostenlose bis kostengünstige Lösung dar ohne viel technisches Wissen mitbringen zu müssen.',
// }
// ]
// },
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Falls du aber nicht möchtest, dass deine Daten auf irgendeinem Fremden server liegen - was ich durchaus verstehen kann! - dann gibt es auch die Möglichkeit mit ',
// },
// {
// type: 'a',
// target: '_blank',
// css: 'text',
// href: 'https://www.strapi.io',
// content: 'Strapi'
// },
// {
// type: 'plain',
// content: ' selbst das eigene CMS zu hosten. Das musst du dann aber allerdings selbst erledigen oder ich erledige das für dich für einen Aufpreis.',
// }
// ]
// },
// {
// type: 'p',
// css: 'place-bottom bold',
// children: [
// {
// type: 'plain',
// content: 'Nie wieder jemand anderen Fragen zu müssen, um deine Website auf dem neuesten Stand zu halten.',
// },
// {
// type: 'br',
// },
// {
// type: 'span',
// css: 'highlight',
// content: 'Mit Fussel-Garantie.'
// }
// ]
// },
// ],
// }, {
// img: 'result',
// title: 'Was dabei am Ende rauskommt',
// text: [
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Grundsätzlich lässt sich das ganz einfach zusammenfassen: ',
// },
// {
// type: 'span',
// css: 'highlight',
// content: 'Dein persönlicher Webauftritt.',
// }
// ]
// },
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Ob du nun etwas kleineres brauchst, um ein paar Hobbies zu zeigen. Oder vielleicht etwas größeres, weil du dir ein eigenes Business aufbauen willst. Eventuell ein eigener Blog, eine komplette Applikation oder '
// + 'die Möglichkeit für andere Leute deine Daten bereit zu stellen. All das, das kann ich dir mit meinen Fähigkeiten und meiner Erfahrung bieten.'
// }
// ]
// },
// {
// type: 'p',
// children: [
// {
// type: 'plain',
// content: 'Erkunde einfach meine ',
// },
// {
// type: 'a',
// href: '/references',
// content: 'Referenzen'
// },
// {
// type: 'plain',
// content: ' und dir wird auffallen, dass diese äußerst durchmischt und bunt sind. So individuell wie die Projekte meiner bisherigen Kunden wird auch dein Projekt behandelt. Und auch, wenn '
// + 'du glaubst, dass die Referenzen nicht zu deinem Projekt passen, werden wir deine ganz individuelle Lösung gemeinsam erarbeiten.'
// }
// ]
// },
// {
// type: 'p',
// css: 'place-bottom bold',
// children: [
// {
// type: 'plain',
// content: 'Denn jedes Projekt ist etwas eigenes und besonderes.',
// },
// {
// type: 'br',
// },
// {
// type: 'span',
// css: 'highlight',
// content: 'Auch deins.'
// }
// ]
// },
// ],
// }
// ]
const skills = await queryCollection('skills').all() const skills = await queryCollection('skills').all()
</script> </script>

View file

@ -1,19 +1,19 @@
<template> <template>
<details class="Spoiler" :open="open" @click="toggle"> <details class="Spoiler" :open="open">
<summary><Icon class="icon" :name="icon" mode="svg" />{{ header }}</summary> <summary @click="toggle"><Icon class="icon" :name="icon" mode="svg" />{{ title }}</summary>
<div> <div>
<p v-for="text in content">{{ text }}</p> <slot />
</div> </div>
</details> </details>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
type Props = { type Props = {
header: string title: string
content: string[] answer ?: string
} }
defineProps<Props>() const { title, answer = '' } = defineProps<Props>()
const open = ref(false) const open = ref(false)
const toggle = (event : MouseEvent) => { const toggle = (event : MouseEvent) => {

View file

@ -3,3 +3,10 @@
<SectionBooking /> <SectionBooking />
</div> </div>
</template> </template>
<script lang="ts" setup>
useSeoMeta({
title: 'Projektbuchung',
description: 'Buche jetzt dein Projekt auf webfussel. Du brauchst eine Schulung in JavaScript, Typescript, HTML, CSS, Vue oder Nuxt? Kein Problem.',
})
</script>

View file

@ -3,3 +3,10 @@
<SectionContact /> <SectionContact />
</div> </div>
</template> </template>
<script lang="ts" setup>
useSeoMeta({
title: 'Kontakt',
description: 'Nimm Kontakt zu webfussel auf. Egal ob über E-Mail oder Social Media - ich freue mich auf deine Nachricht.',
})
</script>

View file

@ -3,3 +3,10 @@
<SectionFlatrate /> <SectionFlatrate />
</div> </div>
</template> </template>
<script lang="ts" setup>
useSeoMeta({
title: 'Flatrate',
description: 'Buche webfussel ganz einfach für eine zugesicherte Anzahl Stunden pro Woche. Wenn immer mal wieder was anfällt - vertrau auf Fusselqualität.',
})
</script>

View file

@ -14,4 +14,9 @@ useHead({
{ rel: 'icon', href: '/favicon.ico', type: 'image/x-icon' }, { rel: 'icon', href: '/favicon.ico', type: 'image/x-icon' },
], ],
}) })
useSeoMeta({
title: 'Home',
description: 'Webprojekte und Retainer mit Fusselqualität. Du brauchst eine Website mit CMS? Bock auf Flatrate? webfussel by Fiona Urban',
})
</script> </script>

View file

@ -2,4 +2,11 @@
<div> <div>
<SectionCustomers /> <SectionCustomers />
</div> </div>
</template> </template>
<script lang="ts" setup>
useSeoMeta({
title: 'Referenzen',
description: 'Schau dir dir Projekte von webfussel an. Über persönliche Webseiten, über Schulungen bis hin zu API Projekten.',
})
</script>

27
app/utils/markdown.ts Normal file
View file

@ -0,0 +1,27 @@
import type { MinimalElement, MinimalNode } from '@nuxt/content'
type TypedRecord<T extends readonly string[]> = Record<T[number], string>
type PlainText<T extends string[]> = {
meta: TypedRecord<T>
text: string
}
const extractText = (element ?: MinimalNode) : string => {
if (!element) return ''
if (typeof element === 'string') return element
const [,, ...nodes] = element
return nodes?.map((el : MinimalNode) => typeof el === 'string' ? el : extractText(el)).join(' ') ?? ''
}
export const generatePlainText = <T extends string[] = []>(body ?: MinimalNode[]) : PlainText<T>[] => {
if (!body) return []
return body.map<PlainText<T>>(part => {
const [, meta] = part as MinimalElement
return {
meta : meta as TypedRecord<T>,
text: extractText(part).replace(/\n/g, ' ')
}
})
}

View file

@ -18,12 +18,12 @@ export const socials = [
name: '@webfussel.de', name: '@webfussel.de',
'aria-label': 'Externer Link: Bluesky Profil' 'aria-label': 'Externer Link: Bluesky Profil'
}, },
{ // {
href: 'https://twitch.tv/webfussel', // href: 'https://twitch.tv/webfussel',
icon: 'ph:twitch-logo-duotone', // icon: 'ph:twitch-logo-duotone',
name: 'webfussel', // name: 'webfussel',
'aria-label': 'Externer Link: Twitch Kanal' // 'aria-label': 'Externer Link: Twitch Kanal'
}, // },
{ {
href: 'https://ko-fi.com/webfussel', href: 'https://ko-fi.com/webfussel',
icon: 'wf:kofi', icon: 'wf:kofi',

View file

@ -10,13 +10,13 @@ export default defineContentConfig({
skills: defineCollection({ skills: defineCollection({
type: 'page', type: 'page',
source: 'snippets/skills/*.md', source: 'snippets/skills/*.md',
}),
faq: defineCollection({
type: 'page',
source: 'snippets/faq/*.md',
schema: z.object({ schema: z.object({
title: z.string(), rawbody: z.string(),
img: z.object({
path: z.string(),
name: z.string(),
position: z.string(),
}),
}) })
}) })
} }

View file

@ -0,0 +1,67 @@
::spoiler
---
title: "Warum machst du keine Stundensätze?"
---
Ich finde, Stundensätze haben für beide Seiten nur Nachteile:
Wenn ich schnell und gut arbeite, bekomme ich weniger Geld. Hab ich mal einen Knoten im Gehirn und brauche sehr lange, muss der Kunde mehr zahlen.
Klar kann man sagen, dass sich das irgendwann ausgleichen könnte aber so weit will ich es gar nicht erst kommen lassen.
::
::spoiler
---
title: "Was bekomme ich denn für die 999 €?"
---
Grundsätzlich kommt das natürlich immer darauf an, was du genau willst. Da spielen einige Faktoren eine Rolle.
**Ein Beispiel für eine 999 € Seite wäre**
---
- Kein CMS
- Eine Section auf der Landingpage
- Impressum, Datenschutzerklärung
- Standarddesign
---
Je nachdem was du willst, kann sich das aber auch anders zusammensetzen.
Zu Beachten ist, dass es sich hier um reines HTML und CSS handelt. Keine Fancy Daten aus einem Backend, keine 3D-Animationen.
Große und gute Websites werden normalerweise über einen längeren Zeitraum entwickelt und kosten entsprechend auch mehr.
Bei diesem Startangebot handelt es sich also einfach nur um eine kleine Online-Visitenkarte.
::
::spoiler
---
title: "Welche Themen bietest du für deine Schulungen an?"
---
<div>
<Highlight>Sprachen</Highlight>
<br />JavaScript, TypeScript, HTML, CSS
</div>
<div>
<Highlight>Frameworks</Highlight>
<br />Vue, Nuxt
</div>
::
::spoiler
---
title: "Wo finden die Schulungen statt?"
---
Die Schulungen finden <Highlight>online</Highlight> statt. Normalerweise nutze ich dafür Google Meet, aber wenn du oder deine Firma eine andere Plattform wünschen und bereitstellen bin ich natürlich flexibel.
Wenn sich deine Firma in der Nähe meines Wohnortes befindet und damit meine ich "In einer Stunde mit der Straßenbahn zu erreichen", dann kann alles natürlich auch vor Ort stattfinden.
::
::spoiler
---
title: "Ich hab ein cooles Projekt! Aber kein Geld …"
---
Tja.
Ne, awas. Meld dich einfach trotzdem über meine E-Mail-Adresse und vielleicht finden wir eine Lösung.
Du findest weitere Kontaktmöglichkeiten auf meiner [Kontakt-Seite <IconLinkInternal />](/contact/){class="text inline-flex-row"}.
::

View file

@ -0,0 +1,53 @@
::spoiler
---
title: "Was ist eine Entwickler-Flatrate?"
---
Die Entwickler-Flatrate ist ein Angebot, bei dem du für eine bestimmte Zeit eine bestimmte Menge an Leistungen erhältst.
Sie wird auch oft als sogenannter "Retainer" bezeichnet und ist eine günstige Art, immer wieder anfallende Aufgaben auszulagern.
::
::spoiler
---
title: "Wie läuft die Zusammenarbeit ab?"
---
Nach einer ersten Analyse deines Projekts legen wir gemeinsam den Umfang und die monatlichen Aufgaben fest. Du kannst mich je nach Paket auf den vereinbarten Wegen jederzeit kontaktieren.
Die Bearbeitung erfolgt innerhalb der vereinbarten Fristen.
::
::spoiler
---
title: "Wie wird abgerechnet?"
---
Du zahlst monatlich einen festen Betrag im Prepaid-Format unabhängig davon, wie viele Aufgaben tatsächlich anfallen.
Dadurch kannst du dein Budget besser einplanen und Aufgaben verteilen.
::
::spoiler
---
title: "Kann ich den Retainer jederzeit kündigen?"
---
Ja klar. Du musst dabei nur die Mindestvertragslaufzeit beachten die bezahlte Leistung steht dir weiterhin zu.
Falls wir merken, dass wir für eine Zusammenarbeit mehr als ungeeignet sind, bekommst du dein Geld anteilig der verbleibenden Zeit zurück und verlierst den Anspruch auf Leistungen ab diesem Zeitpunkt.
::
::spoiler
---
title: "Was passiert, wenn ich dich mal weniger brauche?"
---
Der monatliche feste Betrag bleibt bestehen. Ähnlich wie bei einer Versicherung bezahlst du hier für den Fall, dass du mich brauchst, und erhältst dann entsprechend die vereinbarten Leistungen.
Wenn du merkst, dass du mich nicht mehr brauchst, dann kannst du den Vertrag jederzeit kündigen und die Leistungen auslaufen lassen.
::
::spoiler
---
title: "Für wen lohnt sich sowas überhaupt?"
---
Vor allem Unternehmen, bei denen immer wieder Aufgaben anfallen, für die sich aber kein komplettes Projekt oder gar eine feste Stelle in der Firma lohnt, profitieren von dieser Art von Leistung.
Sie ist ideal für eine langfristige und zuverlässige Zusammenarbeit.
::

View file

@ -15,5 +15,5 @@ Diese Komponenten so zu entwickeln, dass sie wirklich flexibel sind und auch per
**Diese Voraussicht, für den Fall der Fälle vorzusorgen:** **Diese Voraussicht, für den Fall der Fälle vorzusorgen:**
**Die gibt's bei mir dazu.** **Die gibt's bei mir dazu.**
<span class="highlight">Fussel-Ehrenwort.</span> <Highlight>Fussel-Ehrenwort.</Highlight>
:: ::

View file

@ -7,12 +7,12 @@ image:
position: "right" position: "right"
--- ---
In vielen Fällen ist es natürlich praktisch, wenn du einfach Inhalte einer Seite auf das ändern kannst, was gerade aktuell ist, ohne auf andere angewiesen zu sein. Damit das reibungslos möglich ist, entwickle ich dir gerne eine Anwendung oder Homepage, deren Inhalte du komplett selbst auf dem neuesten Stand halten kannst und zwar mit einem sogenannten <span class="highlight">CMS</span> ein <span class="highlight">C</span>ontent <span class="highlight">M</span>anagement <span class="highlight">S</span>ystem. In vielen Fällen ist es natürlich praktisch, wenn du einfach Inhalte einer Seite auf das ändern kannst, was gerade aktuell ist, ohne auf andere angewiesen zu sein. Damit das reibungslos möglich ist, entwickle ich dir gerne eine Anwendung oder Homepage, deren Inhalte du komplett selbst auf dem neuesten Stand halten kannst und zwar mit einem sogenannten <Highlight>CMS</Highlight> ein <Highlight>C</Highlight>ontent <Highlight>M</Highlight>anagement <Highlight>S</Highlight>ystem.
Für CMS setze ich in erster Linie auf die cloudbasierte Lösung [Storyblok <Icon name="ph:arrow-square-out-duotone" mode="svg" />](https://www.storyblok.com){class="inline-flex-row text"}. Dies stellt für die Meisten eine kostenlose bis kostengünstige Lösung dar ohne viel technisches Wissen mitbringen zu müssen. Für CMS setze ich in erster Linie auf die cloudbasierte Lösung [Storyblok <IconLinkExternal />](https://www.storyblok.com){class="inline-flex-row text"}. Dies stellt für die Meisten eine kostenlose bis kostengünstige Lösung dar, ohne viel technisches Wissen mitbringen zu müssen.
Falls du aber nicht möchtest, dass deine Daten auf irgendeinem Fremden Server liegen was ich durchaus verstehen kann! dann gibt es auch die Möglichkeit mit [Strapi <Icon name="ph:arrow-square-out-duotone" mode="svg" />](https://strapi.io){class="inline-flex-row text"} selbst das eigene CMS zu hosten. Das musst du dann aber allerdings selbst erledigen oder ich erledige das für dich für einen Aufpreis. Falls du aber nicht möchtest, dass deine Daten auf irgendeinem Fremden Server liegen was ich durchaus verstehen kann! dann gibt es auch die Möglichkeit mit [Strapi <IconLinkExternal />](https://strapi.io){class="inline-flex-row text"} selbst das eigene CMS zu hosten. Das musst du dann aber allerdings selbst erledigen oder ich erledige das für dich für einen Aufpreis.
**Nie wieder jemand anderen fragen zu müssen, um deine Website auf dem neuesten Stand zu halten.** **Nie wieder jemand anderen fragen zu müssen, um deine Website auf dem neuesten Stand zu halten.**
<span class="highlight">Mit Fussel-Garantie.</span> <Highlight>Mit Fussel-Garantie.</Highlight>
:: ::

View file

@ -7,12 +7,12 @@ image:
position: "left" position: "left"
--- ---
Grundsätzlich lässt sich das ganz einfach zusammenfassen: <span class="highlight">Dein persönlicher Webauftritt.</span> Grundsätzlich lässt sich das ganz einfach zusammenfassen: <Highlight>Dein persönlicher Webauftritt.</Highlight>
Ob du nun etwas kleineres brauchst, um ein paar Hobbies zu zeigen. Oder vielleicht etwas größeres, weil du dir ein eigenes Business aufbauen willst. Eventuell ein eigener Blog, eine komplette Applikation oder die Möglichkeit für andere Leute deine Daten bereit zu stellen. All das, das kann ich dir mit meinen Fähigkeiten und meiner Erfahrung bieten. Ob du nun etwas Kleineres brauchst, um ein paar Hobbys zu zeigen. Oder vielleicht etwas Größeres, weil du dir ein eigenes Business aufbauen willst. Eventuell ein eigener Blog, eine komplette Applikation oder die Möglichkeit für andere Leute deine Daten bereitzustellen. All das, das kann ich dir mit meinen Fähigkeiten und meiner Erfahrung bieten.
Erkunde einfach meine [Referenzen <Icon name="ph:link-simple-duotone" mode="svg" />](references/){class="inline-flex-row text"} und dir wird auffallen, dass diese äußerst durchmischt und bunt sind. So individuell wie die Projekte meiner bisherigen Kunden wird auch dein Projekt behandelt. Und auch, wenn du glaubst, dass die Referenzen nicht zu deinem Projekt passen, werden wir deine ganz individuelle Lösung gemeinsam erarbeiten. Erkunde einfach meine [Referenzen <IconLinkInternal >](references/){class="inline-flex-row text"} und dir wird auffallen, dass diese äußerst durchmischt und bunt sind. So individuell wie die Projekte meiner bisherigen Kunden wird auch dein Projekt behandelt. Und auch, wenn du glaubst, dass die Referenzen nicht zu deinem Projekt passen, werden wir deine ganz individuelle Lösung gemeinsam erarbeiten.
**Denn jedes Projekt ist etwas eigenes und besonderes.** **Denn jedes Projekt ist etwas Eigenes und Besonderes.**
<span class="highlight">Auch deins.</span> <Highlight>Auch deins.</Highlight>
:: ::

47
docker-compose.yaml Normal file
View file

@ -0,0 +1,47 @@
version: '3'
services:
wf4:
image: oven/bun:latest
container_name: wf4
working_dir: /app
ports:
- "1337:3000"
volumes:
- wf4_data:/app
environment:
- NODE_ENV=production
command: >
sh -c "
# Install git and curl if not already installed
if ! command -v git &> /dev/null || ! command -v curl &> /dev/null; then
echo 'Installing required packages...'
apt-get update && apt-get install -y git curl
fi &&
# Clone repository if not already cloned
if [ ! -d /app/.git ]; then
echo 'Cloning repository...'
git clone https://git.webfussel.de/webfussel/wf4 /tmp/wf4 &&
cp -r /tmp/wf4/. /app/ &&
rm -rf /tmp/wf4
fi &&
# Install dependencies and start application
echo 'Installing dependencies...' &&
bun install &&
echo 'Building application...' &&
bun run build &&
echo 'Starting application...' &&
bun .output/server/index.mjs
"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:1337"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
volumes:
wf4_data:

1
package-lock.json generated
View file

@ -3723,7 +3723,6 @@
}, },
"node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": {
"version": "1.1.0", "version": "1.1.0",
"dev": true,
"inBundle": true, "inBundle": true,
"license": "MIT" "license": "MIT"
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB