finished v1.0

This commit is contained in:
2023-02-24 20:37:52 +01:00
parent 484294e611
commit 4471157412
45 changed files with 638 additions and 350 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
# Ignore the node_modules directory
node_modules
# Ignore the dist directory
dist
# Ignore the .git directory
.git
# Ignore the .gitignore file
.gitignore
# Ignore the .dockerignore file
.dockerignore

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
#stage 1
FROM node:18-slim as node
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build:ssr --omit=dev
#stage 2
FROM node:18-slim
COPY --from=node /app/dist /app/dist
WORKDIR /app
CMD ["node", "dist/Portfolio/server/main.js"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -27,7 +27,9 @@
"inlineStyleLanguage": "scss",
"assets": [
"src/favicon.ico",
"src/assets"
"src/assets",
"src/robots.txt",
"src/sitemap.xml"
],
"styles": [
"src/styles.scss",
@@ -40,13 +42,13 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
"maximumWarning": "5mb",
"maximumError": "10mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "2kb",
"maximumError": "4kb"
"maximumWarning": "100kb",
"maximumError": "100kb"
}
],
"outputHashing": "all"

47
package-lock.json generated
View File

@@ -20,13 +20,10 @@
"@angular/platform-server": "^15.1.0",
"@angular/router": "^15.1.0",
"@nguniversal/express-engine": "^15.1.0",
"@sweetalert2/theme-dark": "^5.0.15",
"chart.js": "^4.2.1",
"express": "^4.15.2",
"ngx-device-detector": "^5.0.1",
"pocketbase": "^0.11.0",
"rxjs": "~7.8.0",
"sweetalert2": "^11.7.2",
"tslib": "^2.3.0",
"zone.js": "~0.12.0"
},
@@ -3933,11 +3930,6 @@
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==",
"dev": true
},
"node_modules/@sweetalert2/theme-dark": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/@sweetalert2/theme-dark/-/theme-dark-5.0.15.tgz",
"integrity": "sha512-g1QCwQVOkiAz5hIEBOIvvu0580lubu4KuQlod+48QetYzGIEXNlHEH36QihCDnGVgE6vx48iO48w9q0WrZWyHQ=="
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -10036,18 +10028,6 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
"node_modules/ngx-device-detector": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ngx-device-detector/-/ngx-device-detector-5.0.1.tgz",
"integrity": "sha512-hVKaGzyXzy6zeliYyN7runz3eOOsh3tmZ8A6P5MSpHIjVjSx3pUJcobFTKNyHGn/zGS4JFWuhSSb7QmNwmqK9w==",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"@angular/common": "^15.0.0",
"@angular/core": "^15.0.0"
}
},
"node_modules/nice-napi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",
@@ -12453,15 +12433,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sweetalert2": {
"version": "11.7.2",
"resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.7.2.tgz",
"integrity": "sha512-atPjDa3fv/4xwZpiAt7FZUgAhR5VAASiLP2hu7HUeVDXx+v4/9nD1W0u8xal1e9f2/qGh0DwTxPXPV9XoZIBvg==",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/limonte"
}
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@@ -16562,11 +16533,6 @@
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==",
"dev": true
},
"@sweetalert2/theme-dark": {
"version": "5.0.15",
"resolved": "https://registry.npmjs.org/@sweetalert2/theme-dark/-/theme-dark-5.0.15.tgz",
"integrity": "sha512-g1QCwQVOkiAz5hIEBOIvvu0580lubu4KuQlod+48QetYzGIEXNlHEH36QihCDnGVgE6vx48iO48w9q0WrZWyHQ=="
},
"@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -21312,14 +21278,6 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
},
"ngx-device-detector": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ngx-device-detector/-/ngx-device-detector-5.0.1.tgz",
"integrity": "sha512-hVKaGzyXzy6zeliYyN7runz3eOOsh3tmZ8A6P5MSpHIjVjSx3pUJcobFTKNyHGn/zGS4JFWuhSSb7QmNwmqK9w==",
"requires": {
"tslib": "^2.0.0"
}
},
"nice-napi": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/nice-napi/-/nice-napi-1.0.2.tgz",
@@ -23136,11 +23094,6 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true
},
"sweetalert2": {
"version": "11.7.2",
"resolved": "https://registry.npmjs.org/sweetalert2/-/sweetalert2-11.7.2.tgz",
"integrity": "sha512-atPjDa3fv/4xwZpiAt7FZUgAhR5VAASiLP2hu7HUeVDXx+v4/9nD1W0u8xal1e9f2/qGh0DwTxPXPV9XoZIBvg=="
},
"symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",

View File

@@ -26,13 +26,10 @@
"@angular/platform-server": "^15.1.0",
"@angular/router": "^15.1.0",
"@nguniversal/express-engine": "^15.1.0",
"@sweetalert2/theme-dark": "^5.0.15",
"chart.js": "^4.2.1",
"express": "^4.15.2",
"ngx-device-detector": "^5.0.1",
"pocketbase": "^0.11.0",
"rxjs": "~7.8.0",
"sweetalert2": "^11.7.2",
"tslib": "^2.3.0",
"zone.js": "~0.12.0"
},

