openpanel integration und entwurf blog
This commit is contained in:
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
101
src/app/features/blog/pages/blog-detail/blog-detail.component.ts
Normal file
101
src/app/features/blog/pages/blog-detail/blog-detail.component.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 & 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>
|
||||
183
src/app/features/blog/pages/blog-list/blog-list.component.scss
Normal file
183
src/app/features/blog/pages/blog-list/blog-list.component.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
51
src/app/features/blog/pages/blog-list/blog-list.component.ts
Normal file
51
src/app/features/blog/pages/blog-list/blog-list.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user