Compare commits

...

10 Commits

Author SHA1 Message Date
Linus
19891e49cb DSGVO komform 2025-10-27 06:57:29 +01:00
Pablo
349cf61658
Merge pull request #10 from mr-shortman/main
Main
2025-10-26 06:43:53 +01:00
Pablo
eb51c768c7
Merge pull request #9 from mr-shortman/dev
fixed padding top on hero section
2024-12-02 15:12:40 +01:00
Pablo
4bb159c7d5
Merge pull request #8 from mr-shortman/dev
fixed wrong props
2024-12-02 15:08:22 +01:00
Pablo
2d494e0bb1
Merge pull request #7 from mr-shortman/dev
changed layout padding and heading size
2024-12-02 15:03:53 +01:00
Pablo
6212251792
Merge pull request #6 from mr-shortman/dev
updated projects and fixed sticky mecard + layout padding
2024-12-02 14:34:50 +01:00
Pablo
aa27818f35
Merge pull request #5 from mr-shortman/dev
framer navbar effect
2024-12-02 13:44:05 +01:00
Pablo
39273d6661
Merge pull request #4 from mr-shortman/dev
updated eslint config
2024-12-02 13:21:04 +01:00
Pablo
ea1176a2fd
Merge pull request #3 from mr-shortman/dev
removed unused imports and disabled eslint no any rule
2024-12-02 13:16:20 +01:00
Pablo
1ffad8a9ba
Merge pull request #2 from mr-shortman/dev
New Hero and Tech Section; Mobile, Responsive
2024-12-02 11:40:38 +01:00
48 changed files with 3608 additions and 258 deletions

View File

@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
"registries": {
"@aceternity": "https://ui.aceternity.com/registry/{name}.json"
}
}

28
messages/de/contact.json Normal file
View File

@ -0,0 +1,28 @@
{
"subTitle": "Lass uns",
"title": "Starten",
"form": {
"name": {
"label": "Name",
"placeholder": "Dein name"
},
"email": {
"label": "Email",
"placeholder": "Deine email"
},
"budget": {
"label": "Dein Budget",
"placeholder": "Select your budget",
"options": {
"1": "Weniger als €2k",
"2": "Mehr als €4k",
"3": "Mehr als €6k"
}
},
"message": {
"label": "Nachricht",
"placeholder": "Deine Nachricht"
},
"submit": "Absenden"
}
}

15
messages/de/global.json Normal file
View File

@ -0,0 +1,15 @@
{
"and": "und",
"me": {
"name": "Pablo",
"role": "Webentwickler",
"work": "Verfügbar für Aufträge"
},
"navbar": {
"home": "Home",
"projects": "Projekte",
"work": "Auftrag Anfragen"
}
}

24
messages/de/home.json Normal file
View File

@ -0,0 +1,24 @@
{
"hero": {
"slot1": {
"label": "Zeitlos",
"link": "https://weather.shrt.solutions",
"image": "/projects/weather.png"
},
"slot2": {
"label": "Kreativ",
"link": "https://vico.shortman.me",
"image": "/projects/link-preview-vico.png"
},
"slot3": {
"label": "Einzigartig",
"link": "https://ivorygrow.de",
"image": "/projects/ivory.png"
}
},
"tech": {
"text": "bevorzugte",
"technologies": "Technologien",
"tools": "Werkzeuge"
}
}

20
messages/de/legal.json Normal file
View File

@ -0,0 +1,20 @@
{
"legal": {
"imprint": {
"title": "Impressum",
"company": "Firma",
"address": "Adresse",
"germany": "Deutschland",
"contact": "Kontakt"
},
"privacy": {
"title": "Datenschutzerklärung"
}
},
"footer": {
"copyright": "© {year} Pablo Kurzmann - Portfolio. Alle Rechte vorbehalten.",
"imprint": "impressum",
"privacy": "datenschutz"
}
}

16
messages/de/projects.json Normal file
View File

@ -0,0 +1,16 @@
{
"0": {
"image": "/projects/ivory.png",
"link": { "live": "https://ivorygrow.de" },
"title": "Ivory Grow & Headshop",
"description": "Modernes Webprojekt für einen nachhaltigen Head- & Growshop. Cleanes Design, DSGVO-konform und performance-optimiert. Umgesetzt mit Webflow, Fokus auf UX und Markenwirkung.",
"year": "2025"
},
"1": {
"image": "/projects/weather.png",
"link": { "live": "https://weather.shrt.solutions/" },
"title": "Wetter App",
"description": "Minimalistische Wetter-App mit klarem Fokus auf Übersicht. Echtzeitdaten, responsive UI und moderne API-Integration. Entwickelt mit TypeScript und React für maximale Performance.",
"year": "2024"
}
}

28
messages/en/contact.json Normal file
View File

@ -0,0 +1,28 @@
{
"subTitle": "Lets Work",
"title": "Together",
"form": {
"name": {
"label": "Name",
"placeholder": "Your name"
},
"email": {
"label": "Email",
"placeholder": "Your email"
},
"budget": {
"label": "Budget",
"placeholder": "Select your budget",
"options": {
"1": "Less than $2k",
"2": "More than $4k",
"3": "More than $6k"
}
},
"message": {
"label": "Message",
"placeholder": "Your message"
},
"submit": "Send"
}
}

