Compare commits

...

13 commits

Author SHA1 Message Date
7ad780be6c Merge branch 'blog' 2025-07-15 13:17:50 +02:00
0a23c88fa0 add: blog
blog post
2025-07-11 18:03:01 +02:00
e46242578f add: blog
blog post
2025-07-11 18:01:06 +02:00
6c68f3afb1 add: og image for robert
OG Image for thumbnail for robert in Blog
2025-07-11 13:32:59 +02:00
0a481fee5e add: first post, external posts, new comps
Add first post for monday, add external posts from other authors, add components for internal and external links
2025-07-11 13:32:59 +02:00
427d9ae276 add: Preparation for external posts
Preparation for external blog posts
2025-07-11 13:32:59 +02:00
d6859cdaad add: SEO for articles
SEO and SchemaORg for articles
2025-07-11 13:32:59 +02:00
f1cb4048a4 add: article view
View for blog articles, with date filter
2025-07-11 13:32:59 +02:00
9abd595b49 add: article view
View for blog articles, with date filter
2025-07-11 13:32:59 +02:00
4104477533 add: article view
View for blog articles
2025-07-11 13:32:59 +02:00
579491f216 add: category, date sorting
Add category layouts and date sorting
2025-07-11 13:32:59 +02:00
9b66a79a8c add: category, date sorting
Add category layouts and date sorting
2025-07-11 13:32:59 +02:00
91b59e4ebe add: basic blog layout
Basic Blog Layout to work on
2025-07-11 13:32:59 +02:00
39 changed files with 5422 additions and 6048 deletions

View file

@ -0,0 +1,62 @@
.BlogArticle {
display: flex;
flex-direction: column;
gap: 2rem;
& .meta {
display: flex;
gap: 1rem;
align-items: center;
}
& .article-content {
background: var(--color-black);
border-radius: 1rem;
}
& .article-text {
padding: 2rem;
}
& header {
padding: 2rem 2rem 0;
}
h1 {
font-size: 2rem;
display: flex;
flex-direction: column;
& small {
font-size: 1.2rem;
font-style: italic;
}
}
h2 {
font-size: 1.5rem;
font-weight: lighter;
margin-top: 2rem;
margin-bottom: 1rem;
}
& .image {
width: 100%;
height: 450px;
border-radius: 1rem 1rem 0 0;
overflow: hidden;
background: #000;
& img {
width: 100%;
height: 100%;
opacity: .8;
object-fit: cover;
object-position: center;
}
}
& p {
margin-bottom: .5rem;
}
}

View file

@ -0,0 +1,32 @@
.BlogAuthor {
display: flex;
align-items: center;
gap: 1rem;
& .meta {
display: flex;
flex-direction: column;
gap: .2rem;
& .name {
font-weight: bold;
}
& .date {
font-size: .8rem;
}
}
& .image {
--size: 50px;
height: var(--size);
width: var(--size);
border-radius: 50%;
overflow: hidden;
& img {
height: 100%;
width: 100%;
object-fit: cover;
}
}
}

View file

@ -0,0 +1,68 @@
.BlogCard {
overflow: hidden;
border-radius: 1rem;
background: var(--color-black);
display: flex;
flex-direction: column;
height: 100%;
transition: 150ms;
gap: 1rem;
&:hover {
scale: 1.05;
}
& > .image {
flex: 0 0 200px;
width: 100%;
overflow: hidden;
& img {
height: 100%;
width: 100%;
object-fit: cover;
}
}
& .card-content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0 1.5rem 1rem;
gap: 1.5rem;
}
& .chip {
margin-bottom: 1rem;
}
& header {
display: flex;
flex-direction: column;
}
& footer {
margin-top: auto;
display: flex;
flex-direction: column;
gap: .5rem;
}
& .tags {
display: flex;
flex-wrap: wrap;
gap: .5rem;
opacity: .5;
& .tag {
color: var(--color-orange);
}
}
}
@media (width <= 780px) {
.BlogCard header {
align-items: center;
}
}

View file

@ -0,0 +1,3 @@
.Excerpt {
font-style: italic;
}

View file

