openpanel integration und entwurf blog

This commit is contained in:
2026-04-03 17:05:16 +02:00
parent 5138005397
commit cd694d0776
45 changed files with 2558 additions and 310 deletions

View File

@@ -0,0 +1,57 @@
<app-blog-nav [showBack]="true"></app-blog-nav>
<main class="blog-detail">
@if (loading()) {
<div class="blog-detail__skeleton">
<div class="blog-detail__cover-skeleton"></div>
<div class="blog-detail__wrapper">
<div class="skeleton-line skeleton-line--tag"></div>
<div class="skeleton-line skeleton-line--title"></div>
<div class="skeleton-line skeleton-line--meta"></div>
<div class="skeleton-line skeleton-line--body"></div>
<div class="skeleton-line skeleton-line--body"></div>
<div class="skeleton-line skeleton-line--body skeleton-line--short"></div>
</div>
</div>
} @else if (notFound()) {
<div class="blog-detail__wrapper blog-detail__not-found">
<h1>Artikel nicht gefunden</h1>
<p class="text-muted">Dieser Artikel existiert nicht oder wurde entfernt.</p>
</div>
} @else if (post(); as post) {
@if (getCoverUrl(post); as coverUrl) {
<div class="blog-detail__cover">
<img [src]="coverUrl" [alt]="post.title" />
</div>
}
<div class="blog-detail__wrapper">
<header class="blog-detail__header">
@if (post.tags.length > 0) {
<div class="blog-detail__tags">
@for (tag of post.tags; track tag.tags_id.id) {
<span class="blog-detail__tag">{{ tag.tags_id.name }}</span>
}
</div>
}
<h1 class="blog-detail__title">{{ post.title }}</h1>
<time class="blog-detail__date" [dateTime]="post.published_at">
{{ post.published_at | date:'d. MMMM yyyy':'':'de' }}
</time>
<p class="blog-detail__summary">{{ post.summary }}</p>
</header>
<hr class="blog-detail__divider" />
<article class="blog-detail__content" [innerHTML]="parsedContent()"></article>
</div>
}
</main>

View File

@@ -0,0 +1,243 @@
@use 'abstracts';
@keyframes skeleton-shimmer {
from {
background-position: -400px 0;
}
to {
background-position: 400px 0;
}
}
.blog-detail {
min-height: calc(100vh - var(--nav-height));
padding-bottom: calc(var(--space-4) * 3);
&__wrapper {
@include abstracts.container-wrapper;
max-width: 740px;
}
// ── Cover ──────────────────────────────────────────────────────────────
&__cover {
display: flex;
width: 100%;
height: abstracts.em(500);
overflow: hidden;
margin-bottom: calc(var(--space-4) * 1.5);
img {
margin: auto;
width: auto;
height: 100%;
object-fit: cover;
display: block;
}
}
// ── Header ─────────────────────────────────────────────────────────────
&__header {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
&:not(:first-child) {
margin-top: calc(var(--space-4) * 1.5);
}
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__tag {
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
background-color: oklch(from var(--accent) l c h / 0.12);
color: var(--accent);
}
&__title {
font-size: var(--font-size-xl);
font-weight: 700;
line-height: 1.2;
}
&__date {
font-size: 0.85rem;
color: var(--text-muted);
}
&__summary {
font-size: var(--font-size-lg);
color: var(--text-muted);
line-height: 1.6;
font-weight: 400;
}
&__divider {
border: none;
border-top: 1px solid var(--border-color);
margin-block: var(--space-4);
}
// ── Article content (from Directus HTML) ──────────────────────────────
&__content {
font-size: var(--font-size-base);
line-height: 1.75;
color: var(--text-main);
h2 {
font-size: var(--font-size-lg);
font-weight: 700;
margin-top: var(--space-4);
margin-bottom: var(--space-2);
}
h3 {
font-size: var(--font-size-base);
font-weight: 700;
margin-top: var(--space-3);
margin-bottom: var(--space-2);
}
p {
margin-bottom: var(--space-3);
}
a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
&:hover {
color: var(--accent-hover);
}
}
ul,
ol {
padding-left: var(--space-4);
margin-bottom: var(--space-3);
li {
margin-bottom: var(--space-1);
}
}
blockquote {
border-left: 3px solid var(--accent);
padding-left: var(--space-3);
margin-inline: 0;
margin-block: var(--space-3);
color: var(--text-muted);
font-style: italic;
}
code {
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
background-color: var(--bg-muted);
padding: 2px 6px;
border-radius: 4px;
}
pre {
background-color: var(--bg-muted);
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--space-3);
overflow-x: auto;
margin-bottom: var(--space-3);
code {
background: none;
padding: 0;
font-size: 0.875em;
}
}
img {
max-width: 100%;
border-radius: var(--border-radius);
margin-block: var(--space-3);
}
hr {
border: none;
border-top: 1px solid var(--border-color);
margin-block: var(--space-4);
}
}
// ── Not Found ──────────────────────────────────────────────────────────
&__not-found {
padding-top: calc(var(--space-4) * 2);
text-align: center;
h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
}
// ── Skeleton ───────────────────────────────────────────────────────────
&__cover-skeleton {
width: 100%;
height: 380px;
background-color: var(--bg-muted);
margin-bottom: calc(var(--space-4) * 1.5);
}
&__skeleton .blog-detail__wrapper {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
}
.skeleton-line {
border-radius: 4px;
background: linear-gradient(90deg,
var(--bg-muted) 25%,
var(--border-color) 50%,
var(--bg-muted) 75%);
background-size: 800px 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
&--tag {
height: 1.2em;
width: 80px;
}
&--title {
height: 2em;
width: 75%;
margin-bottom: var(--space-1);
}
&--meta {
height: 0.9em;
width: 120px;
}
&--body {
height: 1em;
width: 100%;
}
&--short {
width: 60%;
}
}

