openpanel integration und entwurf blog
This commit is contained in:
780
package-lock.json
generated
780
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -34,9 +34,11 @@
|
||||
"@angular/ssr": "^21.0.3",
|
||||
"@ng-icons/core": "^33.1.0",
|
||||
"@ng-icons/css.gg": "^33.1.0",
|
||||
"@openpanel/web": "^1.2.0",
|
||||
"@openpanel/web": "^1.3.0",
|
||||
"express": "^5.1.0",
|
||||
"marked": "^17.0.5",
|
||||
"rxjs": "~7.8.0",
|
||||
"shiki": "^4.0.2",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -44,6 +46,7 @@
|
||||
"@angular/cli": "^21.2.3",
|
||||
"@angular/compiler-cli": "^21.2.5",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/marked": "^5.0.2",
|
||||
"@types/node": "^20.19.37",
|
||||
"jsdom": "^27.1.0",
|
||||
"typescript": "~5.9.2",
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, LOCALE_ID } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||
import { provideNgIconsConfig } from '@ng-icons/core';
|
||||
import { environment } from '../environments/openpanel';
|
||||
import { provideOpenPanel } from '@core/provider/openpanel.provider';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
|
||||
registerLocaleData(localeDe);
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
{ provide: LOCALE_ID, useValue: 'de' },
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideRouter(routes),
|
||||
provideHttpClient(withFetch()),
|
||||
provideClientHydration(withEventReplay()),
|
||||
provideNgIconsConfig({}),
|
||||
provideOpenPanel({
|
||||
|
||||
@@ -2,7 +2,18 @@ import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent)
|
||||
path: '',
|
||||
loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent),
|
||||
data: { trackName: 'Home' }
|
||||
},
|
||||
{
|
||||
path: 'blog',
|
||||
loadComponent: () => import('@features/blog').then(m => m.BlogListComponent),
|
||||
data: { trackName: 'Blog' }
|
||||
},
|
||||
{
|
||||
path: 'blog/:slug',
|
||||
loadComponent: () => import('@features/blog').then(m => m.BlogDetailComponent),
|
||||
data: { trackName: 'BlogDetail' }
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, signal, OnInit } from '@angular/core';
|
||||
import { Component, signal, inject } from '@angular/core';
|
||||
import { RouterOutlet, Router, NavigationEnd } from '@angular/router';
|
||||
import {provideIcons} from "@ng-icons/core";
|
||||
import { filter } from 'rxjs/operators';
|
||||
import {cssMenu} from "@ng-icons/css.gg";
|
||||
import { UmamiService } from '@core/services/umami.service';
|
||||
import { OpenPanelService } from '@core/services/openpanel.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -12,19 +12,17 @@ import { UmamiService } from '@core/services/umami.service';
|
||||
styleUrl: './app.scss',
|
||||
viewProviders: [provideIcons({cssMenu})]
|
||||
})
|
||||
export class App implements OnInit {
|
||||
export class App {
|
||||
protected readonly title = signal('hurler-webdesign-saas');
|
||||
private readonly router = inject(Router);
|
||||
private readonly opService = inject(OpenPanelService);
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private umami: UmamiService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
constructor() {
|
||||
// Optional: Manuelles Tracking von Seitenaufrufen, falls nicht automatisch in OpenPanelService konfiguriert
|
||||
this.router.events.pipe(
|
||||
filter(event => event instanceof NavigationEnd)
|
||||
).subscribe(() => {
|
||||
this.umami.trackPageview();
|
||||
).subscribe((event) => {
|
||||
this.opService.trackScreenView((event as NavigationEnd).urlAfterRedirects);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
24
src/app/core/models/blog-posts.model.ts
Normal file
24
src/app/core/models/blog-posts.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/app/blog/models/blog-post.model.ts
|
||||
|
||||
export interface Tag {
|
||||
tags_id: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
cover_image: string | null;
|
||||
published_at: string;
|
||||
status: 'Entwurf' | 'Veröffentlicht' | 'Archiviert';
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export interface DirectusResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
16
src/app/core/services/blog.service.spec.ts
Normal file
16
src/app/core/services/blog.service.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BlogService } from './blog.service';
|
||||
|
||||
describe('BlogService', () => {
|
||||
let service: BlogService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(BlogService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
51
src/app/core/services/blog.service.ts
Normal file
51
src/app/core/services/blog.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// src/app/blog/services/blog.service.ts
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, map } from 'rxjs';
|
||||
import { environment } from '../../../environments/environment.directus';
|
||||
import { BlogPost, DirectusResponse } from '@core/models/blog-posts.model';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BlogService {
|
||||
private http = inject(HttpClient);
|
||||
private baseUrl = environment.directusUrl;
|
||||
|
||||
private readonly defaultFields = [
|
||||
'id',
|
||||
'title',
|
||||
'slug',
|
||||
'summary',
|
||||
'cover_image',
|
||||
'published_at',
|
||||
'tags.tags_id.id',
|
||||
'tags.tags_id.name'
|
||||
].join(',');
|
||||
|
||||
getPosts(): Observable<BlogPost[]> {
|
||||
const params = new HttpParams()
|
||||
.set('sort', '-published_at')
|
||||
.set('fields', this.defaultFields);
|
||||
|
||||
return this.http
|
||||
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
|
||||
.pipe(map(res => res.data));
|
||||
}
|
||||
|
||||
getPostBySlug(slug: string): Observable<BlogPost | null> {
|
||||
const params = new HttpParams()
|
||||
.set('filter', JSON.stringify({ slug: { _eq: slug }, status: { _eq: 'published' } }))
|
||||
.set('fields', `${this.defaultFields},content`);
|
||||
|
||||
return this.http
|
||||
.get<DirectusResponse<BlogPost[]>>(`${this.baseUrl}/items/blog_posts`, { params })
|
||||
.pipe(map(res => res.data[0] ?? null));
|
||||
}
|
||||
|
||||
getAssetUrl(assetId: string, params?: { width?: number; height?: number; quality?: number }): string {
|
||||
const url = new URL(`${this.baseUrl}/assets/${assetId}`);
|
||||
if (params?.width) url.searchParams.set('width', String(params.width));
|
||||
if (params?.height) url.searchParams.set('height', String(params.height));
|
||||
if (params?.quality) url.searchParams.set('quality', String(params.quality));
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,15 @@ export class NavigationService {
|
||||
{ label: 'Features', type: 'anchor', target: 'features-section'},
|
||||
{ label: 'Projekte', type: 'anchor', target: 'projects' },
|
||||
{ label: 'Pricing', type: 'anchor', target: 'pricing' },
|
||||
|
||||
// Route-Links ( andere Pages)
|
||||
|
||||
// Route-Links
|
||||
{ label: 'Blog', type: 'route', target: '/blog' },
|
||||
{ label: 'Login', type: 'route', target: '/login' },
|
||||
{
|
||||
label: 'Dashboard',
|
||||
type: 'route',
|
||||
target: '/dashboard',
|
||||
{
|
||||
label: 'Dashboard',
|
||||
type: 'route',
|
||||
target: '/dashboard',
|
||||
icon: 'layout',
|
||||
// Geschützte Route - wird später gefiltert
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -30,9 +30,9 @@ export class NavigationService {
|
||||
readonly navigationItems = this._navigationItems.asReadonly();
|
||||
|
||||
// Gefiltert nach Kontext (Landingpage vs. App)
|
||||
readonly landingNavigation = computed(() =>
|
||||
this._navigationItems().filter(item =>
|
||||
isAnchor(item) || item.target === '/login'
|
||||
readonly landingNavigation = computed(() =>
|
||||
this._navigationItems().filter(item =>
|
||||
isAnchor(item) || item.target === '/blog' || item.target === '/login'
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -48,19 +48,21 @@ export class OpenPanelService implements OnDestroy {
|
||||
}
|
||||
|
||||
private setupRouteTracking(): void {
|
||||
// Nur einmalig subscriben, vorherige Sub zerstören
|
||||
this.routerSubscription?.unsubscribe();
|
||||
|
||||
this.routerSubscription = this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
// Ersten initialNavigation-Event überspringen – SSR hat ihn schon getriggert
|
||||
skip(1),
|
||||
)
|
||||
.subscribe((event) => {
|
||||
const navEvent = event as NavigationEnd;
|
||||
this.trackScreenView(navEvent.urlAfterRedirects);
|
||||
});
|
||||
this.routerSubscription = this.router.events.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
).subscribe(() => {
|
||||
const route = this.getActiveRoute();
|
||||
const trackName = route.snapshot.data['trackName'] ?? this.router.url;
|
||||
this.trackScreenView(trackName);
|
||||
});
|
||||
}
|
||||
|
||||
private getActiveRoute() {
|
||||
let route = this.router.routerState.root;
|
||||
while (route.firstChild) route = route.firstChild;
|
||||
return route;
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
@@ -121,7 +123,7 @@ export class OpenPanelService implements OnDestroy {
|
||||
|
||||
/**
|
||||
* Decrements a numeric property on the user profile.
|
||||
* @example opService.decrement('credits', 5);
|
||||
* @example opService.decrement('credits');
|
||||
*/
|
||||
decrement(property: string): void {
|
||||
if (!this.op) return;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<div class="blog-nav">
|
||||
<div class="blog-nav__inner">
|
||||
<a routerLink="/" class="blog-nav__logo">
|
||||
<span class="blog-nav__logo-icon">H</span>
|
||||
<span><span class="blog-nav__logo-accent">Hurler</span> Webdesign</span>
|
||||
</a>
|
||||
|
||||
<div class="blog-nav__actions">
|
||||
@if (showBack) {
|
||||
<a routerLink="/blog" class="blog-nav__back">
|
||||
← Alle Artikel
|
||||
</a>
|
||||
} @else {
|
||||
<a routerLink="/" class="blog-nav__back">
|
||||
← Zurück zur Startseite
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
@use 'abstracts';
|
||||
|
||||
.blog-nav {
|
||||
height: var(--nav-height);
|
||||
background-color: var(--nav-bg);
|
||||
backdrop-filter: var(--nav-backdrop);
|
||||
box-shadow: var(--nav-shadow);
|
||||
border-radius: 0 0 10px 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-index-sticky);
|
||||
|
||||
&__inner {
|
||||
@include abstracts.container-wrapper;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
&__logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: abstracts.rem(30);
|
||||
height: abstracts.rem(30);
|
||||
background-color: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
border-radius: 5px;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__logo-accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&__back {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-blog-nav',
|
||||
imports: [RouterModule],
|
||||
templateUrl: './blog-nav.component.html',
|
||||
styleUrl: './blog-nav.component.scss',
|
||||
})
|
||||
export class BlogNavComponent {
|
||||
@Input() showBack = false;
|
||||
}
|
||||
2
src/app/features/blog/index.ts
Normal file
2
src/app/features/blog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BlogListComponent } from './pages/blog-list/blog-list.component';
|
||||
export { BlogDetailComponent } from './pages/blog-detail/blog-detail.component';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,30 @@
|
||||
<section class="features-section" id="features-section">
|
||||
<div class="features-section__wrapper">
|
||||
<div class="features-section__grid centered">
|
||||
@for (feature of featuresList; track feature.id) {
|
||||
<div class="features-section__card">
|
||||
<h3 class="features-section__claim">{{ feature.claim }}</h3>
|
||||
<p class="features-section__description">{{ feature.description }}</p>
|
||||
@if (feature.icon) {
|
||||
<img [src]="feature.icon" [alt]="feature.iconDescription" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="features-section__wrapper">
|
||||
<div class="features-section__header">
|
||||
<h2>Warum Hurler Webdesign?</h2>
|
||||
<p class="text-muted">Handwerk statt Baukasten – das sind die Unterschiede, die zählen.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="features-section__grid">
|
||||
@for (feature of featuresList; track feature.id; let i = $index) {
|
||||
<div
|
||||
#cardRef
|
||||
class="features-section__card"
|
||||
[style.--delay]="(i * 120) + 'ms'"
|
||||
opTrack="feature_card_click"
|
||||
[opTrackProps]="{ feature_id: feature.id, claim: feature.claim }">
|
||||
|
||||
<span class="features-section__card-number">0{{ feature.id }}</span>
|
||||
|
||||
<div class="features-section__icon-wrap">
|
||||
<ng-icon [name]="feature.icon" class="features-section__icon"></ng-icon>
|
||||
</div>
|
||||
|
||||
<h3 class="features-section__claim">{{ feature.claim }}</h3>
|
||||
<p class="features-section__description">{{ feature.description }}</p>
|
||||
|
||||
<div class="features-section__bar"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,55 +1,175 @@
|
||||
@use 'abstracts';
|
||||
|
||||
@keyframes card-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(36px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.features-section {
|
||||
min-height: calc(100vh - var(--neg-nav-height));
|
||||
margin-top: var(--neg-nav-height);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-block: calc(var(--space-4) * 2);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: calc(var(--space-4) * 1.5);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid ──────────────────────────────────────────────────────────────────
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
.features-section__card {
|
||||
&:nth-child(1) { grid-column: 1 / span 2; grid-row: 1; }
|
||||
&:nth-child(2) { grid-column: 3; grid-row: 1; }
|
||||
&:nth-child(3) { grid-column: 1; grid-row: 2; }
|
||||
&:nth-child(4) { grid-column: 2 / span 2; grid-row: 2; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card ──────────────────────────────────────────────────────────────────
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
min-height: abstracts.rem(220);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-4) var(--space-3) var(--space-3);
|
||||
gap: var(--space-2);
|
||||
background-color: var(--bg-surface);
|
||||
cursor: default;
|
||||
|
||||
// Scroll-entrance: start hidden
|
||||
opacity: 0;
|
||||
|
||||
&.is-visible {
|
||||
animation: card-fade-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--delay, 0ms) both;
|
||||
}
|
||||
|
||||
// Hover: lift + deepen shadow
|
||||
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 8px 24px oklch(0% 0 0 / 0.07),
|
||||
0 2px 6px oklch(0% 0 0 / 0.04);
|
||||
}
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
min-height: abstracts.rem(280);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ghost number (decorative) ─────────────────────────────────────────────
|
||||
|
||||
&__card-number {
|
||||
position: absolute;
|
||||
top: -0.1em;
|
||||
right: var(--space-3);
|
||||
font-size: 5rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
opacity: 0.06;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
transition: opacity 0.25s ease;
|
||||
|
||||
.features-section__card:hover & {
|
||||
opacity: 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Icon ─────────────────────────────────────────────────────────────────
|
||||
|
||||
&__icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: abstracts.rem(44);
|
||||
height: abstracts.rem(44);
|
||||
border-radius: 10px;
|
||||
background-color: oklch(from var(--accent) l c h / 0.12);
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.25s ease, transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
.features-section__card:hover & {
|
||||
background-color: oklch(from var(--accent) l c h / 0.2);
|
||||
transform: scale(1.1) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-3);
|
||||
&__icon {
|
||||
font-size: abstracts.rem(22);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
// ── Text ──────────────────────────────────────────────────────────────────
|
||||
|
||||
&__claim {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.65;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// ── Accent bar (grows on hover) ───────────────────────────────────────────
|
||||
|
||||
&__bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
||||
border-radius: 0 3px 0 var(--border-radius);
|
||||
transition: width 0.4s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
|
||||
.features-section__card:hover & {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
height: abstracts.rem(300);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-inline: var(--space-3);
|
||||
justify-content: center;
|
||||
|
||||
&:nth-child(1) {
|
||||
grid-column: 1 / span 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
&:nth-child(4) {
|
||||
grid-column: 2 / span 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
|
||||
&__claim {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,81 @@
|
||||
import { Component } from '@angular/core';
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
PLATFORM_ID,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { isPlatformBrowser } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { cssCode, cssLock, cssDatabase, cssBrowser } from '@ng-icons/css.gg';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
|
||||
interface Features {
|
||||
id: number,
|
||||
claim: string,
|
||||
description: string,
|
||||
icon?: string,
|
||||
iconDescription?: string
|
||||
interface Feature {
|
||||
id: number;
|
||||
claim: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-features-section',
|
||||
imports: [],
|
||||
imports: [NgIcon, OpenPanelTrackDirective],
|
||||
viewProviders: [provideIcons({ cssCode, cssLock, cssDatabase, cssBrowser })],
|
||||
templateUrl: './features-section.component.html',
|
||||
styleUrl: './features-section.component.scss',
|
||||
})
|
||||
export class FeaturesSectionComponent {
|
||||
featuresList: Features[] = [
|
||||
export class FeaturesSectionComponent implements AfterViewInit {
|
||||
@ViewChildren('cardRef') cardElements!: QueryList<ElementRef<HTMLElement>>;
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
|
||||
featuresList: Feature[] = [
|
||||
{
|
||||
id: 1,
|
||||
claim: "Code statt Baukasten",
|
||||
description: "Handgefertigte Performance, die Google und Ihre Nutzer lieben werden."
|
||||
claim: 'Code statt Baukasten',
|
||||
description:
|
||||
'Handgefertigter Code statt träger WordPress-Templates. Ihre Seite lädt in unter einer Sekunde – und das merken Google und Ihre Besucher.',
|
||||
icon: 'cssCode',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
claim: "Sicher per Design",
|
||||
description: "Maximale Rechtskonformität durch eRecht24 und hauseigene Server-Infrastruktur."
|
||||
claim: 'Sicher per Design',
|
||||
description:
|
||||
'Kein Plugin-Dschungel, keine veralteten CMS-Versionen. Maximale Rechtskonformität durch eRecht24-Integration und eine klar strukturierte Infrastruktur.',
|
||||
icon: 'cssLock',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
claim: "Heimat für Ihre Daten",
|
||||
description: "Hosting und Services strikt nach europäischem Datenschutzstandard."
|
||||
claim: 'Heimat für Ihre Daten',
|
||||
description:
|
||||
'Hosting und alle Services laufen ausschließlich auf europäischen Servern – vollständig DSGVO-konform und ohne US-Cloudabhängigkeit.',
|
||||
icon: 'cssDatabase',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
claim: "Alles im Blick",
|
||||
description: "Ein Portal für alles: Kommunikation, Verwaltung und Erfolgskontrolle"
|
||||
claim: 'Alles im Blick',
|
||||
description:
|
||||
'Ein Verwaltungsportal für alles: Inhalte pflegen, Anfragen verwalten und Ihren Webauftritt jederzeit selbst aktualisieren – ohne Programmierkenntnisse.',
|
||||
icon: 'cssBrowser',
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!isPlatformBrowser(this.platformId)) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
this.cardElements.forEach((el) => observer.observe(el.nativeElement));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
<footer class="footer">
|
||||
<div class="footer__wrapper">
|
||||
Hurler Webdesign <br/>
|
||||
Impressum <br/>
|
||||
Über uns
|
||||
|
||||
<footer class="footer" id="contact">
|
||||
<div class="footer__wrapper">
|
||||
<div class="footer__grid">
|
||||
|
||||
<div class="footer__col footer__col--brand">
|
||||
<div class="footer__logo">
|
||||
<span class="footer__logo-icon">H</span>
|
||||
<span><span class="footer__logo-accent">Hurler</span> Webdesign</span>
|
||||
</div>
|
||||
<p class="footer__tagline">
|
||||
Handgefertigte Webseiten für Unternehmen und Vereine im Raum Nördlingen.
|
||||
</p>
|
||||
<address class="footer__address">
|
||||
Untermagerbein 30<br />
|
||||
86751 Mönchsdeggingen<br />
|
||||
Bayern
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<div class="footer__col">
|
||||
<h4 class="footer__col-title">Navigation</h4>
|
||||
<ul class="footer__links">
|
||||
<li><a href="#hero" opTrack="footer_nav_click" [opTrackProps]="{ target: 'hero' }">Start</a></li>
|
||||
<li><a href="#features-section" opTrack="footer_nav_click" [opTrackProps]="{ target: 'features' }">Vorteile</a></li>
|
||||
<li><a href="#projects" opTrack="footer_nav_click" [opTrackProps]="{ target: 'projects' }">Projekte</a></li>
|
||||
<li><a href="#pricing" opTrack="footer_nav_click" [opTrackProps]="{ target: 'pricing' }">Preise</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="footer__col">
|
||||
<h4 class="footer__col-title">Rechtliches</h4>
|
||||
<ul class="footer__links">
|
||||
<li><a routerLink="/impressum" opTrack="footer_legal_click" [opTrackProps]="{ target: 'impressum' }">Impressum</a></li>
|
||||
<li><a routerLink="/datenschutz" opTrack="footer_legal_click" [opTrackProps]="{ target: 'datenschutz' }">Datenschutz</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="footer__bottom">
|
||||
<p>© {{ currentYear }} Hurler Webdesign. Alle Rechte vorbehalten.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,11 +1,102 @@
|
||||
@use 'abstracts';
|
||||
|
||||
.footer {
|
||||
background-color: var(--accent);
|
||||
color: var(--bg-surface);
|
||||
padding: 20px 0;
|
||||
background-color: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
padding-top: var(--space-4);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
padding-bottom: var(--space-4);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: 2fr 1fr 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__col-title {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-3);
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
&__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__logo-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: abstracts.rem(32);
|
||||
height: abstracts.rem(32);
|
||||
background-color: oklch(100% 0 0 / 0.2);
|
||||
border: 1px solid oklch(100% 0 0 / 0.3);
|
||||
border-radius: 5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__logo-accent {
|
||||
// "Hurler" in slightly brighter shade for contrast on accent bg
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__tagline {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
opacity: 0.8;
|
||||
margin-bottom: var(--space-3);
|
||||
max-width: 34ch;
|
||||
}
|
||||
|
||||
&__address {
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
line-height: 1.7;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__links {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
|
||||
a {
|
||||
font-size: var(--font-size-base);
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
border-top: 1px solid oklch(100% 0 0 / 0.15);
|
||||
padding-block: var(--space-3);
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-footer',
|
||||
imports: [],
|
||||
imports: [RouterModule, OpenPanelTrackDirective],
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrl: './footer.component.scss',
|
||||
})
|
||||
export class FooterComponent {
|
||||
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
<section class="hero-section" id="hero">
|
||||
<div class="hero-section__video-container">
|
||||
<video autoplay muted loop>
|
||||
<video autoplay muted loop playsinline>
|
||||
<source src="/video/white_mit_black_stripes.webm" type="video/webm">
|
||||
</video>
|
||||
</div>
|
||||
<div class="hero-section__wrapper">
|
||||
<h1 class="hero-section__header">
|
||||
Digitales Handwerk <br />
|
||||
Digitales Handwerk<br />
|
||||
statt Standard-Baukasten
|
||||
</h1>
|
||||
<p class="hero-section__claim">
|
||||
Wir programmieren Blitzschnelle, sichere und maßgeschneiderte Webseiten für kleine,
|
||||
mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance.
|
||||
Wir programmieren blitzschnelle, sichere und maßgeschneiderte Webseiten
|
||||
für kleine Unternehmen und Vereine – ohne CMS-Ballast, dafür mit maximaler Performance.
|
||||
</p>
|
||||
<div class="hero-section__links">
|
||||
<app-button [item]="{ label: 'Über uns', type: 'anchor', target: '#about' }" variant="primary"></app-button>
|
||||
<app-button (click)="onFeaturesClick()" [item]="{ label: 'Warum kein Wordpress', type: 'anchor', target: 'about'}"
|
||||
variant="primary"></app-button>
|
||||
<app-button
|
||||
opTrack="hero_cta_features"
|
||||
[opTrackProps]="{ location: 'hero' }"
|
||||
[item]="{ label: 'Unsere Vorteile', type: 'anchor', target: '#features-section' }"
|
||||
variant="primary">
|
||||
</app-button>
|
||||
<app-button
|
||||
opTrack="hero_cta_pricing"
|
||||
[opTrackProps]="{ location: 'hero' }"
|
||||
[item]="{ label: 'Preise & Pakete', type: 'anchor', target: '#pricing' }"
|
||||
variant="outline">
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@use "abstracts";
|
||||
|
||||
.hero-section {
|
||||
position: relative; // WICHTIG: Bezugspunkt für das Video
|
||||
position: relative;
|
||||
min-height: calc(100vh + var(--nav-height));
|
||||
margin-top: var(--neg-nav-height);
|
||||
overflow: hidden;
|
||||
@@ -21,6 +21,10 @@
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__video-container {
|
||||
@@ -29,19 +33,20 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -2; // Hinter den Text legen
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: var(--text-main); // Dein Wunsch-Style
|
||||
&__header {
|
||||
color: var(--text-main);
|
||||
font-size: var(--font-size-xxl);
|
||||
position: relative; // Stellt sicher, dass der Text über dem Video-Layer bleibt
|
||||
position: relative;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
&__claim {
|
||||
color: var(--text-main);
|
||||
font-size: var(--font-size-xl);
|
||||
font-size: var(--font-size-lg);
|
||||
max-width: 60ch;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@@ -50,18 +55,15 @@
|
||||
flex-direction: row;
|
||||
gap: var(--space-2);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
video {
|
||||
/* Das hier ist der entscheidende Teil */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover; // WICHTIG: Füllt den Container komplett aus, ohne zu verzerren
|
||||
object-position: center; // Zentriert das Video, falls Ränder abgeschnitten werden
|
||||
mask-image: linear-gradient(to bottom,
|
||||
black 0%,
|
||||
black 70%,
|
||||
transparent 100%);
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 70%, transparent 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { UmamiService } from '@core/services/umami.service';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-hero',
|
||||
imports: [ButtonComponent],
|
||||
imports: [ButtonComponent, OpenPanelTrackDirective],
|
||||
templateUrl: './hero.component.html',
|
||||
styleUrl: './hero.component.scss',
|
||||
})
|
||||
export class HeroComponent {
|
||||
constructor(private umami: UmamiService) {}
|
||||
|
||||
onFeaturesClick(): void {
|
||||
this.umami.trackEvent('features-anchor-click')
|
||||
}
|
||||
}
|
||||
export class HeroComponent {}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<section class="header">
|
||||
<div class="logo-container">
|
||||
<span class="logo-container__logo centered">H</span>
|
||||
<p class="logo-container__company" (click)="onFeaturesClick('lustiges Zeug')"><span>Hurler</span> Webdesign</p>
|
||||
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
|
||||
</div>
|
||||
<div class="header__nav-section centered">
|
||||
<div class="theme-toggle-container">
|
||||
@@ -14,5 +14,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
gap: var(--space-2);
|
||||
padding-left: var(--space-4);
|
||||
|
||||
@@ -48,6 +49,7 @@
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin: auto;
|
||||
|
||||
span {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -3,20 +3,13 @@ import { NgIcon } from '@ng-icons/core';
|
||||
import { ToogleThemeComponent } from '@shared/utils/toogle-theme/toogle-theme.component';
|
||||
import { NavMenuComponent } from '@shared/ui/nav-menu/nav-menu.component';
|
||||
import { NavigationService } from '@core/services/navigation.service';
|
||||
import { OpenPanelService } from '@core/services/openpanel.service';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, OpenPanelTrackDirective],
|
||||
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent],
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrl: './navigation.component.scss',
|
||||
})
|
||||
export class NavigationComponent {
|
||||
protected readonly navigationService = inject(NavigationService);
|
||||
private op = inject(OpenPanelService)
|
||||
|
||||
onFeaturesClick(blindplan: string): void {
|
||||
this.op.track('features_clicked', { blindplan })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1,39 @@
|
||||
<p>pricing works!</p>
|
||||
<section class="pricing" id="pricing">
|
||||
<div class="pricing__wrapper">
|
||||
<div class="pricing__header">
|
||||
<h2>Preise & Pakete</h2>
|
||||
<p class="text-muted">Transparente Preise – kein Abo, kein Versteckspiel.</p>
|
||||
</div>
|
||||
<div class="pricing__grid">
|
||||
@for (tier of tiers; track tier.id) {
|
||||
<div class="pricing__card" [class.pricing__card--highlighted]="tier.highlighted">
|
||||
@if (tier.highlighted) {
|
||||
<span class="pricing__badge">Beliebteste Wahl</span>
|
||||
}
|
||||
<div class="pricing__card-header">
|
||||
<h3 class="pricing__tier-name">{{ tier.name }}</h3>
|
||||
<div class="pricing__price">{{ tier.price }}</div>
|
||||
<p class="pricing__price-note">{{ tier.priceNote }}</p>
|
||||
</div>
|
||||
<p class="pricing__description">{{ tier.description }}</p>
|
||||
<ul class="pricing__features">
|
||||
@for (feature of tier.features; track $index) {
|
||||
<li class="pricing__feature-item">
|
||||
<span class="pricing__check" aria-hidden="true">✓</span>
|
||||
{{ feature }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="pricing__cta">
|
||||
<app-button
|
||||
[opTrack]="'pricing_cta_click'"
|
||||
[opTrackProps]="{ tier: tier.id, cta: tier.cta }"
|
||||
[item]="{ label: tier.cta, type: 'anchor', target: '#contact' }"
|
||||
[variant]="tier.highlighted ? 'primary' : 'outline'">
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
@use 'abstracts';
|
||||
|
||||
.pricing {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-block: var(--space-4);
|
||||
background-color: var(--bg-muted);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
h2 {
|
||||
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);
|
||||
align-items: start;
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
background-color: var(--bg-surface);
|
||||
transition: box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 32px oklch(0% 0 0 / 0.08);
|
||||
}
|
||||
|
||||
&--highlighted {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 4px 24px oklch(45% 0.22 250 / 0.15);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 8px 32px oklch(45% 0.22 250 / 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
background-color: var(--accent);
|
||||
color: var(--text-on-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__card-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__tier-name {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
&__price-note {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&__feature-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
&__check {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: auto;
|
||||
|
||||
app-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,76 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
interface PricingTier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
priceNote: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
cta: string;
|
||||
highlighted: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-pricing',
|
||||
imports: [],
|
||||
imports: [OpenPanelTrackDirective, ButtonComponent],
|
||||
templateUrl: './pricing.component.html',
|
||||
styleUrl: './pricing.component.scss',
|
||||
})
|
||||
export class PricingComponent {
|
||||
|
||||
tiers: PricingTier[] = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: '799 €',
|
||||
priceNote: 'einmalig zzgl. MwSt.',
|
||||
description: 'Ideal für Handwerker und Vereine mit klarem Fokus auf eine starke Online-Präsenz.',
|
||||
features: [
|
||||
'1-Pager / Landingpage',
|
||||
'Individuelles Design',
|
||||
'Suchmaschinenoptimierung (SEO)',
|
||||
'Kontaktformular',
|
||||
'Cookie-Banner & Datenschutz',
|
||||
'12 Monate Hosting inklusive',
|
||||
],
|
||||
cta: 'Jetzt anfragen',
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
name: 'Business',
|
||||
price: '1.499 €',
|
||||
priceNote: 'einmalig zzgl. MwSt.',
|
||||
description: 'Für Unternehmen, die mehr wollen: mehrere Seiten, eigenes CMS-Portal und Analysen.',
|
||||
features: [
|
||||
'Mehrseiter (bis 5 Seiten)',
|
||||
'Alles aus Starter',
|
||||
'Verwaltungsportal (CMS)',
|
||||
'Blog / Neuigkeiten',
|
||||
'Performance-Analyse',
|
||||
'Prioritäts-Support',
|
||||
],
|
||||
cta: 'Jetzt anfragen',
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
id: 'individual',
|
||||
name: 'Individual',
|
||||
price: 'Auf Anfrage',
|
||||
priceNote: 'individuelles Angebot',
|
||||
description: 'Shops, Web-Applikationen, API-Anbindungen – wir setzen komplexe Projekte um.',
|
||||
features: [
|
||||
'Online-Shops',
|
||||
'Web-Applikationen',
|
||||
'API-Integration',
|
||||
'Individuelle Funktionen',
|
||||
'Langfristige Betreuung',
|
||||
'Auf Ihre Bedürfnisse zugeschnitten',
|
||||
],
|
||||
cta: 'Kontakt aufnehmen',
|
||||
highlighted: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
<section class="projects" id="projects">
|
||||
<div class="projects__wrapper">
|
||||
<div class="projects__card-container centered">
|
||||
@for(project of projects; track project.id) {
|
||||
<div class="projects__card">
|
||||
<img [src]="project.image" />
|
||||
<div class="projects__card__description">
|
||||
<h3>{{ project.company }}</h3>
|
||||
<p>{{ project.shortDescription }}</p>
|
||||
<div class="projects__card-features">
|
||||
@for(feature of project.features; track $index) {
|
||||
<p>{{ feature }}</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="projects__wrapper">
|
||||
<div class="projects__header">
|
||||
<h2>Unsere Projekte</h2>
|
||||
<p class="text-muted">Echte Webseiten für echte Unternehmen – sehen Sie selbst.</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="projects__card-container">
|
||||
@for (project of projects; track project.id) {
|
||||
<div
|
||||
class="projects__card"
|
||||
opTrack="project_card_click"
|
||||
[opTrackProps]="{ project_id: project.id, company: project.company }">
|
||||
<img [src]="project.image" [alt]="project.company" />
|
||||
<div class="projects__card__overlay">
|
||||
<h3>{{ project.company }}</h3>
|
||||
<p>{{ project.shortDescription }}</p>
|
||||
<div class="projects__card-features">
|
||||
@for (feature of project.features; track $index) {
|
||||
<span class="projects__tag">{{ feature }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,45 +1,99 @@
|
||||
@use 'abstracts';
|
||||
|
||||
.projects {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-block: var(--space-4);
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
h2 {
|
||||
font-size: var(--font-size-xl);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
}
|
||||
|
||||
&__card-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
||||
@include abstracts.breakpoint('md') {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
aspect-ratio: 4 / 3;
|
||||
background-color: var(--bg-muted);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
&__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-3);
|
||||
background: linear-gradient(to top, oklch(0% 0 0 / 0.8) 0%, transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
color: var(--color-white);
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: var(--font-size-base);
|
||||
margin-bottom: var(--space-2);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&__card-features {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
margin-top: var(--neg-nav-height);
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__wrapper {
|
||||
@include abstracts.container-wrapper;
|
||||
}
|
||||
|
||||
&__card-container {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
|
||||
&__description {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
padding: var(--space-2);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
&:hover &__description {
|
||||
opacity: 1;
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
&__card-features {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
}
|
||||
&__tag {
|
||||
font-size: 0.75rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid oklch(100% 0 0 / 0.4);
|
||||
color: var(--color-white);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||
|
||||
interface Project {
|
||||
id: number,
|
||||
image: string,
|
||||
company: string,
|
||||
shortDescription: string,
|
||||
features: string[]
|
||||
id: number;
|
||||
image: string;
|
||||
company: string;
|
||||
shortDescription: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-projects',
|
||||
imports: [],
|
||||
imports: [OpenPanelTrackDirective],
|
||||
templateUrl: './projects.component.html',
|
||||
styleUrl: './projects.component.scss',
|
||||
})
|
||||
@@ -18,24 +19,24 @@ export class ProjectsComponent {
|
||||
projects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
company: "Backerei Müller",
|
||||
image: "/images/bakery.jpg",
|
||||
shortDescription: "Landingpage mit wechselnden Angeboten",
|
||||
features: ["SEO", "Angebote", "Dark/Light"],
|
||||
company: 'Schreiner Müller GmbH',
|
||||
image: '/images/schreiner-mueller.jpg',
|
||||
shortDescription: 'Handwerkswebsite mit Leistungsübersicht und Kontaktformular',
|
||||
features: ['SEO', 'Kontaktformular', 'Dark/Light'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
company: "Backerei Müller",
|
||||
image: "/images/bakery.jpg",
|
||||
shortDescription: "Landingpage mit wechselnden Angeboten",
|
||||
features: ["SEO", "Angebote", "Dark/Light"],
|
||||
company: 'Schützenverein Nördlingen e.V.',
|
||||
image: '/images/schuetzenverein.jpg',
|
||||
shortDescription: 'Vereinswebsite mit Terminen und Veranstaltungskalender',
|
||||
features: ['SEO', 'Termine', 'Mitglieder'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
company: "Backerei Müller",
|
||||
image: "/images/bakery.jpg",
|
||||
shortDescription: "Landingpage mit wechselnden Angeboten",
|
||||
features: ["SEO", "Angebote", "Dark/Light"],
|
||||
}
|
||||
]
|
||||
company: 'Bäckerei Huber',
|
||||
image: '/images/baeckerei-huber.jpg',
|
||||
shortDescription: 'Landingpage mit täglich wechselnden Tagesangeboten',
|
||||
features: ['SEO', 'Angebote', 'Responsive'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<app-hero></app-hero>
|
||||
<app-features-section></app-features-section>
|
||||
<app-projects></app-projects>
|
||||
<app-footer></app-footer>
|
||||
<app-pricing></app-pricing>
|
||||
<app-footer></app-footer>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HeroComponent } from '../components/hero/hero.component';
|
||||
import { FeaturesSectionComponent } from '../components/features-section/features-section.component';
|
||||
import { FooterComponent } from '../components/footer/footer.component';
|
||||
import { ProjectsComponent } from '../components/projects/projects.component';
|
||||
import { PricingComponent } from '../components/pricing/pricing.component';
|
||||
import { SeoService } from '@core/services/seo.service';
|
||||
|
||||
@Component({
|
||||
@@ -13,8 +14,9 @@ import { SeoService } from '@core/services/seo.service';
|
||||
HeroComponent,
|
||||
FeaturesSectionComponent,
|
||||
ProjectsComponent,
|
||||
PricingComponent,
|
||||
FooterComponent,
|
||||
],
|
||||
],
|
||||
templateUrl: './landingpage.component.html',
|
||||
styleUrl: './landingpage.component.scss',
|
||||
})
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<nav class="navigation">
|
||||
<ul class="navigation__list">
|
||||
@for (item of items(); track item.target) {
|
||||
<li class="navigation__list-item">
|
||||
<!-- Anchor-Link: Smooth Scroll -->
|
||||
@if (isAnchor(item)) {
|
||||
<a [href]="'#' + item.target" (click)="onAnchorClick($event, item)" opTrack="links_nav_menu" [opTrackProps]="{location: `${item.target}`}">
|
||||
@if (item.icon) {
|
||||
<ng-icon [name]="item.icon"></ng-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<li class="navigation__list-item">
|
||||
<!-- Anchor-Link: Smooth Scroll -->
|
||||
@if (isAnchor(item)) {
|
||||
<a [href]="'#' + item.target" (click)="onAnchorClick($event, item)" opTrack="links_nav_menu"
|
||||
[opTrackProps]="{location: `${item.target}`}">
|
||||
@if (item.icon) {
|
||||
<ng-icon [name]="item.icon"></ng-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
|
||||
<!-- Route-Link: Angular Router -->
|
||||
@else if (isRoute(item)) {
|
||||
<a [routerLink]="item.target">
|
||||
@if (item.icon) {
|
||||
<ng-icon [name]="item.icon"></ng-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
</a>
|
||||
<!-- Route-Link: Angular Router -->
|
||||
@else if (isRoute(item)) {
|
||||
<a [routerLink]="item.target" opTrack="links_nav_menu" [opTrackProps]="{location: `${item.target}`}">
|
||||
@if (item.icon) {
|
||||
<ng-icon [name]="item.icon"></ng-icon>
|
||||
}
|
||||
</li>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
</nav>
|
||||
4
src/environments/environment.directus.ts
Normal file
4
src/environments/environment.directus.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
directusUrl: 'https://backend.hurler-webdesign.de',
|
||||
};
|
||||
@@ -1,9 +1,8 @@
|
||||
// environment.ts
|
||||
export const environment = {
|
||||
production: false,
|
||||
secret: "sec_4aa70c091e704023c6df",
|
||||
openPanel: {
|
||||
clientId: '727b9649-26ac-4083-96ea-92c3a60fe7a8',
|
||||
apiUrl: 'https://analytics.hurler-webdesign.de/api', // oder self-hosted URL
|
||||
clientId: 'c0e6dcf4-3eca-4b0b-a631-a93aa5df1477',
|
||||
apiUrl: 'https://data.hurler-webdesign.de',
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>HurlerWebdesignSaas</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
<script defer src="https://stats.hurler-webdesign.de/script.js" data-website-id="33763c9b-43a8-4e36-a1c1-e07d31ed1e5b"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user