Landingpage Grid-layout erstellt, Navbar implementiert, SEO vorbereitet
This commit is contained in:
@@ -37,6 +37,9 @@
|
||||
"outputMode": "server",
|
||||
"ssr": {
|
||||
"entry": "src/server.ts"
|
||||
},
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": ["src/styles"]
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: '',
|
||||
renderMode: RenderMode.Prerender
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Prerender
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { LandingpageComponent } from './features/landing/pages/landingpage.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: "",
|
||||
component: LandingpageComponent
|
||||
loadComponent: () => import('@features/landing/').then(m => m.LandingpageComponent)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { Component, signal, inject } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import {provideIcons} from "@ng-icons/core";
|
||||
import {cssMenu} from "@ng-icons/css.gg"
|
||||
import {cssMenu} from "@ng-icons/css.gg";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
||||
12
src/app/core/models/navigation.model.ts
Normal file
12
src/app/core/models/navigation.model.ts
Normal file
@@ -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";
|
||||
8
src/app/core/models/project.model.ts
Normal file
8
src/app/core/models/project.model.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
client: string;
|
||||
description: string;
|
||||
socialDescription?: string;
|
||||
image: string;
|
||||
}
|
||||
7
src/app/core/models/seo.model.ts
Normal file
7
src/app/core/models/seo.model.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface SeoData {
|
||||
title: string;
|
||||
description: string;
|
||||
socialsDescription?: string;
|
||||
image?: string;
|
||||
type?: "website" | "article";
|
||||
}
|
||||
16
src/app/core/services/navigation.service.spec.ts
Normal file
16
src/app/core/services/navigation.service.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
74
src/app/core/services/navigation.service.ts
Normal file
74
src/app/core/services/navigation.service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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', icon: 'home' },
|
||||
{ label: 'Features', type: 'anchor', target: 'features-section', icon: 'star' },
|
||||
{ label: 'Pricing', type: 'anchor', target: 'pricing', icon: 'tag' },
|
||||
|
||||
// Route-Links ( andere Pages)
|
||||
{ label: 'Login', type: 'route', target: '/login', icon: 'log-in' },
|
||||
{
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/seo.service.spec.ts
Normal file
16
src/app/core/services/seo.service.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
91
src/app/core/services/seo.service.ts
Normal file
91
src/app/core/services/seo.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Component,
|
||||
Injectable,
|
||||
inject,
|
||||
RendererFactory2,
|
||||
ViewEncapsulation,
|
||||
ChangeDetectionStrategy,
|
||||
signal,
|
||||
computed } from "@angular/core";
|
||||
import { Title, Meta } from "@angular/platform-browser";
|
||||
import { SeoData } from "@core/models/seo.model";
|
||||
import { DOCUMENT } from "@angular/common";
|
||||
|
||||
@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)
|
||||
|
||||
private readonly BRAND = "Hurler Webdesign";
|
||||
|
||||
updateMetadata(data: SeoData, canonicalPath: string = "") {
|
||||
const fullTitle = `${data.title} | ${this.BRAND}`;
|
||||
const url = `https://hurler-webdesign.de${canonicalPath}`;
|
||||
const fallbackImage = 'https://hurler-webdesign.de/assets/og-default.jpg'; // Falls mal kein Bild da ist
|
||||
|
||||
this.titleService.setTitle(fullTitle);
|
||||
this.metaService.updateTag({ name: 'description', content: data.description });
|
||||
|
||||
// Open Graph
|
||||
this.metaService.updateTag({ property: 'og:title', content: fullTitle }); // Mit Branding
|
||||
this.metaService.updateTag({ property: 'og:description', content: data.description });
|
||||
this.metaService.updateTag({ property: 'og:type', content: data.type || 'website' });
|
||||
this.metaService.updateTag({ property: 'og:image', content: data.image || fallbackImage });
|
||||
|
||||
// Twitter
|
||||
this.metaService.updateTag({ name: 'twitter:card', content: 'summary_large_image' }); // Wichtig für große Bilder!
|
||||
this.metaService.updateTag({ name: 'twitter:title', content: fullTitle });
|
||||
this.metaService.updateTag({ name: 'twitter:description', content: data.description });
|
||||
this.metaService.updateTag({ name: 'twitter:url', content: url });
|
||||
this.metaService.updateTag({ name: 'twitter:image', content: data.image || fallbackImage });
|
||||
|
||||
this.updateCanonicalUrl(url);
|
||||
this.setLocalBusinessSchema();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 performante Webseiten ohne CMS für kleine und mittelständische Unternehmen.",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Untermagerbein 30",
|
||||
"addressLocality": "Mönchsdeggingen",
|
||||
"postalCode": "86751",
|
||||
"addressCountry": "DE"
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": 48.7506,
|
||||
"longitude": 10.5773
|
||||
},
|
||||
"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);
|
||||
}
|
||||
}
|
||||
16
src/app/core/services/theme.service.spec.ts
Normal file
16
src/app/core/services/theme.service.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
55
src/app/core/services/theme.service.ts
Normal file
55
src/app/core/services/theme.service.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,16 @@
|
||||
<section>hero works!</section>
|
||||
<section class="hero-section">
|
||||
<h1 class="hero-section__header">
|
||||
<span>Digitales Handwerk</span>
|
||||
<span>statt Standard-Baukasten</span>
|
||||
</h1>
|
||||
<p class="hero-section__claim">
|
||||
Wir programmieren Blitzschnelle, sichere und maßgeschneiderte Webseiten für kleine,
|
||||
mittelständische Unternehmen und Vereine. Ohne CMS-Ballast, dafür mit maximaler Performance.
|
||||
</p>
|
||||
<div class="hero-section__links">
|
||||
<button href="#" variant="primary" label="Leistungen ansehen">Leistungen ansehen</button>
|
||||
<button href="#" variant="secondary" label="Warum kein WordPress?">
|
||||
Warum kein WordPress?
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.hero-section {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
<div class="logo-container">
|
||||
<span class="logo-container__logo centered">H</span>
|
||||
<p class="logo-container__company"><span>Hurler</span> Webdesign</p>
|
||||
</div>
|
||||
|
||||
<nav class="navigation">
|
||||
<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>
|
||||
<section class="header">
|
||||
<div class="logo-container">
|
||||
<span class="logo-container__logo centered">H</span>
|
||||
<p class="logo-container__company"><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>
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
@use "../../../../../styles/abstracts";
|
||||
@use "abstracts";
|
||||
|
||||
.navigation {
|
||||
display: none;
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
min-height: abstracts.rem(60);
|
||||
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;
|
||||
|
||||
@include abstracts.breakpoint("md") {
|
||||
display: block;
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
&__list {
|
||||
&__nav-section {
|
||||
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;
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,14 +26,13 @@
|
||||
&__logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-main);
|
||||
color: var(--text-on-accent);
|
||||
border: 1px solid var(--accent);
|
||||
width: abstracts.rem(30);
|
||||
height: abstracts.rem(30);
|
||||
display: flex;
|
||||
background-color: var(--accent);
|
||||
border-radius: 5px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&__company {
|
||||
@@ -60,6 +46,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle-container {
|
||||
width: abstracts.rem(24);
|
||||
height: abstracts.rem(24);
|
||||
margin: auto;
|
||||
margin-left: var(--space-4);
|
||||
|
||||
@include abstracts.breakpoint("md") {
|
||||
margin: auto;
|
||||
margin-left: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
.burger-menu {
|
||||
padding-right: var(--space-4);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, inject } from '@angular/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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-navigation',
|
||||
imports: [NgIcon],
|
||||
imports: [NgIcon, ToogleThemeComponent, NavMenuComponent],
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrl: './navigation.component.scss',
|
||||
})
|
||||
export class NavigationComponent {
|
||||
|
||||
protected readonly navigationService = inject(NavigationService);
|
||||
}
|
||||
|
||||
3
src/app/features/landing/index.ts
Normal file
3
src/app/features/landing/index.ts
Normal file
@@ -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,14 @@
|
||||
<div class="landing-grid">
|
||||
<app-navigation class="navigation__component"></app-navigation>
|
||||
<app-hero class="hero__component"></app-hero>
|
||||
<app-features-section class="features-section__component"></app-features-section>
|
||||
<app-footer class="footer__component"></app-footer>
|
||||
<div class="grid-area-navigation">
|
||||
<app-navigation></app-navigation>
|
||||
</div>
|
||||
<div class="grid-area-hero" id="hero">
|
||||
<app-hero></app-hero>
|
||||
</div>
|
||||
<div class="grid-area-features" id="features-section">
|
||||
<app-features-section></app-features-section>
|
||||
</div>
|
||||
<div class="grid-area-footer">
|
||||
<app-footer></app-footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, OnInit, inject } from '@angular/core';
|
||||
import { NavigationComponent } from '../components/navigation/navigation.component';
|
||||
import { HeroComponent } from '../components/hero/hero.component';
|
||||
import { FeaturesSectionComponent } from '../components/features-section/features-section.component';
|
||||
import { FooterComponent } from '../components/footer/footer.component';
|
||||
import { SeoService } from '@core/services/seo.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-landingpage',
|
||||
@@ -15,6 +16,15 @@ import { FooterComponent } from '../components/footer/footer.component';
|
||||
templateUrl: './landingpage.component.html',
|
||||
styleUrl: './landingpage.component.scss',
|
||||
})
|
||||
export class LandingpageComponent {
|
||||
export class LandingpageComponent implements OnInit {
|
||||
private seo = inject(SeoService);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.seo.updateMetadata({
|
||||
title: 'Performante Webseiten & Webdesign für KMU',
|
||||
description: 'Spezialist für performante Webseiten ohne CMS für KMU. Wir bieten maßgeschneidertes Webdesign für eine schnelle und sichere Online-Präsenz.',
|
||||
socialsDescription: 'Webseiten ohne CMS für KMU – der Performance-Vorteil für Ihr Unternehmen.',
|
||||
type: 'website'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
27
src/app/shared/ui/nav-menu/nav-menu.component.html
Normal file
27
src/app/shared/ui/nav-menu/nav-menu.component.html
Normal file
@@ -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)">
|
||||
@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>
|
||||
36
src/app/shared/ui/nav-menu/nav-menu.component.scss
Normal file
36
src/app/shared/ui/nav-menu/nav-menu.component.scss
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/app/shared/ui/nav-menu/nav-menu.component.spec.ts
Normal file
23
src/app/shared/ui/nav-menu/nav-menu.component.spec.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
24
src/app/shared/ui/nav-menu/nav-menu.component.ts
Normal file
24
src/app/shared/ui/nav-menu/nav-menu.component.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-menu',
|
||||
imports: [RouterLink, NgIcon],
|
||||
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();
|
||||
});
|
||||
});
|
||||
20
src/app/shared/utils/toogle-theme/toogle-theme.component.ts
Normal file
20
src/app/shared/utils/toogle-theme/toogle-theme.component.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
11
src/environments/mock-projects.ts
Normal file
11
src/environments/mock-projects.ts
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -1,3 +1,3 @@
|
||||
@use "styles/abstracts";
|
||||
@use "styles/base";
|
||||
@use "styles/layout";
|
||||
@use "abstracts";
|
||||
@use "base";
|
||||
@use "layout";
|
||||
@@ -1,12 +1,18 @@
|
||||
@use "tokens";
|
||||
|
||||
html {
|
||||
font-size: 100%;
|
||||
box-sizing: border-box;
|
||||
scroll-padding-top: 60px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@forward "tokens";
|
||||
@forward "boilerplate";
|
||||
@forward "typography";
|
||||
@forward "tokens";
|
||||
@forward "typography";
|
||||
@@ -1,3 +1,17 @@
|
||||
@use "abstracts";
|
||||
|
||||
// ==============================
|
||||
// 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 {
|
||||
// Farben
|
||||
--brand-vue: 250;
|
||||
@@ -13,6 +27,9 @@
|
||||
--border: oklch(90% 0.02 250);
|
||||
--shadow-color: oklch(0% 0 250);
|
||||
|
||||
// Stapelbare Werte
|
||||
--z-index-sticky: 100;
|
||||
|
||||
// Skalierung (modulare Skala)
|
||||
--space-unit: 0.25rem;
|
||||
--space-1: calc(var(--space-unit) * 1); // 4px
|
||||
@@ -37,4 +54,24 @@
|
||||
|
||||
--border: oklch(25% 0.02 250);
|
||||
--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: abstracts.rem(60);
|
||||
|
||||
// Dark Mode Overrides
|
||||
[data-theme="dark"] {
|
||||
// Navigation
|
||||
--nav-shadow: 1px 2px 10px oklch(0% 0 250 / 0.5);
|
||||
--nav-bg: oklch(20% 0.02 250 / 0.8);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,35 @@
|
||||
@use "../abstracts";
|
||||
@use "abstracts";
|
||||
|
||||
.landing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr minmax(auto, 1200px) 1fr;
|
||||
grid-template-areas:
|
||||
"navigation navigation navigation"
|
||||
". hero ."
|
||||
"hero hero hero"
|
||||
". features-section ."
|
||||
"footer footer footer";
|
||||
}
|
||||
|
||||
.navigation__component {
|
||||
.grid-area-navigation {
|
||||
grid-area: navigation;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-radius: 0 0 10px 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 1px 2px 10px rgba(197, 197, 197, 0.824);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-index-sticky);
|
||||
}
|
||||
|
||||
.hero__component {
|
||||
.grid-area-hero {
|
||||
grid-area: hero;
|
||||
height: 80vh;
|
||||
padding: var(--space-4);
|
||||
height: 100vh;
|
||||
|
||||
@include abstracts.breakpoint("md") {
|
||||
padding: 0;
|
||||
}
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.features-section__component {
|
||||
.grid-area-features {
|
||||
grid-area: features-section;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.footer__component {
|
||||
.grid-area-footer {
|
||||
grid-area: footer;
|
||||
}
|
||||
@@ -13,7 +13,12 @@
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
"module": "preserve",
|
||||
"paths": {
|
||||
"@core/*": ["./src/app/core/*"],
|
||||
"@features/*": ["./src/app/features/*"],
|
||||
"@shared/*": ["./src/app/shared/*"]
|
||||
}
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
|
||||
Reference in New Issue
Block a user