feat: migrate to site-settings

This commit is contained in:
Lucas Smith
2024-02-23 10:47:01 +00:00
parent c436559787
commit 8165a090d1
25 changed files with 497 additions and 228 deletions

View File

@ -42,6 +42,7 @@
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"remeda": "^1.27.1",
"sharp": "0.33.1", "sharp": "0.33.1",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.2.2", "typescript": "5.2.2",

View File

@ -1,119 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZBannerSchema = z.object({
text: z.string().optional(),
show: z.boolean().optional(),
});
type TBannerSchema = z.infer<typeof ZBannerSchema>;
export function BannerForm({ show, text }: TBannerSchema) {
const router = useRouter();
const { toast } = useToast();
const form = useForm<TBannerSchema>({
resolver: zodResolver(ZBannerSchema),
defaultValues: {
show,
text,
},
});
const { mutateAsync: updateBanner, isLoading: isUpdatingBanner } =
trpcReact.banner.updateBanner.useMutation();
const onBannerUpdate = async ({ show, text }: TBannerSchema) => {
try {
await updateBanner({
show,
text,
});
toast({
title: 'Banner Updated',
description: 'Your banner has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to reset your password. Please try again later.',
});
}
}
};
return (
<Form {...form}>
<form className="flex flex-col" onSubmit={form.handleSubmit(onBannerUpdate)}>
<FormField
control={form.control}
name="show"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
<div className="space-y-0.5">
<FormLabel>Show Banner</FormLabel>
<FormDescription>Show a banner to the users by the admin</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="text"
render={({ field }) => (
<FormItem className="mt-8">
<FormLabel className="text-base ">Banner Text</FormLabel>
<FormControl>
<Textarea placeholder="Text to show to users" className="resize-none" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={isUpdatingBanner} className="mt-3 justify-end self-end">
Update Banner
</Button>
</form>
</Form>
);
}

View File

@ -1,15 +0,0 @@
import { getBanner } from '@documenso/lib/server-only/banner/get-banner';
import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
const banner = await getBanner();
return (
<div className="space-y-8">
<h2 className="text-4xl font-semibold">Banner</h2>
<BannerForm show={banner?.show ?? false} text={banner?.text ?? ''} />
</div>
);
}

View File

@ -5,7 +5,7 @@ import type { HTMLAttributes } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { BadgeAlert, BarChart3, FileStack, User2, Wallet2 } from 'lucide-react'; import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
import { cn } from '@documenso/ui/lib/utils'; import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
@ -87,9 +87,9 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
)} )}
asChild asChild
> >
<Link href="/admin/banner"> <Link href="/admin/site-settings">
<BadgeAlert className="mr-2 h-5 w-5" /> <Settings className="mr-2 h-5 w-5" />
Banner Site Settings
</Link> </Link>
</Button> </Button>
</div> </div>

View File

