mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat(compose): enhance template import with improved error handling and user experience
- Refactor import process to use dedicated `import` mutation - Add warning alert about configuration replacement - Implement form reset on successful import - Improve error handling and user feedback - Remove unnecessary console logs and update UI text
This commit is contained in:
@@ -28,10 +28,11 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Code2, Globe2, HardDrive } from "lucide-react";
|
import { Code2, Globe2, HardDrive } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { AlertBlock } from "@/components/shared/alert-block";
|
||||||
|
|
||||||
const ImportSchema = z.object({
|
const ImportSchema = z.object({
|
||||||
base64: z.string(),
|
base64: z.string(),
|
||||||
@@ -68,10 +69,13 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { mutateAsync: processTemplate } =
|
const { mutateAsync: processTemplate, isLoading: isLoadingTemplate } =
|
||||||
api.compose.processTemplate.useMutation();
|
api.compose.processTemplate.useMutation();
|
||||||
const { mutateAsync: updateCompose, isLoading } =
|
const {
|
||||||
api.compose.update.useMutation();
|
mutateAsync: importTemplate,
|
||||||
|
isLoading: isImporting,
|
||||||
|
isSuccess: isImportSuccess,
|
||||||
|
} = api.compose.import.useMutation();
|
||||||
|
|
||||||
const form = useForm<ImportType>({
|
const form = useForm<ImportType>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -80,21 +84,32 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
resolver: zodResolver(ImportSchema),
|
resolver: zodResolver(ImportSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
base64: "",
|
||||||
|
});
|
||||||
|
}, [isImportSuccess]);
|
||||||
|
|
||||||
const onSubmit = async () => {
|
const onSubmit = async () => {
|
||||||
await updateCompose({
|
const base64 = form.getValues("base64");
|
||||||
composeId,
|
if (!base64) {
|
||||||
composeFile: templateInfo?.compose || "",
|
toast.error("Please enter a base64 template");
|
||||||
})
|
return;
|
||||||
.then(async () => {
|
}
|
||||||
toast.success("Import configuration updated");
|
|
||||||
await utils.compose.one.invalidate({
|
try {
|
||||||
composeId,
|
await importTemplate({
|
||||||
});
|
composeId,
|
||||||
setShowModal(false);
|
base64,
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error updating the import configuration");
|
|
||||||
});
|
});
|
||||||
|
toast.success("Template imported successfully");
|
||||||
|
await utils.compose.one.invalidate({
|
||||||
|
composeId,
|
||||||
|
});
|
||||||
|
setShowModal(false);
|
||||||
|
} catch (_error) {
|
||||||
|
toast.error("Error importing template");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoadTemplate = async () => {
|
const handleLoadTemplate = async () => {
|
||||||
@@ -129,11 +144,13 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
<Card className="bg-background">
|
<Card className="bg-background">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl">Import</CardTitle>
|
<CardTitle className="text-xl">Import</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>Import your Template configuration</CardDescription>
|
||||||
Import your Docker Compose configuration
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-4">
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
Warning: Importing a template will remove all existing environment
|
||||||
|
variables, mounts, and domains from this service.
|
||||||
|
</AlertBlock>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
@@ -144,10 +161,10 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
name="base64"
|
name="base64"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Docker Compose (Base64)</FormLabel>
|
<FormLabel>Configuration (Base64)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Enter your Docker Compose configuration here..."
|
placeholder="Enter your Base64 configuration here..."
|
||||||
className="font-mono min-h-[200px]"
|
className="font-mono min-h-[200px]"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
@@ -161,130 +178,149 @@ export const ShowImport = ({ composeId }: Props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
isLoading={isLoadingTemplate}
|
||||||
onClick={handleLoadTemplate}
|
onClick={handleLoadTemplate}
|
||||||
>
|
>
|
||||||
Load
|
Load
|
||||||
</Button>
|
</Button>
|
||||||
<Button isLoading={isLoading} type="submit" className="w-fit">
|
|
||||||
Import
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Dialog open={showModal} onOpenChange={setShowModal}>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-[50vw] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">
|
||||||
|
Template Information
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="space-y-2">
|
||||||
|
<p>Review the template information before importing</p>
|
||||||
|
<AlertBlock type="warning">
|
||||||
|
Warning: This will remove all existing environment
|
||||||
|
variables, mounts, and domains from this service.
|
||||||
|
</AlertBlock>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Docker Compose
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
language="yaml"
|
||||||
|
value={templateInfo?.compose || ""}
|
||||||
|
className="font-mono"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{templateInfo?.template.domains &&
|
||||||
|
templateInfo.template.domains.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Domains</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{templateInfo.template.domains.map(
|
||||||
|
(domain, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{domain.serviceName}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<div>Port: {domain.port}</div>
|
||||||
|
{domain.host && (
|
||||||
|
<div>Host: {domain.host}</div>
|
||||||
|
)}
|
||||||
|
{domain.path && (
|
||||||
|
<div>Path: {domain.path}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templateInfo?.template.envs &&
|
||||||
|
templateInfo.template.envs.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Code2 className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">
|
||||||
|
Environment Variables
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{templateInfo.template.envs.map((env, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border bg-card p-2 font-mono text-sm"
|
||||||
|
>
|
||||||
|
{env}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{templateInfo?.template.mounts &&
|
||||||
|
templateInfo.template.mounts.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="h-5 w-5 text-primary" />
|
||||||
|
<h3 className="text-lg font-semibold">Mounts</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{templateInfo.template.mounts.map(
|
||||||
|
(mount, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
|
||||||
|
onClick={() => handleShowMountContent(mount)}
|
||||||
|
>
|
||||||
|
{mount.filePath}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
isLoading={isImporting}
|
||||||
|
type="submit"
|
||||||
|
onClick={form.handleSubmit(onSubmit)}
|
||||||
|
className="w-fit"
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={showModal} onOpenChange={setShowModal}>
|
|
||||||
<DialogContent className="max-h-[80vh] max-w-[50vw]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">
|
|
||||||
Template Information
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Review the template information before importing
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<ScrollArea className="h-[60vh] pr-4">
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Code2 className="h-5 w-5 text-primary" />
|
|
||||||
<h3 className="text-lg font-semibold">Docker Compose</h3>
|
|
||||||
</div>
|
|
||||||
<CodeEditor
|
|
||||||
language="yaml"
|
|
||||||
value={templateInfo?.compose || ""}
|
|
||||||
className="font-mono"
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{templateInfo?.template.domains &&
|
|
||||||
templateInfo.template.domains.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Globe2 className="h-5 w-5 text-primary" />
|
|
||||||
<h3 className="text-lg font-semibold">Domains</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-3">
|
|
||||||
{templateInfo.template.domains.map((domain, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
|
|
||||||
>
|
|
||||||
<div className="font-medium">
|
|
||||||
{domain.serviceName}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1">
|
|
||||||
<div>Port: {domain.port}</div>
|
|
||||||
{domain.host && <div>Host: {domain.host}</div>}
|
|
||||||
{domain.path && <div>Path: {domain.path}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templateInfo?.template.envs &&
|
|
||||||
templateInfo.template.envs.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Code2 className="h-5 w-5 text-primary" />
|
|
||||||
<h3 className="text-lg font-semibold">
|
|
||||||
Environment Variables
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{templateInfo.template.envs.map((env, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="rounded-lg border bg-card p-2 font-mono text-sm"
|
|
||||||
>
|
|
||||||
{env}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{templateInfo?.template.mounts &&
|
|
||||||
templateInfo.template.mounts.length > 0 && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<HardDrive className="h-5 w-5 text-primary" />
|
|
||||||
<h3 className="text-lg font-semibold">Mounts</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{templateInfo.template.mounts.map((mount, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
|
|
||||||
onClick={() => handleShowMountContent(mount)}
|
|
||||||
>
|
|
||||||
{mount.filePath}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
|
||||||
<Button variant="outline" onClick={() => setShowModal(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={form.handleSubmit(onSubmit)}>Import</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<Dialog open={showMountContent} onOpenChange={setShowMountContent}>
|
<Dialog open={showMountContent} onOpenChange={setShowMountContent}>
|
||||||
<DialogContent className=" max-w-[50vw]">
|
<DialogContent className="max-w-[50vw]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl font-bold">
|
<DialogTitle className="text-xl font-bold">
|
||||||
{selectedMount?.filePath}
|
{selectedMount?.filePath}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
createComposeByTemplate,
|
createComposeByTemplate,
|
||||||
createDomain,
|
createDomain,
|
||||||
createMount,
|
createMount,
|
||||||
|
deleteMount,
|
||||||
findComposeById,
|
findComposeById,
|
||||||
findDomainsByComposeId,
|
findDomainsByComposeId,
|
||||||
findProjectById,
|
findProjectById,
|
||||||
@@ -49,6 +50,7 @@ import {
|
|||||||
removeCompose,
|
removeCompose,
|
||||||
removeComposeDirectory,
|
removeComposeDirectory,
|
||||||
removeDeploymentsByComposeId,
|
removeDeploymentsByComposeId,
|
||||||
|
removeDomainById,
|
||||||
startCompose,
|
startCompose,
|
||||||
stopCompose,
|
stopCompose,
|
||||||
updateCompose,
|
updateCompose,
|
||||||
@@ -568,15 +570,21 @@ export const composeRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
console.log(input);
|
|
||||||
try {
|
try {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
compose.project.organizationId !== ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to update this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const decodedData = Buffer.from(input.base64, "base64").toString(
|
const decodedData = Buffer.from(input.base64, "base64").toString(
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
const compose = await findComposeById(input.composeId);
|
|
||||||
|
|
||||||
console.log(compose);
|
|
||||||
|
|
||||||
const admin = await findUserById(ctx.user.ownerId);
|
const admin = await findUserById(ctx.user.ownerId);
|
||||||
let serverIp = admin.serverIp || "127.0.0.1";
|
let serverIp = admin.serverIp || "127.0.0.1";
|
||||||
|
|
||||||
@@ -613,4 +621,108 @@ export const composeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
import: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
base64: z.string(),
|
||||||
|
composeId: z.string().min(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
try {
|
||||||
|
const compose = await findComposeById(input.composeId);
|
||||||
|
const decodedData = Buffer.from(input.base64, "base64").toString(
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
compose.project.organizationId !== ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You are not authorized to update this compose",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const mount of compose.mounts) {
|
||||||
|
await deleteMount(mount.mountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const domain of compose.domains) {
|
||||||
|
await removeDomainById(domain.domainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await findUserById(ctx.user.ownerId);
|
||||||
|
let serverIp = admin.serverIp || "127.0.0.1";
|
||||||
|
|
||||||
|
if (compose.serverId) {
|
||||||
|
const server = await findServerById(compose.serverId);
|
||||||
|
serverIp = server.ipAddress;
|
||||||
|
} else if (process.env.NODE_ENV === "development") {
|
||||||
|
serverIp = "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateData = JSON.parse(decodedData);
|
||||||
|
const config = load(templateData.config) as CompleteTemplate;
|
||||||
|
|
||||||
|
if (!templateData.compose || !config) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message:
|
||||||
|
"Invalid template format. Must contain compose and config fields",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const processedTemplate = processTemplate(config, {
|
||||||
|
serverIp: serverIp,
|
||||||
|
projectName: compose.project.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update compose file
|
||||||
|
await updateCompose(input.composeId, {
|
||||||
|
composeFile: templateData.compose,
|
||||||
|
sourceType: "raw",
|
||||||
|
env: processedTemplate.envs?.join("\n"),
|
||||||
|
isolatedDeployment: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mounts
|
||||||
|
if (processedTemplate.mounts && processedTemplate.mounts.length > 0) {
|
||||||
|
for (const mount of processedTemplate.mounts) {
|
||||||
|
await createMount({
|
||||||
|
filePath: mount.filePath,
|
||||||
|
mountPath: "",
|
||||||
|
content: mount.content,
|
||||||
|
serviceId: compose.composeId,
|
||||||
|
serviceType: "compose",
|
||||||
|
type: "file",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create domains
|
||||||
|
if (processedTemplate.domains && processedTemplate.domains.length > 0) {
|
||||||
|
for (const domain of processedTemplate.domains) {
|
||||||
|
await createDomain({
|
||||||
|
...domain,
|
||||||
|
domainType: "compose",
|
||||||
|
certificateType: "none",
|
||||||
|
composeId: compose.composeId,
|
||||||
|
host: domain.host || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: "Template imported successfully",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: `Error importing template: ${error instanceof Error ? error.message : error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user