Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { Palette } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";

const userInterfaceSchema = z.object({
loginPageImage: z.string().url().optional().or(z.literal("")),
});

type UserInterfaceForm = z.infer<typeof userInterfaceSchema>;

export const UserInterfaceForm = () => {
const { data, refetch, isLoading } =
api.settings.getUserInterfaceSettings.useQuery();
const { mutateAsync, isLoading: isUpdating } =
api.settings.updateUserInterfaceSettings.useMutation();

const form = useForm<UserInterfaceForm>({
defaultValues: {
loginPageImage: "",
},
resolver: zodResolver(userInterfaceSchema),
});

useEffect(() => {
if (data) {
form.reset({
loginPageImage: data.loginPageImage || "",
});
}
}, [data, form]);

const onSubmit = async (values: UserInterfaceForm) => {
try {
await mutateAsync({
loginPageImage: values.loginPageImage || null,
});

toast.success("User interface settings updated successfully");
await refetch();
} catch (error) {
toast.error("Failed to update user interface settings");
}
};

return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row gap-2">
<Palette className="size-6 text-muted-foreground self-center" />
User interface settings
</CardTitle>
<CardDescription>
Customize your dashboard experience and interface preferences.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 py-8 border-t">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="loginPageImage"
render={({ field }) => (
<FormItem>
<FormLabel>Login page background image</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/image.jpg"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormDescription>
URL of the background image to display on login page.
Leave empty to use default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>

{form.watch("loginPageImage") && (
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground mb-2">
Preview:
</p>
<div className="relative w-20 rounded-sm overflow-hidden bg-muted">
<img
src={form.watch("loginPageImage")}
alt="Login page background preview"
className="h-full w-full object-cover"
onError={(e) => {
e.currentTarget.style.display = "none";
(
e.currentTarget.nextElementSibling as HTMLElement
).style.display = "flex";
}}
/>
<div className="hidden h-full w-full items-center justify-center text-sm text-muted-foreground">
Failed to load image
</div>
</div>
</div>
)}

<div className="flex justify-end">
<Button
type="submit"
isLoading={isUpdating}
disabled={isLoading}
>
Save settings
</Button>
</div>
</form>
</Form>
</CardContent>
</div>
</Card>
</div>
);
};
40 changes: 36 additions & 4 deletions apps/dokploy/components/layouts/onboarding-layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from "next/link";
import type React from "react";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
Expand All @@ -9,20 +10,51 @@ interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { data: uiSettings } = api.settings.getUserInterfaceSettings.useQuery();
const backgroundImage = uiSettings?.loginPageImage;

return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
<div className="absolute inset-0 bg-muted" />
<div
className="absolute inset-0"
style={{
backgroundImage: backgroundImage
? `url(${backgroundImage})`
: undefined,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<div
className={cn(
"absolute inset-0",
backgroundImage ? "bg-black/40" : "bg-muted",
)}
/>
</div>
<Link
href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" />
Dokploy
<span
className={cn(
backgroundImage ? "text-white drop-shadow-lg" : "text-primary",
)}
>
Dokploy
</span>
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg text-primary">
<p
className={cn(
"text-lg",
backgroundImage ? "text-white drop-shadow-lg" : "text-primary",
)}
>
&ldquo;The Open Source alternative to Netlify, Vercel,
Heroku.&rdquo;
</p>
Expand Down
7 changes: 7 additions & 0 deletions apps/dokploy/components/layouts/side.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
KeyRound,
Loader2,
type LucideIcon,
Palette,
Package,
PieChart,
Server,
Expand Down Expand Up @@ -397,6 +398,12 @@ const MENU: Menu = {
// Only enabled for admins in cloud environments
isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
},
{
isSingle: true,
title: "User interface",
url: "/dashboard/settings/user-interface",
icon: Palette,
},
],

help: [
Expand Down
53 changes: 53 additions & 0 deletions apps/dokploy/pages/dashboard/settings/user-interface.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { UserInterfaceForm } from "@/components/dashboard/settings/user-interface/user-interface-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";

const Page = () => {
return (
<div className="flex flex-col gap-4 w-full">
<UserInterfaceForm />
</div>
);
};

export default Page;

Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="User Interface">{page}</DashboardLayout>;
};

export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
const locale = getLocale(req.cookies);

const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});

await helpers.settings.isCloud.prefetch();
await helpers.user.get.prefetch();

return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}
32 changes: 32 additions & 0 deletions apps/dokploy/server/api/routers/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getDokployImageTag,
getLogCleanupStatus,
getUpdateData,
getUserInterfaceSettings,
getWebServerSettings,
IS_CLOUD,
parseRawConfig,
Expand All @@ -40,6 +41,7 @@ import {
updateLetsEncryptEmail,
updateServerById,
updateServerTraefik,
updateUserInterfaceSettings,
updateWebServerSettings,
writeConfig,
writeMainConfig,
Expand Down Expand Up @@ -907,4 +909,34 @@ export const settingsRouter = createTRPCRouter({
const ips = process.env.DOKPLOY_CLOUD_IPS?.split(",");
return ips;
}),

getUserInterfaceSettings: publicProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getUserInterfaceSettings();
return {
loginPageImage: settings?.loginPageImage || null,
};
}),

updateUserInterfaceSettings: adminProcedure
.input(
z.object({
loginPageImage: z.string().url().optional().nullable(),
}),
)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return null;
}

const settings = await updateUserInterfaceSettings({
loginPageImage: input.loginPageImage,
});

return {
loginPageImage: settings?.loginPageImage || null,
};
}),
});
1 change: 1 addition & 0 deletions packages/server/src/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export * from "./session";
export * from "./shared";
export * from "./ssh-key";
export * from "./user";
export * from "./user-interface-settings";
export * from "./utils";
export * from "./volume-backups";
export * from "./web-server-settings";
29 changes: 29 additions & 0 deletions packages/server/src/db/schema/user-interface-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";

export const userInterfaceSettings = pgTable("userInterfaceSettings", {
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
// UI Customization
loginPageImage: text("loginPageImage"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const userInterfaceSettingsRelations = relations(
userInterfaceSettings,
() => ({}),
);

const createSchema = createInsertSchema(userInterfaceSettings, {
id: z.string().min(1),
});

export const apiUpdateUserInterfaceSettings = createSchema.partial().extend({
loginPageImage: z.string().url().optional().nullable(),
});
1 change: 1 addition & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export * from "./services/server";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/user";
export * from "./services/user-interface-settings";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
Expand Down
Loading