herosection erstellt

This commit is contained in:
2026-03-04 22:01:49 +01:00
parent ce8cb07834
commit 79c71fcf45
28 changed files with 633 additions and 105 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -3
View File
@@ -1,7 +1,9 @@
import { Component, signal, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { Component, signal, OnInit } 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';
@Component({
selector: 'app-root',
@@ -10,6 +12,19 @@ import {cssMenu} from "@ng-icons/css.gg";
styleUrl: './app.scss',
viewProviders: [provideIcons({cssMenu})]
})
export class App {
export class App implements OnInit {
protected readonly title = signal('hurler-webdesign-saas');
constructor(
private router: Router,
private umami: UmamiService
) {}
ngOnInit(): void {
this.router.events.pipe(
filter(event => event instanceof NavigationEnd)
).subscribe(() => {
this.umami.trackPageview();
});
}
}
+20 -3
View File
@@ -13,19 +13,27 @@ export class SeoService {
private renderer =
this.rendererFactory.createRenderer(null, null);
// Constants for metadata
private readonly BRAND = "Hurler Webdesign";
private readonly FALLBACK_IMAGE =
'https://hurler-webdesign.de/assets/og-default.jpg';
private readonly DEFAULT_TYPE = "website";
/**
* Updates SEO metadata with the provided data.
*
* @param data - The SeoData object containing title, description, type, and image.
* @param canonicalPath - Optional parameter for the canonical URL path.
*/
updateMetadata(data: SeoData, canonicalPath?: string) {
const fullTitle = `${data.title} | ${this.BRAND}`;
const url = `https://hurler-webdesign.de${canonicalPath || ""}`;
// Update title and meta description
this.titleService.setTitle(fullTitle);
this.metaService.updateTag({ name: "description", content: data.description });
// Open Graph
// Open Graph metadata
this.metaService.updateTag({
property: "og:title",
content: fullTitle,
@@ -44,7 +52,7 @@ export class SeoService {
});
this.metaService.updateTag({ property: "og:url", content: url });
// Twitter
// Twitter card metadata
this.metaService.updateTag({
name: "twitter:card",
content: "summary_large_image",
@@ -63,13 +71,20 @@ export class SeoService {
content: data.image || this.FALLBACK_IMAGE,
});
// Update canonical URL if provided
if (canonicalPath) {
this.updateCanonicalUrl(url);
}
// Set local business schema.org metadata
this.setLocalBusinessSchema();
}
/**
* Updates the canonical URL for the given page.
*
* @param url - The new canonical URL.
*/
private updateCanonicalUrl(url: string) {
let link: HTMLLinkElement =
this.document.querySelector("link[rel='canonical']") ||
@@ -81,7 +96,9 @@ export class SeoService {
}
}
/**
* Sets the local business schema.org JSON-LD script in the head of the document.
*/
private setLocalBusinessSchema() {
const oldScript = this.document.getElementById('schema-org-data');
if (oldScript) this.renderer.removeChild(this.document.head, oldScript);
@@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { UmamiService } from './umami.service';
describe('UmamiService', () => {
let service: UmamiService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(UmamiService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
});
+42
View File
@@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
export interface UmamiEventData {
[key: string]: string | number | boolean;
}
// Typdefinition für das globale umami-Objekt
declare global {
interface Window {
umami?: {
track: (eventName?: string, data?: UmamiEventData) => void;
};
}
}
@Injectable({
providedIn: 'root',
})
export class UmamiService {
private get isAvailable(): boolean {
return typeof window !== 'undefined' && typeof window.umami !== 'undefined';
}
/**
* Trackt einen Pageview manuell nötig bei SPAs wie Angular,
* da kein echter Seitenaufruf stattfindet.
*/
trackPageview(): void {
if (!this.isAvailable) return;
window.umami!.track();
}
/**
* Trackt ein benutzerdefiniertes Event.
* @param eventName Name des Events, z.B. 'button-click'
* @param data Optionale Zusatzdaten, z.B. { label: 'Hero CTA' }
*/
trackEvent(eventName: string, data?: UmamiEventData): void {
if (!this.isAvailable) return;
window.umami!.track(eventName, data);
}
}
@@ -0,0 +1,3 @@
p {
height: 100vh;
}
@@ -1 +1,3 @@
<p>footer works!</p>
<footer>
Hier kommt der Footer
</footer>
@@ -0,0 +1,3 @@
footer {
height: 500px;
}
@@ -1,16 +1,23 @@
<section class="hero-section">
<div class="hero-section__video-container">
<video autoplay muted loop>
<source src="/video/white_mit_black_stripes.webm" type="video/webm">
</video>
</div>
<div class="hero-section__wrapper">
<h1 class="hero-section__header">
<span>Digitales Handwerk</span> <br />
<span>statt Standard-Baukasten</span>
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.
</p>
<div class="hero-section__links">
<button href="#" variant="primary" label="Leistungen ansehen">Leistungen ansehen</button>
<button href="#" variant="secondary" label="Warum kein WordPress?">
Warum kein WordPress?
</button>
<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>
</div>
</div>
</section>
@@ -1,4 +1,66 @@
@use "abstracts";
.hero-section {
position: relative; // WICHTIG: Bezugspunkt für das Video
height: 100vh;
margin-top: -60px;
padding-top: 60px;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center; // Zentriert den Text-Inhalt
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
max-width: 1200px;
height: 100%;
background-color: var(--overlay);
z-index: -1;
}
&__wrapper {
@include abstracts.container-wrapper;
}
&__video-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -2; // Hinter den Text legen
}
h1 {
color: var(--text-main); // Dein Wunsch-Style
font-size: var(--font-size-xxl);
position: relative; // Stellt sicher, dass der Text über dem Video-Layer bleibt
margin-bottom: var(--space-4);
}
&__claim {
color: var(--text-main);
font-size: var(--font-size-xl);
margin-bottom: var(--space-4);
}
&__links {
display: flex;
flex-direction: row;
gap: var(--space-2);
justify-content: center;
}
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
}
}
@@ -1,11 +1,17 @@
import { Component } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { UmamiService } from '@core/services/umami.service';
@Component({
selector: 'app-hero',
imports: [],
imports: [ButtonComponent],
templateUrl: './hero.component.html',
styleUrl: './hero.component.scss',
})
export class HeroComponent {
constructor(private umami: UmamiService) {}
onFeaturesClick(): void {
this.umami.trackEvent('features-anchor-click')
}
}
@@ -1,3 +1,4 @@
<div class="wrapper">
<section class="header">
<div class="logo-container">
<span class="logo-container__logo centered">H</span>
@@ -13,3 +14,5 @@
</div>
</div>
</section>
</div>
@@ -1,5 +1,15 @@
@use "abstracts";
.wrapper {
border-radius: 0 0 10px 10px;
background-color: var(--nav-bg);
backdrop-filter: var(--nav-backdrop);
box-shadow: var(--nav-shadow);
position: sticky;
top: 0;
z-index: var(--z-index-sticky);
}
.header {
max-width: 1200px;
margin: 0 auto;
@@ -1,14 +1,4 @@
<div class="landing-grid">
<div class="grid-area-navigation">
<app-navigation></app-navigation>
</div>
<div class="grid-area-hero" id="hero">
<app-hero></app-hero>
</div>
<div class="grid-area-features" id="features-section">
<app-features-section></app-features-section>
</div>
<div class="grid-area-footer">
<app-footer></app-footer>
</div>
</div>
+118
View File
@@ -0,0 +1,118 @@
# NavButtonComponent
Eine Angular Standalone-Komponente die ein `NavigationItem` rendert und je nach `NavigationType` automatisch den passenden Link-Typ erzeugt.
## Voraussetzungen
- Angular 17+
- `RouterModule` im Projekt eingebunden
## Dateien
```
button/
├── button.component.ts
├── button.component.html
└── button.component.scss
```
## Verwendung
Die Komponente ist standalone und wird direkt in den `imports` einer anderen Komponente oder eines Moduls eingebunden:
```typescript
import { NavButtonComponent } from './nav-button/nav-button.component';
@Component({
standalone: true,
imports: [NavButtonComponent],
// ...
})
export class AppComponent {}
```
## Inputs
| Input | Typ | Pflicht | Default | Beschreibung |
|-----------|--------------------------------------|---------|-------------|-------------------------------------|
| `item` | `NavigationItem` | ✅ | — | Das NavigationItem-Objekt |
| `size` | `'sm' \| 'md' \| 'lg'` | ❌ | `'md'` | Größe des Buttons |
| `variant` | `'primary' \| 'ghost' \| 'outline'` | ❌ | `'primary'` | Visueller Stil |
| `disabled`| `boolean` | ❌ | `false` | Deaktiviert den Button |
## NavigationItem
```typescript
export type NavigationType = "anchor" | "route" | "external";
export interface NavigationItem {
readonly label: string; // Anzeigetext
readonly type: NavigationType;
readonly target: string; // Pfad, Anchor (#id) oder URL
readonly icon?: string; // Optionales Icon (z. B. Emoji oder Icon-String)
readonly children?: NavigationItem[];
}
```
## Verhalten je NavigationType
| Typ | Verhalten |
|------------|----------------------------------------------------------------|
| `route` | Rendert `<a [routerLink]>` mit `routerLinkActive` |
| `anchor` | Rendert `<a href>` mit `scrollIntoView({ behavior: 'smooth'})` |
| `external` | Rendert `<a target="_blank" rel="noopener noreferrer">` mit `↗`|
## Beispiele
```html
<!-- Interner Routerlink -->
<app-nav-button
[item]="{ label: 'Home', type: 'route', target: '/home' }"
/>
<!-- Anchor Scroll -->
<app-nav-button
[item]="{ label: 'Über uns', type: 'anchor', target: '#about' }"
variant="outline"
/>
<!-- Externer Link -->
<app-nav-button
[item]="{ label: 'GitHub', type: 'external', target: 'https://github.com', icon: '🐙' }"
variant="ghost"
size="sm"
/>
<!-- Deaktiviert -->
<app-nav-button
[item]="{ label: 'Gesperrt', type: 'route', target: '/admin' }"
[disabled]="true"
/>
```
## Theming
Alle Farben und Abstände sind über CSS Custom Properties steuerbar. Überschreibe die Tokens global in deinem `styles.scss`:
```scss
:root {
--btn-primary-bg: #1a1a2e;
--btn-primary-color: #ffffff;
--btn-primary-hover-bg: #16213e;
--btn-outline-border: #1a1a2e;
--btn-outline-color: #1a1a2e;
--btn-outline-hover-bg: #1a1a2e;
--btn-outline-hover-color: #ffffff;
--btn-radius: 8px;
--btn-font: 'Your Font', sans-serif;
}
```
## Accessibility
- Aktiver Routerlink erhält die Klasse `nav-btn--active`
- Deaktivierte Links erhalten `aria-disabled="true"`
- Externe Links sind mit `aria-label="Opens in new tab"` gekennzeichnet
- Focus-Styles über `:focus-visible` vorhanden
+46 -1
View File
@@ -1 +1,46 @@
<p>button works!</p>
<!-- Router Link (internal navigation) -->
@if (isRouteType) {
<a
[routerLink]="item.target"
routerLinkActive="nav-btn--active"
[class]="hostClasses.join(' ')"
[attr.aria-disabled]="disabled || null"
>
@if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
}
<span class="nav-btn__label">{{ item.label }}</span>
</a>
}
<!-- Anchor (smooth scroll) -->
@if (isAnchorType) {
<a
[href]="item.target"
(click)="scrollToAnchor($event)"
[class]="hostClasses.join(' ')"
[attr.aria-disabled]="disabled || null"
>
@if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
}
<span class="nav-btn__label">{{ item.label }}</span>
</a>
}
<!-- External Link -->
@if (isExternalType) {
<a
[href]="item.target"
target="_blank"
rel="noopener noreferrer"
[class]="hostClasses.join(' ')"
[attr.aria-disabled]="disabled || null"
>
@if (item.icon) {
<span class="nav-btn__icon" aria-hidden="true">{{ item.icon }}</span>
}
<span class="nav-btn__label">{{ item.label }}</span>
<span class="nav-btn__external-icon" aria-label="Opens in new tab"></span>
</a>
}
@@ -0,0 +1,139 @@
// ─── Design Tokens ───────────────────────────────────────────────────────────
:host {
--btn-font: 'DM Mono', 'Courier New', monospace;
--btn-radius: 4px;
--btn-transition: 160ms cubic-bezier(0.4, 0, 0.2, 1);
// Size tokens
--btn-sm-padding: 6px 14px;
--btn-md-padding: 10px 22px;
--btn-lg-padding: 14px 32px;
--btn-sm-font: 0.75rem;
--btn-md-font: 0.875rem;
--btn-lg-font: 1rem;
// Color tokens — override at :root level to theme globally
--btn-primary-bg: #0f0f0f;
--btn-primary-color: #f5f5f5;
--btn-primary-border: #0f0f0f;
--btn-primary-hover-bg: #2a2a2a;
--btn-ghost-bg: transparent;
--btn-ghost-color: #0f0f0f;
--btn-ghost-border: transparent;
--btn-ghost-hover-bg: rgba(0, 0, 0, 0.06);
--btn-outline-bg: transparent;
--btn-outline-color: #0f0f0f;
--btn-outline-border: #0f0f0f;
--btn-outline-hover-bg: #0f0f0f;
--btn-outline-hover-color: #f5f5f5;
display: inline-block;
}
// ─── Base ─────────────────────────────────────────────────────────────────────
.nav-btn {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--btn-font);
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
text-decoration: none;
border: 1.5px solid;
border-radius: var(--btn-radius);
cursor: pointer;
white-space: nowrap;
outline-offset: 3px;
transition:
background-color var(--btn-transition),
color var(--btn-transition),
border-color var(--btn-transition),
box-shadow var(--btn-transition),
transform var(--btn-transition);
&:focus-visible {
outline: 2px solid currentColor;
}
&:active:not(.nav-btn--disabled) {
transform: translateY(1px);
}
// ── Sizes ──────────────────────────────────────────────────────────────────
&--sm {
padding: var(--btn-sm-padding);
font-size: var(--btn-sm-font);
}
&--md {
padding: var(--btn-md-padding);
font-size: var(--btn-md-font);
}
&--lg {
padding: var(--btn-lg-padding);
font-size: var(--btn-lg-font);
}
// ── Variants ───────────────────────────────────────────────────────────────
&--primary {
background-color: var(--btn-primary-bg);
color: var(--btn-primary-color);
border-color: var(--btn-primary-border);
&:hover:not(.nav-btn--disabled) {
background-color: var(--btn-primary-hover-bg);
}
}
&--ghost {
background-color: var(--btn-ghost-bg);
color: var(--btn-ghost-color);
border-color: var(--btn-ghost-border);
&:hover:not(.nav-btn--disabled) {
background-color: var(--btn-ghost-hover-bg);
}
}
&--outline {
background-color: var(--btn-outline-bg);
color: var(--btn-outline-color);
border-color: var(--btn-outline-border);
&:hover:not(.nav-btn--disabled) {
background-color: var(--btn-outline-hover-bg);
color: var(--btn-outline-hover-color);
}
}
// ── States ─────────────────────────────────────────────────────────────────
&--active {
box-shadow: inset 0 0 0 1.5px currentColor;
}
&--disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
// ── Parts ──────────────────────────────────────────────────────────────────
&__icon {
font-size: 1.1em;
line-height: 1;
}
&__label {
line-height: 1;
}
&__external-icon {
font-size: 0.85em;
opacity: 0.65;
margin-left: -2px;
}
}
+48 -4
View File
@@ -1,11 +1,55 @@
import { Component } from '@angular/core';
import {
Component,
Input,
OnInit,
ChangeDetectionStrategy,
} from '@angular/core';
import { RouterModule } from '@angular/router';
import { NavigationItem, isAnchor, isRoute } from '@core/models/navigation.model';
@Component({
selector: 'app-button',
imports: [],
standalone: true,
imports: [RouterModule],
templateUrl: './button.component.html',
styleUrl: './button.component.scss',
styleUrls: ['./button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ButtonComponent {
export class ButtonComponent implements OnInit {
@Input({ required: true }) item!: NavigationItem;
/** Optional size variant */
@Input() size: 'sm' | 'md' | 'lg' = 'md';
/** Optional visual variant */
@Input() variant: 'primary' | 'ghost' | 'outline' = 'primary';
/** Whether the button is disabled */
@Input() disabled = false;
isAnchorType = false;
isRouteType = false;
isExternalType = false;
ngOnInit(): void {
this.isAnchorType = isAnchor(this.item);
this.isRouteType = isRoute(this.item);
this.isExternalType = this.item.type === 'external';
}
/** Scroll to anchor target */
scrollToAnchor(event: Event): void {
event.preventDefault();
const target = document.querySelector(this.item.target);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
get hostClasses(): string[] {
return [
'nav-btn',
`nav-btn--${this.variant}`,
`nav-btn--${this.size}`,
this.disabled ? 'nav-btn--disabled' : '',
].filter(Boolean);
}
}
+1
View File
@@ -6,6 +6,7 @@
<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>
-1
View File
@@ -1,3 +1,2 @@
@use "abstracts";
@use "base";
@use "layout";
+6
View File
@@ -13,3 +13,9 @@ $breakpoints: (
@content;
}
}
@mixin container-wrapper {
max-width: 1200px;
margin: auto;
padding-inline: 20px;
}
+4 -2
View File
@@ -26,8 +26,7 @@
--button-text: oklch(100% 0.01 250);
--border: oklch(90% 0.02 250);
--shadow-color: oklch(0% 0 250);
// Stapelbare Werte
--overlay: oklch(90% 0.02 250 / 0.9); // Stapelbare Werte
--z-index-sticky: 100;
// Skalierung (modulare Skala)
@@ -41,6 +40,7 @@
--font-size-base: clamp(1rem, 0.5vw + 0.875rem, 1.125rem);
--font-size-lg: clamp(1.25rem, 1vw + 1rem, 1.5rem);
--font-size-xl: clamp(1.5rem, 2vw + 1rem, 2.5rem);
--font-size-xxl: clamp(2.5rem, 3vw + 1.5rem, 4.5rem)
}
[data-theme="dark"] {
@@ -54,6 +54,8 @@
--border: oklch(25% 0.02 250);
--shadow-color: 0 0% 100%;
--overlay: oklch(30% 0.0075 250 / 0.9);
}
// ==============================
+43 -43
View File
@@ -1,51 +1,51 @@
@use "abstracts";
.landing-grid {
display: grid;
grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
grid-template-areas:
"navigation navigation navigation"
"hero hero hero"
". features-section ."
"footer footer footer";
.layout-wrapper {
max-width: 1200px;
margin: auto;
padding-inline: 20px;
}
.grid-area-navigation {
grid-area: navigation;
border-radius: 0 0 10px 10px;
background-color: var(--nav-bg);
backdrop-filter: var(--nav-backdrop);
box-shadow: var(--nav-shadow);
position: sticky;
top: 0;
z-index: var(--z-index-sticky);
}
// .landing-grid {
// display: grid;
// grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
// grid-template-areas:
// "navigation navigation navigation"
// "hero hero hero"
// ". features-section ."
// "footer footer footer";
// }
.grid-area-hero {
grid-area: hero;
margin-top: -60px;
padding-top: 60px;
height: 100vh;
display: flex;
justify-content: center;
background:
linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 1)),
linear-gradient(0.25turn, oklch(56.347% 0.24089 260.834 / 0.8), oklch(55.088% 0.23462 260.772 / 0.8), oklch(56.347% 0.24089 260.834 / 0.8)),
url("/images/backgroundpattern.webp") center / cover no-repeat;
}
// .grid-area-navigation {
// grid-area: navigation;
[data-theme="dark"] .grid-area-hero {
background:
linear-gradient(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.2)),
linear-gradient(0.25turn, oklch(20.648% 0.14311 250 / 0.5), oklch(27.99% 0.10701 258.719 / 0.5), oklch(20.648% 0.14311 250 / 0.5)),
url("/images/backgroundpattern.webp") center / cover no-repeat;
}
// }
.grid-area-features {
grid-area: features-section;
height: 80vh;
}
// .grid-area-hero {
// grid-area: hero;
// margin-top: -60px;
// padding-top: 60px;
// height: 100vh;
// display: flex;
// justify-content: center;
// background:
// linear-gradient(rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 0.7), rgba(255, 255, 255, 1)),
// linear-gradient(0.25turn, oklch(56.347% 0.24089 260.834 / 0.8), oklch(55.088% 0.23462 260.772 / 0.8), oklch(56.347% 0.24089 260.834 / 0.8)),
// url("/images/backgroundpattern.webp") center / cover no-repeat;
// }
.grid-area-footer {
grid-area: footer;
}
// [data-theme="dark"] .grid-area-hero {
// background:
// linear-gradient(rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.2)),
// linear-gradient(0.25turn, oklch(20.648% 0.14311 250 / 0.5), oklch(27.99% 0.10701 258.719 / 0.5), oklch(20.648% 0.14311 250 / 0.5)),
// url("/images/backgroundpattern.webp") center / cover no-repeat;
// }
// .grid-area-features {
// grid-area: features-section;
// height: 80vh;
// }
// .grid-area-footer {
// grid-area: footer;
// }
View File
-2
View File
@@ -1,2 +0,0 @@
@forward "grid";
@forward "header";