mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
feat: migrate to site-settings
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,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 { 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 { Button } from '@documenso/ui/primitives/button';
|
||||
@ -87,9 +87,9 @@ export const AdminNav = ({ className, ...props }: AdminNavProps) => {
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/admin/banner">
|
||||
<BadgeAlert className="mr-2 h-5 w-5" />
|
||||
Banner
|
||||
<Link href="/admin/site-settings">
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
Site Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</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 () => {
|
||||
const banner = await getBanner();
|
||||
const banner = await getSiteSettings().then((settings) =>
|
||||
settings.find((setting) => setting.id === SITE_SETTINGS_BANNER_ID),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{banner && banner.show && (
|
||||
<div className="bg-documenso-200 dark:bg-documenso-400">
|
||||
<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 className="flex items-center">{banner.text}</div>
|
||||
{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>
|
||||
)}
|
||||
|
||||
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,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[]
|
||||
Template Template[]
|
||||
securityAuditLogs UserSecurityAuditLog[]
|
||||
Banner Banner[]
|
||||
siteSettings SiteSettings[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
@ -452,10 +452,11 @@ model Template {
|
||||
@@unique([templateDocumentDataId])
|
||||
}
|
||||
|
||||
model Banner {
|
||||
id Int @id @default(autoincrement())
|
||||
text String
|
||||
show Boolean @default(false)
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
userId Int?
|
||||
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>;
|
||||
|
||||
@ -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 { authRouter } from './auth-router/router';
|
||||
import { bannerRouter } from './banner-router/router';
|
||||
import { cryptoRouter } from './crypto/router';
|
||||
import { documentRouter } from './document-router/router';
|
||||
import { fieldRouter } from './field-router/router';
|
||||
@ -15,7 +14,6 @@ import { twoFactorAuthenticationRouter } from './two-factor-authentication-route
|
||||
|
||||
export const appRouter = router({
|
||||
auth: authRouter,
|
||||
banner: bannerRouter,
|
||||
crypto: cryptoRouter,
|
||||
profile: profileRouter,
|
||||
document: documentRouter,
|
||||
|
||||
@ -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