9
messages/en/global.json Normal file
View File

@ -0,0 +1,9 @@
{
"and": "and",
"me": {
"name": "Pablo",
"role": "Webdeveloper",
"work": "Available for work"
},
"navbar": { "home": "Home", "projects": "Projects", "work": "Work with me" }
}

24
messages/en/home.json Normal file
View File

@ -0,0 +1,24 @@
{
"hero": {
"slot1": {
"label": "Timeless",
"link": "https://weather.shrt.solutions",
"image": "/projects/weather.png"
},
"slot2": {
"label": "Creative",
"link": "https://vico.shortman.me",
"image": "/projects/link-preview-vico.png"
},
"slot3": {
"label": "Unique",
"link": "https://ivorygrow.de",
"image": "/projects/ivory.png"
}
},
"tech": {
"text": "most liked",
"technologies": "technologies",
"tools": "tools"
}
}

19
messages/en/legal.json Normal file
View File

@ -0,0 +1,19 @@
{
"legal": {
"imprint": {
"title": "Imprint",
"company": "Company",
"address": "Address",
"germany": "Germany",
"contact": "Contact"
},
"privacy": {
"title": "Privacy Policy"
}
},
"footer": {
"imprint": "imprint",
"privacy": "privacy",
"copyright": "© {year} Pablo Kurzmann - Portfolio. All rights reserved."
}
}

16
messages/en/projects.json Normal file
View File

@ -0,0 +1,16 @@
{
"0": {
"image": "/projects/ivory.png",
"link": { "live": "https://ivorygrow.de" },
"title": "Ivory Grow & Headshop",
"description": "Modern web project for a sustainable head & grow shop. Clean design, GDPR-compliant and performance optimized. Built with Webflow, focused on UX and brand impact.",
"year": "2025"
},
"1": {
"image": "/projects/weather.png",
"link": { "live": "https://weather.shrt.solutions/" },
"title": "Weather App",
"description": "Minimalist weather app focused on clarity and simplicity. Real-time data, responsive UI, and modern API integration. Built with TypeScript and React for top performance.",
"year": "2024"
}
}

30
messages/index.ts Normal file
View File

@ -0,0 +1,30 @@
/* EN Imports */
import enGlobal from "./en/global.json";
import enProjects from "./en/projects.json";
import enHome from "./en/home.json";
import enContact from "./en/contact.json";
import enLegal from "./en/legal.json";
/* DE Imports */
import deGlobal from "./de/global.json";
import deProjects from "./de/projects.json";
import deHome from "./de/home.json";
import deContact from "./de/contact.json";
import deLegal from "./de/legal.json";
export const allMessages = {
en: {
global: enGlobal,
projects: enProjects,
home: enHome,
contact: enContact,
legal: enLegal,
},
de: {
global: deGlobal,
projects: deProjects,
home: deHome,
contact: deContact,
legal: deLegal,
},
};

View File

@ -1,7 +1,18 @@
import type { NextConfig } from "next";
import { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const nextConfig: NextConfig = {
/* config options here */
async redirects() {
return [
// Deutsche Bezeichnungen -> kanonische Pfade
{ source: "/impressum", destination: "/imprint", permanent: true },
{ source: "/datenschutz", destination: "/privacy", permanent: true },
// Optional: Plural/Varianten
{ source: "/privacy-policy", destination: "/privacy", permanent: true },
];
},
};
export default nextConfig;
const withNextIntl = createNextIntlPlugin();
export default withNextIntl(nextConfig);

View File

@ -10,13 +10,22 @@
},
"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

File diff suppressed because it is too large Load Diff

BIN
public/projects/ivory.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
public/projects/weather.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -3,7 +3,7 @@ import { TechIcon } from "./components/setions/tech";
export type SocialIcon = "github" | "discord";
type AppConfig = {
navigator: { label: string; path: string }[];
navigator: { label: string; path: string; isButton?: boolean }[];
socials: { name: string; icon: SocialIcon; link: string }[];
};
@ -18,25 +18,21 @@ export const appConfig: AppConfig = {
path: "/projects",
},
{
label: "Contact",
path: "/contact/#",
label: "Work with me",
path: "/contact",
isButton: true,
},
],
socials: [
{
name: "discord",
icon: "discord",
link: "https://discord.com",
link: "https://discord.gg/njGmuBQrfg",
},
{
name: "github",
icon: "github",
link: "https://github.com",
},
{
name: "github",
icon: "github",
link: "https://github.com",
link: "https://github.com/mr-shortman",
},
],
};

View File

