Merge branch 'canary' into filebrowser

This commit is contained in:
Mauricio Siu 2024-10-27 02:49:02 -06:00 committed by GitHub
commit 42b3db37ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 5224 additions and 611 deletions

View File

@ -73,7 +73,9 @@ jobs:
siumauricio/cloud:${{ github.ref_name == 'main' && 'latest' || 'canary' }}
platforms: linux/amd64
build-args: |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ github.ref_name == 'main' && 'pk_live_51QAm7bF3cxQuHeOzMpfNfJIch6oLif8rS32pRE392CdTbBf0MYBdbapAxarQGspqJBWT2nVOxu8e6ZHrHB4NhVHG008DE2A90d' || 'pk_test_51QAm7bF3cxQuHeOz0xg04o9teeyTbbNHQPJ5Tr98MlTEan9MzewT3gwh0jSWBNvrRWZ5vASoBgxUSF4gPWsJwATk00Ir2JZ0S1' }}
NEXT_PUBLIC_UMAMI_HOST=${{ secrets.NEXT_PUBLIC_UMAMI_HOST }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
build-and-push-schedule-image:
runs-on: ubuntu-latest

View File

@ -14,8 +14,15 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --filter=@dokploy/server
# Deploy only the dokploy app
ARG NEXT_PUBLIC_UMAMI_HOST
ENV NEXT_PUBLIC_UMAMI_HOST=$NEXT_PUBLIC_UMAMI_HOST
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
ENV NODE_ENV=production
RUN pnpm --filter=@dokploy/server build
RUN pnpm --filter=./apps/dokploy run build

View File

@ -14,6 +14,7 @@ import {
PlugZapIcon,
TerminalIcon,
} from "lucide-react";
import Script from "next/script";
const inter = Inter({
subsets: ["latin"],
});
@ -63,6 +64,10 @@ export default function Layout({
className={inter.className}
suppressHydrationWarning
>
<Script
src="https://umami.dokploy.com/script.js"
data-website-id="6ad2aa56-6d38-4f39-97a8-1a8fcdda8d51"
/>
<GoogleAnalytics />
<body>
<I18nProvider

View File

@ -6,11 +6,18 @@ import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { loadStripe } from "@stripe/stripe-js";
import clsx from "clsx";
import { AlertTriangle, CheckIcon, MinusIcon, PlusIcon } from "lucide-react";
import {
AlertTriangle,
CheckIcon,
Loader2,
MinusIcon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
import React, { useState } from "react";
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || "",
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
);
export const calculatePrice = (count: number, isAnnual = false) => {
@ -24,7 +31,7 @@ export const calculatePrice = (count: number, isAnnual = false) => {
export const ShowBilling = () => {
const { data: servers } = api.server.all.useQuery(undefined);
const { data: admin } = api.admin.one.useQuery();
const { data } = api.stripe.getProducts.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery();
const { mutateAsync: createCheckoutSession } =
api.stripe.createCheckoutSession.useMutation();
@ -96,145 +103,186 @@ export const ShowBilling = () => {
)}
</div>
)}
{products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first bg-black border py-8 lg:order-none"
: "lg:py-8",
)}
<div className="flex flex-col gap-1.5 mt-4">
<span className="text-base text-primary">
Need Help? We are here to help you.
</span>
<span className="text-sm text-muted-foreground">
Join to our Discord server and we will help you.
</span>
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
<Link
href="https://discord.gg/2tBnJ3jDJc"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className=" text-2xl font-semibold tracking-tight text-primary ">
$ {calculatePrice(serverQuantity, isAnnual).toFixed(2)} USD
</p>
|
<p className=" text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(calculatePrice(serverQuantity, isAnnual) / 12).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
<p className=" text-2xl font-semibold tracking-tight text-primary ">
$ {calculatePrice(serverQuantity, isAnnual).toFixed(2)} USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-white">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
</p>
<ul
role="list"
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li key={feature} className="flex text-muted-foreground">
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
<Button
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
}}
>
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(e.target.value as unknown as number);
}}
/>
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
<div
className={cn(
data?.subscriptions && data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
Join Discord
</Link>
</Button>
</div>
{isLoading ? (
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center justify-center min-h-[10vh]">
Loading...
<Loader2 className="animate-spin" />
</span>
) : (
<>
{products?.map((product) => {
const featured = true;
return (
<div key={product.id}>
<section
className={clsx(
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
featured
? "order-first border py-8 lg:order-none"
: "lg:py-8",
)}
>
{admin?.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
{isAnnual ? (
<div className="flex flex-row gap-2 items-center">
<p className=" text-2xl font-semibold tracking-tight text-primary ">
$ {calculatePrice(serverQuantity, isAnnual).toFixed(2)}{" "}
USD
</p>
|
<p className=" text-base font-semibold tracking-tight text-muted-foreground">
${" "}
{(
calculatePrice(serverQuantity, isAnnual) / 12
).toFixed(2)}{" "}
/ Month USD
</p>
</div>
) : (
<p className=" text-2xl font-semibold tracking-tight text-primary ">
$ {calculatePrice(serverQuantity, isAnnual).toFixed(2)}{" "}
USD
</p>
)}
<h3 className="mt-5 font-medium text-lg text-primary">
{product.name}
</h3>
<p
className={clsx(
"text-sm",
featured ? "text-white" : "text-slate-400",
)}
>
{product.description}
</p>
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<ul
role="list"
className={clsx(
" mt-4 flex flex-col gap-y-2 text-sm",
featured ? "text-white" : "text-slate-200",
)}
>
{[
"All the features of Dokploy",
"Unlimited deployments",
"Self-hosted on your own infrastructure",
"Full access to all deployment features",
"Dokploy integration",
"Backups",
"All Incoming features",
].map((feature) => (
<li key={feature} className="flex text-muted-foreground">
<CheckIcon />
<span className="ml-4">{feature}</span>
</li>
))}
</ul>
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center gap-2 justify-center">
<span className="text-sm text-muted-foreground">
{serverQuantity} Servers
</span>
</div>
<div className="flex items-center space-x-2">
<Button
className="w-full"
onClick={async () => {
handleCheckout(product.id);
disabled={serverQuantity <= 1}
variant="outline"
onClick={() => {
if (serverQuantity <= 1) return;
setServerQuantity(serverQuantity - 1);
}}
disabled={serverQuantity < 1}
>
Subscribe
<MinusIcon className="h-4 w-4" />
</Button>
<NumberInput
value={serverQuantity}
onChange={(e) => {
setServerQuantity(
e.target.value as unknown as number,
);
}}
/>
<Button
variant="outline"
onClick={() => {
setServerQuantity(serverQuantity + 1);
}}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
)}
</div>
<div
className={cn(
data?.subscriptions && data?.subscriptions?.length > 0
? "justify-between"
: "justify-end",
"flex flex-row items-center gap-2 mt-4",
)}
>
{admin?.stripeCustomerId && (
<Button
variant="secondary"
className="w-full"
onClick={async () => {
const session = await createCustomerPortalSession();
window.open(session.url);
}}
>
Manage Subscription
</Button>
)}
{data?.subscriptions?.length === 0 && (
<div className="justify-end w-full">
<Button
className="w-full"
onClick={async () => {
handleCheckout(product.id);
}}
disabled={serverQuantity < 1}
>
Subscribe
</Button>
</div>
)}
</div>
</div>
</section>
</div>
</section>
</div>
);
})}
);
})}
</>
)}
</div>
);
};

View File