View File

@@ -1,4 +1,5 @@
import { Component } from '@angular/core';
import {SeoService} from "./services/seo.service";
@Component({
selector: 'app-root',
@@ -7,4 +8,6 @@ import { Component } from '@angular/core';
})
export class AppComponent {
public constructor(private seo: SeoService) {}
}

View File

@@ -22,6 +22,11 @@ import { ContactComponent } from './sites/contact/contact.component';
import { AboutComponent } from './sites/about/about.component';
import {MatInputModule} from "@angular/material/input";
import {ReactiveFormsModule} from "@angular/forms";
import {MatSnackBarModule} from "@angular/material/snack-bar";
import { TimestampComponent } from './components/timestamp/timestamp.component';
import { CarrierPipe } from './pipes/carrier.pipe';
import { ExperiencePipe } from './pipes/experience.pipe';
import { FooterComponent } from './components/footer/footer.component';
@NgModule({
declarations: [
@@ -38,7 +43,11 @@ import {ReactiveFormsModule} from "@angular/forms";
FrameworksPipe,
SkillsPipe,
ContactComponent,
AboutComponent
AboutComponent,
TimestampComponent,
CarrierPipe,
ExperiencePipe,
FooterComponent
],
imports: [
BrowserModule.withServerTransition({appId: 'serverApp'}),
@@ -48,7 +57,8 @@ import {ReactiveFormsModule} from "@angular/forms";
MatButtonModule,
MatTooltipModule,
MatInputModule,
ReactiveFormsModule
ReactiveFormsModule,
MatSnackBarModule
],
providers: [],
bootstrap: [AppComponent]

View File

@@ -1,4 +1,14 @@
$text-move: hover-text-move 300ms forwards ease-out;
@keyframes hover-text {
from {
opacity: var(--opa-1);
transform: translateY(0);
}
to {
opacity: var(--opa-2);
transform: translateY(-20px);
}
}
.button {
display: flex;
@@ -13,42 +23,27 @@ $text-move: hover-text-move 300ms forwards ease-out;
border-radius: 20px;
text-decoration: none;
font-size: 13px;
overflow: hidden;
.text-1 {
--opa-2: 0;
--opa-1: 1;
margin-top: 12px;
}
.text-2 {
--opa-2: 1;
--opa-1: 0;
opacity: 0;
}
&:hover {
.text-1 {
animation: hover-text 250ms forwards ease-out reverse, $text-move;
animation: hover-text 300ms forwards ease-out;
}
.text-2 {
animation: hover-text 300ms forwards ease-out, $text-move;
animation: hover-text 300ms forwards ease-out;
}
}
}
@keyframes hover-text {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes hover-text-move {
from {
transform: translateY(0);
}
to {
transform: translateY(-20px);
}
}

View File

@@ -0,0 +1,11 @@
<footer id="footer" class="home-section">
<span class="footer-title">Portfolio von Leon Hoppe</span>
<a href="mailto://leon@ladenbau-hoppe.de">leon@ladenbau-hoppe.de</a>
<span>+49 1575 8839776</span>
<div id="social-media">
<a class="header-social" target="_blank" *ngFor="let social of socialLinks" href="{{social.href}}">
<img src="{{social.image}}" alt="{{social.href}}" draggable="false">
</a>
</div>
</footer>

View File

@@ -0,0 +1,39 @@
@use "src/theme";
#footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
margin-top: 120px;
user-select: unset;
.footer-title {
background: theme.$gradient;
background-clip: text;
color: transparent;
font-weight: bold;
user-select: none;
}
a {
text-decoration: none;
}
#social-media {
display: flex;
gap: 5px;
user-select: none;
.header-social > img {
width: 25px;
height: 25px;
}
}
}
@media screen and (max-width: theme.$mobile-width) {
#footer *:not(.footer-title) {
display: none;
}
}

View File

@@ -0,0 +1,20 @@
import {Component, OnInit} from '@angular/core';
import {BackendService} from "../../services/backend.service";
import {Social} from "../../models/social";
@Component({
selector: 'app-footer',
templateUrl: './footer.component.html',
styleUrls: ['./footer.component.scss']
})
export class FooterComponent implements OnInit {
public socialLinks: Social[];
public constructor(private backend: BackendService) {}
async ngOnInit() {
this.socialLinks = await this.backend.getSocials();
}
}

View File