@ -0,0 +1,58 @@
import { getLocale, getTranslations } from "next-intl/server";
import React from "react";
export default async function Page() {
const t = await getTranslations("legal");
const locale = await getLocale();
return (
<main className="py-12 space-y-4 text-lg">
<h1 className="text-4xl font-bold">{t("legal.imprint.title")}</h1>
{/* Beispielstruktur fülle mit deinen Daten */}
{locale === "de" && <p>Angaben gemäß § 5 DDG</p>}
<section>
<p className="font-bold">Pablo Kurzmann</p>
<p>Koppoldstr. 1</p>
<p>86551 Aichach</p>
<p>{t("legal.imprint.germany")}</p>
</section>
<section>
<h2 className="text-2xl font-semibold">{t("legal.imprint.contact")}</h2>
<p>E-Mail: pablo@shortman.me</p>
</section>
{locale === "de" && (
<>
<section>
<p>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV:</p>
<p>Pablo Kurzmann</p>
</section>
<section>
<h2 className="text-2xl font-semibold">Haftung für Inhalte</h2>
<p>
Die Inhalte dieser Seite wurden mit größter Sorgfalt erstellt. Für
die Richtigkeit, Vollständigkeit und Aktualität der Inhalte
übernehme ich jedoch keine Gewähr.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">Haftung für Links</h2>
<p>
Diese Website enthält Links zu externen Websites Dritter, auf
deren Inhalte ich keinen Einfluss habe. Daher kann ich für diese
fremden Inhalte keine Gewähr übernehmen. Für die Inhalte der
verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber
verantwortlich.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">Urheberrecht</h2>
<p>
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf
dieser Website unterliegen dem deutschen Urheberrecht.
</p>
</section>
</>
)}
<p>{t("footer.copyright", { year: new Date().getFullYear() })}</p>
</main>
);
}

View File

@ -0,0 +1,189 @@
import { getLocale } from "next-intl/server";
import React from "react";
const PrivacyDE = (
<main className="py-12 space-y-4 text-lg">
<h1 className="text-4xl font-bold">Datenschutzerklärung</h1>
<section>
<p>
Diese Datenschutzerklärung informiert Sie über die Art, den Umfang und
Zweck der Verarbeitung personenbezogener Daten (nachfolgend kurz
Daten) im Rahmen meines Onlineangebots und der mit ihm verbundenen
Webseiten, Funktionen und Inhalte.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">1. Verantwortlicher</h2>
<p className="font-bold">Pablo Kurzmann</p>
<p>Koppoldstr. 1</p>
<p>86551 Aichach</p>
<p>Deutschland</p>
<p>E-Mail: pablo@shortman.me</p>
</section>
<section>
<h2 className="text-2xl font-semibold">2. Erhobene Daten und Zweck</h2>
<p>
Beim Besuch dieser Website werden keine personenbezogenen Daten
gespeichert oder an Dritte übermittelt. Es werden keine Cookies,
Tracking- oder Analyse-Tools verwendet.
</p>
<p>
Personenbezogene Daten werden nur dann erhoben, wenn Sie das
Kontaktformular nutzen oder mir freiwillig eine E-Mail senden. In diesem
Fall werden die übermittelten Daten (z. B. Name, E-Mail-Adresse,
Nachricht) ausschließlich zur Bearbeitung Ihrer Anfrage verwendet.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">3. Rechtsgrundlage</h2>
<p>
Die Verarbeitung der Daten erfolgt gemäß Art. 6 Abs. 1 lit. b DSGVO,
soweit sie zur Durchführung vorvertraglicher Maßnahmen erforderlich ist,
oder gemäß Art. 6 Abs. 1 lit. f DSGVO auf Grundlage meines berechtigten
Interesses, Anfragen zu beantworten.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">4. E-Mail-Kommunikation</h2>
<p>
Der Versand und Empfang von E-Mails erfolgt über einen selbst
betriebenen Mailserver mit Standort in Deutschland. Die Server werden
nach aktuellen Sicherheitsstandards betrieben. Dennoch kann bei der
Übertragung von Daten im Internet (z. B. bei der Kommunikation per
E-Mail) keine vollständige Sicherheit gewährleistet werden.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">5. Speicherdauer</h2>
<p>
Anfragen, die per E-Mail oder über das Kontaktformular eingehen, werden
nur so lange gespeichert, wie sie zur Bearbeitung der Anfrage
erforderlich sind. Gesetzliche Aufbewahrungsfristen bleiben unberührt.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">6. Ihre Rechte</h2>
<p>
Sie haben das Recht auf Auskunft über die von mir verarbeiteten Daten
(Art. 15 DSGVO), auf Berichtigung (Art. 16 DSGVO), Löschung (Art. 17
DSGVO), Einschränkung der Verarbeitung (Art. 18 DSGVO), Widerspruch
(Art. 21 DSGVO) sowie auf Datenübertragbarkeit (Art. 20 DSGVO).
</p>
<p>
Außerdem haben Sie das Recht, sich bei einer Aufsichtsbehörde zu
beschweren, wenn Sie der Ansicht sind, dass die Verarbeitung Ihrer Daten
gegen die DSGVO verstößt (Art. 77 DSGVO).
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">7. Änderungen dieser Erklärung</h2>
<p>
Ich behalte mir vor, diese Datenschutzerklärung bei Änderungen meiner
Website oder gesetzlichen Vorgaben anzupassen. Die jeweils aktuelle
Fassung finden Sie jederzeit auf dieser Seite.
</p>
</section>
<p>Stand: Oktober 2025</p>
</main>
);
const PrivacyEN = (
<main className="py-12 space-y-4 text-lg">
<h1 className="text-4xl font-bold">Privacy Policy</h1>
<section>
<p>
This privacy policy explains how personal data (data) is processed
when you use this website and its contact form.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">1. Controller</h2>
<p className="font-bold">Pablo Kurzmann</p>
<p>Koppoldstr. 1</p>
<p>86551 Aichach</p>
<p>Germany</p>
<p>Email: pablo@shortman.me</p>
</section>
<section>
<h2 className="text-2xl font-semibold">2. Data collected and purpose</h2>
<p>
No personal data is stored or shared with third parties when visiting
this website. No cookies, analytics, or tracking tools are used.
</p>
<p>
Personal data is only collected when you use the contact form or send an
email voluntarily. The data you provide (e.g., name, email address,
message) is used solely to respond to your inquiry.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">3. Legal basis</h2>
<p>
Data processing is carried out pursuant to Art. 6 (1)(b) GDPR where it
is necessary for pre-contractual steps, or Art. 6 (1)(f) GDPR based on
the legitimate interest in responding to inquiries.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">4. Email communication</h2>
<p>
Emails are sent and received via a self-hosted mail server located in
Germany. The server is operated according to current security standards.
However, data transmission over the internet (e.g., by email) may have
security gaps.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">5. Storage period</h2>
<p>
Messages sent by email or contact form are stored only as long as
necessary to process your inquiry. Statutory retention periods remain
unaffected.
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">6. Your rights</h2>
<p>
You have the right to access (Art. 15 GDPR), rectification (Art. 16),
erasure (Art. 17), restriction (Art. 18), objection (Art. 21), and data
portability (Art. 20 GDPR).
</p>
<p>
You may also lodge a complaint with a supervisory authority if you
believe your data is being processed unlawfully (Art. 77 GDPR).
</p>
</section>
<section>
<h2 className="text-2xl font-semibold">7. Changes</h2>
<p>
This privacy policy may be updated to reflect changes in legal
requirements or the website itself. The current version is always
available on this page.
</p>
</section>
<p>Last updated: October 2025</p>
</main>
);
export default async function Page() {
const locale = await getLocale();
return locale === "de" ? PrivacyDE : PrivacyEN;
}

