Compare commits
10
Commits
b6ca9be225
...
5138005397
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5138005397 | ||
|
|
ff70a0b4a5 | ||
|
|
11a266879b | ||
|
|
5cbe4525d1 | ||
|
|
1294f0f07c | ||
|
|
79c71fcf45 | ||
|
|
ce8cb07834 | ||
|
|
5622fc211f | ||
|
|
8cbefe7078 | ||
|
|
2fdeb41c25 |
@@ -0,0 +1,23 @@
|
|||||||
|
# This is an example configuration file
|
||||||
|
# To learn more, see the full config.yaml reference: https://docs.continue.dev/reference
|
||||||
|
|
||||||
|
name: Example Config
|
||||||
|
version: 1.0.0
|
||||||
|
schema: v1
|
||||||
|
|
||||||
|
# Define which models can be used
|
||||||
|
# https://docs.continue.dev/customization/models
|
||||||
|
models:
|
||||||
|
- name: my gpt-5
|
||||||
|
provider: openai
|
||||||
|
model: gpt-5
|
||||||
|
apiKey: YOUR_OPENAI_API_KEY_HERE
|
||||||
|
- uses: ollama/qwen2.5-coder-7b
|
||||||
|
- uses: anthropic/claude-4-sonnet
|
||||||
|
with:
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
|
||||||
|
# MCP Servers that Continue can access
|
||||||
|
# https://docs.continue.dev/customization/mcp-tools
|
||||||
|
mcpServers:
|
||||||
|
- uses: anthropic/memory-mcp
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
name: New MCP server
|
||||||
|
version: 0.0.1
|
||||||
|
schema: v1
|
||||||
|
mcpServers:
|
||||||
|
- name: New MCP server
|
||||||
|
command: npx
|
||||||
|
args:
|
||||||
|
- -y
|
||||||
|
- <your-mcp-server>
|
||||||
|
env: {}
|
||||||
+5
-1
@@ -11,7 +11,8 @@
|
|||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {
|
"schematics": {
|
||||||
"@schematics/angular:component": {
|
"@schematics/angular:component": {
|
||||||
"style": "scss"
|
"style": "scss",
|
||||||
|
"type": "component"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "",
|
"root": "",
|
||||||
@@ -37,6 +38,9 @@
|
|||||||
"outputMode": "server",
|
"outputMode": "server",
|
||||||
"ssr": {
|
"ssr": {
|
||||||
"entry": "src/server.ts"
|
"entry": "src/server.ts"
|
||||||
|
},
|
||||||
|
"stylePreprocessorOptions": {
|
||||||
|
"includePaths": ["src/styles"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
|
|||||||
Generated
+861
-768
File diff suppressed because it is too large
Load Diff
+12
-11
@@ -24,26 +24,27 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.8.0",
|
"packageManager": "npm@11.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^21.0.0",
|
"@angular/common": "^21.2.5",
|
||||||
"@angular/compiler": "^21.0.0",
|
"@angular/compiler": "^21.2.5",
|
||||||
"@angular/core": "^21.0.0",
|
"@angular/core": "^21.2.5",
|
||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.2.5",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.2.5",
|
||||||
"@angular/platform-server": "^21.0.0",
|
"@angular/platform-server": "^21.2.5",
|
||||||
"@angular/router": "^21.0.0",
|
"@angular/router": "^21.2.5",
|
||||||
"@angular/ssr": "^21.0.3",
|
"@angular/ssr": "^21.0.3",
|
||||||
"@ng-icons/core": "^33.1.0",
|
"@ng-icons/core": "^33.1.0",
|
||||||
"@ng-icons/css.gg": "^33.1.0",
|
"@ng-icons/css.gg": "^33.1.0",
|
||||||
|
"@openpanel/web": "^1.2.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.3",
|
"@angular/build": "^21.2.3",
|
||||||
"@angular/cli": "^21.1.4",
|
"@angular/cli": "^21.2.3",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.2.5",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/node": "^20.17.19",
|
"@types/node": "^20.19.37",
|
||||||
"jsdom": "^27.1.0",
|
"jsdom": "^27.1.0",
|
||||||
"typescript": "~5.9.2",
|
"typescript": "~5.9.2",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,8 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { provideNgIconsConfig } from '@ng-icons/core';
|
import { provideNgIconsConfig } from '@ng-icons/core';
|
||||||
|
import { environment } from '../environments/openpanel';
|
||||||
|
import { provideOpenPanel } from '@core/provider/openpanel.provider';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||||
@@ -10,6 +12,12 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideClientHydration(withEventReplay()),
|
provideClientHydration(withEventReplay()),
|
||||||
provideNgIconsConfig({})
|
provideNgIconsConfig({}),
|
||||||
|
provideOpenPanel({
|
||||||
|
clientId: environment.openPanel.clientId,
|
||||||
|
apiUrl: environment.openPanel.apiUrl,
|
||||||
|
trackScreenViews: true,
|
||||||
|
debug: !environment.production,
|
||||||
|
})
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||||
|
|
||||||
export const serverRoutes: ServerRoute[] = [
|
export const serverRoutes: ServerRoute[] = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
renderMode: RenderMode.Prerender
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '**',
|
path: '**',
|
||||||
renderMode: RenderMode.Prerender
|
renderMode: RenderMode.Prerender
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Routes } from '@angular/router';
|
import { Routes } from '@angular/router';
|
||||||
import { LandingpageComponent } from './features/landing/pages/landingpage.component';
|
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
component: LandingpageComponent
|
loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
+19
-4
@@ -1,7 +1,9 @@
|
|||||||
import { Component, signal } 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 {cssMenu} from "@ng-icons/css.gg"
|
import { filter } from 'rxjs/operators';
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Directive, HostListener, Input, inject } from '@angular/core';
|
||||||
|
import { OpenPanelService, TrackProperties } from '@core/services/openpanel.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directive for declarative event tracking directly in templates.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <button opTrack="signup_clicked" [opTrackProps]="{ location: 'hero' }">
|
||||||
|
* Sign Up
|
||||||
|
* </button>
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* <a routerLink="/pricing" opTrack="pricing_link_clicked">Pricing</a>
|
||||||
|
*/
|
||||||
|
@Directive({
|
||||||
|
selector: '[opTrack]',
|
||||||
|
standalone: true,
|
||||||
|
})
|
||||||
|
export class OpenPanelTrackDirective {
|
||||||
|
private readonly op = inject(OpenPanelService);
|
||||||
|
|
||||||
|
/** The event name to track on click. */
|
||||||
|
@Input({ required: true }) opTrack!: string;
|
||||||
|
|
||||||
|
/** Optional properties to send with the event. */
|
||||||
|
@Input() opTrackProps?: TrackProperties;
|
||||||
|
|
||||||
|
/** Which DOM event triggers tracking. Default: 'click' */
|
||||||
|
@Input() opTrackOn: 'click' | 'mouseenter' | 'focus' | 'blur' = 'click';
|
||||||
|
|
||||||
|
@HostListener('click')
|
||||||
|
onClick(): void {
|
||||||
|
if (this.opTrackOn === 'click') {
|
||||||
|
this.op.track(this.opTrack, this.opTrackProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('mouseenter')
|
||||||
|
onMouseEnter(): void {
|
||||||
|
if (this.opTrackOn === 'mouseenter') {
|
||||||
|
this.op.track(this.opTrack, this.opTrackProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('focus')
|
||||||
|
onFocus(): void {
|
||||||
|
if (this.opTrackOn === 'focus') {
|
||||||
|
this.op.track(this.opTrack, this.opTrackProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('blur')
|
||||||
|
onBlur(): void {
|
||||||
|
if (this.opTrackOn === 'blur') {
|
||||||
|
this.op.track(this.opTrack, this.opTrackProps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type NavigationType = "anchor" | "route" | "external";
|
||||||
|
|
||||||
|
export interface NavigationItem {
|
||||||
|
readonly label: string;
|
||||||
|
readonly type: NavigationType;
|
||||||
|
readonly target: string;
|
||||||
|
readonly icon?: string;
|
||||||
|
readonly children?: NavigationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isAnchor = (item: NavigationItem): boolean => item.type === "anchor";
|
||||||
|
export const isRoute = (item: NavigationItem): boolean => item.type === "route";
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { InjectionToken } from '@angular/core';
|
||||||
|
|
||||||
|
export interface OpenPanelConfig {
|
||||||
|
/** Your OpenPanel Client ID (required) */
|
||||||
|
clientId: string;
|
||||||
|
|
||||||
|
/** URL of your OpenPanel API or self-hosted instance.
|
||||||
|
* Defaults to https://api.openpanel.dev */
|
||||||
|
apiUrl?: string;
|
||||||
|
|
||||||
|
/** Automatically track Angular Router navigation events as screen views.
|
||||||
|
* Default: true */
|
||||||
|
trackScreenViews?: boolean;
|
||||||
|
|
||||||
|
/** Track clicks on outgoing links automatically.
|
||||||
|
* Default: false */
|
||||||
|
trackOutgoingLinks?: boolean;
|
||||||
|
|
||||||
|
/** Enable declarative tracking via data-track HTML attributes.
|
||||||
|
* Default: false */
|
||||||
|
trackAttributes?: boolean;
|
||||||
|
|
||||||
|
/** Global properties sent with every event (e.g. app_version, environment). */
|
||||||
|
globalProperties?: Record<string, string | number | boolean>;
|
||||||
|
|
||||||
|
/** Completely disable all tracking (e.g. in test environments).
|
||||||
|
* Default: false */
|
||||||
|
disabled?: boolean;
|
||||||
|
|
||||||
|
/** Enable verbose console logging for debugging.
|
||||||
|
* Default: false */
|
||||||
|
debug?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OPENPANEL_CONFIG = new InjectionToken<OpenPanelConfig>('OPENPANEL_CONFIG');
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
client: string;
|
||||||
|
description: string;
|
||||||
|
socialDescription?: string;
|
||||||
|
image: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface SeoData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
socialsDescription?: string;
|
||||||
|
image?: string;
|
||||||
|
type?: "website" | "article";
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
|
||||||
|
import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model';
|
||||||
|
import { OpenPanelService } from '@core/services/openpanel.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the OpenPanel analytics service for your Angular application.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // app.config.ts
|
||||||
|
* import { provideOpenPanel } from './openpanel/openpanel.provider';
|
||||||
|
*
|
||||||
|
* export const appConfig: ApplicationConfig = {
|
||||||
|
* providers: [
|
||||||
|
* provideRouter(routes),
|
||||||
|
* provideOpenPanel({
|
||||||
|
* clientId: 'your-client-id',
|
||||||
|
* trackScreenViews: true,
|
||||||
|
* globalProperties: {
|
||||||
|
* app_version: '1.0.0',
|
||||||
|
* environment: 'production',
|
||||||
|
* },
|
||||||
|
* }),
|
||||||
|
* ],
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
export function provideOpenPanel(config: OpenPanelConfig): EnvironmentProviders {
|
||||||
|
return makeEnvironmentProviders([
|
||||||
|
{ provide: OPENPANEL_CONFIG, useValue: config },
|
||||||
|
OpenPanelService,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { NavigationService } from './navigation.service';
|
||||||
|
|
||||||
|
describe('NavigationService', () => {
|
||||||
|
let service: NavigationService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(NavigationService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// core/services/navigation.service.ts
|
||||||
|
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import { NavigationItem, isAnchor, isRoute } from '../models/navigation.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NavigationService {
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
// === STATE ===
|
||||||
|
private readonly _navigationItems = signal<NavigationItem[]>([
|
||||||
|
// Anchor-Links (Landingpage intern)
|
||||||
|
{ label: 'Home', type: 'anchor', target: 'hero' },
|
||||||
|
{ label: 'Features', type: 'anchor', target: 'features-section'},
|
||||||
|
{ label: 'Projekte', type: 'anchor', target: 'projects' },
|
||||||
|
{ label: 'Pricing', type: 'anchor', target: 'pricing' },
|
||||||
|
|
||||||
|
// Route-Links ( andere Pages)
|
||||||
|
{ label: 'Login', type: 'route', target: '/login' },
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
type: 'route',
|
||||||
|
target: '/dashboard',
|
||||||
|
icon: 'layout',
|
||||||
|
// Geschützte Route - wird später gefiltert
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// === PUBLIC SIGNALS ===
|
||||||
|
readonly navigationItems = this._navigationItems.asReadonly();
|
||||||
|
|
||||||
|
// Gefiltert nach Kontext (Landingpage vs. App)
|
||||||
|
readonly landingNavigation = computed(() =>
|
||||||
|
this._navigationItems().filter(item =>
|
||||||
|
isAnchor(item) || item.target === '/login'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly appNavigation = computed(() =>
|
||||||
|
this._navigationItems().filter(item => isRoute(item))
|
||||||
|
);
|
||||||
|
|
||||||
|
// === METHODS ===
|
||||||
|
navigate(item: NavigationItem): void {
|
||||||
|
switch (item.type) {
|
||||||
|
case 'anchor':
|
||||||
|
this.scrollToAnchor(item.target);
|
||||||
|
break;
|
||||||
|
case 'route':
|
||||||
|
this.router.navigate([item.target]);
|
||||||
|
break;
|
||||||
|
case 'external':
|
||||||
|
window.open(item.target, '_blank');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scrollToAnchor(anchorId: string): void {
|
||||||
|
const element = document.getElementById(anchorId);
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamische Updates (z.B. nach Login)
|
||||||
|
addItem(item: NavigationItem): void {
|
||||||
|
this._navigationItems.update(items => [...items, item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(target: string): void {
|
||||||
|
this._navigationItems.update(items =>
|
||||||
|
items.filter(i => i.target !== target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { OpenPanelService } from './openpanel.service';
|
||||||
|
|
||||||
|
describe('OpenpanelService', () => {
|
||||||
|
let service: OpenPanelService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(OpenPanelService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { Injectable, OnDestroy, inject } from '@angular/core';
|
||||||
|
import { Router, NavigationEnd } from '@angular/router';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
|
import { filter, Subscription, skip } from 'rxjs';
|
||||||
|
import { OpenPanel } from '@openpanel/web';
|
||||||
|
import type { IdentifyPayload } from '@openpanel/web';
|
||||||
|
import { OpenPanelConfig, OPENPANEL_CONFIG } from '@core/models/openpanel.model';
|
||||||
|
|
||||||
|
export type TrackProperties = Record<string, string | number | boolean | null | undefined>;
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class OpenPanelService implements OnDestroy {
|
||||||
|
private readonly config = inject(OPENPANEL_CONFIG);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID)
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
private op?: OpenPanel;
|
||||||
|
private routerSubscription?: Subscription;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if(isPlatformBrowser(this.platformId)) {
|
||||||
|
this.initialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Initialization ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private initialize(): void {
|
||||||
|
this.op = new OpenPanel({
|
||||||
|
clientId: this.config.clientId,
|
||||||
|
apiUrl: this.config.apiUrl,
|
||||||
|
trackScreenViews: false, // We handle this manually via Router
|
||||||
|
trackOutgoingLinks: this.config.trackOutgoingLinks ?? false,
|
||||||
|
trackAttributes: this.config.trackAttributes ?? false,
|
||||||
|
disabled: this.config.disabled ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.config.globalProperties) {
|
||||||
|
this.op.setGlobalProperties(this.config.globalProperties);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.trackScreenViews !== false) {
|
||||||
|
this.setupRouteTracking();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks a custom event with optional properties.
|
||||||
|
* @example opService.track('button_clicked', { button_name: 'signup' });
|
||||||
|
*/
|
||||||
|
track(eventName: string, properties?: TrackProperties): void {
|
||||||
|
if (!this.op) return;
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.debug('[OpenPanel] track:', eventName, properties);
|
||||||
|
}
|
||||||
|
this.op.track(eventName, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifies the current user. Call this after login.
|
||||||
|
* @example opService.identify({ profileId: 'user-123', email: 'user@example.com' });
|
||||||
|
*/
|
||||||
|
identify(payload: IdentifyPayload): void {
|
||||||
|
if (!this.op) return;
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.debug('[OpenPanel] identify:', payload.profileId);
|
||||||
|
}
|
||||||
|
this.op.identify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the current user identity. Call this on logout.
|
||||||
|
*/
|
||||||
|
clearUser(): void {
|
||||||
|
if (!this.op) return;
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.debug('[OpenPanel] clearUser');
|
||||||
|
}
|
||||||
|
this.op.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets properties that will be sent with every subsequent event.
|
||||||
|
*/
|
||||||
|
setGlobalProperties(properties: TrackProperties): void {
|
||||||
|
if (!this.op) return;
|
||||||
|
this.op.setGlobalProperties(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments a numeric property on the user profile.
|
||||||
|
* @example opService.increment('login_count');
|
||||||
|
*/
|
||||||
|
increment(property: string): void {
|
||||||
|
if (!this.op) return;
|
||||||
|
this.op.increment(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrements a numeric property on the user profile.
|
||||||
|
* @example opService.decrement('credits', 5);
|
||||||
|
*/
|
||||||
|
decrement(property: string): void {
|
||||||
|
if (!this.op) return;
|
||||||
|
this.op.decrement(property);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually tracks a screen/page view.
|
||||||
|
*/
|
||||||
|
trackScreenView(path?: string): void {
|
||||||
|
if (!this.op) return;
|
||||||
|
const currentPath = path ?? this.router.url;
|
||||||
|
if (this.config.debug) {
|
||||||
|
console.debug('[OpenPanel] screenView:', currentPath);
|
||||||
|
}
|
||||||
|
this.op.track('screen_view', { path: currentPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cleanup ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.routerSubscription?.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { SeoService } from './seo.service';
|
||||||
|
|
||||||
|
describe('SeoService', () => {
|
||||||
|
let service: SeoService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(SeoService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { Injectable, inject } from "@angular/core";
|
||||||
|
import { Title, Meta } from "@angular/platform-browser";
|
||||||
|
import { DOCUMENT } from "@angular/common";
|
||||||
|
import { RendererFactory2 } from "@angular/core";
|
||||||
|
import { SeoData } from "@core/models/seo.model";
|
||||||
|
|
||||||
|
@Injectable({ providedIn: "root" })
|
||||||
|
export class SeoService {
|
||||||
|
private titleService = inject(Title);
|
||||||
|
private metaService = inject(Meta);
|
||||||
|
private document = inject(DOCUMENT);
|
||||||
|
private rendererFactory = inject(RendererFactory2);
|
||||||
|
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 metadata
|
||||||
|
this.metaService.updateTag({
|
||||||
|
property: "og:title",
|
||||||
|
content: fullTitle,
|
||||||
|
});
|
||||||
|
this.metaService.updateTag({
|
||||||
|
property: "og:description",
|
||||||
|
content: data.description,
|
||||||
|
});
|
||||||
|
this.metaService.updateTag({
|
||||||
|
property: "og:type",
|
||||||
|
content: data.type || this.DEFAULT_TYPE,
|
||||||
|
});
|
||||||
|
this.metaService.updateTag({
|
||||||
|
property: "og:image",
|
||||||
|
content: data.image || this.FALLBACK_IMAGE,
|
||||||
|
});
|
||||||
|
this.metaService.updateTag({ property: "og:url", content: url });
|
||||||
|
|
||||||
|
// Twitter card metadata
|
||||||
|
this.metaService.updateTag({
|
||||||
|
name: "twitter:card",
|
||||||
|
content: "summary_large_image",
|
||||||
|
});
|
||||||
|
this.metaService.updateTag({
|
||||||
|
name: "twitter:title",
|
||||||
|
content: fullTitle,
|
||||||
|
});
|
||||||
|
this.metaService.updateTag({
|
||||||
|
name: "twitter:description",
|
||||||
|
content: data.socialsDescription || data.description,
|
||||||
|
});
|
||||||
|
this.metaService.updateTag({ name: "twitter:url", content: url });
|
||||||
|
this.metaService.updateTag({
|
||||||
|
name: "twitter:image",
|
||||||
|
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']") ||
|
||||||
|
this.renderer.createElement("link");
|
||||||
|
this.renderer.setAttribute(link, "rel", "canonical");
|
||||||
|
this.renderer.setAttribute(link, "href", url);
|
||||||
|
if (!this.document.head.contains(link)) {
|
||||||
|
this.renderer.appendChild(this.document.head, link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebDesignService",
|
||||||
|
"name": this.BRAND,
|
||||||
|
"url": "https://hurler-webdesign.de",
|
||||||
|
"logo": "https://hurler-webdesign.de/assets/logo.png",
|
||||||
|
"image": "https://hurler-webdesign.de/assets/office.jpg",
|
||||||
|
"description": "Spezialist für schnelle Webseiten ohne WordPress für Handwerk & Vereine. Wir bieten maßgeschneidertes Webdesign für eine schnelle und sichere Online-Präsenz.",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"streetAddress": "Untermagerbein 30",
|
||||||
|
"addressLocality": "Mönchsdeggingen",
|
||||||
|
"postalCode": "86751",
|
||||||
|
"addressCountry": "DE"
|
||||||
|
},
|
||||||
|
"geo": {
|
||||||
|
"@type": "GeoCoordinates",
|
||||||
|
"latitude": 48.7506,
|
||||||
|
"longitude": 10.5773
|
||||||
|
},
|
||||||
|
"areaServed": ["Nördlingen", "Donauwörth", "Augsburg", "Bayern"],
|
||||||
|
"telephone": "+49 171 8084830",
|
||||||
|
"priceRange": "€€"
|
||||||
|
};
|
||||||
|
const script = this.renderer.createElement('script');
|
||||||
|
script.type = 'application/ld+json';
|
||||||
|
script.id = 'schema-org-data';
|
||||||
|
script.text = JSON.stringify(schema);
|
||||||
|
this.renderer.appendChild(this.document.head, script);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
describe('Theme', () => {
|
||||||
|
let service: ThemeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
service = TestBed.inject(ThemeService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be created', () => {
|
||||||
|
expect(service).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// core/services/theme.service.ts
|
||||||
|
import { Injectable, signal, effect, computed, Inject } from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { PLATFORM_ID } from '@angular/core';
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeService {
|
||||||
|
// Privater Signal-State
|
||||||
|
private readonly _theme = signal<Theme>('system');
|
||||||
|
|
||||||
|
// Öffentlicher Readonly-Signal
|
||||||
|
readonly theme = this._theme.asReadonly();
|
||||||
|
|
||||||
|
// Abgeleiteter Wert: Tatsächlich aktives Theme (aufgelöst)
|
||||||
|
readonly effectiveTheme = computed(() => {
|
||||||
|
const current = this._theme();
|
||||||
|
if (current !== 'system') return current;
|
||||||
|
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
? 'dark'
|
||||||
|
: 'light';
|
||||||
|
}
|
||||||
|
return 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor(@Inject(PLATFORM_ID) private platformId: object) {
|
||||||
|
// Effekt: Reagiert auf Änderungen (nur im Browser)
|
||||||
|
effect(() => {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
const theme = this.effectiveTheme();
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', this._theme());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialisierung
|
||||||
|
this.initializeTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: Theme): void {
|
||||||
|
this._theme.set(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeTheme(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
const saved = localStorage.getItem('theme') as Theme | null;
|
||||||
|
if (saved) {
|
||||||
|
this._theme.set(saved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 @@
|
|||||||
|
<p>dashboard works!</p>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Dashboard } from './dashboard';
|
||||||
|
|
||||||
|
describe('Dashboard', () => {
|
||||||
|
let component: Dashboard;
|
||||||
|
let fixture: ComponentFixture<Dashboard>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Dashboard]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Dashboard);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-dashboard',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './dashboard.html',
|
||||||
|
styleUrl: './dashboard.scss',
|
||||||
|
})
|
||||||
|
export class Dashboard {
|
||||||
|
|
||||||
|
}
|
||||||
+15
-1
@@ -1 +1,15 @@
|
|||||||
<p>features-section works!</p>
|
<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>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@use 'abstracts';
|
||||||
|
|
||||||
|
.features-section {
|
||||||
|
min-height: calc(100vh - var(--neg-nav-height));
|
||||||
|
margin-top: var(--neg-nav-height);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__wrapper {
|
||||||
|
@include abstracts.container-wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__grid {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
&__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,5 +1,13 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
interface Features {
|
||||||
|
id: number,
|
||||||
|
claim: string,
|
||||||
|
description: string,
|
||||||
|
icon?: string,
|
||||||
|
iconDescription?: string
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-features-section',
|
selector: 'app-features-section',
|
||||||
imports: [],
|
imports: [],
|
||||||
@@ -7,5 +15,26 @@ import { Component } from '@angular/core';
|
|||||||
styleUrl: './features-section.component.scss',
|
styleUrl: './features-section.component.scss',
|
||||||
})
|
})
|
||||||
export class FeaturesSectionComponent {
|
export class FeaturesSectionComponent {
|
||||||
|
featuresList: Features[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
claim: "Code statt Baukasten",
|
||||||
|
description: "Handgefertigte Performance, die Google und Ihre Nutzer lieben werden."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
claim: "Sicher per Design",
|
||||||
|
description: "Maximale Rechtskonformität durch eRecht24 und hauseigene Server-Infrastruktur."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
claim: "Heimat für Ihre Daten",
|
||||||
|
description: "Hosting und Services strikt nach europäischem Datenschutzstandard."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
claim: "Alles im Blick",
|
||||||
|
description: "Ein Portal für alles: Kommunikation, Verwaltung und Erfolgskontrolle"
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
<p>footer works!</p>
|
<footer class="footer">
|
||||||
|
<div class="footer__wrapper">
|
||||||
|
Hurler Webdesign <br/>
|
||||||
|
Impressum <br/>
|
||||||
|
Über uns
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@use 'abstracts';
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--bg-surface);
|
||||||
|
padding: 20px 0;
|
||||||
|
|
||||||
|
&__wrapper {
|
||||||
|
@include abstracts.container-wrapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,23 @@
|
|||||||
<section>hero works!</section>
|
<section class="hero-section" id="hero">
|
||||||
|
<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">
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
@use "abstracts";
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
position: relative; // WICHTIG: Bezugspunkt für das Video
|
||||||
|
min-height: calc(100vh + var(--nav-height));
|
||||||
|
margin-top: var(--neg-nav-height);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: var(--hero-video-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
|
||||||
|
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,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,15 +1,18 @@
|
|||||||
<div class="logo-container">
|
<div class="wrapper">
|
||||||
<span class="logo-container__logo centered">H</span>
|
<section class="header">
|
||||||
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
|
<div class="logo-container">
|
||||||
</div>
|
<span class="logo-container__logo centered">H</span>
|
||||||
|
<p class="logo-container__company" (click)="onFeaturesClick('lustiges Zeug')"><span>Hurler</span> Webdesign</p>
|
||||||
|
</div>
|
||||||
|
<div class="header__nav-section centered">
|
||||||
|
<div class="theme-toggle-container">
|
||||||
|
<app-toogle-theme></app-toogle-theme>
|
||||||
|
</div>
|
||||||
|
<app-nav-menu [items]="navigationService.landingNavigation()"></app-nav-menu>
|
||||||
|
<div class="burger-menu centered">
|
||||||
|
<ng-icon name="cssMenu" class="burger-menu__icon"></ng-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<nav class="navigation">
|
</div>
|
||||||
<ul class="navigation__list">
|
|
||||||
<li class="navigation__list-item"><a href="#">Home</a></li>
|
|
||||||
<li class="navigation__list-item"><a href="#">Features</a></li>
|
|
||||||
<li class="navigation__list-item"><a href="#">Contact</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div class="burger-menu centered">
|
|
||||||
<ng-icon name="cssMenu" class="burger-menu__icon"></ng-icon>
|
|
||||||
</div>
|
|
||||||
@@ -1,32 +1,28 @@
|
|||||||
@use "../../../../../styles/abstracts";
|
@use "abstracts";
|
||||||
|
|
||||||
.navigation {
|
.wrapper {
|
||||||
display: none;
|
height: var(--nav-height);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
@include abstracts.breakpoint("md") {
|
.header {
|
||||||
display: block;
|
max-width: 1200px;
|
||||||
padding-right: var(--space-4);
|
margin: 0 auto;
|
||||||
}
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: abstracts.rem(60);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
|
||||||
&__list {
|
&__nav-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--space-4);
|
flex-direction: row;
|
||||||
}
|
|
||||||
|
|
||||||
&__list-item {
|
|
||||||
list-style: none;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: var(--font-size-base);
|
|
||||||
color: var(--text-main);
|
|
||||||
transition: color 0.3s ease;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: var(--accent);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--accent-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +35,13 @@
|
|||||||
&__logo {
|
&__logo {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--text-main);
|
color: var(--text-on-accent);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
width: abstracts.rem(30);
|
width: abstracts.rem(30);
|
||||||
height: abstracts.rem(30);
|
height: abstracts.rem(30);
|
||||||
display: flex;
|
display: flex;
|
||||||
background-color: var(--accent);
|
background-color: var(--accent);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__company {
|
&__company {
|
||||||
@@ -60,8 +55,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-toggle-container {
|
||||||
|
width: abstracts.rem(24);
|
||||||
|
height: abstracts.rem(24);
|
||||||
|
margin: auto;
|
||||||
|
margin-right: var(--space-4);
|
||||||
|
|
||||||
|
@include abstracts.breakpoint("md") {
|
||||||
|
margin: auto;
|
||||||
|
margin-right: var(--space-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.burger-menu {
|
.burger-menu {
|
||||||
padding-right: var(--space-4);
|
padding: 0 var(--space-4) 0 var(--space-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
@include abstracts.breakpoint("md") {
|
@include abstracts.breakpoint("md") {
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, inject } from '@angular/core';
|
||||||
import { NgIcon } from '@ng-icons/core';
|
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({
|
@Component({
|
||||||
selector: 'app-navigation',
|
selector: 'app-navigation',
|
||||||
imports: [NgIcon],
|
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent, OpenPanelTrackDirective],
|
||||||
templateUrl: './navigation.component.html',
|
templateUrl: './navigation.component.html',
|
||||||
styleUrl: './navigation.component.scss',
|
styleUrl: './navigation.component.scss',
|
||||||
})
|
})
|
||||||
export class NavigationComponent {
|
export class NavigationComponent {
|
||||||
|
protected readonly navigationService = inject(NavigationService);
|
||||||
|
private op = inject(OpenPanelService)
|
||||||
|
|
||||||
|
onFeaturesClick(blindplan: string): void {
|
||||||
|
this.op.track('features_clicked', { blindplan })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<p>pricing works!</p>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { PricingComponent } from './pricing.component';
|
||||||
|
|
||||||
|
describe('PricingComponent', () => {
|
||||||
|
let component: PricingComponent;
|
||||||
|
let fixture: ComponentFixture<PricingComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PricingComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(PricingComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-pricing',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './pricing.component.html',
|
||||||
|
styleUrl: './pricing.component.scss',
|
||||||
|
})
|
||||||
|
export class PricingComponent {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<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>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@use 'abstracts';
|
||||||
|
|
||||||
|
.projects {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin-top: var(--neg-nav-height);
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ProjectsComponent } from './projects.component';
|
||||||
|
|
||||||
|
describe('ProjectsComponent', () => {
|
||||||
|
let component: ProjectsComponent;
|
||||||
|
let fixture: ComponentFixture<ProjectsComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ProjectsComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProjectsComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
interface Project {
|
||||||
|
id: number,
|
||||||
|
image: string,
|
||||||
|
company: string,
|
||||||
|
shortDescription: string,
|
||||||
|
features: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-projects',
|
||||||
|
imports: [],
|
||||||
|
templateUrl: './projects.component.html',
|
||||||
|
styleUrl: './projects.component.scss',
|
||||||
|
})
|
||||||
|
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"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
company: "Backerei Müller",
|
||||||
|
image: "/images/bakery.jpg",
|
||||||
|
shortDescription: "Landingpage mit wechselnden Angeboten",
|
||||||
|
features: ["SEO", "Angebote", "Dark/Light"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
company: "Backerei Müller",
|
||||||
|
image: "/images/bakery.jpg",
|
||||||
|
shortDescription: "Landingpage mit wechselnden Angeboten",
|
||||||
|
features: ["SEO", "Angebote", "Dark/Light"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// features/landing/public-api.ts (oder index.ts)
|
||||||
|
// Nur was von außen importiert werden soll
|
||||||
|
export { LandingpageComponent } from './pages/landingpage.component';
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
<div class="landing-grid">
|
<app-navigation></app-navigation>
|
||||||
<app-navigation class="navigation__component"></app-navigation>
|
<app-hero></app-hero>
|
||||||
<app-hero class="hero__component"></app-hero>
|
<app-features-section></app-features-section>
|
||||||
<app-features-section class="features-section__component"></app-features-section>
|
<app-projects></app-projects>
|
||||||
<app-footer class="footer__component"></app-footer>
|
<app-footer></app-footer>
|
||||||
</div>
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component, OnInit, inject } from '@angular/core';
|
||||||
import { NavigationComponent } from '../components/navigation/navigation.component';
|
import { NavigationComponent } from '../components/navigation/navigation.component';
|
||||||
import { HeroComponent } from '../components/hero/hero.component';
|
import { HeroComponent } from '../components/hero/hero.component';
|
||||||
import { FeaturesSectionComponent } from '../components/features-section/features-section.component';
|
import { FeaturesSectionComponent } from '../components/features-section/features-section.component';
|
||||||
import { FooterComponent } from '../components/footer/footer.component';
|
import { FooterComponent } from '../components/footer/footer.component';
|
||||||
|
import { ProjectsComponent } from '../components/projects/projects.component';
|
||||||
|
import { SeoService } from '@core/services/seo.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-landingpage',
|
selector: 'app-landingpage',
|
||||||
@@ -10,11 +12,21 @@ import { FooterComponent } from '../components/footer/footer.component';
|
|||||||
NavigationComponent,
|
NavigationComponent,
|
||||||
HeroComponent,
|
HeroComponent,
|
||||||
FeaturesSectionComponent,
|
FeaturesSectionComponent,
|
||||||
FooterComponent
|
ProjectsComponent,
|
||||||
],
|
FooterComponent,
|
||||||
|
],
|
||||||
templateUrl: './landingpage.component.html',
|
templateUrl: './landingpage.component.html',
|
||||||
styleUrl: './landingpage.component.scss',
|
styleUrl: './landingpage.component.scss',
|
||||||
})
|
})
|
||||||
export class LandingpageComponent {
|
export class LandingpageComponent implements OnInit {
|
||||||
|
private seo = inject(SeoService);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seo.updateMetadata({
|
||||||
|
title: 'Schnelle Webseiten für Handwerk & Vereine',
|
||||||
|
description: 'Spezialist für schnelle Webseiten ohne WordPress für Handwerk & Vereine. Wir bieten maßgeschneidertes Webdesign für eine schnelle und sichere Online-Präsenz.',
|
||||||
|
socialsDescription: 'Webdesign ohne WordPress | Hurler Webdesign – Nördlingen',
|
||||||
|
type: 'website'
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<p>button works!</p>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'app-button',
|
|
||||||
imports: [],
|
|
||||||
templateUrl: './button.component.html',
|
|
||||||
styleUrl: './button.component.scss',
|
|
||||||
})
|
|
||||||
export class ButtonComponent {
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!-- 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterModule],
|
||||||
|
templateUrl: './button.component.html',
|
||||||
|
styleUrls: ['./button.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
@use "abstracts";
|
||||||
|
|
||||||
|
.navigation {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@include abstracts.breakpoint("md") {
|
||||||
|
display: block;
|
||||||
|
padding-right: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list-item {
|
||||||
|
list-style: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-main);
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { NavMenuComponent } from './nav-menu.component';
|
||||||
|
|
||||||
|
describe('NavMenuComponent', () => {
|
||||||
|
let component: NavMenuComponent;
|
||||||
|
let fixture: ComponentFixture<NavMenuComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [NavMenuComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(NavMenuComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Component, inject, input } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { NgIcon } from '@ng-icons/core';
|
||||||
|
import { NavigationService } from '@core/services/navigation.service';
|
||||||
|
import {NavigationItem, isAnchor, isRoute} from '@core/models/navigation.model';
|
||||||
|
import { OpenPanelTrackDirective } from '@core/directives/openpanel.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-nav-menu',
|
||||||
|
imports: [RouterLink, NgIcon, OpenPanelTrackDirective],
|
||||||
|
templateUrl: './nav-menu.component.html',
|
||||||
|
styleUrl: './nav-menu.component.scss',
|
||||||
|
})
|
||||||
|
export class NavMenuComponent {
|
||||||
|
protected readonly navigationService = inject(NavigationService);
|
||||||
|
items = input<NavigationItem[]>([]);
|
||||||
|
|
||||||
|
protected readonly isAnchor = isAnchor;
|
||||||
|
protected readonly isRoute = isRoute;
|
||||||
|
|
||||||
|
onAnchorClick(event: Event, item: NavigationItem): void {
|
||||||
|
event.preventDefault();
|
||||||
|
this.navigationService.navigate(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<ng-icon name="cssSun" (click)="toggleTheme()" class="theme-icon-sun"></ng-icon>
|
||||||
|
|
||||||
|
<ng-icon name="cssMoon" (click)="toggleTheme()" class="theme-icon-moon"></ng-icon>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.theme-icon-moon,
|
||||||
|
.theme-icon-sun {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context([data-theme='dark']) .theme-icon-sun {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context([data-theme='light']) .theme-icon-moon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { ToogleThemeComponent } from './toogle-theme.component';
|
||||||
|
|
||||||
|
describe('ToogleThemeComponent', () => {
|
||||||
|
let component: ToogleThemeComponent;
|
||||||
|
let fixture: ComponentFixture<ToogleThemeComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [ToogleThemeComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ToogleThemeComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ThemeService } from '@core/services/theme.service';
|
||||||
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
|
import { cssMoon, cssSun } from '@ng-icons/css.gg';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-toogle-theme',
|
||||||
|
imports: [NgIcon],
|
||||||
|
templateUrl: './toogle-theme.component.html',
|
||||||
|
styleUrl: './toogle-theme.component.scss',
|
||||||
|
viewProviders: [provideIcons({cssMoon, cssSun})]
|
||||||
|
})
|
||||||
|
export class ToogleThemeComponent {
|
||||||
|
themeService = inject(ThemeService);
|
||||||
|
toggleTheme() {
|
||||||
|
const current = this.themeService.theme();
|
||||||
|
const next = current === 'light' ? 'dark' : 'light';
|
||||||
|
this.themeService.setTheme(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Project } from "@core/models/project.model";
|
||||||
|
|
||||||
|
export const MOCK_PROJECTS: Project[] = [
|
||||||
|
{
|
||||||
|
id: "schreiner-mueller",
|
||||||
|
name: "Schreiner Müller",
|
||||||
|
client: "Schreiner Müller GmbH",
|
||||||
|
description: "Ein modernes Webdesign für die Schreiner Müller GmbH, das die Handwerkskunst und Qualität ihrer Dienstleistungen hervorhebt. Die Website bietet eine benutzerfreundliche Navigation, ansprechende Bildergalerien und detaillierte Informationen zu ihren maßgeschneiderten Schreinerarbeiten.",
|
||||||
|
image: "assets/projects/schreiner-mueller.jpg"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
+2
-3
@@ -1,3 +1,2 @@
|
|||||||
@use "styles/abstracts";
|
@use "abstracts";
|
||||||
@use "styles/base";
|
@use "base";
|
||||||
@use "styles/layout";
|
|
||||||
@@ -9,7 +9,13 @@ $breakpoints: (
|
|||||||
);
|
);
|
||||||
|
|
||||||
@mixin breakpoint($size) {
|
@mixin breakpoint($size) {
|
||||||
@media (width >=map.get($breakpoints, $size)) {
|
@media (width >= map.get($breakpoints, $size)) {
|
||||||
@content;
|
@content;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin container-wrapper {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding-inline: 20px;
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
|
@use "tokens";
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
scroll-padding-top: 60px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
box-sizing: inherit;
|
box-sizing: inherit;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
|
@forward "tokens";
|
||||||
@forward "boilerplate";
|
@forward "boilerplate";
|
||||||
@forward "typography";
|
@forward "typography";
|
||||||
@forward "tokens";
|
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
|
@use "abstracts";
|
||||||
|
@use 'sass:math';
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Tier 1: Primitives
|
||||||
|
// ==============================
|
||||||
|
:root {
|
||||||
|
// Sie haben diese bereits als OKLCH
|
||||||
|
--color-white: oklch(100% 0 0);
|
||||||
|
--color-black: oklch(0% 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Tier 2: Semantic
|
||||||
|
// ==============================
|
||||||
:root {
|
:root {
|
||||||
// Farben
|
// Farben
|
||||||
--brand-vue: 250;
|
--brand-vue: 250;
|
||||||
--bg-surface: oklch(88% 0.01 250);
|
--bg-surface: oklch(100% 0.01 250);
|
||||||
--bg-muted: oklch(99% 0.02 250);
|
--bg-muted: oklch(99% 0.02 250);
|
||||||
--text-main: oklch(20% 0.01 250);
|
--text-main: oklch(20% 0.01 250);
|
||||||
--text-muted: oklch(45% 0.02 250);
|
--text-muted: oklch(45% 0.02 250);
|
||||||
@@ -10,8 +25,10 @@
|
|||||||
--accent-hover: oklch(55% 0.22 250);
|
--accent-hover: oklch(55% 0.22 250);
|
||||||
|
|
||||||
--button-text: oklch(100% 0.01 250);
|
--button-text: oklch(100% 0.01 250);
|
||||||
--border: oklch(90% 0.02 250);
|
--border-color: oklch(31.836% 0.00775 250);
|
||||||
|
--border-radius: 10px;
|
||||||
--shadow-color: oklch(0% 0 250);
|
--shadow-color: oklch(0% 0 250);
|
||||||
|
--z-index-sticky: 100;
|
||||||
|
|
||||||
// Skalierung (modulare Skala)
|
// Skalierung (modulare Skala)
|
||||||
--space-unit: 0.25rem;
|
--space-unit: 0.25rem;
|
||||||
@@ -24,6 +41,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"] {
|
||||||
@@ -37,4 +55,35 @@
|
|||||||
|
|
||||||
--border: oklch(25% 0.02 250);
|
--border: oklch(25% 0.02 250);
|
||||||
--shadow-color: 0 0% 100%;
|
--shadow-color: 0 0% 100%;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Tier 3: Component-Specific
|
||||||
|
// ==============================
|
||||||
|
:root {
|
||||||
|
--text-on-accent: var(--color-white);
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
--nav-shadow: 1px 2px 10px oklch(80% 0 250 / 0.1);
|
||||||
|
--nav-backdrop: blur(10px);
|
||||||
|
--nav-bg: oklch(100% 0.00011 271.152 / 0.05);
|
||||||
|
--nav-height: 3.75rem;
|
||||||
|
--neg-nav-height: -3.75rem;
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
--hero-video-overlay: oklch(100% 0.01 250 / 0.9);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--text-on-accent: var(--color-white);
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
--nav-shadow: 1px 2px 10px oklch(0% 0 250 / 0.5);
|
||||||
|
--nav-bg: oklch(20% 0.02 250 / 0.8);
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
--hero-video-overlay: oklch(15% 0.02 250 / 0.9);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@use 'sass:math';
|
||||||
|
@use 'abstracts' as *;
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Base Typography
|
||||||
|
// ==============================
|
||||||
|
:root {
|
||||||
|
// Schriftarten (Beispiel: System-Stack)
|
||||||
|
--font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', sans-serif;
|
||||||
|
--font-family-heading: var(--font-family-base); // oder eine spezifische Schrift wie 'Inter', 'Helvetica Neue'
|
||||||
|
|
||||||
|
// Schriftgrößen (fluid, basierend auf _tokens.scss)
|
||||||
|
--font-size-sm: clamp(0.875rem, 0.5vw + 0.75rem, 1rem);
|
||||||
|
--font-size-base: var(--font-size-base); // aus _tokens.scss
|
||||||
|
--font-size-lg: var(--font-size-lg); // aus _tokens.scss
|
||||||
|
--font-size-xl: var(--font-size-xl); // aus _tokens.scss
|
||||||
|
|
||||||
|
// Schriftgewichte
|
||||||
|
--font-weight-light: 300;
|
||||||
|
--font-weight-regular: 400;
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-bold: 700;
|
||||||
|
|
||||||
|
// Zeilenhöhen
|
||||||
|
--line-height-base: 1.5;
|
||||||
|
--line-height-heading: 1.2;
|
||||||
|
|
||||||
|
// Buchstabenabstand
|
||||||
|
--letter-spacing-base: 0;
|
||||||
|
--letter-spacing-heading: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Typografie für Elemente
|
||||||
|
// ==============================
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: var(--font-family-heading);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--line-height-heading);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: var(--font-size-xxl); }
|
||||||
|
h2 { font-size: var(--font-size-xl); }
|
||||||
|
h3 { font-size: var(--font-size-lg); }
|
||||||
|
h4 { font-size: var(--font-size-base); }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==============================
|
||||||
|
// Utility-Klassen für Typografie
|
||||||
|
// ==============================
|
||||||
|
.text-muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-light { font-weight: var(--font-weight-light); }
|
||||||
|
.font-medium { font-weight: var(--font-weight-medium); }
|
||||||
|
|||||||
@@ -1,41 +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 ."
|
|
||||||
". features-section ."
|
|
||||||
"footer footer footer";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation__component {
|
// .landing-grid {
|
||||||
grid-area: navigation;
|
// display: grid;
|
||||||
display: flex;
|
// grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
|
||||||
justify-content: space-between;
|
// grid-template-areas:
|
||||||
border-radius: 0 0 10px 10px;
|
// "navigation navigation navigation"
|
||||||
backdrop-filter: blur(10px);
|
// "hero hero hero"
|
||||||
box-shadow: 1px 2px 10px rgba(197, 197, 197, 0.824);
|
// ". features-section ."
|
||||||
position: sticky;
|
// "footer footer footer";
|
||||||
top: 0;
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
.hero__component {
|
// .grid-area-navigation {
|
||||||
grid-area: hero;
|
// grid-area: navigation;
|
||||||
height: 80vh;
|
|
||||||
padding: var(--space-4);
|
|
||||||
|
|
||||||
@include abstracts.breakpoint("md") {
|
// }
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-section__component {
|
// .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;
|
||||||
|
// }
|
||||||
|
|
||||||
.footer__component {
|
// [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";
|
|
||||||
+6
-1
@@ -13,7 +13,12 @@
|
|||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "preserve"
|
"module": "preserve",
|
||||||
|
"paths": {
|
||||||
|
"@core/*": ["./src/app/core/*"],
|
||||||
|
"@features/*": ["./src/app/features/*"],
|
||||||
|
"@shared/*": ["./src/app/shared/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
|||||||
Reference in New Issue
Block a user