merge #11
@ -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
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 = {
|
||||
/* 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": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@types/nodemailer": "^7.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.462.0",
|
||||
"motion": "^12.23.24",
|
||||
"next": "15.0.3",
|
||||
"next-intl": "^4.4.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "^7.0.10",
|
||||
"qss": "^3.0.0",
|
||||
"react": "19.0.0-rc-66855b96-20241106",
|
||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||
"react-hook-form": "^7.53.2",
|
||||
|
||||
1859
pnpm-lock.yaml
generated
1859
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
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";
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
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 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;
|
||||
|
||||
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,
|
||||
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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
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: {
|
||||
sm: "600px",
|
||||
md: "728px",
|
||||
lg: "984px",
|
||||
xl: "1000px",
|
||||
"2xl": "1050px",
|
||||
lg: "900px",
|
||||
// xl: "1000px",
|
||||
// "2xl": "1000px",
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
|
||||
Reference in New Issue
Block a user