View File

@@ -0,0 +1,101 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { DatePipe } from '@angular/common';
import { marked, Renderer } from 'marked';
import { createHighlighter, Highlighter } from 'shiki';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { BlogService } from '@core/services/blog.service';
import { SeoService } from '@core/services/seo.service';
import { OpenPanelService } from '@core/services/openpanel.service';
import { BlogPost } from '@core/models/blog-posts.model';
import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component';
const SHIKI_LANGS = ['html', 'css', 'scss', 'javascript', 'typescript', 'sql', 'python'] as const;
const SHIKI_THEME = 'ayu-light';
@Component({
selector: 'app-blog-detail',
imports: [DatePipe, BlogNavComponent],
templateUrl: './blog-detail.component.html',
styleUrl: './blog-detail.component.scss',
})
export class BlogDetailComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly blogService = inject(BlogService);
private readonly seo = inject(SeoService);
private readonly op = inject(OpenPanelService);
private readonly sanitizer = inject(DomSanitizer);
post = signal<BlogPost | null>(null);
loading = signal(true);
notFound = signal(false);
parsedContent = signal<SafeHtml>('');
private highlighter: Highlighter | null = null;
private async getHighlighter(): Promise<Highlighter> {
if (!this.highlighter) {
this.highlighter = await createHighlighter({
themes: [SHIKI_THEME],
langs: [...SHIKI_LANGS],
});
}
return this.highlighter;
}
private async parseContent(content: string): Promise<SafeHtml> {
const highlighter = await this.getHighlighter();
const loadedLangs = highlighter.getLoadedLanguages();
const renderer = new Renderer();
renderer.code = ({ text, lang }) => {
const language = lang && loadedLangs.includes(lang as any) ? lang : 'text';
return highlighter.codeToHtml(text, { lang: language, theme: SHIKI_THEME });
};
marked.use({ renderer });
const html = await marked(content);
return this.sanitizer.bypassSecurityTrustHtml(html);
}
async ngOnInit(): Promise<void> {
const slug = this.route.snapshot.paramMap.get('slug') ?? '';
this.blogService.getPostBySlug(slug).subscribe({
next: async (post) => {
if (!post) {
this.notFound.set(true);
this.loading.set(false);
return;
}
this.post.set(post);
if (post.content) {
this.parsedContent.set(await this.parseContent(post.content));
}
this.loading.set(false);
this.seo.updateMetadata({
title: post.title,
description: post.summary,
image: post.cover_image
? this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 })
: undefined,
type: 'article',
});
this.op.track('blog_post_view', { slug, title: post.title });
},
error: () => {
this.notFound.set(true);
this.loading.set(false);
},
});
}
getCoverUrl(post: BlogPost): string | null {
if (!post.cover_image) return null;
return this.blogService.getAssetUrl(post.cover_image, { width: 1200, quality: 85 });
}
}

View File

@@ -0,0 +1,70 @@
<app-blog-nav></app-blog-nav>
<main class="blog-list">
<div class="blog-list__wrapper">
<header class="blog-list__header">
<h1>Blog</h1>
<p class="text-muted">Einblicke, Tipps und Hintergründe rund um Webdesign &amp; digitale Präsenz.</p>
</header>
@if (loading()) {
<div class="blog-list__grid">
@for (_ of [1, 2, 3]; track $index) {
<div class="blog-card blog-card--skeleton">
<div class="blog-card__image blog-card__image--skeleton"></div>
<div class="blog-card__body">
<div class="skeleton-line skeleton-line--title"></div>
<div class="skeleton-line skeleton-line--text"></div>
<div class="skeleton-line skeleton-line--text skeleton-line--short"></div>
</div>
</div>
}
</div>
} @else if (error()) {
<div class="blog-list__error">
<p>{{ error() }}</p>
</div>
} @else if (posts().length === 0) {
<div class="blog-list__empty">
<p>Noch keine Artikel veröffentlicht schau bald wieder vorbei.</p>
</div>
} @else {
<div class="blog-list__grid">
@for (post of posts(); track post.id) {
<a
class="blog-card"
[routerLink]="['/blog', post.slug]"
opTrack="blog_post_click"
[opTrackProps]="{ slug: post.slug, title: post.title }">
<div class="blog-card__image">
@if (getCoverUrl(post); as coverUrl) {
<img [src]="coverUrl" [alt]="post.title" loading="lazy" />
} @else {
<div class="blog-card__image-placeholder"></div>
}
</div>
<div class="blog-card__body">
@if (post.tags.length > 0) {
<div class="blog-card__tags">
@for (tag of post.tags; track tag.tags_id.id) {
<span class="blog-card__tag">{{ tag.tags_id.name }}</span>
}
</div>
}
<h2 class="blog-card__title">{{ post.title }}</h2>
<p class="blog-card__summary">{{ post.summary }}</p>
<time class="blog-card__date" [dateTime]="post.published_at">
{{ post.published_at | date:'d. MMMM yyyy':'':'de' }}
</time>
</div>
</a>
}
</div>
}
</div>
</main>

