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