@@ -2,22 +2,22 @@
<img src="../../../favicon.ico" alt="logo" class="logo" draggable="false">
<span class="name">Leon Hoppe</span>
<div id="header-links" *ngIf="!deviceService.isMobile()">
<div id="header-links">
<a class="header-link" *ngFor="let link of navLinks" [routerLink]="link.href" [ngClass]="{'active': cleanUrl(router.url) == link.href}">{{link.label}}</a>
</div>
<div id="social-media" [ngStyle]="{'margin-left': deviceService.isMobile() ? 'auto' : ''}">
<div id="social-media">
<a class="header-social" target="_blank" *ngFor="let social of socialLinks" href="{{social.href}}">
<img src="{{social.image}}" alt="{{social.href}}" draggable="false">
</a>
</div>
</nav>
<section id="content" [ngStyle]="{'height': deviceService.isMobile() ? 'calc(100% - 102px)' : 'calc(100% - 51px)'}">
<section id="content">
<ng-content></ng-content>
</section>
<nav *ngIf="deviceService.isMobile()" class="footer">
<nav class="footer">
<button mat-button class="footer-link" *ngFor="let link of navLinks" [routerLink]="link.href" [ngClass]="{'active': cleanUrl(router.url) == link.href}">
<mat-icon>{{link.icon}}</mat-icon>
</button>

View File

@@ -70,8 +70,8 @@
width: 100%;
border-top: 1px solid theme.$border-color;
background-color: map.get(theme.$background, 'background');
display: none;
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
@@ -106,4 +106,25 @@
#content {
overflow: auto;
height: calc(100% - 51px);
}
@media screen and (max-width: theme.$mobile-width) {
.header {
#header-links {
display: none;
}
#social-media {
margin-left: auto;
}
}
#content {
height: calc(100% - 102px);
}
.footer {
display: grid;
}
}

View File

