Compare commits
	
		
			No commits in common. "19891e49cb191a17ddd37b04bda8b4b0843690a4" and "42768fbdbe82e4c4c464eef7fc402ce20a134523" have entirely different histories.
		
	
	
		
			19891e49cb
			...
			42768fbdbe
		
	
		
| @ -10,7 +10,6 @@ | ||||
|     "cssVariables": true, | ||||
|     "prefix": "" | ||||
|   }, | ||||
|   "iconLibrary": "lucide", | ||||
|   "aliases": { | ||||
|     "components": "@/components", | ||||
|     "utils": "@/lib/utils", | ||||
| @ -18,7 +17,5 @@ | ||||
|     "lib": "@/lib", | ||||
|     "hooks": "@/hooks" | ||||
|   }, | ||||
|   "registries": { | ||||
|     "@aceternity": "https://ui.aceternity.com/registry/{name}.json" | ||||
|   } | ||||
| } | ||||
|   "iconLibrary": "lucide" | ||||
| } | ||||
| @ -1,28 +0,0 @@ | ||||
| { | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
| @ -1,15 +0,0 @@ | ||||
| { | ||||
|   "and": "und", | ||||
| 
 | ||||
|   "me": { | ||||
|     "name": "Pablo", | ||||
|     "role": "Webentwickler", | ||||
|     "work": "Verfügbar für Aufträge" | ||||
|   }, | ||||
| 
 | ||||
|   "navbar": { | ||||
|     "home": "Home", | ||||
|     "projects": "Projekte", | ||||
|     "work": "Auftrag Anfragen" | ||||
|   } | ||||
| } | ||||
| @ -1,24 +0,0 @@ | ||||
| { | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
| @ -1,20 +0,0 @@ | ||||
| { | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
| @ -1,16 +0,0 @@ | ||||
| { | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
| @ -1,28 +0,0 @@ | ||||
| { | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
| @ -1,9 +0,0 @@ | ||||
| { | ||||
|   "and": "and", | ||||
|   "me": { | ||||
|     "name": "Pablo", | ||||
|     "role": "Webdeveloper", | ||||
|     "work": "Available for work" | ||||
|   }, | ||||
|   "navbar": { "home": "Home", "projects": "Projects", "work": "Work with me" } | ||||
| } | ||||
| @ -1,24 +0,0 @@ | ||||
| { | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
| @ -1,19 +0,0 @@ | ||||
| { | ||||
|   "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." | ||||
|   } | ||||
| } | ||||
| @ -1,16 +0,0 @@ | ||||
| { | ||||
|   "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" | ||||
|   } | ||||
| } | ||||
| @ -1,30 +0,0 @@ | ||||
| /* 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,18 +1,7 @@ | ||||
| import { NextConfig } from "next"; | ||||
| import createNextIntlPlugin from "next-intl/plugin"; | ||||
| import type { NextConfig } from "next"; | ||||
| 
 | ||||
| const nextConfig: NextConfig = { | ||||
|   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 }, | ||||
|     ]; | ||||
|   }, | ||||
|   /* config options here */ | ||||
| }; | ||||
| 
 | ||||
| const withNextIntl = createNextIntlPlugin(); | ||||
| export default withNextIntl(nextConfig); | ||||
| export default nextConfig; | ||||
|  | ||||
| @ -10,22 +10,13 @@ | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@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-popover": "^1.1.15", | ||||
|     "@radix-ui/react-select": "^2.1.2", | ||||
|     "@radix-ui/react-slot": "^1.1.0", | ||||
|     "@types/nodemailer": "^7.0.3", | ||||
|     "class-variance-authority": "^0.7.1", | ||||
|     "clsx": "^2.1.1", | ||||
|     "lucide-react": "^0.462.0", | ||||
|     "motion": "^12.23.24", | ||||
|     "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-dom": "19.0.0-rc-66855b96-20241106", | ||||
|     "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
											
										
									
								
							
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 262 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 78 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 65 KiB | 
| @ -3,7 +3,7 @@ import { TechIcon } from "./components/setions/tech"; | ||||
| export type SocialIcon = "github" | "discord"; | ||||
| 
 | ||||
| type AppConfig = { | ||||
|   navigator: { label: string; path: string; isButton?: boolean }[]; | ||||
|   navigator: { label: string; path: string }[]; | ||||
|   socials: { name: string; icon: SocialIcon; link: string }[]; | ||||
| }; | ||||
| 
 | ||||
| @ -18,21 +18,25 @@ export const appConfig: AppConfig = { | ||||
|       path: "/projects", | ||||
|     }, | ||||
|     { | ||||
|       label: "Work with me", | ||||
|       path: "/contact", | ||||
|       isButton: true, | ||||
|       label: "Contact", | ||||
|       path: "/contact/#", | ||||
|     }, | ||||
|   ], | ||||
|   socials: [ | ||||
|     { | ||||
|       name: "discord", | ||||
|       icon: "discord", | ||||
|       link: "https://discord.gg/njGmuBQrfg", | ||||
|       link: "https://discord.com", | ||||
|     }, | ||||
|     { | ||||
|       name: "github", | ||||
|       icon: "github", | ||||
|       link: "https://github.com/mr-shortman", | ||||
|       link: "https://github.com", | ||||
|     }, | ||||
|     { | ||||
|       name: "github", | ||||
|       icon: "github", | ||||
|       link: "https://github.com", | ||||
|     }, | ||||
|   ], | ||||
| }; | ||||
|  | ||||
| @ -1,58 +0,0 @@ | ||||
| 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> | ||||
|   ); | ||||
| } | ||||
| @ -1,189 +0,0 @@ | ||||
| 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,7 +6,12 @@ function Layout({ children }: { children: JSX.Element }) { | ||||
|   return ( | ||||
|     <div className="space-y-4 "> | ||||
|       <Navbar /> | ||||
|       <main className="container min-h-screen py-4 ">{children}</main> | ||||
|       <main className="container min-h-screen py-4"> | ||||
|         {children} | ||||
| 
 | ||||
|         {/* <div className="absolute w-1 h-screen top-0 left-1/2 -translate-x-1/2 transform bg-foreground" /> */} | ||||
|       </main> | ||||
| 
 | ||||
|       <Footer /> | ||||
|     </div> | ||||
|   ); | ||||
| @ -14,8 +14,8 @@ body { | ||||
|     --card-foreground: 240 10% 3.9%; | ||||
|     --popover: 0 0% 100%; | ||||
|     --popover-foreground: 240 10% 3.9%; | ||||
|     --primary: 142.1 76.2% 36.3%; | ||||
|     --primary-foreground: 355.7 100% 97.3%; | ||||
|     --primary: 240 5.9% 10%; | ||||
|     --primary-foreground: 0 0% 98%; | ||||
|     --secondary: 240 4.8% 95.9%; | ||||
|     --secondary-foreground: 240 5.9% 10%; | ||||
|     --muted: 240 4.8% 95.9%; | ||||
| @ -26,59 +26,41 @@ body { | ||||
|     --destructive-foreground: 0 0% 98%; | ||||
|     --border: 240 5.9% 90%; | ||||
|     --input: 240 5.9% 90%; | ||||
|     --ring: 142.1 76.2% 36.3%; | ||||
|     --ring: 240 10% 3.9%; | ||||
|     --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; | ||||
|     --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 { | ||||
|     --background: 20 14.3% 4.1%; | ||||
|     --foreground: 0 0% 95%; | ||||
|     --card: 24 9.8% 10%; | ||||
|     --card-foreground: 0 0% 95%; | ||||
|     --popover: 0 0% 9%; | ||||
|     --popover-foreground: 0 0% 95%; | ||||
|     --primary: 142.1 70.6% 45.3%; | ||||
|     --primary-foreground: 144.9 80.4% 10%; | ||||
|     --background: 20 8% 8%; | ||||
|     --foreground: 0 0% 100%; | ||||
|     --card: 240 10% 3.9%; | ||||
|     --card-foreground: 0 0% 98%; | ||||
|     --popover: 240 10% 3.9%; | ||||
|     --popover-foreground: 0 0% 98%; | ||||
|     --primary: 17 90% 59%; | ||||
|     --primary-foreground: 0 0% 98%; | ||||
|     --secondary: 240 3.7% 15.9%; | ||||
|     --secondary-foreground: 0 0% 98%; | ||||
|     --muted: 0 0% 15%; | ||||
|     --muted-foreground: 240 5% 64.9%; | ||||
|     --accent: 12 6.5% 15.1%; | ||||
|     --muted: 20 6% 10%; | ||||
|     --muted-foreground: 0 5% 58%; | ||||
|     --accent: 240 3.7% 15.9%; | ||||
|     --accent-foreground: 0 0% 98%; | ||||
|     --destructive: 0 62.8% 30.6%; | ||||
|     --destructive-foreground: 0 85.7% 97.3%; | ||||
|     --destructive-foreground: 0 0% 98%; | ||||
|     --border: 240 3.7% 15.9%; | ||||
|     --input: 240 3.7% 15.9%; | ||||
|     --ring: 142.4 71.8% 29.2%; | ||||
|     --radius: 0.5rem; | ||||
|     --chart-1: 142.1 70.6% 45.3%; | ||||
|     --chart-2: 240 3.7% 15.9%; | ||||
|     --chart-3: 12 6.5% 15.1%; | ||||
|     --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%; | ||||
|     --ring: 240 4.9% 83.9%; | ||||
|     --chart-1: 220 70% 50%; | ||||
|     --chart-2: 160 60% 45%; | ||||
|     --chart-3: 30 80% 55%; | ||||
|     --chart-4: 280 65% 60%; | ||||
|     --chart-5: 340 75% 55%; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| @ -103,7 +85,7 @@ body { | ||||
|     @apply border-border; | ||||
|   } | ||||
|   body { | ||||
|     @apply bg-background text-foreground; | ||||
|     @apply dark bg-background text-foreground; | ||||
|   } | ||||
|   html { | ||||
|     scroll-behavior: smooth; | ||||
| @ -170,12 +152,3 @@ nav.navbar::before { | ||||
|   background: var(--accent); | ||||
|   opacity: 0.2; | ||||
| } | ||||
| 
 | ||||
| @layer base { | ||||
|   * { | ||||
|     @apply border-border; | ||||
|   } | ||||
|   body { | ||||
|     @apply bg-background text-foreground; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,5 @@ | ||||
| import type { Metadata } from "next"; | ||||
| import "./globals.css"; | ||||
| import { NextIntlClientProvider } from "next-intl"; | ||||
| import { getRequestConfig } from "next-intl/server"; | ||||
| import { ThemeProvider } from "@/components/theme-provider"; | ||||
| 
 | ||||
| export const metadata: Metadata = { | ||||
|   title: "Pablo Shortman", | ||||
| @ -15,16 +12,9 @@ export default function RootLayout({ | ||||
|   children: React.ReactNode; | ||||
| }>) { | ||||
|   return ( | ||||
|     <html lang="en" suppressHydrationWarning> | ||||
|     <html lang="en"> | ||||
|       <body suppressHydrationWarning className={"font-display antialiased"}> | ||||
|         <ThemeProvider | ||||
|           attribute="class" | ||||
|           defaultTheme="system" | ||||
|           enableSystem | ||||
|           disableTransitionOnChange | ||||
|         > | ||||
|           <NextIntlClientProvider>{children}</NextIntlClientProvider> | ||||
|         </ThemeProvider> | ||||
|         {children} | ||||
|       </body> | ||||
|     </html> | ||||
|   ); | ||||
|  | ||||
| @ -24,22 +24,26 @@ import { | ||||
|   SelectValue, | ||||
| } from "@/components/ui/select"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import { useTranslations } from "next-intl"; | ||||
| import { sendContactMail } from "@/lib/server-actions"; | ||||
| import { contactFormSchema } from "@/lib/zod"; | ||||
| 
 | ||||
| const formSchema = z.object({ | ||||
|   name: z.string().min(2).max(50), | ||||
|   email: z.string().email(), | ||||
|   budget: z.string(), | ||||
|   message: z.string().max(500), | ||||
| }); | ||||
| 
 | ||||
| function ContactForm() { | ||||
|   // 1. Define your form.
 | ||||
|   const form = useForm<z.infer<typeof contactFormSchema>>({ | ||||
|     resolver: zodResolver(contactFormSchema), | ||||
|   const form = useForm<z.infer<typeof formSchema>>({ | ||||
|     resolver: zodResolver(formSchema), | ||||
|   }); | ||||
| 
 | ||||
|   // 2. Define a submit handler.
 | ||||
|   async function onSubmit(values: z.infer<typeof contactFormSchema>) { | ||||
|     const result = await sendContactMail(values); | ||||
|     console.log(result); | ||||
|   function onSubmit(values: z.infer<typeof formSchema>) { | ||||
|     // Do something with the form values.
 | ||||
|     // ✅ This will be type-safe and validated.
 | ||||
|     console.log(values); | ||||
|   } | ||||
|   const t = useTranslations(); | ||||
| 
 | ||||
|   return ( | ||||
|     <Form {...form}> | ||||
| @ -50,12 +54,9 @@ function ContactForm() { | ||||
|             name="name" | ||||
|             render={({ field }) => ( | ||||
|               <FormItem className="w-full "> | ||||
|                 <FormLabel>{t("contact.form.name.label")}</FormLabel> | ||||
|                 <FormLabel>Name</FormLabel> | ||||
|                 <FormControl> | ||||
|                   <Input | ||||
|                     placeholder={t("contact.form.name.placeholder")} | ||||
|                     {...field} | ||||
|                   /> | ||||
|                   <Input placeholder="John Doe" {...field} /> | ||||
|                 </FormControl> | ||||
|                 <FormMessage /> | ||||
|               </FormItem> | ||||
| @ -66,12 +67,9 @@ function ContactForm() { | ||||
|             name="email" | ||||
|             render={({ field }) => ( | ||||
|               <FormItem className="w-full"> | ||||
|                 <FormLabel>{t("contact.form.email.label")}</FormLabel> | ||||
|                 <FormLabel>Email</FormLabel> | ||||
|                 <FormControl> | ||||
|                   <Input | ||||
|                     placeholder={t("contact.form.email.placeholder")} | ||||
|                     {...field} | ||||
|                   /> | ||||
|                   <Input placeholder="email@example.com" {...field} /> | ||||
|                 </FormControl> | ||||
|                 <FormMessage /> | ||||
|               </FormItem> | ||||
| @ -83,7 +81,7 @@ function ContactForm() { | ||||
|           name="budget" | ||||
|           render={({ field }) => ( | ||||
|             <FormItem> | ||||
|               <FormLabel>{t("contact.form.budget.label")}</FormLabel> | ||||
|               <FormLabel>Budget</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Select | ||||
|                   onValueChange={field.onChange} | ||||
| @ -97,20 +95,12 @@ function ContactForm() { | ||||
|                         : "text-muted-foreground" | ||||
|                     )} | ||||
|                   > | ||||
|                     <SelectValue | ||||
|                       placeholder={t("contact.form.budget.placeholder")} | ||||
|                     /> | ||||
|                     <SelectValue placeholder="Select your Budget" /> | ||||
|                   </SelectTrigger> | ||||
|                   <SelectContent> | ||||
|                     <SelectItem value="< €2k"> | ||||
|                       {t("contact.form.budget.options.1")} | ||||
|                     </SelectItem> | ||||
|                     <SelectItem value="> €4k"> | ||||
|                       {t("contact.form.budget.options.2")} | ||||
|                     </SelectItem> | ||||
|                     <SelectItem value="> €6k"> | ||||
|                       {t("contact.form.budget.options.3")} | ||||
|                     </SelectItem> | ||||
|                     <SelectItem value="< €2k">{"less then $2k"}</SelectItem> | ||||
|                     <SelectItem value="> €4k">{"more then $4k"}</SelectItem> | ||||
|                     <SelectItem value="> €6k">{"more then $6k"}</SelectItem> | ||||
|                   </SelectContent> | ||||
|                 </Select> | ||||
|               </FormControl> | ||||
| @ -123,20 +113,16 @@ function ContactForm() { | ||||
|           name="message" | ||||
|           render={({ field }) => ( | ||||
|             <FormItem> | ||||
|               <FormLabel> {t("contact.form.message.label")}</FormLabel> | ||||
|               <FormLabel>Message</FormLabel> | ||||
|               <FormControl> | ||||
|                 <Textarea | ||||
|                   rows={4} | ||||
|                   placeholder={t("contact.form.message.placeholder")} | ||||
|                   {...field} | ||||
|                 /> | ||||
|                 <Textarea rows={4} placeholder="Message..." {...field} /> | ||||
|               </FormControl> | ||||
|               <FormMessage /> | ||||
|             </FormItem> | ||||
|           )} | ||||
|         /> | ||||
|         <Button type="submit" className="w-full"> | ||||
|           {t("contact.form.submit")} | ||||
|           Submit | ||||
|         </Button> | ||||
|       </form> | ||||
|     </Form> | ||||
|  | ||||
| @ -1,42 +1,32 @@ | ||||
| import Link from "next/link"; | ||||
| import React from "react"; | ||||
| 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 ( | ||||
|     <footer className="container p-4 gap-x-4 flex flex-col md:flex-row items-center "> | ||||
|       <ModeToggle /> | ||||
|       <span className="text-sm "> | ||||
|         {t("copyright", { | ||||
|           year: new Date().getFullYear(), | ||||
|         })} | ||||
|     <footer className="container p-4 flex flex-col md:flex-row items-center justify-between"> | ||||
|       <span className="text-sm"> | ||||
|         Copyright © {new Date().getFullYear()}. All rights reserved. | ||||
|       </span> | ||||
| 
 | ||||
|       <menu className="flex items-center gap-4 mx-4"> | ||||
|       <menu className="flex items-center gap-4"> | ||||
|         <Button | ||||
|           asChild | ||||
|           variant={"link"} | ||||
|           className="text-muted-foreground text-xs hover:text-foreground px-0 w-max" | ||||
|         > | ||||
|           <Link href={t("imprint")}>{t("imprint")}</Link> | ||||
|           <Link href={"/imprint"}>Imprint</Link> | ||||
|         </Button> | ||||
|         <Button | ||||
|           asChild | ||||
|           variant={"link"} | ||||
|           className="text-muted-foreground text-xs hover:text-foreground px-0 w-max" | ||||
|         > | ||||
|           <Link href={t("privacy")}>{t("privacy")}</Link> | ||||
|           <Link href={"/privacy"}>Privacy</Link> | ||||
|         </Button> | ||||
|       </menu> | ||||
| 
 | ||||
|       <div className="order-last sm:order-none ml-auto flex items-center gap-4"> | ||||
|         <LocaleSwitcher /> | ||||
|       </div> | ||||
|     </footer> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default Footer; | ||||
|  | ||||
| @ -1,54 +0,0 @@ | ||||
| "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,9 +3,6 @@ import Image from "next/image"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import Link from "next/link"; | ||||
| import { socials } from "@/app.config"; | ||||
| import { getTranslations } from "next-intl/server"; | ||||
| import { GlowEffect } from "./ui/glow-effect"; | ||||
| import { Tilt } from "./ui/tilt"; | ||||
| 
 | ||||
| const icons = { | ||||
|   github: (props: any) => ( | ||||
| @ -36,68 +33,53 @@ const icons = { | ||||
|   ), | ||||
| }; | ||||
| 
 | ||||
| export default async function MeCard({ className }: { className?: string }) { | ||||
|   const t = await getTranslations(); | ||||
| function MeCard({ className }: { className?: string }) { | ||||
|   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 | ||||
|             className={cn( | ||||
|               `group relative border overflow-hidden w-full bg-card h-full  
 | ||||
|     <div | ||||
|       className={cn( | ||||
|         `group overflow-hidden w-full max-w-xs h-[28rem] bg-muted 
 | ||||
|                       rounded-xl space-y-8 p-4 flex flex-col  | ||||
|                       justify-between pb-20 ` | ||||
|             )} | ||||
|           > | ||||
|             <div className="space-y-4"> | ||||
|               <div className="h-52 w-3/4 mx-auto relative rounded-xl overflow-hidden z-10"> | ||||
|                 <Image src="/me.jpg" alt="me" fill className="object-cover " /> | ||||
|               </div> | ||||
|                       justify-between pb-20 relative`,
 | ||||
|         className | ||||
|       )} | ||||
|     > | ||||
|       <div className="space-y-4"> | ||||
|         <div className="h-52 w-3/4 mx-auto relative rounded-xl overflow-hidden z-10"> | ||||
|           <Image src="/me.jpg" alt="me" fill className="object-cover " /> | ||||
|         </div> | ||||
| 
 | ||||
|               <div className="text-center flex flex-col items-center"> | ||||
|                 <h4 className="text-lg  font-bold translate-y-px  "> | ||||
|                   {t("global.me.name")} | ||||
|                 </h4> | ||||
|                 <span className="text-emerald-500 text-sm -translate-y-px"> | ||||
|                   {t("global.me.work")} | ||||
|                 </span> | ||||
|                 {/* <Button className="mt-4" asChild> | ||||
|         <div className="text-center flex flex-col items-center"> | ||||
|           <h4 className="text-lg  font-bold translate-y-px  ">Pablo</h4> | ||||
|           <span className="text-emerald-500 text-sm -translate-y-px"> | ||||
|             Available for work | ||||
|           </span> | ||||
|           {/* <Button className="mt-4" asChild> | ||||
|             <Link href={button.link}>{button.label}</Link> | ||||
|           </Button> */} | ||||
|                 <menu className="w-full flex justify-center items-center gap-2 pt-4"> | ||||
|                   {socials.map((s, idx) => { | ||||
|                     const Icon = icons[s.icon]; | ||||
|                     return ( | ||||
|                       <li key={idx}> | ||||
|                         <Link href={s.link} target="_blank"> | ||||
|                           <div className="p-2 rounded-full bg-background flex items-center justify-center hover:scale-105 transition-all duration-150"> | ||||
|                             {<Icon className="size-6" />} | ||||
|                           </div> | ||||
|                         </Link> | ||||
|                       </li> | ||||
|                     ); | ||||
|                   })} | ||||
|                 </menu> | ||||
|               </div> | ||||
|             </div> | ||||
| 
 | ||||
|             <p className="text-center text-muted-foreground text-lg font-bold"> | ||||
|               {t("global.me.role")} | ||||
|             </p> | ||||
| 
 | ||||
|             <div className="w-full h-40 halftone absolute left-0 top-0 z-0" /> | ||||
|           </div> | ||||
|           <menu className="w-full flex justify-center items-center gap-2 pt-4"> | ||||
|             {socials.map((s, idx) => { | ||||
|               const Icon = icons[s.icon]; | ||||
|               return ( | ||||
|                 <li key={idx}> | ||||
|                   <Link href={s.link} target="_blank"> | ||||
|                     <div className="p-2 rounded-full bg-background flex items-center justify-center hover:scale-105 transition-all duration-150"> | ||||
|                       {<Icon className="size-6" />} | ||||
|                     </div> | ||||
|                   </Link> | ||||
|                 </li> | ||||
|               ); | ||||
|             })} | ||||
|           </menu> | ||||
|         </div> | ||||
|       </div> | ||||
|     </Tilt> | ||||
| 
 | ||||
|       <p className="text-center text-muted-foreground text-lg font-bold"> | ||||
|         Web Developer | ||||
|       </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> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default MeCard; | ||||
|  | ||||
| @ -1,40 +0,0 @@ | ||||
| "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,70 +11,53 @@ import { | ||||
|   BreadcrumbList, | ||||
|   BreadcrumbSeparator, | ||||
| } from "@/components/ui/breadcrumb"; | ||||
| import { appNavigator } from "@/app.config"; | ||||
| import { cn } from "@/lib/utils"; | ||||
| import LocaleSwitcher from "./locale-switcher"; | ||||
| import { useTranslations } from "next-intl"; | ||||
| 
 | ||||
| export default function Navbar() { | ||||
| function Navbar() { | ||||
|   const pathname = usePathname(); | ||||
|   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 ( | ||||
|     <div className="w-full fixed top-0 right-0 left-0 z-50"> | ||||
|       <nav className="container p-4 flex items-center navbar"> | ||||
|         <div className="items-center gap-4 hidden sm:flex"> | ||||
|           <LocaleSwitcher /> | ||||
|       <nav className="container p-4 flex justify-between items-center navbar"> | ||||
|         <div className="flex items-center gap-4"> | ||||
|           <Link | ||||
|             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> | ||||
| 
 | ||||
|         <Breadcrumb className="ml-auto"> | ||||
|         <Breadcrumb> | ||||
|           <BreadcrumbList> | ||||
|             {navigation.map(({ label, path, isButton }, idx) => { | ||||
|               return ( | ||||
|                 <div key={path} className="flex items-center gap-2"> | ||||
|                   <BreadcrumbItem> | ||||
|                     {isButton ? ( | ||||
|                       <Button | ||||
|                         asChild | ||||
|                         size={"sm"} | ||||
|                         variant={isActive(path) ? "outline" : "default"} | ||||
|                       > | ||||
|                         <Link href={path}>{label}</Link> | ||||
|                       </Button> | ||||
|                     ) : ( | ||||
|                       <BreadcrumbLink | ||||
|                         className={cn(isActive(path) && "text-foreground")} | ||||
|                         href={path} | ||||
|                       > | ||||
|                         {label} | ||||
|                       </BreadcrumbLink> | ||||
|                     )} | ||||
|                   </BreadcrumbItem> | ||||
|                   {idx + 1 < navigation.length && ( | ||||
|                     <BreadcrumbSeparator> | ||||
|                       <span>/</span> | ||||
|                     </BreadcrumbSeparator> | ||||
|                   )} | ||||
|                 </div> | ||||
|               ); | ||||
|             })} | ||||
|             {appNavigator.map(({ label, path }, idx) => ( | ||||
|               <div key={path} className="flex items-center gap-2"> | ||||
|                 <BreadcrumbItem> | ||||
|                   <BreadcrumbLink | ||||
|                     className={cn(isActive(path) && "text-foreground")} | ||||
|                     href={path} | ||||
|                   > | ||||
|                     {label} | ||||
|                   </BreadcrumbLink> | ||||
|                 </BreadcrumbItem> | ||||
|                 {idx + 1 < appNavigator.length && ( | ||||
|                   <BreadcrumbSeparator> | ||||
|                     <span>/</span> | ||||
|                   </BreadcrumbSeparator> | ||||
|                 )} | ||||
|               </div> | ||||
|             ))} | ||||
|           </BreadcrumbList> | ||||
|         </Breadcrumb> | ||||
|       </nav> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default Navbar; | ||||
|  | ||||
| @ -2,23 +2,19 @@ import React from "react"; | ||||
| import MeCard from "../me-card"; | ||||
| import Heading from "../heading"; | ||||
| import ContactForm from "../contact-form"; | ||||
| import { getTranslations } from "next-intl/server"; | ||||
| 
 | ||||
| export default async function Contact({ | ||||
|   withoutMeCard, | ||||
| }: { | ||||
|   withoutMeCard?: boolean; | ||||
| }) { | ||||
|   const t = await getTranslations(); | ||||
| function Contact({ withoutMeCard }: { withoutMeCard?: boolean }) { | ||||
|   return ( | ||||
|     <div className="flex flex-col lg:flex-row gap-12 lg:gap-20 container "> | ||||
|       {!withoutMeCard && ( | ||||
|         <MeCard className="mx-auto order-last lg:order-first lg:sticky lg:top-20" /> | ||||
|       )} | ||||
|       <div className="w-full space-y-4 p-4"> | ||||
|         <Heading subTitle={t("contact.subTitle")} title={t("contact.title")} /> | ||||
|         <Heading subTitle="Let's Work" title="Together" /> | ||||
|         <ContactForm /> | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default Contact; | ||||
|  | ||||
| @ -1,55 +1,59 @@ | ||||
| import React from "react"; | ||||
| import { LinkPreview } from "../ui/link-preview"; | ||||
| import { getTranslations } from "next-intl/server"; | ||||
| "use client"; | ||||
| 
 | ||||
| import { cn } from "@/lib/utils"; | ||||
| import Image from "next/image"; | ||||
| import React from "react"; | ||||
| 
 | ||||
| export default async function Hero() { | ||||
|   const t = await getTranslations(); | ||||
|   const items = [ | ||||
|     { | ||||
|       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"), | ||||
|     }, | ||||
|   ]; | ||||
| function Hero() { | ||||
|   const [hovered, setHovered] = React.useState(""); | ||||
| 
 | ||||
|   const textHover = | ||||
|     "hover:text-primary hover:scale-105 transition-all duration-300"; | ||||
|   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 "> | ||||
|       <span className="hover:scale-105 transition-transform duration-150 ease-in-out cursor-default w-max"> | ||||
|         {items[0].label} | ||||
|       </span> | ||||
|       <span className="hover:scale-105 transition-transform duration-150 ease-in-out cursor-default w-max ml-auto"> | ||||
|         {items[1].label} | ||||
|       </span> | ||||
| 
 | ||||
|       <span className="hover:scale-105 text-primary transition-transform duration-150 ease-in-out cursor-default w-max"> | ||||
|         {items[2].label} | ||||
|       </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 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={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> | ||||
|         <div className="flex gap-4 justify-end items-center pr-4 lg:pr-0"> | ||||
|           <div | ||||
|             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> | ||||
|         </div> | ||||
|         <span | ||||
|           onMouseEnter={() => setHovered("text-3")} | ||||
|           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> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| // export default Hero;
 | ||||
| export default Hero; | ||||
|  | ||||
| @ -1,21 +1,25 @@ | ||||
| import { Project, projects } from "@/constants"; | ||||
| import { ArrowRight } from "lucide-react"; | ||||
| import Image from "next/image"; | ||||
| import Link from "next/link"; | ||||
| import React from "react"; | ||||
| import MeCard from "../me-card"; | ||||
| import Heading from "../heading"; | ||||
| import { getTranslations } from "next-intl/server"; | ||||
| import { GlowEffect } from "../ui/glow-effect"; | ||||
| 
 | ||||
| const ProjectCard = (p: any) => ( | ||||
| const ProjectCard = (p: Project) => ( | ||||
|   <Link href={p.link} target="_blank"> | ||||
|     <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="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="h-40 w-full lg:max-w-32 relative rounded-xl overflow-hidden"> | ||||
|         <Image src={p.image} alt="project-image" fill className="object-fill" /> | ||||
|         <Image | ||||
|           src={p.image} | ||||
|           alt="project-image" | ||||
|           fill | ||||
|           className="object-cover" | ||||
|         /> | ||||
|       </div> | ||||
|       <div className="space-y-1 w-full p-4"> | ||||
|         <span className="text-xs text-muted-foreground ">{`[ ${p.year} ]`}</span> | ||||
|         <h3 className="text-2xl md:text-3xl font-black">{p.title}</h3> | ||||
|         <h3 className="text-2xl md:text-3xl font-black">{p.name}</h3> | ||||
|         <p className="text-muted-foreground text-sm md:text-lg"> | ||||
|           {p.description} | ||||
|         </p> | ||||
| @ -26,34 +30,26 @@ const ProjectCard = (p: any) => ( | ||||
|   </Link> | ||||
| ); | ||||
| 
 | ||||
| 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), | ||||
|   })); | ||||
| function Projects() { | ||||
|   return ( | ||||
|     <> | ||||
|       <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" /> | ||||
|         <div className="space-y-8  w-full p-4"> | ||||
|           <Heading | ||||
|             title="Projects" | ||||
|             subTitle="Recent" | ||||
|             // className="sticky top-0 z-50 pb-6"
 | ||||
|           /> | ||||
|           <menu className=" flex flex-col items-center gap-8 lg:pb-20 w-full"> | ||||
|             {projects.map((p, idx) => ( | ||||
|               <li key={idx} className="w-full"> | ||||
|                 <ProjectCard {...p} /> | ||||
|               </li> | ||||
|             ))} | ||||
|           </menu> | ||||
|         </div> | ||||
|     <div className="flex flex-col md:flex-row gap-8 container lg:px-20"> | ||||
|       <MeCard className="mx-auto order-last md:order-first md:sticky md:top-20" /> | ||||
|       <div className="space-y-8  w-full p-4"> | ||||
|         <Heading | ||||
|           title="Projects" | ||||
|           subTitle="Recent" | ||||
|           // className="sticky top-0 z-50 pb-6"
 | ||||
|         /> | ||||
|         <menu className=" flex flex-col items-center gap-8 lg:pb-20 w-full"> | ||||
|           {projects.map((p) => ( | ||||
|             <li key={p.link} className="w-full"> | ||||
|               <ProjectCard {...p} /> | ||||
|             </li> | ||||
|           ))} | ||||
|         </menu> | ||||
|       </div> | ||||
|     </> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default Projects; | ||||
|  | ||||
| @ -1,29 +1,8 @@ | ||||
| import { getTranslations } from "next-intl/server"; | ||||
| import React from "react"; | ||||
| 
 | ||||
| export type TechIcon = "ts" | "next" | "react" | "tailwind" | "PostgreSQL"; | ||||
| 
 | ||||
| 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", | ||||
|     Logo: (props: any) => ( | ||||
| @ -232,20 +211,18 @@ const tech: { label: string; Logo: (props: any) => JSX.Element }[] = [ | ||||
|   }, | ||||
| ]; | ||||
| 
 | ||||
| export default async function Tech() { | ||||
|   const t = await getTranslations(); | ||||
| function Tech() { | ||||
|   return ( | ||||
|     <div className="py-12 lg:py-20 container relative space-y-8"> | ||||
|       <h3 className="text-sm font-mono text-muted-foreground text-center"> | ||||
|         {t("home.tech.text")}{" "} | ||||
|         <span className="text-foreground">{t("home.tech.technologies")}</span>{" "} | ||||
|         {t("global.and")}{" "} | ||||
|         <span className="text-foreground">{t("home.tech.tools")}</span> | ||||
|         most liked{" "} | ||||
|         <span className="underline text-foreground">technologies</span> and{" "} | ||||
|         <span className="underline text-foreground">tools</span> | ||||
|       </h3> | ||||
|       <ul className="flex w-full items-center justify-center gap-2 md:gap-4 lg:gap-8 flex-wrap"> | ||||
|         {tech.map(({ Logo, label }, idx) => ( | ||||
|           <li | ||||
|             className="flex items-center gap-2 py-2 px-6 rounded-md bg-card" | ||||
|             className="flex items-center gap-2 py-2 px-6 rounded-md bg-muted" | ||||
|             key={idx} | ||||
|           > | ||||
|             <Logo className="size-6" /> | ||||
| @ -256,3 +233,5 @@ export default async function Tech() { | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| export default Tech; | ||||
|  | ||||
| @ -1,11 +0,0 @@ | ||||
| "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>; | ||||
| } | ||||
| @ -1,201 +0,0 @@ | ||||
| "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, | ||||
| } | ||||
| @ -1,151 +0,0 @@ | ||||
| "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 | ||||
|       )} | ||||
|     /> | ||||
|   ); | ||||
| } | ||||
| @ -1,160 +0,0 @@ | ||||
| "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> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
| @ -1,92 +0,0 @@ | ||||
| '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
									
									
								
							
							
						
						
									
										1
									
								
								src/global.d.ts
									
									
									
									
										vendored
									
									
								
							| @ -1 +0,0 @@ | ||||
| declare module "*.css"; | ||||
| @ -1,45 +0,0 @@ | ||||
| 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, | ||||
|   }; | ||||
| }); | ||||
| @ -1,32 +0,0 @@ | ||||
| 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; | ||||
| } | ||||
| @ -1,84 +0,0 @@ | ||||
| "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; | ||||
|   } | ||||
| } | ||||
| @ -1,9 +0,0 @@ | ||||
| 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: { | ||||
|           sm: "600px", | ||||
|           md: "728px", | ||||
|           lg: "900px", | ||||
|           // xl: "1000px",
 | ||||
|           // "2xl": "1000px",
 | ||||
|           lg: "984px", | ||||
|           xl: "1000px", | ||||
|           "2xl": "1050px", | ||||
|         }, | ||||
|       }, | ||||
|       colors: { | ||||
|  | ||||
		Reference in New Issue
	
	Block a user