mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge pull request #665 from PaiJi/feat/i18n-support
feat(i18n): add i18n support
This commit is contained in:
@@ -20,6 +20,15 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import useLocale from "@/utils/hooks/use-locale";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -28,6 +37,9 @@ const appearanceFormSchema = z.object({
|
||||
theme: z.enum(["light", "dark", "system"], {
|
||||
required_error: "Please select a theme.",
|
||||
}),
|
||||
language: z.enum(["en", "zh-Hans"], {
|
||||
required_error: "Please select a language.",
|
||||
}),
|
||||
});
|
||||
|
||||
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
||||
@@ -35,10 +47,14 @@ type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
|
||||
// This can come from your database or API.
|
||||
const defaultValues: Partial<AppearanceFormValues> = {
|
||||
theme: "system",
|
||||
language: "en",
|
||||
};
|
||||
|
||||
export function AppearanceForm() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { locale, setLocale } = useLocale();
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const form = useForm<AppearanceFormValues>({
|
||||
resolver: zodResolver(appearanceFormSchema),
|
||||
defaultValues,
|
||||
@@ -47,19 +63,23 @@ export function AppearanceForm() {
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
theme: (theme ?? "system") as AppearanceFormValues["theme"],
|
||||
language: locale,
|
||||
});
|
||||
}, [form, theme]);
|
||||
}, [form, theme, locale]);
|
||||
function onSubmit(data: AppearanceFormValues) {
|
||||
setTheme(data.theme);
|
||||
setLocale(data.language);
|
||||
toast.success("Preferences Updated");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Appearance</CardTitle>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.appearance.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Customize the theme of your dashboard.
|
||||
{t("settings.appearance.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
@@ -72,9 +92,9 @@ export function AppearanceForm() {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="space-y-1 ">
|
||||
<FormLabel>Theme</FormLabel>
|
||||
<FormLabel>{t("settings.appearance.theme")}</FormLabel>
|
||||
<FormDescription>
|
||||
Select a theme for your dashboard
|
||||
{t("settings.appearance.themeDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<RadioGroup
|
||||
@@ -92,7 +112,7 @@ export function AppearanceForm() {
|
||||
<img src="/images/theme-light.svg" alt="light" />
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Light
|
||||
{t("settings.appearance.themes.light")}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -105,7 +125,7 @@ export function AppearanceForm() {
|
||||
<img src="/images/theme-dark.svg" alt="dark" />
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
Dark
|
||||
{t("settings.appearance.themes.dark")}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -121,7 +141,7 @@ export function AppearanceForm() {
|
||||
<img src="/images/theme-system.svg" alt="system" />
|
||||
</div>
|
||||
<span className="block w-full p-2 text-center font-normal">
|
||||
System
|
||||
{t("settings.appearance.themes.system")}
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -131,7 +151,43 @@ export function AppearanceForm() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit">Save</Button>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
defaultValue={form.control._defaultValues.language}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel>{t("settings.appearance.language")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("settings.appearance.languageDescription")}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No preset selected" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[
|
||||
{ label: "English", value: "en" },
|
||||
{ label: "简体中文", value: "zh-Hans" },
|
||||
].map((preset) => (
|
||||
<SelectItem key={preset.label} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button type="submit">{t("settings.common.save")}</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,6 +52,7 @@ const randomImages = [
|
||||
export const ProfileForm = () => {
|
||||
const { data, refetch } = api.auth.get.useQuery();
|
||||
const { mutateAsync, isLoading } = api.auth.update.useMutation();
|
||||
const { t } = useTranslation("settings");
|
||||
|
||||
const form = useForm<Profile>({
|
||||
defaultValues: {
|
||||
@@ -91,10 +93,10 @@ export const ProfileForm = () => {
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader className="flex flex-row gap-2 flex-wrap justify-between items-center">
|
||||
<div>
|
||||
<CardTitle className="text-xl">Account</CardTitle>
|
||||
<CardDescription>
|
||||
Change the details of your profile here.
|
||||
</CardDescription>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.profile.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("settings.profile.description")}</CardDescription>
|
||||
</div>
|
||||
{!data?.is2FAEnabled ? <Enable2FA /> : <Disable2FA />}
|
||||
</CardHeader>
|
||||
@@ -107,9 +109,12 @@ export const ProfileForm = () => {
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormLabel>{t("settings.profile.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Email" {...field} />
|
||||
<Input
|
||||
placeholder={t("settings.profile.email")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -120,11 +125,11 @@ export const ProfileForm = () => {
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t("settings.profile.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
placeholder={t("settings.profile.password")}
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
@@ -139,7 +144,7 @@ export const ProfileForm = () => {
|
||||
name="image"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Avatar</FormLabel>
|
||||
<FormLabel>{t("settings.profile.avatar")}</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => {
|
||||
@@ -177,7 +182,7 @@ export const ProfileForm = () => {
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" isLoading={isLoading}>
|
||||
Save
|
||||
{t("settings.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||
import { GPUSupportModal } from "../gpu-support-modal";
|
||||
|
||||
export const ShowDokployActions = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mutateAsync: reloadServer, isLoading } =
|
||||
api.settings.reloadServer.useMutation();
|
||||
|
||||
@@ -23,11 +25,13 @@ export const ShowDokployActions = () => {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={isLoading}>
|
||||
<Button isLoading={isLoading} variant="outline">
|
||||
Server
|
||||
{t("settings.server.webServer.server.label")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("settings.server.webServer.actions")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
@@ -41,10 +45,10 @@ export const ShowDokployActions = () => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Reload</span>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
<ShowModalLogs appName="dokploy">
|
||||
<span>Watch logs</span>
|
||||
<span>{t("settings.server.webServer.watchLogs")}</span>
|
||||
</ShowModalLogs>
|
||||
<GPUSupportModal />
|
||||
</DropdownMenuGroup>
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/utils/api";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mutateAsync: cleanAll, isLoading: cleanAllIsLoading } =
|
||||
api.settings.cleanAll.useMutation();
|
||||
|
||||
@@ -64,11 +66,13 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
Space
|
||||
{t("settings.server.webServer.storage.label")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="start">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("settings.server.webServer.actions")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
@@ -85,7 +89,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean unused images</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanUnusedImages")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="w-full cursor-pointer"
|
||||
@@ -101,7 +107,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean unused volumes</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanUnusedVolumes")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -118,7 +126,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean stopped containers</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanStoppedContainers")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
@@ -135,7 +145,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean Docker Builder & System</span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanDockerBuilder")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{!serverId && (
|
||||
<DropdownMenuItem
|
||||
@@ -150,7 +162,9 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean Monitoring </span>
|
||||
<span>
|
||||
{t("settings.server.webServer.storage.cleanMonitoring")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
@@ -168,7 +182,7 @@ export const ShowStorageActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Clean all</span>
|
||||
<span>{t("settings.server.webServer.storage.cleanAll")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { api } from "@/utils/api";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { EditTraefikEnv } from "../../web-server/edit-traefik-env";
|
||||
import { ShowModalLogs } from "../../web-server/show-modal-logs";
|
||||
|
||||
@@ -30,6 +31,7 @@ interface Props {
|
||||
serverId?: string;
|
||||
}
|
||||
export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { mutateAsync: reloadTraefik, isLoading: reloadTraefikIsLoading } =
|
||||
api.settings.reloadTraefik.useMutation();
|
||||
|
||||
@@ -51,11 +53,13 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
isLoading={reloadTraefikIsLoading || toggleDashboardIsLoading}
|
||||
variant="outline"
|
||||
>
|
||||
Traefik
|
||||
{t("settings.server.webServer.traefik.label")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56" align="start">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuLabel>
|
||||
{t("settings.server.webServer.actions")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
@@ -71,17 +75,17 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span>Reload</span>
|
||||
<span>{t("settings.server.webServer.reload")}</span>
|
||||
</DropdownMenuItem>
|
||||
<ShowModalLogs appName="dokploy-traefik" serverId={serverId}>
|
||||
<span>Watch logs</span>
|
||||
<span>{t("settings.server.webServer.watchLogs")}</span>
|
||||
</ShowModalLogs>
|
||||
<EditTraefikEnv serverId={serverId}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="w-full cursor-pointer space-x-3"
|
||||
>
|
||||
<span>Modify Env</span>
|
||||
<span>{t("settings.server.webServer.traefik.modifyEnv")}</span>
|
||||
</DropdownMenuItem>
|
||||
</EditTraefikEnv>
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -49,6 +50,7 @@ const addServerDomain = z
|
||||
type AddServerDomain = z.infer<typeof addServerDomain>;
|
||||
|
||||
export const WebDomain = () => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data: user, refetch } = api.admin.one.useQuery();
|
||||
const { mutateAsync, isLoading } =
|
||||
api.settings.assignDomainServer.useMutation();
|
||||
@@ -89,9 +91,11 @@ export const WebDomain = () => {
|
||||
<div className="w-full">
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Server Domain</CardTitle>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.server.domain.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add a domain to your server application.
|
||||
{t("settings.server.domain.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex w-full flex-col gap-4">
|
||||
@@ -106,7 +110,9 @@ export const WebDomain = () => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Domain</FormLabel>
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.domain")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
@@ -126,7 +132,9 @@ export const WebDomain = () => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>Letsencrypt Email</FormLabel>
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.letsEncryptEmail")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="w-full"
|
||||
@@ -145,20 +153,32 @@ export const WebDomain = () => {
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className="md:col-span-2">
|
||||
<FormLabel>Certificate</FormLabel>
|
||||
<FormLabel>
|
||||
{t("settings.server.domain.form.certificate.label")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a certificate" />
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"settings.server.domain.form.certificate.placeholder",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value={"none"}>None</SelectItem>
|
||||
<SelectItem value={"none"}>
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.none",
|
||||
)}
|
||||
</SelectItem>
|
||||
<SelectItem value={"letsencrypt"}>
|
||||
Letsencrypt (Default)
|
||||
{t(
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt",
|
||||
)}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -169,7 +189,7 @@ export const WebDomain = () => {
|
||||
/>
|
||||
<div>
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
Save
|
||||
{t("settings.common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { useTranslation } from "next-i18next";
|
||||
import React from "react";
|
||||
import { ShowDokployActions } from "./servers/actions/show-dokploy-actions";
|
||||
import { ShowStorageActions } from "./servers/actions/show-storage-actions";
|
||||
@@ -18,6 +19,7 @@ interface Props {
|
||||
className?: string;
|
||||
}
|
||||
export const WebServer = ({ className }: Props) => {
|
||||
const { t } = useTranslation("settings");
|
||||
const { data } = api.admin.one.useQuery();
|
||||
|
||||
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
|
||||
@@ -25,8 +27,12 @@ export const WebServer = ({ className }: Props) => {
|
||||
return (
|
||||
<Card className={cn("rounded-lg w-full bg-transparent p-0", className)}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Web server settings</CardTitle>
|
||||
<CardDescription>Reload or clean the web server.</CardDescription>
|
||||
<CardTitle className="text-xl">
|
||||
{t("settings.server.webServer.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("settings.server.webServer.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 ">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
|
||||
Reference in New Issue
Block a user