@ -52,8 +52,6 @@ export const ProfileForm = () => {
const { data, refetch } = api.auth.get.useQuery();
const { mutateAsync, isLoading } = api.auth.update.useMutation();
const { mutateAsync: generateToken, isLoading: isLoadingToken } =
api.auth.generateToken.useMutation();
const form = useForm<Profile>({
defaultValues: {
email: data?.email || "",
@ -76,7 +74,7 @@ export const ProfileForm = () => {
const onSubmit = async (values: Profile) => {
await mutateAsync({
email: values.email,
email: values.email.toLowerCase(),
password: values.password,
image: values.image,
})

View File

@ -54,7 +54,7 @@ export const AddUser = () => {
const onSubmit = async (data: AddUser) => {
await mutateAsync({
email: data.email,
email: data.email.toLowerCase(),
})
.then(async () => {
toast.success("Invitation created");

View File

@ -1,162 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { SquarePen } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const updateUserSchema = z.object({
email: z
.string()
.min(1, "Email is required")
.email({ message: "Invalid email" }),
password: z.string(),
});
type UpdateUser = z.infer<typeof updateUserSchema>;
interface Props {
authId: string;
}
export const UpdateUser = ({ authId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, error, isError, isLoading } =
api.auth.updateByAdmin.useMutation();
const { data } = api.auth.one.useQuery(
{
id: authId,
},
{
enabled: !!authId,
},
);
const form = useForm<UpdateUser>({
defaultValues: {
email: "",
password: "",
},
resolver: zodResolver(updateUserSchema),
});
useEffect(() => {
if (data) {
form.reset({
email: data.email || "",
password: "",
});
}
}, [data, form, form.reset]);
const onSubmit = async (formData: UpdateUser) => {
await mutateAsync({
email: formData.email === data?.email ? null : formData.email,
password: formData.password,
id: authId,
})
.then(() => {
toast.success("User updated succesfully");
utils.user.all.invalidate();
})
.catch(() => {
toast.error("Error to update the user");
})
.finally(() => {});
};
return (
<Dialog>
<DialogTrigger asChild className="w-fit">
<Button
variant="ghost"
className=" cursor-pointer space-x-3 w-fit"
onSelect={(e) => e.preventDefault()}
>
<SquarePen className="size-4 text-muted-foreground" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update User</DialogTitle>
<DialogDescription>Update the user</DialogDescription>
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<div className="grid gap-4">
<div className="grid items-center gap-4">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
id="hook-form-update-user"
className="grid w-full gap-4 "
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="XNl5C@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="*******"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
form="hook-form-update-user"
type="submit"
isLoading={isLoading}
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -0,0 +1,2 @@
ALTER TABLE "auth" ADD COLUMN "confirmationToken" text;--> statement-breakpoint
ALTER TABLE "auth" ADD COLUMN "confirmationExpiresAt" text;

File diff suppressed because it is too large Load Diff

View File

@ -295,6 +295,13 @@
"when": 1729667438853,
"tag": "0041_huge_bruce_banner",
"breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1729984439862,
"tag": "0042_fancy_havok",
"breakpoints": true
}
]
}

View File

@ -2,7 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL || "";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql);

View File

@ -7,6 +7,7 @@ import { ThemeProvider } from "next-themes";
import type { AppProps } from "next/app";
import { Inter } from "next/font/google";
import Head from "next/head";
import Script from "next/script";
import type { ReactElement, ReactNode } from "react";
const inter = Inter({ subsets: ["latin"] });
@ -36,6 +37,14 @@ const MyApp = ({
<Head>
<title>Dokploy</title>
</Head>
{process.env.NEXT_PUBLIC_UMAMI_HOST &&
process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
<Script
src={process.env.NEXT_PUBLIC_UMAMI_HOST}
data-website-id={process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID}
/>
)}
<ThemeProvider
attribute="class"
defaultTheme="system"

View File

@ -3,8 +3,9 @@ import { Head, Html, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en" className="font-sans">
<Head />
<Head>
<link rel="icon" href="/icon.svg" />
</Head>
<body className="flex h-full flex-col font-sans">
<Main />
<NextScript />

View File

@ -6,7 +6,7 @@ import { asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET || "";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export const config = {
api: {
@ -21,7 +21,7 @@ export default async function handler(
if (!endpointSecret) {
return res.status(400).send("Webhook Error: Missing Stripe Secret Key");
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
maxNetworkRetries: 3,
});

View File

@ -0,0 +1,96 @@
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { Logo } from "@/components/shared/logo";
import { CardDescription, CardTitle } from "@/components/ui/card";
import { db } from "@/server/db";
import { auth } from "@/server/db/schema";
import { IS_CLOUD, updateAuthById } from "@dokploy/server";
import { isBefore } from "date-fns";
import { eq } from "drizzle-orm";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import type { ReactElement } from "react";
export default function Home() {
return (
<div className="flex h-screen w-full items-center justify-center ">
<div className="flex flex-col items-center gap-4 w-full">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo />
<span className="font-medium text-sm">Dokploy</span>
</Link>
<CardTitle className="text-2xl font-bold">Email Confirmed</CardTitle>
<CardDescription>
Congratulations, your email is confirmed.
</CardDescription>
<div>
<Link href="/" className="w-full text-primary">
Click here to login
</Link>
</div>
</div>
</div>
);
}
Home.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const { token } = context.query;
if (typeof token !== "string") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const authR = await db.query.auth.findFirst({
where: eq(auth.confirmationToken, token),
});
if (
!authR ||
authR?.confirmationToken === null ||
authR?.confirmationExpiresAt === null
) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const isExpired = isBefore(new Date(authR.confirmationExpiresAt), new Date());
if (isExpired) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
await updateAuthById(authR.id, {
confirmationToken: null,
confirmationExpiresAt: null,
});
return {
props: {
token: authR.confirmationToken,
},
};
}

View File

@ -1,5 +1,6 @@
import { Login2FA } from "@/components/auth/login-2fa";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import {
@ -63,7 +64,8 @@ export default function Home({ IS_CLOUD }: Props) {
is2FAEnabled: false,
authId: "",
});
const { mutateAsync, isLoading } = api.auth.login.useMutation();
const { mutateAsync, isLoading, error, isError } =
api.auth.login.useMutation();
const router = useRouter();
const form = useForm<Login>({
defaultValues: {
@ -115,6 +117,12 @@ export default function Home({ IS_CLOUD }: Props) {
</CardDescription>
<Card className="mx-auto w-full max-w-lg bg-transparent ">
<div className="p-3.5" />
{isError && (
<AlertBlock type="error" className="mx-4 my-2">
<span>{error?.message}</span>
</AlertBlock>
)}
<CardContent>
{!temp.is2FAEnabled ? (
<Form {...form}>
@ -170,12 +178,14 @@ export default function Home({ IS_CLOUD }: Props) {
<div className="flex flex-row justify-between flex-wrap">
<div className="mt-4 text-center text-sm flex flex-row justify-center gap-2">
<Link
className="hover:underline text-muted-foreground"
href="/register"
>
Create an account
</Link>
{IS_CLOUD && (
<Link
className="hover:underline text-muted-foreground"
href="/register"
>
Create an account
</Link>
)}
</div>
<div className="mt-4 text-sm flex flex-row justify-center gap-2">

View File

@ -16,7 +16,7 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { getUserByToken } from "@dokploy/server";
import { IS_CLOUD, getUserByToken } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
@ -69,9 +69,10 @@ type Register = z.infer<typeof registerSchema>;
interface Props {
token: string;
invitation: Awaited<ReturnType<typeof getUserByToken>>;
isCloud: boolean;
}
const Invitation = ({ token, invitation }: Props) => {
const Invitation = ({ token, invitation, isCloud }: Props) => {
const router = useRouter();
const { data } = api.admin.getUserByToken.useQuery(
{
@ -83,7 +84,8 @@ const Invitation = ({ token, invitation }: Props) => {
},
);
const { mutateAsync, error, isError } = api.auth.createUser.useMutation();
const { mutateAsync, error, isError, isSuccess } =
api.auth.createUser.useMutation();
const form = useForm<Register>({
defaultValues: {
@ -112,7 +114,9 @@ const Invitation = ({ token, invitation }: Props) => {
})
.then(() => {
toast.success("User registration succesfuly", {
duration: 2000,
description:
"Please check your inbox or spam folder to confirm your account.",
duration: 100000,
});
router.push("/dashboard/projects");
})
@ -146,6 +150,7 @@ const Invitation = ({ token, invitation }: Props) => {
</span>
</div>
)}
<CardContent>
<Form {...form}>
<form
@ -210,6 +215,25 @@ const Invitation = ({ token, invitation }: Props) => {
Register
</Button>
</div>
<div className="mt-4 text-sm flex flex-row justify-between gap-2 w-full">
{isCloud && (
<>
<Link
className="hover:underline text-muted-foreground"
href="/"
>
Login
</Link>
<Link
className="hover:underline text-muted-foreground"
href="/send-reset-password"
>
Lost your password?
</Link>
</>
)}
</div>
</form>
</Form>
</CardContent>
@ -250,6 +274,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
return {
props: {
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
},

View File

@ -1,3 +1,4 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import {
@ -72,7 +73,8 @@ interface Props {
const Register = ({ isCloud }: Props) => {
const router = useRouter();
const { mutateAsync, error, isError } = api.auth.createAdmin.useMutation();
const { mutateAsync, error, isError, data } =
api.auth.createAdmin.useMutation();
const form = useForm<Register>({
defaultValues: {
@ -89,14 +91,16 @@ const Register = ({ isCloud }: Props) => {
const onSubmit = async (values: Register) => {
await mutateAsync({
email: values.email,
email: values.email.toLowerCase(),
password: values.password,
})
.then(() => {
toast.success("User registration succesfuly", {
duration: 2000,
});
router.push("/");
if (!isCloud) {
router.push("/");
}
})
.catch((e) => e);
};
@ -130,6 +134,14 @@ const Register = ({ isCloud }: Props) => {
</span>
</div>
)}
{data && (
<AlertBlock type="success" className="mx-4 my-2">
<span>
Registration succesfuly, Please check your inbox or spam
folder to confirm your account.
</span>
</AlertBlock>
)}
<CardContent>
<Form {...form}>
<form

View File

@ -17,6 +17,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { db } from "@/server/db";
import { auth } from "@/server/db/schema";
import { api } from "@/utils/api";
import { IS_CLOUD } from "@dokploy/server";
@ -194,7 +195,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const authR = await db?.query.auth.findFirst({
const authR = await db.query.auth.findFirst({
where: eq(auth.resetPasswordToken, token),
});

View File

@ -15,7 +15,6 @@ const Home: NextPage = () => {
const [spec, setSpec] = useState({});
useEffect(() => {
// Esto solo se ejecutará en el cliente
if (data) {
const protocolAndHost = `${window.location.protocol}//${window.location.host}/api`;
const newSpec = {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1 @@
<svg height="2500" viewBox=".16 0 571.71 800" width="1788" xmlns="http://www.w3.org/2000/svg"><g fill="#13bef9"><path d="m190.83 175.88h-12.2v63.2h12.2zm52.47 0h-12.2v63.2h12.2zm71.69-120.61-12.5-21.68-208.67 120.61 12.5 21.68z"/><path d="m313.77 55.27 12.51-21.68 208.67 120.61-12.51 21.68z"/><path d="m571.87 176.18v-25.03h-571.71v25.03z"/><path d="m345.5 529.77v-370.99h25.02v389.01c-6.71-7.64-15.26-13.13-25.02-18.02zm-42.71-6.41v-523.36h25.02v526.41c-7.02-3.36-24.1-3.05-25.02-3.05zm-237.04 52.21c-30.51-22.59-50.64-58.62-50.64-99.54 0-21.68 5.79-43.05 16.47-61.68h213.55c10.98 18.63 16.48 40 16.48 61.68 0 18.93-2.44 36.64-10.07 52.52-16.17-15.57-39.97-22.29-64.07-22.29-42.71 0-79.32 26.56-88.77 66.26-3.36-.31-5.49-.61-8.85-.61-8.24.3-16.17 1.53-24.1 3.66z" fill-rule="evenodd"/><path d="m170.69 267.18h-64.67v65.03h64.67zm-72.91 0h-64.67v65.03h64.67zm0 72.36h-64.67v65.04h64.67zm72.91 0h-64.67v65.04h64.67zm72.61 0h-64.67v65.04h64.67zm0-107.17h-64.67v65.03h64.67z"/><path d="m109.37 585.34c8.85-37.55 42.71-65.65 82.98-65.65 25.94 0 49.12 11.61 64.99 29.93 13.72-9.47 30.2-14.96 48.2-14.96 46.98 0 85.11 38.16 85.11 85.19 0 9.77-1.52 18.93-4.57 27.78 10.37 14.05 16.78 31.76 16.78 50.69 0 47.02-38.14 85.19-85.12 85.19-20.75 0-39.66-7.33-54.3-19.54-15.56 21.68-40.88 36.03-69.56 36.03-32.95 0-61.63-18.93-75.96-46.41-5.8 1.22-11.6 1.83-17.7 1.83-46.98 0-85.42-38.17-85.42-85.19s38.14-85.19 85.42-85.19c3.05-.31 6.1-.31 9.15.3z" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -6,7 +6,6 @@ import {
apiRemoveUser,
users,
} from "@/server/db/schema";
import {
createInvitation,
findAdminById,

View File

@ -4,12 +4,13 @@ import {
apiFindOneAuth,
apiLogin,
apiUpdateAuth,
apiUpdateAuthByAdmin,
apiVerify2FA,
apiVerifyLogin2FA,
auth,
} from "@/server/db/schema";
import { WEBSITE_URL } from "@/server/utils/stripe";
import {
type Auth,
IS_CLOUD,
createAdmin,
createUser,
@ -19,6 +20,7 @@ import {
getUserByToken,
lucia,
luciaToken,
sendDiscordNotification,
sendEmailNotification,
updateAuthById,
validateRequest,
@ -53,6 +55,12 @@ export const authRouter = createTRPCRouter({
}
}
const newAdmin = await createAdmin(input);
if (IS_CLOUD) {
await sendDiscordNotificationWelcome(newAdmin);
await sendVerificationEmail(newAdmin.id);
return true;
}
const session = await lucia.createSession(newAdmin.id || "", {});
ctx.res.appendHeader(
"Set-Cookie",
@ -60,7 +68,12 @@ export const authRouter = createTRPCRouter({
);
return true;
} catch (error) {
throw error;
throw new TRPCError({
code: "BAD_REQUEST",
// @ts-ignore
message: `Error: ${error?.code === "23505" ? "Email already exists" : "Error to create admin"}`,
cause: error,
});
}
}),
createUser: publicProcedure
@ -74,7 +87,13 @@ export const authRouter = createTRPCRouter({
message: "Invalid token",
});
}
const newUser = await createUser(input);
if (IS_CLOUD) {
await sendVerificationEmail(token.authId);
return true;
}
const session = await lucia.createSession(newUser?.authId || "", {});
ctx.res.appendHeader(
"Set-Cookie",
@ -106,6 +125,15 @@ export const authRouter = createTRPCRouter({
});
}
if (auth?.confirmationToken && IS_CLOUD) {
await sendVerificationEmail(auth.id);
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Email not confirmed, we have sent you a confirmation email please check your inbox.",
});
}
if (auth?.is2FAEnabled) {
return {
is2FAEnabled: true,
@ -126,7 +154,7 @@ export const authRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Credentials do not match",
message: `Error: ${error instanceof Error ? error.message : "Error to login"}`,
cause: error,
});
}
@ -151,7 +179,7 @@ export const authRouter = createTRPCRouter({
.input(apiUpdateAuth)
.mutation(async ({ ctx, input }) => {
const auth = await updateAuthById(ctx.user.authId, {
...(input.email && { email: input.email }),
...(input.email && { email: input.email.toLowerCase() }),
...(input.password && {
password: bcrypt.hashSync(input.password, 10),
}),
@ -183,19 +211,6 @@ export const authRouter = createTRPCRouter({
return auth;
}),
updateByAdmin: protectedProcedure
.input(apiUpdateAuthByAdmin)
.mutation(async ({ input }) => {
const auth = await updateAuthById(input.id, {
...(input.email && { email: input.email }),
...(input.password && {
password: bcrypt.hashSync(input.password, 10),
}),
...(input.image && { image: input.image }),
});
return auth;
}),
generate2FASecret: protectedProcedure.query(async ({ ctx }) => {
return await generate2FASecret(ctx.user.authId);
}),
@ -236,9 +251,6 @@ export const authRouter = createTRPCRouter({
});
return auth;
}),
verifyToken: protectedProcedure.mutation(async () => {
return true;
}),
sendResetPasswordEmail: publicProcedure
.input(
z.object({
@ -270,20 +282,20 @@ export const authRouter = createTRPCRouter({
).toISOString(),
});
const email = await sendEmailNotification(
await sendEmailNotification(
{
fromAddress: process.env.SMTP_FROM_ADDRESS || "",
fromAddress: process.env.SMTP_FROM_ADDRESS!,
toAddresses: [authR.email],
smtpServer: process.env.SMTP_SERVER || "",
smtpServer: process.env.SMTP_SERVER!,
smtpPort: Number(process.env.SMTP_PORT),
username: process.env.SMTP_USERNAME || "",
password: process.env.SMTP_PASSWORD || "",
username: process.env.SMTP_USERNAME!,
password: process.env.SMTP_PASSWORD!,
},
"Reset Password",
`
Reset your password by clicking the link below:
The link will expire in 24 hours.
<a href="http://localhost:3000/reset-password?token=${token}">
<a href="${WEBSITE_URL}/reset-password?token=${token}">
Reset Password
</a>
@ -334,6 +346,113 @@ export const authRouter = createTRPCRouter({
password: bcrypt.hashSync(input.password, 10),
});
return true;
}),
confirmEmail: adminProcedure
.input(
z.object({
confirmationToken: z.string().min(1),
}),
)
.mutation(async ({ ctx, input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Functionality not available in cloud version",
});
}
const authR = await db.query.auth.findFirst({
where: eq(auth.confirmationToken, input.confirmationToken),
});
if (!authR || authR.confirmationExpiresAt === null) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Token not found",
});
}
if (authR.confirmationToken !== input.confirmationToken) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Confirmation Token not found",
});
}
const isExpired = isBefore(
new Date(authR.confirmationExpiresAt),
new Date(),
);
if (isExpired) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Confirmation Token expired",
});
}
1;
await updateAuthById(authR.id, {
confirmationToken: null,
confirmationExpiresAt: null,
});
return true;
}),
});
export const sendVerificationEmail = async (authId: string) => {
const token = nanoid();
const result = await updateAuthById(authId, {
confirmationToken: token,
confirmationExpiresAt: new Date(
new Date().getTime() + 24 * 60 * 60 * 1000,
).toISOString(),
});
if (!result) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User not found",
});
}
await sendEmailNotification(
{
fromAddress: process.env.SMTP_FROM_ADDRESS || "",
toAddresses: [result?.email],
smtpServer: process.env.SMTP_SERVER || "",
smtpPort: Number(process.env.SMTP_PORT),
username: process.env.SMTP_USERNAME || "",
password: process.env.SMTP_PASSWORD || "",
},
"Confirm your email | Dokploy",
`
Welcome to Dokploy!
Please confirm your email by clicking the link below:
<a href="${WEBSITE_URL}/confirm-email?token=${result?.confirmationToken}">
Confirm Email
</a>
`,
);
return true;
};
export const sendDiscordNotificationWelcome = async (newAdmin: Auth) => {
await sendDiscordNotification(
{
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
},
{
title: "✅ New User Registered",
color: 0x00ff00,
fields: [
{
name: "Email",
value: newAdmin.email,
inline: true,
},
],
timestamp: newAdmin.createdAt,
footer: {
text: "Dokploy User Registration Notification",
},
},
);
};

View File

@ -15,7 +15,7 @@ export const stripeRouter = createTRPCRouter({
const admin = await findAdminById(ctx.user.adminId);
const stripeCustomerId = admin.stripeCustomerId;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
@ -51,7 +51,7 @@ export const stripeRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
@ -98,7 +98,7 @@ export const stripeRouter = createTRPCRouter({
}
const stripeCustomerId = admin.stripeCustomerId;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || "", {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});

View File

@ -4,7 +4,7 @@ export default defineConfig({
schema: "./server/db/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL || "",
url: process.env.DATABASE_URL!,
},
out: "drizzle",
migrations: {

View File

@ -8,12 +8,12 @@ declare global {
export let db: PostgresJsDatabase<typeof schema>;
if (process.env.NODE_ENV === "production") {
db = drizzle(postgres(process.env.DATABASE_URL || ""), {
db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});
} else {
if (!global.db)
global.db = drizzle(postgres(process.env.DATABASE_URL || ""), {
global.db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});

View File

@ -2,7 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL || "";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql);

View File

@ -3,7 +3,7 @@ import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL || "";
const connectionString = process.env.DATABASE_URL!;
const pg = postgres(connectionString, { max: 1 });
const db = drizzle(pg);

View File

@ -3,7 +3,7 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { users } from "./schema";
const connectionString = process.env.DATABASE_URL || "";
const connectionString = process.env.DATABASE_URL!;
const pg = postgres(connectionString, { max: 1 });
const db = drizzle(pg);

View File

@ -3,9 +3,9 @@ export const WEBSITE_URL =
? "http://localhost:3000"
: process.env.SITE_URL;
const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID || ""; // $4.00
const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00
const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID || ""; // $7.99
const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99
export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => {
const items = [];

View File

@ -0,0 +1,30 @@
version: '3.8'
services:
agent:
image: portainer/agent
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /var/lib/docker/volumes:/var/lib/docker/volumes
networks:
- dokploy-network
deploy:
mode: global
placement:
constraints: [node.platform.os == linux]
portainer:
image: portainer/portainer-ce
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer-data:/data
deploy:
mode: replicated
placement:
constraints: [node.role == manager]
volumes:
portainer-data:

View File

@ -0,0 +1,19 @@
import {
type DomainSchema,
type Schema,
type Template,
generateRandomDomain,
} from "../utils";
export function generate(schema: Schema): Template {
const domains: DomainSchema[] = [
{
host: generateRandomDomain(schema),
port: 9000,
serviceName: "portainer",
},
];
return {
domains,
};
}

View File

@ -512,7 +512,6 @@ export const templates: TemplateData[] = [
tags: ["self-hosted", "email", "webmail"],
load: () => import("./roundcube/index").then((m) => m.generate),
},
{
id: "filebrowser",
name: "File Browser",
@ -527,5 +526,20 @@ export const templates: TemplateData[] = [
},
tags: ["file", "manager"],
load: () => import("./filebrowser/index").then((m) => m.generate),
},
{
id: "portainer",
name: "Portainer",
version: "2.21.4",
description:
"Portainer is a container management tool for deploying, troubleshooting, and securing applications across cloud, data centers, and IoT.",
logo: "portainer.svg",
links: {
github: "https://github.com/portainer/portainer",
website: "https://www.portainer.io/",
docs: "https://docs.portainer.io/",
},
tags: ["cloud", "monitoring"],
load: () => import("./portainer/index").then((m) => m.generate),
},
];

View File

@ -3,7 +3,7 @@ import IORedis from "ioredis";
import { logger } from "./logger";
import type { QueueJob } from "./schema";
export const connection = new IORedis(process.env.REDIS_URL || "", {
export const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
export const jobQueue = new Queue("backupQueue", {

View File

@ -1,8 +1,6 @@
import clsx from "clsx";
import { Inter, Lexend } from "next/font/google";
import "@/styles/tailwind.css";
import GoogleAnalytics from "@/components/analitycs/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
@ -88,7 +86,14 @@ export default async function RootLayout({
lang={locale}
className={clsx("h-full scroll-smooth", inter.variable, lexend.variable)}
>
<GoogleAnalytics />
<head>
<script
defer
src="https://umami.dokploy.com/script.js"
data-website-id="7d1422e4-3776-4870-8145-7d7b2075d470"
/>
</head>
{/* <GoogleAnalytics /> */}
<body className="flex h-full flex-col">
<NextIntlClientProvider messages={messages}>
<Header />

View File

@ -1,11 +1,40 @@
import Link from "next/link";
"use client";
import { useTranslations } from "next-intl";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Link, useRouter } from "@/i18n/routing";
import { useLocale, useTranslations } from "next-intl";
import type { SVGProps } from "react";
import { Container } from "./Container";
import { NavLink } from "./NavLink";
import { Logo } from "./shared/Logo";
import { buttonVariants } from "./ui/button";
const I18nIcon = (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
fill="currentColor"
stroke="currentColor"
strokeWidth={0}
viewBox="0 0 512 512"
{...props}
>
<path
stroke="none"
d="m478.33 433.6-90-218a22 22 0 0 0-40.67 0l-90 218a22 22 0 1 0 40.67 16.79L316.66 406h102.67l18.33 44.39A22 22 0 0 0 458 464a22 22 0 0 0 20.32-30.4zM334.83 362 368 281.65 401.17 362zm-66.99-19.08a22 22 0 0 0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73 39.65-53.68 62.11-114.75 71.27-143.49H330a22 22 0 0 0 0-44H214V70a22 22 0 0 0-44 0v20H54a22 22 0 0 0 0 44h197.25c-9.52 26.95-27.05 69.5-53.79 108.36-31.41-41.68-43.08-68.65-43.17-68.87a22 22 0 0 0-40.58 17c.58 1.38 14.55 34.23 52.86 83.93.92 1.19 1.83 2.35 2.74 3.51-39.24 44.35-77.74 71.86-93.85 80.74a22 22 0 1 0 21.07 38.63c2.16-1.18 48.6-26.89 101.63-85.59 22.52 24.08 38 35.44 38.93 36.1a22 22 0 0 0 30.75-4.9z"
/>
</svg>
);
export function Footer() {
const router = useRouter();
const locale = useLocale();
const t = useTranslations("HomePage");
const linkT = useTranslations("Link");
@ -31,7 +60,7 @@ export function Footer() {
</nav>
</div>
<div className="flex flex-col items-center border-t border-slate-400/10 py-10 sm:flex-row-reverse sm:justify-between">
<div className="flex gap-x-6">
<div className="flex gap-x-6 items-center">
<Link
href="https://x.com/getdokploy"
className="group"
@ -56,6 +85,30 @@ export function Footer() {
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z" />
</svg>
</Link>
<Select
onValueChange={(locale) => {
router.replace("/", {
locale: locale as "en" | "zh-Hans",
});
}}
value={locale}
>
<SelectTrigger
className={buttonVariants({
variant: "outline",
className:
" flex items-center gap-2 !rounded-full visited:outline-none focus-within:outline-none focus:outline-none",
})}
>
<I18nIcon width={20} height={20} />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">{t("navigation.i18nEn")}</SelectItem>
<SelectItem value="zh-Hans">
{t("navigation.i18nZh-Hans")}
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="mt-6 text-sm text-muted-foreground sm:mt-0">
{t("footer.copyright", {

View File

@ -1,16 +1,10 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { Link, useRouter } from "@/i18n/routing";
import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
import { Popover, Transition } from "@headlessui/react";
import { HeartIcon } from "lucide-react";
import { useLocale, useTranslations } from "next-intl";
import { useTranslations } from "next-intl";
import { Fragment, type JSX, type SVGProps } from "react";
import { Container } from "./Container";
import { NavLink } from "./NavLink";
@ -125,10 +119,7 @@ function MobileNavigation() {
as="div"
className="absolute inset-x-0 top-full mt-4 flex origin-top flex-col rounded-2xl border border-border bg-background p-4 text-lg tracking-tight text-primary shadow-xl ring-1 ring-border/5"
>
<MobileNavLink href="/#features">
{t("navigation.features")}
</MobileNavLink>
{/* <MobileNavLink href="/#testimonials">Testimonials</MobileNavLink> */}
<MobileNavLink href="/pricing">Pricing</MobileNavLink>
<MobileNavLink href="/#faqs">{t("navigation.faqs")}</MobileNavLink>
<MobileNavLink href={linkT("docs.intro")} target="_blank">
{t("navigation.docs")}
@ -141,8 +132,6 @@ function MobileNavigation() {
}
export function Header() {
const router = useRouter();
const locale = useLocale();
const t = useTranslations("HomePage");
const linkT = useTranslations("Link");
@ -155,8 +144,7 @@ export function Header() {
<Logo className="h-10 w-auto" />
</Link>
<div className="hidden md:flex md:gap-x-6">
<NavLink href="/#features">{t("navigation.features")}</NavLink>
{/* <NavLink href="/#testimonials">Testimonials</NavLink> */}
<NavLink href="/pricing">{t("navigation.pricing")}</NavLink>
<NavLink href="/#faqs">{t("navigation.faqs")}</NavLink>
<NavLink href={linkT("docs.intro")} target="_blank">
{t("navigation.docs")}
@ -164,31 +152,6 @@ export function Header() {
</div>
</div>
<div className="flex items-center gap-x-2 md:gap-x-5">
<Select
onValueChange={(locale) => {
router.replace("/", {
locale: locale as "en" | "zh-Hans",
});
}}
value={locale}
>
<SelectTrigger
className={buttonVariants({
variant: "outline",
className:
" flex items-center gap-2 !rounded-full visited:outline-none focus-within:outline-none focus:outline-none",
})}
>
<I18nIcon width={20} height={20} />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">{t("navigation.i18nEn")}</SelectItem>
<SelectItem value="zh-Hans">
{t("navigation.i18nZh-Hans")}
</SelectItem>
</SelectContent>
</Select>
<Link
className={buttonVariants({
variant: "outline",
@ -202,25 +165,14 @@ export function Header() {
</span>
<HeartIcon className="animate-heartbeat size-4 fill-red-600 text-red-500 " />
</Link>
<Button
className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0]"
asChild
>
<Button className="rounded-xl" asChild>
<Link
href="https://discord.gg/2tBnJ3jDJc"
href="https://app.dokploy.com"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
// className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
{t("navigation.discord")}
{t("navigation.dashboard")}
</Link>
</Button>
<div className="-mr-1 md:hidden">

View File

@ -1,10 +1,13 @@
"use client";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/utils";
import { ArrowRight, ArrowRightIcon, Check, Copy } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Container } from "./Container";
import AnimatedShinyText from "./ui/animated-shiny-text";
import { Button } from "./ui/button";
import { HoverBorderGradient } from "./ui/hover-border-gradient";
const ProductHunt = () => {
return (
@ -69,7 +72,22 @@ export function Hero() {
</div>
<div className="relative">
<h1 className="mx-auto max-w-4xl font-display text-5xl font-medium tracking-tight text-muted-foreground sm:text-7xl">
<Link href="/pricing" className="relative z-10 mb-4 inline-block">
<div className="flex items-center justify-center">
<div
className={cn(
"group rounded-full border border-black/5 bg-neutral-100 text-sm font-medium text-white transition-all ease-in hover:cursor-pointer hover:bg-neutral-200 dark:border-white/5 dark:bg-neutral-900 dark:hover:bg-neutral-800",
)}
>
<AnimatedShinyText className="inline-flex items-center justify-center px-4 py-1 text-neutral-800 transition ease-out hover:text-neutral-900 hover:duration-300 hover:dark:text-neutral-400">
<span>🚀 {t("hero.cloud")} </span>
<ArrowRightIcon className="ml-1 size-3 transition-transform duration-300 ease-in-out group-hover:translate-x-0.5" />
</AnimatedShinyText>
</div>
</div>
</Link>
<h1 className="mx-auto max-w-4xl font-display text-5xl font-medium tracking-tight text-muted-foreground sm:text-7xl">
{t("hero.deploy")}{" "}
<span className="relative whitespace-nowrap text-primary">
<svg
@ -125,7 +143,9 @@ export function Hero() {
Discord
</Link>
</Button> */}
<Button className="rounded-xl" asChild>
</div>
<div className="mx-auto flex w-full max-w-sm flex-wrap items-center justify-center gap-3 md:flex-nowrap">
<Button className="w-full rounded-xl" asChild>
<Link
href="https://github.com/dokploy/dokploy"
aria-label="Dokploy on GitHub"
@ -138,6 +158,27 @@ export function Hero() {
Github
</Link>
</Button>
<Button
className="w-full rounded-xl bg-[#5965F2] hover:bg-[#4A55E0]"
asChild
>
<Link
href="https://discord.gg/2tBnJ3jDJc"
aria-label="Dokploy on GitHub"
target="_blank"
className="flex flex-row items-center gap-2 text-white"
>
<svg
role="img"
className="h-6 w-6 fill-white"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
{t("navigation.discord")}
</Link>
</Button>
</div>
</div>
<div className="mt-16 flex flex-row justify-center gap-x-8 rounded-lg sm:gap-x-0 sm:gap-y-10 xl:gap-x-12 xl:gap-y-0">
@ -182,22 +223,20 @@ export function Hero() {
}
export const ShowSponsors = () => {
const t = useTranslations("HomePage");
return (
<div className="mt-20 flex flex-col justify-center gap-y-10">
<div className="flex flex-col gap-4 justify-start">
<div className="flex flex-col justify-start gap-4">
<h1 className="mx-auto max-w-2xl font-display text-3xl font-medium tracking-tight text-primary sm:text-5xl">
Sponsors
{t("hero.sponsors.title")}
</h1>
<p className="mx-auto max-w-2xl text-lg tracking-tight text-muted-foreground">
Dokploy is an open source project that is maintained by a community of
volunteers. We would like to thank our sponsors for their support and
contributions to the project, which help us to continue to develop and
improve Dokploy.
{t("hero.sponsors.description")}
</p>
</div>
<div className="flex flex-col gap-4 md:gap-6 justify-start">
<h2 className="font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl text-left">
Hero Sponsors 🎖
<div className="flex flex-col items-center justify-start gap-4 md:gap-6">
<h2 className="text-left font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl">
{t("hero.sponsors.level.hero")} 🎖
</h2>
<div className="flex flex-wrap items-center gap-4">
<a
@ -209,7 +248,7 @@ export const ShowSponsors = () => {
<img
src="https://raw.githubusercontent.com/Dokploy/dokploy/canary/.github/sponsors/hostinger.jpg"
alt="hostinger.com"
className="rounded-xl w-[190px] h-auto"
className="h-auto w-[190px] rounded-xl"
/>
</a>
<a
@ -221,16 +260,16 @@ export const ShowSponsors = () => {
<img
src="https://raw.githubusercontent.com/Dokploy/dokploy/canary/.github/sponsors/lxaer.png"
alt="lxaer.com"
className="rounded-xl w-[70px] h-auto"
className="h-auto w-[70px] rounded-xl"
/>
</a>
</div>
</div>
<div className="flex flex-col gap-4 md:gap-8 justify-start">
<h2 className="font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl text-left">
Premium Supporters 🥇
<div className="flex flex-col items-center justify-start gap-4 md:gap-8">
<h2 className="text-left font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl">
{t("hero.sponsors.level.premium")} 🥇
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div className="flex flex-col items-center justify-start gap-4 md:gap-6">
<a
href="https://supafort.com/?ref=dokploy"
target="_blank"
@ -245,9 +284,9 @@ export const ShowSponsors = () => {
</a>
</div>
</div>
<div className="flex flex-col gap-4 md:gap-8 justify-start">
<h2 className="font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl text-left">
Supporting Members 🥉
<div className="flex flex-col items-center justify-start gap-4 md:gap-8">
<h2 className="text-left font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl">
{t("hero.sponsors.level.supporting")} 🥉
</h2>
<div className="flex flex-row gap-10">
<a
@ -276,9 +315,9 @@ export const ShowSponsors = () => {
</a>
</div>
</div>
<div className="flex flex-col gap-4 md:gap-8 justify-start">
<h2 className="font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl text-left">
Community Backers 🤝
<div className="justify-star flex flex-col items-center gap-4 md:gap-8">
<h2 className="text-left font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl">
{t("hero.sponsors.level.community")} 🤝
</h2>
<div className="flex flex-row gap-10">
<a
@ -320,9 +359,9 @@ export const ShowSponsors = () => {
</a>
</div>
</div>
<div className="flex flex-col gap-4 md:gap-8 justify-start">
<h2 className="font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl text-left">
Organizations:
<div className="flex flex-col items-center justify-start gap-4 md:gap-8">
<h2 className="text-left font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl">
{t("hero.sponsors.level.organizations")}
</h2>
<div className="flex flex-row gap-10">
<a
@ -337,9 +376,9 @@ export const ShowSponsors = () => {
</a>
</div>
</div>
<div className="flex flex-col gap-4 md:gap-8 justify-start">
<h2 className="font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl text-left">
Individuals:
<div className="flex flex-col items-center justify-start gap-4 md:gap-8">
<h2 className="text-left font-display text-2xl font-medium tracking-tight text-primary sm:text-2xl">
{t("hero.sponsors.level.individuals")}
</h2>
<div className="flex flex-row gap-10">
<a

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,40 @@
import type { CSSProperties, FC, ReactNode } from "react";
import { cn } from "@/lib/utils";
interface AnimatedShinyTextProps {
children: ReactNode;
className?: string;
shimmerWidth?: number;
}
const AnimatedShinyText: FC<AnimatedShinyTextProps> = ({
children,
className,
shimmerWidth = 100,
}) => {
return (
<p
style={
{
"--shiny-width": `${shimmerWidth}px`,
} as CSSProperties
}
className={cn(
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
// Shine effect
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
// Shine gradient
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
className,
)}
>
{children}
</p>
);
};
export default AnimatedShinyText;

View File

@ -0,0 +1,100 @@
"use client";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
type Direction = "TOP" | "LEFT" | "BOTTOM" | "RIGHT";
export function HoverBorderGradient({
children,
containerClassName,
className,
as: Tag = "button",
duration = 1,
clockwise = true,
...props
}: React.PropsWithChildren<
{
as?: React.ElementType;
containerClassName?: string;
className?: string;
duration?: number;
clockwise?: boolean;
} & React.HTMLAttributes<HTMLElement>
>) {
const [hovered, setHovered] = useState<boolean>(false);
const [direction, setDirection] = useState<Direction>("TOP");
const rotateDirection = (currentDirection: Direction): Direction => {
const directions: Direction[] = ["TOP", "LEFT", "BOTTOM", "RIGHT"];
const currentIndex = directions.indexOf(currentDirection);
const nextIndex = clockwise
? (currentIndex - 1 + directions.length) % directions.length
: (currentIndex + 1) % directions.length;
return directions[nextIndex];
};
const movingMap: Record<Direction, string> = {
TOP: "radial-gradient(20.7% 50% at 50% 0%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
LEFT: "radial-gradient(16.6% 43.1% at 0% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
BOTTOM:
"radial-gradient(20.7% 50% at 50% 100%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
RIGHT:
"radial-gradient(16.2% 41.199999999999996% at 100% 50%, hsl(0, 0%, 100%) 0%, rgba(255, 255, 255, 0) 100%)",
};
const highlight =
"radial-gradient(75% 181.15942028985506% at 50% 50%, #3275F8 0%, rgba(255, 255, 255, 0) 100%)";
useEffect(() => {
if (!hovered) {
const interval = setInterval(() => {
setDirection((prevState) => rotateDirection(prevState));
}, duration * 1000);
return () => clearInterval(interval);
}
}, [hovered]);
return (
<Tag
onMouseEnter={(event: React.MouseEvent<HTMLDivElement>) => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
className={cn(
"relative flex rounded-full border content-center bg-black/20 hover:bg-black/10 transition duration-500 dark:bg-white/20 items-center flex-col flex-nowrap gap-10 h-min justify-center overflow-visible p-px decoration-clone w-fit",
containerClassName,
)}
{...props}
>
<div
className={cn(
"w-auto text-white z-10 bg-black px-4 py-2 rounded-[inherit]",
className,
)}
>
{children}
</div>
<motion.div
className={cn(
"flex-none inset-0 overflow-hidden absolute z-0 rounded-[inherit]",
)}
style={{
filter: "blur(2px)",
position: "absolute",
width: "100%",
height: "100%",
}}
initial={{ background: movingMap[direction] }}
animate={{
background: hovered
? [movingMap[direction], highlight]
: movingMap[direction],
}}
transition={{ ease: "linear", duration: duration ?? 1 }}
/>
<div className="bg-black absolute z-1 flex-none inset-[2px] rounded-[100px]" />
</Tag>
);
}

View File

@ -4,18 +4,33 @@
"features": "Features",
"faqs": "FAQ",
"docs": "Docs",
"pricing": "Pricing",
"support": "Support",
"dashboard": "Dashboard",
"discord": "Discord",
"i18nButtonPlaceholder": "Language",
"i18nEn": "English",
"i18nZh-Hans": "简体中文"
},
"hero": {
"cloud": "Introducing Dokploy Cloud",
"deploy": "Deploy",
"anywhere": "Anywhere",
"with": "with Total Freedom and Ease.",
"des": "Streamline your operations with our all-in-one platform—perfect for managing projects, data, and system health with simplicity and efficiency.",
"featuredIn": "Featured in"
"featuredIn": "Featured in",
"sponsors": {
"title": "Sponsors",
"description": "Dokploy is an open source project that is maintained by a community of volunteers. We would like to thank our sponsors for their support and contributions to the project, which help us to continue to develop and improve Dokploy.",
"level": {
"hero": "Hero Sponsors",
"premium": "Premium Supporters",
"supporting": "Supporting Members",
"community": "Community Backers",
"organizations": "Organizations",
"individuals": "Individuals"
}
}
},
"primaryFeatures": {
"title": "Comprehensive Control for Your Digital Ecosystem",
@ -92,5 +107,70 @@
"intro": "https://docs.dokploy.com/get-started/introduction",
"install": "https://docs.dokploy.com/en/docs/core/get-started/introduction"
}
},
"Pricing": {
"swirlyDoodleTitle": "Simple & Affordable,",
"restTitle": "Pricing.",
"description": "Deploy Smarter, Scale Faster Without Breaking the Bank",
"billingCycle": {
"monthly": "Monthly",
"annual": "Annual"
},
"plan": {
"free": {
"title": "Free",
"subTitle": "Open Source",
"section": {
"title": "Dokploy Open Source",
"description": "Manager your own infrastructure installing dokploy ui in your own server."
},
"features": {
"f1": "Complete Flexibility: Install Dokploy UI on your own infrastructure",
"f2": "Unlimited Deployments",
"f3": "Self-hosted Infrastructure",
"f4": "Community Support",
"f5": "Access to Core Features",
"f6": "Dokploy Integration",
"f7": "Basic Backups",
"f8": "Access to All Updates",
"f9": "Unlimited Servers"
},
"go": "Installation"
},
"cloud": {
"title": "Recommended",
"section": {
"title": "Dokploy Plan",
"description": " to manage Dokploy UI infrastructure, we take care of it for you."
},
"servers": "{serverQuantity}Servers (You bring the servers)",
"features": {
"f1": "Managed Hosting: No need to manage your own servers",
"f2": "Priority Support",
"f3": "Future-Proof Features"
},
"go": "Subscribe"
}
},
"faq": {
"title": "Frequently asked questions",
"description": "If you cant find what youre looking for, please send us an email to",
"q1": "How does Dokploy's Open Source plan work?",
"a1": "You can host Dokploy UI on your own infrastructure and you will be responsible for the maintenance and updates.",
"q2": "Do I need to provide my own server for the managed plan?",
"a2": "Yes, in the managed plan, you provide your own server eg(Hetzner, Hostinger, AWS, ETC.) VPS, and we manage the Dokploy UI infrastructure for you.",
"q3": "What happens if I need more than one server?",
"a3": "The first server costs $4.50/month, if you buy more than one it will be $3.50/month per server.",
"q4": "Is there a limit on the number of deployments?",
"a4": "No, there is no limit on the number of deployments in any of the plans.",
"q5": "What happens if I exceed my purchased server limit?",
"a5": "The most recently added servers will be deactivated. You won't be able to create services on inactive servers until they are reactivated.",
"q6": "Do you offer a refunds?",
"a6": "We do not offer refunds. However, you can cancel your subscription at any time. Feel free to try our open-source version for free before making a purchase.",
"q7": "What kind of support do you offer?",
"a7": "We offer community support for the open source version and priority support for paid plans.",
"q8": "Is Dokploy open-source?",
"a8": "Yes, Dokploy is fully open-source. You can contribute or modify it as needed for your projects."
}
}
}

View File

@ -4,18 +4,33 @@
"features": "特性",
"faqs": "FAQ",
"docs": "文档",
"pricing": "价格",
"support": "赞助",
"dashboard": "控制台",
"discord": "Discord",
"i18nButtonPlaceholder": "语言",
"i18nEn": "English",
"i18nZh-Hans": "简体中文"
},
"hero": {
"cloud": "隆重介绍 Dokploy 云",
"deploy": "部署在",
"anywhere": "任何设施之上",
"with": "",
"des": "以前所未有的简洁和高效提供一站式项目、数据的管理以及系统监控。",
"featuredIn": "发布于"
"featuredIn": "发布于",
"sponsors": {
"title": "赞助名单",
"description": "Dokploy 是由社区成员共同支持的完全免费的开源项目,您的慷慨解囊将帮助我们继续开发和改进 Dokploy。",
"level": {
"hero": "特别赞助",
"premium": "金牌赞助",
"supporting": "银牌赞助",
"community": "铜牌赞助",
"organizations": "组织赞助",
"individuals": "个人赞助"
}
}
},
"primaryFeatures": {
"title": "全面掌控您的基础设施",
@ -92,5 +107,70 @@
"intro": "https://docs.dokploy.com/cn/docs/core/get-started/introduction",
"install": "https://docs.dokploy.com/cn/docs/core/get-started/introduction"
}
},
"Pricing": {
"swirlyDoodleTitle": "简洁明了的,",
"restTitle": "定价",
"description": "更聪明的部署方式,更快的扩容速度,以及不会让你破产的账单。",
"billingCycle": {
"monthly": "按月",
"annual": "按年"
},
"plan": {
"free": {
"title": "免费",
"subTitle": "开源版本",
"section": {
"title": "部署Dokploy的开源版本",
"description": "自行管理您的基础设施,不收取任何费用"
},
"features": {
"f1": "灵活的架构,您可以单独部署 Dokploy 管理端",
"f2": "无限数量的部署",
"f3": "自维护的基础设施",
"f4": "来自社区的有限支持",
"f5": "所有功能可用",
"f6": "Dokploy 集成",
"f7": "基础备份服务",
"f8": "跟随开源版本更新",
"f9": "无限服务器数量"
},
"go": "立即开始"
},
"cloud": {
"title": "推荐(年付更优惠)",
"section": {
"title": "Dokploy 云",
"description": "使用我们的云服务,一站式管理您所有的部署。"
},
"servers": "{serverQuantity}台受控服务器",
"features": {
"f1": "由 Dokploy 云提供支持的独立控制面板",
"f2": "优先技术支持",
"f3": "优先体验先行功能"
},
"go": "订阅"
}
},
"faq": {
"title": "常见问题",
"description": "如果您的问题不在以下列表,请随时向我们致信",
"q1": "Dokploy 的开源版本是什么?",
"a1": "您可以免费安装 Dokploy 开源版本,并自行负责 Dokploy 的日后维护工作",
"q2": "付费计划还需要自己的服务器吗?",
"a2": "是的在付费计划中您依然需要提供您用于实际运行服务的服务器如阿里云、腾讯云、AWS 等),我们会为您管理 Dokploy 控制面板。",
"q3": "如果我需要管理更多的服务器怎么办?",
"a3": "第一台服务器的费用为 $4.5/月,之后每台的费用是 $3.5/月。",
"q4": "部署服务的数量有限制吗?",
"a4": "不管您如何使用 Dokploy我们都不会限制您部署服务的数量。",
"q5": "如果我意外超过了最大服务器数量怎么办?",
"a5": "最新添加的服务器将被停用,您将不能向其创建服务,直到它们被重新激活。",
"q6": "关于退款服务的政策?",
"a6": "您可以随时取消您的订阅,但请原谅我们无法提供退款服务,您可以无限试用开源版本,然后再购买。",
"q7": "关于技术支持?",
"a7": "付费计划可以得到优先的技术支持,开源版本则是由社区提供技术支持。",
"q8": "Dokploy 开源吗?",
"a8": "是的Dokploy 完全开源,您可以参与贡献或者是 Fork 后自行修改以用于您的私人需求。"
}
}
}

View File

@ -10,9 +10,6 @@ const config = {
],
prefix: "",
theme: {
// fontFamily: {
// sans: ["var(--font-sans)", ...fontFamily.sans],
// },
fontSize: {
xs: ["0.75rem", { lineHeight: "1rem" }],
sm: ["0.875rem", { lineHeight: "1.5rem" }],
@ -75,7 +72,6 @@ const config = {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
"4xl": "2rem",
},
fontFamily: {
@ -84,17 +80,34 @@ const config = {
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
},
},
"shiny-text": {
"0%, 90%, 100%": {
"background-position": "calc(-100% - var(--shiny-width)) 0",
},
"30%, 60%": {
"background-position": "calc(100% + var(--shiny-width)) 0",
},
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"shiny-text": "shiny-text 8s infinite",
},
},
},

View File

@ -4,7 +4,7 @@ export default defineConfig({
schema: "./server/db/schema/index.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL || "",
url: process.env.DATABASE_URL!,
},
out: "drizzle",
migrations: {

View File

@ -7,12 +7,12 @@ declare global {
export let db: PostgresJsDatabase<typeof schema>;
if (process.env.NODE_ENV === "production") {
db = drizzle(postgres(process.env.DATABASE_URL || ""), {
db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});
} else {
if (!global.db)
global.db = drizzle(postgres(process.env.DATABASE_URL || ""), {
global.db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});

View File

@ -2,7 +2,7 @@
// import { migrate } from "drizzle-orm/postgres-js/migrator";
// import postgres from "postgres";
// const connectionString = process.env.DATABASE_URL || "";
// const connectionString = process.env.DATABASE_URL!;
// const sql = postgres(connectionString, { max: 1 });
// const db = drizzle(sql);

View File

@ -3,7 +3,7 @@ import { sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL || "";
const connectionString = process.env.DATABASE_URL!;
const pg = postgres(connectionString, { max: 1 });
const db = drizzle(pg);

View File

@ -50,6 +50,8 @@ export const auth = pgTable("auth", {
.$defaultFn(() => new Date().toISOString()),
resetPasswordToken: text("resetPasswordToken"),
resetPasswordExpiresAt: text("resetPasswordExpiresAt"),
confirmationToken: text("confirmationToken"),
confirmationExpiresAt: text("confirmationExpiresAt"),
});
export const authRelations = relations(auth, ({ many }) => ({

View File

@ -3,7 +3,7 @@
// import postgres from "postgres";
// import { users } from "./schema";
// const connectionString = process.env.DATABASE_URL || "";
// const connectionString = process.env.DATABASE_URL!;
// const pg = postgres(connectionString, { max: 1 });
// const db = drizzle(pg);

View File

@ -15,9 +15,7 @@ interface NotionMagicLinkEmailProps {
loginCode?: string;
}
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "";
const baseUrl = process.env.VERCEL_URL!;
export const NotionMagicLinkEmail = ({
loginCode,

View File

@ -15,9 +15,7 @@ interface PlaidVerifyIdentityEmailProps {
validationCode?: string;
}
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "";
const baseUrl = process.env.VERCEL_URL!;
export const PlaidVerifyIdentityEmail = ({
validationCode,

View File

@ -13,9 +13,7 @@ import {
} from "@react-email/components";
import * as React from "react";
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "";
const baseUrl = process.env.VERCEL_URL!;
export const StripeWelcomeEmail = () => (
<Html>

View File

@ -29,9 +29,7 @@ interface VercelInviteUserEmailProps {
inviteFromLocation?: string;
}
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: "";
const baseUrl = process.env.VERCEL_URL!;
export const VercelInviteUserEmail = ({
username,

View File

@ -20,7 +20,7 @@ export const createInvitation = async (
const result = await tx
.insert(auth)
.values({
email: input.email,
email: input.email.toLowerCase(),
rol: "user",
password: bcrypt.hashSync("01231203012312", 10),
})

View File

@ -24,7 +24,7 @@ export const createAdmin = async (input: typeof apiCreateAdmin._type) => {
const newAuth = await tx
.insert(auth)
.values({
email: input.email,
email: input.email.toLowerCase(),
password: hashedPassword,
rol: "admin",
})
@ -93,7 +93,7 @@ export const findAuthByEmail = async (email: string) => {
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Auth not found",
message: "User not found",
});
}
return result;

View File

@ -9,8 +9,8 @@ import type { FileConfig } from "../utils/traefik/file-types";
import type { MainTraefikConfig } from "../utils/traefik/types";
const TRAEFIK_SSL_PORT =
Number.parseInt(process.env.TRAEFIK_SSL_PORT ?? "", 10) || 443;
const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT ?? "", 10) || 80;
Number.parseInt(process.env.TRAEFIK_SSL_PORT!, 10) || 443;
const TRAEFIK_PORT = Number.parseInt(process.env.TRAEFIK_PORT!, 10) || 80;
interface TraefikOptions {
enableDashboard?: boolean;

View File

@ -13454,7 +13454,7 @@ snapshots:
eslint: 8.45.0
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-jsx-a11y: 6.9.0(eslint@8.45.0)
eslint-plugin-react: 7.35.0(eslint@8.45.0)
eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@8.45.0)
@ -13478,7 +13478,7 @@ snapshots:
enhanced-resolve: 5.17.1
eslint: 8.45.0
eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0)
fast-glob: 3.3.2
get-tsconfig: 4.7.5
is-core-module: 2.15.0
@ -13500,7 +13500,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1)(eslint@8.45.0):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.45.0)(typescript@5.1.6))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@8.45.0))(eslint@8.45.0))(eslint@8.45.0):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5