@@ -1,7 +1,7 @@
import {Component, OnInit} from '@angular/core';
import {DeviceDetectorService} from "ngx-device-detector";
import {Router} from "@angular/router";
import {BackendService} from "../../services/backend.service";
import {Social} from "../../models/social";
@Component({
selector: 'app-navigation',
@@ -10,7 +10,7 @@ import {BackendService} from "../../services/backend.service";
})
export class NavigationComponent implements OnInit {
public constructor(public deviceService: DeviceDetectorService, public router: Router, private backend: BackendService) {}
public constructor(public router: Router, private backend: BackendService) {}
public navLinks: {label: string, href: string, icon?: string}[] = [
{label: 'Home', href: '/', icon: 'home'},
@@ -20,7 +20,7 @@ export class NavigationComponent implements OnInit {
{label: 'Kontakt', href: '/contact', icon: 'mail'}
];
public socialLinks: {href: string, image: string}[];
public socialLinks: Social[];
public cleanUrl(url: string): string {
try {

View File

@@ -0,0 +1,4 @@
<div class="timestamp" #timestampRef>
<h2>{{timestamp.date}}</h2>
<span>{{timestamp.description}}</span>
</div>

View File

@@ -0,0 +1,73 @@
@use "src/theme";
.timestamp {
flex-grow: 1;
flex-basis: 0;
display: flex;
flex-direction: column;
gap: 50px;
position: relative;
opacity: 0;
h2 {
font-size: 20px;
margin: 0;
font-weight: normal;
}
span {
box-sizing: border-box;
color: theme.$desc-color;
font-size: 14px;
padding-right: 10px;
}
&:after {
content: '';
width: 15px;
height: 15px;
background: theme.$gradient-angled;
border-radius: 50%;
box-shadow: 0 3px 10px 0.5px rgba(theme.$primary, 0.4);
position: absolute;
top: 45px;
}
&:before {
content: '';
width: 0;
height: 3px;
background-color: #FFF;
top: 51px;
position: absolute;
display: var(--show-bar, block);
}
}
@media screen and (max-width: theme.$mobile-width) {
.timestamp {
gap: 15px;
padding-left: 30px;
box-sizing: border-box;
animation: none !important;
opacity: 1;
span {
margin-bottom: 50px;
}
&:after {
top: 5px;
left: -5px;
}
&:before {
top: 5px;
left: 1px;
width: 3px;
height: 100%;
animation: none !important;
}
}
}

View File

@@ -0,0 +1,13 @@
import {Component, Input} from '@angular/core';
import {Timestamp} from "../../models/timestamp";
@Component({
selector: 'app-timestamp',
templateUrl: './timestamp.component.html',
styleUrls: ['./timestamp.component.scss']
})
export class TimestampComponent {
@Input('timestamp') timestamp: Timestamp;
}

4
src/app/models/about.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface About {
about: string;
future: string;
}

View File

@@ -2,4 +2,5 @@ export interface Timestamp {
date: number,
description: string;
featured?: boolean;
carrier?: boolean;
}

View File

@@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from '@angular/core';
import {Timestamp} from "../models/timestamp";
@Pipe({
name: 'carrier'
})
export class CarrierPipe implements PipeTransform {
transform(objects: Timestamp[]): Timestamp[] {
return objects?.filter(obj => obj.carrier);
}
}

View File

@@ -0,0 +1,13 @@
import { Pipe, PipeTransform } from '@angular/core';
import {Timestamp} from "../models/timestamp";
@Pipe({
name: 'experience'
})
export class ExperiencePipe implements PipeTransform {
transform(objects: Timestamp[]): Timestamp[] {
return objects?.filter(obj => !obj.carrier);
}
}

View File

@@ -16,4 +16,8 @@ export class AnimatorService {
});
}
public getAnimationDelay(index: number, multiplier = 150, additional = 0): string {
return `${index * multiplier + additional}ms`;
}
}

View File

@@ -5,6 +5,7 @@ import {Technology} from "../models/technology";
import {Timestamp} from "../models/timestamp";
import {Social} from "../models/social";
import {Message} from "../models/message";
import {About} from "../models/about";
@Injectable({
@@ -14,6 +15,13 @@ export class BackendService {
private pb: PocketBase;
private states: {id: string, name: string}[] = [
{id: 'finished', name: "Fertig"},
{id: 'canceled', name: "Abgebrochen"},
{id: 'paused', name: "Pausiert"},
{id: 'development', name: "In Entwicklung"}
]
constructor() {
this.pb = new PocketBase('https://ed168214-77da-44f1-9a61-859abb49edf8.api.leon-hoppe.de');
}
@@ -23,13 +31,12 @@ export class BackendService {
sort: '-order'
}) as Project[];
const allLanguages = await this.pb?.collection('languages').getFullList();
const states = await this.pb?.collection('project_states').getFullList();
const projects: Project[] = [];
for(let rawProject of rawProjects) {
const project = rawProject as Project;
project.status = states?.filter(state => state.id == rawProject.status)[0]['name'];
project.status = this.states?.filter(state => state.id == rawProject.status)[0]['name'];
if (rawProject.languages != undefined) {
const languages: Language[] = []
@@ -50,7 +57,9 @@ export class BackendService {
}
public async getTimeline(): Promise<Timestamp[]> {
return await this.pb?.collection('timeline').getFullList();
return await this.pb?.collection('timeline').getFullList(200, {
sort: 'date'
});
}
public async getSocials(): Promise<Social[]> {
@@ -61,6 +70,10 @@ export class BackendService {
];
}
public async getAbout(): Promise<About> {
return await this.pb?.collection('about').getFirstListItem('');
}
public async sendMessage(message: Message) {
await this.pb?.collection('messages').create(message);
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import {Meta, Title} from "@angular/platform-browser";
@Injectable({
providedIn: 'root'
})
export class SeoService {
constructor(private title: Title, private meta: Meta) {
this.setDefaults();
}
public setTitle(title: string): void {
this.title.setTitle(title);
this.meta.updateTag({property: "og:title", content: title});
}
public setDescription(description: string): void {
this.meta.updateTag({property: "description", content: description});
this.meta.updateTag({property: "og:description", content: description});
}
public setDefaults(): void {
this.meta.updateTag({property: "description", content: "Auf dieser Seite erfahren Sie, an welchen Projekten ich bereits gearbeitet habe, was meine Programmierkenntnisse sind und welche Pläne ich für die Zukunft habe."});
this.meta.updateTag({property: "og:description", content: "Auf dieser Seite erfahren Sie, an welchen Projekten ich bereits gearbeitet habe, was meine Programmierkenntnisse sind und welche Pläne ich für die Zukunft habe."});
this.meta.updateTag({property: "og:url", content: "https://leon-hoppe.de/"});
this.meta.updateTag({property: "og:title", content: "Portfolio von Leon Hoppe"});
this.meta.updateTag({property: "og:image", content: "https://leon-hoppe.de/favicon.ico"});
this.title.setTitle("Portfolio von Leon Hoppe");
}
}

View File

@@ -1 +1,26 @@
<p>about works!</p>
<section class="home-section" id="about">
<div>
<h1 class="title">Über mich</h1>
<p [innerText]="about?.about"></p>
</div>
<div>
<h1 class="title">Zukünftige Projekte</h1>
<p [innerText]="about?.future"></p>
</div>
</section>
<section class="home-section" #experience>
<h1 class="title">Programmiererfahrung</h1>
<div class="timeline">
<app-timestamp *ngFor="let timestamp of timeline | experience; let i = index" [timestamp]="timestamp" [ngStyle]="{'--delay': animator.getAnimationDelay(i, 500)}" />
</div>
</section>
<section class="home-section" #carrier>
<h1 class="title">Karriere</h1>
<div class="timeline">
<app-timestamp *ngFor="let timestamp of timeline | carrier; let i = index" [timestamp]="timestamp" [ngStyle]="{'--delay': animator.getAnimationDelay(i, 500)}" />
</div>
</section>
<app-footer />

View File

@@ -0,0 +1,13 @@
#about {
margin-top: 50px;
display: grid;
gap: 20px;
grid-template-columns: repeat(2, 1fr);
}
@media screen and (max-width: 1200px) {
#about {
grid-template-columns: unset;
grid-template-rows: repeat(2, max-content);
}
}

View File

@@ -1,10 +1,35 @@
import { Component } from '@angular/core';
import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import {Timestamp} from "../../models/timestamp";
import {BackendService} from "../../services/backend.service";
import {AnimatorService} from "../../services/animator.service";
import {About} from "../../models/about";
import {SeoService} from "../../services/seo.service";
@Component({
selector: 'app-about',
templateUrl: './about.component.html',
styleUrls: ['./about.component.scss']
})
export class AboutComponent {
export class AboutComponent implements OnInit, AfterViewInit {
@ViewChild('experience') experience: ElementRef;
@ViewChild('carrier') carrier: ElementRef;
public about: About;
public timeline: Timestamp[];
public constructor(private backend: BackendService, public animator: AnimatorService, public seo: SeoService) {
this.seo.setDefaults();
this.seo.setTitle("Über mich");
}
async ngOnInit() {
this.about = await this.backend.getAbout();
this.timeline = await this.backend.getTimeline();
}
ngAfterViewInit(): void {
this.animator.observer.observe(this.experience.nativeElement);
this.animator.observer.observe(this.carrier.nativeElement);
}
}

View File

@@ -1,5 +1,6 @@
<div class="hider" [ngClass]="{'mobile': device.isMobile()}">
<div class="form-wrapper" [ngClass]="{'mobile': device.isMobile()}">
<div class="hider">
<div class="form-wrapper">
<form [formGroup]="form" (ngSubmit)="sendMessage()">
<section id="contact-info">
<h1>Kontakt</h1>

View File

@@ -3,10 +3,6 @@
.hider {
overflow: hidden;
height: 100%;
&.mobile {
overflow-y: auto;
}
}
.form-wrapper {
@@ -64,8 +60,14 @@
}
}
}
}
&.mobile {
@media screen and (max-width: theme.$mobile-width) {
.hider {
overflow-y: auto;
}
.form-wrapper {
display: block;
height: max-content;

View File

@@ -1,8 +1,8 @@
import { Component } from '@angular/core';
import {FormControl, FormGroup, Validators} from "@angular/forms";
import Swal from 'sweetalert2/dist/sweetalert2.js';
import {BackendService} from "../../services/backend.service";
import {DeviceDetectorService} from "ngx-device-detector";
import {MatSnackBar} from "@angular/material/snack-bar";
import {SeoService} from "../../services/seo.service";
@Component({
selector: 'app-contact',
@@ -17,7 +17,10 @@ export class ContactComponent {
message: new FormControl('', [Validators.required])
});
public constructor(public backend: BackendService, public device: DeviceDetectorService) {}
public constructor(public backend: BackendService, private snackbar: MatSnackBar, private seo: SeoService) {
seo.setTitle("Kontakt");
seo.setDescription("Schreiben Sie mir eine Nachricht");
}
public async sendMessage() {
if (!this.form.valid) return;
@@ -29,12 +32,7 @@ export class ContactComponent {
});
this.form.reset();
Swal.fire({
icon: 'success',
title: 'Nachricht gesendet',
showConfirmButton: false,
timer: 1500
});
this.snackbar.open("Nachricht gesendet!", undefined, {duration: 2000});
}
}

View File

@@ -1,12 +1,12 @@
<section id="hero" [ngClass]="{'mobile': deviceService.isMobile()}">
<section id="hero">
<div class="artwork">
<div class="circle big-circle"></div>
<div class="circle small-circle"></div>
<div class="circle image"></div>
</div>
<h1>
<span>Hallo, ich bin Leon Hoppe,</span><br>
full stack developer
<span id="welcome">Hallo, ich bin Leon Hoppe,</span><br>
<span id="jobs">{{jobs.display}}</span>
</h1>
<p>
Auf dieser Seite erfahren Sie, an welchen Projekten ich bereits gearbeitet habe,<br>
@@ -15,15 +15,15 @@
<a href="#projects">Mehr erfahren</a>
</section>
<section id="projects" class="home-section" [ngClass]="{'mobile': deviceService.isMobile()}">
<section id="projects" class="home-section">
<h1 class="title">Projekte</h1>
<a routerLink="/projects">alle ansehen</a>
<div id="projects-wrapper" #projectsWrapper>
<app-project *ngFor="let project of projects | featured; let i = index" [project]="project" [ngStyle]="{'animation-delay': getAnimationDelay(i)}" />
<app-project *ngFor="let project of projects | featured; let i = index" [project]="project" [ngStyle]="{'animation-delay': animator.getAnimationDelay(i)}" />
</div>
</section>
<section id="technologies" class="home-section" [ngClass]="{'mobile': deviceService.isMobile()}">
<section id="technologies" class="home-section">
<h1 class="title">Technologien</h1>
<a routerLink="/technologies">mehr erfahren</a>
<div class="technologies-wrapper">
@@ -31,25 +31,12 @@
</div>
</section>
<section id="about" class="home-section" [ngClass]="{'mobile': deviceService.isMobile()}">
<section id="about" class="home-section">
<h1 class="title">Über mich</h1>
<a routerLink="/about">mehr erfahren</a>
<div id="timeline" #timelineElement>
<div class="timestamp" *ngFor="let timestamp of timeline | featured; let i = index" [ngStyle]="{'--delay': getAnimationDelay(i, 500)}">
<h2>{{timestamp.date}}</h2>
<span>{{timestamp.description}}</span>
</div>
<div class="timeline" #timelineElement>
<app-timestamp *ngFor="let timestamp of timeline | featured; let i = index" [timestamp]="timestamp" [ngStyle]="{'--delay': animator.getAnimationDelay(i, 500)}" />
</div>
</section>
<section id="footer" class="home-section" [ngClass]="{'mobile': deviceService.isMobile()}">
<span class="footer-title">Portfolio von Leon Hoppe</span>
<a href="mailto://leon@ladenbau-hoppe.de">leon@ladenbau-hoppe.de</a>
<span>+49 1575 8839776</span>
<div id="social-media">
<a class="header-social" target="_blank" *ngFor="let social of socialLinks" href="{{social.href}}">
<img src="{{social.image}}" alt="{{social.href}}" draggable="false">
</a>
</div>
</section>
<app-footer />

View File

@@ -15,11 +15,26 @@
font-size: 45px;
line-height:70px;
span {
#welcome {
background: theme.$gradient;
background-clip: text;
color: transparent;
}
#jobs {
position: relative;
&:after {
content: '';
position: absolute;
left: calc(100% + 5px);
top: 0;
width: 20px;
height: 100%;
background-color: map.get(theme.$text, 'text');
animation: blink 800ms infinite;
}
}
}
p {
@@ -41,25 +56,6 @@
box-shadow: 0 0 40px -5px theme.$primary;
}
&.mobile {
padding-left: theme.$padding-small;
h1 {
margin-top: 10vh;
font-size: 30px;
line-height: 50px;
}
p {
font-size: 15px;
margin-right: 20px;
}
.artwork > .small-circle, .artwork > .image {
display: none;
}
}
}
#projects {
@@ -79,13 +75,6 @@
animation: fade-in 250ms forwards;
}
}
&.mobile {
#projects-wrapper {
margin-top: 30px;
gap: 30px;
}
}
}
#technologies {
@@ -94,152 +83,44 @@
#about {
margin-top: 150px;
}
#timeline {
display: flex;
@media screen and (max-width: theme.$mobile-width) {
#hero {
padding-left: theme.$padding-small;
h1 {
margin-top: 10vh;
font-size: 30px;
line-height: 50px;
}
p {
font-size: 15px;
margin-right: 20px;
}
.artwork > .small-circle, .artwork > .image {
display: none;
}
}
#projects #projects-wrapper {
margin-top: 30px;
.timestamp {
flex-grow: 1;
flex-basis: 0;
display: flex;
flex-direction: column;
gap: 50px;
position: relative;
opacity: 0;
h2 {
font-size: 20px;
margin: 0;
font-weight: normal;
gap: 30px;
}
}
span {
box-sizing: border-box;
color: theme.$desc-color;
font-size: 14px;
padding-right: 10px;
}
&:after {
content: '';
width: 15px;
height: 15px;
background: theme.$gradient-angled;
border-radius: 50%;
box-shadow: 0 3px 10px 0.5px rgba(theme.$primary, 0.4);
position: absolute;
top: 45px;
}
&:before {
content: '';
width: 0;
height: 3px;
background-color: #FFF;
top: 51px;
position: absolute;
}
&:last-of-type:before {
display: none;
}
}
&.in-view .timestamp {
animation: fade-in 200ms forwards var(--delay) ease-out;
&:before {
animation: timestamp-in 500ms forwards var(--delay) ease-in-out;
}
}
}
&.mobile {
#timeline {
flex-direction: column-reverse;
.timestamp {
gap: 15px;
padding-left: 30px;
box-sizing: border-box;
animation: none;
@keyframes blink {
0% {
opacity: 1;
span {
margin-bottom: 50px;
}
&:after {
top: 5px;
left: -5px;
50% {
opacity: 0;
}
&:before {
top: 5px;
left: 1px;
width: 3px;
height: 100%;
animation: none;
}
&:last-of-type:before {
display: block;
}
&:first-of-type:before {
display: none;
}
}
}
}
}
#footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
margin-top: 120px;
user-select: unset;
.footer-title {
background: theme.$gradient;
background-clip: text;
color: transparent;
font-weight: bold;
user-select: none;
}
a {
text-decoration: none;
}
#social-media {
display: flex;
gap: 5px;
user-select: none;
.header-social > img {
width: 25px;
height: 25px;
}
}
&.mobile {
*:not(.footer-title) {
display: none;
}
}
}
@keyframes timestamp-in {
from {
width: 0;
}
to {
width: 100%;
100% {
opacity: 1;
}
}

View File

@@ -1,10 +1,10 @@
import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core';
import {DeviceDetectorService} from "ngx-device-detector";
import {Project} from "../../models/project";
import {Technology} from "../../models/technology";
import {BackendService} from "../../services/backend.service";
import {AnimatorService} from "../../services/animator.service";
import {Timestamp} from "../../models/timestamp";
import {SeoService} from "../../services/seo.service";
@Component({
selector: 'app-home',
@@ -20,10 +20,16 @@ export class HomeComponent implements OnInit, AfterViewInit {
public timeline: Timestamp[];
public socialLinks: {href: string, image: string}[];
public constructor(public deviceService: DeviceDetectorService, private backend: BackendService, private animator: AnimatorService) {}
public jobs: {current: number, all: string[], state: number, display: string} = {
current: 0,
all: ["full stack developer", "C# developer", "Java developer"],
state: 0,
display: ""
};
public getAnimationDelay(index: number, multiplier = 150): string {
return `${index * multiplier}ms`;
public constructor(private backend: BackendService, public animator: AnimatorService, private seo: SeoService) {
setInterval(this.handleJobsAnimation.bind(this), 50);
seo.setDefaults();
}
async ngOnInit() {
@@ -38,4 +44,23 @@ export class HomeComponent implements OnInit, AfterViewInit {
this.animator.observer.observe(this.timelineElement.nativeElement);
}
private handleJobsAnimation(): void {
if (this.jobs.state == 0) {
const len = this.jobs.display.length;
this.jobs.display = this.jobs.all[this.jobs.current].slice(0, len + 1);
if (this.jobs.display.length >= this.jobs.all[this.jobs.current].length) this.jobs.state = 1;
} else if (this.jobs.state == 50) {
const len = this.jobs.display.length;
this.jobs.display = this.jobs.display.slice(0, len - 1);
if (this.jobs.display.length <= 1) {
this.jobs.state = 0;
this.jobs.current = (this.jobs.current + 1) % this.jobs.all.length;
}
} else {
this.jobs.state++;
}
}
}

View File

@@ -1,7 +1,7 @@
<section class="home-section" [ngClass]="{'mobile': deviceService.isMobile()}">
<section class="home-section">
<h1 class="title">Alle Projekte</h1>
<div class="projects-wrapper">
<div class="project-wrapper" *ngFor="let project of projects; let i = index" [ngStyle]="{'animation-delay': getAnimationDelay(i)}">
<div class="project-wrapper" *ngFor="let project of projects; let i = index" [ngStyle]="{'animation-delay': animator.getAnimationDelay(i)}">
<app-project [project]="project" showInfo />
</div>
</div>

View File

@@ -1,7 +1,8 @@
import {Component, OnInit} from '@angular/core';
import {Project} from "../../models/project";
import {DeviceDetectorService} from "ngx-device-detector";
import {BackendService} from "../../services/backend.service";
import {AnimatorService} from "../../services/animator.service";
import {SeoService} from "../../services/seo.service";
@Component({
selector: 'app-projects',
@@ -10,14 +11,13 @@ import {BackendService} from "../../services/backend.service";
})
export class ProjectsComponent implements OnInit {
public constructor(public deviceService: DeviceDetectorService, private backend: BackendService) {}
public constructor(private backend: BackendService, public animator: AnimatorService, private seo: SeoService) {
seo.setTitle("Projekte");
seo.setDescription("Ein Überblick von all meinen Projekten");
}
public projects: Project[] | undefined;
public getAnimationDelay(index: number): string {
return `${index * 150}ms`;
}
async ngOnInit() {
this.projects = await this.backend.getProjects();
}

View File

@@ -1,4 +1,4 @@
<section class="home-section" id="tech-projects" [ngClass]="{'mobile': deviceService.isMobile()}">
<section class="home-section" id="tech-projects">
<div class="artwork">
<div class="circle big-circle"></div>
<div class="circle small-circle"></div>
@@ -10,21 +10,21 @@
</div>
</section>
<section class="home-section" id="languages" [ngClass]="{'mobile': deviceService.isMobile()}">
<section class="home-section" id="languages">
<h1 class="title">Programmiersprachen</h1>
<div class="technologies-wrapper">
<app-technology *ngFor="let technology of technologies | languages" [technology]="technology" />
</div>
</section>
<section class="home-section" id="frameworks" [ngClass]="{'mobile': deviceService.isMobile()}">
<section class="home-section" id="frameworks">
<h1 class="title">Frameworks</h1>
<div class="technologies-wrapper">
<app-technology *ngFor="let technology of technologies | frameworks" [technology]="technology" />
</div>
</section>
<section class="home-section" id="additional" [ngClass]="{'mobile': deviceService.isMobile()}">
<section class="home-section" id="additional">
<h1 class="title">Zusätzliche Fähigkeiten</h1>
<div id="skills-wrapper">
<div class="skill" *ngFor="let skill of technologies | skills">

View File

@@ -2,7 +2,7 @@
@use "sass:map";
#tech-projects {
margin-top: 200px;
margin-top: 50px;
position: relative;
.chart {
@@ -28,16 +28,6 @@
justify-content: center;
}
}
&.mobile {
margin-top: 50px;
.chart .chart-container {
aspect-ratio: 1 / 1;
width: 100%;
height: auto;
}
}
}
#additional {
@@ -68,8 +58,20 @@
}
}
}
}
&.mobile #skills-wrapper {
@media screen and (max-width: theme.$mobile-width) {
#additional #skills-wrapper {
flex-direction: column;
}
#tech-projects {
margin-top: 50px;
.chart .chart-container {
aspect-ratio: 1 / 1;
width: 100%;
height: auto;
}
}
}