@ -0,0 +1,16 @@
.BlogOverview {
& .category-list {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
list-style: none;
}
& .article-overview {
width: 100%;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
}

View file

@ -97,6 +97,15 @@ a {
color: var(--color-white);
}
a.side {
color: var(--color-orange);
text-decoration: none;
&:hover {
color: var(--color-orange-light);
}
}
a.text {
color: var(--color-orange);
text-decoration: underline;
@ -142,12 +151,22 @@ span.chip {
border-radius: 999px;
font-size: 1rem;
height: max-content;
padding: .5em 1em;
padding: .2em 1em;
user-select: none;
display: flex;
align-items: center;
gap: .5em;
width: max-content;
transition: var(--transition-time);
&.interactive {
cursor: pointer;
&:hover {
background: var(--color-orange-dark);
color: var(--color-white);
}
}
&:not(.dark) {
--background: var(--color-orange);
@ -197,6 +216,10 @@ span.chip {
margin-top: 1rem;
}
.margin-top-middle {
margin-top: 2rem;
}
.margin-top-big {
margin-top: 6rem;
}
@ -206,7 +229,7 @@ span.chip {
}
.bg-radial {
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-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;
@ -241,6 +264,10 @@ span.chip {
flex-direction: column;
}
.flex-wrap {
flex-wrap: wrap;
}
.gap-default {
gap: 3rem;
}
@ -319,7 +346,7 @@ span.chip {
}
@media (width <= 780px) {
h1, h2, h3, h4, h5, h6, p {
h1, h2, h3, h4, h5, h6, p, small {
text-align: center;
}
}

View file

@ -0,0 +1,49 @@
<template>
<aside class="BlogAuthor">
<div class="image">
<img
loading="lazy"
width="50"
height="50"
:srcset="imageSet"
:src="initialImage"
aria-hidden="true"
:alt="`Profilbild von ${name}`"
/>
</div>
<div class="meta">
<span class="name">{{ name }}</span>
<span class="date">{{ dateFormatted }}</span>
</div>
</aside>
</template>
<script setup lang="ts">
import { getImageSet, getInitialImage } from '../../utils/image'
type Props = {
name: string
date: string
img?: string
}
const { name, date, img } = defineProps<Props>()
const formatter = new Intl.DateTimeFormat('de-DE', {
year: 'numeric',
month: 'long',
day: '2-digit',
})
const dateFormatted = computed(() => formatter.format(new Date(date)))
const imageSet = computed(() => {
if (img) return img
return getImageSet('/img/blog/authors/', name).join(', ')
})
const initialImage = computed(() => {
if (img) return img
return getInitialImage('/img/blog/authors/', name)
})
</script>

View file

@ -0,0 +1,29 @@
<template>
<NuxtLink :to="link" :external="isExternal" :target="isExternal ? '_blank' : '_self'" class="BlogCard z-2">
<div class="image">
<img :src="image" alt=" " aria-hidden="true"/>
</div>
<div class="card-content">
<header>
<span class="chip"><BlogCategory :name="category"/></span>
<h2>{{ title }}</h2>
</header>
<main>
<p>
{{ description }}
</p>
</main>
<footer>
<BlogAuthor :name="author.name" :img="author.img" :date="date"/>
</footer>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
import type { BlogCard } from './types'
const { link } = defineProps<BlogCard>()
const isExternal = computed(() => link.startsWith('http'))
</script>

View file

@ -0,0 +1,25 @@
<template>
<Icon :name="icon" mode="svg"/>
{{ name }}
</template>
<script setup lang="ts">
import type { Category } from './types'
type Props = {
name: Category
}
const { name } = defineProps<Props>()
const icons: Record<Category, string> = {
'story': 'ph:chat-circle-dots-duotone',
'snippet': 'ph:code-duotone',
'tutorial': 'ph:lightbulb-duotone',
'news': 'ph:newspaper-duotone',
'freelancing': 'ph:laptop-duotone',
'extern': 'ph:repeat-duotone',
}
const icon = computed(() => icons[name] ?? 'ph:question-mark-duotone')
</script>

View file

@ -0,0 +1,9 @@
<template>
<p class="Excerpt">
<slot/>
</p>
</template>
<script setup lang="ts">
</script>

View file

@ -0,0 +1,14 @@
export type Category = 'story' | 'snippet' | 'tutorial' | 'news' | 'freelancing' | 'extern'
export type BlogCard = {
title: string
description: string
image: string
date: string
link: string
category: Category
author: {
name: string
img?: string
}
}

View file

@ -8,7 +8,7 @@
<script setup lang="ts">
type Props = {
title : string
titleTag ?: 'strong' | 'h3'
titleTag ?: 'strong' | 'h3' | 'h2'
}
defineProps<Props>()

View file

@ -0,0 +1,14 @@
<template>
<NuxtLink class="inline-flex-row text" external :to="url" target="_blank">
<slot/>
<IconLinkExternal/>
</NuxtLink>
</template>
<script setup lang="ts">
type Props = {
url: string
}
defineProps<Props>()
</script>

View file

@ -0,0 +1,14 @@
<template>
<NuxtLink class="inline-flex-row text" :to="url">
<slot/>
<IconLinkInternal/>
</NuxtLink>
</template>
<script setup lang="ts">
type Props = {
url: string
}
defineProps<Props>()
</script>

View file

@ -6,7 +6,9 @@
<span class="my-name-wrapper">Ich bin <Highlight>Fiona</Highlight>.</span>
</h1>
<h2>
Component <Highlight>&</Highlight> API Entwicklerin
Component
<Highlight>&</Highlight>
API Entwicklerin
</h2>
<p class="fulltext">
Ich unterstütze Unternehmen dabei, ihre Daten von verschiedenen Endpunkten sauber aufzubereiten
@ -21,14 +23,14 @@
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" />
<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" />
<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>

20
app/error.vue Normal file
View file

@ -0,0 +1,20 @@
<template>
<section class="bg-radial">
<NuxtLayout>
<section class="content">
<h1>{{ error?.statusCode }}</h1>
<p>{{ message }}</p>
</section>
</NuxtLayout>
</section>
</template>
<script setup lang="ts">
import type { NuxtError } from 'nuxt/app'
type Props = {
error: NuxtError
}
const { error } = defineProps<Props>()
const message = getErrorMessage(error)
</script>

58
app/pages/blog/[slug].vue Normal file
View file

@ -0,0 +1,58 @@
<template>
<section class="BlogArticle content">
<NuxtLink class="text inline-flex-row" to="/blog">
<Icon name="ph:arrow-left-duotone"/>
Zurück zur Übersicht
</NuxtLink>
<main v-if="article" class="article-content z-3">
<div class="image z-2">
<img :src="article.image" alt="Artikelbild" aria-hidden="true"/>
</div>
<header>
<div class="meta">
<NuxtLink :to="`/blog/?category=${article.category}`">
<span class="chip interactive"><BlogCategory :name="article.category as Category"/></span>
</NuxtLink>
</div>
<h1 class="margin-top">
<span>{{ article.title }}</span>
<small>{{ article.description }}</small>
</h1>
</header>
<div class="flex-col article-text">
<ContentRenderer v-if="article" :value="article" :style="{ display: 'contents' }"/>
</div>
</main>
</section>
</template>
<script setup lang="ts">
import type { Category } from '../../components/Blog/types'
const route = useRoute()
const { data: article } = await useAsyncData('article', () => queryCollection('blog').path(route.path).where('date', '<', tomorrow(new Date())).first())
if (!article.value) {
throw createError({
statusCode: 404,
statusMessage: 'Page Not Found',
})
} else {
useHead(article.value.head || {})
useSeoMeta({
...(article.value.seo || {}),
ogTitle: article.value.title,
ogDescription: article.value.description,
ogImage: article.value.image,
ogImageAlt: article.value.description,
ogUrl: `https://webfussel.de${article.value.path}`,
twitterTitle: article.value.title,
twitterDescription: article.value.description,
twitterImage: article.value.image,
twitterImageAlt: article.value.description,
twitterUrl: `https://webfussel.de${article.value.path}`,
})
}
</script>

93
app/pages/blog/index.vue Normal file
View file

@ -0,0 +1,93 @@
<template>
<section id="blog" class="BlogOverview content">
<h1>Blogfussel - für mehr Fussel im Blog</h1>
<p>Hey! Hier sammel ich alles, was mir so durch den Kopf geht - von handfesten Tutorials und Code-Snippets über Freelancing-Stories
bis hin zu News meiner Apps und random Gedanken. Manchmal ausführlich, manchmal nur kurz angerissen.</p>
<main class="margin-top">
<ul class="category-list">
<li>
<NuxtLink class="inline-flex-row gap-sm side" to="/blog">
<span class="chip"
:class="{ 'dark' : route.query.category && Object.keys(allCategoriesAndCount).includes(route.query.category as string)}">Alle {{
articles?.length
}}</span>
</NuxtLink>
</li>
<li v-for="(count, category) in allCategoriesAndCount">
<NuxtLink class="inline-flex-row gap-sm side" :to="`?category=${category}`">
<span class="chip" :class="{ 'dark' : category !== route.query.category}"><BlogCategory :name="category"/> {{ count }}</span>
</NuxtLink>
</li>
</ul>
<div class="grid margin-top-middle article-overview">
<BlogCard v-for="article in allPosts" v-bind="article"/>
<div v-if="allPosts.length < 2"/>
<div v-if="allPosts.length < 3"/>
</div>
</main>
</section>
</template>
<script setup lang="ts">
import type { BlogCollectionItem } from '@nuxt/content'
import type { BlogCard, Category } from '../../components/Blog/types'
const route = useRoute()
const { data: articles } = await useAsyncData('articles', () => queryCollection('blog')
.where('date', '<', tomorrow(new Date()))
.order('date', 'DESC')
.all(),
)
const firstTen = computed<BlogCard[]>(() => {
if (route.query.category && Object.keys(allCategoriesAndCount.value).includes(route.query.category as Category)) {
return (articles.value?.filter(article => article.category === route.query.category).slice(0, 10) ?? []).map(makeBlogCard)
}
return (articles.value?.slice(0, 10) ?? []).map(makeBlogCard)
})
const allCategoriesAndCount = computed<Record<Category, number>>(() => {
const categories = {} as Record<Category, number>
articles.value?.forEach(article => {
const category = article.category as Category
if (category) {
categories[category] = (categories[category] ?? 0) + 1
}
})
return categories
})
const { data: externalPostsRaw } = useFetch('/api/external-posts', { method: 'POST' })
const externalPosts = computed<BlogCard[]>(() => externalPostsRaw.value?.flatMap(externalBlog => {
return externalBlog.posts.map(post => ({
title: post.title,
description: post.excerpt,
image: post['og-image'],
date: post.date,
link: post.url,
category: 'extern',
author: externalBlog.author,
}))
}) ?? [])
const allPosts = computed<BlogCard[]>(() => [...(firstTen.value ?? []), ...(externalPosts.value ?? [])].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()))
const makeBlogCard = (article: BlogCollectionItem): BlogCard => ({
title: article.title,
description: article.description,
image: article.thumbnail as string,
date: article.date as string,
link: article.path,
category: article.category as Category,
author: article.author as { name: string, image: string },
})
useHead({
link: [
{ rel: 'alternate', type: 'application/rss+xml', href: '/blog/rss.xml', title: 'blogfussel' },
],
})
</script>

4
app/utils/date.ts Normal file
View file

@ -0,0 +1,4 @@
export const tomorrow = (date: Date) => {
date.setDate(date.getDate() + 1)
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}-${date.getDate()}`
}

15
app/utils/error.ts Normal file
View file

@ -0,0 +1,15 @@
import type { NuxtError } from 'nuxt/app'
const codes: Record<number, string> = {
400: 'Hey, deine Anfrage war irgendwie kaputt. Check nochmal!',
404: 'Sorry bro, aber diese Seite existiert einfach nicht.',
408: 'Alter, das hat viel zu lange gedauert. Timeout!',
410: 'Diese Seite ist für immer weg. Gone!',
500: 'Crap, da ging was im Hintergrund schief. Sorry!',
501: 'Das haben wir noch nicht implementiert. Oops!',
502: 'Der Server dahinter spinnt gerade rum.',
503: 'Service ist gerade down. Versuch es später nochmal.',
504: 'Gateway Timeout - da hängt was fest.',
}
export const getErrorMessage = (error: NuxtError) => codes[error.statusCode] ?? 'Sorry, da ist etwas schief gelaufen.'

View file

@ -23,4 +23,10 @@ export const navigation = [
icon: 'ph:chats-circle-duotone',
aria: 'Link dieser Seite: Kontakt'
},
{
to: `/blog/`,
label: 'Blog',
icon: 'ph:book-open-user-duotone',
aria: 'Link dieser Seite: Blog'
},
]

2489
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,22 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
import { asSchemaOrgCollection } from 'nuxt-schema-org/content'
export default defineContentConfig({
collections: {
blog: defineCollection({
blog: defineCollection(asSchemaOrgCollection({
type: 'page',
source: 'blog/**/*.md'
}),
source: 'blog/*.md',
schema: z.object({
date: z.string(),
image: z.string(),
thumbnail: z.string(),
category: z.string(),
author: z.object({
name: z.string(),
image: z.string(),
}),
}),
})),
skills: defineCollection({
type: 'page',
@ -17,7 +28,7 @@ export default defineContentConfig({
source: 'snippets/faq/*.md',
schema: z.object({
rawbody: z.string(),
})
})
}
}),
}),
},
})

View file

@ -0,0 +1,83 @@
---
image: '/img/blog/posts/0000.start.webp'
thumbnail: '/img/blog/posts/0000.start_thumb.webp'
title: 'Blogfussel - schon wieder'
description: 'Warum ich mich dazu entschlossen habe, jetzt doch wieder mit dem Bloggen anzufangen? Da muss ich etwas ausholen.'
navigation: true
date: 2025-07-11
category: 'story'
author:
name: 'webfussel'
image: '/img/blog/authors/webfussel@3x.webp'
schemaOrg:
type: 'BlogPosting'
headline: 'Warum es wieder einen blogfussel gibt'
author:
type: 'Person'
name: 'Fiona Urban aka webfussel'
datePublished: '2025-07-11'
---
Ich habe schon häufig versucht zu bloggen, bin aber leider am Ende nie so richtig reingekommen. Trotz allem habe ich jetzt große Hoffnung, dass es
diesmal funktionieren wird.
Warum ist auch ganz einfach erklärt: Ich Microblogge theoretisch schon auf Social Media. Seit Jahren. Und es funktioniert ganz gut.
Auf LinkedIn, Mastodon, Bluesky, Instagram... Instagram? Naja, da leider nicht mehr.
Eigentlich wollte ich dort Lerncontent zur WebEntwicklung posten. Kleine Tutorials für Einsteiger, Code Snippets und all sowas.
LinkedIn ist bei mir ja bekanntlich für ShitPosts vorgesehen.
Aber ja...
## Warum ich nicht mehr auf Instagram poste
Ich würde ja jetzt gerne irgendwas labern und behaupten "Weil Meta ne böse Firma ist!" (was sie sind) oder "Weil diese Oberflächlichkeit von Insta
mich ankotzt!" (was sie tut).
Aber der Grund ist noch viel einfacher:
Ich wurde gesperrt. Quasi grundlos. Aus dem Nichts heraus.
Mir wird vorgeworfen, ich hätte ein Fakeprofil betrieben und selbst nach einem Selfie und WhatsApp Verifizierung dachten die Fische "Joa, wir denken
weiterhin dass du Fake bist.".
Das wäre ja gar nich so schlimm - wenn Threads nicht so eine fantastische Plattform für Reichweite wäre.
## Threads? *würg*
Ja, versteh ich. Seh ich größtenteils auch so. Aber ich habe ein Ziel:
Ich möchte gerne Inhalte für jeden Menschen so gut es geht zugänglich machen. Und das geht einfach besser, wenn man eine große Reichweite hat.
Zusätzlich möchte ich gerne mehr (Web)Apps entwickeln - kostenlos, ohne Tracker, ohne Werbung, ohne Bullshit - und diese auch gut an die Leute bringen
können.
Und das geht wie sehr gut? Genau. Mit Reichweite.
Ich weiß, dass Reichweite für viele etwas ist, auf das sie sich einen schrubben und das sie für ihr Ego brauchen.
Aber mir ist das egal - ich will einfach nur gute Software an die Leute bringen so gut es geht.
Mir ist bewusst, dass das irgendwie auch so funktionieren wird, nur halt langsamer.
Es wäre aber dennoch schön, wenn sich statt Narzissmus auch mal Altruismus schnell verteilen würde.
## Und darum wieder der Blog
Dabei ist mir dann mal wieder aufgefallen, wie unfassbar scheiße es ist sich auf externe Anbieter verlassen zu müssen. Man kann jederzeit grundlos
geblockt werden.
Und auf einmal ist alles von dort einfach weg. Von jetzt auf gleich.
Mit einem eigenen Blog passiert mir das nicht. Da hab ich alles hier in meinem GIT Repo gespeichert als MD Dateien und kann das im größten Notfall
einfach woanders hosten und alles ist wieder da.
So wie das Web früher halt auch war: Da hatten wir alle unsere kleinen Ecken in denen wir uns einrichten konnten, wie wir wollten.
Klar ist das nicht kostenlos. Mein vServer + diese Domain kosten mich sage und schreibe 6 € im Monat.
Und natürlich braucht man für so etwas auch ein Stück weit technische Kenntnisse. Aber meines Erachtens lohnt es sich.
## Was zukünftig kommt
Hier werden euch typische Blogigkeiten erwarten:
Infoposts, Stories aus dem Leben, Freelancing, etc.
Auf lange Sicht mag ich auch große Tutorials schreiben.
Aber ich sag euch gleich: Die, für die ich mir dann wirklich viel Zeit nehme, werde ich monetarisieren.
Dafür werde ich später auf <LinkExternal url="https://www.patreon.com/">Patreon</LinkExternal>
und <LinkExternal url="https://steady.page/de/">Steady</LinkExternal> zurückgreifen, da die eine API bieten, mit der ich
abchecken kann, ob jemand Abonnent ist.
Natürlich wird das dann nicht so teuer. Will euch ja nicht abziehen. Aber als Freelancerin mag ich auch ein bisschen die Arbeit vergütet bekommen, die
ich erbringe.
Ihr würdet ja auch nicht stundenlang kostenlos arbeiten wollen.
Die meisten Dinge hier werden aber frei bleiben.

View file

@ -4,14 +4,20 @@ title: "Da, wo du eintragen kannst"
image:
path: "/img/explanations/"
name: "cms"
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 <Highlight>CMS</Highlight> ein <Highlight>C</Highlight>ontent <Highlight>M</Highlight>anagement <Highlight>S</Highlight>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 <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.
Für CMS setze ich in erster Linie auf die cloudbasierte Lösung <LinkExternal url="https://www.storyblok.com">StoryBlok</LinkExternal>. 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 <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.
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 <LinkExternal url="https://strapi.io">Strapi</LinkExternal> 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.**
<Highlight>Mit Fussel-Garantie.</Highlight>

View file

@ -11,7 +11,7 @@ Grundsätzlich lässt sich das ganz einfach zusammenfassen: <Highlight>Dein pers
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 <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.
Erkunde einfach meine <LinkInternal url="references/">Referenzen</LinkInternal> 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.**
<Highlight>Auch deins.</Highlight>

View file

@ -22,8 +22,8 @@ export default defineNuxtConfig({
'/flatrate',
'/references',
'/contact',
]
}
],
},
},
css: [
@ -43,6 +43,11 @@ export default defineNuxtConfig({
'~/assets/css/burger.css',
'~/assets/css/teaser.css',
'~/assets/css/project.css',
'~/assets/css/blog/card.css',
'~/assets/css/blog/author.css',
'~/assets/css/blog/overview.css',
'~/assets/css/blog/article.css',
'~/assets/css/blog/excerpt.css',
],
postcss: {
@ -67,17 +72,23 @@ export default defineNuxtConfig({
},
head: {
htmlAttrs: { lang: 'de' },
}
},
},
modules: ['@nuxt/icon', '@nuxt/fonts', '@vueuse/nuxt', '@nuxtjs/seo', '@nuxt/content'],
modules: [
'@nuxt/icon',
'@nuxt/fonts',
'@vueuse/nuxt',
'@nuxtjs/seo',
'@nuxt/content',
],
icon: {
customCollections: [
{
prefix: 'wf',
dir: './app/assets/icons'
}
dir: './app/assets/icons',
},
],
provider: 'iconify',
serverBundle: 'local',
@ -92,5 +103,5 @@ export default defineNuxtConfig({
trailingSlash: true,
},
compatibilityDate: '2024-12-04'
compatibilityDate: '2024-12-04',
})

8126
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,9 +9,7 @@
"generate": "nuxt generate",
"preview": "nuxt preview",
"gen:prev": "nuxt generate && nuxt preview",
"postinstall": "nuxt prepare",
"generate:deploy": "nuxt generate && firebase deploy --only hosting",
"build:deploy": "nuxt build && firebase deploy --only hosting"
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@iconify-json/material-symbols": "^1.2.14",
@ -26,6 +24,10 @@
"vue": "^3.5.15"
},
"dependencies": {
"@nuxt/content": "^3.5.1"
}
"@nuxt/content": "^3.5.1",
"better-sqlite3": "^12.2.0"
},
"trustedDependencies": [
"@parcel/watcher"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

View file

@ -0,0 +1,14 @@
import type { ExternalPost } from '../../shared/types/Blog'
export default defineEventHandler(async (): Promise<ExternalPost[]> => {
// Robert
const webertoireResponse: ExternalPost = await (await fetch('https://robertjanus.de/blog.json')).json()
const webertoireCut = webertoireResponse.posts.slice(0, 3).filter(filterBeforeFirstPost)
return [
{
author: webertoireResponse.author,
posts: webertoireCut,
},
]
})

View file

@ -0,0 +1,36 @@
const simpleDate = (date: Date) => {
date.setDate(date.getDate() + 1)
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}-${`${date.getDate()}`.padStart(2, '0')}`
}
const author = (baseUrl: string) => ({
name: 'webfussel',
img: `${baseUrl}/img/blog/authors/webfussel@3x.webp`,
img1x: `${baseUrl}/img/blog/authors/webfussel@1x.webp`,
img2x: `${baseUrl}/img/blog/authors/webfussel@2x.webp`,
img3x: `${baseUrl}/img/blog/authors/webfussel@3x.webp`,
})
export default defineEventHandler(async event => {
const url = getRequestURL(event)
const baseUrl = `${url.protocol}//${url.host}`
const articles = await queryCollection(event, 'blog')
.where('date', '<', simpleDate(new Date()))
.order('date', 'DESC')
.all()
const entries = articles.map(article => ({
url: `${baseUrl}${article.path}`,
title: article.title,
excerpt: article.description,
date: article.date,
thumbnail: `${baseUrl}${article.thumbnail}`,
category: article.category,
}))
return {
author: author(baseUrl),
posts: entries,
}
})

View file

@ -0,0 +1,47 @@
import { BlogCollectionItem } from '@nuxt/content'
const clean = (value: string) => value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/ä/g, '&auml;')
.replace(/ö/g, '&ouml;')
.replace(/ü/g, '&uuml;')
.replace(/ß/g, '&szlig;')
const makeItem = (article: BlogCollectionItem, base: string) => `
<item>
<title>${clean(article.title)}</title>
<description>${clean(article.description)}</description>
<link>${base}${article.path}</link>
</item>
`
const simpleDate = (date: Date) => {
date.setDate(date.getDate() + 1)
return `${date.getFullYear()}-${`${date.getMonth() + 1}`.padStart(2, '0')}-${`${date.getDate()}`.padStart(2, '0')}`
}
export default defineEventHandler(async event => {
const baseUrl = getRequestURL(event)
const blogUrl = `${baseUrl.protocol}//${baseUrl.host}`
const articles = await queryCollection(event, 'blog')
.where('date', '<', simpleDate(new Date()))
.order('date', 'DESC')
.all()
const xmlItems = articles.map(article => makeItem(article, blogUrl)).join('')
return `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Blogfussel</title>
<link>${baseUrl}/blog</link>
<description>f&uuml;r mehr Fussel im Blog</description>
<language>de</language>
${xmlItems}
</channel>
</rss>
`
})

3
server/utils/filter.ts Normal file
View file

@ -0,0 +1,3 @@
import type { Post } from '../../shared/types/Blog'
export const filterBeforeFirstPost = (post: Post) => new Date(post.date) > new Date('2025-07-14')

20
shared/types/Blog.ts Normal file
View file

@ -0,0 +1,20 @@
export type Post = {
url: string
title: string
excerpt: string
date: string
'og-image': string
}
type Author = {
name: string
img: string
img1x: string
img2x: string
img3x: string
}
export type ExternalPost = {
author: Author
posts: Post[]
}