From 7aa4199113cef1d3877ca0bd24471777478f4e85 Mon Sep 17 00:00:00 2001 From: axherrm Date: Wed, 31 Jan 2024 17:12:41 +0100 Subject: [PATCH 1/2] add draw for contact & footer --- src/app/app.component.html | 6 +- src/app/app.component.scss | 2 +- src/app/app.component.ts | 6 +- .../chat-row/chat-row.component.html | 12 ++ .../chat-row/chat-row.component.scss | 138 ++++++++++++++++++ .../components/chat-row/chat-row.component.ts | 25 ++++ src/app/data/data.service.ts | 12 +- src/app/data/model.ts | 2 + .../sections/contact/contact.component.html | 14 ++ .../sections/contact/contact.component.scss | 109 ++++++++++++++ src/app/sections/contact/contact.component.ts | 22 +++ .../footer-section.component.html | 6 + .../footer-section.component.scss | 19 +++ .../footer-section.component.ts | 14 ++ src/assets/chat-avatar/anonymous.svg | 13 ++ src/assets/chat-avatar/avatar.png | Bin 0 -> 30510 bytes src/data/contact.json | 10 ++ src/data/general.json | 6 +- 18 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 src/app/components/chat-row/chat-row.component.html create mode 100644 src/app/components/chat-row/chat-row.component.scss create mode 100644 src/app/components/chat-row/chat-row.component.ts create mode 100644 src/app/sections/contact/contact.component.html create mode 100644 src/app/sections/contact/contact.component.scss create mode 100644 src/app/sections/contact/contact.component.ts create mode 100644 src/app/sections/footer-section/footer-section.component.html create mode 100644 src/app/sections/footer-section/footer-section.component.scss create mode 100644 src/app/sections/footer-section/footer-section.component.ts create mode 100644 src/assets/chat-avatar/anonymous.svg create mode 100644 src/assets/chat-avatar/avatar.png create mode 100644 src/data/contact.json diff --git a/src/app/app.component.html b/src/app/app.component.html index 220ce01..93ff890 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -28,7 +28,7 @@

-
+
@@ -70,7 +70,11 @@