View File

@@ -2,7 +2,7 @@ import {AfterViewInit, Component, ElementRef, ViewChild} from '@angular/core';
import Chart from 'chart.js/auto';
import {BackendService} from "../../services/backend.service";
import {Technology} from "../../models/technology";
import {DeviceDetectorService} from "ngx-device-detector";
import {SeoService} from "../../services/seo.service";
@Component({
selector: 'app-technologies',
@@ -14,7 +14,10 @@ export class TechnologiesComponent implements AfterViewInit {
@ViewChild('chart') chartRef: ElementRef;
public technologies: Technology[];
public constructor(public deviceService: DeviceDetectorService, private backend: BackendService) {}
public constructor(private backend: BackendService, private seo: SeoService) {
seo.setTitle("Technologien");
seo.setDescription("Eine Übersicht über alle Technologien, die ich Benutze");
}
async ngAfterViewInit() {
const projects = await this.backend.getProjects();

View File

@@ -4,6 +4,13 @@
<meta charset="utf-8">
<title>Portfolio von Leon Hoppe</title>
<base href="/">
<meta name="description" content="Auf dieser Seite erfahren Sie, an welchen Projekten ich bereits gearbeitet habe, was meine Programmierkenntnisse sind und welche Pläne ich für die Zukunft habe.">
<meta property="og:url" content="https://leon-hoppe.de/">
<meta property="og:title" content="Portfolio von Leon Hoppe">
<meta property="og:description" content="Auf dieser Seite erfahren Sie, an welchen Projekten ich bereits gearbeitet habe, was meine Programmierkenntnisse sind und welche Pläne ich für die Zukunft habe.">
<meta property="og:image" content="https://leon-hoppe.de/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.gstatic.com">

4
src/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://leon-hoppe.de/sitemap.xml

23
src/sitemap.xml Normal file
View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://leon-hoppe.de/</loc>
<lastmod>2023-02-24</lastmod>
</url>
<url>
<loc>https://leon-hoppe.de/projects</loc>
<lastmod>2023-02-24</lastmod>
</url>
<url>
<loc>https://leon-hoppe.de/technologies</loc>
<lastmod>2023-02-24</lastmod>
</url>
<url>
<loc>https://leon-hoppe.de/about</loc>
<lastmod>2023-02-24</lastmod>
</url>
<url>
<loc>https://leon-hoppe.de/contact</loc>
<lastmod>2023-02-24</lastmod>
</url>
</urlset>

View File

@@ -1,8 +1,6 @@
@use "sass:map";
@use "theme";
@import '@sweetalert2/theme-dark/dark.scss';
html, body { height: 100vh; }
body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; }
*, html {scroll-behavior: smooth !important;}
@@ -104,6 +102,63 @@ mat-drawer > div {
}
}
.title {
font-size: 35px;
display: inline;
margin-right: 10px;
}
.home-section {
padding-inline: theme.$padding;
user-select: none;
margin-bottom: 100px;
}
.timeline {
display: flex;
margin-top: 30px;
app-timestamp {
flex: 1 1 0;
&:last-of-type {
--show-bar: none;
}
}
}
.in-view {
.timestamp {
animation: fade-in 200ms forwards var(--delay) ease-out;
&:before {
animation: timestamp-in 500ms forwards var(--delay) ease-in-out;
}
}
}
@media screen and (max-width: theme.$mobile-width) {
.home-section {
padding-inline: theme.$padding-small;
.title {
font-size: 25px;
}
}
.timeline {
flex-direction: column-reverse;
app-timestamp:last-of-type {
--show-bar: block;
}
app-timestamp:first-of-type {
--show-bar: none;
}
}
}
@keyframes fade-in {
from {
opacity: 0;
@@ -113,3 +168,13 @@ mat-drawer > div {
opacity: 1;
}
}
@keyframes timestamp-in {
from {
width: 0;
}
to {
width: 100%;
}
}

View File

@@ -50,6 +50,7 @@ $gradient-straight: linear-gradient($primary, $secondary);
$padding: 12.5vw;
$padding-small: 5vw;
$mobile-width: 750px;
$desc-color: #7c8393;
$border-color: #2d2d2d;
@@ -66,26 +67,6 @@ body {
color: map.get($text, 'text');
}
.title {
font-size: 35px;
display: inline;
margin-right: 10px;
}
.home-section {
padding-inline: $padding;
user-select: none;
margin-bottom: 100px;
&.mobile {
padding-inline: $padding-small;
.title {
font-size: 25px;
}
}
}
.mat-mdc-text-field-wrapper {
background-color: map.get($background, 'background') !important;
}