@ -0,0 +1,202 @@
'use client';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import type { TSiteSettingsBannerSchema } from '@documenso/lib/server-only/site-settings/schemas/banner';
import {
SITE_SETTINGS_BANNER_ID,
ZSiteSettingsBannerSchema,
} from '@documenso/lib/server-only/site-settings/schemas/banner';
import { TRPCClientError } from '@documenso/trpc/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { ColorPicker } from '@documenso/ui/primitives/color-picker';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Switch } from '@documenso/ui/primitives/switch';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZBannerFormSchema = ZSiteSettingsBannerSchema;
type TBannerFormSchema = z.infer<typeof ZBannerFormSchema>;
export type BannerFormProps = {
banner?: TSiteSettingsBannerSchema;
};
export function BannerForm({ banner }: BannerFormProps) {
const router = useRouter();
const { toast } = useToast();
const form = useForm<TBannerFormSchema>({
resolver: zodResolver(ZBannerFormSchema),
defaultValues: {
id: SITE_SETTINGS_BANNER_ID,
enabled: banner?.enabled ?? false,
data: {
content: banner?.data?.content ?? '',
bgColor: banner?.data?.bgColor ?? '#000000',
textColor: banner?.data?.textColor ?? '#FFFFFF',
},
},
});
const enabled = form.watch('enabled');
const values = form.getValues();
console.log({ values });
const { mutateAsync: updateSiteSetting, isLoading: isUpdateSiteSettingLoading } =
trpcReact.admin.updateSiteSetting.useMutation();
const onBannerUpdate = async ({ id, enabled, data }: TBannerFormSchema) => {
try {
await updateSiteSetting({
id,
enabled,
data,
});
toast({
title: 'Banner Updated',
description: 'Your banner has been updated successfully.',
duration: 5000,
});
router.refresh();
} catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({
title: 'An error occurred',
description: err.message,
variant: 'destructive',
});
} else {
toast({
title: 'An unknown error occurred',
variant: 'destructive',
description:
'We encountered an unknown error while attempting to update the banner. Please try again later.',
});
}
}
};
return (
<div>
<h2 className="font-semibold">Site Banner</h2>
<p className="text-muted-foreground mt-2 text-sm">
The site banner is a message that is shown at the top of the site. It can be used to display
important information to your users.
</p>
<Form {...form}>
<form
className="mt-4 flex flex-col rounded-md"
onSubmit={form.handleSubmit(onBannerUpdate)}
>
<div className="mt-4 flex flex-col gap-4 md:flex-row">
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>Enabled</FormLabel>
<FormControl>
<div>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</div>
</FormControl>
</FormItem>
)}
/>
<fieldset
className="flex flex-col gap-4 md:flex-row"
disabled={!enabled}
aria-disabled={!enabled}
>
<FormField
control={form.control}
name="data.bgColor"
render={({ field }) => (
<FormItem>
<FormLabel>Background Color</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="data.textColor"
render={({ field }) => (
<FormItem>
<FormLabel>Text Color</FormLabel>
<FormControl>
<div>
<ColorPicker {...field} />
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
</div>
<fieldset disabled={!enabled} aria-disabled={!enabled}>
<FormField
control={form.control}
name="data.content"
render={({ field }) => (
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<Textarea className="h-32 resize-none" {...field} />
</FormControl>
<FormDescription>
The content to show in the banner, HTML is allowed
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
loading={isUpdateSiteSettingLoading}
className="mt-4 justify-end self-end"
>
Update Banner
</Button>
</form>
</Form>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header';
import { BannerForm } from './banner-form';
// import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return (
<div>
<SettingsHeader title="Site Settings" subtitle="Manage your site settings here" />
<div className="mt-8">
<BannerForm banner={banner} />
</div>
</div>
);
}

View File

@ -1,14 +1,22 @@
import { getBanner } from '@documenso/lib/server-only/banner/get-banner'; import { getSiteSettings } from '@documenso/lib/server-only/site-settings/get-site-settings';
import { SITE_SETTINGS_BANNER_ID } from '@documenso/lib/server-only/site-settings/schemas/banner';
export const Banner = async () => { export const Banner = async () => {
const banner = await getBanner(); const banner = await getSiteSettings().then((settings) =>
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
);
return ( return (
<> <>
{banner && banner.show && ( {banner && banner.enabled && (
<div className="bg-documenso-200 dark:bg-documenso-400"> <div className="mb-2" style={{ background: banner.data.bgColor }}>
<div className="text-documenso-900 mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"> <div
<div className="flex items-center">{banner.text}</div> className="mx-auto flex h-auto max-w-screen-xl items-center justify-center px-4 py-3 text-sm font-medium"
style={{ color: banner.data.textColor }}
>
<div className="flex items-center">
<span dangerouslySetInnerHTML={{ __html: banner.data.content }} />
</div>
</div> </div>
</div> </div>
)} )}

11
package-lock.json generated
View File

@ -158,6 +158,7 @@
"react-hotkeys-hook": "^4.4.1", "react-hotkeys-hook": "^4.4.1",
"react-icons": "^4.11.0", "react-icons": "^4.11.0",
"react-rnd": "^10.4.1", "react-rnd": "^10.4.1",
"remeda": "^1.27.1",
"sharp": "0.33.1", "sharp": "0.33.1",
"ts-pattern": "^5.0.5", "ts-pattern": "^5.0.5",
"typescript": "5.2.2", "typescript": "5.2.2",
@ -15749,6 +15750,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-confetti": { "node_modules/react-confetti": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz", "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz",
@ -19817,6 +19827,7 @@
"luxon": "^3.4.2", "luxon": "^3.4.2",
"next": "14.0.3", "next": "14.0.3",
"pdfjs-dist": "3.6.172", "pdfjs-dist": "3.6.172",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.7.1", "react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",
"react-pdf": "7.3.3", "react-pdf": "7.3.3",

View File

@ -1,11 +0,0 @@
'use server';
import { prisma } from '@documenso/prisma';
export const getBanner = async () => {
return await prisma.banner.findUnique({
where: {
id: 1,
},
});
};

View File

@ -1,28 +0,0 @@
'use server';
import { prisma } from '@documenso/prisma';
import { Role } from '@documenso/prisma/client';
export type UpdateUserOptions = {
userId: number;
show?: boolean;
text?: string | undefined;
};
export const upsertBanner = async ({ userId, show, text }: UpdateUserOptions) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user?.roles.includes(Role.ADMIN)) {
throw Error('You are unauthorised to perform this action');
}
return await prisma.banner.upsert({
where: { id: 1, user: {} },
update: { show, text },
create: { show, text: text ?? '' },
});
};

View File

@ -0,0 +1,9 @@
import { prisma } from '@documenso/prisma';
import { ZSiteSettingsSchema } from './schema';
export const getSiteSettings = async () => {
const settings = await prisma.siteSettings.findMany();
return ZSiteSettingsSchema.parse(settings);
};

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
import { ZSiteSettingsBannerSchema } from './schemas/banner';
// TODO: Use `z.union([...])` once we have more than one setting
export const ZSiteSettingSchema = ZSiteSettingsBannerSchema;
export type TSiteSettingSchema = z.infer<typeof ZSiteSettingSchema>;
export const ZSiteSettingsSchema = z.array(ZSiteSettingSchema);
export type TSiteSettingsSchema = z.infer<typeof ZSiteSettingsSchema>;

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
export const ZSiteSettingsBaseSchema = z.object({
id: z.string().min(1),
enabled: z.boolean(),
data: z.never(),
});
export type TSiteSettingsBaseSchema = z.infer<typeof ZSiteSettingsBaseSchema>;

View File

@ -0,0 +1,23 @@
import { z } from 'zod';
import { ZSiteSettingsBaseSchema } from './_base';
export const SITE_SETTINGS_BANNER_ID = 'site.banner';
export const ZSiteSettingsBannerSchema = ZSiteSettingsBaseSchema.extend({
id: z.literal(SITE_SETTINGS_BANNER_ID),
data: z
.object({
content: z.string(),
bgColor: z.string(),
textColor: z.string(),
})
.optional()
.default({
content: '',
bgColor: '#000000',
textColor: '#FFFFFF',
}),
});
export type TSiteSettingsBannerSchema = z.infer<typeof ZSiteSettingsBannerSchema>;

View File

@ -0,0 +1,31 @@
import { prisma } from '@documenso/prisma';
import type { TSiteSettingSchema } from './schema';
export type UpsertSiteSettingOptions = TSiteSettingSchema & {
userId: number;
};
export const upsertSiteSetting = async ({
id,
enabled,
data,
userId,
}: UpsertSiteSettingOptions) => {
return await prisma.siteSettings.upsert({
where: {
id,
},
create: {
id,
enabled,
data,
lastModifiedByUserId: userId,
},
update: {
enabled,
data,
lastModifiedByUserId: userId,
},
});
};

View File

@ -0,0 +1,25 @@
/*
Warnings:
- You are about to drop the `Banner` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "Banner" DROP CONSTRAINT "Banner_userId_fkey";
-- DropTable
DROP TABLE "Banner";
-- CreateTable
CREATE TABLE "SiteSettings" (
"id" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT false,
"data" JSONB NOT NULL,
"lastModifiedByUserId" INTEGER,
"lastModifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SiteSettings_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "SiteSettings" ADD CONSTRAINT "SiteSettings_lastModifiedByUserId_fkey" FOREIGN KEY ("lastModifiedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,13 @@
INSERT INTO "SiteSettings" ("id", "enabled", "data")
VALUES (
'site.banner',
FALSE,
jsonb_build_object(
'content',
'This is a test banner',
'bgColor',
'#000000',
'textColor',
'#ffffff'
)
);

View File

@ -47,7 +47,7 @@ model User {
VerificationToken VerificationToken[] VerificationToken VerificationToken[]
Template Template[] Template Template[]
securityAuditLogs UserSecurityAuditLog[] securityAuditLogs UserSecurityAuditLog[]
Banner Banner[] siteSettings SiteSettings[]
@@index([email]) @@index([email])
} }
@ -452,10 +452,11 @@ model Template {
@@unique([templateDocumentDataId]) @@unique([templateDocumentDataId])
} }
model Banner { model SiteSettings {
id Int @id @default(autoincrement()) id String @id
text String enabled Boolean @default(false)
show Boolean @default(false) data Json
user User? @relation(fields: [userId], references: [id]) lastModifiedByUserId Int?
userId Int? lastModifiedAt DateTime @default(now())
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
} }

View File

@ -1,9 +1,10 @@
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { updateUser } from '@documenso/lib/server-only/admin/update-user'; import { updateUser } from '@documenso/lib/server-only/admin/update-user';
import { upsertSiteSetting } from '@documenso/lib/server-only/site-settings/upsert-site-setting';
import { adminProcedure, router } from '../trpc'; import { adminProcedure, router } from '../trpc';
import { ZUpdateProfileMutationByAdminSchema } from './schema'; import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema';
export const adminRouter = router({ export const adminRouter = router({
updateUser: adminProcedure updateUser: adminProcedure
@ -20,4 +21,24 @@ export const adminRouter = router({
}); });
} }
}), }),
updateSiteSetting: adminProcedure
.input(ZUpdateSiteSettingMutationSchema)
.mutation(async ({ ctx, input }) => {
try {
const { id, enabled, data } = input;
return await upsertSiteSetting({
id,
enabled,
data,
userId: ctx.user.id,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update the site setting provided.',
});
}
}),
}); });

View File

@ -1,6 +1,8 @@
import { Role } from '@prisma/client'; import { Role } from '@prisma/client';
import z from 'zod'; import z from 'zod';
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
export const ZUpdateProfileMutationByAdminSchema = z.object({ export const ZUpdateProfileMutationByAdminSchema = z.object({
id: z.number().min(1), id: z.number().min(1),
name: z.string().nullish(), name: z.string().nullish(),
@ -11,3 +13,7 @@ export const ZUpdateProfileMutationByAdminSchema = z.object({
export type TUpdateProfileMutationByAdminSchema = z.infer< export type TUpdateProfileMutationByAdminSchema = z.infer<
typeof ZUpdateProfileMutationByAdminSchema typeof ZUpdateProfileMutationByAdminSchema
>; >;
export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
export type TUpdateSiteSettingMutationSchema = z.infer<typeof ZUpdateSiteSettingMutationSchema>;

View File

@ -1,27 +0,0 @@
import { TRPCError } from '@trpc/server';
import { upsertBanner } from '@documenso/lib/server-only/banner/upsert-banner';
import { adminProcedure, router } from '../trpc';
import { ZCreateBannerByAdminSchema } from './schema';
export const bannerRouter = router({
updateBanner: adminProcedure
.input(ZCreateBannerByAdminSchema)
.mutation(async ({ input, ctx }) => {
const { show, text } = input;
try {
return await upsertBanner({
userId: ctx.user.id,
show,
text,
});
} catch (err) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'We were unable to update your banner. Please try again.',
});
}
}),
});

View File

@ -1,8 +0,0 @@
import z from 'zod';
export const ZCreateBannerByAdminSchema = z.object({
text: z.string().optional(),
show: z.boolean().optional(),
});
export type TCreateBannerByAdminSchema = z.infer<typeof ZCreateBannerByAdminSchema>;

View File

@ -1,6 +1,5 @@
import { adminRouter } from './admin-router/router'; import { adminRouter } from './admin-router/router';
import { authRouter } from './auth-router/router'; import { authRouter } from './auth-router/router';
import { bannerRouter } from './banner-router/router';
import { cryptoRouter } from './crypto/router'; import { cryptoRouter } from './crypto/router';
import { documentRouter } from './document-router/router'; import { documentRouter } from './document-router/router';
import { fieldRouter } from './field-router/router'; import { fieldRouter } from './field-router/router';
@ -15,7 +14,6 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route
export const appRouter = router({ export const appRouter = router({
auth: authRouter, auth: authRouter,
banner: bannerRouter,
crypto: cryptoRouter, crypto: cryptoRouter,
profile: profileRouter, profile: profileRouter,
document: documentRouter, document: documentRouter,

View File

@ -64,6 +64,7 @@
"luxon": "^3.4.2", "luxon": "^3.4.2",
"next": "14.0.3", "next": "14.0.3",
"pdfjs-dist": "3.6.172", "pdfjs-dist": "3.6.172",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.7.1", "react-day-picker": "^8.7.1",
"react-hook-form": "^7.45.4", "react-hook-form": "^7.45.4",
"react-pdf": "7.3.3", "react-pdf": "7.3.3",

View File

@ -0,0 +1,82 @@
import type { HTMLAttributes } from 'react';
import React, { useState } from 'react';
import { HexColorInput, HexColorPicker } from 'react-colorful';
import { cn } from '../lib/utils';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
export type ColorPickerProps = {
disabled?: boolean;
value: string;
defaultValue?: string;
onChange: (color: string) => void;
} & HTMLAttributes<HTMLDivElement>;
export const ColorPicker = ({
className,
disabled = false,
value,
defaultValue = '#000000',
onChange,
...props
}: ColorPickerProps) => {
const [color, setColor] = useState(value || defaultValue);
const [inputColor, setInputColor] = useState(value || defaultValue);
const onColorChange = (newColor: string) => {
setColor(newColor);
setInputColor(newColor);
onChange(newColor);
};
const onInputChange = (newColor: string) => {
setInputColor(newColor);
};
const onInputBlur = () => {
setColor(inputColor);
onChange(inputColor);
};
return (
<Popover>
<PopoverTrigger>
<button
type="button"
disabled={disabled}
className="bg-background h-12 w-12 rounded-md border p-1 disabled:pointer-events-none disabled:opacity-50"
>
<div className="h-full w-full rounded-sm" style={{ backgroundColor: color }} />
</button>
</PopoverTrigger>
<PopoverContent className="w-auto">
<HexColorPicker
className={cn(
className,
'w-full aria-disabled:pointer-events-none aria-disabled:opacity-50',
)}
color={color}
onChange={onColorChange}
aria-disabled={disabled}
{...props}
/>
<HexColorInput
className="mt-4 h-10 rounded-md border bg-transparent px-3 py-2 text-sm disabled:pointer-events-none disabled:opacity-50"
color={inputColor}
onChange={onInputChange}
onBlur={onInputBlur}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
onInputBlur();
}
}}
disabled={disabled}
/>
</PopoverContent>
</Popover>
);
};