View File

@@ -0,0 +1,183 @@
@use 'abstracts';
@keyframes skeleton-shimmer {
from { background-position: -400px 0; }
to { background-position: 400px 0; }
}
.blog-list {
min-height: calc(100vh - var(--nav-height));
padding-block: calc(var(--space-4) * 2);
&__wrapper {
@include abstracts.container-wrapper;
}
&__header {
text-align: center;
margin-bottom: calc(var(--space-4) * 1.5);
h1 {
font-size: var(--font-size-xl);
margin-bottom: var(--space-2);
}
p {
font-size: var(--font-size-lg);
}
}
&__grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3);
@include abstracts.breakpoint('md') {
grid-template-columns: repeat(2, 1fr);
}
@include abstracts.breakpoint('lg') {
grid-template-columns: repeat(3, 1fr);
}
}
&__error,
&__empty {
text-align: center;
padding: var(--space-4);
color: var(--text-muted);
font-size: var(--font-size-lg);
border: 1px dashed var(--border-color);
border-radius: var(--border-radius);
}
}
// ── Blog Card ────────────────────────────────────────────────────────────────
.blog-card {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
overflow: hidden;
background-color: var(--bg-surface);
color: var(--text-main);
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
box-shadow 0.22s ease,
border-color 0.22s ease;
&:hover {
transform: translateY(-4px);
border-color: var(--accent);
box-shadow: 0 8px 24px oklch(0% 0 0 / 0.07);
}
&__image {
aspect-ratio: 16 / 9;
overflow: hidden;
background-color: var(--bg-muted);
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: transform 0.4s ease;
.blog-card:hover & {
transform: scale(1.04);
}
}
}
&__image-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--bg-muted) 0%, var(--border-color) 100%);
}
&__body {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
flex: 1;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
}
&__tag {
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
background-color: oklch(from var(--accent) l c h / 0.12);
color: var(--accent);
}
&__title {
font-size: var(--font-size-lg);
font-weight: 700;
line-height: 1.25;
}
&__summary {
font-size: var(--font-size-base);
color: var(--text-muted);
line-height: 1.6;
flex: 1;
// Clamp to 3 lines
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
&__date {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: auto;
}
// ── Skeleton ────────────────────────────────────────────────────────────
&--skeleton {
pointer-events: none;
.blog-card__image--skeleton {
background-color: var(--bg-muted);
}
}
}
.skeleton-line {
height: 1em;
border-radius: 4px;
background: linear-gradient(
90deg,
var(--bg-muted) 25%,
var(--border-color) 50%,
var(--bg-muted) 75%
);
background-size: 800px 100%;
animation: skeleton-shimmer 1.4s ease-in-out infinite;
&--title {
height: 1.4em;
width: 80%;
margin-bottom: var(--space-1);
}
&--text {
width: 100%;
}
&--short {
width: 50%;
}
}

View File

@@ -0,0 +1,51 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RouterModule } from '@angular/router';
import { BlogService } from '@core/services/blog.service';
import { SeoService } from '@core/services/seo.service';
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
import { BlogPost } from '@core/models/blog-posts.model';
import { BlogNavComponent } from '../../components/blog-nav/blog-nav.component';
@Component({
selector: 'app-blog-list',
imports: [RouterModule, DatePipe, OpenPanelTrackDirective, BlogNavComponent],
templateUrl: './blog-list.component.html',
styleUrl: './blog-list.component.scss',
})
export class BlogListComponent implements OnInit {
private readonly blogService = inject(BlogService);
private readonly seo = inject(SeoService);
posts = signal<BlogPost[]>([]);
loading = signal(true);
error = signal<string | null>(null);
ngOnInit(): void {
this.seo.updateMetadata({
title: 'Blog Webdesign-Tipps & Einblicke',
description: 'Artikel rund um Webdesign, Performance und digitale Präsenz für Handwerk und Vereine.',
type: 'website',
});
this.blogService.getPosts().subscribe({
next: (posts) => {
this.posts.set(posts);
this.loading.set(false);
},
error: () => {
this.error.set('Die Artikel konnten nicht geladen werden. Bitte versuche es später erneut.');
this.loading.set(false);
},
});
}
getCoverUrl(post: BlogPost): string | null {
if (!post.cover_image) return null;
return this.blogService.getAssetUrl(post.cover_image, { width: 640, quality: 80 });
}
formatDate(dateStr: string): string {
return dateStr;
}
}