mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 08:13:56 +10:00
feat: migrate to site-settings
This commit is contained in:
@ -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",
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
|||||||
202
apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
Normal file
202
apps/web/src/app/(dashboard)/admin/site-settings/banner-form.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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
11
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
'use server';
|
|
||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
|
||||||
|
|
||||||
export const getBanner = async () => {
|
|
||||||
return await prisma.banner.findUnique({
|
|
||||||
where: {
|
|
||||||
id: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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 ?? '' },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@ -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,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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -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,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])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
@ -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>;
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
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