herosection erstellt
This commit is contained in:
BIN
public/video/black mit white stripes.webm
Normal file
BIN
public/video/black mit white stripes.webm
Normal file
Binary file not shown.
BIN
public/video/dark mit blue stripes.webm
Normal file
BIN
public/video/dark mit blue stripes.webm
Normal file
Binary file not shown.
BIN
public/video/türkis motherboard.webm
Normal file
BIN
public/video/türkis motherboard.webm
Normal file
Binary file not shown.
BIN
public/video/white_mit_black_stripes.webm
Normal file
BIN
public/video/white_mit_black_stripes.webm
Normal file
Binary file not shown.
@@ -1,7 +1,9 @@
|
|||||||
import { Component, signal, inject } from '@angular/core';
|
import { Component, signal, OnInit } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet, Router, NavigationEnd } from '@angular/router';
|
||||||
import {provideIcons} from "@ng-icons/core";
|
import {provideIcons} from "@ng-icons/core";
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
import {cssMenu} from "@ng-icons/css.gg";
|
import {cssMenu} from "@ng-icons/css.gg";
|
||||||
|
import { UmamiService } from '@core/services/umami.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -10,6 +12,19 @@ import {cssMenu} from "@ng-icons/css.gg";
|
|||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
viewProviders: [provideIcons({cssMenu})]
|
viewProviders: [provideIcons({cssMenu})]
|
||||||
})
|
})
|
||||||
export class App {
|
export class App implements OnInit {
|
||||||
protected readonly title = signal('hurler-webdesign-saas');
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,27 @@ export class SeoService {
|
|||||||
private renderer =
|
private renderer =
|
||||||
this.rendererFactory.createRenderer(null, null);
|
this.rendererFactory.createRenderer(null, null);
|
||||||
|
|
||||||
|
// Constants for metadata
|
||||||
private readonly BRAND = "Hurler Webdesign";
|
private readonly BRAND = "Hurler Webdesign";
|
||||||
private readonly FALLBACK_IMAGE =
|
private readonly FALLBACK_IMAGE =
|
||||||
'https://hurler-webdesign.de/assets/og-default.jpg';
|
'https://hurler-webdesign.de/assets/og-default.jpg';
|
||||||
private readonly DEFAULT_TYPE = "website";
|
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) {
|
updateMetadata(data: SeoData, canonicalPath?: string) {
|
||||||
const fullTitle = `${data.title} | ${this.BRAND}`;
|
const fullTitle = `${data.title} | ${this.BRAND}`;
|
||||||
const url = `https://hurler-webdesign.de${canonicalPath || ""}`;
|
const url = `https://hurler-webdesign.de${canonicalPath || ""}`;
|
||||||
|
|
||||||
|
// Update title and meta description
|
||||||
this.titleService.setTitle(fullTitle);
|
this.titleService.setTitle(fullTitle);
|
||||||
this.metaService.updateTag({ name: "description", content: data.description });
|
this.metaService.updateTag({ name: "description", content: data.description });
|
||||||
|
|
||||||
// Open Graph
|
// Open Graph metadata
|
||||||
this.metaService.updateTag({
|
this.metaService.updateTag({
|
||||||
property: "og:title",
|
property: "og:title",
|
||||||
content: fullTitle,
|
content: fullTitle,
|
||||||
@@ -44,7 +52,7 @@ export class SeoService {
|
|||||||
});
|
});
|
||||||
this.metaService.updateTag({ property: "og:url", content: url });
|
this.metaService.updateTag({ property: "og:url", content: url });
|
||||||
|
|
||||||
// Twitter
|
// Twitter card metadata
|
||||||
this.metaService.updateTag({
|
this.metaService.updateTag({
|
||||||
name: "twitter:card",
|
name: "twitter:card",
|
||||||
content: "summary_large_image",
|
content: "summary_large_image",
|
||||||
@@ -63,13 +71,20 @@ export class SeoService {
|
|||||||
content: data.image || this.FALLBACK_IMAGE,
|
content: data.image || this.FALLBACK_IMAGE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update canonical URL if provided
|
||||||
if (canonicalPath) {
|
if (canonicalPath) {
|
||||||
this.updateCanonicalUrl(url);
|
this.updateCanonicalUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set local business schema.org metadata
|
||||||
this.setLocalBusinessSchema();
|
this.setLocalBusinessSchema();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the canonical URL for the given page.
|
||||||
|
*
|
||||||
|
* @param url - The new canonical URL.
|
||||||
|
*/
|
||||||
private updateCanonicalUrl(url: string) {
|
private updateCanonicalUrl(url: string) {
|
||||||
let link: HTMLLinkElement =
|
let link: HTMLLinkElement =
|
||||||
this.document.querySelector("link[rel='canonical']") ||
|
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() {
|
private setLocalBusinessSchema() {
|
||||||
const oldScript = this.document.getElementById('schema-org-data');
|
const oldScript = this.document.getElementById('schema-org-data');
|
||||||
if (oldScript) this.renderer.removeChild(this.document.head, oldScript);
|
if (oldScript) this.renderer.removeChild(this.document.head, oldScript);
|
||||||
|
|||||||
16
src/app/core/services/umami.service.spec.ts
Normal file
16
src/app/core/services/umami.service.spec.ts
Normal file
@@ -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
src/app/core/services/umami.service.ts
Normal file
42
src/app/core/services/umami.service.ts
Normal 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">
|
<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">
|
<h1 class="hero-section__header">
|
||||||
<span>Digitales Handwerk</span> <br />
|
Digitales Handwerk <br />
|
||||||
<span>statt Standard-Baukasten</span>
|
statt Standard-Baukasten
|
||||||
</h1>
|
</h1>
|
||||||
<p class="hero-section__claim">
|
<p class="hero-section__claim">
|
||||||
Wir programmieren Blitzschnelle, sichere und maßgeschneiderte Webseiten für kleine,
|
Wir programmieren Blitzschnelle, sichere und maßgeschneiderte Webseiten für kleine,
|
||||||
mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance.
|
mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-section__links">
|
<div class="hero-section__links">
|
||||||
<button href="#" variant="primary" label="Leistungen ansehen">Leistungen ansehen</button>
|
<app-button [item]="{ label: 'Über uns', type: 'anchor', target: '#about' }" variant="primary"></app-button>
|
||||||
<button href="#" variant="secondary" label="Warum kein WordPress?">
|
<app-button (click)="onFeaturesClick()" [item]="{ label: 'Warum kein Wordpress', type: 'anchor', target: 'about'}"
|
||||||
Warum kein WordPress?
|
variant="primary"></app-button>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1,4 +1,66 @@
|
|||||||
|
@use "abstracts";
|
||||||
|
|
||||||
.hero-section {
|
.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%;
|
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 { Component } from '@angular/core';
|
||||||
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
|
import { UmamiService } from '@core/services/umami.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-hero',
|
selector: 'app-hero',
|
||||||
imports: [],
|
imports: [ButtonComponent],
|
||||||
templateUrl: './hero.component.html',
|
templateUrl: './hero.component.html',
|
||||||
styleUrl: './hero.component.scss',
|
styleUrl: './hero.component.scss',
|
||||||
})
|
})
|
||||||
export class HeroComponent {
|
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">
|
<section class="header">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<span class="logo-container__logo centered">H</span>
|
<span class="logo-container__logo centered">H</span>
|
||||||
@@ -13,3 +14,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
@use "abstracts";
|
@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 {
|
.header {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
<div class="landing-grid">
|
|
||||||
<div class="grid-area-navigation">
|
|
||||||
<app-navigation></app-navigation>
|
<app-navigation></app-navigation>
|
||||||
</div>
|
|
||||||
<div class="grid-area-hero" id="hero">
|
|
||||||
<app-hero></app-hero>
|
<app-hero></app-hero>
|
||||||
</div>
|
|
||||||
<div class="grid-area-features" id="features-section">
|
|
||||||
<app-features-section></app-features-section>
|
<app-features-section></app-features-section>
|
||||||
</div>
|
|
||||||
<div class="grid-area-footer">
|
|
||||||
<app-footer></app-footer>
|
<app-footer></app-footer>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
118
src/app/shared/ui/button/README_BUTTON.md
Normal file
118
src/app/shared/ui/button/README_BUTTON.md
Normal 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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({
|
@Component({
|
||||||
selector: 'app-button',
|
selector: 'app-button',
|
||||||
imports: [],
|
standalone: true,
|
||||||
|
imports: [RouterModule],
|
||||||
templateUrl: './button.component.html',
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
|||||||
@@ -1,3 +1,2 @@
|
|||||||
@use "abstracts";
|
@use "abstracts";
|
||||||
@use "base";
|
@use "base";
|
||||||
@use "layout";
|
|
||||||
@@ -13,3 +13,9 @@ $breakpoints: (
|
|||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin container-wrapper {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: auto;
|
||||||
|
padding-inline: 20px;
|
||||||
|
}
|
||||||
@@ -26,8 +26,7 @@
|
|||||||
--button-text: oklch(100% 0.01 250);
|
--button-text: oklch(100% 0.01 250);
|
||||||
--border: oklch(90% 0.02 250);
|
--border: oklch(90% 0.02 250);
|
||||||
--shadow-color: oklch(0% 0 250);
|
--shadow-color: oklch(0% 0 250);
|
||||||
|
--overlay: oklch(90% 0.02 250 / 0.9); // Stapelbare Werte
|
||||||
// Stapelbare Werte
|
|
||||||
--z-index-sticky: 100;
|
--z-index-sticky: 100;
|
||||||
|
|
||||||
// Skalierung (modulare Skala)
|
// Skalierung (modulare Skala)
|
||||||
@@ -41,6 +40,7 @@
|
|||||||
--font-size-base: clamp(1rem, 0.5vw + 0.875rem, 1.125rem);
|
--font-size-base: clamp(1rem, 0.5vw + 0.875rem, 1.125rem);
|
||||||
--font-size-lg: clamp(1.25rem, 1vw + 1rem, 1.5rem);
|
--font-size-lg: clamp(1.25rem, 1vw + 1rem, 1.5rem);
|
||||||
--font-size-xl: clamp(1.5rem, 2vw + 1rem, 2.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"] {
|
[data-theme="dark"] {
|
||||||
@@ -54,6 +54,8 @@
|
|||||||
|
|
||||||
--border: oklch(25% 0.02 250);
|
--border: oklch(25% 0.02 250);
|
||||||
--shadow-color: 0 0% 100%;
|
--shadow-color: 0 0% 100%;
|
||||||
|
--overlay: oklch(30% 0.0075 250 / 0.9);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==============================
|
// ==============================
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
@use "abstracts";
|
@use "abstracts";
|
||||||
|
|
||||||
.landing-grid {
|
.layout-wrapper {
|
||||||
display: grid;
|
max-width: 1200px;
|
||||||
grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
|
margin: auto;
|
||||||
grid-template-areas:
|
padding-inline: 20px;
|
||||||
"navigation navigation navigation"
|
|
||||||
"hero hero hero"
|
|
||||||
". features-section ."
|
|
||||||
"footer footer footer";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-area-navigation {
|
// .landing-grid {
|
||||||
grid-area: navigation;
|
// display: grid;
|
||||||
border-radius: 0 0 10px 10px;
|
// grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
|
||||||
background-color: var(--nav-bg);
|
// grid-template-areas:
|
||||||
backdrop-filter: var(--nav-backdrop);
|
// "navigation navigation navigation"
|
||||||
box-shadow: var(--nav-shadow);
|
// "hero hero hero"
|
||||||
position: sticky;
|
// ". features-section ."
|
||||||
top: 0;
|
// "footer footer footer";
|
||||||
z-index: var(--z-index-sticky);
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
.grid-area-hero {
|
// .grid-area-navigation {
|
||||||
grid-area: hero;
|
// grid-area: navigation;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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-hero {
|
||||||
grid-area: features-section;
|
// grid-area: hero;
|
||||||
height: 80vh;
|
// 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 {
|
// [data-theme="dark"] .grid-area-hero {
|
||||||
grid-area: footer;
|
// 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;
|
||||||
|
// }
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
@forward "grid";
|
|
||||||
@forward "header";
|
|
||||||
Reference in New Issue
Block a user