+ + +
+ diff --git a/src/app/app.component.scss b/src/app/app.component.scss index efa2fbf..1062a9b 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -63,7 +63,7 @@ // for rotation position: relative; - transform-style: preserve-3d; + //transform-style: preserve-3d; // breaks background-attachment: fixed see https://bugzilla.mozilla.org/show_bug.cgi?id=1352915 transform-origin: left; transition: all .6s ease-in-out; &.rotated { diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 49d761f..4f6d1ee 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,6 +19,8 @@ import {AboutComponent} from "./sections/about/about.component"; gsap.registerPlugin(ScrollTrigger); import "./js/lenis.js"; +import {ContactComponent} from "./sections/contact/contact.component"; +import {FooterSectionComponent} from "./sections/footer-section/footer-section.component"; @Component({ selector: 'app-root', @@ -37,7 +39,9 @@ import "./js/lenis.js"; NavbarDotComponent, SidebarComponent, AboutCardComponent, - AboutComponent + AboutComponent, + ContactComponent, + FooterSectionComponent ], templateUrl: './app.component.html', styleUrl: './app.component.scss' diff --git a/src/app/components/chat-row/chat-row.component.html b/src/app/components/chat-row/chat-row.component.html new file mode 100644 index 0000000..a5b6b66 --- /dev/null +++ b/src/app/components/chat-row/chat-row.component.html @@ -0,0 +1,12 @@ +
+ + +
+ +
+
+ +
+ + + diff --git a/src/app/components/chat-row/chat-row.component.scss b/src/app/components/chat-row/chat-row.component.scss new file mode 100644 index 0000000..dfadc09 --- /dev/null +++ b/src/app/components/chat-row/chat-row.component.scss @@ -0,0 +1,138 @@ +:host { + position: relative; + display: flex; + width: 70%; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: flex-start; + min-height: var(--1-row-height); + + --1-row-height: 5rem; + --triangle-width: 1.25rem; + --triangle-height: 1rem; + + // Chat animation + animation-name: show-chat; + animation-duration: 0.5s; + animation-timing-function: ease-in; + animation-delay: 0s; + + &.left { + transform-origin: left bottom; + flex-direction: row; + + .triangle { + margin-left: 1.5rem; + border-bottom: var(--triangle-height) solid transparent; + border-right: var(--triangle-width) solid rgba(226, 38, 255, 0.8); + } + + .fake-triangle { + background: #a445b2; + transform: translateX(1.4rem) rotateZ(45deg); + } + } + + &.right { + transform-origin: right bottom; + flex-direction: row-reverse; + align-self: flex-end; + + .triangle { + margin-right: 1.5rem; + border-bottom: var(--triangle-height) solid transparent; + border-left: var(--triangle-width) solid rgba(226, 38, 255, 0.8); + } + + .fake-triangle { + background: #fa4299; + transform: translateX(-1.4rem) rotateZ(-45deg); + } + } +} + +.img-container { + height: var(--1-row-height); + width: auto; + aspect-ratio: 1; + flex-shrink: 0; + + img { + width: 100%; + height: 100%; + border-radius: 50%; + } +} + +.message-box { + position: relative; + align-self: center; + background: linear-gradient(90deg, #a445b2, #fa4299); + border-radius: 1rem; + padding: 1.5rem; + font-family: 'Noto Sans', sans-serif; + font-size: 1.7rem; + line-height: 1.7rem; + text-align: left; +} + +.triangle { + margin-top: calc((var(--1-row-height) - var(--triangle-height)) / 2); + //margin-top: -0.5rem; + width: 0; + height: 0; +} + +.fake-triangle { + position: relative; + margin-top: calc((var(--1-row-height)) / 2 - 1rem); + width: 2rem; + height: 2rem; + flex-shrink: 0; + background: #a445b2; + border-radius: 0.4rem; +} + +// for real gradient on multiple elements, see: https://codepen.io/axherrm/pen/YzgYEeL +//.background-gradient { +// background-image: linear-gradient(90deg, #a445b2, #fa4299); +// background-repeat: no-repeat; +// background-attachment: fixed; +//} + +//@keyframes show-chat-left { +// 0% { +// //margin-left: -50%; +// //margin-top: 6rem; +// //transform: scale(0.1); +// //transform: translateX(-70%) translateY(50%) scale(0.1); +// transform: translateX(-70%) scale(0.1); +// } +// //80% { +// // //transform: translateX(-5%) translateY(30%); +// // transform: translateX(-5%) translateY(30%); +// //} +// //90% { +// // transform: translateY(20%); +// //} +// 100% { +// //margin-left: 0; +// //margin-top: 0; +// transform: none; +// } +//} + +@keyframes show-chat { + 0% { + opacity: 0; + transform: scale(0); + } + 80% { + opacity: 0.9; + transform: scale(0.9); + } + 100% { + opacity: 1; + transform: none; + } +} diff --git a/src/app/components/chat-row/chat-row.component.ts b/src/app/components/chat-row/chat-row.component.ts new file mode 100644 index 0000000..e71b25d --- /dev/null +++ b/src/app/components/chat-row/chat-row.component.ts @@ -0,0 +1,25 @@ +import {Component, HostBinding, Input} from '@angular/core'; +import {NgIf} from "@angular/common"; + +@Component({ + selector: 'chat-row', + standalone: true, + imports: [ + NgIf + ], + templateUrl: './chat-row.component.html', + styleUrl: './chat-row.component.scss' +}) +export class ChatRowComponent { + + @Input() side: "left" | "right" = "left"; + + // @HostBinding("style.flex-direction") flexDirection: string = "row"; + @HostBinding("class.left") get left() { return this.isLeft() } + @HostBinding("class.right") get right() { return !this.isLeft() } + + isLeft(): boolean { + return this.side === "left"; + } + +} diff --git a/src/app/data/data.service.ts b/src/app/data/data.service.ts index cd04716..a76c074 100644 --- a/src/app/data/data.service.ts +++ b/src/app/data/data.service.ts @@ -1,11 +1,12 @@ import {EventEmitter, Injectable} from '@angular/core'; +import {AboutCard, EducationItem, ExperienceItem, LanguagePack, Skill, SkillCategory} from "./model"; +import {MenuItem} from "primeng/api"; import * as educationJson from '../../data/education.json'; import * as generalJson from '../../data/general.json'; import * as experienceJson from '../../data/experience.json'; import * as skillsJson from '../../data/skills.json'; import * as aboutJson from '../../data/about.json'; -import {AboutCard, EducationItem, ExperienceItem, LanguagePack, Skill, SkillCategory} from "./model"; -import {MenuItem} from "primeng/api"; +import * as contactJson from '../../data/contact.json'; @Injectable({ providedIn: 'root' @@ -23,6 +24,7 @@ export class DataService { skillCategories: SkillCategory[]; skills: Skill[]; about: AboutCard[]; + contact: string[]; langChange: EventEmitter = new EventEmitter(true); @@ -40,12 +42,14 @@ export class DataService { this.experience = experienceJson[this.lang]; // @ts-ignore this.skillCategories = skillsJson[this.lang]; - // @ts-ignore - this.about = aboutJson[this.lang]; this.skills = []; for (let skillCategory of this.skillCategories) { this.skills = this.skills.concat(skillCategory.skills); } + // @ts-ignore + this.about = aboutJson[this.lang]; + // @ts-ignore + this.contact = contactJson[this.lang]; this.fillLanguageButton(); } diff --git a/src/app/data/model.ts b/src/app/data/model.ts index 000c884..dbfa6f2 100644 --- a/src/app/data/model.ts +++ b/src/app/data/model.ts @@ -70,6 +70,7 @@ export class LanguagePack implements ILanguagePack { experience: string; skills: string; about: string; + contact: string; sections: Section[]; @@ -82,6 +83,7 @@ export class LanguagePack implements ILanguagePack { {name: this.experience, id: "experience-start", position: 2}, {name: this.skills, id: "skills-start", position: 3}, {name: this.about, id: "about-start", position: 4}, + {name: this.contact, id: "contact-start", position: 5}, ]; } } diff --git a/src/app/sections/contact/contact.component.html b/src/app/sections/contact/contact.component.html new file mode 100644 index 0000000..45986ce --- /dev/null +++ b/src/app/sections/contact/contact.component.html @@ -0,0 +1,14 @@ +
+
+
+ {{ message }} + Das ist eine Testmessage +
+
+
+ +
+
+
+
+
diff --git a/src/app/sections/contact/contact.component.scss b/src/app/sections/contact/contact.component.scss new file mode 100644 index 0000000..46688df --- /dev/null +++ b/src/app/sections/contact/contact.component.scss @@ -0,0 +1,109 @@ +:host { + position: relative; + width: 80%; + display: flex; + flex-direction: column; + gap: 2rem; +} + +.card { + border-radius: 1.5rem; + padding: 3rem; + background: #262626; + + .card-content { + color: white; + height: 100%; + width: 100%; + font-family: 'Inter', sans-serif; + font-weight: 200; + font-size: 1.4rem; + line-height: 1.8rem; + overflow: hidden; + + display: flex; + flex-direction: column; + gap: 2rem; + align-items: flex-start; + } +} + +.chat-rows { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +form { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + flex-wrap: nowrap; + gap: 0.4rem; + + .gradient-border { + $border-radius: 2.5rem; + $border: 3px; + position: relative; + width: 80%; + //height: 48px; + background: white; + border-radius: $border-radius; + margin: 1px; + padding: $border calc($border-radius / 2); + + &::before { + // code from: https://dev.to/afif/border-with-gradient-and-radius-387f + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + padding: $border; + margin: -1px; + background: linear-gradient(to right, #a445b2, #fa4299); + -webkit-mask: + linear-gradient(#fff 0 0) content-box, + linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + } + } + + .icon-container { + background: linear-gradient(to right, #a445b2, #fa4299); + height: 3rem; + width: 3rem; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + + .icon { + color: white; + font-size: 1.5rem; + margin-left: -0.15rem; + } + } +} + +:host ::ng-deep { + + .p-inputtext#chat-input { + position: relative; + resize: none; + width: 100%; + height: 100%; + border: 0; + padding-left: 0; + padding-right: 0; + + &:focus { + outline: none; + box-shadow: none; + //box-shadow: 1px 1px 5px rgba(1, 1, 0, .7); + } + } + +} diff --git a/src/app/sections/contact/contact.component.ts b/src/app/sections/contact/contact.component.ts new file mode 100644 index 0000000..2c7d252 --- /dev/null +++ b/src/app/sections/contact/contact.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; +import {ChatRowComponent} from "../../components/chat-row/chat-row.component"; +import {NgForOf} from "@angular/common"; +import {DataService} from "../../data/data.service"; +import {InputTextareaModule} from "primeng/inputtextarea"; + +@Component({ + selector: 'contact', + standalone: true, + imports: [ + ChatRowComponent, + NgForOf, + InputTextareaModule + ], + templateUrl: './contact.component.html', + styleUrl: './contact.component.scss' +}) +export class ContactComponent { + + constructor(readonly dataService: DataService) {} + +} diff --git a/src/app/sections/footer-section/footer-section.component.html b/src/app/sections/footer-section/footer-section.component.html new file mode 100644 index 0000000..ead61fe --- /dev/null +++ b/src/app/sections/footer-section/footer-section.component.html @@ -0,0 +1,6 @@ +
+
C 2023 axherrm
+
Insta
+
+
{{ appVersion }}
+
diff --git a/src/app/sections/footer-section/footer-section.component.scss b/src/app/sections/footer-section/footer-section.component.scss new file mode 100644 index 0000000..66ca2ef --- /dev/null +++ b/src/app/sections/footer-section/footer-section.component.scss @@ -0,0 +1,19 @@ +:host { + position: relative; + display: block; + width: 100%; + padding: 2rem; +} + +footer { + position: relative; + display: flex; + width: 100%; + flex-direction: row; + flex-wrap: nowrap; + gap: 2rem; +} + +.spacer { + flex-grow: 1; +} diff --git a/src/app/sections/footer-section/footer-section.component.ts b/src/app/sections/footer-section/footer-section.component.ts new file mode 100644 index 0000000..ef3e4c6 --- /dev/null +++ b/src/app/sections/footer-section/footer-section.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import {appVersion} from "../../js/global.vars"; + +@Component({ + selector: 'footer-section', + standalone: true, + imports: [], + templateUrl: './footer-section.component.html', + styleUrl: './footer-section.component.scss' +}) +export class FooterSectionComponent { + + protected readonly appVersion = appVersion; +} diff --git a/src/assets/chat-avatar/anonymous.svg b/src/assets/chat-avatar/anonymous.svg new file mode 100644 index 0000000..1551bd1 --- /dev/null +++ b/src/assets/chat-avatar/anonymous.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/chat-avatar/avatar.png b/src/assets/chat-avatar/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..801fdf0d109a1aef421775a6f7dedb4d6b99298d GIT binary patch literal 30510 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 FV1nLZQv(11 literal 0 HcmV?d00001 diff --git a/src/data/contact.json b/src/data/contact.json new file mode 100644 index 0000000..7df0509 --- /dev/null +++ b/src/data/contact.json @@ -0,0 +1,10 @@ +{ + "de": [ + "Ich hoffe, diese Webseite gefällt Ihnen!", + "Ich freue mich über jegliche Rückmeldungen und Business Anfragen." + ], + "en": [ + "I hope you like this website!", + "I look forward to any feedback and business inquiries." + ] +} diff --git a/src/data/general.json b/src/data/general.json index 360a7ff..8b128ee 100644 --- a/src/data/general.json +++ b/src/data/general.json @@ -14,7 +14,8 @@ "education": "Bildungsweg", "experience": "Erfahrung", "skills": "Kenntnisse", - "about": "Über" + "about": "Über", + "contact": "Kontakt" }, "en": { "id": "en", @@ -29,6 +30,7 @@ "education": "Education", "experience": "Experience", "skills": "Skills", - "about": "About" + "about": "About", + "contact": "Contact" } } From 36d43740bfda2dab46f5db3bf948b1ca1bbeae89 Mon Sep 17 00:00:00 2001 From: axherrm Date: Thu, 1 Feb 2024 21:18:44 +0100 Subject: [PATCH 2/2] finish contact and footer --- package-lock.json | 13 +- package.json | 3 +- src/app/app.component.html | 4 +- src/app/components/badge/badge.component.scss | 13 -- .../chat-row/chat-row.component.html | 8 +- .../chat-row/chat-row.component.scss | 21 +++- .../components/chat-row/chat-row.component.ts | 9 ++ .../social-media-card.component.html | 11 ++ .../social-media-card.component.scss | 39 ++++++ .../social-media-card.component.ts | 18 +++ src/app/data/data.service.ts | 29 ++++- src/app/data/model.ts | 23 ++++ src/app/sections/about/about.component.html | 1 + .../sections/contact/contact.component.html | 19 ++- .../sections/contact/contact.component.scss | 100 ++++++++------- src/app/sections/contact/contact.component.ts | 118 +++++++++++++++++- .../footer-section.component.html | 17 ++- .../footer-section.component.scss | 25 +++- .../footer-section.component.ts | 11 +- src/app/services/mail.service.ts | 31 +++++ src/assets/svg/EmailJS.svg | 1 + src/data/contact.json | 69 ++++++++-- src/styles.scss | 6 + 23 files changed, 491 insertions(+), 98 deletions(-) create mode 100644 src/app/components/social-media-card/social-media-card.component.html create mode 100644 src/app/components/social-media-card/social-media-card.component.scss create mode 100644 src/app/components/social-media-card/social-media-card.component.ts create mode 100644 src/app/services/mail.service.ts create mode 100644 src/assets/svg/EmailJS.svg diff --git a/package-lock.json b/package-lock.json index 1e79b4f..b9208d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv", - "version": "0.1", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv", - "version": "0.1", + "version": "0.1.0", "dependencies": { "@angular/common": "^17.0.7", "@angular/compiler": "^17.0.7", @@ -16,6 +16,7 @@ "@angular/platform-browser": "^17.0.7", "@angular/platform-browser-dynamic": "^17.0.7", "@angular/router": "^17.0.7", + "@emailjs/browser": "^3.12.1", "@studio-freight/lenis": "^1.0.33", "gsap": "^3.12.2", "js-circle-progress": "^1.0.0-beta.0", @@ -2291,6 +2292,14 @@ "node": ">=10.0.0" } }, + "node_modules/@emailjs/browser": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@emailjs/browser/-/browser-3.12.1.tgz", + "integrity": "sha512-C5nK07CgSCFx3onsuRt/ZaaMvIi0T3SHHanM7fKozjSvbZu+OjHHP9W608fYpic0OavF7yIlfy4+lRDO33JdbA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@esbuild/android-arm": { "version": "0.19.5", "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.5.tgz", diff --git a/package.json b/package.json index 05ed2e9..a6f14ad 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "@angular/compiler": "^17.0.7", "@angular/core": "^17.0.7", "@angular/forms": "^17.0.7", + "@angular/material": "^17.0.7", "@angular/platform-browser": "^17.0.7", "@angular/platform-browser-dynamic": "^17.0.7", "@angular/router": "^17.0.7", - "@angular/material": "^17.0.7", + "@emailjs/browser": "^3.12.1", "@studio-freight/lenis": "^1.0.33", "gsap": "^3.12.2", "js-circle-progress": "^1.0.0-beta.0", diff --git a/src/app/app.component.html b/src/app/app.component.html index 93ff890..8052c76 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,4 +1,4 @@ -
+
@@ -72,8 +72,6 @@

