diff --git a/apps/docs/app/layout.config.tsx b/apps/docs/app/layout.config.tsx index e9413af..d04993b 100644 --- a/apps/docs/app/layout.config.tsx +++ b/apps/docs/app/layout.config.tsx @@ -1,5 +1,12 @@ import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared"; -import { Github, GlobeIcon, HeartIcon, Rss } from "lucide-react"; +import { + Github, + GlobeIcon, + HeartIcon, + Rss, + LogIn, + UserPlus, +} from "lucide-react"; import Link from "next/link"; /** * Shared layout configurations @@ -45,6 +52,18 @@ export const baseOptions: BaseLayoutProps = { ), }, links: [ + { + text: "Login", + url: "https://app.dokploy.com/", + active: "nested-url", + icon: , + }, + { + text: "Sign Up", + url: "https://app.dokploy.com/register", + active: "nested-url", + icon: , + }, { text: "Website", url: "https://dokploy.com", diff --git a/apps/docs/content/docs/core/cluster.mdx b/apps/docs/content/docs/core/cluster.mdx index 134e801..72d971b 100644 --- a/apps/docs/content/docs/core/cluster.mdx +++ b/apps/docs/content/docs/core/cluster.mdx @@ -38,24 +38,20 @@ If you choose the second option, we will proceed to configure the different serv To start, we need to configure a Docker registry, as when deploying an application, you need a registry to deploy and download the application image on the other servers. -We offer two ways to configure a registry: - -1. **External Registry**: Use any registry you want. -2. **Self-Hosted Registry**: We create and configure a self-hosted registry for you. - ### External Registry -You can use any registry, such as Docker Hub, DigitalOcean Spaces, ECR, or your choice. Make sure to enter the correct credentials and test the connection before adding the registry. +You can use any external registry of your choice. Here are some popular options: -### Self-Hosted Registry +1. **Docker Hub** - Free tier available, easy to set up +2. **GitHub Container Registry (ghcr.io)** - Free for public repositories +3. **DigitalOcean Container Registry** - Simple setup with good integration +4. **Amazon ECR** - AWS's managed container registry +5. **Google Container Registry** - Google Cloud's managed registry +6. **Azure Container Registry** - Microsoft's managed registry -We will ask you for three things: +Make sure to enter the correct credentials and test the connection before adding the registry to your cluster configuration. -1. A user. -2. A password. -3. A domain. Ensure this domain is pointing to the dokploy VPS. - -Once set up, the Cluster section will be unlocked. +Once configured, the Cluster section will be unlocked. ## Understanding Docker Swarm diff --git a/apps/docs/content/docs/core/docker-compose/index.mdx b/apps/docs/content/docs/core/docker-compose/index.mdx index 5d24dd6..9c9ea50 100644 --- a/apps/docs/content/docs/core/docker-compose/index.mdx +++ b/apps/docs/content/docs/core/docker-compose/index.mdx @@ -73,6 +73,8 @@ volumes: - "../files/my-configs:/etc/my-app/config" ✅ ``` +**Important:** If you need to use files from your repository (configuration files, scripts, etc.), you must move them to Dokploy's File Mounts (via Advanced → Mounts) instead of mounting them directly from the repository. When using AutoDeploy, Dokploy performs a `git clone` on each deployment, which clears the repository directory. Mounting files directly from your repository using relative paths (e.g., `./` or `./config/file.conf`) will cause them to be lost or empty in subsequent deployments. See the [Troubleshooting guide](/docs/core/troubleshooting#using-files-from-your-repository) for more details. + ## Keyboard Shortcuts diff --git a/apps/docs/content/docs/core/manual-installation.mdx b/apps/docs/content/docs/core/manual-installation.mdx index 9fa3088..a4cd347 100644 --- a/apps/docs/content/docs/core/manual-installation.mdx +++ b/apps/docs/content/docs/core/manual-installation.mdx @@ -143,7 +143,7 @@ install_dokploy() { --mount type=volume,source=redis-data-volume,target=/data \ redis:7 - docker pull traefik:v3.5.0 + docker pull traefik:v3.6.1 docker pull dokploy/dokploy:latest # Installation @@ -167,11 +167,11 @@ install_dokploy() { --restart always \ -v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \ -v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ - -v /var/run/docker.sock:/var/run/docker.sock \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ -p 80:80/tcp \ -p 443:443/tcp \ -p 443:443/udp \ - traefik:v3.5.0 + traefik:v3.6.1 docker network connect dokploy-network dokploy-traefik @@ -183,11 +183,11 @@ install_dokploy() { # --network dokploy-network \ # --mount type=bind,source=/etc/dokploy/traefik/traefik.yml,target=/etc/traefik/traefik.yml \ # --mount type=bind,source=/etc/dokploy/traefik/dynamic,target=/etc/dokploy/traefik/dynamic \ - # --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \ + # --mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock,readonly \ # --publish mode=host,published=443,target=443 \ # --publish mode=host,published=80,target=80 \ # --publish mode=host,published=443,target=443,protocol=udp \ - # traefik:v3.5.0 + # traefik:v3.6.1 GREEN="\033[0;32m" YELLOW="\033[1;33m" diff --git a/apps/docs/content/docs/core/multi-server/deployments.mdx b/apps/docs/content/docs/core/multi-server/deployments.mdx index 059fbac..14d3fac 100644 --- a/apps/docs/content/docs/core/multi-server/deployments.mdx +++ b/apps/docs/content/docs/core/multi-server/deployments.mdx @@ -16,6 +16,10 @@ The server setup process prepares the necessary environment for securely and eff Root access to the server is required. We currently do not support non-root deployments. + + If your remote server is configured with a different shell (other than bash), you must configure bash as the default shell, as Dokploy has been developed and tested with bash. + + + If your remote server is configured with a different shell (other than bash), you must configure bash as the default shell, as Dokploy has been developed and tested with bash. + + 2. Create an SSH key by going to `/dashboard/settings/ssh-keys` and add a new key. Be sure to copy the public key. + If you need to use files from your repository (e.g., configuration files, scripts, or directories), you **must** move them to Dokploy's file mounts and reference them manually using the Dokploy interface. This is because when using AutoDeploy, Dokploy performs a `git clone` operation on each deployment, which clears the repository directory. If you mount files directly from your repository using relative paths like `./` or `./docker/config/odoo.conf`, these files will be lost or empty in subsequent deployments, even though the first deployment may work correctly. + + +**Why this happens:** +- On the first deployment, the files exist and are mounted correctly +- On subsequent deployments, Dokploy cleans the directory and performs a fresh `git clone` +- Docker loses the reference to the files that were in the filesystem, and the new files have a new reference +- This causes mounted directories and files to be empty or missing inside the container + +**Solution:** +1. Go to **Advanced** → **Mounts** in your Docker Compose application +2. Create a new **File Mount** for each file or directory you need from your repository +3. Copy the content from your repository files into the File Mount content field +4. Specify the file path for your configuration +5. Reference the file mount in your `docker-compose.yml` using the `../files/` path: + +```yaml +volumes: + - "../files/my-config.json:/etc/my-app/config" ✅ + - "../files/my-directory:/path/in/container" ✅ +``` + +**Example:** +Instead of mounting directly from your repository: +```yaml +volumes: + - ./:/mnt/extra-addons/va_subscription_18 ❌ + - ./docker/config/odoo.conf:/etc/odoo/odoo.conf ❌ +``` + +Use Dokploy's file mounts: +```yaml +volumes: + - ../files/va_subscription_18:/mnt/extra-addons/va_subscription_18 ✅ + - ../files/odoo.conf:/etc/odoo/odoo.conf ✅ +``` + ## Logs Not Loading When Deploying to a Remote Server? There are a few potential reasons for this: @@ -204,6 +244,10 @@ volumes: - ../files/my-config.json:/etc/my-app/config ``` + + **Important for AutoDeploy users:** If you have configuration files or directories in your repository that you need to mount into your containers, you must copy their content to Dokploy's File Mounts (via Advanced → Mounts) instead of mounting them directly from the repository. This ensures the files persist across deployments, as the repository directory is cleaned and re-cloned on each AutoDeploy. + + ## Failed to initialize Docker Swarm @@ -428,11 +472,11 @@ docker run -d \ --restart always \ -v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \ -v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ - -v /var/run/docker.sock:/var/run/docker.sock \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ -p 80:80/tcp \ -p 443:443/tcp \ -p 443:443/udp \ - traefik:v3.5.0 + traefik:v3.6.1 docker network connect dokploy-network dokploy-traefik @@ -445,11 +489,11 @@ docker run -d \ --restart always \ -v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \ -v /etc/dokploy/traefik/dynamic:/etc/dokploy/traefik/dynamic \ - -v /var/run/docker.sock:/var/run/docker.sock \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ -p 80:80/tcp \ -p 443:443/tcp \ -p 443:443/udp \ - traefik:v3.5.0 + traefik:v3.6.1 ``` Remove the dokploy service: diff --git a/apps/website/README.md b/apps/website/README.md index 693a4a8..5a7f037 100644 --- a/apps/website/README.md +++ b/apps/website/README.md @@ -2,6 +2,8 @@ Main Landing Page of Dokploy +## Development + Run development server: ```bash @@ -14,9 +16,20 @@ yarn dev Open http://localhost:3000 with your browser to see the result. +## Environment Variables -For Blog Page, you can use the following command to generate the static pages: +### Required for Contact Form +``` +RESEND_API_KEY=your_resend_api_key_here +``` +### Required for HubSpot Integration (Sales Forms) +``` +HUBSPOT_PORTAL_ID=147033433 +HUBSPOT_FORM_GUID=0d788925-ef54-4fda-9b76-741fb5877056 +``` + +### Required for Blog Page ``` GHOST_URL="" GHOST_KEY="" diff --git a/apps/website/app/[locale]/[...rest]/page.tsx b/apps/website/app/[locale]/[...rest]/page.tsx deleted file mode 100644 index 4583936..0000000 --- a/apps/website/app/[locale]/[...rest]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { notFound } from "next/navigation"; - -export default function CatchAll() { - notFound(); -} diff --git a/apps/website/app/[locale]/api/og/route.ts b/apps/website/app/[locale]/api/og/route.ts deleted file mode 100644 index 7a2dc79..0000000 --- a/apps/website/app/[locale]/api/og/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { getPost } from "@/lib/ghost"; -import { generateOGImage } from "@/lib/og-image"; -import type { NextRequest } from "next/server"; - -export async function GET( - request: NextRequest, - { params }: { params: { locale: string } }, -) { - try { - const { searchParams } = new URL(request.url); - const slug = searchParams.get("slug"); - - console.log( - "Generating OG image for slug:", - slug, - "locale:", - params.locale, - ); - - if (!slug) { - console.error("Missing slug parameter"); - return new Response("Missing slug parameter", { status: 400 }); - } - - const post = await getPost(slug); - - if (!post) { - console.error("Post not found for slug:", slug); - return new Response("Post not found", { status: 404 }); - } - - console.log("Found post:", post.title); - - const formattedDate = new Date(post.published_at).toLocaleDateString( - params.locale, - { - year: "numeric", - month: "long", - day: "numeric", - }, - ); - - const ogImage = await generateOGImage({ - title: post.title, - author: post.primary_author - ? { - name: post.primary_author.name, - image: post.primary_author.profile_image || undefined, - } - : undefined, - date: formattedDate, - readingTime: post.reading_time, - }); - - console.log("Successfully generated OG image"); - - return new Response(ogImage, { - headers: { - "Content-Type": "image/png", - "Cache-Control": "public, max-age=31536000, immutable", - }, - }); - } catch (error) { - console.error("Error generating OG image:", error); - return new Response(`Error generating image: ${error}`, { status: 500 }); - } -} diff --git a/apps/website/app/[locale]/layout.tsx b/apps/website/app/[locale]/layout.tsx deleted file mode 100644 index 8a7729e..0000000 --- a/apps/website/app/[locale]/layout.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { Inter, Lexend } from "next/font/google"; -import "@/styles/tailwind.css"; -import "react-photo-view/dist/react-photo-view.css"; -import { Footer } from "@/components/Footer"; -import { Header } from "@/components/Header"; -import type { Metadata } from "next"; -import { setRequestLocale } from "next-intl/server"; - -export const metadata: Metadata = { - metadataBase: new URL("https://dokploy.com"), - title: { - default: "Dokploy - Effortless Deployment Solutions", - template: "%s | Simplify Your DevOps", - }, - icons: { - icon: "icon.svg", - apple: "apple-touch-icon.png", - }, - alternates: { - canonical: "https://dokploy.com", - languages: { - en: "https://dokploy.com", - }, - }, - description: - "Streamline your deployment process with Dokploy. Effortlessly manage applications and databases on any VPS using Docker and Traefik for improved performance and security.", - applicationName: "Dokploy", - keywords: [ - "Dokploy", - "Docker", - "Traefik", - "deployment", - "VPS", - "application management", - "database management", - "DevOps", - "cloud infrastructure", - "UI Self hosted", - ], - referrer: "origin", - robots: "index, follow", - openGraph: { - type: "website", - url: "https://dokploy.com", - title: "Dokploy - Effortless Deployment Solutions", - description: - "Simplify your DevOps with Dokploy. Deploy applications and manage databases efficiently on any VPS.", - siteName: "Dokploy", - images: [ - { - url: "https://dokploy.com/og.png", - }, - { - url: "https://dokploy.com/icon.svg", - width: 24, - height: 24, - alt: "Dokploy Logo", - }, - ], - }, - twitter: { - card: "summary_large_image", - site: "@Dokploy", - creator: "@Dokploy", - title: "Dokploy - Simplify Your DevOps", - description: - "Deploy applications and manage databases with ease using Dokploy. Learn how our platform can elevate your infrastructure management.", - images: "https://dokploy.com/og.png", - }, -}; - -export default async function RootLayout({ - children, - params, -}: { - children: React.ReactNode; - params: { locale: string }; -}) { - const { locale } = await params; - setRequestLocale(locale); - return ( -
-
- {children} -
-
- ); -} diff --git a/apps/website/app/api/contact/route.ts b/apps/website/app/api/contact/route.ts new file mode 100644 index 0000000..26469e1 --- /dev/null +++ b/apps/website/app/api/contact/route.ts @@ -0,0 +1,145 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { Resend } from "resend"; +import { submitToHubSpot, getHubSpotUTK } from "@/lib/hubspot"; + +interface ContactFormData { + inquiryType: "support" | "sales" | "other"; + firstName: string; + lastName: string; + email: string; + company: string; + message: string; +} + +export async function POST(request: NextRequest) { + try { + // Initialize Resend with API key check + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + console.error("RESEND_API_KEY is not configured"); + return NextResponse.json( + { error: "Email service not configured" }, + { status: 500 }, + ); + } + + const resend = new Resend(apiKey); + const body: ContactFormData = await request.json(); + + // Validate required fields + if ( + !body.inquiryType || + !body.firstName || + !body.lastName || + !body.email || + !body.company || + !body.message + ) { + return NextResponse.json( + { error: "All fields are required" }, + { status: 400 }, + ); + } + + // Validate email format + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(body.email)) { + return NextResponse.json( + { error: "Invalid email format" }, + { status: 400 }, + ); + } + + // Submit to HubSpot if it's a sales inquiry + if (body.inquiryType === "sales") { + try { + const hutk = getHubSpotUTK(request.headers.get("cookie") || undefined); + const hubspotSuccess = await submitToHubSpot(body, hutk); + + if (hubspotSuccess) { + console.log("Successfully submitted sales inquiry to HubSpot"); + } else { + console.warn( + "Failed to submit sales inquiry to HubSpot, but continuing with email", + ); + } + } catch (error) { + console.error("Error submitting to HubSpot:", error); + // Continue with email even if HubSpot fails + } + } + + // Format email content + const emailSubject = `[${body.inquiryType.toUpperCase()}] New contact form submission from ${body.firstName} ${body.lastName}`; + const emailBody = ` +New contact form submission: + +Type: ${body.inquiryType} +First Name: ${body.firstName} +Last Name: ${body.lastName} +Email: ${body.email} +Company: ${body.company} + +Message: +${body.message} + +--- +Sent from Dokploy website contact form + `.trim(); + + // Send email to Dokploy team + await resend.emails.send({ + from: "Dokploy Contact Form ", + to: + body.inquiryType === "sales" + ? ["sales@dokploy.com", "contact@dokploy.com"] + : ["contact@dokploy.com"], + subject: emailSubject, + text: emailBody, + replyTo: body.email, + }); + + // Send confirmation email to the user + const confirmationSubject = + "Thank you for contacting Dokploy - We received your message"; + const confirmationBody = ` +Hello ${body.firstName} ${body.lastName}, + +Thank you for reaching out to us! We have successfully received your message and our team will get back to you as soon as possible. + +Here's a summary of what you sent us: + +Subject: ${body.inquiryType.charAt(0).toUpperCase() + body.inquiryType.slice(1)} inquiry +Company: ${body.company} +Message: ${body.message} + +We typically respond within 24-48 hours during business days. If your inquiry is urgent, please don't hesitate to reach out to us directly. + +Best regards, +The Dokploy Team + +--- +This is an automated confirmation email. Please do not reply to this email. +If you need immediate assistance, contact us at contact@dokploy.com + `.trim(); + + await resend.emails.send({ + from: "Dokploy Team ", + to: [body.email], + subject: confirmationSubject, + text: confirmationBody, + }); + + return NextResponse.json( + { message: "Contact form submitted successfully" }, + { status: 200 }, + ); + } catch (error) { + console.error("Error processing contact form:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/website/app/api/github-stars/route.ts b/apps/website/app/api/github-stars/route.ts new file mode 100644 index 0000000..57c7a4d --- /dev/null +++ b/apps/website/app/api/github-stars/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; + +// Cache the result for 5 minutes to avoid rate limiting +let cachedStars: { count: number; timestamp: number } | null = null; +const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const owner = searchParams.get("owner"); + const repo = searchParams.get("repo"); + + if (!owner || !repo) { + return NextResponse.json( + { error: "Owner and repo parameters are required" }, + { status: 400 }, + ); + } + + // Check if we have a valid cached result + if ( + cachedStars && + Date.now() - cachedStars.timestamp < CACHE_DURATION + ) { + return NextResponse.json( + { stargazers_count: cachedStars.count }, + { + headers: { + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }, + ); + } + + try { + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}`, + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Dokploy-Website", + }, + }, + ); + + if (!response.ok) { + return NextResponse.json( + { error: "Failed to fetch repository data" }, + { status: response.status }, + ); + } + + const data = await response.json(); + const starCount = data.stargazers_count; + + // Cache the result + cachedStars = { + count: starCount, + timestamp: Date.now(), + }; + + return NextResponse.json( + { stargazers_count: starCount }, + { + headers: { + "Cache-Control": "public, s-maxage=300, stale-while-revalidate=600", + }, + }, + ); + } catch (error) { + console.error("Error fetching GitHub stars:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} + diff --git a/apps/website/app/api/og/route.ts b/apps/website/app/api/og/route.ts index 9883471..53cac07 100644 --- a/apps/website/app/api/og/route.ts +++ b/apps/website/app/api/og/route.ts @@ -21,7 +21,6 @@ export async function GET(request: NextRequest) { return new Response("Post not found", { status: 404 }); } - console.log("Found post:", post.title); const formattedDate = new Date(post.published_at).toLocaleDateString( "en-US", @@ -44,8 +43,6 @@ export async function GET(request: NextRequest) { readingTime: post.reading_time, }); - console.log("Successfully generated OG image"); - return new Response(ogImage, { headers: { "Content-Type": "image/png", diff --git a/apps/website/app/[locale]/blog/[slug]/components/CodeBlock.tsx b/apps/website/app/blog/[slug]/components/CodeBlock.tsx similarity index 80% rename from apps/website/app/[locale]/blog/[slug]/components/CodeBlock.tsx rename to apps/website/app/blog/[slug]/components/CodeBlock.tsx index 0cee0a2..da8be4e 100644 --- a/apps/website/app/[locale]/blog/[slug]/components/CodeBlock.tsx +++ b/apps/website/app/blog/[slug]/components/CodeBlock.tsx @@ -18,9 +18,7 @@ interface CodeBlockProps { async function formatCode(code: string, lang: string) { try { let parser: string; - let plugins = []; - - // Select parser and plugins based on language + let plugins = [] as any[]; switch (lang.toLowerCase()) { case "yaml": case "yml": @@ -35,12 +33,8 @@ async function formatCode(code: string, lang: string) { plugins = [babel, estree]; break; default: - // For unsupported languages, return the original code return code; } - - console.log(`Formatting ${lang} with parser:`, parser); - const formatted = await prettier.format(code, { parser, plugins, @@ -50,12 +44,10 @@ async function formatCode(code: string, lang: string) { useTabs: false, printWidth: 120, }); - - console.log("Formatted code:", formatted); return formatted; } catch (error) { console.error("Error formatting code:", error); - return code; // Return original code if there's an error + return code; } } @@ -66,22 +58,15 @@ export function CodeBlock({ code, lang, initial }: CodeBlockProps) { useLayoutEffect(() => { async function formatAndHighlight() { try { - console.log("Original code:", code); - console.log("Language:", lang); const formatted = await formatCode(code, lang); setFormattedCode(formatted); - - // Then highlight the formatted code const highlighted = await highlight(formatted, lang); setNodes(highlighted); } catch (error) { - console.error("Error in formatAndHighlight:", error); - // If formatting fails, try to highlight the original code const highlighted = await highlight(code, lang); setNodes(highlighted); } } - void formatAndHighlight(); }, [code, lang]); diff --git a/apps/website/app/[locale]/blog/[slug]/components/Headings.tsx b/apps/website/app/blog/[slug]/components/Headings.tsx similarity index 87% rename from apps/website/app/[locale]/blog/[slug]/components/Headings.tsx rename to apps/website/app/blog/[slug]/components/Headings.tsx index 540c247..cc6ac6d 100644 --- a/apps/website/app/[locale]/blog/[slug]/components/Headings.tsx +++ b/apps/website/app/blog/[slug]/components/Headings.tsx @@ -29,15 +29,10 @@ function LinkIcon() { export function H1({ children, ...props }: HeadingProps) { const router = useRouter(); - const id = slugify(children?.toString() || "", { - lower: true, - strict: true, - }); - + const id = slugify(children?.toString() || "", { lower: true, strict: true }); const handleClick = () => { router.push(`#${id}`); }; - return (

{ router.push(`#${id}`); }; - return (

{ router.push(`#${id}`); }; - return (

observer.disconnect(); @@ -48,31 +46,25 @@ export function TableOfContents() {

Table of Contents

    {headings.length > 0 ? ( - <> - {headings.map((heading) => ( -
  • ( +
  • + { + e.preventDefault(); + document + .getElementById(heading.id) + ?.scrollIntoView({ behavior: "smooth" }); + }} + className={`hover:text-primary transition-colors block ${activeId === heading.id ? "text-primary font-medium" : "text-muted-foreground"}`} > - { - e.preventDefault(); - document.getElementById(heading.id)?.scrollIntoView({ - behavior: "smooth", - }); - }} - className={`hover:text-primary transition-colors block ${ - activeId === heading.id - ? "text-primary font-medium" - : "text-muted-foreground" - }`} - > - {heading.text} - -
  • - ))} - + {heading.text} + + + )) ) : (
  • No headings found

    diff --git a/apps/website/app/[locale]/blog/[slug]/components/ZoomableImage.tsx b/apps/website/app/blog/[slug]/components/ZoomableImage.tsx similarity index 99% rename from apps/website/app/[locale]/blog/[slug]/components/ZoomableImage.tsx rename to apps/website/app/blog/[slug]/components/ZoomableImage.tsx index 702c342..7b97960 100644 --- a/apps/website/app/[locale]/blog/[slug]/components/ZoomableImage.tsx +++ b/apps/website/app/blog/[slug]/components/ZoomableImage.tsx @@ -3,6 +3,7 @@ import { cn } from "@/lib/utils"; import { PhotoProvider, PhotoView } from "react-photo-view"; import "react-photo-view/dist/react-photo-view.css"; + interface ZoomableImageProps { src: string; alt: string; diff --git a/apps/website/app/[locale]/blog/[slug]/components/shared.ts b/apps/website/app/blog/[slug]/components/shared.ts similarity index 84% rename from apps/website/app/[locale]/blog/[slug]/components/shared.ts rename to apps/website/app/blog/[slug]/components/shared.ts index 377f2ca..4e6096b 100644 --- a/apps/website/app/[locale]/blog/[slug]/components/shared.ts +++ b/apps/website/app/blog/[slug]/components/shared.ts @@ -10,10 +10,5 @@ export async function highlight(code: string, lang: BundledLanguage) { lang, theme: "houston", }); - - return toJsxRuntime(out, { - Fragment, - jsx, - jsxs, - }) as JSX.Element; + return toJsxRuntime(out, { Fragment, jsx, jsxs }) as JSX.Element; } diff --git a/apps/website/app/[locale]/blog/[slug]/page.tsx b/apps/website/app/blog/[slug]/page.tsx similarity index 90% rename from apps/website/app/[locale]/blog/[slug]/page.tsx rename to apps/website/app/blog/[slug]/page.tsx index 95a95db..03e63cf 100644 --- a/apps/website/app/[locale]/blog/[slug]/page.tsx +++ b/apps/website/app/blog/[slug]/page.tsx @@ -1,6 +1,5 @@ import { getPost, getPosts } from "@/lib/ghost"; import type { Metadata, ResolvingMetadata } from "next"; -import { getTranslations } from "next-intl/server"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; @@ -18,15 +17,16 @@ import { CodeBlock } from "./components/CodeBlock"; import { H1, H2, H3 } from "./components/Headings"; import { TableOfContents } from "./components/TableOfContents"; import { ZoomableImage } from "./components/ZoomableImage"; + type Props = { - params: { locale: string; slug: string }; + params: { slug: string }; }; export async function generateMetadata( { params }: Props, parent: ResolvingMetadata, ): Promise { - const { locale, slug } = await params; + const { slug } = await params; const post = await getPost(slug); if (!post) { @@ -36,7 +36,7 @@ export async function generateMetadata( } const ogUrl = new URL( - `/${locale}/api/og`, + `/api/og`, process.env.NODE_ENV === "production" ? "https://dokploy.com" : "http://localhost:3000", @@ -69,51 +69,29 @@ export async function generateMetadata( }; } -// export async function generateStaticParams() { -// const posts = await getPosts(); -// const locales = ["en", "fr", "es", "zh-Hans"]; - -// return posts.flatMap((post) => -// locales.map((locale) => ({ -// locale, -// slug: post.slug, -// })), -// ); -// } - export default async function BlogPostPage({ params }: Props) { const { slug } = await params; - const t = await getTranslations("blog"); const post = await getPost(slug); const allPosts = await getPosts(); - // Get related posts (excluding current post) - const relatedPosts = allPosts.filter((p) => p.id !== post?.id).slice(0, 3); // Show only 3 related posts + const relatedPosts = allPosts.filter((p) => p.id !== post?.id).slice(0, 3); if (!post) { notFound(); } - // Limpiar HTML antes de convertir a Markdown const cleanHtml = (html: string) => { - // Crear un DOM temporal para limpiar el HTML if (typeof window !== "undefined") { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); - - // Remover scripts JSON-LD y otros scripts const scripts = doc.querySelectorAll( 'script[type="application/ld+json"], script', ); scripts.forEach((script) => script.remove()); - - // Remover otros elementos no deseados const unwantedElements = doc.querySelectorAll("style, meta, link"); unwantedElements.forEach((el) => el.remove()); - return doc.body.innerHTML; } else { - // Fallback para servidor - usar regex para limpiar return html .replace( /]*type="application\/ld\+json"[^>]*>[\s\S]*?<\/script>/gi, @@ -126,7 +104,6 @@ export default async function BlogPostPage({ params }: Props) { } }; - // Convertir HTML a Markdown const turndownService = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", @@ -219,8 +196,6 @@ export default async function BlogPostPage({ params }: Props) { children, inline, }: { className: string; children: React.ReactNode; inline: boolean }) => { - console.log(className, children, inline); - // Si es código inline (no tiene className con language-*), renderizar como span if (inline || !className || !/language-(\w+)/.test(className)) { return ( @@ -228,8 +203,6 @@ export default async function BlogPostPage({ params }: Props) { ); } - - // Si es un bloque de código, usar CodeBlock const match = /language-(\w+)/.exec(className); return ( - {t("backToBlog")} + Back to Blog
    @@ -338,7 +311,7 @@ export default async function BlogPostPage({ params }: Props) { {post.tags && post.tags.length > 0 && (
    -

    {t("tags")}

    +

    Tags

    {post.tags.map((tag) => ( 0 && (
    -

    {t("relatedPosts")}

    +

    Related Posts

    {relatedPosts.map((relatedPost) => { const relatedPostDate = new Date( diff --git a/apps/website/app/[locale]/blog/components/BlogPostCard.tsx b/apps/website/app/blog/components/BlogPostCard.tsx similarity index 96% rename from apps/website/app/[locale]/blog/components/BlogPostCard.tsx rename to apps/website/app/blog/components/BlogPostCard.tsx index eeaa171..c610dd8 100644 --- a/apps/website/app/[locale]/blog/components/BlogPostCard.tsx +++ b/apps/website/app/blog/components/BlogPostCard.tsx @@ -3,14 +3,14 @@ import type { Post } from "@/lib/ghost"; import Link from "next/link"; import { useRouter } from "next/navigation"; + interface BlogPostCardProps { post: Post; - locale: string; } -export function BlogPostCard({ post, locale }: BlogPostCardProps) { +export function BlogPostCard({ post }: BlogPostCardProps) { const router = useRouter(); - const formattedDate = new Date(post.published_at).toLocaleDateString(locale, { + const formattedDate = new Date(post.published_at).toLocaleDateString("en", { year: "numeric", month: "long", day: "numeric", diff --git a/apps/website/app/[locale]/blog/components/SearchAndFilter.tsx b/apps/website/app/blog/components/SearchAndFilter.tsx similarity index 100% rename from apps/website/app/[locale]/blog/components/SearchAndFilter.tsx rename to apps/website/app/blog/components/SearchAndFilter.tsx diff --git a/apps/website/app/[locale]/blog/page.tsx b/apps/website/app/blog/page.tsx similarity index 72% rename from apps/website/app/[locale]/blog/page.tsx rename to apps/website/app/blog/page.tsx index cffd450..e389b29 100644 --- a/apps/website/app/[locale]/blog/page.tsx +++ b/apps/website/app/blog/page.tsx @@ -2,10 +2,10 @@ import { getPosts, getTags } from "@/lib/ghost"; import type { Post } from "@/lib/ghost"; import { RssIcon } from "lucide-react"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { BlogPostCard } from "./components/BlogPostCard"; import { SearchAndFilter } from "./components/SearchAndFilter"; + interface Tag { id: string; name: string; @@ -13,20 +13,16 @@ interface Tag { } export const metadata: Metadata = { - title: "Blog | Dokploy", + title: "Blog", description: "Latest news, updates, and articles from Dokploy", }; export default async function BlogPage({ - params, searchParams, }: { - params: { locale: string }; searchParams: { [key: string]: string | string[] | undefined }; }) { - const { locale } = await params; const searchParams2 = await searchParams; - const t = await getTranslations("blog"); const posts = await getPosts(); const tags = (await getTags()) as Tag[]; const search = @@ -63,24 +59,24 @@ export default async function BlogPage({
    - + - {filteredPosts.length === 0 ? ( -
    -

    - {search || selectedTag ? t("noResults") : t("noPosts")} -

    -
    - ) : ( + {filteredPosts.length === 0 ? ( +
    +

    + {search || selectedTag ? "No posts found matching your criteria" : "No posts available"} +

    +
    + ) : (
    {filteredPosts.map((post: Post) => ( - + ))}
    )} diff --git a/apps/website/app/[locale]/blog/tag/[tag]/page.tsx b/apps/website/app/blog/tag/[tag]/page.tsx similarity index 82% rename from apps/website/app/[locale]/blog/tag/[tag]/page.tsx rename to apps/website/app/blog/tag/[tag]/page.tsx index c8806e5..1fd1913 100644 --- a/apps/website/app/[locale]/blog/tag/[tag]/page.tsx +++ b/apps/website/app/blog/tag/[tag]/page.tsx @@ -1,43 +1,47 @@ import { getPostsByTag, getTags } from "@/lib/ghost"; import type { Post } from "@/lib/ghost"; import type { Metadata } from "next"; -import { getTranslations } from "next-intl/server"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; type Props = { - params: { locale: string; tag: string }; + params: { tag: string }; }; export async function generateMetadata({ params }: Props): Promise { const { tag } = await params; - const t = await getTranslations("blog"); + const posts = await getPostsByTag(tag); + + if (!posts || posts.length === 0) { + return { + title: "Tag Not Found", + description: "The requested tag could not be found", + }; + } + + const tagName = + posts[0].tags?.find((t: { slug: string }) => t.slug === tag)?.name || tag; return { - title: `${t("tagTitle", { tag })}`, - description: t("tagDescription", { tag }), + title: `${tagName} Posts`, + description: `Browse all posts tagged with ${tagName}`, }; } export async function generateStaticParams() { const tags = await getTags(); - - return tags.map((tag: { slug: string }) => ({ - tag: tag.slug, - })); + return tags.map((tag: { slug: string }) => ({ tag: tag.slug })); } export default async function TagPage({ params }: Props) { const { tag } = await params; - const t = await getTranslations("blog"); const posts = await getPostsByTag(tag); if (!posts || posts.length === 0) { notFound(); } - // Get the tag name from the first post const tagName = posts[0].tags?.find((t: { slug: string }) => t.slug === tag)?.name || tag; @@ -59,30 +63,30 @@ export default async function TagPage({ params }: Props) { clipRule="evenodd" /> - {t("backToBlog")} + Back to Blog

    - {t("postsTaggedWith")}{" "} + Posts tagged with{" "} "{tagName}"

    - {t("foundPosts", { count: posts.length })} + {posts.length} {posts.length === 1 ? 'post' : 'posts'} found

    {posts.map((post: Post) => ( - + ))}
    ); } -function BlogPostCard({ post, locale }: { post: Post; locale: string }) { - const formattedDate = new Date(post.published_at).toLocaleDateString(locale, { +function BlogPostCard({ post }: { post: Post }) { + const formattedDate = new Date(post.published_at).toLocaleDateString("en", { year: "numeric", month: "long", day: "numeric", diff --git a/apps/website/app/[locale]/_changelog/page.tsx b/apps/website/app/changelog/page.tsx similarity index 51% rename from apps/website/app/[locale]/_changelog/page.tsx rename to apps/website/app/changelog/page.tsx index f6b1b6d..8acfde8 100644 --- a/apps/website/app/[locale]/_changelog/page.tsx +++ b/apps/website/app/changelog/page.tsx @@ -1,6 +1,11 @@ -// import { ScrollArea } from "@/components/ui/scroll-area"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Changelog", + description: + "Stay updated with the latest changes, improvements, and features in Dokploy", +}; -// Datos de ejemplo del changelog const changelogEntries = [ { date: "2023-11-01", @@ -57,7 +62,7 @@ const changelogEntries = [ }, ]; -const Comp = () => { +export default function ChangelogPage() { return (
    @@ -77,7 +82,7 @@ const Comp = () => {
    -
    +

    Changelog

    {changelogEntries.map((entry, index) => ( @@ -115,64 +120,5 @@ const Comp = () => {
    ); -}; +} -// export default function Changelog() { -// return ( -//
    -//
    -// -// -// -// -// -// -// -// -// -// -// -// -// -// -// -//
    -//
    -//

    Changelog

    -//
    -// {changelogEntries.map((entry, index) => ( -//
    -//
    -// -// {entry.date} -// -//

    {entry.title}

    -//
    -//
    -//
    -// {`Imagen -//
    - -//
      -// {entry.changes.map((change, changeIndex) => ( -//
    • -// {change} -//
    • -// ))} -//
    -//
    -//
    -// ))} -//
    -//
    -//
    -// ); -// } diff --git a/apps/website/app/contact/layout.tsx b/apps/website/app/contact/layout.tsx new file mode 100644 index 0000000..fab9665 --- /dev/null +++ b/apps/website/app/contact/layout.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +export const metadata: Metadata = { + title: "Contact Us", + description: + "Get in touch with our team. We're here to help with any questions about Dokploy.", +}; + +export default function ContactLayout({ children }: { children: ReactNode }) { + return <>{children}; +} + diff --git a/apps/website/app/contact/page.tsx b/apps/website/app/contact/page.tsx new file mode 100644 index 0000000..7c2343e --- /dev/null +++ b/apps/website/app/contact/page.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useState } from "react"; +import { Container } from "@/components/Container"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { trackGAEvent } from "@/components/analitycs"; +import AnimatedGridPattern from "@/components/ui/animated-grid-pattern"; +import { cn } from "@/lib/utils"; + +interface ContactFormData { + inquiryType: "" | "support" | "sales" | "other"; + firstName: string; + lastName: string; + email: string; + company: string; + message: string; +} + +export default function ContactPage() { + const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [formData, setFormData] = useState({ + inquiryType: "", + firstName: "", + lastName: "", + email: "", + company: "", + message: "", + }); + const [errors, setErrors] = useState>({}); + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!formData.inquiryType) { + newErrors.inquiryType = "Please select what we can help you with"; + } + if (!formData.firstName.trim()) { + newErrors.firstName = "First name is required"; + } + if (!formData.lastName.trim()) { + newErrors.lastName = "Last name is required"; + } + if (!formData.email.trim()) { + newErrors.email = "Email is required"; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + newErrors.email = "Please enter a valid email address"; + } + if (!formData.company.trim()) { + newErrors.company = "Company name is required"; + } + if (!formData.message.trim()) { + newErrors.message = "Message is required"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + + try { + const response = await fetch("/api/contact", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + if (response.ok) { + trackGAEvent({ + action: "Contact Form Submitted", + category: "Contact", + label: formData.inquiryType, + }); + + setFormData({ + inquiryType: "", + firstName: "", + lastName: "", + email: "", + company: "", + message: "", + }); + setErrors({}); + setIsSubmitted(true); + } else { + throw new Error("Failed to submit form"); + } + } catch (error) { + console.error("Error submitting form:", error); + alert("There was an error sending your message. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleInputChange = (field: keyof ContactFormData, value: any) => { + setFormData((prev) => ({ ...prev, [field]: value })); + if (errors[field]) { + setErrors((prev) => { + const newErrors = { ...prev }; + delete newErrors[field]; + return newErrors; + }); + } + }; + + if (isSubmitted) { + return ( +
    + +
    +

    + Thank you for contacting us! +

    +

    + We've received your message and will get back to you as soon as + possible. +

    +
    + +
    +
    +
    +
    + ); + } + + return ( +
    + + +
    +
    +

    + Contact Us +

    +

    + Get in touch with our team. We're here to help with any questions + about Dokploy. +

    +
    + +
    +
    + + + {errors.inquiryType && ( +

    {errors.inquiryType}

    + )} +
    + +
    +
    + + + handleInputChange("firstName", e.target.value) + } + placeholder="Your first name" + /> + {errors.firstName && ( +

    {errors.firstName}

    + )} +
    + +
    + + + handleInputChange("lastName", e.target.value) + } + placeholder="Your last name" + /> + {errors.lastName && ( +

    {errors.lastName}

    + )} +
    +
    + +
    + + handleInputChange("email", e.target.value)} + placeholder="your.email@company.com" + /> + {errors.email && ( +

    {errors.email}

    + )} +
    + +
    + + handleInputChange("company", e.target.value)} + placeholder="Your company name" + /> + {errors.company && ( +

    {errors.company}

    + )} +
    + +
    + +