merge #11
| @ -10,6 +10,7 @@ | |||||||
|     "cssVariables": true, |     "cssVariables": true, | ||||||
|     "prefix": "" |     "prefix": "" | ||||||
|   }, |   }, | ||||||
|  |   "iconLibrary": "lucide", | ||||||
|   "aliases": { |   "aliases": { | ||||||
|     "components": "@/components", |     "components": "@/components", | ||||||
|     "utils": "@/lib/utils", |     "utils": "@/lib/utils", | ||||||
| @ -17,5 +18,7 @@ | |||||||
|     "lib": "@/lib", |     "lib": "@/lib", | ||||||
|     "hooks": "@/hooks" |     "hooks": "@/hooks" | ||||||
|   }, |   }, | ||||||
|   "iconLibrary": "lucide" |   "registries": { | ||||||
|  |     "@aceternity": "https://ui.aceternity.com/registry/{name}.json" | ||||||
|  |   } | ||||||
| } | } | ||||||
							
								
								
									
										28
									
								
								messages/de/contact.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								messages/de/contact.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | |||||||
|  | { | ||||||
|  |   "subTitle": "Lass uns", | ||||||
|  |   "title": "Starten", | ||||||
|  |   "form": { | ||||||
|  |     "name": { | ||||||
|  |       "label": "Name", | ||||||
|  |       "placeholder": "Dein name" | ||||||
|  |     }, | ||||||
|  |     "email": { | ||||||
|  |       "label": "Email", | ||||||
|  |       "placeholder": "Deine email" | ||||||
|  |     }, | ||||||
|  |     "budget": { | ||||||
|  |       "label": "Dein Budget", | ||||||
|  |       "placeholder": "Select your budget", | ||||||
|  |       "options": { | ||||||
|  |         "1": "Weniger als €2k", | ||||||
|  |         "2": "Mehr als €4k", | ||||||
|  |         "3": "Mehr als €6k" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "message": { | ||||||
|  |       "label": "Nachricht", | ||||||
|  |       "placeholder": "Deine Nachricht" | ||||||
|  |     }, | ||||||
|  |     "submit": "Absenden" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								messages/de/global.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								messages/de/global.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | |||||||
|  | { | ||||||
|  |   "and": "und", | ||||||
|  | 
 | ||||||
|  |   "me": { | ||||||
|  |     "name": "Pablo", | ||||||
|  |     "role": "Webentwickler", | ||||||
|  |     "work": "Verfügbar für Aufträge" | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|  |   "navbar": { | ||||||
|  |     "home": "Home", | ||||||
|  |     "projects": "Projekte", | ||||||
|  |     "work": "Auftrag Anfragen" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								messages/de/home.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								messages/de/home.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | { | ||||||
|  |   "hero": { | ||||||
|  |     "slot1": { | ||||||
|  |       "label": "Zeitlos", | ||||||
|  |       "link": "https://weather.shrt.solutions", | ||||||
|  |       "image": "/projects/weather.png" | ||||||
|  |     }, | ||||||
|  |     "slot2": { | ||||||
|  |       "label": "Kreativ", | ||||||
|  |       "link": "https://vico.shortman.me", | ||||||
|  |       "image": "/projects/link-preview-vico.png" | ||||||
|  |     }, | ||||||
|  |     "slot3": { | ||||||
|  |       "label": "Einzigartig", | ||||||
|  |       "link": "https://ivorygrow.de", | ||||||
|  |       "image": "/projects/ivory.png" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "tech": { | ||||||
|  |     "text": "bevorzugte", | ||||||
|  |     "technologies": "Technologien", | ||||||
|  |     "tools": "Werkzeuge" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										20
									
								
								messages/de/legal.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								messages/de/legal.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,20 @@ | |||||||
|  | { | ||||||
|  |   "legal": { | ||||||
|  |     "imprint": { | ||||||
|  |       "title": "Impressum", | ||||||
|  |       "company": "Firma", | ||||||
|  |       "address": "Adresse", | ||||||
|  |       "germany": "Deutschland", | ||||||
|  |       "contact": "Kontakt" | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     "privacy": { | ||||||
|  |       "title": "Datenschutzerklärung" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "footer": { | ||||||
|  |     "copyright": "© {year} Pablo Kurzmann - Portfolio. Alle Rechte vorbehalten.", | ||||||
|  |     "imprint": "impressum", | ||||||
|  |     "privacy": "datenschutz" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								messages/de/projects.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								messages/de/projects.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "0": { | ||||||
|  |     "image": "/projects/ivory.png", | ||||||
|  |     "link": { "live": "https://ivorygrow.de" }, | ||||||
|  |     "title": "Ivory Grow & Headshop", | ||||||
|  |     "description": "Modernes Webprojekt für einen nachhaltigen Head- & Growshop. Cleanes Design, DSGVO-konform und performance-optimiert. Umgesetzt mit Webflow, Fokus auf UX und Markenwirkung.", | ||||||
|  |     "year": "2025" | ||||||
|  |   }, | ||||||
|  |   "1": { | ||||||
|  |     "image": "/projects/weather.png", | ||||||
|  |     "link": { "live": "https://weather.shrt.solutions/" }, | ||||||
|  |     "title": "Wetter App", | ||||||
|  |     "description": "Minimalistische Wetter-App mit klarem Fokus auf Übersicht. Echtzeitdaten, responsive UI und moderne API-Integration. Entwickelt mit TypeScript und React für maximale Performance.", | ||||||
|  |     "year": "2024" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										28
									
								
								messages/en/contact.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								messages/en/contact.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | |||||||
|  | { | ||||||
|  |   "subTitle": "Lets Work", | ||||||
|  |   "title": "Together", | ||||||
|  |   "form": { | ||||||
|  |     "name": { | ||||||
|  |       "label": "Name", | ||||||
|  |       "placeholder": "Your name" | ||||||
|  |     }, | ||||||
|  |     "email": { | ||||||
|  |       "label": "Email", | ||||||
|  |       "placeholder": "Your email" | ||||||
|  |     }, | ||||||
|  |     "budget": { | ||||||
|  |       "label": "Budget", | ||||||
|  |       "placeholder": "Select your budget", | ||||||
|  |       "options": { | ||||||
|  |         "1": "Less than $2k", | ||||||
|  |         "2": "More than $4k", | ||||||
|  |         "3": "More than $6k" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "message": { | ||||||
|  |       "label": "Message", | ||||||
|  |       "placeholder": "Your message" | ||||||
|  |     }, | ||||||
|  |     "submit": "Send" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								messages/en/global.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								messages/en/global.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | { | ||||||
|  |   "and": "and", | ||||||
|  |   "me": { | ||||||
|  |     "name": "Pablo", | ||||||
|  |     "role": "Webdeveloper", | ||||||
|  |     "work": "Available for work" | ||||||
|  |   }, | ||||||
|  |   "navbar": { "home": "Home", "projects": "Projects", "work": "Work with me" } | ||||||
|  | } | ||||||
							
								
								
									
										24
									
								
								messages/en/home.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								messages/en/home.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,24 @@ | |||||||
|  | { | ||||||
|  |   "hero": { | ||||||
|  |     "slot1": { | ||||||
|  |       "label": "Timeless", | ||||||
|  |       "link": "https://weather.shrt.solutions", | ||||||
|  |       "image": "/projects/weather.png" | ||||||
|  |     }, | ||||||
|  |     "slot2": { | ||||||
|  |       "label": "Creative", | ||||||
|  |       "link": "https://vico.shortman.me", | ||||||
|  |       "image": "/projects/link-preview-vico.png" | ||||||
|  |     }, | ||||||
|  |     "slot3": { | ||||||
|  |       "label": "Unique", | ||||||
|  |       "link": "https://ivorygrow.de", | ||||||
|  |       "image": "/projects/ivory.png" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "tech": { | ||||||
|  |     "text": "most liked", | ||||||
|  |     "technologies": "technologies", | ||||||
|  |     "tools": "tools" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										19
									
								
								messages/en/legal.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								messages/en/legal.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | |||||||
|  | { | ||||||
|  |   "legal": { | ||||||
|  |     "imprint": { | ||||||
|  |       "title": "Imprint", | ||||||
|  |       "company": "Company", | ||||||
|  |       "address": "Address", | ||||||
|  |       "germany": "Germany", | ||||||
|  |       "contact": "Contact" | ||||||
|  |     }, | ||||||
|  |     "privacy": { | ||||||
|  |       "title": "Privacy Policy" | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   "footer": { | ||||||
|  |     "imprint": "imprint", | ||||||
|  |     "privacy": "privacy", | ||||||
|  |     "copyright": "© {year} Pablo Kurzmann - Portfolio. All rights reserved." | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								messages/en/projects.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								messages/en/projects.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,16 @@ | |||||||
|  | { | ||||||
|  |   "0": { | ||||||
|  |     "image": "/projects/ivory.png", | ||||||
|  |     "link": { "live": "https://ivorygrow.de" }, | ||||||
|  |     "title": "Ivory Grow & Headshop", | ||||||
|  |     "description": "Modern web project for a sustainable head & grow shop. Clean design, GDPR-compliant and performance optimized. Built with Webflow, focused on UX and brand impact.", | ||||||
|  |     "year": "2025" | ||||||
|  |   }, | ||||||
|  |   "1": { | ||||||
|  |     "image": "/projects/weather.png", | ||||||
|  |     "link": { "live": "https://weather.shrt.solutions/" }, | ||||||
|  |     "title": "Weather App", | ||||||
|  |     "description": "Minimalist weather app focused on clarity and simplicity. Real-time data, responsive UI, and modern API integration. Built with TypeScript and React for top performance.", | ||||||
|  |     "year": "2024" | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										30
									
								
								messages/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								messages/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,30 @@ | |||||||
|  | /* EN Imports */ | ||||||
|  | import enGlobal from "./en/global.json"; | ||||||
|  | import enProjects from "./en/projects.json"; | ||||||
|  | import enHome from "./en/home.json"; | ||||||
|  | import enContact from "./en/contact.json"; | ||||||
|  | import enLegal from "./en/legal.json"; | ||||||
|  | 
 | ||||||
|  | /* DE Imports */ | ||||||
|  | import deGlobal from "./de/global.json"; | ||||||
|  | import deProjects from "./de/projects.json"; | ||||||
|  | import deHome from "./de/home.json"; | ||||||
|  | import deContact from "./de/contact.json"; | ||||||
|  | import deLegal from "./de/legal.json"; | ||||||
|  | 
 | ||||||
|  | export const allMessages = { | ||||||
|  |   en: { | ||||||
|  |     global: enGlobal, | ||||||
|  |     projects: enProjects, | ||||||
|  |     home: enHome, | ||||||
|  |     contact: enContact, | ||||||
|  |     legal: enLegal, | ||||||
|  |   }, | ||||||
|  |   de: { | ||||||
|  |     global: deGlobal, | ||||||
|  |     projects: deProjects, | ||||||
|  |     home: deHome, | ||||||
|  |     contact: deContact, | ||||||
|  |     legal: deLegal, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
| @ -1,7 +1,18 @@ | |||||||
| import type { NextConfig } from "next"; | import { NextConfig } from "next"; | ||||||
|  | import createNextIntlPlugin from "next-intl/plugin"; | ||||||
| 
 | 
 | ||||||
| const nextConfig: NextConfig = { | const nextConfig: NextConfig = { | ||||||
|   /* config options here */ |   async redirects() { | ||||||
|  |     return [ | ||||||
|  |       // Deutsche Bezeichnungen -> kanonische Pfade
 | ||||||
|  |       { source: "/impressum", destination: "/imprint", permanent: true }, | ||||||
|  |       { source: "/datenschutz", destination: "/privacy", permanent: true }, | ||||||
|  | 
 | ||||||
|  |       // Optional: Plural/Varianten
 | ||||||
|  |       { source: "/privacy-policy", destination: "/privacy", permanent: true }, | ||||||
|  |     ]; | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default nextConfig; | const withNextIntl = createNextIntlPlugin(); | ||||||
|  | export default withNextIntl(nextConfig); | ||||||
|  | |||||||
| @ -10,13 +10,22 @@ | |||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@hookform/resolvers": "^3.9.1", |     "@hookform/resolvers": "^3.9.1", | ||||||
|  |     "@radix-ui/react-dropdown-menu": "^2.1.16", | ||||||
|  |     "@radix-ui/react-hover-card": "^1.1.15", | ||||||
|     "@radix-ui/react-label": "^2.1.0", |     "@radix-ui/react-label": "^2.1.0", | ||||||
|  |     "@radix-ui/react-popover": "^1.1.15", | ||||||
|     "@radix-ui/react-select": "^2.1.2", |     "@radix-ui/react-select": "^2.1.2", | ||||||
|     "@radix-ui/react-slot": "^1.1.0", |     "@radix-ui/react-slot": "^1.1.0", | ||||||
|  |     "@types/nodemailer": "^7.0.3", | ||||||
|     "class-variance-authority": "^0.7.1", |     "class-variance-authority": "^0.7.1", | ||||||
|     "clsx": "^2.1.1", |     "clsx": "^2.1.1", | ||||||
|     "lucide-react": "^0.462.0", |     "lucide-react": "^0.462.0", | ||||||
|  |     "motion": "^12.23.24", | ||||||
|     "next": "15.0.3", |     "next": "15.0.3", | ||||||
|  |     "next-intl": "^4.4.0", | ||||||
|  |     "next-themes": "^0.4.6", | ||||||
|  |     "nodemailer": "^7.0.10", | ||||||
|  |     "qss": "^3.0.0", | ||||||
|     "react": "19.0.0-rc-66855b96-20241106", |     "react": "19.0.0-rc-66855b96-20241106", | ||||||
|     "react-dom": "19.0.0-rc-66855b96-20241106", |     "react-dom": "19.0.0-rc-66855b96-20241106", | ||||||
|     "react-hook-form": "^7.53.2", |     "react-hook-form": "^7.53.2", | ||||||
|  | |||||||
							
								
								
									
										1859
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1859
									
								
								pnpm-lock.yaml
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/projects/ivory.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/projects/ivory.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 262 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/projects/link-preview-vico.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/projects/link-preview-vico.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 78 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/projects/weather.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/projects/weather.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 65 KiB | 
| @ -3,7 +3,7 @@ import { TechIcon } from "./components/setions/tech"; | |||||||
| export type SocialIcon = "github" | "discord"; | export type SocialIcon = "github" | "discord"; | ||||||
| 
 | 
 | ||||||
| type AppConfig = { | type AppConfig = { | ||||||
|   navigator: { label: string; path: string }[]; |   navigator: { label: string; path: string; isButton?: boolean }[]; | ||||||
|   socials: { name: string; icon: SocialIcon; link: string }[]; |   socials: { name: string; icon: SocialIcon; link: string }[]; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| @ -18,25 +18,21 @@ export const appConfig: AppConfig = { | |||||||
|       path: "/projects", |       path: "/projects", | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       label: "Contact", |       label: "Work with me", | ||||||
|       path: "/contact/#", |       path: "/contact", | ||||||
|  |       isButton: true, | ||||||
|     }, |     }, | ||||||
|   ], |   ], | ||||||
|   socials: [ |   socials: [ | ||||||
|     { |     { | ||||||
|       name: "discord", |       name: "discord", | ||||||
|       icon: "discord", |       icon: "discord", | ||||||
|       link: "https://discord.com", |       link: "https://discord.gg/njGmuBQrfg", | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|       name: "github", |       name: "github", | ||||||
|       icon: "github", |       icon: "github", | ||||||
|       link: "https://github.com", |       link: "https://github.com/mr-shortman", | ||||||
|     }, |  | ||||||
|     { |  | ||||||
|       name: "github", |  | ||||||
|       icon: "github", |  | ||||||
|       link: "https://github.com", |  | ||||||
|     }, |     }, | ||||||
|   ], |   ], | ||||||
| }; | }; | ||||||
|  | |||||||
							
								
								
									
										58
									
								
								src/app/(ROUTING)/(LEGAL)/imprint/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/app/(ROUTING)/(LEGAL)/imprint/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | |||||||
|  | import { getLocale, getTranslations } from "next-intl/server"; | ||||||
|  | import React from "react"; | ||||||
|  | 
 | ||||||
|  | export default async function Page() { | ||||||
|  |   const t = await getTranslations("legal"); | ||||||
|  |   const locale = await getLocale(); | ||||||
|  |   return ( | ||||||
|  |     <main className="py-12 space-y-4 text-lg"> | ||||||
|  |       <h1 className="text-4xl font-bold">{t("legal.imprint.title")}</h1> | ||||||
|  |       {/* Beispielstruktur – fülle mit deinen Daten */} | ||||||
|  |       {locale === "de" && <p>Angaben gemäß § 5 DDG</p>} | ||||||
|  |       <section> | ||||||
|  |         <p className="font-bold">Pablo Kurzmann</p> | ||||||
|  |         <p>Koppoldstr. 1</p> | ||||||
|  |         <p>86551 Aichach</p> | ||||||
|  |         <p>{t("legal.imprint.germany")}</p> | ||||||
|  |       </section> | ||||||
|  |       <section> | ||||||
|  |         <h2 className="text-2xl font-semibold">{t("legal.imprint.contact")}</h2> | ||||||
|  |         <p>E-Mail: pablo@shortman.me</p> | ||||||
|  |       </section> | ||||||
|  |       {locale === "de" && ( | ||||||
|  |         <> | ||||||
|  |           <section> | ||||||
|  |             <p>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV:</p> | ||||||
|  |             <p>Pablo Kurzmann</p> | ||||||
|  |           </section> | ||||||
|  |           <section> | ||||||
|  |             <h2 className="text-2xl font-semibold">Haftung für Inhalte</h2> | ||||||
|  |             <p> | ||||||
|  |               Die Inhalte dieser Seite wurden mit größter Sorgfalt erstellt. Für | ||||||
|  |               die Richtigkeit, Vollständigkeit und Aktualität der Inhalte | ||||||
|  |               übernehme ich jedoch keine Gewähr. | ||||||
|  |             </p> | ||||||
|  |           </section> | ||||||
|  |           <section> | ||||||
|  |             <h2 className="text-2xl font-semibold">Haftung für Links</h2> | ||||||
|  |             <p> | ||||||
|  |               Diese Website enthält Links zu externen Websites Dritter, auf | ||||||
|  |               deren Inhalte ich keinen Einfluss habe. Daher kann ich für diese | ||||||
|  |               fremden Inhalte keine Gewähr übernehmen. Für die Inhalte der | ||||||
|  |               verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber | ||||||
|  |               verantwortlich. | ||||||
|  |             </p> | ||||||
|  |           </section> | ||||||
|  |           <section> | ||||||
|  |             <h2 className="text-2xl font-semibold">Urheberrecht</h2> | ||||||
|  |             <p> | ||||||
|  |               Die durch den Seitenbetreiber erstellten Inhalte und Werke auf | ||||||
|  |               dieser Website unterliegen dem deutschen Urheberrecht. | ||||||
|  |             </p> | ||||||
|  |           </section> | ||||||
|  |         </> | ||||||
|  |       )} | ||||||
|  |       <p>{t("footer.copyright", { year: new Date().getFullYear() })}</p> | ||||||
|  |     </main> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										189
									
								
								src/app/(ROUTING)/(LEGAL)/privacy/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										189
									
								
								src/app/(ROUTING)/(LEGAL)/privacy/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,189 @@ | |||||||
|  | import { getLocale } from "next-intl/server"; | ||||||
|  | import React from "react"; | ||||||
|  | 
 | ||||||
|  | const PrivacyDE = ( | ||||||
|  |   <main className="py-12 space-y-4 text-lg"> | ||||||
|  |     <h1 className="text-4xl font-bold">Datenschutzerklärung</h1> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <p> | ||||||
|  |         Diese Datenschutzerklärung informiert Sie über die Art, den Umfang und | ||||||
|  |         Zweck der Verarbeitung personenbezogener Daten (nachfolgend kurz | ||||||
|  |         „Daten“) im Rahmen meines Onlineangebots und der mit ihm verbundenen | ||||||
|  |         Webseiten, Funktionen und Inhalte. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">1. Verantwortlicher</h2> | ||||||
|  |       <p className="font-bold">Pablo Kurzmann</p> | ||||||
|  |       <p>Koppoldstr. 1</p> | ||||||
|  |       <p>86551 Aichach</p> | ||||||
|  |       <p>Deutschland</p> | ||||||
|  |       <p>E-Mail: pablo@shortman.me</p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">2. Erhobene Daten und Zweck</h2> | ||||||
|  |       <p> | ||||||
|  |         Beim Besuch dieser Website werden keine personenbezogenen Daten | ||||||
|  |         gespeichert oder an Dritte übermittelt. Es werden keine Cookies, | ||||||
|  |         Tracking- oder Analyse-Tools verwendet. | ||||||
|  |       </p> | ||||||
|  |       <p> | ||||||
|  |         Personenbezogene Daten werden nur dann erhoben, wenn Sie das | ||||||
|  |         Kontaktformular nutzen oder mir freiwillig eine E-Mail senden. In diesem | ||||||
|  |         Fall werden die übermittelten Daten (z. B. Name, E-Mail-Adresse, | ||||||
|  |         Nachricht) ausschließlich zur Bearbeitung Ihrer Anfrage verwendet. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">3. Rechtsgrundlage</h2> | ||||||
|  |       <p> | ||||||
|  |         Die Verarbeitung der Daten erfolgt gemäß Art. 6 Abs. 1 lit. b DSGVO, | ||||||
|  |         soweit sie zur Durchführung vorvertraglicher Maßnahmen erforderlich ist, | ||||||
|  |         oder gemäß Art. 6 Abs. 1 lit. f DSGVO auf Grundlage meines berechtigten | ||||||
|  |         Interesses, Anfragen zu beantworten. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">4. E-Mail-Kommunikation</h2> | ||||||
|  |       <p> | ||||||
|  |         Der Versand und Empfang von E-Mails erfolgt über einen selbst | ||||||
|  |         betriebenen Mailserver mit Standort in Deutschland. Die Server werden | ||||||
|  |         nach aktuellen Sicherheitsstandards betrieben. Dennoch kann bei der | ||||||
|  |         Übertragung von Daten im Internet (z. B. bei der Kommunikation per | ||||||
|  |         E-Mail) keine vollständige Sicherheit gewährleistet werden. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">5. Speicherdauer</h2> | ||||||
|  |       <p> | ||||||
|  |         Anfragen, die per E-Mail oder über das Kontaktformular eingehen, werden | ||||||
|  |         nur so lange gespeichert, wie sie zur Bearbeitung der Anfrage | ||||||
|  |         erforderlich sind. Gesetzliche Aufbewahrungsfristen bleiben unberührt. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">6. Ihre Rechte</h2> | ||||||
|  |       <p> | ||||||
|  |         Sie haben das Recht auf Auskunft über die von mir verarbeiteten Daten | ||||||
|  |         (Art. 15 DSGVO), auf Berichtigung (Art. 16 DSGVO), Löschung (Art. 17 | ||||||
|  |         DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Widerspruch | ||||||
|  |         (Art. 21 DSGVO) sowie auf Datenübertragbarkeit (Art. 20 DSGVO). | ||||||
|  |       </p> | ||||||
|  |       <p> | ||||||
|  |         Außerdem haben Sie das Recht, sich bei einer Aufsichtsbehörde zu | ||||||
|  |         beschweren, wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer Daten | ||||||
|  |         gegen die DSGVO verstößt (Art. 77 DSGVO). | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">7. Änderungen dieser Erklärung</h2> | ||||||
|  |       <p> | ||||||
|  |         Ich behalte mir vor, diese Datenschutzerklärung bei Änderungen meiner | ||||||
|  |         Website oder gesetzlichen Vorgaben anzupassen. Die jeweils aktuelle | ||||||
|  |         Fassung finden Sie jederzeit auf dieser Seite. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <p>Stand: Oktober 2025</p> | ||||||
|  |   </main> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | const PrivacyEN = ( | ||||||
|  |   <main className="py-12 space-y-4 text-lg"> | ||||||
|  |     <h1 className="text-4xl font-bold">Privacy Policy</h1> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <p> | ||||||
|  |         This privacy policy explains how personal data (“data”) is processed | ||||||
|  |         when you use this website and its contact form. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">1. Controller</h2> | ||||||
|  |       <p className="font-bold">Pablo Kurzmann</p> | ||||||
|  |       <p>Koppoldstr. 1</p> | ||||||
|  |       <p>86551 Aichach</p> | ||||||
|  |       <p>Germany</p> | ||||||
|  |       <p>Email: pablo@shortman.me</p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">2. Data collected and purpose</h2> | ||||||
|  |       <p> | ||||||
|  |         No personal data is stored or shared with third parties when visiting | ||||||
|  |         this website. No cookies, analytics, or tracking tools are used. | ||||||
|  |       </p> | ||||||
|  |       <p> | ||||||
|  |         Personal data is only collected when you use the contact form or send an | ||||||
|  |         email voluntarily. The data you provide (e.g., name, email address, | ||||||
|  |         message) is used solely to respond to your inquiry. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">3. Legal basis</h2> | ||||||
|  |       <p> | ||||||
|  |         Data processing is carried out pursuant to Art. 6 (1)(b) GDPR where it | ||||||
|  |         is necessary for pre-contractual steps, or Art. 6 (1)(f) GDPR based on | ||||||
|  |         the legitimate interest in responding to inquiries. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">4. Email communication</h2> | ||||||
|  |       <p> | ||||||
|  |         Emails are sent and received via a self-hosted mail server located in | ||||||
|  |         Germany. The server is operated according to current security standards. | ||||||
|  |         However, data transmission over the internet (e.g., by email) may have | ||||||
|  |         security gaps. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">5. Storage period</h2> | ||||||
|  |       <p> | ||||||
|  |         Messages sent by email or contact form are stored only as long as | ||||||
|  |         necessary to process your inquiry. Statutory retention periods remain | ||||||
|  |         unaffected. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">6. Your rights</h2> | ||||||
|  |       <p> | ||||||
|  |         You have the right to access (Art. 15 GDPR), rectification (Art. 16), | ||||||
|  |         erasure (Art. 17), restriction (Art. 18), objection (Art. 21), and data | ||||||
|  |         portability (Art. 20 GDPR). | ||||||
|  |       </p> | ||||||
|  |       <p> | ||||||
|  |         You may also lodge a complaint with a supervisory authority if you | ||||||
|  |         believe your data is being processed unlawfully (Art. 77 GDPR). | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <section> | ||||||
|  |       <h2 className="text-2xl font-semibold">7. Changes</h2> | ||||||
|  |       <p> | ||||||
|  |         This privacy policy may be updated to reflect changes in legal | ||||||
|  |         requirements or the website itself. The current version is always | ||||||
|  |         available on this page. | ||||||
|  |       </p> | ||||||
|  |     </section> | ||||||
|  | 
 | ||||||
|  |     <p>Last updated: October 2025</p> | ||||||
|  |   </main> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export default async function Page() { | ||||||
|  |   const locale = await getLocale(); | ||||||
|  |   return locale === "de" ? PrivacyDE : PrivacyEN; | ||||||
|  | } | ||||||
| @ -6,12 +6,7 @@ function Layout({ children }: { children: JSX.Element }) { | |||||||
|   return ( |   return ( | ||||||
|     <div className="space-y-4 "> |     <div className="space-y-4 "> | ||||||
|       <Navbar /> |       <Navbar /> | ||||||
|       <main className="container min-h-screen py-4"> |       <main className="container min-h-screen py-4 ">{children}</main> | ||||||
|         {children} |  | ||||||
| 
 |  | ||||||
|         {/* <div className="absolute w-1 h-screen top-0 left-1/2 -translate-x-1/2 transform bg-foreground" /> */} |  | ||||||
|       </main> |  | ||||||
| 
 |  | ||||||
|       <Footer /> |       <Footer /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| @ -14,8 +14,8 @@ body { | |||||||
|     --card-foreground: 240 10% 3.9%; |     --card-foreground: 240 10% 3.9%; | ||||||
|     --popover: 0 0% 100%; |     --popover: 0 0% 100%; | ||||||
|     --popover-foreground: 240 10% 3.9%; |     --popover-foreground: 240 10% 3.9%; | ||||||
|     --primary: 240 5.9% 10%; |     --primary: 142.1 76.2% 36.3%; | ||||||
|     --primary-foreground: 0 0% 98%; |     --primary-foreground: 355.7 100% 97.3%; | ||||||
|     --secondary: 240 4.8% 95.9%; |     --secondary: 240 4.8% 95.9%; | ||||||
|     --secondary-foreground: 240 5.9% 10%; |     --secondary-foreground: 240 5.9% 10%; | ||||||
|     --muted: 240 4.8% 95.9%; |     --muted: 240 4.8% 95.9%; | ||||||
| @ -26,41 +26,59 @@ body { | |||||||
|     --destructive-foreground: 0 0% 98%; |     --destructive-foreground: 0 0% 98%; | ||||||
|     --border: 240 5.9% 90%; |     --border: 240 5.9% 90%; | ||||||
|     --input: 240 5.9% 90%; |     --input: 240 5.9% 90%; | ||||||
|     --ring: 240 10% 3.9%; |     --ring: 142.1 76.2% 36.3%; | ||||||
|     --chart-1: 12 76% 61%; |  | ||||||
|     --chart-2: 173 58% 39%; |  | ||||||
|     --chart-3: 197 37% 24%; |  | ||||||
|     --chart-4: 43 74% 66%; |  | ||||||
|     --chart-5: 27 87% 67%; |  | ||||||
| 
 |  | ||||||
|     /* Border Radius */ |  | ||||||
|     --radius: 0.5rem; |     --radius: 0.5rem; | ||||||
|  |     --chart-1: 142.1 76.2% 36.3%; | ||||||
|  |     --chart-2: 240 4.8% 95.9%; | ||||||
|  |     --chart-3: 240 4.8% 95.9%; | ||||||
|  |     --chart-4: 240 4.8% 105.9%; | ||||||
|  |     --chart-5: 142.1 76.2% 46.3%; | ||||||
|  | 
 | ||||||
|  |     --sidebar-background: 0 0% 97%; | ||||||
|  |     --sidebar-foreground: 240 10% 38%; | ||||||
|  |     --sidebar-primary: 142 76% 33%; | ||||||
|  |     --sidebar-primary-foreground: 356 100% 100%; | ||||||
|  |     --sidebar-accent: 240 5% 75%; | ||||||
|  |     --sidebar-accent-foreground: 240 5.9% 10%; | ||||||
|  |     --sidebar-border: 240 6% 87%; | ||||||
|  |     --sidebar-ring: 142 76% 33%; | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|   .dark { |   .dark { | ||||||
|     --background: 20 8% 8%; |     --background: 20 14.3% 4.1%; | ||||||
|     --foreground: 0 0% 100%; |     --foreground: 0 0% 95%; | ||||||
|     --card: 240 10% 3.9%; |     --card: 24 9.8% 10%; | ||||||
|     --card-foreground: 0 0% 98%; |     --card-foreground: 0 0% 95%; | ||||||
|     --popover: 240 10% 3.9%; |     --popover: 0 0% 9%; | ||||||
|     --popover-foreground: 0 0% 98%; |     --popover-foreground: 0 0% 95%; | ||||||
|     --primary: 17 90% 59%; |     --primary: 142.1 70.6% 45.3%; | ||||||
|     --primary-foreground: 0 0% 98%; |     --primary-foreground: 144.9 80.4% 10%; | ||||||
|     --secondary: 240 3.7% 15.9%; |     --secondary: 240 3.7% 15.9%; | ||||||
|     --secondary-foreground: 0 0% 98%; |     --secondary-foreground: 0 0% 98%; | ||||||
|     --muted: 20 6% 10%; |     --muted: 0 0% 15%; | ||||||
|     --muted-foreground: 0 5% 58%; |     --muted-foreground: 240 5% 64.9%; | ||||||
|     --accent: 240 3.7% 15.9%; |     --accent: 12 6.5% 15.1%; | ||||||
|     --accent-foreground: 0 0% 98%; |     --accent-foreground: 0 0% 98%; | ||||||
|     --destructive: 0 62.8% 30.6%; |     --destructive: 0 62.8% 30.6%; | ||||||
|     --destructive-foreground: 0 0% 98%; |     --destructive-foreground: 0 85.7% 97.3%; | ||||||
|     --border: 240 3.7% 15.9%; |     --border: 240 3.7% 15.9%; | ||||||
|     --input: 240 3.7% 15.9%; |     --input: 240 3.7% 15.9%; | ||||||
|     --ring: 240 4.9% 83.9%; |     --ring: 142.4 71.8% 29.2%; | ||||||
|     --chart-1: 220 70% 50%; |     --radius: 0.5rem; | ||||||
|     --chart-2: 160 60% 45%; |     --chart-1: 142.1 70.6% 45.3%; | ||||||
|     --chart-3: 30 80% 55%; |     --chart-2: 240 3.7% 15.9%; | ||||||
|     --chart-4: 280 65% 60%; |     --chart-3: 12 6.5% 15.1%; | ||||||
|     --chart-5: 340 75% 55%; |     --chart-4: 12 6.5% 25.1%; | ||||||
|  |     --chart-5: 142.1 70.6% 55.3%; | ||||||
|  | 
 | ||||||
|  |     --sidebar-background: 0 0% 0%; | ||||||
|  |     --sidebar-foreground: 0 0% 50%; | ||||||
|  |     --sidebar-primary: 142 71% 25%; | ||||||
|  |     --sidebar-primary-foreground: 145 80% 100%; | ||||||
|  |     --sidebar-accent: 12 7% 7%; | ||||||
|  |     --sidebar-accent-foreground: 0 0% 98%; | ||||||
|  |     --sidebar-border: 240 4% 8%; | ||||||
|  |     --sidebar-ring: 142 72% 21%; | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| @ -85,7 +103,7 @@ body { | |||||||
|     @apply border-border; |     @apply border-border; | ||||||
|   } |   } | ||||||
|   body { |   body { | ||||||
|     @apply dark bg-background text-foreground; |     @apply bg-background text-foreground; | ||||||
|   } |   } | ||||||
|   html { |   html { | ||||||
|     scroll-behavior: smooth; |     scroll-behavior: smooth; | ||||||
| @ -152,3 +170,12 @@ nav.navbar::before { | |||||||
|   background: var(--accent); |   background: var(--accent); | ||||||
|   opacity: 0.2; |   opacity: 0.2; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | @layer base { | ||||||
|  |   * { | ||||||
|  |     @apply border-border; | ||||||
|  |   } | ||||||
|  |   body { | ||||||
|  |     @apply bg-background text-foreground; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | |||||||
| @ -1,5 +1,8 @@ | |||||||
| import type { Metadata } from "next"; | import type { Metadata } from "next"; | ||||||
| import "./globals.css"; | import "./globals.css"; | ||||||
|  | import { NextIntlClientProvider } from "next-intl"; | ||||||
|  | import { getRequestConfig } from "next-intl/server"; | ||||||
|  | import { ThemeProvider } from "@/components/theme-provider"; | ||||||
| 
 | 
 | ||||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||||
|   title: "Pablo Shortman", |   title: "Pablo Shortman", | ||||||
| @ -12,9 +15,16 @@ export default function RootLayout({ | |||||||
|   children: React.ReactNode; |   children: React.ReactNode; | ||||||
| }>) { | }>) { | ||||||
|   return ( |   return ( | ||||||
|     <html lang="en"> |     <html lang="en" suppressHydrationWarning> | ||||||
|       <body suppressHydrationWarning className={"font-display antialiased"}> |       <body suppressHydrationWarning className={"font-display antialiased"}> | ||||||
|         {children} |         <ThemeProvider | ||||||
|  |           attribute="class" | ||||||
|  |           defaultTheme="system" | ||||||
|  |           enableSystem | ||||||
|  |           disableTransitionOnChange | ||||||
|  |         > | ||||||
|  |           <NextIntlClientProvider>{children}</NextIntlClientProvider> | ||||||
|  |         </ThemeProvider> | ||||||
|       </body> |       </body> | ||||||
|     </html> |     </html> | ||||||
|   ); |   ); | ||||||
|  | |||||||
| @ -24,26 +24,22 @@ import { | |||||||
|   SelectValue, |   SelectValue, | ||||||
| } from "@/components/ui/select"; | } from "@/components/ui/select"; | ||||||
| import { cn } from "@/lib/utils"; | import { cn } from "@/lib/utils"; | ||||||
| 
 | import { useTranslations } from "next-intl"; | ||||||
| const formSchema = z.object({ | import { sendContactMail } from "@/lib/server-actions"; | ||||||
|   name: z.string().min(2).max(50), | import { contactFormSchema } from "@/lib/zod"; | ||||||
|   email: z.string().email(), |  | ||||||
|   budget: z.string(), |  | ||||||
|   message: z.string().max(500), |  | ||||||
| }); |  | ||||||
| 
 | 
 | ||||||
| function ContactForm() { | function ContactForm() { | ||||||
|   // 1. Define your form.
 |   // 1. Define your form.
 | ||||||
|   const form = useForm<z.infer<typeof formSchema>>({ |   const form = useForm<z.infer<typeof contactFormSchema>>({ | ||||||
|     resolver: zodResolver(formSchema), |     resolver: zodResolver(contactFormSchema), | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   // 2. Define a submit handler.
 |   // 2. Define a submit handler.
 | ||||||
|   function onSubmit(values: z.infer<typeof formSchema>) { |   async function onSubmit(values: z.infer<typeof contactFormSchema>) { | ||||||
|     // Do something with the form values.
 |     const result = await sendContactMail(values); | ||||||
|     // ✅ This will be type-safe and validated.
 |     console.log(result); | ||||||
|     console.log(values); |  | ||||||
|   } |   } | ||||||
|  |   const t = useTranslations(); | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|     <Form {...form}> |     <Form {...form}> | ||||||
| @ -54,9 +50,12 @@ function ContactForm() { | |||||||
|             name="name" |             name="name" | ||||||
|             render={({ field }) => ( |             render={({ field }) => ( | ||||||
|               <FormItem className="w-full "> |               <FormItem className="w-full "> | ||||||
|                 <FormLabel>Name</FormLabel> |                 <FormLabel>{t("contact.form.name.label")}</FormLabel> | ||||||
|                 <FormControl> |                 <FormControl> | ||||||
|                   <Input placeholder="John Doe" {...field} /> |                   <Input | ||||||
|  |                     placeholder={t("contact.form.name.placeholder")} | ||||||
|  |                     {...field} | ||||||
|  |                   /> | ||||||
|                 </FormControl> |                 </FormControl> | ||||||
|                 <FormMessage /> |                 <FormMessage /> | ||||||
|               </FormItem> |               </FormItem> | ||||||
| @ -67,9 +66,12 @@ function ContactForm() { | |||||||
|             name="email" |             name="email" | ||||||
|             render={({ field }) => ( |             render={({ field }) => ( | ||||||
|               <FormItem className="w-full"> |               <FormItem className="w-full"> | ||||||
|                 <FormLabel>Email</FormLabel> |                 <FormLabel>{t("contact.form.email.label")}</FormLabel> | ||||||
|                 <FormControl> |                 <FormControl> | ||||||
|                   <Input placeholder="email@example.com" {...field} /> |                   <Input | ||||||
|  |                     placeholder={t("contact.form.email.placeholder")} | ||||||
|  |                     {...field} | ||||||
|  |                   /> | ||||||
|                 </FormControl> |                 </FormControl> | ||||||
|                 <FormMessage /> |                 <FormMessage /> | ||||||
|               </FormItem> |               </FormItem> | ||||||
| @ -81,7 +83,7 @@ function ContactForm() { | |||||||
|           name="budget" |           name="budget" | ||||||
|           render={({ field }) => ( |           render={({ field }) => ( | ||||||
|             <FormItem> |             <FormItem> | ||||||
|               <FormLabel>Budget</FormLabel> |               <FormLabel>{t("contact.form.budget.label")}</FormLabel> | ||||||
|               <FormControl> |               <FormControl> | ||||||
|                 <Select |                 <Select | ||||||
|                   onValueChange={field.onChange} |                   onValueChange={field.onChange} | ||||||
| @ -95,12 +97,20 @@ function ContactForm() { | |||||||
|                         : "text-muted-foreground" |                         : "text-muted-foreground" | ||||||
|                     )} |                     )} | ||||||
|                   > |                   > | ||||||
|                     <SelectValue placeholder="Select your Budget" /> |                     <SelectValue | ||||||
|  |                       placeholder={t("contact.form.budget.placeholder")} | ||||||
|  |                     /> | ||||||
|                   </SelectTrigger> |                   </SelectTrigger> | ||||||
|                   <SelectContent> |                   <SelectContent> | ||||||
|                     <SelectItem value="< €2k">{"less then $2k"}</SelectItem> |                     <SelectItem value="< €2k"> | ||||||
|                     <SelectItem value="> €4k">{"more then $4k"}</SelectItem> |                       {t("contact.form.budget.options.1")} | ||||||
|                     <SelectItem value="> €6k">{"more then $6k"}</SelectItem> |                     </SelectItem> | ||||||
|  |                     <SelectItem value="> €4k"> | ||||||
|  |                       {t("contact.form.budget.options.2")} | ||||||
|  |                     </SelectItem> | ||||||
|  |                     <SelectItem value="> €6k"> | ||||||
|  |                       {t("contact.form.budget.options.3")} | ||||||
|  |                     </SelectItem> | ||||||
|                   </SelectContent> |                   </SelectContent> | ||||||
|                 </Select> |                 </Select> | ||||||
|               </FormControl> |               </FormControl> | ||||||
| @ -113,16 +123,20 @@ function ContactForm() { | |||||||
|           name="message" |           name="message" | ||||||
|           render={({ field }) => ( |           render={({ field }) => ( | ||||||
|             <FormItem> |             <FormItem> | ||||||
|               <FormLabel>Message</FormLabel> |               <FormLabel> {t("contact.form.message.label")}</FormLabel> | ||||||
|               <FormControl> |               <FormControl> | ||||||
|                 <Textarea rows={4} placeholder="Message..." {...field} /> |                 <Textarea | ||||||
|  |                   rows={4} | ||||||
|  |                   placeholder={t("contact.form.message.placeholder")} | ||||||
|  |                   {...field} | ||||||
|  |                 /> | ||||||
|               </FormControl> |               </FormControl> | ||||||
|               <FormMessage /> |               <FormMessage /> | ||||||
|             </FormItem> |             </FormItem> | ||||||
|           )} |           )} | ||||||
|         /> |         /> | ||||||
|         <Button type="submit" className="w-full"> |         <Button type="submit" className="w-full"> | ||||||
|           Submit |           {t("contact.form.submit")} | ||||||
|         </Button> |         </Button> | ||||||
|       </form> |       </form> | ||||||
|     </Form> |     </Form> | ||||||
|  | |||||||
| @ -1,32 +1,42 @@ | |||||||
| import Link from "next/link"; | import Link from "next/link"; | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import { Button } from "./ui/button"; | import { Button } from "./ui/button"; | ||||||
|  | import LocaleSwitcher from "./locale-switcher"; | ||||||
|  | import { getTranslations } from "next-intl/server"; | ||||||
|  | import { ModeToggle } from "./mode-toggle"; | ||||||
|  | 
 | ||||||
|  | export default async function Footer() { | ||||||
|  |   const t = await getTranslations("legal.footer"); | ||||||
| 
 | 
 | ||||||
| function Footer() { |  | ||||||
|   return ( |   return ( | ||||||
|     <footer className="container p-4 flex flex-col md:flex-row items-center justify-between"> |     <footer className="container p-4 gap-x-4 flex flex-col md:flex-row items-center "> | ||||||
|  |       <ModeToggle /> | ||||||
|       <span className="text-sm "> |       <span className="text-sm "> | ||||||
|         Copyright © {new Date().getFullYear()}. All rights reserved. |         {t("copyright", { | ||||||
|  |           year: new Date().getFullYear(), | ||||||
|  |         })} | ||||||
|       </span> |       </span> | ||||||
| 
 | 
 | ||||||
|       <menu className="flex items-center gap-4"> |       <menu className="flex items-center gap-4 mx-4"> | ||||||
|         <Button |         <Button | ||||||
|           asChild |           asChild | ||||||
|           variant={"link"} |           variant={"link"} | ||||||
|           className="text-muted-foreground text-xs hover:text-foreground px-0 w-max" |           className="text-muted-foreground text-xs hover:text-foreground px-0 w-max" | ||||||
|         > |         > | ||||||
|           <Link href={"/imprint"}>Imprint</Link> |           <Link href={t("imprint")}>{t("imprint")}</Link> | ||||||
|         </Button> |         </Button> | ||||||
|         <Button |         <Button | ||||||
|           asChild |           asChild | ||||||
|           variant={"link"} |           variant={"link"} | ||||||
|           className="text-muted-foreground text-xs hover:text-foreground px-0 w-max" |           className="text-muted-foreground text-xs hover:text-foreground px-0 w-max" | ||||||
|         > |         > | ||||||
|           <Link href={"/privacy"}>Privacy</Link> |           <Link href={t("privacy")}>{t("privacy")}</Link> | ||||||
|         </Button> |         </Button> | ||||||
|       </menu> |       </menu> | ||||||
|  | 
 | ||||||
|  |       <div className="order-last sm:order-none ml-auto flex items-center gap-4"> | ||||||
|  |         <LocaleSwitcher /> | ||||||
|  |       </div> | ||||||
|     </footer> |     </footer> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export default Footer; |  | ||||||
|  | |||||||
							
								
								
									
										54
									
								
								src/components/locale-switcher.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/components/locale-switcher.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | |||||||
|  | "use client"; | ||||||
|  | import React from "react"; | ||||||
|  | import { useLocale } from "next-intl"; | ||||||
|  | import { useRouter } from "next/navigation"; | ||||||
|  | import { cn } from "@/lib/utils"; | ||||||
|  | 
 | ||||||
|  | const LocaleButton = ({ | ||||||
|  |   locale, | ||||||
|  |   handleLocaleChange, | ||||||
|  |   active, | ||||||
|  | }: { | ||||||
|  |   locale: string; | ||||||
|  |   active?: boolean; | ||||||
|  |   handleLocaleChange: (value: string) => void; | ||||||
|  | }) => { | ||||||
|  |   return ( | ||||||
|  |     <button | ||||||
|  |       className={cn( | ||||||
|  |         "uppercase", | ||||||
|  |         active ? "text-foreground" : "text-muted-foreground" | ||||||
|  |       )} | ||||||
|  |       onClick={() => handleLocaleChange(locale)} | ||||||
|  |     > | ||||||
|  |       {locale} | ||||||
|  |     </button> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default function LocaleSwitcher() { | ||||||
|  |   const locale = useLocale(); | ||||||
|  | 
 | ||||||
|  |   const router = useRouter(); | ||||||
|  | 
 | ||||||
|  |   const handleLocaleChange = (value: string) => { | ||||||
|  |     const currentLocale = value; | ||||||
|  |     document.cookie = `locale=${currentLocale}; path=/`; | ||||||
|  |     router.refresh(); | ||||||
|  |   }; | ||||||
|  |   return ( | ||||||
|  |     <div> | ||||||
|  |       <LocaleButton | ||||||
|  |         locale="en" | ||||||
|  |         handleLocaleChange={handleLocaleChange} | ||||||
|  |         active={locale === "en"} | ||||||
|  |       /> | ||||||
|  |       <span className="text-muted-foreground">{" / "}</span> | ||||||
|  |       <LocaleButton | ||||||
|  |         locale="de" | ||||||
|  |         handleLocaleChange={handleLocaleChange} | ||||||
|  |         active={locale === "de"} | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @ -3,6 +3,9 @@ import Image from "next/image"; | |||||||
| import { cn } from "@/lib/utils"; | import { cn } from "@/lib/utils"; | ||||||
| import Link from "next/link"; | import Link from "next/link"; | ||||||
| import { socials } from "@/app.config"; | import { socials } from "@/app.config"; | ||||||
|  | import { getTranslations } from "next-intl/server"; | ||||||
|  | import { GlowEffect } from "./ui/glow-effect"; | ||||||
|  | import { Tilt } from "./ui/tilt"; | ||||||
| 
 | 
 | ||||||
| const icons = { | const icons = { | ||||||
|   github: (props: any) => ( |   github: (props: any) => ( | ||||||
| @ -33,14 +36,26 @@ const icons = { | |||||||
|   ), |   ), | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| function MeCard({ className }: { className?: string }) { | export default async function MeCard({ className }: { className?: string }) { | ||||||
|  |   const t = await getTranslations(); | ||||||
|   return ( |   return ( | ||||||
|  |     <Tilt | ||||||
|  |       rotationFactor={4} | ||||||
|  |       isRevese | ||||||
|  |       className={cn("w-full max-w-xs h-[28rem] ", className)} | ||||||
|  |     > | ||||||
|  |       <div className="relative  size-full"> | ||||||
|  |         {/* <GlowEffect | ||||||
|  |           colors={["#00C39D", "#F9F871", "#8DE281"]} | ||||||
|  |           mode="static" | ||||||
|  |           blur="medium" | ||||||
|  |         /> */} | ||||||
|  |         <div className="relative size-full rounded-2xl  p-2 "> | ||||||
|           <div |           <div | ||||||
|             className={cn( |             className={cn( | ||||||
|         `group overflow-hidden w-full max-w-xs h-[28rem] bg-muted 
 |               `group relative border overflow-hidden w-full bg-card h-full  
 | ||||||
|                       rounded-xl space-y-8 p-4 flex flex-col  |                       rounded-xl space-y-8 p-4 flex flex-col  | ||||||
|                       justify-between pb-20 relative`,
 |                       justify-between pb-20 ` | ||||||
|         className |  | ||||||
|             )} |             )} | ||||||
|           > |           > | ||||||
|             <div className="space-y-4"> |             <div className="space-y-4"> | ||||||
| @ -49,9 +64,11 @@ function MeCard({ className }: { className?: string }) { | |||||||
|               </div> |               </div> | ||||||
| 
 | 
 | ||||||
|               <div className="text-center flex flex-col items-center"> |               <div className="text-center flex flex-col items-center"> | ||||||
|           <h4 className="text-lg  font-bold translate-y-px  ">Pablo</h4> |                 <h4 className="text-lg  font-bold translate-y-px  "> | ||||||
|  |                   {t("global.me.name")} | ||||||
|  |                 </h4> | ||||||
|                 <span className="text-emerald-500 text-sm -translate-y-px"> |                 <span className="text-emerald-500 text-sm -translate-y-px"> | ||||||
|             Available for work |                   {t("global.me.work")} | ||||||
|                 </span> |                 </span> | ||||||
|                 {/* <Button className="mt-4" asChild> |                 {/* <Button className="mt-4" asChild> | ||||||
|             <Link href={button.link}>{button.label}</Link> |             <Link href={button.link}>{button.label}</Link> | ||||||
| @ -74,12 +91,13 @@ function MeCard({ className }: { className?: string }) { | |||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             <p className="text-center text-muted-foreground text-lg font-bold"> |             <p className="text-center text-muted-foreground text-lg font-bold"> | ||||||
|         Web Developer |               {t("global.me.role")} | ||||||
|             </p> |             </p> | ||||||
| 
 | 
 | ||||||
|       <div className="w-full h-40 halftone absolute left-0 group-hover:scale-[102.5%] transition-all duration-300 top-0 z-0" /> |             <div className="w-full h-40 halftone absolute left-0 top-0 z-0" /> | ||||||
|           </div> |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </Tilt> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export default MeCard; |  | ||||||
|  | |||||||
							
								
								
									
										40
									
								
								src/components/mode-toggle.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/components/mode-toggle.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | |||||||
|  | "use client"; | ||||||
|  | 
 | ||||||
|  | import * as React from "react"; | ||||||
|  | import { Moon, Sun } from "lucide-react"; | ||||||
|  | import { useTheme } from "next-themes"; | ||||||
|  | 
 | ||||||
|  | import { Button } from "@/components/ui/button"; | ||||||
|  | import { | ||||||
|  |   DropdownMenu, | ||||||
|  |   DropdownMenuContent, | ||||||
|  |   DropdownMenuItem, | ||||||
|  |   DropdownMenuTrigger, | ||||||
|  | } from "@/components/ui/dropdown-menu"; | ||||||
|  | 
 | ||||||
|  | export function ModeToggle() { | ||||||
|  |   const { setTheme } = useTheme(); | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <DropdownMenu> | ||||||
|  |       <DropdownMenuTrigger asChild> | ||||||
|  |         <Button variant="ghost" size="icon"> | ||||||
|  |           <Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" /> | ||||||
|  |           <Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" /> | ||||||
|  |           <span className="sr-only">Toggle theme</span> | ||||||
|  |         </Button> | ||||||
|  |       </DropdownMenuTrigger> | ||||||
|  |       <DropdownMenuContent align="end"> | ||||||
|  |         <DropdownMenuItem onClick={() => setTheme("light")}> | ||||||
|  |           Light | ||||||
|  |         </DropdownMenuItem> | ||||||
|  |         <DropdownMenuItem onClick={() => setTheme("dark")}> | ||||||
|  |           Dark | ||||||
|  |         </DropdownMenuItem> | ||||||
|  |         <DropdownMenuItem onClick={() => setTheme("system")}> | ||||||
|  |           System | ||||||
|  |         </DropdownMenuItem> | ||||||
|  |       </DropdownMenuContent> | ||||||
|  |     </DropdownMenu> | ||||||
|  |   ); | ||||||
|  | } | ||||||
| @ -11,53 +11,70 @@ import { | |||||||
|   BreadcrumbList, |   BreadcrumbList, | ||||||
|   BreadcrumbSeparator, |   BreadcrumbSeparator, | ||||||
| } from "@/components/ui/breadcrumb"; | } from "@/components/ui/breadcrumb"; | ||||||
| import { appNavigator } from "@/app.config"; |  | ||||||
| import { cn } from "@/lib/utils"; | import { cn } from "@/lib/utils"; | ||||||
|  | import LocaleSwitcher from "./locale-switcher"; | ||||||
|  | import { useTranslations } from "next-intl"; | ||||||
| 
 | 
 | ||||||
| function Navbar() { | export default function Navbar() { | ||||||
|   const pathname = usePathname(); |   const pathname = usePathname(); | ||||||
|   const isActive = (path: string) => pathname === path.replace(/\/#/g, ""); |   const isActive = (path: string) => pathname === path.replace(/\/#/g, ""); | ||||||
|  |   const t = useTranslations(); | ||||||
|  |   const navigation = [ | ||||||
|  |     { | ||||||
|  |       label: t("global.navbar.home"), | ||||||
|  |       path: "/", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: t("global.navbar.projects"), | ||||||
|  |       path: "/projects", | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: t("global.navbar.work"), | ||||||
|  |       path: "/contact", | ||||||
|  |       isButton: true, | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|   return ( |   return ( | ||||||
|     <div className="w-full fixed top-0 right-0 left-0 z-50"> |     <div className="w-full fixed top-0 right-0 left-0 z-50"> | ||||||
|       <nav className="container p-4 flex justify-between items-center navbar"> |       <nav className="container p-4 flex items-center navbar"> | ||||||
|         <div className="flex items-center gap-4"> |         <div className="items-center gap-4 hidden sm:flex"> | ||||||
|           <Link |           <LocaleSwitcher /> | ||||||
|             className="text-sm hidden md:block" |  | ||||||
|             href={`mailto:${process.env.NEXT_PUBLIC_EMAIL}`} |  | ||||||
|           > |  | ||||||
|             {process.env.NEXT_PUBLIC_EMAIL} |  | ||||||
|           </Link> |  | ||||||
|           {!isActive("/contact") && ( |  | ||||||
|             <Button asChild size={"sm"} className="hidden md:flex"> |  | ||||||
|               <Link href={"/contact"}>Work with me</Link> |  | ||||||
|             </Button> |  | ||||||
|           )} |  | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <Breadcrumb> |         <Breadcrumb className="ml-auto"> | ||||||
|           <BreadcrumbList> |           <BreadcrumbList> | ||||||
|             {appNavigator.map(({ label, path }, idx) => ( |             {navigation.map(({ label, path, isButton }, idx) => { | ||||||
|  |               return ( | ||||||
|                 <div key={path} className="flex items-center gap-2"> |                 <div key={path} className="flex items-center gap-2"> | ||||||
|                   <BreadcrumbItem> |                   <BreadcrumbItem> | ||||||
|  |                     {isButton ? ( | ||||||
|  |                       <Button | ||||||
|  |                         asChild | ||||||
|  |                         size={"sm"} | ||||||
|  |                         variant={isActive(path) ? "outline" : "default"} | ||||||
|  |                       > | ||||||
|  |                         <Link href={path}>{label}</Link> | ||||||
|  |                       </Button> | ||||||
|  |                     ) : ( | ||||||
|                       <BreadcrumbLink |                       <BreadcrumbLink | ||||||
|                         className={cn(isActive(path) && "text-foreground")} |                         className={cn(isActive(path) && "text-foreground")} | ||||||
|                         href={path} |                         href={path} | ||||||
|                       > |                       > | ||||||
|                         {label} |                         {label} | ||||||
|                       </BreadcrumbLink> |                       </BreadcrumbLink> | ||||||
|  |                     )} | ||||||
|                   </BreadcrumbItem> |                   </BreadcrumbItem> | ||||||
|                 {idx + 1 < appNavigator.length && ( |                   {idx + 1 < navigation.length && ( | ||||||
|                     <BreadcrumbSeparator> |                     <BreadcrumbSeparator> | ||||||
|                       <span>/</span> |                       <span>/</span> | ||||||
|                     </BreadcrumbSeparator> |                     </BreadcrumbSeparator> | ||||||
|                   )} |                   )} | ||||||
|                 </div> |                 </div> | ||||||
|             ))} |               ); | ||||||
|  |             })} | ||||||
|           </BreadcrumbList> |           </BreadcrumbList> | ||||||
|         </Breadcrumb> |         </Breadcrumb> | ||||||
|       </nav> |       </nav> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export default Navbar; |  | ||||||
|  | |||||||
| @ -2,19 +2,23 @@ import React from "react"; | |||||||
| import MeCard from "../me-card"; | import MeCard from "../me-card"; | ||||||
| import Heading from "../heading"; | import Heading from "../heading"; | ||||||
| import ContactForm from "../contact-form"; | import ContactForm from "../contact-form"; | ||||||
|  | import { getTranslations } from "next-intl/server"; | ||||||
| 
 | 
 | ||||||
| function Contact({ withoutMeCard }: { withoutMeCard?: boolean }) { | export default async function Contact({ | ||||||
|  |   withoutMeCard, | ||||||
|  | }: { | ||||||
|  |   withoutMeCard?: boolean; | ||||||
|  | }) { | ||||||
|  |   const t = await getTranslations(); | ||||||
|   return ( |   return ( | ||||||
|     <div className="flex flex-col lg:flex-row gap-12 lg:gap-20 container "> |     <div className="flex flex-col lg:flex-row gap-12 lg:gap-20 container "> | ||||||
|       {!withoutMeCard && ( |       {!withoutMeCard && ( | ||||||
|         <MeCard className="mx-auto order-last lg:order-first lg:sticky lg:top-20" /> |         <MeCard className="mx-auto order-last lg:order-first lg:sticky lg:top-20" /> | ||||||
|       )} |       )} | ||||||
|       <div className="w-full space-y-4 p-4"> |       <div className="w-full space-y-4 p-4"> | ||||||
|         <Heading subTitle="Let's Work" title="Together" /> |         <Heading subTitle={t("contact.subTitle")} title={t("contact.title")} /> | ||||||
|         <ContactForm /> |         <ContactForm /> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export default Contact; |  | ||||||
|  | |||||||
| @ -1,59 +1,55 @@ | |||||||
| "use client"; |  | ||||||
| 
 |  | ||||||
| import { cn } from "@/lib/utils"; |  | ||||||
| import Image from "next/image"; |  | ||||||
| import React from "react"; | import React from "react"; | ||||||
|  | import { LinkPreview } from "../ui/link-preview"; | ||||||
|  | import { getTranslations } from "next-intl/server"; | ||||||
|  | import { cn } from "@/lib/utils"; | ||||||
| 
 | 
 | ||||||
| function Hero() { | export default async function Hero() { | ||||||
|   const [hovered, setHovered] = React.useState(""); |   const t = await getTranslations(); | ||||||
| 
 |   const items = [ | ||||||
|   const textHover = |     { | ||||||
|     "hover:text-primary hover:scale-105 transition-all duration-300"; |       label: t("home.hero.slot1.label"), | ||||||
|  |       link: t("home.hero.slot1.link"), | ||||||
|  |       image: t("home.hero.slot1.image"), | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: t("home.hero.slot2.label"), | ||||||
|  |       link: t("home.hero.slot2.link"), | ||||||
|  |       image: t("home.hero.slot2.image"), | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       label: t("home.hero.slot3.label"), | ||||||
|  |       link: t("home.hero.slot3.link"), | ||||||
|  |       image: t("home.hero.slot3.image"), | ||||||
|  |     }, | ||||||
|  |   ]; | ||||||
|   return ( |   return ( | ||||||
|     <> |     <div className="text-5xl uppercase sm:text-6xl md:text-7xl lg:text-8xl lg:max-w-2xl flex flex-col w-full sm:max-w-sm max-w-xs md:max-w-lg  mx-auto font-black p-4 lg:p-0 "> | ||||||
|       <div className="text-5xl lg:text-7xl rounded-md group font-bold max-w-xs lg:max-w-md mx-auto text-right  flex flex-col relative cursor-default  "> |       <span className="hover:scale-105 transition-transform duration-150 ease-in-out cursor-default w-max"> | ||||||
|         <span |         {items[0].label} | ||||||
|           className={cn( |  | ||||||
|             `absolute left-4 lg:left-0 bottom-full z-10 ${textHover}`, |  | ||||||
|             hovered === "text-2" && "text-background blur-sm" |  | ||||||
|           )} |  | ||||||
|           onMouseEnter={() => setHovered("text-1")} |  | ||||||
|           onMouseLeave={() => setHovered("")} |  | ||||||
|         > |  | ||||||
|           Timeless |  | ||||||
|       </span> |       </span> | ||||||
|         <div className="flex gap-4 justify-end items-center pr-4 lg:pr-0"> |       <span className="hover:scale-105 transition-transform duration-150 ease-in-out cursor-default w-max ml-auto"> | ||||||
|           <div |         {items[1].label} | ||||||
|             className={cn( |  | ||||||
|               `size-12 rounded-full bg-background origin-right
 |  | ||||||
|               relative overflow-hidden transition-all duration-300 opacity-0 `,
 |  | ||||||
|               hovered === "text-2" && "scale-[300%] bg-primary opacity-100" |  | ||||||
|             )} |  | ||||||
|           > |  | ||||||
|             <Image src={"/hero.gif"} alt="herp-gif" fill /> |  | ||||||
|           </div> |  | ||||||
|           <span |  | ||||||
|             className={textHover} |  | ||||||
|             onMouseEnter={() => setHovered("text-2")} |  | ||||||
|             onMouseLeave={() => setHovered("")} |  | ||||||
|           > |  | ||||||
|             Creative |  | ||||||
|       </span> |       </span> | ||||||
|         </div> | 
 | ||||||
|         <span |       <span className="hover:scale-105 text-primary transition-transform duration-150 ease-in-out cursor-default w-max"> | ||||||
|           onMouseEnter={() => setHovered("text-3")} |         {items[2].label} | ||||||
|           onMouseLeave={() => setHovered("")} |  | ||||||
|           className={cn( |  | ||||||
|             `absolute left-4 lg:left-0 top-full ${textHover}`, |  | ||||||
|             hovered.length ? "text-foreground" : "text-primary", |  | ||||||
|             hovered === "text-2" && "text-background blur-sm" |  | ||||||
|           )} |  | ||||||
|         > |  | ||||||
|           Unique |  | ||||||
|       </span> |       </span> | ||||||
|  |       {/* <LinkPreview | ||||||
|  |         newTab={true} | ||||||
|  |         url={items[2].link} | ||||||
|  |         imageSrc={items[2].image} | ||||||
|  |         className={cn(" text-primary")} | ||||||
|  |         classNames={{ | ||||||
|  |           imageWrapper: cn("rounded-full bg-primary border-primary"), | ||||||
|  |           image: "rounded-full", | ||||||
|  |         }} | ||||||
|  |         side="bottom" | ||||||
|  |         isStatic | ||||||
|  |       > | ||||||
|  |         {items[2].label} | ||||||
|  |       </LinkPreview> */} | ||||||
|     </div> |     </div> | ||||||
|     </> |  | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export default Hero; | // export default Hero;
 | ||||||
|  | |||||||
| @ -1,25 +1,21 @@ | |||||||
| import { Project, projects } from "@/constants"; |  | ||||||
| import { ArrowRight } from "lucide-react"; | import { ArrowRight } from "lucide-react"; | ||||||
| import Image from "next/image"; | import Image from "next/image"; | ||||||
| import Link from "next/link"; | import Link from "next/link"; | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import MeCard from "../me-card"; | import MeCard from "../me-card"; | ||||||
| import Heading from "../heading"; | import Heading from "../heading"; | ||||||
|  | import { getTranslations } from "next-intl/server"; | ||||||
|  | import { GlowEffect } from "../ui/glow-effect"; | ||||||
| 
 | 
 | ||||||
| const ProjectCard = (p: Project) => ( | const ProjectCard = (p: any) => ( | ||||||
|   <Link href={p.link} target="_blank"> |   <Link href={p.link} target="_blank"> | ||||||
|     <div className="group max-w-xs lg:max-w-none mx-auto md:mx-0 flex flex-col lg:flex-row items-center gap-4 cursor-pointer hover:bg-muted w-full rounded-xl relative"> |     <div className="group max-w-xs pl-4 pt-4 lg:max-w-none mx-auto md:mx-0 flex flex-col lg:flex-row  gap-4 cursor-pointer hover:bg-accent w-full rounded-xl relative"> | ||||||
|       <div className="h-40 w-full lg:max-w-32 relative rounded-xl overflow-hidden"> |       <div className="h-40 w-full lg:max-w-32 relative rounded-xl overflow-hidden"> | ||||||
|         <Image |         <Image src={p.image} alt="project-image" fill className="object-fill" /> | ||||||
|           src={p.image} |  | ||||||
|           alt="project-image" |  | ||||||
|           fill |  | ||||||
|           className="object-cover" |  | ||||||
|         /> |  | ||||||
|       </div> |       </div> | ||||||
|       <div className="space-y-1 w-full p-4"> |       <div className="space-y-1 w-full p-4"> | ||||||
|         <span className="text-xs text-muted-foreground ">{`[ ${p.year} ]`}</span> |         <span className="text-xs text-muted-foreground ">{`[ ${p.year} ]`}</span> | ||||||
|         <h3 className="text-2xl md:text-3xl font-black">{p.name}</h3> |         <h3 className="text-2xl md:text-3xl font-black">{p.title}</h3> | ||||||
|         <p className="text-muted-foreground text-sm md:text-lg"> |         <p className="text-muted-foreground text-sm md:text-lg"> | ||||||
|           {p.description} |           {p.description} | ||||||
|         </p> |         </p> | ||||||
| @ -30,9 +26,18 @@ const ProjectCard = (p: Project) => ( | |||||||
|   </Link> |   </Link> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| function Projects() { | export default async function Projects() { | ||||||
|  |   const t = await getTranslations(); | ||||||
|  |   const projects = Array.from({ length: 2 }).map((_, idx) => ({ | ||||||
|  |     title: t(`projects.${idx}.title` as any), | ||||||
|  |     description: t(`projects.${idx}.description` as any), | ||||||
|  |     year: t(`projects.${idx}.year` as any), | ||||||
|  |     image: t(`projects.${idx}.image` as any), | ||||||
|  |     link: t(`projects.${idx}.link.live` as any), | ||||||
|  |   })); | ||||||
|   return ( |   return ( | ||||||
|     <div className="flex flex-col md:flex-row gap-8 container lg:px-20"> |     <> | ||||||
|  |       <div className="flex flex-col md:flex-row gap-8 container "> | ||||||
|         <MeCard className="mx-auto order-last md:order-first md:sticky md:top-20" /> |         <MeCard className="mx-auto order-last md:order-first md:sticky md:top-20" /> | ||||||
|         <div className="space-y-8  w-full p-4"> |         <div className="space-y-8  w-full p-4"> | ||||||
|           <Heading |           <Heading | ||||||
| @ -41,15 +46,14 @@ function Projects() { | |||||||
|             // className="sticky top-0 z-50 pb-6"
 |             // className="sticky top-0 z-50 pb-6"
 | ||||||
|           /> |           /> | ||||||
|           <menu className=" flex flex-col items-center gap-8 lg:pb-20 w-full"> |           <menu className=" flex flex-col items-center gap-8 lg:pb-20 w-full"> | ||||||
|           {projects.map((p) => ( |             {projects.map((p, idx) => ( | ||||||
|             <li key={p.link} className="w-full"> |               <li key={idx} className="w-full"> | ||||||
|                 <ProjectCard {...p} /> |                 <ProjectCard {...p} /> | ||||||
|               </li> |               </li> | ||||||
|             ))} |             ))} | ||||||
|           </menu> |           </menu> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  |     </> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export default Projects; |  | ||||||
|  | |||||||
| @ -1,8 +1,29 @@ | |||||||
|  | import { getTranslations } from "next-intl/server"; | ||||||
| import React from "react"; | import React from "react"; | ||||||
| 
 | 
 | ||||||
| export type TechIcon = "ts" | "next" | "react" | "tailwind" | "PostgreSQL"; | export type TechIcon = "ts" | "next" | "react" | "tailwind" | "PostgreSQL"; | ||||||
| 
 | 
 | ||||||
| const tech: { label: string; Logo: (props: any) => JSX.Element }[] = [ | const tech: { label: string; Logo: (props: any) => JSX.Element }[] = [ | ||||||
|  |   { | ||||||
|  |     label: "Webflow", | ||||||
|  |     Logo: (props: any) => ( | ||||||
|  |       <svg | ||||||
|  |         xmlns="http://www.w3.org/2000/svg" | ||||||
|  |         width="1080" | ||||||
|  |         height="674" | ||||||
|  |         viewBox="0 0 1080 674" | ||||||
|  |         fill="none" | ||||||
|  |         {...props} | ||||||
|  |       > | ||||||
|  |         <path | ||||||
|  |           fillRule="evenodd" | ||||||
|  |           clipRule="evenodd" | ||||||
|  |           d="M1080 0L735.386 673.684H411.695L555.916 394.481H549.445C430.464 548.934 252.942 650.61 -0.000488281 673.684V398.344C-0.000488281 398.344 161.813 388.787 256.938 288.776H-0.000488281V0.0053214H288.771V237.515L295.252 237.489L413.254 0.0053214H631.644V236.009L638.126 235.999L760.555 0H1080Z" | ||||||
|  |           fill="#146EF5" | ||||||
|  |         /> | ||||||
|  |       </svg> | ||||||
|  |     ), | ||||||
|  |   }, | ||||||
|   { |   { | ||||||
|     label: "Tailwind CSS", |     label: "Tailwind CSS", | ||||||
|     Logo: (props: any) => ( |     Logo: (props: any) => ( | ||||||
| @ -211,18 +232,20 @@ const tech: { label: string; Logo: (props: any) => JSX.Element }[] = [ | |||||||
|   }, |   }, | ||||||
| ]; | ]; | ||||||
| 
 | 
 | ||||||
| function Tech() { | export default async function Tech() { | ||||||
|  |   const t = await getTranslations(); | ||||||
|   return ( |   return ( | ||||||
|     <div className="py-12 lg:py-20 container relative space-y-8"> |     <div className="py-12 lg:py-20 container relative space-y-8"> | ||||||
|       <h3 className="text-sm font-mono text-muted-foreground text-center"> |       <h3 className="text-sm font-mono text-muted-foreground text-center"> | ||||||
|         most liked{" "} |         {t("home.tech.text")}{" "} | ||||||
|         <span className="underline text-foreground">technologies</span> and{" "} |         <span className="text-foreground">{t("home.tech.technologies")}</span>{" "} | ||||||
|         <span className="underline text-foreground">tools</span> |         {t("global.and")}{" "} | ||||||
|  |         <span className="text-foreground">{t("home.tech.tools")}</span> | ||||||
|       </h3> |       </h3> | ||||||
|       <ul className="flex w-full items-center justify-center gap-2 md:gap-4 lg:gap-8 flex-wrap"> |       <ul className="flex w-full items-center justify-center gap-2 md:gap-4 lg:gap-8 flex-wrap"> | ||||||
|         {tech.map(({ Logo, label }, idx) => ( |         {tech.map(({ Logo, label }, idx) => ( | ||||||
|           <li |           <li | ||||||
|             className="flex items-center gap-2 py-2 px-6 rounded-md bg-muted" |             className="flex items-center gap-2 py-2 px-6 rounded-md bg-card" | ||||||
|             key={idx} |             key={idx} | ||||||
|           > |           > | ||||||
|             <Logo className="size-6" /> |             <Logo className="size-6" /> | ||||||
| @ -233,5 +256,3 @@ function Tech() { | |||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| 
 |  | ||||||
| export default Tech; |  | ||||||
|  | |||||||
							
								
								
									
										11
									
								
								src/components/theme-provider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/components/theme-provider.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | |||||||
|  | "use client"; | ||||||
|  | 
 | ||||||
|  | import * as React from "react"; | ||||||
|  | import { ThemeProvider as NextThemesProvider } from "next-themes"; | ||||||
|  | 
 | ||||||
|  | export function ThemeProvider({ | ||||||
|  |   children, | ||||||
|  |   ...props | ||||||
|  | }: React.ComponentProps<typeof NextThemesProvider>) { | ||||||
|  |   return <NextThemesProvider {...props}>{children}</NextThemesProvider>; | ||||||
|  | } | ||||||
							
								
								
									
										201
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								src/components/ui/dropdown-menu.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,201 @@ | |||||||
|  | "use client" | ||||||
|  | 
 | ||||||
|  | import * as React from "react" | ||||||
|  | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" | ||||||
|  | import { Check, ChevronRight, Circle } from "lucide-react" | ||||||
|  | 
 | ||||||
|  | import { cn } from "@/lib/utils" | ||||||
|  | 
 | ||||||
|  | const DropdownMenu = DropdownMenuPrimitive.Root | ||||||
|  | 
 | ||||||
|  | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger | ||||||
|  | 
 | ||||||
|  | const DropdownMenuGroup = DropdownMenuPrimitive.Group | ||||||
|  | 
 | ||||||
|  | const DropdownMenuPortal = DropdownMenuPrimitive.Portal | ||||||
|  | 
 | ||||||
|  | const DropdownMenuSub = DropdownMenuPrimitive.Sub | ||||||
|  | 
 | ||||||
|  | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup | ||||||
|  | 
 | ||||||
|  | const DropdownMenuSubTrigger = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { | ||||||
|  |     inset?: boolean | ||||||
|  |   } | ||||||
|  | >(({ className, inset, children, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.SubTrigger | ||||||
|  |     ref={ref} | ||||||
|  |     className={cn( | ||||||
|  |       "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", | ||||||
|  |       inset && "pl-8", | ||||||
|  |       className | ||||||
|  |     )} | ||||||
|  |     {...props} | ||||||
|  |   > | ||||||
|  |     {children} | ||||||
|  |     <ChevronRight className="ml-auto" /> | ||||||
|  |   </DropdownMenuPrimitive.SubTrigger> | ||||||
|  | )) | ||||||
|  | DropdownMenuSubTrigger.displayName = | ||||||
|  |   DropdownMenuPrimitive.SubTrigger.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuSubContent = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.SubContent>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> | ||||||
|  | >(({ className, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.SubContent | ||||||
|  |     ref={ref} | ||||||
|  |     className={cn( | ||||||
|  |       "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", | ||||||
|  |       className | ||||||
|  |     )} | ||||||
|  |     {...props} | ||||||
|  |   /> | ||||||
|  | )) | ||||||
|  | DropdownMenuSubContent.displayName = | ||||||
|  |   DropdownMenuPrimitive.SubContent.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuContent = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.Content>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> | ||||||
|  | >(({ className, sideOffset = 4, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.Portal> | ||||||
|  |     <DropdownMenuPrimitive.Content | ||||||
|  |       ref={ref} | ||||||
|  |       sideOffset={sideOffset} | ||||||
|  |       className={cn( | ||||||
|  |         "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", | ||||||
|  |         "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]", | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   </DropdownMenuPrimitive.Portal> | ||||||
|  | )) | ||||||
|  | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuItem = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.Item>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { | ||||||
|  |     inset?: boolean | ||||||
|  |   } | ||||||
|  | >(({ className, inset, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.Item | ||||||
|  |     ref={ref} | ||||||
|  |     className={cn( | ||||||
|  |       "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", | ||||||
|  |       inset && "pl-8", | ||||||
|  |       className | ||||||
|  |     )} | ||||||
|  |     {...props} | ||||||
|  |   /> | ||||||
|  | )) | ||||||
|  | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuCheckboxItem = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem> | ||||||
|  | >(({ className, children, checked, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.CheckboxItem | ||||||
|  |     ref={ref} | ||||||
|  |     className={cn( | ||||||
|  |       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||||
|  |       className | ||||||
|  |     )} | ||||||
|  |     checked={checked} | ||||||
|  |     {...props} | ||||||
|  |   > | ||||||
|  |     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||||
|  |       <DropdownMenuPrimitive.ItemIndicator> | ||||||
|  |         <Check className="h-4 w-4" /> | ||||||
|  |       </DropdownMenuPrimitive.ItemIndicator> | ||||||
|  |     </span> | ||||||
|  |     {children} | ||||||
|  |   </DropdownMenuPrimitive.CheckboxItem> | ||||||
|  | )) | ||||||
|  | DropdownMenuCheckboxItem.displayName = | ||||||
|  |   DropdownMenuPrimitive.CheckboxItem.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuRadioItem = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem> | ||||||
|  | >(({ className, children, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.RadioItem | ||||||
|  |     ref={ref} | ||||||
|  |     className={cn( | ||||||
|  |       "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | ||||||
|  |       className | ||||||
|  |     )} | ||||||
|  |     {...props} | ||||||
|  |   > | ||||||
|  |     <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> | ||||||
|  |       <DropdownMenuPrimitive.ItemIndicator> | ||||||
|  |         <Circle className="h-2 w-2 fill-current" /> | ||||||
|  |       </DropdownMenuPrimitive.ItemIndicator> | ||||||
|  |     </span> | ||||||
|  |     {children} | ||||||
|  |   </DropdownMenuPrimitive.RadioItem> | ||||||
|  | )) | ||||||
|  | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuLabel = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.Label>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { | ||||||
|  |     inset?: boolean | ||||||
|  |   } | ||||||
|  | >(({ className, inset, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.Label | ||||||
|  |     ref={ref} | ||||||
|  |     className={cn( | ||||||
|  |       "px-2 py-1.5 text-sm font-semibold", | ||||||
|  |       inset && "pl-8", | ||||||
|  |       className | ||||||
|  |     )} | ||||||
|  |     {...props} | ||||||
|  |   /> | ||||||
|  | )) | ||||||
|  | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuSeparator = React.forwardRef< | ||||||
|  |   React.ElementRef<typeof DropdownMenuPrimitive.Separator>, | ||||||
|  |   React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> | ||||||
|  | >(({ className, ...props }, ref) => ( | ||||||
|  |   <DropdownMenuPrimitive.Separator | ||||||
|  |     ref={ref} | ||||||
|  |     className={cn("-mx-1 my-1 h-px bg-muted", className)} | ||||||
|  |     {...props} | ||||||
|  |   /> | ||||||
|  | )) | ||||||
|  | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName | ||||||
|  | 
 | ||||||
|  | const DropdownMenuShortcut = ({ | ||||||
|  |   className, | ||||||
|  |   ...props | ||||||
|  | }: React.HTMLAttributes<HTMLSpanElement>) => { | ||||||
|  |   return ( | ||||||
|  |     <span | ||||||
|  |       className={cn("ml-auto text-xs tracking-widest opacity-60", className)} | ||||||
|  |       {...props} | ||||||
|  |     /> | ||||||
|  |   ) | ||||||
|  | } | ||||||
|  | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" | ||||||
|  | 
 | ||||||
|  | export { | ||||||
|  |   DropdownMenu, | ||||||
|  |   DropdownMenuTrigger, | ||||||
|  |   DropdownMenuContent, | ||||||
|  |   DropdownMenuItem, | ||||||
|  |   DropdownMenuCheckboxItem, | ||||||
|  |   DropdownMenuRadioItem, | ||||||
|  |   DropdownMenuLabel, | ||||||
|  |   DropdownMenuSeparator, | ||||||
|  |   DropdownMenuShortcut, | ||||||
|  |   DropdownMenuGroup, | ||||||
|  |   DropdownMenuPortal, | ||||||
|  |   DropdownMenuSub, | ||||||
|  |   DropdownMenuSubContent, | ||||||
|  |   DropdownMenuSubTrigger, | ||||||
|  |   DropdownMenuRadioGroup, | ||||||
|  | } | ||||||
							
								
								
									
										151
									
								
								src/components/ui/glow-effect.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								src/components/ui/glow-effect.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,151 @@ | |||||||
|  | "use client"; | ||||||
|  | import { cn } from "@/lib/utils"; | ||||||
|  | import { motion, Transition } from "motion/react"; | ||||||
|  | 
 | ||||||
|  | export type GlowEffectProps = { | ||||||
|  |   className?: string; | ||||||
|  |   style?: React.CSSProperties; | ||||||
|  |   colors?: string[]; | ||||||
|  |   mode?: | ||||||
|  |     | "rotate" | ||||||
|  |     | "pulse" | ||||||
|  |     | "breathe" | ||||||
|  |     | "colorShift" | ||||||
|  |     | "flowHorizontal" | ||||||
|  |     | "static"; | ||||||
|  |   blur?: | ||||||
|  |     | number | ||||||
|  |     | "softest" | ||||||
|  |     | "soft" | ||||||
|  |     | "medium" | ||||||
|  |     | "strong" | ||||||
|  |     | "stronger" | ||||||
|  |     | "strongest" | ||||||
|  |     | "none"; | ||||||
|  |   transition?: Transition; | ||||||
|  |   scale?: number; | ||||||
|  |   duration?: number; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export function GlowEffect({ | ||||||
|  |   className, | ||||||
|  |   style, | ||||||
|  |   colors = ["#FF5733", "#33FF57", "#3357FF", "#F1C40F"], | ||||||
|  |   mode = "rotate", | ||||||
|  |   blur = "medium", | ||||||
|  |   transition, | ||||||
|  |   scale = 1, | ||||||
|  |   duration = 5, | ||||||
|  | }: GlowEffectProps) { | ||||||
|  |   const BASE_TRANSITION = { | ||||||
|  |     repeat: Infinity, | ||||||
|  |     duration: duration, | ||||||
|  |     ease: "linear", | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const animations = { | ||||||
|  |     rotate: { | ||||||
|  |       background: [ | ||||||
|  |         `conic-gradient(from 0deg at 50% 50%, ${colors.join(", ")})`, | ||||||
|  |         `conic-gradient(from 360deg at 50% 50%, ${colors.join(", ")})`, | ||||||
|  |       ], | ||||||
|  |       transition: { | ||||||
|  |         ...(transition ?? BASE_TRANSITION), | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     pulse: { | ||||||
|  |       background: colors.map( | ||||||
|  |         (color) => | ||||||
|  |           `radial-gradient(circle at 50% 50%, ${color} 0%, transparent 100%)` | ||||||
|  |       ), | ||||||
|  |       scale: [1 * scale, 1.1 * scale, 1 * scale], | ||||||
|  |       opacity: [0.5, 0.8, 0.5], | ||||||
|  |       transition: { | ||||||
|  |         ...(transition ?? { | ||||||
|  |           ...BASE_TRANSITION, | ||||||
|  |           repeatType: "mirror", | ||||||
|  |         }), | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     breathe: { | ||||||
|  |       background: [ | ||||||
|  |         ...colors.map( | ||||||
|  |           (color) => | ||||||
|  |             `radial-gradient(circle at 50% 50%, ${color} 0%, transparent 100%)` | ||||||
|  |         ), | ||||||
|  |       ], | ||||||
|  |       scale: [1 * scale, 1.05 * scale, 1 * scale], | ||||||
|  |       transition: { | ||||||
|  |         ...(transition ?? { | ||||||
|  |           ...BASE_TRANSITION, | ||||||
|  |           repeatType: "mirror", | ||||||
|  |         }), | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     colorShift: { | ||||||
|  |       background: colors.map((color, index) => { | ||||||
|  |         const nextColor = colors[(index + 1) % colors.length]; | ||||||
|  |         return `conic-gradient(from 0deg at 50% 50%, ${color} 0%, ${nextColor} 50%, ${color} 100%)`; | ||||||
|  |       }), | ||||||
|  |       transition: { | ||||||
|  |         ...(transition ?? { | ||||||
|  |           ...BASE_TRANSITION, | ||||||
|  |           repeatType: "mirror", | ||||||
|  |         }), | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     flowHorizontal: { | ||||||
|  |       background: colors.map((color) => { | ||||||
|  |         const nextColor = colors[(colors.indexOf(color) + 1) % colors.length]; | ||||||
|  |         return `linear-gradient(to right, ${color}, ${nextColor})`; | ||||||
|  |       }), | ||||||
|  |       transition: { | ||||||
|  |         ...(transition ?? { | ||||||
|  |           ...BASE_TRANSITION, | ||||||
|  |           repeatType: "mirror", | ||||||
|  |         }), | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     static: { | ||||||
|  |       background: `linear-gradient(to right, ${colors.join(", ")})`, | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const getBlurClass = (blur: GlowEffectProps["blur"]) => { | ||||||
|  |     if (typeof blur === "number") { | ||||||
|  |       return `blur-[${blur}px]`; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     const presets = { | ||||||
|  |       softest: "blur-xs", | ||||||
|  |       soft: "blur-sm", | ||||||
|  |       medium: "blur-md", | ||||||
|  |       strong: "blur-lg", | ||||||
|  |       stronger: "blur-xl", | ||||||
|  |       strongest: "blur-xl", | ||||||
|  |       none: "blur-none", | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|  |     return presets[blur as keyof typeof presets]; | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <motion.div | ||||||
|  |       style={ | ||||||
|  |         { | ||||||
|  |           ...style, | ||||||
|  |           "--scale": scale, | ||||||
|  |           willChange: "transform", | ||||||
|  |           backfaceVisibility: "hidden", | ||||||
|  |         } as React.CSSProperties | ||||||
|  |       } | ||||||
|  |       animate={animations[mode] as any} | ||||||
|  |       className={cn( | ||||||
|  |         "pointer-events-none absolute inset-0 h-full w-full", | ||||||
|  |         "scale-[var(--scale)] transform-gpu", | ||||||
|  |         getBlurClass(blur), | ||||||
|  |         className | ||||||
|  |       )} | ||||||
|  |     /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										160
									
								
								src/components/ui/link-preview.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										160
									
								
								src/components/ui/link-preview.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,160 @@ | |||||||
|  | "use client"; | ||||||
|  | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"; | ||||||
|  | 
 | ||||||
|  | import { encode } from "qss"; | ||||||
|  | import React from "react"; | ||||||
|  | import { | ||||||
|  |   AnimatePresence, | ||||||
|  |   motion, | ||||||
|  |   useMotionValue, | ||||||
|  |   useSpring, | ||||||
|  | } from "motion/react"; | ||||||
|  | 
 | ||||||
|  | import { cn } from "@/lib/utils"; | ||||||
|  | 
 | ||||||
|  | type LinkPreviewProps = { | ||||||
|  |   children: React.ReactNode; | ||||||
|  |   url: string; | ||||||
|  |   className?: string; | ||||||
|  |   width?: number; | ||||||
|  |   height?: number; | ||||||
|  |   quality?: number; | ||||||
|  |   layout?: string; | ||||||
|  |   newTab?: boolean; | ||||||
|  |   classNames?: { | ||||||
|  |     image?: string; | ||||||
|  |     imageWrapper?: string; | ||||||
|  |   }; | ||||||
|  |   side?: "top" | "bottom" | "left" | "right"; | ||||||
|  | } & ( | ||||||
|  |   | { isStatic: true; imageSrc: string } | ||||||
|  |   | { isStatic?: false; imageSrc?: never } | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export const LinkPreview = ({ | ||||||
|  |   children, | ||||||
|  |   url, | ||||||
|  |   className, | ||||||
|  |   width = 200, | ||||||
|  |   height = 125, | ||||||
|  |   quality = 50, | ||||||
|  |   layout = "fixed", | ||||||
|  |   isStatic = false, | ||||||
|  |   imageSrc = "", | ||||||
|  |   side = "top", | ||||||
|  |   newTab = false, | ||||||
|  |   classNames, | ||||||
|  | }: LinkPreviewProps) => { | ||||||
|  |   let src; | ||||||
|  |   if (!isStatic) { | ||||||
|  |     const params = encode({ | ||||||
|  |       url, | ||||||
|  |       screenshot: true, | ||||||
|  |       meta: false, | ||||||
|  |       embed: "screenshot.url", | ||||||
|  |       colorScheme: "dark", | ||||||
|  |       "viewport.isMobile": true, | ||||||
|  |       "viewport.deviceScaleFactor": 1, | ||||||
|  |       "viewport.width": width * 3, | ||||||
|  |       "viewport.height": height * 3, | ||||||
|  |     }); | ||||||
|  |     src = `https://api.microlink.io/?${params}`; | ||||||
|  |   } else { | ||||||
|  |     src = imageSrc; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const [isOpen, setOpen] = React.useState(false); | ||||||
|  | 
 | ||||||
|  |   const [isMounted, setIsMounted] = React.useState(false); | ||||||
|  | 
 | ||||||
|  |   React.useEffect(() => { | ||||||
|  |     setIsMounted(true); | ||||||
|  |   }, []); | ||||||
|  | 
 | ||||||
|  |   const springConfig = { stiffness: 100, damping: 15 }; | ||||||
|  |   const x = useMotionValue(0); | ||||||
|  | 
 | ||||||
|  |   const translateX = useSpring(x, springConfig); | ||||||
|  | 
 | ||||||
|  |   const handleMouseMove = (event: any) => { | ||||||
|  |     const targetRect = event.target.getBoundingClientRect(); | ||||||
|  |     const eventOffsetX = event.clientX - targetRect.left; | ||||||
|  |     const offsetFromCenter = (eventOffsetX - targetRect.width / 2) / 2; // Reduce the effect to make it subtle
 | ||||||
|  |     x.set(offsetFromCenter); | ||||||
|  |   }; | ||||||
|  |   const target = newTab ? "_blank" : ""; | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       {isMounted ? ( | ||||||
|  |         <div className="hidden"> | ||||||
|  |           <img src={src} width={width} height={height} alt="hidden image" /> | ||||||
|  |         </div> | ||||||
|  |       ) : null} | ||||||
|  | 
 | ||||||
|  |       <HoverCardPrimitive.Root | ||||||
|  |         openDelay={50} | ||||||
|  |         closeDelay={100} | ||||||
|  |         onOpenChange={(open) => { | ||||||
|  |           setOpen(open); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         <HoverCardPrimitive.Trigger | ||||||
|  |           onMouseMove={handleMouseMove} | ||||||
|  |           className={cn("text-foreground", className)} | ||||||
|  |           target={target} | ||||||
|  |           href={url} | ||||||
|  |         > | ||||||
|  |           {children} | ||||||
|  |         </HoverCardPrimitive.Trigger> | ||||||
|  | 
 | ||||||
|  |         <HoverCardPrimitive.Content | ||||||
|  |           className="[transform-origin:var(--radix-hover-card-content-transform-origin)]" | ||||||
|  |           side={side} | ||||||
|  |           align="center" | ||||||
|  |           sideOffset={10} | ||||||
|  |         > | ||||||
|  |           <AnimatePresence> | ||||||
|  |             {isOpen && ( | ||||||
|  |               <motion.div | ||||||
|  |                 initial={{ opacity: 0, y: 20, scale: 0.6 }} | ||||||
|  |                 animate={{ | ||||||
|  |                   opacity: 1, | ||||||
|  |                   y: 0, | ||||||
|  |                   scale: 1, | ||||||
|  |                   transition: { | ||||||
|  |                     type: "spring", | ||||||
|  |                     stiffness: 260, | ||||||
|  |                     damping: 20, | ||||||
|  |                   }, | ||||||
|  |                 }} | ||||||
|  |                 exit={{ opacity: 0, y: 20, scale: 0.6 }} | ||||||
|  |                 className={`shadow-xl ${classNames?.imageWrapper}`} | ||||||
|  |                 style={{ | ||||||
|  |                   x: translateX, | ||||||
|  |                 }} | ||||||
|  |               > | ||||||
|  |                 <a | ||||||
|  |                   href={url} | ||||||
|  |                   className={cn( | ||||||
|  |                     "block p-1 bg-white border-2 border-transparent shadow rounded-xl hover:border-border", | ||||||
|  |                     classNames?.imageWrapper | ||||||
|  |                   )} | ||||||
|  |                   style={{ fontSize: 0 }} | ||||||
|  |                   target={target} | ||||||
|  |                 > | ||||||
|  |                   <img | ||||||
|  |                     src={isStatic ? imageSrc : src} | ||||||
|  |                     width={width} | ||||||
|  |                     height={height} | ||||||
|  |                     className={cn("rounded-lg", classNames?.image)} | ||||||
|  |                     alt="preview image" | ||||||
|  |                   /> | ||||||
|  |                 </a> | ||||||
|  |               </motion.div> | ||||||
|  |             )} | ||||||
|  |           </AnimatePresence> | ||||||
|  |         </HoverCardPrimitive.Content> | ||||||
|  |       </HoverCardPrimitive.Root> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
							
								
								
									
										92
									
								
								src/components/ui/tilt.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/ui/tilt.tsx
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,92 @@ | |||||||
|  | 'use client'; | ||||||
|  | 
 | ||||||
|  | import React, { useRef } from 'react'; | ||||||
|  | import { | ||||||
|  |   motion, | ||||||
|  |   useMotionTemplate, | ||||||
|  |   useMotionValue, | ||||||
|  |   useSpring, | ||||||
|  |   useTransform, | ||||||
|  |   MotionStyle, | ||||||
|  |   SpringOptions, | ||||||
|  | } from 'motion/react'; | ||||||
|  | 
 | ||||||
|  | export type TiltProps = { | ||||||
|  |   children: React.ReactNode; | ||||||
|  |   className?: string; | ||||||
|  |   style?: MotionStyle; | ||||||
|  |   rotationFactor?: number; | ||||||
|  |   isRevese?: boolean; | ||||||
|  |   springOptions?: SpringOptions; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export function Tilt({ | ||||||
|  |   children, | ||||||
|  |   className, | ||||||
|  |   style, | ||||||
|  |   rotationFactor = 15, | ||||||
|  |   isRevese = false, | ||||||
|  |   springOptions, | ||||||
|  | }: TiltProps) { | ||||||
|  |   const ref = useRef<HTMLDivElement>(null); | ||||||
|  | 
 | ||||||
|  |   const x = useMotionValue(0); | ||||||
|  |   const y = useMotionValue(0); | ||||||
|  | 
 | ||||||
|  |   const xSpring = useSpring(x, springOptions); | ||||||
|  |   const ySpring = useSpring(y, springOptions); | ||||||
|  | 
 | ||||||
|  |   const rotateX = useTransform( | ||||||
|  |     ySpring, | ||||||
|  |     [-0.5, 0.5], | ||||||
|  |     isRevese | ||||||
|  |       ? [rotationFactor, -rotationFactor] | ||||||
|  |       : [-rotationFactor, rotationFactor] | ||||||
|  |   ); | ||||||
|  |   const rotateY = useTransform( | ||||||
|  |     xSpring, | ||||||
|  |     [-0.5, 0.5], | ||||||
|  |     isRevese | ||||||
|  |       ? [-rotationFactor, rotationFactor] | ||||||
|  |       : [rotationFactor, -rotationFactor] | ||||||
|  |   ); | ||||||
|  | 
 | ||||||
|  |   const transform = useMotionTemplate`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; | ||||||
|  | 
 | ||||||
|  |   const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { | ||||||
|  |     if (!ref.current) return; | ||||||
|  | 
 | ||||||
|  |     const rect = ref.current.getBoundingClientRect(); | ||||||
|  |     const width = rect.width; | ||||||
|  |     const height = rect.height; | ||||||
|  |     const mouseX = e.clientX - rect.left; | ||||||
|  |     const mouseY = e.clientY - rect.top; | ||||||
|  | 
 | ||||||
|  |     const xPos = mouseX / width - 0.5; | ||||||
|  |     const yPos = mouseY / height - 0.5; | ||||||
|  | 
 | ||||||
|  |     x.set(xPos); | ||||||
|  |     y.set(yPos); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   const handleMouseLeave = () => { | ||||||
|  |     x.set(0); | ||||||
|  |     y.set(0); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   return ( | ||||||
|  |     <motion.div | ||||||
|  |       ref={ref} | ||||||
|  |       className={className} | ||||||
|  |       style={{ | ||||||
|  |         transformStyle: 'preserve-3d', | ||||||
|  |         ...style, | ||||||
|  |         transform, | ||||||
|  |       }} | ||||||
|  |       onMouseMove={handleMouseMove} | ||||||
|  |       onMouseLeave={handleMouseLeave} | ||||||
|  |     > | ||||||
|  |       {children} | ||||||
|  |     </motion.div> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										1
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | |||||||
|  | declare module "*.css"; | ||||||
							
								
								
									
										45
									
								
								src/i18n/request.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/i18n/request.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,45 @@ | |||||||
|  | import { cookies, headers } from "next/headers"; | ||||||
|  | import type { AppConfig } from "next-intl"; | ||||||
|  | import { getRequestConfig } from "next-intl/server"; | ||||||
|  | import { allMessages } from "../../messages"; | ||||||
|  | 
 | ||||||
|  | const locales = ["en", "de"]; | ||||||
|  | 
 | ||||||
|  | declare module "next-intl" { | ||||||
|  |   interface AppConfig { | ||||||
|  |     Locale: (typeof locales)[number]; | ||||||
|  |     Messages: typeof allMessages.en; | ||||||
|  |     // Formats: typeof formats;
 | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const getPreferredLocale = async () => { | ||||||
|  |   const h = await headers(); | ||||||
|  |   const header = h.get("accept-language"); | ||||||
|  |   if (!header) return "en"; // Fallback
 | ||||||
|  | 
 | ||||||
|  |   // Beispiel: "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"
 | ||||||
|  |   const languages = header.split(",").map((lang) => { | ||||||
|  |     const [code, qValue] = lang.trim().split(";q="); | ||||||
|  |     return { code, q: qValue ? parseFloat(qValue) : 1.0 }; | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   // Nach Qualität sortieren
 | ||||||
|  |   languages.sort((a, b) => b.q - a.q); | ||||||
|  | 
 | ||||||
|  |   // Nur den Sprachcode, kein Regionssuffix
 | ||||||
|  |   const top = languages[0]?.code?.split("-")[0] ?? "en"; | ||||||
|  |   return top; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default getRequestConfig(async () => { | ||||||
|  |   const store = await cookies(); | ||||||
|  |   const locale: AppConfig["Locale"] = | ||||||
|  |     store.get("locale")?.value || (await getPreferredLocale()); | ||||||
|  |   const messages: AppConfig["Messages"] = | ||||||
|  |     locale === "de" ? allMessages.de : allMessages.en; | ||||||
|  |   return { | ||||||
|  |     locale, | ||||||
|  |     messages, | ||||||
|  |   }; | ||||||
|  | }); | ||||||
							
								
								
									
										32
									
								
								src/lib/mailer.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/lib/mailer.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,32 @@ | |||||||
|  | import nodemailer from "nodemailer"; | ||||||
|  | 
 | ||||||
|  | export function createTransport() { | ||||||
|  |   const host = process.env.SMTP_HOST!; | ||||||
|  |   const port = Number(process.env.SMTP_PORT || 465); | ||||||
|  |   const user = process.env.SMTP_USER!; | ||||||
|  |   const pass = process.env.SMTP_PASS!; | ||||||
|  | 
 | ||||||
|  |   const transporter = nodemailer.createTransport({ | ||||||
|  |     host, | ||||||
|  |     port, | ||||||
|  |     secure: port === 465, // 465 = implicit TLS
 | ||||||
|  |     auth: { user, pass }, | ||||||
|  |     requireTLS: port !== 465, // bei 587 erzwinge STARTTLS
 | ||||||
|  |     tls: { | ||||||
|  |       minVersion: "TLSv1.2", | ||||||
|  |       ciphers: | ||||||
|  |         "TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256", | ||||||
|  |       rejectUnauthorized: true, | ||||||
|  |       servername: host, // SNI
 | ||||||
|  |     }, | ||||||
|  |     // Optional: DKIM-Signatur, falls du einen Selector + Private Key hast
 | ||||||
|  |     // dkim: {
 | ||||||
|  |     //   domainName: "shortman.me",
 | ||||||
|  |     //   keySelector: "mail",
 | ||||||
|  |     //   privateKey: process.env.DKIM_PRIVATE_KEY!,
 | ||||||
|  |     //   cacheDir: false,
 | ||||||
|  |     // },
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return transporter; | ||||||
|  | } | ||||||
							
								
								
									
										84
									
								
								src/lib/server-actions.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								src/lib/server-actions.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,84 @@ | |||||||
|  | "use server"; | ||||||
|  | 
 | ||||||
|  | import { contactFormSchema } from "./zod"; | ||||||
|  | import { headers } from "next/headers"; | ||||||
|  | import { z } from "zod"; | ||||||
|  | import { createTransport } from "./mailer"; | ||||||
|  | 
 | ||||||
|  | export async function sendContactMail(body: z.infer<typeof contactFormSchema>) { | ||||||
|  |   const escapeHtml = (s: string) => { | ||||||
|  |     return s | ||||||
|  |       .replaceAll("&", "&") | ||||||
|  |       .replaceAll("<", "<") | ||||||
|  |       .replaceAll(">", ">") | ||||||
|  |       .replaceAll('"', """) | ||||||
|  |       .replaceAll("'", "'"); | ||||||
|  |   }; | ||||||
|  |   const parsed = contactFormSchema.safeParse(body); | ||||||
|  |   if (!parsed.success) { | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |   const { hp, name, email, message, budget } = parsed.data; | ||||||
|  |   // detect spam
 | ||||||
|  |   if (hp && hp.trim().length > 0) { | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  |   const transporter = createTransport(); | ||||||
|  | 
 | ||||||
|  |   const h = await headers(); | ||||||
|  |   const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown"; | ||||||
|  | 
 | ||||||
|  |   try { | ||||||
|  |     // Optional: Transport prüfen
 | ||||||
|  |     await transporter.verify(); | ||||||
|  | 
 | ||||||
|  |     const from = process.env.SMTP_FROM!; | ||||||
|  |     const to = process.env.SMTP_TO!; | ||||||
|  | 
 | ||||||
|  |     const subject = `Neue Kontaktanfrage von ${name}`; | ||||||
|  |     const text = [ | ||||||
|  |       `Name: ${name}`, | ||||||
|  |       `E-Mail: ${email}`, | ||||||
|  |       `IP: ${ip}`, | ||||||
|  |       ``, | ||||||
|  |       `Nachricht:`, | ||||||
|  |       message, | ||||||
|  |     ].join("\n"); | ||||||
|  | 
 | ||||||
|  |     const html = ` | ||||||
|  |       <div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;line-height:1.5"> | ||||||
|  |         <h2 style="margin:0 0 8px">Neue Kontaktanfrage</h2> | ||||||
|  |         <p><strong>Name:</strong> ${escapeHtml(name)}</p> | ||||||
|  |         <p><strong>E-Mail:</strong> ${escapeHtml(email)}</p> | ||||||
|  |         <p><strong>Budget:</strong> ${escapeHtml(budget)}</p> | ||||||
|  |         <p><strong>IP:</strong> ${escapeHtml(ip)}</p> | ||||||
|  |         <br /> | ||||||
|  |         <h3>Message</h3> | ||||||
|  |         <hr style="border:none;border-top:1px solid #ddd;margin:12px 0"/> | ||||||
|  |         <p style="white-space:pre-wrap">${escapeHtml(message)}</p> | ||||||
|  |       </div> | ||||||
|  |     `;
 | ||||||
|  | 
 | ||||||
|  |     await transporter.sendMail({ | ||||||
|  |       from, // MUSS zu deiner Domain passen (SPF/DMARC)
 | ||||||
|  |       to, // du selbst
 | ||||||
|  |       replyTo: email, // damit du direkt antworten kannst
 | ||||||
|  |       subject, | ||||||
|  |       text, | ||||||
|  |       html, | ||||||
|  |       envelope: { | ||||||
|  |         from, // technische Absenderadresse (Return-Path aligned)
 | ||||||
|  |         to, | ||||||
|  |       }, | ||||||
|  |       headers: { | ||||||
|  |         "X-Contact-Form": "portfolio", | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     return true; | ||||||
|  |   } catch (err) { | ||||||
|  |     console.log(err); | ||||||
|  | 
 | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								src/lib/zod.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/lib/zod.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,9 @@ | |||||||
|  | import { z } from "zod"; | ||||||
|  | 
 | ||||||
|  | export const contactFormSchema = z.object({ | ||||||
|  |   name: z.string().min(2).max(50), | ||||||
|  |   email: z.string().email(), | ||||||
|  |   budget: z.string(), | ||||||
|  |   message: z.string().max(500), | ||||||
|  |   hp: z.string().optional(), | ||||||
|  | }); | ||||||
| @ -15,9 +15,9 @@ export default { | |||||||
|         screens: { |         screens: { | ||||||
|           sm: "600px", |           sm: "600px", | ||||||
|           md: "728px", |           md: "728px", | ||||||
|           lg: "984px", |           lg: "900px", | ||||||
|           xl: "1000px", |           // xl: "1000px",
 | ||||||
|           "2xl": "1050px", |           // "2xl": "1000px",
 | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|       colors: { |       colors: { | ||||||
|  | |||||||
		Reference in New Issue
	
	Block a user