feat: custom banner (#956)
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"remeda": "^1.27.1",
|
||||
"sharp": "0.33.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { HTMLAttributes } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { BarChart3, FileStack, User2, Wallet2 } from 'lucide-react';
|
||||
import { BarChart3, FileStack, Settings, User2, Wallet2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -78,6 +78,20 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
Subscriptions
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'justify-start md:w-full',
|
||||
pathname?.startsWith('/admin/banner') && 'bg-secondary',
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/site-settings">
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
Site Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
200
apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
Normal file
200
apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'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 { 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>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
Normal file
24
apps/web/src/app/(dashboard)/admin/site-settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { NEXT_AUTH_OPTIONS } from '@documenso/lib/next-auth/auth-options';
|
||||
import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session';
|
||||
import { getTeams } from '@documenso/lib/server-only/team/get-teams';
|
||||
|
||||
import { Banner } from '~/components/(dashboard)/layout/banner';
|
||||
import { Header } from '~/components/(dashboard)/layout/header';
|
||||
import { VerifyEmailBanner } from '~/components/(dashboard)/layout/verify-email-banner';
|
||||
import { RefreshOnFocus } from '~/components/(dashboard)/refresh-on-focus/refresh-on-focus';
|
||||
@@ -37,6 +38,8 @@ export default async function AuthenticatedDashboardLayout({
|
||||
<LimitsProvider>
|
||||
{!user.emailVerified && <VerifyEmailBanner email={user.email} />}
|
||||
|
||||
<Banner />
|
||||
|
||||
<Header user={user} teams={teams} />
|
||||
|
||||
<main className="mt-8 pb-8 md:mt-12 md:pb-12">{children}</main>
|
||||
|
||||
29
apps/web/src/components/(dashboard)/layout/banner.tsx
Normal file
29
apps/web/src/components/(dashboard)/layout/banner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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 () => {
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{banner && banner.enabled && (
|
||||
<div className="mb-2" style={{ background: banner.data.bgColor }}>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Banner
|
||||
// Custom Text
|
||||
// Custom Text with Custom Icon
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -158,6 +158,7 @@
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-rnd": "^10.4.1",
|
||||
"remeda": "^1.27.1",
|
||||
"sharp": "0.33.1",
|
||||
"ts-pattern": "^5.0.5",
|
||||
"typescript": "5.2.2",
|
||||
@@ -15749,6 +15750,15 @@
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.1.0.tgz",
|
||||
@@ -19817,6 +19827,7 @@
|
||||
"luxon": "^3.4.2",
|
||||
"next": "14.0.3",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "7.3.3",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Role } from '@documenso/prisma/client';
|
||||
import type { Role } from '@documenso/prisma/client';
|
||||
|
||||
export type UpdateUserOptions = {
|
||||
id: number;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
12
packages/lib/server-only/site-settings/schema.ts
Normal file
12
packages/lib/server-only/site-settings/schema.ts
Normal 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>;
|
||||
9
packages/lib/server-only/site-settings/schemas/_base.ts
Normal file
9
packages/lib/server-only/site-settings/schemas/_base.ts
Normal 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>;
|
||||
23
packages/lib/server-only/site-settings/schemas/banner.ts
Normal file
23
packages/lib/server-only/site-settings/schemas/banner.ts
Normal 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>;
|
||||
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
lastModifiedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
enabled,
|
||||
data,
|
||||
lastModifiedByUserId: userId,
|
||||
lastModifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Banner" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"customHTML" TEXT NOT NULL,
|
||||
"userId" INTEGER,
|
||||
|
||||
CONSTRAINT "Banner_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Banner" ADD CONSTRAINT "Banner_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Banner" ADD COLUMN "show" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `customHTML` on the `Banner` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Banner" DROP COLUMN "customHTML";
|
||||
@@ -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;
|
||||
@@ -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'
|
||||
)
|
||||
);
|
||||
@@ -47,6 +47,7 @@ model User {
|
||||
VerificationToken VerificationToken[]
|
||||
Template Template[]
|
||||
securityAuditLogs UserSecurityAuditLog[]
|
||||
siteSettings SiteSettings[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@@ -210,15 +211,15 @@ model DocumentData {
|
||||
}
|
||||
|
||||
model DocumentMeta {
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
redirectUrl String?
|
||||
id String @id @default(cuid())
|
||||
subject String?
|
||||
message String?
|
||||
timezone String? @default("Etc/UTC") @db.Text
|
||||
password String?
|
||||
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
|
||||
documentId Int @unique
|
||||
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
redirectUrl String?
|
||||
}
|
||||
|
||||
enum ReadStatus {
|
||||
@@ -450,3 +451,12 @@ model Template {
|
||||
|
||||
@@unique([templateDocumentDataId])
|
||||
}
|
||||
|
||||
model SiteSettings {
|
||||
id String @id
|
||||
enabled Boolean @default(false)
|
||||
data Json
|
||||
lastModifiedByUserId Int?
|
||||
lastModifiedAt DateTime @default(now())
|
||||
lastModifiedByUser User? @relation(fields: [lastModifiedByUserId], references: [id])
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
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 { ZUpdateProfileMutationByAdminSchema } from './schema';
|
||||
import { ZUpdateProfileMutationByAdminSchema, ZUpdateSiteSettingMutationSchema } from './schema';
|
||||
|
||||
export const adminRouter = router({
|
||||
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.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Role } from '@prisma/client';
|
||||
import z from 'zod';
|
||||
|
||||
import { ZSiteSettingSchema } from '@documenso/lib/server-only/site-settings/schema';
|
||||
|
||||
export const ZUpdateProfileMutationByAdminSchema = z.object({
|
||||
id: z.number().min(1),
|
||||
name: z.string().nullish(),
|
||||
@@ -11,3 +13,7 @@ export const ZUpdateProfileMutationByAdminSchema = z.object({
|
||||
export type TUpdateProfileMutationByAdminSchema = z.infer<
|
||||
typeof ZUpdateProfileMutationByAdminSchema
|
||||
>;
|
||||
|
||||
export const ZUpdateSiteSettingMutationSchema = ZSiteSettingSchema;
|
||||
|
||||
export type TUpdateSiteSettingMutationSchema = z.infer<typeof ZUpdateSiteSettingMutationSchema>;
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"luxon": "^3.4.2",
|
||||
"next": "14.0.3",
|
||||
"pdfjs-dist": "3.6.172",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-day-picker": "^8.7.1",
|
||||
"react-hook-form": "^7.45.4",
|
||||
"react-pdf": "7.3.3",
|
||||
|
||||
82
packages/ui/primitives/color-picker.tsx
Normal file
82
packages/ui/primitives/color-picker.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user