- -
diff --git a/src/app/components/badge/badge.component.scss b/src/app/components/badge/badge.component.scss index c662000..e20fee7 100644 --- a/src/app/components/badge/badge.component.scss +++ b/src/app/components/badge/badge.component.scss @@ -52,20 +52,7 @@ max-height: 100%; display: flex; align-items: center; - //margin-right: 0.3rem; - //max-height: 2.25rem; - //height: 100%; - //width: auto; font-size: 2rem; - - object { - //height: 100%; - //width: auto; - } - - //img { - // height: 100%; - //} } .text { diff --git a/src/app/components/chat-row/chat-row.component.html b/src/app/components/chat-row/chat-row.component.html index a5b6b66..2078081 100644 --- a/src/app/components/chat-row/chat-row.component.html +++ b/src/app/components/chat-row/chat-row.component.html @@ -1,11 +1,11 @@ -
+
-
-
- +
+
+
diff --git a/src/app/components/chat-row/chat-row.component.scss b/src/app/components/chat-row/chat-row.component.scss index dfadc09..6757834 100644 --- a/src/app/components/chat-row/chat-row.component.scss +++ b/src/app/components/chat-row/chat-row.component.scss @@ -2,6 +2,7 @@ position: relative; display: flex; width: 70%; + margin-top: 1.2rem; flex-wrap: nowrap; justify-content: flex-start; align-items: flex-start; @@ -49,6 +50,10 @@ transform: translateX(-1.4rem) rotateZ(-45deg); } } + + &.following-message { + margin-top: 0.3rem; + } } .img-container { @@ -70,15 +75,15 @@ background: linear-gradient(90deg, #a445b2, #fa4299); border-radius: 1rem; padding: 1.5rem; - font-family: 'Noto Sans', sans-serif; - font-size: 1.7rem; - line-height: 1.7rem; text-align: left; + + &.not-received { + filter: brightness(0.5); + } } .triangle { margin-top: calc((var(--1-row-height) - var(--triangle-height)) / 2); - //margin-top: -0.5rem; width: 0; height: 0; } @@ -91,6 +96,14 @@ flex-shrink: 0; background: #a445b2; border-radius: 0.4rem; + + &.not-received { + filter: brightness(0.5); + } +} + +.hidden { + visibility: hidden; } // for real gradient on multiple elements, see: https://codepen.io/axherrm/pen/YzgYEeL diff --git a/src/app/components/chat-row/chat-row.component.ts b/src/app/components/chat-row/chat-row.component.ts index e71b25d..fcc4256 100644 --- a/src/app/components/chat-row/chat-row.component.ts +++ b/src/app/components/chat-row/chat-row.component.ts @@ -12,7 +12,16 @@ import {NgIf} from "@angular/common"; }) export class ChatRowComponent { + @Input() text: string; @Input() side: "left" | "right" = "left"; + /** + * Whether a message is not the first message on the same side of the chat. + */ + @Input() @HostBinding("class.following-message") followingMessage: boolean = false; + /** + * Whether the message is sent successfully. Not sent message are styled differently. + */ + @Input() sent: boolean = true; // @HostBinding("style.flex-direction") flexDirection: string = "row"; @HostBinding("class.left") get left() { return this.isLeft() } diff --git a/src/app/components/social-media-card/social-media-card.component.html b/src/app/components/social-media-card/social-media-card.component.html new file mode 100644 index 0000000..4a6712d --- /dev/null +++ b/src/app/components/social-media-card/social-media-card.component.html @@ -0,0 +1,11 @@ + +
+ +
+ +
+
+ +
+
{{ input.category }}
+
diff --git a/src/app/components/social-media-card/social-media-card.component.scss b/src/app/components/social-media-card/social-media-card.component.scss new file mode 100644 index 0000000..084f3e0 --- /dev/null +++ b/src/app/components/social-media-card/social-media-card.component.scss @@ -0,0 +1,39 @@ +.container { + border-radius: 1.5rem; + padding: 3rem; + background: #262626; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + + color: white; + //max-width: 20vw; +} + +.icon-container { + background: black; + height: 4rem; + width: 4rem; + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; + .icon { + aspect-ratio: 1; + color: white; + font-size: 1.7rem; + //display: flex; + //align-items: center; + } + margin-bottom: 3rem; +} + +.username { + font-size: 1.6rem; + text-align: right; +} + +.category { + margin-top: 1rem; +} diff --git a/src/app/components/social-media-card/social-media-card.component.ts b/src/app/components/social-media-card/social-media-card.component.ts new file mode 100644 index 0000000..8c9aaba --- /dev/null +++ b/src/app/components/social-media-card/social-media-card.component.ts @@ -0,0 +1,18 @@ +import {Component, Input} from '@angular/core'; +import {SocialMediaItem} from "../../data/model"; +import {NgIf} from "@angular/common"; + +@Component({ + selector: 'social-media-card', + standalone: true, + imports: [ + NgIf + ], + templateUrl: './social-media-card.component.html', + styleUrl: './social-media-card.component.scss' +}) +export class SocialMediaCardComponent { + + @Input({required: true}) input: SocialMediaItem; + +} diff --git a/src/app/data/data.service.ts b/src/app/data/data.service.ts index a76c074..5dcbf2b 100644 --- a/src/app/data/data.service.ts +++ b/src/app/data/data.service.ts @@ -1,5 +1,14 @@ import {EventEmitter, Injectable} from '@angular/core'; -import {AboutCard, EducationItem, ExperienceItem, LanguagePack, Skill, SkillCategory} from "./model"; +import { + AboutCard, + ContactMessages, + EducationItem, + ExperienceItem, + LanguagePack, + MailSettings, + Skill, + SkillCategory, SocialMediaItem +} from "./model"; import {MenuItem} from "primeng/api"; import * as educationJson from '../../data/education.json'; import * as generalJson from '../../data/general.json'; @@ -8,6 +17,10 @@ import * as skillsJson from '../../data/skills.json'; import * as aboutJson from '../../data/about.json'; import * as contactJson from '../../data/contact.json'; +/** + * Service that imports all the customizable JSON data and stores them. + * Access user data through this service. + */ @Injectable({ providedIn: 'root' }) @@ -16,7 +29,12 @@ export class DataService { defaultLang: string = generalJson.defaultLanguage; loadedLanguages: string[] = generalJson.languages; languagesMenuItems: MenuItem[] = []; + mailSettings: MailSettings = contactJson["mail-settings"]; + socialMedia: SocialMediaItem[] = contactJson["social-media"]; + /** + * Language specific data + */ lang: string; languagePack: LanguagePack; education: EducationItem[]; @@ -24,8 +42,11 @@ export class DataService { skillCategories: SkillCategory[]; skills: Skill[]; about: AboutCard[]; - contact: string[]; + contact: ContactMessages; + /** + * Emitted when the user switches language + */ langChange: EventEmitter = new EventEmitter(true); constructor() { @@ -35,10 +56,10 @@ export class DataService { loadData(): void { console.log("Loading data for lang", this.lang); // @ts-ignore - this.education = educationJson[this.lang]; - // @ts-ignore this.languagePack = new LanguagePack(generalJson[this.lang]); // @ts-ignore + this.education = educationJson[this.lang]; + // @ts-ignore this.experience = experienceJson[this.lang]; // @ts-ignore this.skillCategories = skillsJson[this.lang]; diff --git a/src/app/data/model.ts b/src/app/data/model.ts index dbfa6f2..bd3ffa9 100644 --- a/src/app/data/model.ts +++ b/src/app/data/model.ts @@ -99,3 +99,26 @@ export interface AboutCard { heading?: string; text?: string; } + +export interface MailSettings { + enabled: boolean; + publicKey: string; + serviceId: string; + templateId: string; + ownMessageDelay: number; +} + +export interface ContactMessages { + conversationStart: string[]; + successMessages: string[]; + failedMessages: string[]; + tooManyMessages: string[]; +} + +export interface SocialMediaItem { + category: string; + username: string; + primeIcon?: string; + iconRef?: string; + link: string; +} diff --git a/src/app/sections/about/about.component.html b/src/app/sections/about/about.component.html index 8d9aac6..9e2d938 100644 --- a/src/app/sections/about/about.component.html +++ b/src/app/sections/about/about.component.html @@ -10,6 +10,7 @@ PrimeIcons GSAP Lenis + EmailJS
diff --git a/src/app/sections/contact/contact.component.html b/src/app/sections/contact/contact.component.html index 45986ce..ec75edf 100644 --- a/src/app/sections/contact/contact.component.html +++ b/src/app/sections/contact/contact.component.html @@ -1,14 +1,21 @@ -
+
- {{ message }} - Das ist eine Testmessage + +
-
+ +
- +
-
+
+ + +
diff --git a/src/app/sections/contact/contact.component.scss b/src/app/sections/contact/contact.component.scss index 46688df..f22e119 100644 --- a/src/app/sections/contact/contact.component.scss +++ b/src/app/sections/contact/contact.component.scss @@ -1,3 +1,8 @@ +//$font-family: 'Inter', sans-serif; +//$font-weight: 200; +$font-size: 1.6rem; +$line-height: $font-size; + :host { position: relative; width: 80%; @@ -15,10 +20,12 @@ color: white; height: 100%; width: 100%; + font-family: 'Inter', sans-serif; + font-size: $font-size; + line-height: $line-height; font-weight: 200; - font-size: 1.4rem; - line-height: 1.8rem; + overflow: hidden; display: flex; @@ -39,46 +46,64 @@ form { width: 100%; display: flex; justify-content: center; - align-items: center; + align-items: flex-end; flex-wrap: nowrap; gap: 0.4rem; + $textarea-padding: 0.75rem; + $chat-border: 3px; + .gradient-border { - $border-radius: 2.5rem; - $border: 3px; position: relative; + z-index: 2; width: 80%; - //height: 48px; - background: white; - border-radius: $border-radius; - margin: 1px; - padding: $border calc($border-radius / 2); - - &::before { - // code from: https://dev.to/afif/border-with-gradient-and-radius-387f - content: ""; - position: absolute; - inset: 0; + background: linear-gradient(to right, #a445b2, #fa4299); + border-radius: 2.5rem; + line-height: 0; // Safari & Chrome need this + padding: $chat-border; + + textarea#chat-input { + box-sizing: border-box; + width: 100%; + min-height: calc($line-height + $textarea-padding * 2); + border: 0; + margin: 0; + + resize: none; + overflow: hidden; + + font-family: inherit; + font-weight: inherit; + font-size: inherit; + line-height: $line-height; + border-radius: inherit; - padding: $border; - margin: -1px; - background: linear-gradient(to right, #a445b2, #fa4299); - -webkit-mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; + padding: $textarea-padding 1.25rem; + &:focus { + outline: none; + box-shadow: none; + } } } .icon-container { + z-index: 1; background: linear-gradient(to right, #a445b2, #fa4299); - height: 3rem; - width: 3rem; + height: calc($line-height + $textarea-padding * 2 + $chat-border * 2); + width: auto; + aspect-ratio: 1; border-radius: 50%; display: flex; justify-content: center; align-items: center; + border: 0; + + transition: all .1s linear; + &.invisible { + transform: translateX(-150%); + opacity: 0; + visibility: hidden; + } .icon { color: white; @@ -88,22 +113,9 @@ form { } } -:host ::ng-deep { - - .p-inputtext#chat-input { - position: relative; - resize: none; - width: 100%; - height: 100%; - border: 0; - padding-left: 0; - padding-right: 0; - - &:focus { - outline: none; - box-shadow: none; - //box-shadow: 1px 1px 5px rgba(1, 1, 0, .7); - } - } - +.social-media-container { + position: relative; + width: 100%; + display: flex; + gap: 2rem; } diff --git a/src/app/sections/contact/contact.component.ts b/src/app/sections/contact/contact.component.ts index 2c7d252..8e9467d 100644 --- a/src/app/sections/contact/contact.component.ts +++ b/src/app/sections/contact/contact.component.ts @@ -1,8 +1,20 @@ -import { Component } from '@angular/core'; +import {ChangeDetectorRef, Component, ElementRef, HostListener, Renderer2, ViewChild} from '@angular/core'; import {ChatRowComponent} from "../../components/chat-row/chat-row.component"; import {NgForOf} from "@angular/common"; import {DataService} from "../../data/data.service"; -import {InputTextareaModule} from "primeng/inputtextarea"; +import {FormControl, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import {MailService} from "../../services/mail.service"; +import gsap from "gsap"; +import {ScrollTrigger} from "gsap/ScrollTrigger"; +import {SocialMediaCardComponent} from "../../components/social-media-card/social-media-card.component"; + +gsap.registerPlugin(ScrollTrigger); + +interface Message { + text: string; + side: "left" | "right"; + sent: boolean; +} @Component({ selector: 'contact', @@ -10,13 +22,111 @@ import {InputTextareaModule} from "primeng/inputtextarea"; imports: [ ChatRowComponent, NgForOf, - InputTextareaModule + ReactiveFormsModule, + FormsModule, + SocialMediaCardComponent ], templateUrl: './contact.component.html', styleUrl: './contact.component.scss' }) export class ContactComponent { - constructor(readonly dataService: DataService) {} + @ViewChild("chat_input", {read: ElementRef}) chatInputEl: ElementRef; + @ViewChild("social_media_container", {read: ElementRef}) socialMediaContainer: ElementRef; + @ViewChild("spacer", {read: ElementRef}) spacer: ElementRef; + + messages: Message[] = []; + messagesSent: number = 0; + + text: FormControl = new FormControl("", [Validators.required]); + + protected readonly noop = Function; + + constructor(readonly dataService: DataService, + readonly renderer2: Renderer2, + readonly mailService: MailService, + readonly host: ElementRef, + readonly changeDetectorRef: ChangeDetectorRef) { + } + + ngAfterViewInit() { + ScrollTrigger.create({ + trigger: this.host.nativeElement, + start: "top 80%", + onEnter: () => this.showOwnMessages(this.dataService.contact.conversationStart), + once: true + }) + this.text.valueChanges.subscribe(() => this.resizeChatInput()); + this.adjustSpacing(); + } + + @HostListener('window:resize', ['$event']) + onResize() { + this.adjustSpacing(); + } + + adjustSpacing() { + const lastElementHeight = this.socialMediaContainer.nativeElement.clientHeight; + this.renderer2.setStyle(this.spacer.nativeElement, "height", `calc((100vh - ${lastElementHeight}px) / 2 - 2rem)`); + } + + /** + * Resizes chat input field whenever a new character is inserted to the textarea + */ + resizeChatInput() { + this.renderer2.setStyle(this.chatInputEl.nativeElement, "height", "auto"); + if (this.chatInputEl.nativeElement.scrollHeight !== this.chatInputEl.nativeElement.clientHeight) { + this.renderer2.setStyle(this.chatInputEl.nativeElement, + "height", this.chatInputEl.nativeElement.scrollHeight + "px"); + } + ScrollTrigger.refresh(); + } + + /** + * Called whenever the user wants to send the message. + * Content of textarea gets displayed as new message of the user. + * If delivery of the message via mail successes, the message gets restyled. + * Also triggers responses (for first successful message, failed messages and spam protection). + */ + sendMessage() { + if (!this.text.value || this.text.invalid) { return; } + + const text = this.text.value.replaceAll(/\n/g, "
"); + const newMessage: Message = {text: text, side: "right", sent: false}; + this.messages.push(newMessage); + ScrollTrigger.refresh(); + if (this.messagesSent < 5) { + this.mailService.sendMail(text, + () => { + newMessage.sent = true; + if (this.messagesSent === 0) { + this.showOwnMessages(this.dataService.contact.successMessages); + } + this.messagesSent++; + }, () => { + this.showOwnMessages(this.dataService.contact.failedMessages); + } + ) + } else { + this.showOwnMessages(this.dataService.contact.tooManyMessages); + } + this.text.reset(); + } + /** + * Displays messages of the website owner on the left side of the chat. + * Adds delay for multiple messages as configured in mail settings. + * @param messages + */ + showOwnMessages(messages: string[]) { + let totalDelay = 0; + for (let message of messages) { + setTimeout(() => { + this.messages.push({side: "left", sent: true, text: message}); + this.changeDetectorRef.detectChanges(); + ScrollTrigger.refresh(); + }, totalDelay); + totalDelay += this.dataService.mailSettings.ownMessageDelay; + } + } } diff --git a/src/app/sections/footer-section/footer-section.component.html b/src/app/sections/footer-section/footer-section.component.html index ead61fe..4a846e6 100644 --- a/src/app/sections/footer-section/footer-section.component.html +++ b/src/app/sections/footer-section/footer-section.component.html @@ -1,6 +1,17 @@ diff --git a/src/app/sections/footer-section/footer-section.component.scss b/src/app/sections/footer-section/footer-section.component.scss index 66ca2ef..691fff2 100644 --- a/src/app/sections/footer-section/footer-section.component.scss +++ b/src/app/sections/footer-section/footer-section.component.scss @@ -1,5 +1,7 @@ :host { - position: relative; + position: absolute; + bottom: 0; + left: 0; display: block; width: 100%; padding: 2rem; @@ -17,3 +19,24 @@ footer { .spacer { flex-grow: 1; } + +.button { + padding: 1rem; + background: #262626; + //backdrop-filter: blur(5px); + border-radius: 2rem; + display: flex; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + + color: white; + font-size: 1rem; + box-shadow: 0 0 5px #a445b2, 0 0 5px #fa4299; +} + +.text { + //background: -webkit-linear-gradient(left, #a445b2, #fa4299); + //-webkit-background-clip: text; + //-webkit-text-fill-color: transparent; +} diff --git a/src/app/sections/footer-section/footer-section.component.ts b/src/app/sections/footer-section/footer-section.component.ts index ef3e4c6..e712050 100644 --- a/src/app/sections/footer-section/footer-section.component.ts +++ b/src/app/sections/footer-section/footer-section.component.ts @@ -1,14 +1,21 @@ import { Component } from '@angular/core'; -import {appVersion} from "../../js/global.vars"; +import {appVersion, githubURL} from "../../js/global.vars"; +import {CustomButtonComponent} from "../../components/custom-button/custom-button.component"; @Component({ selector: 'footer-section', standalone: true, - imports: [], + imports: [ + CustomButtonComponent + ], templateUrl: './footer-section.component.html', styleUrl: './footer-section.component.scss' }) export class FooterSectionComponent { protected readonly appVersion = appVersion; + protected readonly githubURL = githubURL; + + currentYear: string = '' + new Date().getFullYear(); + } diff --git a/src/app/services/mail.service.ts b/src/app/services/mail.service.ts new file mode 100644 index 0000000..9fe7730 --- /dev/null +++ b/src/app/services/mail.service.ts @@ -0,0 +1,31 @@ +import {Injectable} from '@angular/core'; +import emailjs from '@emailjs/browser'; +import {DataService} from "../data/data.service"; + +@Injectable({ + providedIn: 'root' +}) +export class MailService { + + constructor(readonly dataService: DataService) {} + + /** + * Sends the given message via configured mail settings and invokes appropriate callback function + * @param message + * @param onSuccess + * @param onFail + */ + async sendMail(message: string, onSuccess: () => void, onFail: () => void) { + emailjs.send(this.dataService.mailSettings.serviceId, this.dataService.mailSettings.templateId, { + message: message, + host: location.host + }, this.dataService.mailSettings.publicKey) + .then(response => { + console.log("Sent message successfully", response.status, response.text); + onSuccess(); + }, error => { + console.error("Sending message failed", error); + onFail(); + }); + } +} diff --git a/src/assets/svg/EmailJS.svg b/src/assets/svg/EmailJS.svg new file mode 100644 index 0000000..e8df7bd --- /dev/null +++ b/src/assets/svg/EmailJS.svg @@ -0,0 +1 @@ + diff --git a/src/data/contact.json b/src/data/contact.json index 7df0509..47509cc 100644 --- a/src/data/contact.json +++ b/src/data/contact.json @@ -1,10 +1,65 @@ { - "de": [ - "Ich hoffe, diese Webseite gefällt Ihnen!", - "Ich freue mich über jegliche Rückmeldungen und Business Anfragen." + "mail-settings": { + "enabled": true, + "publicKey": "0_0q3lt_klW_BfqYj", + "serviceId": "CV", + "templateId": "CV-template", + "ownMessageDelay": 1500 + }, + "social-media": [ + { + "category": "E-Mail", + "username": "axherrm.business@gmail.com", + "primeIcon": "pi-envelope", + "link": "mailto:axherrm.business@gmail.com" + }, + { + "category": "LinkedIn", + "username": "@axherrm", + "primeIcon": "pi-linkedin", + "link": "https://www.linkedin.com/in/axherrm/" + }, + { + "category": "GitHub", + "username": "@axherrm", + "primeIcon": "pi-github", + "link": "https://github.com/axherrm" + } ], - "en": [ - "I hope you like this website!", - "I look forward to any feedback and business inquiries." - ] + "de": { + "conversationStart": [ + "Ich hoffe, diese Webseite gefällt Ihnen!", + "Ich freue mich über jegliche Rückmeldungen und Business Anfragen." + ], + "successMessages": [ + "Vielen Dank für Ihre Nachricht.", + "Falls Sie noch etwas hinzufügen möchten oder Sie vergessen haben anzugeben, wie ich Sie erreichen kann, schreiben Sie einfach eine weitere Nachricht." + ], + "failedMessages": [ + "Ihre Nachricht konnte leider aufgrund von technischen Problemen nicht zugestellt werden.", + "Bitte verwenden sie eine andere Kontaktmöglichkeit stattdessen." + ], + "tooManyMessages": [ + "Zum Schutz vor Spam können maximal fünf Nachrichten versendet werden.", + "Falls es sich nicht um Spam handelt, benutzen Sie bitte eine andere Kontaktmöglichkeit." + ] + }, + "en": { + "conversationStart": [ + "I hope you like this website!", + "I look forward to any feedback and business inquiries." + ], + "successMessages": [ + "Thank you for your message.", + "In case there is anything else you would like to add or if you have forgotten to specify how I can reach you, simply write another message." + ], + "failedMessages": [ + "Unfortunately, your message could not be delivered due to technical problems.", + "Please use another contact option instead." + ], + "tooManyMessages": [ + "To protect against spam, a maximum of five messages may be sent.", + "If it is not spam, simply use another contact option." + ] + } } diff --git a/src/styles.scss b/src/styles.scss index 02f423a..147c4a4 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -22,6 +22,12 @@ html { // TODO remove once style theme is properly done: Anleitung dafür: https://angularindepth.com/posts/1320/custom-theme-for-angular-material-components-series-part-1-create-a-theme :root { + // deactivate link style + a { + color: inherit; + text-decoration: none; + } + --color-grey-dark-1: #363C43; --color-grey-dark-2: #747474; --color-grey-medium-1: #CACACA;