View File

@ -6,12 +6,7 @@ function Layout({ children }: { children: JSX.Element }) {
return (
<div className="space-y-4 ">
<Navbar />
<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>
<main className="container min-h-screen py-4 ">{children}</main>
<Footer />
</div>
);

View File

@ -14,8 +14,8 @@ body {
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--primary: 142.1 76.2% 36.3%;
--primary-foreground: 355.7 100% 97.3%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
@ -26,41 +26,59 @@ body {
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--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 */
--ring: 142.1 76.2% 36.3%;
--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 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%;
--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%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 20 6% 10%;
--muted-foreground: 0 5% 58%;
--accent: 240 3.7% 15.9%;
--muted: 0 0% 15%;
--muted-foreground: 240 5% 64.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--destructive-foreground: 0 85.7% 97.3%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--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%;
--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%;
}
}
@ -85,7 +103,7 @@ body {
@apply border-border;
}
body {
@apply dark bg-background text-foreground;
@apply bg-background text-foreground;
}
html {
scroll-behavior: smooth;
@ -152,3 +170,12 @@ nav.navbar::before {
background: var(--accent);
opacity: 0.2;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -1,5 +1,8 @@
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",
@ -12,9 +15,16 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning className={"font-display antialiased"}>
{children}
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</ThemeProvider>
</body>
</html>
);

View File

@ -24,26 +24,22 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
const formSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
budget: z.string(),
message: z.string().max(500),
});
import { useTranslations } from "next-intl";
import { sendContactMail } from "@/lib/server-actions";
import { contactFormSchema } from "@/lib/zod";
function ContactForm() {
// 1. Define your form.
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm<z.infer<typeof contactFormSchema>>({
resolver: zodResolver(contactFormSchema),
});
// 2. Define a submit handler.
function onSubmit(values: z.infer<typeof formSchema>) {
// Do something with the form values.
// ✅ This will be type-safe and validated.
console.log(values);
async function onSubmit(values: z.infer<typeof contactFormSchema>) {
const result = await sendContactMail(values);
console.log(result);
}
const t = useTranslations();
return (
<Form {...form}>
@ -54,9 +50,12 @@ function ContactForm() {
name="name"
render={({ field }) => (
<FormItem className="w-full ">
<FormLabel>Name</FormLabel>
<FormLabel>{t("contact.form.name.label")}</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
<Input
placeholder={t("contact.form.name.placeholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -67,9 +66,12 @@ function ContactForm() {
name="email"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Email</FormLabel>
<FormLabel>{t("contact.form.email.label")}</FormLabel>
<FormControl>
<Input placeholder="email@example.com" {...field} />
<Input
placeholder={t("contact.form.email.placeholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@ -81,7 +83,7 @@ function ContactForm() {
name="budget"
render={({ field }) => (
<FormItem>
<FormLabel>Budget</FormLabel>
<FormLabel>{t("contact.form.budget.label")}</FormLabel>
<FormControl>
<Select
onValueChange={field.onChange}
@ -95,12 +97,20 @@ function ContactForm() {
: "text-muted-foreground"
)}
>
<SelectValue placeholder="Select your Budget" />
<SelectValue
placeholder={t("contact.form.budget.placeholder")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="< €2k">{"less then $2k"}</SelectItem>
<SelectItem value="> €4k">{"more then $4k"}</SelectItem>
<SelectItem value="> €6k">{"more then $6k"}</SelectItem>
<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>
</SelectContent>
</Select>
</FormControl>
@ -113,16 +123,20 @@ function ContactForm() {
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>Message</FormLabel>
<FormLabel> {t("contact.form.message.label")}</FormLabel>
<FormControl>
<Textarea rows={4} placeholder="Message..." {...field} />
<Textarea
rows={4}
placeholder={t("contact.form.message.placeholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full">
Submit
{t("contact.form.submit")}
</Button>
</form>
</Form>

View File

@ -1,32 +1,42 @@
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 flex flex-col md:flex-row items-center justify-between">
<span className="text-sm">
Copyright © {new Date().getFullYear()}. All rights reserved.
<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(),
})}
</span>
<menu className="flex items-center gap-4">
<menu className="flex items-center gap-4 mx-4">
<Button
asChild
variant={"link"}
className="text-muted-foreground text-xs hover:text-foreground px-0 w-max"
>
<Link href={"/imprint"}>Imprint</Link>
<Link href={t("imprint")}>{t("imprint")}</Link>
</Button>
<Button
asChild
variant={"link"}
className="text-muted-foreground text-xs hover:text-foreground px-0 w-max"
>
<Link href={"/privacy"}>Privacy</Link>
<Link href={t("privacy")}>{t("privacy")}</Link>
</Button>
</menu>
<div className="order-last sm:order-none ml-auto flex items-center gap-4">
<LocaleSwitcher />
</div>
</footer>
);
}
export default Footer;

View File

@ -0,0 +1,54 @@
"use client";
import React from "react";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
const LocaleButton = ({
locale,
handleLocaleChange,
active,
}: {
locale: string;
active?: boolean;
handleLocaleChange: (value: string) => void;
}) => {
return (
<button
className={cn(
"uppercase",
active ? "text-foreground" : "text-muted-foreground"
)}
onClick={() => handleLocaleChange(locale)}
>
{locale}
</button>
);
};
export default function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
const handleLocaleChange = (value: string) => {
const currentLocale = value;
document.cookie = `locale=${currentLocale}; path=/`;
router.refresh();
};
return (
<div>
<LocaleButton
locale="en"
handleLocaleChange={handleLocaleChange}
active={locale === "en"}
/>
<span className="text-muted-foreground">{" / "}</span>
<LocaleButton
locale="de"
handleLocaleChange={handleLocaleChange}
active={locale === "de"}
/>
</div>
);
}

View File

@ -3,6 +3,9 @@ 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) => (
@ -33,53 +36,68 @@ const icons = {
),
};
function MeCard({ className }: { className?: string }) {
export default async function MeCard({ className }: { className?: string }) {
const t = await getTranslations();
return (
<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 relative`,
className
)}
<Tilt
rotationFactor={4}
isRevese
className={cn("w-full max-w-xs h-[28rem] ", 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="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
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>
<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>
<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>
<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>
<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>
</div>
</div>
<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>
</Tilt>
);
}
export default MeCard;

View File

@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export function ModeToggle() {
const { setTheme } = useTheme();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -11,53 +11,70 @@ 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";
function Navbar() {
export default 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 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>
)}
<nav className="container p-4 flex items-center navbar">
<div className="items-center gap-4 hidden sm:flex">
<LocaleSwitcher />
</div>
<Breadcrumb>
<Breadcrumb className="ml-auto">
<BreadcrumbList>
{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>
))}
{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>
);
})}
</BreadcrumbList>
</Breadcrumb>
</nav>
</div>
);
}
export default Navbar;

View File

@ -2,19 +2,23 @@ import React from "react";
import MeCard from "../me-card";
import Heading from "../heading";
import ContactForm from "../contact-form";
import { getTranslations } from "next-intl/server";
function Contact({ withoutMeCard }: { withoutMeCard?: boolean }) {
export default async function Contact({
withoutMeCard,
}: {
withoutMeCard?: boolean;
}) {
const t = await getTranslations();
return (
<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="Let's Work" title="Together" />
<Heading subTitle={t("contact.subTitle")} title={t("contact.title")} />
<ContactForm />
</div>
</div>
);
}
export default Contact;

View File

@ -1,59 +1,55 @@
"use client";
import { cn } from "@/lib/utils";
import Image from "next/image";
import React from "react";
import { LinkPreview } from "../ui/link-preview";
import { getTranslations } from "next-intl/server";
import { cn } from "@/lib/utils";
function Hero() {
const [hovered, setHovered] = React.useState("");
const textHover =
"hover:text-primary hover:scale-105 transition-all duration-300";
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"),
},
];
return (
<>
<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>
</>
<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>
);
}
export default Hero;
// export default Hero;

View File

@ -1,25 +1,21 @@
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: Project) => (
const ProjectCard = (p: any) => (
<Link href={p.link} target="_blank">
<div className="group max-w-xs lg:max-w-none mx-auto md:mx-0 flex flex-col lg:flex-row items-center gap-4 cursor-pointer hover:bg-muted w-full rounded-xl relative">
<div className="group max-w-xs pl-4 pt-4 lg:max-w-none mx-auto md:mx-0 flex flex-col lg:flex-row gap-4 cursor-pointer hover:bg-accent w-full rounded-xl relative">
<div className="h-40 w-full lg:max-w-32 relative rounded-xl overflow-hidden">
<Image
src={p.image}
alt="project-image"
fill
className="object-cover"
/>
<Image src={p.image} alt="project-image" fill className="object-fill" />
</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.name}</h3>
<h3 className="text-2xl md:text-3xl font-black">{p.title}</h3>
<p className="text-muted-foreground text-sm md:text-lg">
{p.description}
</p>
@ -30,26 +26,34 @@ const ProjectCard = (p: Project) => (
</Link>
);
function Projects() {
export default async function Projects() {
const t = await getTranslations();
const projects = Array.from({ length: 2 }).map((_, idx) => ({
title: t(`projects.${idx}.title` as any),
description: t(`projects.${idx}.description` as any),
year: t(`projects.${idx}.year` as any),
image: t(`projects.${idx}.image` as any),
link: t(`projects.${idx}.link.live` as any),
}));
return (
<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 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>
</div>
</>
);
}
export default Projects;

View File

@ -1,8 +1,29 @@
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) => (
@ -211,18 +232,20 @@ const tech: { label: string; Logo: (props: any) => JSX.Element }[] = [
},
];
function Tech() {
export default async function Tech() {
const t = await getTranslations();
return (
<div className="py-12 lg:py-20 container relative space-y-8">
<h3 className="text-sm font-mono text-muted-foreground text-center">
most liked{" "}
<span className="underline text-foreground">technologies</span> and{" "}
<span className="underline text-foreground">tools</span>
{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>
</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-muted"
className="flex items-center gap-2 py-2 px-6 rounded-md bg-card"
key={idx}
>
<Logo className="size-6" />
@ -233,5 +256,3 @@ function Tech() {
</div>
);
}
export default Tech;

View File

@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -0,0 +1,151 @@
"use client";
import { cn } from "@/lib/utils";
import { motion, Transition } from "motion/react";
export type GlowEffectProps = {
className?: string;
style?: React.CSSProperties;
colors?: string[];
mode?:
| "rotate"
| "pulse"
| "breathe"
| "colorShift"
| "flowHorizontal"
| "static";
blur?:
| number
| "softest"
| "soft"
| "medium"
| "strong"
| "stronger"
| "strongest"
| "none";
transition?: Transition;
scale?: number;
duration?: number;
};
export function GlowEffect({
className,
style,
colors = ["#FF5733", "#33FF57", "#3357FF", "#F1C40F"],
mode = "rotate",
blur = "medium",
transition,
scale = 1,
duration = 5,
}: GlowEffectProps) {
const BASE_TRANSITION = {
repeat: Infinity,
duration: duration,
ease: "linear",
};
const animations = {
rotate: {
background: [
`conic-gradient(from 0deg at 50% 50%, ${colors.join(", ")})`,
`conic-gradient(from 360deg at 50% 50%, ${colors.join(", ")})`,
],
transition: {
...(transition ?? BASE_TRANSITION),
},
},
pulse: {
background: colors.map(
(color) =>
`radial-gradient(circle at 50% 50%, ${color} 0%, transparent 100%)`
),
scale: [1 * scale, 1.1 * scale, 1 * scale],
opacity: [0.5, 0.8, 0.5],
transition: {
...(transition ?? {
...BASE_TRANSITION,
repeatType: "mirror",
}),
},
},
breathe: {
background: [
...colors.map(
(color) =>
`radial-gradient(circle at 50% 50%, ${color} 0%, transparent 100%)`
),
],
scale: [1 * scale, 1.05 * scale, 1 * scale],
transition: {
...(transition ?? {
...BASE_TRANSITION,
repeatType: "mirror",
}),
},
},
colorShift: {
background: colors.map((color, index) => {
const nextColor = colors[(index + 1) % colors.length];
return `conic-gradient(from 0deg at 50% 50%, ${color} 0%, ${nextColor} 50%, ${color} 100%)`;
}),
transition: {
...(transition ?? {
...BASE_TRANSITION,
repeatType: "mirror",
}),
},
},
flowHorizontal: {
background: colors.map((color) => {
const nextColor = colors[(colors.indexOf(color) + 1) % colors.length];
return `linear-gradient(to right, ${color}, ${nextColor})`;
}),
transition: {
...(transition ?? {
...BASE_TRANSITION,
repeatType: "mirror",
}),
},
},
static: {
background: `linear-gradient(to right, ${colors.join(", ")})`,
},
};
const getBlurClass = (blur: GlowEffectProps["blur"]) => {
if (typeof blur === "number") {
return `blur-[${blur}px]`;
}
const presets = {
softest: "blur-xs",
soft: "blur-sm",
medium: "blur-md",
strong: "blur-lg",
stronger: "blur-xl",
strongest: "blur-xl",
none: "blur-none",
};
return presets[blur as keyof typeof presets];
};
return (
<motion.div
style={
{
...style,
"--scale": scale,
willChange: "transform",
backfaceVisibility: "hidden",
} as React.CSSProperties
}
animate={animations[mode] as any}
className={cn(
"pointer-events-none absolute inset-0 h-full w-full",
"scale-[var(--scale)] transform-gpu",
getBlurClass(blur),
className
)}
/>
);
}

View File

@ -0,0 +1,160 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { encode } from "qss";
import React from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useSpring,
} from "motion/react";
import { cn } from "@/lib/utils";
type LinkPreviewProps = {
children: React.ReactNode;
url: string;
className?: string;
width?: number;
height?: number;
quality?: number;
layout?: string;
newTab?: boolean;
classNames?: {
image?: string;
imageWrapper?: string;
};
side?: "top" | "bottom" | "left" | "right";
} & (
| { isStatic: true; imageSrc: string }
| { isStatic?: false; imageSrc?: never }
);
export const LinkPreview = ({
children,
url,
className,
width = 200,
height = 125,
quality = 50,
layout = "fixed",
isStatic = false,
imageSrc = "",
side = "top",
newTab = false,
classNames,
}: LinkPreviewProps) => {
let src;
if (!isStatic) {
const params = encode({
url,
screenshot: true,
meta: false,
embed: "screenshot.url",
colorScheme: "dark",
"viewport.isMobile": true,
"viewport.deviceScaleFactor": 1,
"viewport.width": width * 3,
"viewport.height": height * 3,
});
src = `https://api.microlink.io/?${params}`;
} else {
src = imageSrc;
}
const [isOpen, setOpen] = React.useState(false);
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => {
setIsMounted(true);
}, []);
const springConfig = { stiffness: 100, damping: 15 };
const x = useMotionValue(0);
const translateX = useSpring(x, springConfig);
const handleMouseMove = (event: any) => {
const targetRect = event.target.getBoundingClientRect();
const eventOffsetX = event.clientX - targetRect.left;
const offsetFromCenter = (eventOffsetX - targetRect.width / 2) / 2; // Reduce the effect to make it subtle
x.set(offsetFromCenter);
};
const target = newTab ? "_blank" : "";
return (
<>
{isMounted ? (
<div className="hidden">
<img src={src} width={width} height={height} alt="hidden image" />
</div>
) : null}
<HoverCardPrimitive.Root
openDelay={50}
closeDelay={100}
onOpenChange={(open) => {
setOpen(open);
}}
>
<HoverCardPrimitive.Trigger
onMouseMove={handleMouseMove}
className={cn("text-foreground", className)}
target={target}
href={url}
>
{children}
</HoverCardPrimitive.Trigger>
<HoverCardPrimitive.Content
className="[transform-origin:var(--radix-hover-card-content-transform-origin)]"
side={side}
align="center"
sideOffset={10}
>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.6 }}
animate={{
opacity: 1,
y: 0,
scale: 1,
transition: {
type: "spring",
stiffness: 260,
damping: 20,
},
}}
exit={{ opacity: 0, y: 20, scale: 0.6 }}
className={`shadow-xl ${classNames?.imageWrapper}`}
style={{
x: translateX,
}}
>
<a
href={url}
className={cn(
"block p-1 bg-white border-2 border-transparent shadow rounded-xl hover:border-border",
classNames?.imageWrapper
)}
style={{ fontSize: 0 }}
target={target}
>
<img
src={isStatic ? imageSrc : src}
width={width}
height={height}
className={cn("rounded-lg", classNames?.image)}
alt="preview image"
/>
</a>
</motion.div>
)}
</AnimatePresence>
</HoverCardPrimitive.Content>
</HoverCardPrimitive.Root>
</>
);
};

View File

@ -0,0 +1,92 @@
'use client';
import React, { useRef } from 'react';
import {
motion,
useMotionTemplate,
useMotionValue,
useSpring,
useTransform,
MotionStyle,
SpringOptions,
} from 'motion/react';
export type TiltProps = {
children: React.ReactNode;
className?: string;
style?: MotionStyle;
rotationFactor?: number;
isRevese?: boolean;
springOptions?: SpringOptions;
};
export function Tilt({
children,
className,
style,
rotationFactor = 15,
isRevese = false,
springOptions,
}: TiltProps) {
const ref = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const xSpring = useSpring(x, springOptions);
const ySpring = useSpring(y, springOptions);
const rotateX = useTransform(
ySpring,
[-0.5, 0.5],
isRevese
? [rotationFactor, -rotationFactor]
: [-rotationFactor, rotationFactor]
);
const rotateY = useTransform(
xSpring,
[-0.5, 0.5],
isRevese
? [-rotationFactor, rotationFactor]
: [rotationFactor, -rotationFactor]
);
const transform = useMotionTemplate`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const xPos = mouseX / width - 0.5;
const yPos = mouseY / height - 0.5;
x.set(xPos);
y.set(yPos);
};
const handleMouseLeave = () => {
x.set(0);
y.set(0);
};
return (
<motion.div
ref={ref}
className={className}
style={{
transformStyle: 'preserve-3d',
...style,
transform,
}}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{children}
</motion.div>
);
}

1
src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "*.css";

45
src/i18n/request.ts Normal file
View File

@ -0,0 +1,45 @@
import { cookies, headers } from "next/headers";
import type { AppConfig } from "next-intl";
import { getRequestConfig } from "next-intl/server";
import { allMessages } from "../../messages";
const locales = ["en", "de"];
declare module "next-intl" {
interface AppConfig {
Locale: (typeof locales)[number];
Messages: typeof allMessages.en;
// Formats: typeof formats;
}
}
const getPreferredLocale = async () => {
const h = await headers();
const header = h.get("accept-language");
if (!header) return "en"; // Fallback
// Beispiel: "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"
const languages = header.split(",").map((lang) => {
const [code, qValue] = lang.trim().split(";q=");
return { code, q: qValue ? parseFloat(qValue) : 1.0 };
});
// Nach Qualität sortieren
languages.sort((a, b) => b.q - a.q);
// Nur den Sprachcode, kein Regionssuffix
const top = languages[0]?.code?.split("-")[0] ?? "en";
return top;
};
export default getRequestConfig(async () => {
const store = await cookies();
const locale: AppConfig["Locale"] =
store.get("locale")?.value || (await getPreferredLocale());
const messages: AppConfig["Messages"] =
locale === "de" ? allMessages.de : allMessages.en;
return {
locale,
messages,
};
});

32
src/lib/mailer.ts Normal file
View File

@ -0,0 +1,32 @@
import nodemailer from "nodemailer";
export function createTransport() {
const host = process.env.SMTP_HOST!;
const port = Number(process.env.SMTP_PORT || 465);
const user = process.env.SMTP_USER!;
const pass = process.env.SMTP_PASS!;
const transporter = nodemailer.createTransport({
host,
port,
secure: port === 465, // 465 = implicit TLS
auth: { user, pass },
requireTLS: port !== 465, // bei 587 erzwinge STARTTLS
tls: {
minVersion: "TLSv1.2",
ciphers:
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256",
rejectUnauthorized: true,
servername: host, // SNI
},
// Optional: DKIM-Signatur, falls du einen Selector + Private Key hast
// dkim: {
// domainName: "shortman.me",
// keySelector: "mail",
// privateKey: process.env.DKIM_PRIVATE_KEY!,
// cacheDir: false,
// },
});
return transporter;
}

84
src/lib/server-actions.ts Normal file
View File

@ -0,0 +1,84 @@
"use server";
import { contactFormSchema } from "./zod";
import { headers } from "next/headers";
import { z } from "zod";
import { createTransport } from "./mailer";
export async function sendContactMail(body: z.infer<typeof contactFormSchema>) {
const escapeHtml = (s: string) => {
return s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
};
const parsed = contactFormSchema.safeParse(body);
if (!parsed.success) {
return false;
}
const { hp, name, email, message, budget } = parsed.data;
// detect spam
if (hp && hp.trim().length > 0) {
return false;
}
const transporter = createTransport();
const h = await headers();
const ip = h.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
try {
// Optional: Transport prüfen
await transporter.verify();
const from = process.env.SMTP_FROM!;
const to = process.env.SMTP_TO!;
const subject = `Neue Kontaktanfrage von ${name}`;
const text = [
`Name: ${name}`,
`E-Mail: ${email}`,
`IP: ${ip}`,
``,
`Nachricht:`,
message,
].join("\n");
const html = `
<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;line-height:1.5">
<h2 style="margin:0 0 8px">Neue Kontaktanfrage</h2>
<p><strong>Name:</strong> ${escapeHtml(name)}</p>
<p><strong>E-Mail:</strong> ${escapeHtml(email)}</p>
<p><strong>Budget:</strong> ${escapeHtml(budget)}</p>
<p><strong>IP:</strong> ${escapeHtml(ip)}</p>
<br />
<h3>Message</h3>
<hr style="border:none;border-top:1px solid #ddd;margin:12px 0"/>
<p style="white-space:pre-wrap">${escapeHtml(message)}</p>
</div>
`;
await transporter.sendMail({
from, // MUSS zu deiner Domain passen (SPF/DMARC)
to, // du selbst
replyTo: email, // damit du direkt antworten kannst
subject,
text,
html,
envelope: {
from, // technische Absenderadresse (Return-Path aligned)
to,
},
headers: {
"X-Contact-Form": "portfolio",
},
});
return true;
} catch (err) {
console.log(err);
return false;
}
}

9
src/lib/zod.ts Normal file
View File

@ -0,0 +1,9 @@
import { z } from "zod";
export const contactFormSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
budget: z.string(),
message: z.string().max(500),
hp: z.string().optional(),
});

View File

@ -15,9 +15,9 @@ export default {
screens: {
sm: "600px",
md: "728px",
lg: "984px",
xl: "1000px",
"2xl": "1050px",
lg: "900px",
// xl: "1000px",
// "2xl": "1000px",
},
},
colors: {